Merge remote-tracking branch 'origin/master' into codex/superuser-user-overview-hub

# Conflicts:
#	.github/workflows/tests.yml
This commit is contained in:
Jeppe Bundgaard
2026-07-06 18:38:40 +02:00
65 changed files with 1375 additions and 218 deletions
+4 -1
View File
@@ -102,7 +102,7 @@ jobs:
needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 4
@@ -149,11 +149,14 @@ jobs:
zero_sha="0000000000000000000000000000000000000000"
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
base_ref="$PR_BASE_SHA"
head_ref="$PR_HEAD_SHA"
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
base_ref="origin/$DEFAULT_BRANCH"
head_ref="$HEAD_SHA"
else
base_ref="$PUSH_BEFORE_SHA"
head_ref="$HEAD_SHA"
fi
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_HEAD_SHA" ]]; then
head_ref="$PR_HEAD_SHA"
+12
View File
@@ -2415,6 +2415,12 @@ paths:
type: string
description: Contact person name
example: "Mikkel"
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
g_recaptcha_response:
type: string
description: reCAPTCHA verification token
@@ -8189,6 +8195,12 @@ paths:
email: {type: string}
phone: {type: integer}
name: {type: string}
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
responses:
'200':
description: Success
+14 -2
View File
@@ -95,8 +95,20 @@ export const sourceMappings = [
},
{
name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
patterns: [
/\/pos[/-]/iu,
/POS/iu,
/^src\/assets\/pos\.css$/u,
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
/^src\/features\/customer\/customerProductRules\.js$/u,
/^src\/services\/economicCustomerIdentifiers\.js$/u,
],
specs: [
"tests/e2e/pos-flow.spec.js",
"tests/e2e/pos-mobile-order-flow.spec.js",
"tests/e2e/admin-pos-orders.spec.ts",
"tests/e2e/pos-customer-rules.spec.js",
],
projects: chromiumProjects,
},
{
+11 -45
View File
@@ -6,6 +6,7 @@ import Swal from "sweetalert2";
import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import { useI18n } from 'vue-i18n';
import { isProductRestrictedForCustomer } from "@/features/customer/customerProductRules.js";
const { t } = useI18n();
const expandIcon = ref(null);
@@ -83,23 +84,6 @@ const hasRestrictAdditionalServices = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
};
const hasRestrictSpotFree = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
};
const hasRestrictInteriorCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
};
const hasRestrictTankCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
};
const hasOnlyTankCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
};
/**
* On before add product addon
* This is used to check if adding a product has any requirements.
@@ -162,37 +146,19 @@ const doesAddonHaveNoteRequirement = (addon) => {
};
const isAddonRestricted = (addon) => {
const addonName = getAddonName(addon).toLowerCase();
// Check if the addon has "Spot Free" in the name
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
return true;
}
// Check if the addon has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
return true;
}
const addonProduct = getAddonProduct(addon);
return isProductRestrictedForCustomer({
...addonProduct,
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value);
};
const isProductRestricted = () => {
const productName = String(props.name || '').toLowerCase();
// Check if the product has "Spot Free" in the name
if (hasRestrictSpotFree() && productName.includes('spot free')) {
return true;
}
// Check if the product has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
return true;
}
// Check if the product category is 5 (Tank Cleaning)
if (props.product?.category === 5) {
if (hasRestrictTankCleaning()) {
return true;
}
if (!hasOnlyTankCleaning()) {
return true;
}
}
return isProductRestrictedForCustomer({
...props.product,
name: props.name || props.product?.name,
}, customerAttributesSafe.value);
};
const orderByOrderPriority = (addons) => {
@@ -24,8 +24,24 @@ const onCustomerChange = () => {
isCustomerSelected.value = false;
}
const parseValue = (value) => {
let tmp_value = parseFloat(value).toFixed(2);
const parseFixedPriceValue = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
}
const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2);
const parseValue = (discount) => {
const fixedPrice = parseFixedPriceValue(discount.fixed_price);
if (fixedPrice !== null) {
return `${formatNumber(fixedPrice)} Kr.`;
}
let tmp_value = parseFloat(discount.percentage).toFixed(2);
// Add the percentage sign
return `${tmp_value}%`;
}
@@ -78,7 +94,7 @@ watch(customer_id, onCustomerChange, { immediate: true });
<!--<span class="icon is-small mr-1">
<i class="fas fa-percent" aria-hidden="true"></i>
</span> -->
<span>{{ parseValue(discount.percentage) }}</span>
<span>{{ parseValue(discount) }}</span>
</div>
</div>
</div>
@@ -86,4 +102,4 @@ watch(customer_id, onCustomerChange, { immediate: true });
<style scoped>
</style>
</style>
@@ -29,6 +29,8 @@ const discount_product = ref(null);
const discount_category = ref(null);
// The global discount (if any)
const discount_global = ref(null);
// The fixed product price (if any)
const fixed_product_price = ref(null);
// Show the discounts dropdown
const showDiscountsDropdown = ref(false);
@@ -49,6 +51,19 @@ const hasGlobalDiscount = () => {
return discount_global.value > 0;
}
const hasFixedProductPrice = () => {
return fixed_product_price.value !== null;
}
const parseFixedPriceValue = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
}
// Set the product discount
const setProductDiscount = (percentage) => {
// Check if the percentage is a number
@@ -83,6 +98,16 @@ const setGlobalDiscount = (percentage) => {
discount_global.value = percentage;
}
const setFixedProductPrice = (fixedPrice) => {
const parsedFixedPrice = parseFixedPriceValue(fixedPrice);
if (parsedFixedPrice === null) {
fixed_product_price.value = null;
return;
}
fixed_product_price.value = parsedFixedPrice;
}
// Debugging function
const getDiscountDebug = () => {
@@ -101,9 +126,15 @@ const getDiscountDebug = () => {
// Parse the customer discounts
const parseCustomerDiscounts = () => {
const tmp_discount_product = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.id && !discount.is_category)
const tmp_discount_category = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.category && discount.is_category);
const tmp_discount_global = props.customer_discounts.find((discount) => discount.id === 999999 && discount.is_category);
discount_product.value = null;
discount_category.value = null;
discount_global.value = null;
fixed_product_price.value = null;
const customerDiscounts = Array.isArray(props.customer_discounts) ? props.customer_discounts : [];
const tmp_discount_product = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.id && Number(discount.is_category) === 0)
const tmp_discount_category = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.category && Number(discount.is_category) === 1);
const tmp_discount_global = customerDiscounts.find((discount) => discount.id === 999999 && Number(discount.is_category) === 1);
//console.log("Product discount: ", tmp_discount_product);
//console.log("Category discount: ", tmp_discount_category);
@@ -112,6 +143,7 @@ const parseCustomerDiscounts = () => {
// Set the product discount
if (tmp_discount_product) {
setProductDiscount(tmp_discount_product.percentage);
setFixedProductPrice(tmp_discount_product.fixed_price);
}
// Set the category discount
if (tmp_discount_category) {
@@ -125,6 +157,10 @@ const parseCustomerDiscounts = () => {
// Get the best discount for the customer
const getBestDiscount = () => {
if (hasFixedProductPrice()) {
highestEligibleDiscount.value = 0;
return 0;
}
// Set the best discount to 0
let tmp_best_discount = 0;
// Check if the product discount is higher than the current best discount
@@ -182,13 +218,32 @@ watch(() => props.customer_discounts, () => {
<span
aria-haspopup="true"
aria-controls="dropdown-menu"
v-if="highestEligibleDiscount > 0"
v-if="hasFixedProductPrice()"
class="tag is-success is-light is-text text-can-not-select"
>{{ fixed_product_price }} Kr.</span>
<span
aria-haspopup="true"
aria-controls="dropdown-menu"
v-else-if="highestEligibleDiscount > 0"
class="tag is-warning is-light is-text text-can-not-select"
> -{{ highestEligibleDiscount }}%</span>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content py-0">
<div class="list has-overflow-ellipsis" style="width: 340px">
<template v-if="hasFixedProductPrice()">
<a class="list-item">
<div class="list-item-content">
<div class="list-item-title">Fast pris</div>
</div>
<div class="list-item-controls list-item-controls-force-visible">
<div class="tags has-addons">
<span class="tag is-success is-light">{{ fixed_product_price }} Kr.</span>
</div>
</div>
</a>
</template>
<template v-else>
<!-- Best discount -->
<a class="list-item">
<div class="list-item-content">
@@ -239,6 +294,7 @@ watch(() => props.customer_discounts, () => {
</div>
</div>
</a>
</template>
</div>
</div>
</div>
@@ -254,4 +310,4 @@ watch(() => props.customer_discounts, () => {
.text-can-not-select {
user-select: none;
}
</style>
</style>
@@ -62,6 +62,7 @@ watch(
form.invoiceEmail,
form.contactEmail,
form.contactPhone,
form.ean,
canCreateCustomer.value,
searchResult.value,
errorMessage.value,
@@ -124,6 +125,24 @@ watch(
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.ean") }}</small></label
>
<div class="control">
<input
v-model="form.ean"
class="input"
type="text"
inputmode="numeric"
maxlength="13"
:placeholder="$t('admin.pos.ean')"
:disabled="searchResult === null || submitting"
data-testid="pos-mobile-add-customer-ean"
@input="handleAutofillFieldInput('ean')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
@@ -3,6 +3,7 @@ export const POS_ADD_CUSTOMER_AUTOFILL_FIELDS = [
"invoiceEmail",
"contactEmail",
"contactPhone",
"ean",
];
export function normalizePosAddCustomerValue(value) {
@@ -20,6 +21,7 @@ export function createPosAddCustomerFields(overrides = {}) {
invoiceEmail: "",
contactEmail: "",
contactPhone: "",
ean: "",
...overrides,
};
}
@@ -30,6 +32,7 @@ export function createPosAddCustomerManualOverrides(overrides = {}) {
invoiceEmail: false,
contactEmail: false,
contactPhone: false,
ean: false,
...overrides,
};
}
@@ -93,6 +96,7 @@ export function buildPosAddCustomerAutofillValues(searchResult) {
invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
contactEmail: normalizePosAddCustomerValue(searchResult?.email),
contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
ean: normalizePosAddCustomerValue(searchResult?.ean),
};
}
@@ -22,7 +22,7 @@ const props = defineProps({
import {computed, ref, watch} from 'vue';
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {orderBy, orderDirection, setOrder, loadList} from "@/components/pagination/paginatedList.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import Swal from "sweetalert2";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
@@ -38,6 +38,8 @@ import {
setCachedXlvaskUsageAmount
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
const redirectDepartmentOrderPage = (orderId, departmentId) => {
// Send the user to the order page (In a new tab)
// `/admin/${departmentId}/modules/pos/orders/${orderId}`
@@ -0,0 +1 @@
export const SELFWASH_PERIOD_ALL_LIMIT = 10000;
@@ -21,9 +21,32 @@ const props = defineProps({
type: Number, default: 0
}
})
import {computed, ref, watch } from "vue";
import {computed, provide, ref, watch } from "vue";
import { useRouter } from "vue-router";
import {
PaginatedListKey,
usePaginatedList,
} from "@/components/pagination/paginatedList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
@@ -38,23 +61,8 @@ import {
setOrder,
hideSearchField,
setHideSearchField,
} from "@/components/pagination/paginatedList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
} = paginatedList;
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const SELFWASH_PERIOD_ALL_LIMIT = 10000;
setEndpoint("/modules/xlvask/services/usage/orders", false);
if (props.loadAllAtOnce) {
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
@@ -4,11 +4,13 @@ import axios from "axios";
import { API_URL } from "@/config.js";
import { getError, parseError, removeError } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { economicEanPayloadValue, normalizeEconomicEan } from "@/services/economicCustomerIdentifiers.js";
const customerFormData = reactive({
cvr: null,
contactEmail: null,
contactPhone: null
contactPhone: null,
ean: null
});
const deliverSuccess = ref(false);
@@ -17,17 +19,24 @@ let deliverTimeoutId;
const submitCustomer = async () => {
removeError("authRegisterCvr");
try {
await axios.post(API_URL + "/auth/register/cvr", {
const payload = {
cvr: customerFormData.cvr,
invoiceEmail: customerFormData.contactEmail,
contactEmail: customerFormData.contactEmail,
contactPhone: customerFormData.contactPhone,
companyPhone: customerFormData.contactPhone
});
};
const ean = economicEanPayloadValue(customerFormData.ean);
if (ean !== undefined) {
payload.ean = ean;
}
await axios.post(API_URL + "/auth/register/cvr", payload);
customerFormData.contactPhone = null;
customerFormData.contactEmail = null;
customerFormData.cvr = null;
customerFormData.ean = null;
deliverSuccess.value = true;
deliverTimeoutId = setTimeout(() => {
@@ -59,6 +68,20 @@ onBeforeUnmount(() => {
>
</div>
<div class="form-field">
<label for="customer_ean">{{ $t("customer_creation.customer.ean_label") }}</label>
<input
id="customer_ean"
:value="customerFormData.ean"
type="text"
inputmode="numeric"
maxlength="13"
autocomplete="off"
placeholder="5790001234567"
@input="customerFormData.ean = normalizeEconomicEan($event.target.value)"
>
</div>
<div class="form-field">
<label for="email">{{ $t("customer_creation.customer.email_label") }}</label>
<input id="email" v-model="customerFormData.contactEmail" type="email" name="email" autocomplete="email">
@@ -5,15 +5,17 @@ import {
getProductsCategory,
loadOrderItems,
productCategoryAllowed,
isProductRestricted,
setProductsCategory,
getUserProductDiscount,
getUserProductPrice,
showFakeCreateOrderItem,
department_id,
user_discounts,
getUserGlobalDiscount,
getUserDiscounts,
customer_id,
customer_attributes
customer_attributes,
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
import {createOrderItem} from "@/components/shop/OrdersItems.vue";
@@ -151,6 +153,10 @@ const fetchProductsForCategory = async ({
products.value = [];
try {
if (customerNumber) {
await loadCustomerAttributes(customerNumber);
}
const response = normalizedCategory === null
? await getProducts(departmentId, props.finalPrice, customerNumber)
: await getProductCategory(normalizedCategory, departmentId, props.finalPrice, customerNumber);
@@ -549,10 +555,9 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
continue;
}
const previousOrderProduct = getPreviousOrderProduct(orderItem);
const discount = getUserProductDiscount(previousOrderProduct || {});
const previousOrderProduct = getPreviousOrderProduct(orderItem) || { id: productId, price: getPreviousOrderProductPrice(orderItem) };
const basePrice = getPreviousOrderProductPrice(orderItem);
const discountedPrice = (basePrice - (basePrice * (discount / 100))).toFixed(0);
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
showFakeCreateOrderItem(productId, quantity, discountedPrice);
await createOrderItem(orderId, productId, quantity);
@@ -586,14 +591,12 @@ const getRecommendedProductPrice = (productId) => {
return '0';
}
const discount = getUserProductDiscount(product);
const price = Number(product.price ?? 0);
return (price - (price * (discount / 100))).toFixed(0);
return String(getUserProductPrice(product));
};
const isRecommendedProductCategoryAllowed = (productId) => {
const product = getProductById(productId);
return !!product && productCategoryAllowed(product.category);
return !!product && !isProductRestricted(product);
};
const addRecommendedProductToOrder = async (productId) => {
@@ -405,6 +405,24 @@ watch(activeQuickAction, (nextMode, previousMode) => {
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.ean") }}</small></label
>
<div class="control">
<input
v-model="addCustomerForm.ean"
class="input"
type="text"
inputmode="numeric"
maxlength="13"
:placeholder="$t('admin.pos.ean')"
:disabled="addCustomerSearchResult === null || isAddCustomerSubmitting"
data-testid="pos-desktop-add-customer-ean"
@input="handleAddCustomerFieldInput('ean')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
+59 -54
View File
@@ -10,6 +10,10 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
import {
isProductCategoryRestrictedForCustomer,
isProductRestrictedForCustomer,
} from "@/features/customer/customerProductRules.js";
import Swal from "sweetalert2";
/**
@@ -153,6 +157,8 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
clearErrors();
// If the current step is 1, create the order
if (step.value === 1) {
await loadCustomerAttributes(customer_id.value);
if (normalizedOptions.orderCreation === false) {
step.value++;
return;
@@ -1613,24 +1619,7 @@ export const customerUsesPONumbers = () => {
/** Check if the product category is allowed */
export const productCategoryAllowed = (category) => {
// Check if the product category is the addons category
if (category === "addons") {
// Check if the customer has the attribute restrictAdditionalServices
return !hasAttribute("restrictAdditionalServices");
}
if (category === "spot_free") {
// Check if the customer has the attribute restrictSpotFree
return !hasAttribute("restrictSpotFree");
}
if (category === "interior_cleaning") {
// Check if the customer has the attribute restrictInteriorCleaning
return !hasAttribute("restrictInteriorCleaning");
}
if (category === "tank_cleaning") {
// Check if the customer has the attribute restrictTankCleaning
return !hasAttribute("restrictTankCleaning");
}
return true;
return !isProductCategoryRestrictedForCustomer(category, customer_attributes.value);
};
/** Check if the customer should be billed per order, or per billing period */
@@ -1676,43 +1665,12 @@ export const hasOnlyTankCleaning = () => {
/** Check if a product is restricted based on customer attributes */
export const isProductRestricted = (product) => {
if (!product) return false;
const productName = (product.name || "").toLowerCase();
// Check if the product has "Spot Free" in the name
if (!canBuySpotFree() && productName.includes("spot free")) {
return true;
}
// Check if the product has "Indvendig vask" in the name
if (!canBuyInteriorCleaning() && productName.includes("indvendig vask")) {
return true;
}
// Check if the product category is 5 (Tank Cleaning)
if (product.category === 5) {
if (!canBuyTankCleaning()) {
return true;
}
if (!hasOnlyTankCleaning()) {
return true;
}
}
return false;
return isProductRestrictedForCustomer(product, customer_attributes.value);
};
/** Check if an addon is restricted based on customer attributes */
export const isAddonRestricted = (addon) => {
if (!addon) return false;
const addonName = (addon.name || "").toLowerCase();
// Check if the addon has "Spot Free" in the name
if (!canBuySpotFree() && addonName.includes("spot free")) {
return true;
}
// Check if the addon has "Indvendig vask" in the name
if (!canBuyInteriorCleaning() && addonName.includes("indvendig vask")) {
return true;
}
return false;
return isProductRestrictedForCustomer(addon, customer_attributes.value);
};
export function loadOrderDetails(onAfterSuccess = null, options = {}) {
@@ -1844,13 +1802,30 @@ export const getUserDiscounts = async () => {
});
};
/** Get user product discount */
export const getUserProductDiscount = (product, allowCategory = null, onlyCategory = null) => {
// Check if the user discounts are loaded
const ensureUserDiscountsLoaded = () => {
if (!has_user_discounts_loaded.value) {
has_user_discounts_loaded.value = true;
getUserDiscounts().then(() => {});
}
};
const isDirectCustomerProductDiscount = (discount, productId) => {
return discount?.product_or_category_id == productId && Number(discount?.is_category) === 0;
};
const parseFixedPriceValue = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
};
/** Get user product discount */
export const getUserProductDiscount = (product, allowCategory = null, onlyCategory = null) => {
// Check if the user discounts are loaded
ensureUserDiscountsLoaded();
// Get the product's id
const productId = product.id;
@@ -1948,6 +1923,36 @@ export const getUserProductDiscount = (product, allowCategory = null, onlyCatego
return appliedDiscount;
};
export const getUserProductFixedPrice = (product) => {
ensureUserDiscountsLoaded();
const productId = product?.id;
if (productId === null || productId === undefined) {
return null;
}
const fixedPriceDiscount = (Array.isArray(user_discounts.value) ? user_discounts.value : []).find((discount) => (
isDirectCustomerProductDiscount(discount, productId) && parseFixedPriceValue(discount.fixed_price) !== null
));
return fixedPriceDiscount ? parseFixedPriceValue(fixedPriceDiscount.fixed_price) : null;
};
export const getUserProductPrice = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
if (fixedPrice !== null) {
return fixedPrice;
}
const price = Number(product?.price ?? 0);
const discount = Number(getUserProductDiscount(product) || 0);
if (!Number.isFinite(price)) {
return 0;
}
return Number((price - (price * (discount / 100))).toFixed(0));
};
export const showFakeCreateOrderItem = (product, quantity, price) => {
order_items.value.push({
id: Math.floor(Math.random() * 1000),
@@ -189,7 +189,7 @@ const descriptionKeyOverrides: Partial<Record<EntityType, string[]>> = {
xlvask_customers: ['email', 'city', 'address', 'vatnumber'],
motorapi_lookups: ['endpoint', 'license_plate', 'result'],
fxratesapi_conversion_rates: ['base', 'target', 'endpoint', 'result'],
customer_discounts: ['customer_number', 'percentage'],
customer_discounts: ['customer_number', 'fixed_price', 'percentage'],
module_config: ['description', 'value'],
department_goals: ['criteria.type', 'criteria.progress_alert_frequency', 'criteria.progress_alert_destination']
};
@@ -251,6 +251,7 @@ const keyFieldOverrides: Partial<Record<EntityType, FieldSelector[]>> = {
),
customer_discounts: fieldList(
field('Discount', 'discount', 'percentage'),
field('Fixed price', 'fixed_price'),
field('Target', 'product_or_category_id', 'object_id'),
field('Category', 'is_category'),
field('Customer #', 'customer_number'),
+9 -2
View File
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import { economicEanPayloadValue } from "@/services/economicCustomerIdentifiers.js";
import {
applyPosAddCustomerSearchResult,
createPosAddCustomerFields,
@@ -152,14 +153,20 @@ export function usePosAddCustomerForm(options = {}) {
errorMessage.value = null;
try {
await SessionUser.request("/auth/register/cvr", "POST", {
const payload = {
cvr: form.cvr,
companyPhone: Number.parseInt(form.companyPhone, 10),
invoiceEmail: form.invoiceEmail,
contactEmail: form.contactEmail,
contactPhone: Number.parseInt(form.contactPhone, 10),
searchResult: searchResult.value,
});
};
const ean = economicEanPayloadValue(form.ean);
if (ean !== undefined) {
payload.ean = ean;
}
await SessionUser.request("/auth/register/cvr", "POST", payload);
const selectedCustomer = await searchAndSelectCustomer(form.companyPhone);
const createdCustomerNumber = Number.parseInt(form.companyPhone, 10);
@@ -0,0 +1,95 @@
const TANK_CLEANING_TERMS = ["tank cleaning", "tankcleaning", "tankrens"];
const normalizeAttributeName = (attribute) => {
if (typeof attribute === "string") {
return attribute;
}
return String(attribute?.attribute ?? "");
};
export const hasCustomerAttribute = (attributes, attributeName) => {
if (!Array.isArray(attributes)) {
return false;
}
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 isTankCleaningProduct = (product) => {
if (!product) {
return false;
}
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
);
};
export const isProductCategoryRestrictedForCustomer = (category, attributes = []) => {
if (category === "addons" && hasCustomerAttribute(attributes, "restrictAdditionalServices")) {
return true;
}
if (category === "spot_free" && hasCustomerAttribute(attributes, "restrictSpotFree")) {
return true;
}
if (category === "interior_cleaning" && hasCustomerAttribute(attributes, "restrictInteriorCleaning")) {
return true;
}
if (isTankCleaningCategory(category) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
return true;
}
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningCategory(category)) {
return true;
}
return false;
};
export const isProductRestrictedForCustomer = (product, attributes = []) => {
if (!product) {
return false;
}
const productName = normalizedText(product.name ?? product.product_name);
if (hasCustomerAttribute(attributes, "restrictSpotFree") && productName.includes("spot free")) {
return true;
}
if (hasCustomerAttribute(attributes, "restrictInteriorCleaning") && productName.includes("indvendig vask")) {
return true;
}
if (isTankCleaningProduct(product) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
return true;
}
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningProduct(product)) {
return true;
}
return false;
};
+20 -4
View File
@@ -1220,7 +1220,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1250,7 +1256,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1704,6 +1711,7 @@
"company_phone": "Virksomhedstelefon",
"contact_email": "@.capitalize:{'words.generated.kontakt'}-@:{'words.generated.e'}-@:{'words.generated.mail'}",
"contact_phone": "Kontakttelefon",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.oprettet'} @:{'words.generated.af'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.indtastede'} @:{'words.generated.nummerplade'} @:{'words.generated.tilhører'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.anden'} @:{'words.generated.gemt'} @:{'words.generated.kunde'}. @.capitalize:{'words.generated.vælg'} @:{'words.generated.om'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.kunde'} @:{'words.generated.skal'} @:{'words.generated.beholdes'}, @:{'words.generated.eller'} @:{'words.generated.om'} @:{'words.generated.der'} @:{'words.generated.skal'} @:{'words.generated.skiftes'} @:{'words.generated.til'} @:{'words.generated.køretøjets'} @:{'words.generated.kunde'}.",
@@ -3252,6 +3260,7 @@
"benefit_portal": "@:{'words.generated.kundeportal'}",
"benefit_portal_desc": "@:{'words.generated.benyt'} kundeportalen @:{'words.generated.til'} @:{'words.generated.at'} @:{'words.generated.tilføje'} @:{'words.generated.køretøjer'}, @:{'words.generated.booke'} @:{'words.generated.vaske'}, downloade @:{'words.generated.fakturaer'}, @:{'words.generated.vaskecertifikater'} @:{'words.generated.og'} @:{'words.generated.information'} @:{'words.generated.for'} @:{'words.generated.historiske'} @:{'words.generated.vaske'}",
"benefit_wash_desc": "@:{'words.generated.benyt'} @:{'words.generated.vores'} @:{'words.generated.nationale'} @:{'words.generated.netværk'} @:{'words.generated.af'} @:{'words.generated.vaskehaller'} @:{'words.generated.til'} @:{'words.generated.konkurrencedygtige'} @:{'words.generated.priser'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "@:{'words.generated.firma'} @:{'words.generated.email'}",
"intro": "@.capitalize:{'words.generated.som'} @:{'words.generated.kunde'} @:{'words.generated.hos'} @:{'words.generated.truck'} @:{'words.generated.wash'} @:{'words.generated.far'} @:{'words.generated.du'} @:{'words.generated.adgang'} @:{'words.generated.til'}:",
"phone_label": "@:{'words.generated.firma'} @:{'words.generated.telefon'}",
@@ -6108,7 +6117,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'words.generated.rediger'}",
"deactivate": "Deaktiver"
"deactivate": "Deaktiver",
"login_link": "QR-login",
"login_link_title": "Medarbejderloginlink",
"login_link_qr_alt": "QR-kode-loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Kopiér link",
"login_link_copied": "Linket er kopieret."
},
"roles": {
"viewer": {
@@ -6138,7 +6153,8 @@
"save_prices": "@.capitalize:{'words.generated.afdelingspriser'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.gemmes'}.",
"load_employees": "@.capitalize:{'words.generated.medarbejdere'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.indlæses'}.",
"save_employee": "@.capitalize:{'words.generated.medarbejderen'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.gemmes'}.",
"deactivate_employee": "@.capitalize:{'words.generated.medarbejderen'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktiveres."
"deactivate_employee": "@.capitalize:{'words.generated.medarbejderen'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktiveres.",
"generate_employee_login_link": "Medarbejderens loginlink kunne ikke oprettes."
},
"role_permissions": {
"title": "Rolletilladelser",
+20 -4
View File
@@ -1331,7 +1331,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1361,7 +1367,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1815,6 +1822,7 @@
"company_phone": "Firmentelefon",
"contact_email": "@:{'words.generated.kontakt'}-@.upper:{'words.generated.e'}-@:{'words.generated.mail'}",
"contact_phone": "Kontakttelefonnummer",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.erstellt'} @:{'words.generated.von'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.dieses'} @:{'words.generated.kennzeichen'} @:{'words.generated.gehoert'} @:{'words.generated.zu'} @:{'words.generated.einem'} @:{'words.generated.anderen'} @:{'words.generated.gespeicherten'} @:{'words.generated.kunden'}. @:{'words.generated.waehlen'} @.capitalize:{'words.generated.sie'}, @:{'words.generated.ob'} @:{'words.generated.der'} @:{'words.generated.ausgewaehlte'} @.capitalize:{'words.generated.kunde'} @:{'words.generated.beibehalten'} @:{'words.generated.oder'} @:{'words.generated.zum'} @:{'words.generated.fahrzeugkunden'} @:{'words.generated.gewechselt'} @:{'words.generated.werden'} @:{'words.generated.soll'}.",
@@ -3363,6 +3371,7 @@
"benefit_portal": "@:{'words.generated.kundenportal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.nutzen'} @.capitalize:{'words.generated.sie'} @:{'words.generated.das'} @:{'words.generated.kundenportal'}, @:{'words.generated.um'} @:{'words.generated.fahrzeuge'} hinzuzuf?@:{'words.generated.gen'}, @.upper:{'words.generated.w'}?@:{'words.generated.schen'} @:{'words.generated.zu'} @:{'words.generated.buchen'}, @:{'words.generated.rechnungen'} @:{'words.generated.und'} @:{'words.generated.waschzertifikate'} herunterzuladen @:{'words.generated.sowie'} @:{'words.generated.informationen'} @:{'words.generated.zu'} @:{'words.generated.fr'}?@:{'words.generated.heren'} @.upper:{'words.generated.w'}?@:{'words.generated.schen'} einzusehen",
"benefit_wash_desc": "@.capitalize:{'words.generated.nutzen'} @.capitalize:{'words.generated.sie'} @:{'words.generated.unser'} @:{'words.generated.nationales'} @:{'words.generated.netz'} @:{'words.generated.von'} @:{'words.generated.waschhallen'} @:{'words.generated.zu'} @:{'words.generated.wettbewerbsf'}?@:{'words.generated.higen'} @:{'words.generated.preisen'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "Firmen-@.upper:{'words.generated.e'}-@:{'words.generated.mail'}",
"intro": "@.capitalize:{'words.generated.als'} @.capitalize:{'words.generated.kunde'} @:{'words.generated.bei'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.erhalten'} @.capitalize:{'words.generated.sie'} @:{'words.generated.zugang'} @:{'words.generated.zu'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -6197,7 +6206,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@.capitalize:{'words.generated.bearbeiten'}",
"deactivate": "Deaktivieren"
"deactivate": "Deaktivieren",
"login_link": "QR-Login",
"login_link_title": "Mitarbeiter-Loginlink",
"login_link_qr_alt": "QR-Code-Loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Link kopieren",
"login_link_copied": "Link kopiert."
},
"roles": {
"viewer": {
@@ -6227,7 +6242,8 @@
"save_prices": "@.capitalize:{'words.generated.abteilungspreise'} @:{'words.generated.konnten'} @:{'words.generated.nicht'} @:{'words.generated.gespeichert'} @:{'words.generated.werden'}.",
"load_employees": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnten'} @:{'words.generated.nicht'} @:{'words.generated.geladen'} @:{'words.generated.werden'}.",
"save_employee": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnte'} @:{'words.generated.nicht'} @:{'words.generated.gespeichert'} @:{'words.generated.werden'}.",
"deactivate_employee": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnte'} @:{'words.generated.nicht'} @:{'words.generated.deaktiviert'} @:{'words.generated.werden'}."
"deactivate_employee": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnte'} @:{'words.generated.nicht'} @:{'words.generated.deaktiviert'} @:{'words.generated.werden'}.",
"generate_employee_login_link": "Mitarbeiter-Loginlink konnte nicht erstellt werden."
},
"role_permissions": {
"title": "Rollenberechtigungen",
+20 -4
View File
@@ -1052,7 +1052,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1082,7 +1088,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1536,6 +1543,7 @@
"company_phone": "@.capitalize:{'words.generated.company'} @:{'words.generated.phone'}",
"contact_email": "@.capitalize:{'words.generated.contact'} @:{'words.generated.email'}",
"contact_phone": "@.capitalize:{'words.generated.contact'} @:{'words.generated.phone'}",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.created'} @:{'words.generated.by'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.this'} @:{'words.generated.typed'} @:{'words.generated.plate'} @:{'words.generated.belongs'} @:{'words.generated.to'} @:{'words.generated.another'} @:{'words.generated.saved'} @:{'words.generated.customer'}. @.capitalize:{'words.generated.choose'} @:{'words.generated.whether'} @:{'words.generated.to'} @:{'words.generated.keep'} @:{'words.replication.article.host_mention'} @:{'words.generated.selected'} @:{'words.generated.customer'} @:{'words.generated.or'} @:{'words.generated.switch'} @:{'words.generated.to'} @:{'words.replication.article.host_mention'} @:{'words.generated.vehicle'} @:{'words.generated.customer'}.",
@@ -3084,6 +3092,7 @@
"benefit_portal": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.portal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.use'} @:{'words.replication.article.host_mention'} @:{'words.generated.customer'} @:{'words.generated.portal'} @:{'words.generated.to'} @:{'words.generated.add'} @:{'words.generated.vehicles'}, @:{'words.generated.book'} @:{'words.generated.washes'}, @:{'words.generated.download'} @:{'words.generated.invoices'}, @:{'words.generated.wash'} @:{'words.generated.certificates'} @:{'words.generated.and'} @:{'words.generated.information'} @:{'words.generated.for'} @:{'words.generated.historical'} @:{'words.generated.washes'}",
"benefit_wash_desc": "@.capitalize:{'words.generated.use'} @:{'words.generated.our'} @:{'words.generated.national'} @:{'words.generated.network'} @:{'words.generated.of'} @:{'words.generated.wash'} @:{'words.generated.halls'} @:{'words.generated.at'} @:{'words.generated.competitive'} @:{'words.generated.prices'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "@.capitalize:{'words.generated.company'} @:{'words.generated.email'}",
"intro": "@.capitalize:{'words.generated.as'} @:{'words.generated.a'} @:{'words.generated.customer'} @:{'words.generated.at'} @.capitalize:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.you'} @:{'words.generated.get'} @:{'words.generated.access'} @:{'words.generated.to'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -5940,7 +5949,13 @@
"active": "Active",
"inactive": "@.capitalize:{'words.generated.inactive'}",
"edit": "@.capitalize:{'words.generated.edit'}",
"deactivate": "@.capitalize:{'words.generated.deactivate'}"
"deactivate": "@.capitalize:{'words.generated.deactivate'}",
"login_link": "QR login",
"login_link_title": "Employee login link",
"login_link_qr_alt": "QR code login link",
"login_link_url": "Login link",
"copy_login_link": "Copy link",
"login_link_copied": "Link copied."
},
"roles": {
"viewer": {
@@ -5970,7 +5985,8 @@
"save_prices": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.save'} @:{'words.generated.department'} @:{'words.generated.prices'}.",
"load_employees": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.load'} @:{'words.generated.employees'}.",
"save_employee": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.save'} @:{'words.generated.employee'}.",
"deactivate_employee": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.deactivate'} @:{'words.generated.employee'}."
"deactivate_employee": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.deactivate'} @:{'words.generated.employee'}.",
"generate_employee_login_link": "Could not create employee login link."
},
"role_permissions": {
"title": "Role permissions",
+2
View File
@@ -385,6 +385,7 @@
"delete": "@:{'templates.generated.compat.global.delete'}",
"details": "@:{'templates.generated.compat.admin.pos.details'}",
"download": "@:{'templates.generated.compat.guest.pwa.title'}",
"ean": "@:{'templates.generated.compat.admin.pos.ean'}",
"drafts_assignment": {
"button": "@:{'templates.generated.compat.admin.pos.drafts_assignment.button'}",
"create_invoice_collection": "@:{'templates.generated.compat.admin.pos.drafts_assignment.create_invoice_collection'}",
@@ -2061,6 +2062,7 @@
"benefit_wash": "@:{'templates.generated.compat.tables.bookings.wash'}",
"benefit_wash_desc": "@:{'templates.generated.compat.tables.bookings.wash_desc'}",
"cvr_label": "@:{'templates.generated.compat.customers.cvr'}",
"ean_label": "@:{'templates.generated.compat.customer_creation.customer.ean_label'}",
"email_label": "@:{'templates.generated.compat.customer_creation.customer.email_label'}",
"intro": "@:{'templates.generated.compat.customer_creation.customer.intro'}",
"phone_label": "@:{'templates.generated.compat.customer_creation.customer.phone_label'}",
+20 -4
View File
@@ -1334,7 +1334,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1364,7 +1370,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1818,6 +1825,7 @@
"company_phone": "Firmatelefon",
"contact_email": "@.capitalize:{'words.generated.kontakt'} @:{'words.generated.e'}-@:{'words.generated.post'}",
"contact_phone": "@.capitalize:{'words.generated.kontakt'} @:{'words.generated.telefon'}",
"ean": "EAN",
"created_by": "Laget @:{'words.generated.av'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.det'} @:{'words.generated.innskrevne'} @:{'words.generated.skiltet'} @:{'words.generated.tilhoerer'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.annen'} @:{'words.generated.lagret'} @:{'words.generated.kunde'}. @.capitalize:{'words.generated.velg'} @:{'words.generated.om'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.kunden'} @:{'words.generated.skal'} @:{'words.generated.beholdes'} @:{'words.generated.eller'} @:{'words.generated.om'} @:{'words.generated.det'} @:{'words.generated.skal'} @:{'words.generated.byttes'} @:{'words.generated.til'} @:{'words.generated.kjoretoykunden'}.",
@@ -3366,6 +3374,7 @@
"benefit_portal": "@:{'words.generated.kundeportal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.bruk'} kundeportalen @:{'words.generated.til'} @:{'words.generated.a'} @:{'words.generated.legge'} @:{'words.generated.til'} @:{'words.generated.kjøretøy'}, bokvaske, @:{'words.generated.laste'} @:{'words.generated.ned'} @:{'words.generated.fakturaer'}, @:{'words.generated.vaskesertifikater'} @:{'words.generated.og'} @:{'words.generated.informasjon'} @:{'words.generated.for'} @:{'words.generated.historiske'} @:{'words.generated.vask'}.",
"benefit_wash_desc": "@.capitalize:{'words.generated.bruk'} @:{'words.generated.vart'} @:{'words.generated.nasjonale'} @:{'words.generated.nettverk'} @:{'words.generated.av'} @:{'words.generated.vaskehaller'} @:{'words.generated.til'} @:{'words.generated.konkurransedyktige'} @:{'words.generated.priser'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "Firmaets @:{'words.generated.e'}-@:{'words.generated.post'}",
"intro": "@.capitalize:{'words.generated.som'} @:{'words.generated.kunde'} @:{'words.generated.hos'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.far'} @:{'words.generated.du'} @:{'words.generated.tilgang'} @:{'words.generated.til'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -6200,7 +6209,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'words.generated.rediger'}",
"deactivate": "Deaktiver"
"deactivate": "Deaktiver",
"login_link": "QR-innlogging",
"login_link_title": "Innloggingslenke for medarbeider",
"login_link_qr_alt": "QR-kode for innloggingslenke",
"login_link_url": "Innloggingslenke",
"copy_login_link": "Kopier lenke",
"login_link_copied": "Lenken er kopiert."
},
"roles": {
"viewer": {
@@ -6230,7 +6245,8 @@
"save_prices": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.lagre'} @:{'words.generated.avdelingspriser'}.",
"load_employees": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.laste'} @:{'words.generated.medarbeidere'}.",
"save_employee": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.lagre'} @:{'words.generated.medarbeideren'}.",
"deactivate_employee": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktivere @:{'words.generated.medarbeideren'}."
"deactivate_employee": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktivere @:{'words.generated.medarbeideren'}.",
"generate_employee_login_link": "Kunne ikke opprette innloggingslenke for medarbeideren."
},
"role_permissions": {
"title": "Rolletillatelser",
+20 -4
View File
@@ -1384,7 +1384,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1414,7 +1420,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1868,6 +1875,7 @@
"company_phone": "Färetagstelefon",
"contact_email": "@.capitalize:{'words.generated.kontakt'}-@:{'words.generated.e'}-@:{'words.generated.post'}",
"contact_phone": "Kontakttelefon",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.skapad'} @:{'words.generated.av'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.det'} @:{'words.generated.angivna'} @:{'words.generated.registreringsnumret'} @:{'words.generated.tillhor'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.annan'} @:{'words.generated.sparad'} @:{'words.generated.kund'}. @:{'words.generated.valj_2'} @:{'words.generated.om'} @:{'words.replication.article.host_mention'} @:{'words.generated.valda'} @:{'words.generated.kunden'} @:{'words.generated.ska'} @:{'words.generated.behallas'} @:{'words.generated.eller'} @:{'words.generated.om'} @:{'words.generated.bytet'} @:{'words.generated.ska'} @:{'words.generated.goras'} @:{'words.generated.till'} @:{'words.generated.fordonskunden'}.",
@@ -3416,6 +3424,7 @@
"benefit_portal": "@:{'words.generated.kundportal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.anvand'} kundportalen @:{'words.generated.for'} @:{'words.generated.att'} @:{'words.generated.lagga'} @:{'words.generated.till'} @:{'words.generated.fordon'}, @:{'words.generated.boka'} @:{'words.generated.tvattar'}, @:{'words.generated.ladda'} @:{'words.generated.ner'} @:{'words.generated.fakturor'}, @:{'words.generated.tvattcertifikat'} @:{'words.generated.och'} @:{'words.generated.information'} @:{'words.generated.om'} @:{'words.generated.historiska'} @:{'words.generated.tvattar'}",
"benefit_wash_desc": "@.capitalize:{'words.generated.anvand'} @:{'words.generated.vart'} @:{'words.generated.nationella'} @:{'words.generated.natverk'} @:{'words.generated.av'} @:{'words.generated.tvatthallar'} @:{'words.generated.till'} @:{'words.generated.konkurrenskraftiga'} @:{'words.generated.priser'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "Företagets @:{'words.generated.e'}-@:{'words.generated.post'}",
"intro": "@.capitalize:{'words.generated.som'} @:{'words.generated.kund'} @:{'words.generated.hos'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.far'} @:{'words.generated.du'} @:{'words.generated.tillgang'} @:{'words.generated.till'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -6250,7 +6259,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'words.generated.redigera'}",
"deactivate": "@.capitalize:{'words.generated.inaktivera'}"
"deactivate": "@.capitalize:{'words.generated.inaktivera'}",
"login_link": "QR-inloggning",
"login_link_title": "Inloggningslänk för medarbetare",
"login_link_qr_alt": "QR-kod för inloggningslänk",
"login_link_url": "Inloggningslänk",
"copy_login_link": "Kopiera länk",
"login_link_copied": "Länken har kopierats."
},
"roles": {
"viewer": {
@@ -6280,7 +6295,8 @@
"save_prices": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.spara'} @:{'words.generated.avdelningspriser'}.",
"load_employees": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.ladda'} @:{'words.generated.medarbetare'}.",
"save_employee": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.spara'} @:{'words.generated.medarbetaren'}.",
"deactivate_employee": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.inaktivera'} @:{'words.generated.medarbetaren'}."
"deactivate_employee": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.inaktivera'} @:{'words.generated.medarbetaren'}.",
"generate_employee_login_link": "Kunde inte skapa inloggningslänk för medarbetaren."
},
"role_permissions": {
"title": "Rollbehörigheter",
@@ -221,6 +221,7 @@
"company_phone": "Virksomhedstelefon",
"contact_email": "@.capitalize:{'terms.glossary.kontakt'}-@:{'terms.glossary.e'}-@:{'terms.glossary.mail'}",
"contact_phone": "Kontakttelefon",
"ean": "EAN",
"created_by": "@.capitalize:{'terms.glossary.oprettet'} @:{'terms.glossary.af'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.indtastede'} @:{'terms.glossary.nummerplade'} @:{'terms.glossary.tilhører'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.anden'} @:{'terms.glossary.gemt'} @:{'terms.glossary.kunde'}. @.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.om'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.kunde'} @:{'terms.glossary.skal'} @:{'terms.glossary.beholdes'}, @:{'terms.glossary.eller'} @:{'terms.glossary.om'} @:{'terms.glossary.der'} @:{'terms.glossary.skal'} @:{'terms.glossary.skiftes'} @:{'terms.glossary.til'} @:{'terms.glossary.køretøjets'} @:{'terms.glossary.kunde'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@:{'terms.glossary.kundeportal'}",
"benefit_portal_desc": "@:{'terms.glossary.benyt'} kundeportalen @:{'terms.glossary.til'} @:{'terms.glossary.at'} @:{'terms.glossary.tilføje'} @:{'terms.glossary.køretøjer'}, @:{'terms.glossary.booke'} @:{'terms.glossary.vaske'}, downloade @:{'terms.glossary.fakturaer'}, @:{'terms.glossary.vaskecertifikater'} @:{'terms.glossary.og'} @:{'terms.glossary.information'} @:{'terms.glossary.for'} @:{'terms.glossary.historiske'} @:{'terms.glossary.vaske'}",
"benefit_wash_desc": "@:{'terms.glossary.benyt'} @:{'terms.glossary.vores'} @:{'terms.glossary.nationale'} @:{'terms.glossary.netværk'} @:{'terms.glossary.af'} @:{'terms.glossary.vaskehaller'} @:{'terms.glossary.til'} @:{'terms.glossary.konkurrencedygtige'} @:{'terms.glossary.priser'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "@:{'terms.glossary.firma'} @:{'terms.glossary.email'}",
"intro": "@.capitalize:{'terms.glossary.som'} @:{'terms.glossary.kunde'} @:{'terms.glossary.hos'} @:{'terms.glossary.truck'} @:{'terms.glossary.wash'} @:{'terms.glossary.far'} @:{'terms.glossary.du'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'}:",
"phone_label": "@:{'terms.glossary.firma'} @:{'terms.glossary.telefon'}",
+18 -4
View File
@@ -76,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -106,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -321,7 +328,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'terms.glossary.rediger'}",
"deactivate": "Deaktiver"
"deactivate": "Deaktiver",
"login_link": "QR-login",
"login_link_title": "Medarbejderloginlink",
"login_link_qr_alt": "QR-kode-loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Kopiér link",
"login_link_copied": "Linket er kopieret."
},
"roles": {
"viewer": {
@@ -351,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.afdelingspriser'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.gemmes'}.",
"load_employees": "@.capitalize:{'terms.glossary.medarbejdere'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.indlæses'}.",
"save_employee": "@.capitalize:{'terms.glossary.medarbejderen'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.gemmes'}.",
"deactivate_employee": "@.capitalize:{'terms.glossary.medarbejderen'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} deaktiveres."
"deactivate_employee": "@.capitalize:{'terms.glossary.medarbejderen'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} deaktiveres.",
"generate_employee_login_link": "Medarbejderens loginlink kunne ikke oprettes."
},
"role_permissions": {
"title": "Rolletilladelser",
@@ -221,6 +221,7 @@
"company_phone": "Firmentelefon",
"contact_email": "@:{'terms.glossary.kontakt'}-@.upper:{'terms.glossary.e'}-@:{'terms.glossary.mail'}",
"contact_phone": "Kontakttelefonnummer",
"ean": "EAN",
"created_by": "@.capitalize:{'terms.glossary.erstellt'} @:{'terms.glossary.von'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.glossary.dieses'} @:{'terms.glossary.kennzeichen'} @:{'terms.glossary.gehoert'} @:{'terms.glossary.zu'} @:{'terms.glossary.einem'} @:{'terms.glossary.anderen'} @:{'terms.glossary.gespeicherten'} @:{'terms.glossary.kunden'}. @:{'terms.glossary.waehlen'} @.capitalize:{'terms.glossary.sie'}, @:{'terms.glossary.ob'} @:{'terms.glossary.der'} @:{'terms.glossary.ausgewaehlte'} @.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.beibehalten'} @:{'terms.glossary.oder'} @:{'terms.glossary.zum'} @:{'terms.glossary.fahrzeugkunden'} @:{'terms.glossary.gewechselt'} @:{'terms.glossary.werden'} @:{'terms.glossary.soll'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@:{'terms.glossary.kundenportal'}",
"benefit_portal_desc": "@.capitalize:{'terms.glossary.nutzen'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.das'} @:{'terms.glossary.kundenportal'}, @:{'terms.glossary.um'} @:{'terms.glossary.fahrzeuge'} hinzuzuf?@:{'terms.glossary.gen'}, @.upper:{'terms.glossary.w'}?@:{'terms.glossary.schen'} @:{'terms.glossary.zu'} @:{'terms.glossary.buchen'}, @:{'terms.glossary.rechnungen'} @:{'terms.glossary.und'} @:{'terms.glossary.waschzertifikate'} herunterzuladen @:{'terms.glossary.sowie'} @:{'terms.glossary.informationen'} @:{'terms.glossary.zu'} @:{'terms.glossary.fr'}?@:{'terms.glossary.heren'} @.upper:{'terms.glossary.w'}?@:{'terms.glossary.schen'} einzusehen",
"benefit_wash_desc": "@.capitalize:{'terms.glossary.nutzen'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.unser'} @:{'terms.glossary.nationales'} @:{'terms.glossary.netz'} @:{'terms.glossary.von'} @:{'terms.glossary.waschhallen'} @:{'terms.glossary.zu'} @:{'terms.glossary.wettbewerbsf'}?@:{'terms.glossary.higen'} @:{'terms.glossary.preisen'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "Firmen-@.upper:{'terms.glossary.e'}-@:{'terms.glossary.mail'}",
"intro": "@.capitalize:{'terms.glossary.als'} @.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.bei'} @:{'terms.glossary.truck'} @.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.erhalten'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.zugang'} @:{'terms.glossary.zu'}:",
"phone_label": "@:{'phrases.compat.admin.pos.company_phone'}",
+18 -4
View File
@@ -76,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -106,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -321,7 +328,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@.capitalize:{'terms.glossary.bearbeiten'}",
"deactivate": "Deaktivieren"
"deactivate": "Deaktivieren",
"login_link": "QR-Login",
"login_link_title": "Mitarbeiter-Loginlink",
"login_link_qr_alt": "QR-Code-Loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Link kopieren",
"login_link_copied": "Link kopiert."
},
"roles": {
"viewer": {
@@ -351,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.abteilungspreise'} @:{'terms.glossary.konnten'} @:{'terms.glossary.nicht'} @:{'terms.glossary.gespeichert'} @:{'terms.glossary.werden'}.",
"load_employees": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnten'} @:{'terms.glossary.nicht'} @:{'terms.glossary.geladen'} @:{'terms.glossary.werden'}.",
"save_employee": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnte'} @:{'terms.glossary.nicht'} @:{'terms.glossary.gespeichert'} @:{'terms.glossary.werden'}.",
"deactivate_employee": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnte'} @:{'terms.glossary.nicht'} @:{'terms.glossary.deaktiviert'} @:{'terms.glossary.werden'}."
"deactivate_employee": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnte'} @:{'terms.glossary.nicht'} @:{'terms.glossary.deaktiviert'} @:{'terms.glossary.werden'}.",
"generate_employee_login_link": "Mitarbeiter-Loginlink konnte nicht erstellt werden."
},
"role_permissions": {
"title": "Rollenberechtigungen",
@@ -221,6 +221,7 @@
"company_phone": "@.capitalize:{'terms.glossary.company'} @:{'terms.glossary.phone'}",
"contact_email": "@.capitalize:{'terms.glossary.contact'} @:{'terms.glossary.email'}",
"contact_phone": "@.capitalize:{'terms.glossary.contact'} @:{'terms.glossary.phone'}",
"ean": "EAN",
"created_by": "@.capitalize:{'terms.glossary.created'} @:{'terms.glossary.by'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.glossary.this'} @:{'terms.glossary.typed'} @:{'terms.glossary.plate'} @:{'terms.glossary.belongs'} @:{'terms.glossary.to'} @:{'terms.glossary.another'} @:{'terms.glossary.saved'} @:{'terms.glossary.customer'}. @.capitalize:{'terms.glossary.choose'} @:{'terms.glossary.whether'} @:{'terms.glossary.to'} @:{'terms.glossary.keep'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.selected'} @:{'terms.glossary.customer'} @:{'terms.glossary.or'} @:{'terms.glossary.switch'} @:{'terms.glossary.to'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.customer'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.portal'}",
"benefit_portal_desc": "@.capitalize:{'terms.glossary.use'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.customer'} @:{'terms.glossary.portal'} @:{'terms.glossary.to'} @:{'terms.glossary.add'} @:{'terms.glossary.vehicles'}, @:{'terms.glossary.book'} @:{'terms.glossary.washes'}, @:{'terms.glossary.download'} @:{'terms.glossary.invoices'}, @:{'terms.glossary.wash'} @:{'terms.glossary.certificates'} @:{'terms.glossary.and'} @:{'terms.glossary.information'} @:{'terms.glossary.for'} @:{'terms.glossary.historical'} @:{'terms.glossary.washes'}",
"benefit_wash_desc": "@.capitalize:{'terms.glossary.use'} @:{'terms.glossary.our'} @:{'terms.glossary.national'} @:{'terms.glossary.network'} @:{'terms.glossary.of'} @:{'terms.glossary.wash'} @:{'terms.glossary.halls'} @:{'terms.glossary.at'} @:{'terms.glossary.competitive'} @:{'terms.glossary.prices'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "@.capitalize:{'terms.glossary.company'} @:{'terms.glossary.email'}",
"intro": "@.capitalize:{'terms.glossary.as'} @:{'terms.glossary.a'} @:{'terms.glossary.customer'} @:{'terms.glossary.at'} @.capitalize:{'terms.glossary.truck'} @.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.you'} @:{'terms.glossary.get'} @:{'terms.glossary.access'} @:{'terms.glossary.to'}:",
"phone_label": "@:{'phrases.compat.admin.pos.company_phone'}",
+18 -4
View File
@@ -76,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -106,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -321,7 +328,13 @@
"active": "Active",
"inactive": "@.capitalize:{'terms.glossary.inactive'}",
"edit": "@.capitalize:{'terms.glossary.edit'}",
"deactivate": "@.capitalize:{'terms.glossary.deactivate'}"
"deactivate": "@.capitalize:{'terms.glossary.deactivate'}",
"login_link": "QR login",
"login_link_title": "Employee login link",
"login_link_qr_alt": "QR code login link",
"login_link_url": "Login link",
"copy_login_link": "Copy link",
"login_link_copied": "Link copied."
},
"roles": {
"viewer": {
@@ -351,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.save'} @:{'terms.glossary.department'} @:{'terms.glossary.prices'}.",
"load_employees": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.load'} @:{'terms.glossary.employees'}.",
"save_employee": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.save'} @:{'terms.glossary.employee'}.",
"deactivate_employee": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.deactivate'} @:{'terms.glossary.employee'}."
"deactivate_employee": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.deactivate'} @:{'terms.glossary.employee'}.",
"generate_employee_login_link": "Could not create employee login link."
},
"role_permissions": {
"title": "Role permissions",
@@ -293,6 +293,7 @@
"delete": "@:{'phrases.compat.global.delete'}",
"details": "@:{'phrases.compat.admin.pos.details'}",
"download": "@:{'phrases.compat.guest.pwa.title'}",
"ean": "@:{'phrases.compat.admin.pos.ean'}",
"drafts_assignment": {
"button": "@:{'phrases.compat.admin.pos.drafts_assignment.button'}",
"create_invoice_collection": "@:{'phrases.compat.admin.pos.drafts_assignment.create_invoice_collection'}",
@@ -8,6 +8,7 @@
"benefit_wash": "@:{'phrases.compat.tables.bookings.wash'}",
"benefit_wash_desc": "@:{'phrases.compat.tables.bookings.wash_desc'}",
"cvr_label": "@:{'phrases.compat.customers.cvr'}",
"ean_label": "@:{'phrases.compat.customer_creation.customer.ean_label'}",
"email_label": "@:{'phrases.compat.customer_creation.customer.email_label'}",
"intro": "@:{'phrases.compat.customer_creation.customer.intro'}",
"phone_label": "@:{'phrases.compat.customer_creation.customer.phone_label'}",
@@ -221,6 +221,7 @@
"company_phone": "Firmatelefon",
"contact_email": "@.capitalize:{'terms.glossary.kontakt'} @:{'terms.glossary.e'}-@:{'terms.glossary.post'}",
"contact_phone": "@.capitalize:{'terms.glossary.kontakt'} @:{'terms.glossary.telefon'}",
"ean": "EAN",
"created_by": "Laget @:{'terms.glossary.av'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.innskrevne'} @:{'terms.glossary.skiltet'} @:{'terms.glossary.tilhoerer'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.annen'} @:{'terms.glossary.lagret'} @:{'terms.glossary.kunde'}. @.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.om'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.kunden'} @:{'terms.glossary.skal'} @:{'terms.glossary.beholdes'} @:{'terms.glossary.eller'} @:{'terms.glossary.om'} @:{'terms.glossary.det'} @:{'terms.glossary.skal'} @:{'terms.glossary.byttes'} @:{'terms.glossary.til'} @:{'terms.glossary.kjoretoykunden'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@:{'terms.glossary.kundeportal'}",
"benefit_portal_desc": "@.capitalize:{'terms.glossary.bruk'} kundeportalen @:{'terms.glossary.til'} @:{'terms.glossary.a'} @:{'terms.glossary.legge'} @:{'terms.glossary.til'} @:{'terms.glossary.kjøretøy'}, bokvaske, @:{'terms.glossary.laste'} @:{'terms.glossary.ned'} @:{'terms.glossary.fakturaer'}, @:{'terms.glossary.vaskesertifikater'} @:{'terms.glossary.og'} @:{'terms.glossary.informasjon'} @:{'terms.glossary.for'} @:{'terms.glossary.historiske'} @:{'terms.glossary.vask'}.",
"benefit_wash_desc": "@.capitalize:{'terms.glossary.bruk'} @:{'terms.glossary.vart'} @:{'terms.glossary.nasjonale'} @:{'terms.glossary.nettverk'} @:{'terms.glossary.av'} @:{'terms.glossary.vaskehaller'} @:{'terms.glossary.til'} @:{'terms.glossary.konkurransedyktige'} @:{'terms.glossary.priser'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "Firmaets @:{'terms.glossary.e'}-@:{'terms.glossary.post'}",
"intro": "@.capitalize:{'terms.glossary.som'} @:{'terms.glossary.kunde'} @:{'terms.glossary.hos'} @:{'terms.glossary.truck'} @.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.far'} @:{'terms.glossary.du'} @:{'terms.glossary.tilgang'} @:{'terms.glossary.til'}:",
"phone_label": "@:{'phrases.compat.admin.pos.company_phone'}",
+18 -4
View File
@@ -76,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -106,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -321,7 +328,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'terms.glossary.rediger'}",
"deactivate": "Deaktiver"
"deactivate": "Deaktiver",
"login_link": "QR-innlogging",
"login_link_title": "Innloggingslenke for medarbeider",
"login_link_qr_alt": "QR-kode for innloggingslenke",
"login_link_url": "Innloggingslenke",
"copy_login_link": "Kopier lenke",
"login_link_copied": "Lenken er kopiert."
},
"roles": {
"viewer": {
@@ -351,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.lagre'} @:{'terms.glossary.avdelingspriser'}.",
"load_employees": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.laste'} @:{'terms.glossary.medarbeidere'}.",
"save_employee": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.lagre'} @:{'terms.glossary.medarbeideren'}.",
"deactivate_employee": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} deaktivere @:{'terms.glossary.medarbeideren'}."
"deactivate_employee": "@.capitalize:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} deaktivere @:{'terms.glossary.medarbeideren'}.",
"generate_employee_login_link": "Kunne ikke opprette innloggingslenke for medarbeideren."
},
"role_permissions": {
"title": "Rolletillatelser",
@@ -221,6 +221,7 @@
"company_phone": "Färetagstelefon",
"contact_email": "@.capitalize:{'terms.glossary.kontakt'}-@:{'terms.glossary.e'}-@:{'terms.glossary.post'}",
"contact_phone": "Kontakttelefon",
"ean": "EAN",
"created_by": "@.capitalize:{'terms.glossary.skapad'} @:{'terms.glossary.av'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.angivna'} @:{'terms.glossary.registreringsnumret'} @:{'terms.glossary.tillhor'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.annan'} @:{'terms.glossary.sparad'} @:{'terms.glossary.kund'}. @:{'terms.glossary.valj_2'} @:{'terms.glossary.om'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valda'} @:{'terms.glossary.kunden'} @:{'terms.glossary.ska'} @:{'terms.glossary.behallas'} @:{'terms.glossary.eller'} @:{'terms.glossary.om'} @:{'terms.glossary.bytet'} @:{'terms.glossary.ska'} @:{'terms.glossary.goras'} @:{'terms.glossary.till'} @:{'terms.glossary.fordonskunden'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@:{'terms.glossary.kundportal'}",
"benefit_portal_desc": "@.capitalize:{'terms.glossary.anvand'} kundportalen @:{'terms.glossary.for'} @:{'terms.glossary.att'} @:{'terms.glossary.lagga'} @:{'terms.glossary.till'} @:{'terms.glossary.fordon'}, @:{'terms.glossary.boka'} @:{'terms.glossary.tvattar'}, @:{'terms.glossary.ladda'} @:{'terms.glossary.ner'} @:{'terms.glossary.fakturor'}, @:{'terms.glossary.tvattcertifikat'} @:{'terms.glossary.och'} @:{'terms.glossary.information'} @:{'terms.glossary.om'} @:{'terms.glossary.historiska'} @:{'terms.glossary.tvattar'}",
"benefit_wash_desc": "@.capitalize:{'terms.glossary.anvand'} @:{'terms.glossary.vart'} @:{'terms.glossary.nationella'} @:{'terms.glossary.natverk'} @:{'terms.glossary.av'} @:{'terms.glossary.tvatthallar'} @:{'terms.glossary.till'} @:{'terms.glossary.konkurrenskraftiga'} @:{'terms.glossary.priser'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "Företagets @:{'terms.glossary.e'}-@:{'terms.glossary.post'}",
"intro": "@.capitalize:{'terms.glossary.som'} @:{'terms.glossary.kund'} @:{'terms.glossary.hos'} @:{'terms.glossary.truck'} @.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.far'} @:{'terms.glossary.du'} @:{'terms.glossary.tillgang'} @:{'terms.glossary.till'}:",
"phone_label": "@:{'phrases.compat.admin.pos.company_phone'}",
+18 -4
View File
@@ -76,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -106,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -321,7 +328,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'terms.glossary.redigera'}",
"deactivate": "@.capitalize:{'terms.glossary.inaktivera'}"
"deactivate": "@.capitalize:{'terms.glossary.inaktivera'}",
"login_link": "QR-inloggning",
"login_link_title": "Inloggningslänk för medarbetare",
"login_link_qr_alt": "QR-kod för inloggningslänk",
"login_link_url": "Inloggningslänk",
"copy_login_link": "Kopiera länk",
"login_link_copied": "Länken har kopierats."
},
"roles": {
"viewer": {
@@ -351,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.inte'} @:{'terms.glossary.spara'} @:{'terms.glossary.avdelningspriser'}.",
"load_employees": "@.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.inte'} @:{'terms.glossary.ladda'} @:{'terms.glossary.medarbetare'}.",
"save_employee": "@.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.inte'} @:{'terms.glossary.spara'} @:{'terms.glossary.medarbetaren'}.",
"deactivate_employee": "@.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.inte'} @:{'terms.glossary.inaktivera'} @:{'terms.glossary.medarbetaren'}."
"deactivate_employee": "@.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.inte'} @:{'terms.glossary.inaktivera'} @:{'terms.glossary.medarbetaren'}.",
"generate_employee_login_link": "Kunde inte skapa inloggningslänk för medarbetaren."
},
"role_permissions": {
"title": "Rollbehörigheter",
@@ -0,0 +1,12 @@
export function normalizeEconomicEan(value) {
if (value === null || value === undefined) {
return "";
}
return String(value).replace(/\D+/g, "");
}
export function economicEanPayloadValue(value) {
const normalized = normalizeEconomicEan(value);
return normalized === "" ? undefined : normalized;
}
+3
View File
@@ -24,6 +24,9 @@ export const createLimitedBackofficeEmployee = (payload) =>
export const updateLimitedBackofficeEmployee = (employeeId, payload) =>
limitedBackofficeRequest(`/employees/${encodeURIComponent(String(employeeId))}`, "PUT", payload);
export const generateLimitedBackofficeEmployeeLoginLink = (employeeId) =>
limitedBackofficeRequest(`/employees/${encodeURIComponent(String(employeeId))}/login-link`, "POST");
export const deactivateLimitedBackofficeEmployee = (employeeId) =>
limitedBackofficeRequest(`/employees/${encodeURIComponent(String(employeeId))}`, "DELETE");
@@ -7,6 +7,7 @@ import LimitedBackofficeLayout from "@/views/backoffice/components/LimitedBackof
import {
createLimitedBackofficeEmployee,
deactivateLimitedBackofficeEmployee,
generateLimitedBackofficeEmployeeLoginLink,
getLimitedBackofficeDepartments,
getLimitedBackofficeEmployees,
getLimitedBackofficeRoles,
@@ -22,6 +23,14 @@ const PHONE_COUNTRY_CODES = [
{ value: "47", flag: "🇳🇴", labelKey: "norway" },
{ value: "358", flag: "🇫🇮", labelKey: "finland" },
];
let qrCodeModulePromise = null;
const loadQRCodeModule = () => {
if (!qrCodeModulePromise) {
qrCodeModulePromise = import("qrcode");
}
return qrCodeModulePromise;
};
const { t } = useI18n();
const departments = ref([]);
@@ -35,6 +44,12 @@ const successMessage = ref("");
const editingEmployee = ref(null);
const formAttempted = ref(false);
const showRolePermissions = ref(false);
const loginLinkModalOpen = ref(false);
const loginLinkEmployee = ref(null);
const loginLinkUrl = ref("");
const loginLinkQrSrc = ref("");
const loginLinkGeneratingId = ref(null);
const loginLinkCopyMessage = ref("");
const form = ref({
display_name: "",
email: "",
@@ -255,6 +270,8 @@ const sanitizePhone = (event) => {
form.value.phone = String(event?.target?.value || "").replace(/\D/g, "");
};
const absoluteLoginLink = (loginPath) => new URL(String(loginPath || ""), window.location.origin).toString();
const loadEmployees = async () => {
const response = await getLimitedBackofficeEmployees({ includeInactive: includeInactive.value });
employees.value = unwrapLimitedBackofficeResponse(response) || [];
@@ -396,6 +413,72 @@ const deactivateEmployee = async (employee) => {
}
};
const showEmployeeLoginLink = async (employee) => {
successMessage.value = "";
errorMessage.value = "";
loginLinkCopyMessage.value = "";
loginLinkGeneratingId.value = employee.id;
try {
const response = await generateLimitedBackofficeEmployeeLoginLink(employee.id);
const payload = unwrapLimitedBackofficeResponse(response) || {};
const loginPath = String(payload.login_path || "");
if (!loginPath) {
throw new Error(t("templates.limited_backoffice.errors.generate_employee_login_link"));
}
const loginUrl = absoluteLoginLink(loginPath);
const { toDataURL } = await loadQRCodeModule();
loginLinkEmployee.value = employee;
loginLinkUrl.value = loginUrl;
loginLinkQrSrc.value = await toDataURL(loginUrl, { margin: 2, width: 240 });
loginLinkModalOpen.value = true;
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(
error,
t("templates.limited_backoffice.errors.generate_employee_login_link")
);
} finally {
loginLinkGeneratingId.value = null;
}
};
const copyText = async (text) => {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back for browser contexts where the Clipboard API is present but denied.
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
};
const copyEmployeeLoginLink = async () => {
if (!loginLinkUrl.value) {
return;
}
await copyText(loginLinkUrl.value);
loginLinkCopyMessage.value = t("templates.limited_backoffice.employees.login_link_copied");
};
const closeEmployeeLoginLink = () => {
loginLinkModalOpen.value = false;
loginLinkCopyMessage.value = "";
};
const reloadEmployees = async () => {
loading.value = true;
errorMessage.value = "";
@@ -697,6 +780,18 @@ onMounted(() => {
</td>
<td class="limited-employees-table__actions">
<div class="buttons are-small">
<button
v-if="employee.active"
type="button"
class="button is-info is-light"
:class="{ 'is-loading': loginLinkGeneratingId === employee.id }"
:disabled="saving || loginLinkGeneratingId === employee.id"
:data-testid="`limited-employee-login-link-${employee.id}`"
@click="showEmployeeLoginLink(employee)"
>
<span class="icon is-small"><i class="fas fa-qrcode" aria-hidden="true"></i></span>
<span>{{ t("templates.limited_backoffice.employees.login_link") }}</span>
</button>
<button
type="button"
class="button is-light"
@@ -813,6 +908,77 @@ onMounted(() => {
</footer>
</div>
</b-modal>
<b-modal
v-model="loginLinkModalOpen"
:width="420"
:can-cancel="['escape', 'x', 'outside']"
trap-focus
aria-role="dialog"
aria-modal
>
<div class="modal-card limited-login-link-modal" data-testid="limited-login-link-modal">
<header class="modal-card-head">
<div>
<p class="modal-card-title">{{ t("templates.limited_backoffice.employees.login_link_title") }}</p>
<p v-if="loginLinkEmployee" class="is-size-7 has-text-grey">
{{ loginLinkEmployee.display_name }}
</p>
</div>
<button
type="button"
class="delete"
:aria-label="t('templates.limited_backoffice.role_permissions.close')"
data-testid="limited-login-link-close"
@click="closeEmployeeLoginLink"
></button>
</header>
<section class="modal-card-body">
<div class="limited-login-link-modal__qr">
<img
v-if="loginLinkQrSrc"
:src="loginLinkQrSrc"
:alt="t('templates.limited_backoffice.employees.login_link_qr_alt')"
data-testid="limited-login-link-qr"
/>
</div>
<div class="field">
<label class="label" for="limited-login-link-url">
{{ t("templates.limited_backoffice.employees.login_link_url") }}
</label>
<div class="control">
<input
id="limited-login-link-url"
class="input"
type="text"
readonly
:value="loginLinkUrl"
data-testid="limited-login-link-url"
@focus="$event.target.select()"
/>
</div>
<p v-if="loginLinkCopyMessage" class="help is-success" data-testid="limited-login-link-copy-message">
{{ loginLinkCopyMessage }}
</p>
</div>
</section>
<footer class="modal-card-foot is-justify-content-flex-end">
<button
type="button"
class="button is-info"
data-testid="limited-login-link-copy"
@click="copyEmployeeLoginLink"
>
<span class="icon is-small"><i class="fas fa-copy" aria-hidden="true"></i></span>
<span>{{ t("templates.limited_backoffice.employees.copy_login_link") }}</span>
</button>
<button type="button" class="button is-light" @click="closeEmployeeLoginLink">
{{ t("templates.limited_backoffice.role_permissions.close") }}
</button>
</footer>
</div>
</b-modal>
</LimitedBackofficeLayout>
</RestrictedPageWrapper>
</template>
@@ -882,7 +1048,24 @@ onMounted(() => {
}
.limited-employees-table__actions {
width: 190px;
width: 300px;
}
.limited-login-link-modal {
width: 100%;
max-width: 420px;
}
.limited-login-link-modal__qr {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.limited-login-link-modal__qr img {
width: 240px;
max-width: 100%;
height: auto;
}
.limited-role-permissions-modal {
@@ -23,6 +23,7 @@ import {
} from "../../imports/InvoicingBillingPeriodImportPaging.js";
import { buildPossibleDuplicateGroups } from "../../utils/possibleDuplicateGroups.js";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
const period_result = ref<any>(null);
const route = useRoute();
@@ -416,7 +417,7 @@ const loadSelfWashCounts = async ({ forceRefresh = false } = {}) => {
'GET',
{
page: 1,
limit: 500,
limit: SELFWASH_PERIOD_ALL_LIMIT,
filters,
order: 'StartTime:desc',
},
@@ -4,7 +4,7 @@ export const PERIOD_CACHE_TTL_MS = 10 * 60 * 1000;
export const PERIOD_LIMIT_OPTIONS = [10, 25, 50, 100, 200, 500, "all"];
const CACHE_PREFIX = "invoicing-period-page:v2:";
const SELF_WASH_COUNTS_CACHE_PREFIX = "invoicing-period-self-wash-counts:";
const SELF_WASH_COUNTS_CACHE_PREFIX = "invoicing-period-self-wash-counts:v2:";
const DEFAULT_LIMIT = 100;
const ALL_LIMIT = "all";
@@ -165,6 +165,19 @@ export const getUserData = async (id = userId.value) => {
return activeLoadPromise;
};
const isDirectProductDiscount = (discount, productId) => {
return discount?.product_or_category_id == productId && Number(discount?.is_category) === 0;
};
const parseFixedPriceValue = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
};
/** Get user product discount */
export const getUserProductDiscount = (product, allowCategory = 1, onlyCategory = 0, allowGlobal = 0) => {
// Get the product id
@@ -237,12 +250,25 @@ export const getUserProductOnlyDiscount = (product) => {
// Find the discount for the product
let discount = null;
try {
discount = user.discounts.value.find(discount => discount.product_or_category_id == productId && !discount.is_category) || null;
discount = user.discounts.value.find(discount => isDirectProductDiscount(discount, productId)) || null;
} catch (e) {}
// If the discount is not found, return 0
return discount ? discount.percentage : 0;
};
export const getUserProductFixedPrice = (product) => {
const productId = product.id;
let fixedPriceDiscount = null;
try {
fixedPriceDiscount = user.discounts.value.find((discount) => (
isDirectProductDiscount(discount, productId) && parseFixedPriceValue(discount.fixed_price) !== null
)) || null;
} catch (e) {}
return fixedPriceDiscount ? parseFixedPriceValue(fixedPriceDiscount.fixed_price) : null;
};
export const isProductAllowedToApplyCategoryDiscounts = (product) => {
// Check if the product is allowed to apply category discounts
return parseInt(product.apply_category_discount) === 1;
@@ -270,6 +296,21 @@ export const getProductBestApplicableDiscount = (product) => {
return Math.max(productDiscount, categoryDiscount, globalDiscount);
}
export const getProductEffectivePrice = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
if (fixedPrice !== null) {
return fixedPrice;
}
const basePrice = Number(product.price ?? 0);
const discount = Number(getProductBestApplicableDiscount(product) || 0);
if (!Number.isFinite(basePrice)) {
return 0;
}
return Number((basePrice - (basePrice * (discount / 100))).toFixed(0));
}
export const user = {
id: ref(''),
customer_number: ref(''),
@@ -304,9 +345,11 @@ export const user = {
getUserCategoryDiscount: getUserCategoryDiscount,
getUserGlobalDiscount: getUserGlobalDiscount,
getUserProductOnlyDiscount: getUserProductOnlyDiscount,
getUserProductFixedPrice: getUserProductFixedPrice,
isProductAllowedToApplyCategoryDiscounts: isProductAllowedToApplyCategoryDiscounts,
getProductCategory: getProductCategory,
getProductBestApplicableDiscount: getProductBestApplicableDiscount,
getProductEffectivePrice: getProductEffectivePrice,
},
};
</script>
@@ -3,7 +3,7 @@
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { useRouter } from 'vue-router'
import { user, getUserData, userId, setUser, getUserProductDiscount, getUserGlobalDiscount, getProductBestApplicableDiscount, getUserProductOnlyDiscount, getProductCategory, getUserCategoryDiscount, isProductAllowedToApplyCategoryDiscounts } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { user, getUserData, userId, setUser, getUserProductDiscount, getUserGlobalDiscount, getProductBestApplicableDiscount, getUserProductOnlyDiscount, getUserProductFixedPrice, getProductEffectivePrice, getProductCategory, getUserCategoryDiscount, isProductAllowedToApplyCategoryDiscounts } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { getProducts } from "@/components/shop/Products.vue";
import { ref } from 'vue';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
@@ -56,6 +56,72 @@ const editDiscount = (product, isCategory) => {
allowOutsideClick: () => !Swal.isLoading()
});
}
const formatPrice = (price) => `${Number(price || 0).toFixed(0)} Kr.`;
const getProductPriceDisplay = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
if (fixedPrice !== null) {
return `${formatPrice(fixedPrice)} (fast pris)`;
}
const discount = getProductBestApplicableDiscount(product);
if (discount === 0) {
return formatPrice(product.price);
}
return `${formatPrice(getProductEffectivePrice(product))} (${discount}% rabat)`;
};
const editFixedPrice = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
Swal.fire({
title: product.name + ' ( product: ' + product.id + ' )',
input: 'number',
inputValue: fixedPrice === null ? '' : fixedPrice,
inputLabel: 'Fast pris (Kr.)',
inputAttributes: {
autocapitalize: 'off',
min: 0,
step: 1
},
showCancelButton: true,
confirmButtonText: 'Save',
showLoaderOnConfirm: true,
inputValidator: (value) => {
const normalizedValue = String(value ?? '').trim();
if (normalizedValue === '') {
return null;
}
const parsedValue = Number(normalizedValue);
if (!Number.isInteger(parsedValue) || parsedValue < 0) {
return 'Fast pris skal være et heltal eller tom.';
}
return null;
},
preConfirm: (value) => {
const normalizedValue = String(value ?? '').trim();
const fixedPriceValue = normalizedValue === '' ? null : Number.parseInt(normalizedValue, 10);
return authenticatedRequest(`/superuser/user/discounts`, "POST", {
user_id: userId.value,
object_id: product.id,
discount: getUserProductOnlyDiscount(product),
fixed_price: fixedPriceValue,
is_category: false
})
.then((response) => {
console.log(response);
getUserData();
})
.catch((error) => {
console.log(error);
});
},
allowOutsideClick: () => !Swal.isLoading()
});
}
</script>
<template>
@@ -74,6 +140,7 @@ const editDiscount = (product, isCategory) => {
<th>{{ $t('tables.common.category') }}</th>
<th>{{ $t('tables.common.product_name') }}</th>
<th>{{ $t('tables.common.price') }}</th>
<th>Fast pris</th>
<th>{{ $t('tables.common.discount_item') }}</th>
<th>{{ $t('tables.common.discount_category') }}</th>
</tr>
@@ -84,8 +151,16 @@ const editDiscount = (product, isCategory) => {
<td>{{ getProductCategory(product)}}</td>
<td>{{ product.name }}</td>
<!-- Price -->
<td v-if="getProductBestApplicableDiscount(product) === 0">{{ product.price }} Kr.</td>
<td v-else>{{ (product.price - (product.price * (getProductBestApplicableDiscount(product) / 100))).toFixed(0) }} Kr. ({{ getProductBestApplicableDiscount(product) }}% rabat)</td>
<td>{{ getProductPriceDisplay(product) }}</td>
<!-- Fixed price -->
<td
class="is-clickable"
@click="editFixedPrice(product)"
:data-testid="`customer-fixed-price-${product.id}`"
><template v-if="getUserProductFixedPrice(product) !== null">{{ getUserProductFixedPrice(product) }} Kr.</template><template v-else>-</template>
<i class="is-pulled-right fas fa-edit"></i>
</td>
<!-- Discount: Item -->
<td
@@ -122,4 +197,4 @@ const editDiscount = (product, isCategory) => {
<style scoped>
</style>
</style>
@@ -1369,6 +1369,7 @@ const scheduleRecentCompletedWashRefresh = (attempt = 0) => {
}
recentCompletedRefreshTimeout.value = hostWindow.setTimeout(() => {
recentCompletedRefreshTimeout.value = null;
if (isMyWashStartUnmounted.value) {
return;
}
@@ -16,6 +16,7 @@ const json = (body: unknown, status = 200) => ({
contentType: "application/json",
body: JSON.stringify(body),
});
const pageReadyTimeout = process.env.CI ? 30_000 : 15_000;
const setupNotificationSmsApi = async (page: Page) => {
let listRequestCount = 0;
@@ -115,9 +116,10 @@ test("@smoke department notification SMS active toggle and delete refresh the ta
const notificationSmsApi = await setupNotificationSmsApi(page);
await page.goto("/admin/1/modules/notifications", { waitUntil: "domcontentloaded" });
await expect.poll(() => notificationSmsApi.listRequestCount, { timeout: pageReadyTimeout }).toBeGreaterThan(0);
const row = page.locator("tr", { hasText: "Dispatch line" });
await expect(row).toBeVisible();
await expect(row).toBeVisible({ timeout: pageReadyTimeout });
const enabledToggle = page.getByTestId("department-notification-sms-enabled-toggle-501");
await expect(enabledToggle).toBeChecked();
+45 -20
View File
@@ -17,6 +17,8 @@ const POS_PERMISSIONS = [
const SUPERUSER_POS_PERMISSIONS = [...POS_PERMISSIONS, "superuser"];
const POS_BOOT_URL = "/admin/12/modules/pos?step=1";
const POS_BOOT_READY_TIMEOUT = 20_000;
const POS_ORDER_READY_TIMEOUT = 20_000;
const DRAFT_TRANSACTION_CUSTOMER_ID = 44556677;
const DRAFT_TRANSACTION_CUSTOMER_NAME = "(TEST) Draft Transaction Customer";
@@ -49,6 +51,22 @@ function getPosBootStep(page: Page) {
return page.locator('[data-testid="pos-step-1"]:visible, [data-testid="pos-mobile-step-1"]:visible').first();
}
function getPosOrderReadyTarget(page: Page) {
return page
.locator(
[
'[data-testid="pos-order-card"]:visible',
'[data-testid="pos-order-main"]:visible',
'[data-testid="pos-order-detail"]:visible',
].join(", ")
)
.first();
}
async function expectPosOrderReady(page: Page, timeout = POS_ORDER_READY_TIMEOUT) {
await expect(getPosOrderReadyTarget(page)).toBeVisible({ timeout });
}
async function navigateToPosBootPage(page: Page, { waitForSession = false }: { waitForSession?: boolean } = {}) {
let lastError: unknown;
@@ -64,9 +82,8 @@ async function navigateToPosBootPage(page: Page, { waitForSession = false }: { w
try {
await page.goto(POS_BOOT_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
await Promise.race([sessionRequest, getPosBootStep(page).waitFor({ state: "visible", timeout: 10_000 })]);
await expect(getPosBootStep(page)).toBeVisible({ timeout: 10_000 });
await sessionRequest;
await expect(getPosBootStep(page)).toBeVisible({ timeout: POS_BOOT_READY_TIMEOUT });
return;
} catch (error) {
lastError = error;
@@ -162,19 +179,24 @@ async function waitForSwalToClose(page: Page) {
async function openOrderSettings(page: Page, orderId: number) {
await page.goto(`/admin/12/modules/pos/orders/${orderId}`);
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 20_000 });
await expectPosOrderReady(page);
await clickVisibleTestId(page, "pos-order-tab-settings");
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
}
async function revisitCurrentPage(page: Page) {
await page.goto(page.url(), { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 20_000 });
if (new URL(page.url()).pathname.includes("/modules/pos/orders/")) {
await expectPosOrderReady(page);
return;
}
await expect(getPosBootStep(page)).toBeVisible({ timeout: POS_BOOT_READY_TIMEOUT });
}
async function reloadOrderSettings(page: Page, orderId: number) {
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await page.goto(`/admin/12/modules/pos/orders/${orderId}`, { waitUntil: "domcontentloaded", timeout: 30_000 });
await expectPosOrderReady(page);
await clickVisibleTestId(page, "pos-order-tab-settings");
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
}
@@ -313,17 +335,18 @@ async function changeOrderInvoiceCollection(page: Page, closedAt: string) {
async function openOrderDetail(page: Page, orderId = 54518) {
const orderUrl = `/admin/12/modules/pos/orders/${orderId}`;
for (let attempt = 0; attempt < 2; attempt += 1) {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await page.goto(orderUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 10_000 });
await expectPosOrderReady(page);
return;
} catch (error) {
if (attempt === 1) {
if (attempt === 2) {
throw error;
}
await navigateToPosBootPage(page);
await page.goto("about:blank").catch(() => {});
await delay(250);
}
}
}
@@ -989,7 +1012,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
expect(Math.abs((beforeRailBox?.width ?? 0) - (restoredRailBox?.width ?? 0))).toBeLessThanOrEqual(20);
await page.goto("/admin/12/modules/pos/orders/54518", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
await expectOrderTotal(page, 1397);
});
@@ -1107,7 +1130,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(noteInput).toBeFocused();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(referenceValue);
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(poValue);
await expect(page.getByTestId("pos-order-metadata-note")).toContainText(noteValue);
@@ -1160,7 +1183,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
});
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-customer-wishes-reference")).toContainText("+ Tilføj");
await expect(page.getByTestId("pos-order-customer-wishes-po")).toContainText("+ Tilføj");
});
@@ -1221,7 +1244,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(reg3Input).toHaveValue("NO9012", { timeout: 10000 });
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-registration-1")).toContainText("XY1234");
await expect(page.getByTestId("pos-order-registration-2")).toContainText("TR5678");
await expect(page.getByTestId("pos-order-registration-3")).toContainText("NO9012");
@@ -1249,7 +1272,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 2123);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-item-note-trigger-9101")).toBeVisible();
await expect(page.getByTestId("pos-order-item-reference-trigger-9101")).toBeVisible();
await expect(page.getByTestId("pos-order-item-price-9101")).toContainText("1400");
@@ -1274,7 +1297,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 1423);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-item-note-trigger-9102")).toBeVisible();
await expect(page.getByTestId("pos-order-item-price-9102")).toContainText("450");
await expectOrderTotal(page, 1423);
@@ -1324,7 +1347,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 0);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-empty-state")).toBeVisible();
await expectOrderTotal(page, 0);
});
@@ -1347,7 +1370,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expectOrderTotal(page, 2021);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(page.getByTestId("pos-order-item-edit-9200").first()).toBeVisible();
await expectOrderTotal(page, 2021);
});
@@ -1432,6 +1455,8 @@ test.describe("Admin POS Orders - desktop settings", () => {
});
test("persists scalar metadata edits across settings and details tabs", async ({ page }) => {
test.setTimeout(90_000);
const { orderId } = await createDisposableOrder(page);
const submittedValues = {
reference: "UPDATED-REF-2026",
@@ -2964,7 +2989,7 @@ test.describe("Admin POS Orders - mobile smoke", () => {
test("opens the settings tab and renders the new controls", async ({ page }) => {
await page.goto("/admin/12/modules/pos/orders/54518");
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await clickVisibleTestId(page, "pos-order-tab-settings");
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
@@ -3093,7 +3118,7 @@ test.describe("Admin POS Orders - mobile smoke", () => {
expect(Math.abs((restoredMainBox?.width ?? 0) - (beforeMainBox?.width ?? 0))).toBeLessThanOrEqual(20);
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expectPosOrderReady(page);
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
await expectOrderTotal(page, 1397);
});
+1 -1
View File
@@ -55,7 +55,7 @@ async function suppressVueDevtoolsOverlay(page) {
async function primeSuperuserSession(page) {
const token = "superuser-e2e-token";
await primeMockSession(page, { token });
await primeMockSession(page, { token, bootPath: null });
}
async function prepareInvoiceDistributionPage(page, overrides = {}) {
+8 -3
View File
@@ -1132,7 +1132,6 @@ test.describe("Invoicing period tab", () => {
};
await openPeriodView(page);
await setEntireMarchPeriod(page);
await page.route("**/modules/xlvask/services/usage/orders**", async (route) => {
const path = new URL(route.request().url()).pathname;
@@ -1242,8 +1241,10 @@ test.describe("Invoicing period tab", () => {
);
});
await setEntireMarchPeriod(page);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/1)");
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (0/2)");
await expect(page.getByTestId("invoicing-period-view-selector-progress-self_wash")).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-self_wash").click();
@@ -1273,6 +1274,7 @@ test.describe("Invoicing period tab", () => {
await page.getByTestId("xlvask-automation-accept-8101").click();
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Accepteret #7001");
expect(automationAcceptRequests).toEqual([{ suggestion_id: 7101 }]);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (1/2)");
await expect.poll(() => usageOrderRequests.length).toBeGreaterThan(0);
expect(usageOrderRequests.some((request) => request.limit === "10000")).toBe(true);
@@ -2772,7 +2774,10 @@ test.describe("Invoicing period tab", () => {
await page.goto("/superuser/invoices?activeTab=period");
await expect(page).toHaveURL(/activeTab=period/);
await expect.poll(() => requestCount).toBeGreaterThan(0);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
});
await expect.poll(() => requestCount, { timeout: periodRouteReadyTimeout }).toBeGreaterThan(0);
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-customer-queue-6001")).toBeVisible();
+66
View File
@@ -289,6 +289,7 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
const priceUpdateCalls: unknown[] = [];
const employeeCreateCalls: unknown[] = [];
const employeeUpdateCalls: unknown[] = [];
const loginLinkCalls: string[] = [];
const currentPricePayload = {
value: cloneJson(options.pricePayload ?? pricePayload),
};
@@ -400,6 +401,27 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
return;
}
const loginLinkMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)\/login-link$/);
if (loginLinkMatch && method === "POST") {
const employeeId = loginLinkMatch[1];
const status = options.loginLinkStatus ?? 200;
loginLinkCalls.push(employeeId);
await route.fulfill(
json(
status === 200
? {
data: {
employee_id: Number(employeeId),
login_path: `/login/qr?token=${"a".repeat(64)}`,
},
}
: { message: options.loginLinkMessage ?? "Could not create employee login link." },
status
)
);
return;
}
const employeeMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)$/);
if (employeeMatch && method === "PUT") {
const body = request.postDataJSON?.() || {};
@@ -436,6 +458,7 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
priceUpdateCalls,
employeeCreateCalls,
employeeUpdateCalls,
loginLinkCalls,
};
}
@@ -710,6 +733,49 @@ test.describe("Limited backoffice", () => {
expect(api.forbiddenCalls).toEqual([]);
});
test("generates an employee QR login link", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await page.getByTestId("limited-employee-login-link-501").click();
const modal = page.getByTestId("limited-login-link-modal");
await expect(modal).toBeVisible();
await expect(modal).toContainText("Employee login link");
await expect(modal).toContainText("Casey Clerk");
await expect(page.getByTestId("limited-login-link-qr")).toHaveAttribute("src", /^data:image\/png;base64,/);
await expect(page.getByTestId("limited-login-link-url")).toHaveValue(/\/login\/qr\?token=a{64}$/);
await page.getByTestId("limited-login-link-copy").click();
await expect(page.getByTestId("limited-login-link-copy-message")).toHaveText("Link copied.");
expect(api.loginLinkCalls).toEqual(["501"]);
expect(api.forbiddenCalls).toEqual([]);
});
test("shows an error when employee QR login link generation fails", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page, sessionData, {
loginLinkMessage: "Missing employee management permission.",
loginLinkStatus: 403,
});
await page.goto("/backoffice/employees");
await page.getByTestId("limited-employee-login-link-501").click();
await expect(page.getByTestId("limited-employees-error")).toHaveText("Missing employee management permission.");
await expect(page.getByTestId("limited-login-link-modal")).toHaveCount(0);
expect(api.loginLinkCalls).toEqual(["501"]);
expect(api.forbiddenCalls).toEqual([]);
});
test("shows grouped human-readable role permission help without raw permission keys", async ({ page }, testInfo) => {
await seedLimitedBackofficeSession(page);
await mockLimitedBackofficeApi(page);
+67
View File
@@ -257,3 +257,70 @@ test("customer details renders economic customer mobilePhone object as number on
await expect(phoneValue).not.toContainText("country_code");
await expect(phoneValue).not.toContainText("number");
});
test("only tankcleaning customers can only add tankcleaning products", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
const baseFixture = createPosFixture();
const tankCleaningProduct = {
id: 66,
name: "Tank cleaning 4 spulehoveder",
description: "Tankcleaning service",
price: 799,
subscription_allowed: true,
category: 5,
piktogram: "truck",
apply_category_discount: true,
requires_note: false,
is_wash: false,
display_in_booking_form: true,
order_priority: 1,
addons: [],
};
const fixture = createPosFixture({
customerAttributesByNumber: {
12345679: [{ id: 1, customer_number: 12345679, attribute: "onlyTankCleaning" }],
},
products: [...baseFixture.products, tankCleaningProduct],
departmentCategories: [
...baseFixture.departmentCategories,
{ id: 13, department_id: 12, category: { id: 5, name: "Tankcleaning", meta: { products: [66] } } },
],
});
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
await primeOperatorSession(page, "pos-only-tankcleaning-token");
const customer = fixture.customersByNumber[12345679];
const customerAttributesResponse = page.waitForResponse((response) => {
return (
response.request().method() === "GET" &&
response.url().includes(`/customer/attributes?customer_number=${customer.customerNumber}`)
);
});
await openPosAndSelectCustomer(page, customer);
await customerAttributesResponse;
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
await page.getByTestId("pos-product-card-53").first().click();
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled();
await page.locator(".tabs li").filter({ hasText: "Tankcleaning" }).click();
await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible();
await page.getByTestId("pos-product-card-66").first().click();
await expect(page.getByTestId("pos-add-to-cart-66").first()).toBeEnabled();
const addRequest = page.waitForRequest((request) => {
return request.method() === "POST" && request.url().includes("/order/items");
});
await page.getByTestId("pos-add-to-cart-66").first().click();
const request = await addRequest;
expect(request.postDataJSON().product_id).toBe(66);
});
+6
View File
@@ -591,6 +591,7 @@ async function mockPosApi(page, fixture) {
city: body?.searchResult?.city || "Taastrup",
mobilePhone: String(body.contactPhone || body.companyPhone || ""),
email: body.contactEmail || body.invoiceEmail || "",
ean: String(body.ean || ""),
corporateIdentificationNumber: String(body.cvr || "").padStart(8, "0"),
barred: false,
};
@@ -1503,6 +1504,7 @@ test.describe("POS flow", () => {
const invoiceEmail = page.getByTestId("pos-desktop-add-customer-invoice-email");
const contactEmail = page.getByTestId("pos-desktop-add-customer-contact-email");
const contactPhone = page.getByTestId("pos-desktop-add-customer-contact-phone");
const ean = page.getByTestId("pos-desktop-add-customer-ean");
const submitButton = page.getByTestId("pos-desktop-add-customer-submit");
await expect(newCustomerButton).toBeVisible();
@@ -1517,15 +1519,18 @@ test.describe("POS flow", () => {
await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactPhone).toHaveValue("21754690");
await expect(ean).toHaveValue("");
await companyPhone.fill("55550000");
await contactEmail.fill("dispatch@truckwash.test");
await ean.fill("5790001234567");
await searchInput.fill("43423010");
await expect(companyPhone).toHaveValue("55550000", { timeout: 10_000 });
await expect(contactEmail).toHaveValue("dispatch@truckwash.test");
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
await expect(contactPhone).toHaveValue("42331128");
await expect(ean).toHaveValue("5790001234567");
await expect(submitButton).toBeEnabled();
await submitButton.click();
@@ -1536,6 +1541,7 @@ test.describe("POS flow", () => {
await expect(page.locator(".field.has-addons input[disabled]")).toHaveValue("Wash Group ApS", { timeout: 10_000 });
await expect(page.getByRole("button", { name: "Ryd" })).toBeVisible();
await expect.poll(() => fixture.customerRegistrations.length, { timeout: 10_000 }).toBe(1);
expect(fixture.customerRegistrations[0]).toMatchObject({ ean: "5790001234567" });
await expect.poll(() => fixture.cvrSearches.length, { timeout: 10_000 }).toBe(2);
});
+4
View File
@@ -1015,6 +1015,7 @@ test.describe("POS mobile order flow", () => {
const invoiceEmail = page.getByTestId("pos-mobile-add-customer-invoice-email");
const contactEmail = page.getByTestId("pos-mobile-add-customer-contact-email");
const contactPhone = page.getByTestId("pos-mobile-add-customer-contact-phone");
const ean = page.getByTestId("pos-mobile-add-customer-ean");
const submitButton = page.getByTestId("pos-mobile-add-customer-submit");
await searchInput.fill("41004355");
@@ -1022,10 +1023,12 @@ test.describe("POS mobile order flow", () => {
await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactPhone).toHaveValue("21754690");
await expect(ean).toHaveValue("");
await expect(submitButton).toBeVisible({ timeout: 10_000 });
await companyPhone.fill("55550000");
await contactEmail.fill("dispatch@truckwash.test");
await ean.fill("5790001234567");
await searchInput.fill("43423010");
@@ -1033,6 +1036,7 @@ test.describe("POS mobile order flow", () => {
await expect(contactEmail).toHaveValue("dispatch@truckwash.test");
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
await expect(contactPhone).toHaveValue("42331128");
await expect(ean).toHaveValue("5790001234567");
await expect(submitButton).toBeVisible({ timeout: 10_000 });
});
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {},
}));
import {
getProductBestApplicableDiscount,
getProductEffectivePrice,
getUserProductFixedPrice,
user,
} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import CustomerProductDiscountDisplay from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
const product = {
id: 101,
category: 9,
category_id: 9,
name: "Premium wash",
price: 1000,
apply_category_discount: 1,
};
const globalDiscount = {
id: 999999,
product_or_category_id: "global",
is_category: 1,
percentage: 50,
fixed_price: null,
};
const categoryDiscount = {
id: 200,
product_or_category_id: 9,
is_category: 1,
percentage: 80,
fixed_price: null,
};
beforeEach(() => {
user.discounts.value = [];
});
describe("customer product fixed prices", () => {
it("uses a direct fixed product price as the effective price without changing discount selection", () => {
user.discounts.value = [
globalDiscount,
categoryDiscount,
{
id: 300,
product_or_category_id: 101,
is_category: 0,
percentage: 10,
fixed_price: 350,
},
];
expect(getProductBestApplicableDiscount(product)).toBe(80);
expect(getUserProductFixedPrice(product)).toBe(350);
expect(getProductEffectivePrice(product)).toBe(350);
});
it("falls back to the existing best-discount calculation when fixed price is not set", () => {
user.discounts.value = [
globalDiscount,
{
...categoryDiscount,
percentage: 30,
},
{
id: 300,
product_or_category_id: 101,
is_category: 0,
percentage: 10,
fixed_price: null,
},
];
expect(getUserProductFixedPrice(product)).toBeNull();
expect(getProductBestApplicableDiscount(product)).toBe(50);
expect(getProductEffectivePrice(product)).toBe(500);
});
it("treats zero as a set fixed price", () => {
user.discounts.value = [
{
id: 300,
product_or_category_id: 101,
is_category: 0,
percentage: 10,
fixed_price: 0,
},
];
expect(getUserProductFixedPrice(product)).toBe(0);
expect(getProductEffectivePrice(product)).toBe(0);
});
it("shows fixed price instead of discount percentage in the POS product badge", () => {
const wrapper = mount(CustomerProductDiscountDisplay, {
props: {
product,
customer_discounts: [
categoryDiscount,
{
id: 300,
product_or_category_id: 101,
is_category: 0,
percentage: 10,
fixed_price: 350,
},
],
},
});
expect(wrapper.text()).toContain("350 Kr.");
expect(wrapper.text()).not.toContain("-80%");
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
isProductCategoryRestrictedForCustomer,
isProductRestrictedForCustomer,
isTankCleaningProduct,
} from "@/features/customer/customerProductRules.js";
const onlyTankCleaning = [{ attribute: "onlyTankCleaning" }];
const restrictTankCleaning = [{ attribute: "restrictTankCleaning" }];
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("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("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("applies category restrictions consistently", () => {
expect(isProductCategoryRestrictedForCustomer(4, onlyTankCleaning)).toBe(true);
expect(isProductCategoryRestrictedForCustomer(5, onlyTankCleaning)).toBe(false);
expect(isProductCategoryRestrictedForCustomer("tank_cleaning", restrictTankCleaning)).toBe(true);
});
});
@@ -134,6 +134,7 @@ function mountComponent(props = {}) {
invoice_email: "Invoice email",
contact_email: "Contact email",
contact_phone: "Contact phone",
ean: "EAN",
add_customer: "Add customer",
customer_creation_error: "Error: {error}",
customer_creation_error_generic: "Unable to create customer",
@@ -253,6 +254,7 @@ describe("CustomerSearchFieldPos", () => {
invoiceEmail: "billing@wash-group.test",
contactEmail: "dispatch@wash-group.test",
contactPhone: 42331128,
ean: "5790001234567",
});
return {
@@ -286,6 +288,7 @@ describe("CustomerSearchFieldPos", () => {
await wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').setValue("55550000");
await wrapper.get('[data-testid="pos-desktop-add-customer-contact-email"]').setValue("dispatch@wash-group.test");
await wrapper.get('[data-testid="pos-desktop-add-customer-ean"]').setValue("5790001234567");
await wrapper.get('[data-testid="pos-desktop-add-customer-submit"]').trigger("click");
await flushComponentUpdates();
@@ -755,6 +755,42 @@ describe("Invoicing period queue-driven refresh", () => {
expect(selfWashCalls()).toHaveLength(1);
});
it("loads Selvvask selector counts with the full selected-period limit", async () => {
requestMock.mockImplementation((url) => {
if (url === "/superuser/invoicing/period") {
return Promise.resolve(emptyPeriodResponse());
}
return Promise.resolve(
selfWashResponse(
[
{ id: 8101, linked_order_id: 7001 },
{
id: 8102,
linked_order_id: null,
automation: {
status: "accepted",
action: "attach_order",
},
},
{ id: 8103, linked_order_id: null },
],
3
)
);
});
const wrapper = mountRight();
await flushAll();
expect(wrapper.get("[data-testid='invoicing-period-view-selector-self_wash']").text()).toContain("Selvvask (2/3)");
expect(selfWashCalls()[0][2]).toMatchObject({
limit: 10000,
filters: "StartTime-date_from:2026-04-01,StartTime-date_to:2026-04-30",
order: "StartTime:desc",
});
});
it("ignores stale period responses that resolve after a newer search request", async () => {
const deferred = [];
requestMock.mockImplementation((url) => {
+12
View File
@@ -81,6 +81,11 @@ describe("Playwright PR mapping", () => {
expect(triggersFallback("scripts/run-playwright-full-slice.mjs")).toBe(false);
});
it("maps customer product rule changes to POS customer rule E2E coverage", () => {
expect(specsFor("src/features/customer/customerProductRules.js")).toContain("tests/e2e/pos-customer-rules.spec.js");
expect(specsFor("src/components/displays/boxes/ProductBox.vue")).toContain("tests/e2e/pos-customer-rules.spec.js");
});
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"
@@ -95,4 +100,11 @@ describe("Playwright PR mapping", () => {
"tests/e2e/limited-backoffice.spec.ts"
);
});
it("maps e-conomic customer identifier changes to POS customer registration E2E coverage", () => {
const specs = specsFor("src/services/economicCustomerIdentifiers.js");
expect(specs).toContain("tests/e2e/pos-flow.spec.js");
expect(specs).toContain("tests/e2e/pos-mobile-order-flow.spec.js");
});
});
@@ -35,6 +35,7 @@ describe("POS mobile add-customer autofill", () => {
invoiceEmail: "mikkel@truckwash.dk",
contactEmail: "mikkel@truckwash.dk",
contactPhone: "21754690",
ean: "",
});
});
@@ -69,6 +70,7 @@ describe("POS mobile add-customer autofill", () => {
invoiceEmail: "billing@wash-group.test",
contactEmail: "billing@wash-group.test",
contactPhone: "42331128",
ean: "",
});
});
@@ -76,6 +78,7 @@ describe("POS mobile add-customer autofill", () => {
const manualOverrides = createPosAddCustomerManualOverrides({
companyPhone: true,
contactEmail: true,
ean: true,
});
const nextFields = applyPosAddCustomerSearchResult({
@@ -85,6 +88,7 @@ describe("POS mobile add-customer autofill", () => {
invoiceEmail: "old-invoice@example.com",
contactEmail: "dispatch@example.com",
contactPhone: "21754690",
ean: "5790001234567",
}),
manualOverrides,
searchResult: createSearchResult({
@@ -100,6 +104,24 @@ describe("POS mobile add-customer autofill", () => {
expect(nextFields.invoiceEmail).toBe("billing@wash-group.test");
expect(nextFields.contactPhone).toBe("42331128");
expect(nextFields.cvr).toBe("43423010");
expect(nextFields.ean).toBe("5790001234567");
});
it("preserves manually entered EAN across later autofill passes", () => {
const nextFields = applyPosAddCustomerSearchResult({
fields: createPosAddCustomerFields({
ean: "5790001234567",
}),
manualOverrides: createPosAddCustomerManualOverrides({
ean: true,
}),
searchResult: createSearchResult({
ean: "5790007654321",
}),
notFoundLabel: "Not found",
});
expect(nextFields.ean).toBe("5790001234567");
});
it("refills a field after the operator clears a manual override", () => {
+15 -1
View File
@@ -100,6 +100,10 @@ const xlvaskUsagePaginationSource = readFileSync(
join(root, "src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue"),
"utf8"
);
const xlvaskUsagePeriodConstantsSource = readFileSync(
join(root, "src/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js"),
"utf8"
);
const xlvaskUsageOrdersTableSource = readFileSync(
join(root, "src/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue"),
"utf8"
@@ -479,7 +483,7 @@ describe("Periode tab contract", () => {
expect(periodViewSelfWashSource).toContain(':load-all-at-once="true"');
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
expect(xlvaskUsagePaginationSource).toContain("loadAllAtOnce");
expect(xlvaskUsagePaginationSource).toContain("const SELFWASH_PERIOD_ALL_LIMIT = 10000");
expect(xlvaskUsagePeriodConstantsSource).toContain("export const SELFWASH_PERIOD_ALL_LIMIT = 10000");
expect(xlvaskUsagePaginationSource).toContain("setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false)");
expect(xlvaskUsagePaginationSource).toContain(
"const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters)"
@@ -496,6 +500,7 @@ describe("Periode tab contract", () => {
expect(periodRightSource).toContain("getCachedSelfWashCounts");
expect(periodRightSource).toContain("setCachedSelfWashCounts");
expect(periodRightSource).toContain("'/modules/xlvask/services/usage/orders'");
expect(periodRightSource).toContain("limit: SELFWASH_PERIOD_ALL_LIMIT");
expect(periodRightSource).toContain("StartTime-date_from:${dates.computed.formattedStartDate.value}");
expect(periodRightSource).toContain("StartTime-date_to:${dates.computed.formattedEndDate.value}");
expect(periodRightSource).toContain("if (type.name === 'self_wash')");
@@ -504,6 +509,15 @@ describe("Periode tab contract", () => {
expect(periodRightSource).not.toContain("showProgress: false");
});
it("keeps Selvvask usage pagination scoped to the period view", () => {
expect(xlvaskUsagePaginationSource).toContain("usePaginatedList");
expect(xlvaskUsagePaginationSource).toContain("provide(PaginatedListKey, paginatedList)");
expect(xlvaskUsageOrdersTableSource).toContain("usePaginatedListInstance");
expect(xlvaskUsageOrdersTableSource).toContain(
"const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();"
);
});
it("keeps Selvvask usage entries wrapped inside the period content area", () => {
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-primary");
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-status");
@@ -356,6 +356,7 @@ describe("system search card view-model behavior", () => {
id: 326,
customer_number: 12345679,
discount: 12.5,
fixed_price: 350,
object_id: "global",
is_category: true,
created_at: "2026-03-12T09:15:00Z",
@@ -364,6 +365,7 @@ describe("system search card view-model behavior", () => {
);
expect(vm.keyFields.some((entry) => entry.label === "Discount" && entry.value === "12.5%")).toBe(true);
expect(vm.keyFields.some((entry) => entry.label === "Fixed price" && entry.value === "350")).toBe(true);
expect(vm.keyFields.some((entry) => entry.label === "Target" && entry.value === "global")).toBe(true);
expect(vm.keyFields.some((entry) => entry.label === "Category" && entry.value === "Yes")).toBe(true);
expect(vm.keyFields.some((entry) => entry.label === "Created" && entry.value.includes("2026-03-12"))).toBe(true);