Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 693184244e Fix null product query parameters 2026-07-06 19:56:07 +02:00
Jeppe B 27994d6ac6 Merge pull request #159 from copenhagentruckwash/codex/superuser-user-overview-hub
[codex] Finish superuser user overview hub
2026-07-06 18:57:04 +02:00
Jeppe Bundgaard 9d6978e9c7 Merge remote-tracking branch 'origin/master' into codex/superuser-user-overview-hub
# Conflicts:
#	.github/workflows/tests.yml
2026-07-06 18:38:40 +02:00
Jeppe Bundgaard 62ae4bc890 [codex] Move PR E2E to hosted runners 2026-07-06 18:33:44 +02:00
Jeppe B c2ce6a859b Merge pull request #150 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price UI
2026-07-06 18:15:31 +02:00
Jeppe BandJeppe Bundgaard aa5e0a5507 Add EAN to customer registration flows (#147)
* Add EAN to economic customer registration

* Focus EAN PR Playwright mapping

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 18:12:05 +02:00
Jeppe Bundgaard 6c1c1def78 Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe Bundgaard 1a232183a8 Merge master into fixed price override branch 2026-07-06 17:28:58 +02:00
Jeppe Bundgaard cfa7d5be69 Merge remote-tracking branch 'origin/master' into codex/superuser-user-overview-hub
# Conflicts:
#	.github/workflows/tests.yml
#	scripts/playwright-pr-mapping.mjs
#	src/views/dashboards/userDashboard/wash/MyWashStart.vue
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 17:12:45 +02:00
Jeppe B bb375eb149 Merge pull request #144 from copenhagentruckwash/codex/fix-invoicing-period-selfwash
Fix invoicing period Selvvask counts
2026-07-06 17:08:06 +02:00
Jeppe B 7e5e6ce680 Enforce only tankcleaning POS product rules (#151) 2026-07-06 17:07:03 +02:00
Jeppe Bundgaard 1032f8b4f1 Show limited backoffice employees in backoffice 2026-07-06 16:59:46 +02:00
Jeppe B 744a56c8b2 Merge PR #157: Cleanup superuser dashboard tabs
Cleanup superuser dashboard tabs
2026-07-06 16:59:27 +02:00
Jeppe Bundgaard e58372a190 Merge remote-tracking branch 'origin/master' into codex/pr157-merge-resolve-692380
# Conflicts:
#	scripts/playwright-pr-mapping.mjs
#	src/i18n/generated/da-v2.json
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/en-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/da/phrases/index.json
#	src/i18n/source/de/phrases/index.json
#	src/i18n/source/en/phrases/index.json
#	src/i18n/source/no/phrases/index.json
#	src/i18n/source/sv/phrases/index.json
#	src/views/dashboards/userDashboard/wash/MyWashStart.vue
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 16:59:00 +02:00
Jeppe Bundgaard 19d44b8666 Add limited backoffice user ID translations 2026-07-06 16:53:41 +02:00
Jeppe Bundgaard 92a52d6194 Gate subuser customer navigation and actions 2026-07-06 16:48:24 +02:00
Jeppe BandJeppe Bundgaard db1c0e25c0 Move limited backoffice shortcut into header (#140)
* Move limited backoffice shortcut into header

* Use shared Playwright port locks in CI

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 16:47:33 +02:00
Jeppe Bundgaard 04860a7304 Deduplicate limited backoffice price products 2026-07-06 16:34:10 +02:00
Jeppe B d8d67301ed Add autosave for limited backoffice prices (#143) 2026-07-06 16:26:46 +02:00
Jeppe Bundgaard fa1e915b9c Merge origin/master into Selvvask period fix 2026-07-06 16:07:05 +02:00
Jeppe BandJeppe Bundgaard da86b69b2c Warn before invoicing multiple months together (#155)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 16:02:10 +02:00
Jeppe Bundgaard 5a5cc0bed3 Guard MyWashStart timers after teardown 2026-07-06 15:02:57 +02:00
Jeppe Bundgaard 1a9e7503ef Stabilize PR Playwright runner load 2026-07-06 15:01:01 +02:00
Jeppe Bundgaard d84e9e3406 Map superuser dashboard changes to E2E 2026-07-06 14:56:18 +02:00
Jeppe Bundgaard c41954993c Fix PR E2E changed diff head 2026-07-06 14:54:41 +02:00
Jeppe Bundgaard a7c8fc3bff Reduce PR E2E runner pressure 2026-07-06 14:52:30 +02:00
Jeppe Bundgaard 6002f97fb6 Finish superuser user overview hub 2026-07-06 14:42:23 +02:00
Jeppe Bundgaard 4378dad2b9 Stabilize PR E2E runner checks 2026-07-06 14:39:06 +02:00
Jeppe Bundgaard b8959cb7bb Cleanup superuser dashboard tabs 2026-07-06 14:07:36 +02:00
Jeppe Bundgaard 3f42b77d13 Stabilize invoice distribution smoke session setup 2026-07-06 13:48:48 +02:00
Jeppe Bundgaard f2b66ff19f Stabilize recent wash refresh timers in tests 2026-07-06 13:05:01 +02:00
Jeppe Bundgaard 2fd0893773 Add customer product fixed price UI 2026-07-06 12:52:31 +02:00
Jeppe Bundgaard ce844137a0 Harden POS order detail E2E wait 2026-07-06 12:18:20 +02:00
Jeppe Bundgaard 1a1c3db8e5 Fix invoicing period selfwash counts 2026-07-06 10:33:48 +02:00
144 changed files with 5533 additions and 1110 deletions
+9 -4
View File
@@ -101,8 +101,8 @@ jobs:
if: github.event_name != 'schedule'
needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 30
runs-on: ubuntu-latest
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"
@@ -189,8 +192,9 @@ jobs:
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
@@ -367,8 +371,9 @@ jobs:
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
+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
+44 -2
View File
@@ -28,6 +28,12 @@ export const sourceMappings = [
specs: ["tests/e2e/navigation.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "backoffice",
patterns: [/^src\/views\/backoffice\//u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-roles-permissions",
patterns: [
@@ -37,6 +43,21 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-users",
patterns: [/^src\/views\/dashboards\/superUserDashboard\/user\//u],
specs: ["tests/e2e/superuser-users.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-dashboard",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/SuperUserDashboard(?:Navigation)?\.vue$/u,
/^src\/components\/displays\/superuser\/system\//u,
],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-department-overview",
patterns: [
@@ -47,6 +68,15 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-department-overview.spec.js"],
projects: chromiumProjects,
},
{
name: "header-navigation",
patterns: [
/^src\/components\/viewport\/page\/headers\//u,
/^src\/components\/models\/navigation\/items\/NavigationMenuItemsGlobal\.vue$/u,
],
specs: ["tests/e2e/navigation.smoke.spec.js", "tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "booking",
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
@@ -65,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>
@@ -817,6 +817,10 @@ const isDraftAssignmentModalOpen = computed(() => {
});
const canEditTransaction = (order) => {
if (props.isCustomerView) {
return SessionUser.canAccessCustomerFeature("orders", "edit");
}
return SessionUser.canAccessAdmin() || SessionUser.canAccessDepartment(order.department_id);
};
@@ -1259,7 +1263,7 @@ const formatCashierName = (order) => {
:hover-text="order.reference"
:parse-function="(value) => truncateOrderField(value, 10)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
<EditableTableColumn
:object="order"
@@ -1278,7 +1282,7 @@ const formatCashierName = (order) => {
:hover-text="order.po"
:parse-function="(value) => truncateOrderField(value, 10)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canSeePoField() || SessionUser.canAccessAdmin()"
:permission-check-function="() => canEditTransaction(order)"
/>
<td>{{ order.created_at }}</td>
<td>{{ SessionUser.functions.currency.toLocal(order.total_net_amount) }}</td>
@@ -1774,7 +1778,7 @@ const formatCashierName = (order) => {
:hover-text="order.reference"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1818,7 +1822,7 @@ const formatCashierName = (order) => {
:hover-text="order.po"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canSeePoField() || SessionUser.canAccessAdmin()"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1863,7 +1867,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_1"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1895,7 +1899,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_2"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1927,7 +1931,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_3"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -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);
@@ -10,11 +10,21 @@ const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
const {
list,
loadList,
setAdditionalQueryParameters,
setEndpoint,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
const { t } = useI18n();
setEndpoint("/users", false);
setFilter("customer_number", 0, false);
setAdditionalQueryParameters({ include_limited_backoffice_employees: "true" });
setOrder("created_at", "desc");
// Hide the search field, if the hideSearch prop is set
@@ -24,7 +24,7 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {ref, watch} from "vue";
import {computed, ref, watch} from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n'
@@ -32,6 +32,8 @@ import { todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
// Set the selected status to all
const selectedStatus = ref("*");
@@ -148,7 +150,7 @@ const showNewOrderBookingsPortal = () => {
</div>
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
<!-- Only show the bookings for today (Switch, if the route is /user) -->
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="isUserRoute" :class="{ 'has-text-right': !isSmall }">
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
<div class="control">
<div class="field">
@@ -158,7 +160,7 @@ const showNewOrderBookingsPortal = () => {
</div>
</div>
<!-- Create a new booking, if the route is /user -->
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="isUserRoute && canCreateBooking" :class="{ 'has-text-right': !isSmall }">
<button
class="button is-link"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
@@ -17,6 +17,7 @@ const { t } = useI18n();
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
/**
* Props
*/
@@ -202,7 +203,7 @@ onMounted(() => {
<label for="today"></label>
</div>
</div>
<div class="order-bookings-pagination__new-booking-action">
<div class="order-bookings-pagination__new-booking-action" v-if="canCreateBooking">
<label class="label is-small order-bookings-pagination__desktop-spacer">&nbsp;</label>
<button
class="button is-link button-same-width"
@@ -8,6 +8,7 @@ import {
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
import { computed } from "vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import {Colors} from "@/ThemeConfig.vue";
@@ -20,6 +21,8 @@ const { t } = useI18n();
* Router
*/
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canAddVehicle = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
const props = defineProps({
customer_id: {
@@ -58,7 +61,7 @@ loadList();
</div>
<!-- Create a new vehicle, if the route is /user -->
<div class="vehicles-pagination__add-action"
v-if="router.currentRoute.value.path.startsWith('/user')">
v-if="isUserRoute && canAddVehicle">
<label class="label is-small">&nbsp;</label>
<button
class="button is-link button-same-width vehicles-pagination__add-button"
@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
import {
@@ -16,6 +16,7 @@ import {
const { t, te, locale } = useI18n();
const MAX_GATEWAY_CARDS = 8;
const DEFAULT_DASHBOARD_TAB = "infrastructure";
const gatewayStatusPriority = Object.freeze({
OFFLINE: 0,
DEGRADED: 1,
@@ -77,7 +78,14 @@ const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
const gatewaySectionSuppressed = ref(false);
const gatewayDepartments = ref({});
const gatewayDepartmentsLoaded = ref(false);
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
const dashboardTabKeys = computed(() => [
DEFAULT_DASHBOARD_TAB,
...(showGatewaySection.value ? ["gateways"] : []),
"modules",
"sessions",
]);
const gatewaySummaryCards = computed(() => [
{
id: "total",
@@ -187,6 +195,16 @@ const isStale = computed(() => {
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
});
watch(
() => [activeDashboardTab.value, dashboardTabKeys.value.join("|")],
() => {
if (!dashboardTabKeys.value.includes(activeDashboardTab.value)) {
activeDashboardTab.value = DEFAULT_DASHBOARD_TAB;
}
},
{ immediate: true }
);
const loadStatus = async ({ force = false } = {}) => {
const [snapshotValue] = await Promise.all([
getSuperuserSystemStatus({ force }),
@@ -707,203 +725,250 @@ function modulePath(key) {
</ul>
</div>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
<section
v-if="showGatewaySection"
class="system-status-section"
data-testid="system-status-gateways"
<b-tabs
v-model="activeDashboardTab"
expanded
type="is-boxed"
class="system-status-tabs"
data-testid="system-status-dashboard-tabs"
>
<div class="section-heading">
<div>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<b-tab-item value="infrastructure">
<template #header>
<b-icon pack="fas" icon="server" />
<span data-testid="system-status-tab-infrastructure">
{{ $t("system_status.sections.infrastructure") }}
</span>
</template>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
<section class="system-status-section" data-testid="system-status-panel-infrastructure">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item v-if="showGatewaySection" value="gateways">
<template #header>
<b-icon pack="fas" icon="network-wired" />
<span data-testid="system-status-tab-gateways">
{{ $t("system_status.sections.gateways") }}
</span>
</template>
<section
class="system-status-section"
data-testid="system-status-gateways"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div class="section-heading">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
</div>
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
</b-tab-item>
<b-tab-item value="modules">
<template #header>
<b-icon pack="fas" icon="puzzle-piece" />
<span data-testid="system-status-tab-modules">
{{ $t("system_status.sections.modules") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-modules">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item value="sessions">
<template #header>
<b-icon pack="fas" icon="users" />
<span data-testid="system-status-tab-sessions">
{{ $t("system_status.sections.sessions") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-sessions">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
</span>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</b-tab-item>
</b-tabs>
</div>
</template>
@@ -924,10 +989,10 @@ function modulePath(key) {
.module-card,
.gateway-card {
border: 1px solid #d7dde7;
border-radius: 18px;
border-radius: 8px;
padding: 1rem 1.1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
background: #ffffff;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
}
.summary-card--overall {
@@ -991,6 +1056,23 @@ function modulePath(key) {
color: #0f4c81;
}
.system-status-tabs {
min-width: 0;
}
.system-status-tabs :deep(.tab-content) {
padding: 1rem 0 0;
}
.system-status-tabs :deep(.tabs ul) {
align-items: stretch;
}
.system-status-tabs :deep(.tabs a) {
min-height: 2.75rem;
gap: 0.35rem;
}
.status-card__top,
.module-card__top {
display: flex;
@@ -1107,7 +1189,7 @@ function modulePath(key) {
.gateway-empty {
border: 1px dashed #cbd5e1;
border-radius: 18px;
border-radius: 8px;
padding: 1rem 1.1rem;
color: #475569;
background: rgba(248, 250, 252, 0.8);
@@ -12,6 +12,11 @@ import UserOtherVaskeabonnement
import Swal from "sweetalert2";
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import {
buildMultiMonthInvoiceContext,
MULTI_MONTH_INVOICE_ACTION,
promptMultiMonthInvoiceWarning,
} from "@/services/invoiceMonthSplitWarning.js";
const props = defineProps({
orders: {
@@ -126,32 +131,34 @@ const isAnyOrderSelected = () => {
return selectedInvoiceCollections.value.length > 0;
}
const getSelectedOrders = () => {
return props.orders.filter((order) => selectedInvoiceCollections.value.includes(order.invoice_collection_id));
}
/** Invoice collections */
const onInvoiceCollections = async () => {
// Check if any orders are selected
if (selectedInvoiceCollections.value.length === 0) {
return;
}
/** Create the invoice */
const onCreateInvoiceDraft = async () => {
// Create the invoice
console.log('Create invoice');
await SessionUser.objects.collectedOrderInvoices.functions.economic.invoice(parseInt(props.collectedOrderInvoice.id)).then((response) => {
console.log('Invoice created successfully', response);
Swal.fire({
title: 'Fakturaen er oprettet',
text: 'Fakturaen er oprettet i E-conomic',
icon: 'success',
showConfirmButton: false,
timer: 2000
}).then(() => {
location.reload();
});
}).catch((error) => {
console.log('Error creating invoice', error);
}
)
const selectedOrders = getSelectedOrders();
const invoiceWarningContext = buildMultiMonthInvoiceContext(selectedOrders, {
getDate: (order) => order?.created_at ?? order?.date,
getInvoiceCollectionId: (order) => order?.invoice_collection_id,
});
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
context: invoiceWarningContext,
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
parseErrorMessage: SessionUser.functions.parseErrorMessage,
});
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
location.reload();
return;
}
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
return;
}
for (let i = 0; i < selectedInvoiceCollections.value.length; i++) {
const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i];
// Check if the invoice collection is already booked
@@ -210,6 +217,7 @@ const isOrderContentVisible = (order) => {
class="button is-small"
@click="onInvoiceCollections()"
:disabled="!isAnyOrderSelected()"
data-testid="invoice-order-table-invoice-button"
>
{{ $t('global.invoice_now') }}
</button>
@@ -274,4 +282,4 @@ const isOrderContentVisible = (order) => {
<style scoped>
</style>
</style>
@@ -11,7 +11,9 @@ defineProps({
});
const editUser = (user) => {
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id, {
limitedBackofficeManaged: Boolean(user.limited_backoffice_managed),
});
};
</script>
@@ -45,6 +45,11 @@ const reload = () => {
}
};
const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "list");
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
const redirectUserVehiclePage = (vehicleId) => {
// Send the user to the vehicle page
window.location.href = `/user/vehicles/${vehicleId}`;
@@ -146,6 +151,7 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="reg"
/>
<!-- Type -->
@@ -153,6 +159,7 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="type"
:parse-function="
(value) => {
@@ -165,6 +172,7 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="wash_subscription"
:parse-function="
(value) => {
@@ -174,7 +182,7 @@ const getProductOptionsLabel = (vehicle) => {
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription">
<template v-if="object.wash_subscription && canEditVehicle()">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
@@ -218,13 +226,14 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="reference"
/>
<!-- Actions -->
<td>
<td v-if="!props.compact">
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="!props.compact"
v-if="canShowVehicleRowActions()"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
@@ -232,12 +241,14 @@ const getProductOptionsLabel = (vehicle) => {
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
v-if="canViewVehicle()"
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
v-if="canDeleteVehicle()"
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
@@ -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
@@ -5,7 +5,9 @@ import Swal from "sweetalert2";
//
export const showEditUserForm = async (id, customer_number, display_name, role) => {
export const showEditUserForm = async (id, customer_number, display_name, role, options = {}) => {
const lockedRoleFields = options.limitedBackofficeManaged ? 'disabled' : '';
await Swal.fire({
title: 'Edit User',
html: `
@@ -13,7 +15,7 @@ export const showEditUserForm = async (id, customer_number, display_name, role)
<div class="field">
<label class="label">Customer Number</label>
<div class="control">
<input class="input" type="text" id="customer_number" value="${customer_number}" />
<input class="input" type="text" id="customer_number" value="${customer_number}" ${lockedRoleFields} />
</div>
</div>
<div class="field">
@@ -25,7 +27,7 @@ export const showEditUserForm = async (id, customer_number, display_name, role)
<div class="field">
<label class="label">Role</label>
<div class="control">
<input class="input" type="text" id="role" value="${role}" />
<input class="input" type="text" id="role" value="${role}" ${lockedRoleFields} />
</div>
</div>
<div class="field">
@@ -50,4 +52,4 @@ export const showEditUserForm = async (id, customer_number, display_name, role)
});
}
</script>
</script>
@@ -75,19 +75,11 @@ const items = computed<NavigationItemProps[]>(() => [
* ]
* }
*/
{
label: t('templates.limited_backoffice.title'),
to: `/backoffice`,
type: 'link',
permissions: ['limited_backoffice_access'],
hidden: hasPermission('superuser'),
},
{
label: t('common.settings'),
to: `/user/profile`,
type: 'link',
permissions: ['user'],
hidden: !window.location.pathname.startsWith('/user')
hidden: !window.location.pathname.startsWith('/user') || !SessionUser.authenticated.value
},
{
label: t('common.logout'),
@@ -51,7 +51,7 @@ const items = computed<NavigationItemProps[]>(() => [
to: `/user`,
type: 'link',
// Dashboard visible to regular users and all subusers (subusers always have at least one grant)
permissions: ['user', 'BOOKINGS_LIST', 'ORDERS_LIST', 'VEHICLES_LIST', 'SELFSERVE_LIST', 'SUBUSERS_LIST'],
permissions: ['user', 'BOOKINGS_LIST', 'BOOKINGS_ADD', 'ORDERS_LIST', 'VEHICLES_LIST', 'VEHICLES_ADD', 'SELFSERVE_LIST', 'SUBUSERS_LIST'],
},
{
label: t('common.vehicles'),
@@ -89,6 +89,66 @@ const SUBUSER_MANAGEMENT_PERMISSIONS = {
},
};
const CUSTOMER_FEATURE_PERMISSIONS = {
vehicles: {
list: {
customer: ["user", "list_own_vehicles"],
subuser: ["VEHICLES_LIST"],
},
add: {
customer: ["user", "add_vehicle"],
subuser: ["VEHICLES_ADD"],
},
edit: {
customer: ["edit_vehicle", "add_vehicle"],
subuser: ["VEHICLES_EDIT"],
},
delete: {
customer: ["delete_vehicle"],
subuser: ["VEHICLES_DELETE"],
},
},
bookings: {
list: {
customer: ["user", "list_own_bookings"],
subuser: ["BOOKINGS_LIST"],
},
add: {
customer: ["user"],
subuser: ["BOOKINGS_ADD"],
},
edit: {
customer: ["edit_own_bookings"],
subuser: ["BOOKINGS_EDIT"],
},
delete: {
customer: ["delete_own_bookings"],
subuser: ["BOOKINGS_DELETE"],
},
},
orders: {
list: {
customer: ["user", "list_own_orders"],
subuser: ["ORDERS_LIST"],
},
edit: {
customer: ["edit_own_orders"],
subuser: ["ORDERS_EDIT"],
},
},
selfserve: {
list: {
customer: ["user", "list_own_department_selfserve_vehicle_conditions"],
subuser: ["SELFSERVE_LIST"],
},
add: {
customer: ["add_own_department_selfserve_vehicle_conditions"],
subuser: ["SELFSERVE_ADD"],
},
},
subusers: SUBUSER_MANAGEMENT_PERMISSIONS,
};
const getStoredSessionSnapshot = () => {
if (typeof window === "undefined") {
return null;
@@ -858,6 +918,13 @@ export const SessionUser = {
}
return SessionUser.permissions.value.includes(permission) || SessionUser.permissions.value.includes("superuser");
},
hasAnyPermission: (permissions = []) => {
const permissionList = Array.isArray(permissions) ? permissions : [permissions].filter(Boolean);
if (permissionList.length === 0) {
return true;
}
return permissionList.some((permission) => SessionUser.hasPermission(permission));
},
/** Can access shortcuts */
canAccessAdmin: () => {
return SessionUser.hasPermission("admin") || SessionUser.hasPermission("superuser");
@@ -877,6 +944,15 @@ export const SessionUser = {
? SessionUser.hasPermission(permissions.subuser)
: SessionUser.hasPermission(permissions.customer);
},
canAccessCustomerFeature: (feature, action = "list") => {
const permissions = CUSTOMER_FEATURE_PERMISSIONS[feature]?.[action];
if (!permissions) {
return false;
}
const permissionList = SessionUser.isSubuser.value ? permissions.subuser : permissions.customer;
return SessionUser.hasAnyPermission(permissionList);
},
canAccessDepartment: (id = 0) => {
return SessionUser.hasPermission("department_access_" + parseInt(id)) || SessionUser.canAccessSuperUser();
},
@@ -444,10 +444,14 @@ export const CollectedOrderInvoices = {
});
},
split_by_month: async (dateFrom, dateTo, options = {}) => {
const invoiceCollectionIds = Array.isArray(options.invoiceCollectionIds)
? options.invoiceCollectionIds
: options.invoice_collection_ids;
return authenticatedRequest('/collected-invoices/split-by-month', 'POST', {
dateFrom,
dateTo,
...(options.preview !== undefined ? { preview: !!options.preview } : {}),
...(Array.isArray(invoiceCollectionIds) ? { invoice_collection_ids: invoiceCollectionIds } : {}),
}).then((response) => {
console.log(response);
return response;
@@ -32,6 +32,23 @@ const normalizeDateTimeLocalValue = (value) => {
return `${datePart}T${timePart.slice(0, 5)}`;
};
const sanitizeQueryParams = (params = {}) => {
if (!params || typeof params !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== null && value !== undefined)
);
};
const buildQueryString = (params = {}) => {
const queryParams = sanitizeQueryParams(params);
const queryString = new URLSearchParams(queryParams).toString();
return queryString ? `?${queryString}` : '';
};
/**
* The Global Objects object, which contains global functions for objects.
*/
@@ -431,20 +448,15 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
object: async (endpoint, id, optionsObject = {}) => {
const cacheKey = JSON.stringify({endpoint, id, optionsObject});
const requestParams = typeof id === 'object' && id !== null
? sanitizeQueryParams(id)
: {id};
const cacheKey = JSON.stringify({endpoint, id: requestParams, optionsObject});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
let requestEndpoint = endpoint;
if (typeof id === 'object') {
// If the id is an object, we need to convert it to a query string
const params = new URLSearchParams(id).toString();
requestEndpoint = `${endpoint}?${params}`;
} else {
// If the id is a number, we can just append it to the endpoint
requestEndpoint = `${endpoint}?id=${id}`;
}
const requestEndpoint = `${endpoint}${buildQueryString(requestParams)}`;
let options = ObjectsGlobal.requestOptions.applyOptions(optionsObject);
let promise;
if (options.authenticated) {
@@ -499,23 +511,24 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
objects: async (endpoint, data = {}) => {
const cacheKey = JSON.stringify({endpoint, data});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
if (data.filters && typeof data.filters === 'object') {
data.filters = Object.entries(data.filters).flatMap(([key, value]) => {
const requestData = sanitizeQueryParams(data);
if (requestData.filters && typeof requestData.filters === 'object') {
requestData.filters = Object.entries(requestData.filters).flatMap(([key, value]) => {
if (Array.isArray(value)) {
return value.map(v => `${key}:${v}`);
}
return `${key}:${value}`;
}).join(',');
}
const cacheKey = JSON.stringify({endpoint, data: requestData});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
const promise = authenticatedRequest(
endpoint,
"GET",
data
requestData
).then((response) => {
return response.data.data;
}).catch((error) => {
@@ -8,6 +8,17 @@ import i18n from '@/i18n';
const t = (key) => i18n.global.t(key);
const normalizeProductQueryOptions = (options = {}) => {
const queryOptions = { ...options };
if (!Object.prototype.hasOwnProperty.call(queryOptions, "final_price")) {
queryOptions.final_price = false;
}
return Object.fromEntries(
Object.entries(queryOptions).filter(([, value]) => value !== null && value !== undefined)
);
};
/**
* Local products cache
* @type {ref<null>}
@@ -269,19 +280,19 @@ export const Products = {
},
},
get: {
all: async (options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, {...options});
all: async (options = {}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, normalizeProductQueryOptions(options));
},
single: async (id, options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...options});
single: async (id, options = {}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...normalizeProductQueryOptions(options)});
},
category: async (category_id, department_id, customer_id = null, final_price = false) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {
return ObjectsGlobal.get.object(Products.meta.endpoint, normalizeProductQueryOptions({
category: category_id,
department_id: department_id,
...(customer_id !== null ? {customer_id: customer_id} : {}),
...{final_price: final_price}
});
}));
},
},
delete: async (id) => {
@@ -324,4 +335,4 @@ export const Products = {
);
},
};
</script>
</script>
+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),
@@ -6,9 +6,8 @@ import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/men
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
import HeaderAccessShortcuts from "@/components/viewport/page/headers/HeaderAccessShortcuts.vue";
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -41,20 +40,7 @@ const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() &&
<!-- User account name and avatar (Desktop) -->
<div class="navbar-item mr-1">
<div class="buttons">
<!-- Back-Office button -->
<router-link class="button is-white" to="/superuser" v-if="SessionUser.canAccessSuperUser() && !route.path.includes('/superuser')">
<span class="icon">
<i class="fas fa-tools" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('header.backoffice') }}</span>
</router-link>
<!-- Department button -->
<router-link class="button is-white" to="/admin" v-if="SessionUser.canAccessAdmin() && !route.path.includes('/admin')">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('common.departments') }}</span>
</router-link>
<HeaderAccessShortcuts />
<!-- Return to own account if impersonating -->
<template v-if="SessionUser.hasSuperUserToken()">
<router-link class="button is-white" to="/auth/return">
@@ -63,11 +63,7 @@ const navigationItems = computed<NavigationMenuItem[]>(() => {
});
const hasPermission = (item: NavigationMenuItem) => {
if (!item.permissions?.length) {
return true;
}
return item.permissions.some((permission) => SessionUser.hasPermission(permission));
return SessionUser.hasAnyPermission(item.permissions ?? []);
};
const isItemVisible = (item: NavigationMenuItem) => !item.hidden && hasPermission(item);
@@ -0,0 +1,99 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { IS_DEV } from "@/config.js";
import SessionUser from "@/components/session/token/SessionUser.vue";
import {
getAdminDepartmentIdFromRoute,
getAdminDepartmentRoute,
getLimitedBackofficeDepartmentIdFromRoute,
getLimitedBackofficeDepartmentRoute,
} from "@/components/viewport/page/headers/headerShortcuts.js";
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const adminDepartmentId = computed(() => getAdminDepartmentIdFromRoute(route));
const backofficeDepartmentId = computed(() => getLimitedBackofficeDepartmentIdFromRoute(route));
const canAccessDepartment = (departmentId) =>
Number.isInteger(departmentId) && departmentId > 0 && SessionUser.canAccessDepartment(departmentId);
const canShowSuperuserBackoffice = computed(
() => SessionUser.canAccessSuperUser() && !route.path.includes("/superuser")
);
const canShowLimitedBackoffice = computed(() => {
const departmentId = adminDepartmentId.value;
return (
canAccessDepartment(departmentId) &&
SessionUser.hasPermission("limited_backoffice_access") &&
!SessionUser.canAccessSuperUser()
);
});
const limitedBackofficeTarget = computed(() => getLimitedBackofficeDepartmentRoute(adminDepartmentId.value));
const adminShortcutTarget = computed(() =>
backofficeDepartmentId.value ? getAdminDepartmentRoute(backofficeDepartmentId.value) : "/admin"
);
const canShowAdminShortcut = computed(() => {
if (!SessionUser.canAccessAdmin() || route.path.includes("/admin")) {
return false;
}
const departmentId = backofficeDepartmentId.value;
return departmentId === null || canAccessDepartment(departmentId);
});
</script>
<template>
<router-link
v-if="canShowSuperuserBackoffice"
class="button is-white"
to="/superuser"
data-testid="superuser-backoffice-header-button"
>
<span class="icon">
<i class="fas fa-tools" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("header.backoffice") }}</span>
</router-link>
<router-link
v-if="canShowLimitedBackoffice"
class="button is-white"
:to="limitedBackofficeTarget"
data-testid="limited-backoffice-header-button"
>
<span class="icon">
<i class="fas fa-tools" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("templates.limited_backoffice.title") }}</span>
</router-link>
<router-link
v-if="canShowAdminShortcut"
class="button is-white"
:to="adminShortcutTarget"
data-testid="admin-header-button"
>
<span class="icon">
<i class="fas fa-building" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("common.departments") }}</span>
</router-link>
</template>
<style scoped>
.transform-color-black {
filter: invert(100%) grayscale(100%) brightness(0) contrast(100%);
}
.transform-color-red {
filter: invert(27%) sepia(96%) saturate(7493%) hue-rotate(357deg) brightness(103%) contrast(101%);
}
</style>
@@ -5,9 +5,8 @@ import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/
import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/menu/CollectedInvoiceQueueMonitor.vue";
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
import HeaderAccessShortcuts from "@/components/viewport/page/headers/HeaderAccessShortcuts.vue";
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -41,20 +40,7 @@ const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() &&
<!-- User account name and avatar (Desktop) -->
<div class="navbar-item mr-1">
<div class="buttons">
<!-- Back-Office button -->
<router-link class="button is-white" to="/superuser" v-if="SessionUser.canAccessSuperUser() && !route.path.includes('/superuser')">
<span class="icon">
<i class="fas fa-tools" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('header.backoffice') }}</span>
</router-link>
<!-- Department button -->
<router-link class="button is-white" to="/admin" v-if="SessionUser.canAccessAdmin() && !route.path.includes('/admin')">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('common.departments') }}</span>
</router-link>
<HeaderAccessShortcuts />
<!-- Return to own account if impersonating -->
<template v-if="SessionUser.hasSuperUserToken()">
<router-link class="button is-white" to="/auth/return">
@@ -0,0 +1,31 @@
const parsePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getPath = (route) => String(route?.path ?? "");
export const getAdminDepartmentIdFromRoute = (route) => {
const path = getPath(route);
const fromPath = parsePositiveInteger(path.match(/^\/admin\/(\d+)(?:\/|$)/)?.[1]);
if (fromPath !== null) {
return fromPath;
}
return path.startsWith("/admin") ? parsePositiveInteger(route?.params?.departmentId) : null;
};
export const getLimitedBackofficeDepartmentIdFromRoute = (route) => {
const path = getPath(route);
const fromPath = parsePositiveInteger(path.match(/^\/backoffice\/departments\/(\d+)(?:\/|$)/)?.[1]);
if (fromPath !== null) {
return fromPath;
}
return path.startsWith("/backoffice/departments") ? parsePositiveInteger(route?.params?.departmentId) : null;
};
export const getLimitedBackofficeDepartmentRoute = (departmentId) =>
`/backoffice/departments/${encodeURIComponent(String(departmentId))}/prices`;
export const getAdminDepartmentRoute = (departmentId) => `/admin/${encodeURIComponent(String(departmentId))}`;
@@ -43,23 +43,24 @@ const redirect = async (to: string) => {
}
};
// Permissions and visibility logic
const hasPermission = (permission: string) => {
return SessionUser.hasPermission(permission);
}
const hasDepartmentIdInPath = (path: string) => {
const departmentRegex = /\/admin\/\d+/;
return departmentRegex.test(path);
}
const hasItemPermissions = (item: any) => {
return SessionUser.hasAnyPermission(item?.permissions ?? []);
}
const hasPermissions = computed(() => {
if (!props.item.permissions || props.item.permissions.length === 0) {
return true; // No permissions required, item is visible
}
return props.item.permissions.some(hasPermission);
return hasItemPermissions(props.item);
});
const isVisible = () => {
if (props.item.hidden) {
return false;
}
switch (props.item.type) {
case 'category':
// Check if any child item is visible
@@ -96,10 +97,14 @@ const hasChildren = computed(() => {
});
const isChildVisible = (child: any) => {
if (child.type === 'department') {
return hasPermissions.value && hasDepartmentIdInPath(router.currentRoute.value.path);
if (child.hidden) {
return false;
}
return hasPermissions.value;
if (child.type === 'department') {
return hasItemPermissions(child) && hasDepartmentIdInPath(router.currentRoute.value.path);
}
return hasItemPermissions(child);
}
const hasVisibleChildren = computed(() => {
@@ -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;
};
+62 -4
View File
@@ -1176,6 +1176,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1190,6 +1193,7 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -1216,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": {
@@ -1246,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'}",
@@ -1700,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'}.",
@@ -3248,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'}",
@@ -4043,6 +4056,15 @@
"preview_title": "@:{'words.generated.forhandsvis'} @:{'words.generated.manedsopdeling'}",
"success_text": "@:{'words.generated.behandlede'} {processed} @:{'words.generated.fakturasamlinger'}. Opdelte {changed} @:{'words.generated.og'} sprang {skipped} @:{'words.generated.over'}.",
"success_title": "@.capitalize:{'words.generated.manedsopdeling'} @:{'words.generated.fuldført'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturer sammen",
"split_by_month": "Opdel efter måned",
"split_error_title": "Månedsopdeling mislykkedes",
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
"split_success_title": "Månedsopdeling fuldført",
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
"title": "Ordrer fra flere måneder"
}
},
"invoicing": {
@@ -5742,6 +5764,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "@:{'templates.generated.compat.global.other'}",
"overview": {
"active_vehicles": "Aktive køretøjer",
"attributes": "Attributter",
"connected": "Forbundet",
"customer_management": "Kundestyring",
"customer_number_required": "Et kundenummer er påkrævet før kundeindstillinger kan administreres.",
"customer_rules": "Kunderegler",
"effective_access": "Effektiv adgang",
"loaded": "Indlæst",
"load_failed": "Brugerdata kunne ikke indlæses",
"loading_user": "Indlæser bruger",
"management_hub": "Administrationshub",
"missing": "Mangler",
"more_permissions": "+{count} flere tilladelser",
"no_subscription_transactions": "Der er ikke registreret vaskeabonnementstransaktioner for kunden.",
"not_invoiced": "Ikke faktureret",
"open_draft": "Åben kladde",
"open_vehicles": "Åbn køretøjer",
"price_overrides": "Prisoverstyringer",
"pricing": "Priser",
"subscription_invoicing": "Abonnementsfakturering",
"subtitle": "Overblik over kundedata, adgang, priser, køretøjer og abonnementsfakturering.",
"vehicles_load_failed": "Køretøjer kunne ikke indlæses.",
"workspace_shortcuts": "Arbejdsgange"
},
"permissions": "@.capitalize:{'words.generated.tilladelser'}",
"user_data": "@.capitalize:{'words.generated.brugerdata'}",
"user_id": "@.capitalize:{'words.generated.bruger'} @.upper:{'words.generated.id'}",
@@ -6026,6 +6073,9 @@
"title": "@.capitalize:{'words.generated.afdelingspriser'}",
"save": "@:{'words.generated.gem'} @:{'words.generated.priser'}",
"saved": "Priserne @:{'words.generated.er'} @:{'words.generated.gemt'}.",
"autosave_saving": "Gemmer priser...",
"autosave_pending": "Prisændringer afventer.",
"autosave_saved": "Alle prisændringer er gemt.",
"product": "Produkt",
"price": "Afdelingspris",
"price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.skal'} have @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.",
@@ -6040,6 +6090,7 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbejder'}",
"name": "Navn",
"email": "Email",
"user_id": "Bruger-id",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
@@ -6066,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": {
@@ -6096,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",
+62 -4
View File
@@ -1287,6 +1287,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1301,6 +1304,7 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -1327,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": {
@@ -1357,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'}",
@@ -1811,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'}.",
@@ -3359,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'}",
@@ -4147,6 +4160,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Zusammen abrechnen",
"split_by_month": "Nach Monat aufteilen",
"split_error_title": "Monatsaufteilung fehlgeschlagen",
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
"split_success_title": "Monatsaufteilung abgeschlossen",
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
"title": "Aufträge aus mehreren Monaten"
}
},
"invoicing": {
@@ -5845,6 +5867,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "@.capitalize:{'words.generated.andere'}",
"overview": {
"active_vehicles": "Aktive Fahrzeuge",
"attributes": "Attribute",
"connected": "Verbunden",
"customer_management": "Kundenverwaltung",
"customer_number_required": "Eine Kundennummer ist erforderlich, bevor Kundeneinstellungen verwaltet werden können.",
"customer_rules": "Kundenregeln",
"effective_access": "Effektiver Zugriff",
"loaded": "Geladen",
"load_failed": "Benutzerdaten konnten nicht geladen werden",
"loading_user": "Benutzer wird geladen",
"management_hub": "Verwaltung",
"missing": "Fehlt",
"more_permissions": "+{count} weitere Berechtigungen",
"no_subscription_transactions": "Für diesen Kunden sind keine Waschabonnement-Transaktionen registriert.",
"not_invoiced": "Nicht fakturiert",
"open_draft": "Offener Entwurf",
"open_vehicles": "Fahrzeuge öffnen",
"price_overrides": "Preisüberschreibungen",
"pricing": "Preise",
"subscription_invoicing": "Abonnementabrechnung",
"subtitle": "Überblick über Kundendaten, Zugriff, Preise, Fahrzeuge und Abonnementabrechnung.",
"vehicles_load_failed": "Fahrzeuge konnten nicht geladen werden.",
"workspace_shortcuts": "Arbeitsbereich"
},
"permissions": "Tilladelser",
"user_data": "Brugerdata",
"user_id": "@:{'words.generated.benutzer'}-@.upper:{'words.generated.id'}",
@@ -6115,6 +6162,9 @@
"title": "@.capitalize:{'words.generated.abteilungspreise'}",
"save": "@:{'words.generated.preise'} @:{'words.generated.speichern'}",
"saved": "@:{'words.generated.preise'} @:{'words.generated.gespeichert'}.",
"autosave_saving": "Preise werden gespeichert...",
"autosave_pending": "Preisaenderungen ausstehend.",
"autosave_saved": "Alle Preisaenderungen gespeichert.",
"product": "@.capitalize:{'words.generated.produkt'}",
"price": "Abteilungspreis",
"price_required": "Jedes @.capitalize:{'words.generated.produkt'} @:{'words.generated.muss'} einen @:{'words.generated.nicht'}-negativen numerischen Preis @:{'words.generated.haben'}.",
@@ -6129,6 +6179,7 @@
"edit_title": "@:{'words.generated.mitarbeiter'} @:{'words.generated.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"user_id": "Benutzer-ID",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
@@ -6155,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": {
@@ -6185,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",
+62 -4
View File
@@ -1008,6 +1008,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1022,6 +1025,7 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -1048,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": {
@@ -1078,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'}",
@@ -1532,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'}.",
@@ -3080,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'}",
@@ -3875,6 +3888,15 @@
"preview_title": "@.capitalize:{'words.generated.preview'} @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Invoice together",
"split_by_month": "Split by month",
"split_error_title": "Monthly split failed",
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
"split_success_title": "Monthly split completed",
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
"title": "Orders from multiple months"
}
},
"invoicing": {
@@ -5574,6 +5596,31 @@
"transaction_history": "@.capitalize:{'words.generated.transaction'} @:{'words.generated.history'}"
},
"other": "@:{'templates.generated.compat.global.other'}",
"overview": {
"active_vehicles": "Active vehicles",
"attributes": "Attributes",
"connected": "Connected",
"customer_management": "Customer management",
"customer_number_required": "A customer number is required before customer settings can be managed.",
"customer_rules": "Customer rules",
"effective_access": "Effective access",
"loaded": "Loaded",
"load_failed": "User data could not be loaded",
"loading_user": "Loading user",
"management_hub": "Management hub",
"missing": "Missing",
"more_permissions": "+{count} more permissions",
"no_subscription_transactions": "No wash subscription transactions are registered for this customer.",
"not_invoiced": "Not invoiced",
"open_draft": "Open draft",
"open_vehicles": "Open vehicles",
"price_overrides": "Price overrides",
"pricing": "Pricing",
"subscription_invoicing": "Subscription invoicing",
"subtitle": "Overview of customer data, access, pricing, vehicles, and subscription invoicing.",
"vehicles_load_failed": "Vehicles could not be loaded.",
"workspace_shortcuts": "Workspace shortcuts"
},
"permissions": "@.capitalize:{'words.generated.permissions'}",
"user_data": "@.capitalize:{'words.generated.user'} @:{'words.generated.data'}",
"user_id": "@.capitalize:{'words.generated.user'} @.upper:{'words.generated.id'}",
@@ -5858,6 +5905,9 @@
"title": "@.capitalize:{'words.generated.department'} @:{'words.generated.prices'}",
"save": "@.capitalize:{'words.generated.save'} @:{'words.generated.prices'}",
"saved": "@.capitalize:{'words.generated.prices'} @:{'words.generated.saved'}.",
"autosave_saving": "Saving prices...",
"autosave_pending": "Price changes pending.",
"autosave_saved": "All price changes saved.",
"product": "@.capitalize:{'words.generated.product'}",
"price": "@.capitalize:{'words.generated.department'} @:{'words.generated.price'}",
"price_required": "Every @:{'words.generated.product'} @:{'words.generated.must'} @:{'words.generated.have'} @:{'words.generated.a'} non-negative numeric @:{'words.generated.price'}.",
@@ -5872,6 +5922,7 @@
"edit_title": "@.capitalize:{'words.generated.edit'} @:{'words.generated.employee'}",
"name": "Name",
"email": "Email",
"user_id": "User ID",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
@@ -5898,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": {
@@ -5928,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",
+36
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'}",
@@ -3156,6 +3158,15 @@
"preview_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.preview_title'}",
"success_text": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_text'}",
"success_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_title'}"
},
"multi_month_invoice_warning": {
"invoice_together": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
"split_by_month": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
"split_error_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
"split_success_text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
"split_success_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
"text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.text'}",
"title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.title'}"
}
},
"invoicing": {
@@ -5621,6 +5632,31 @@
"transaction_history": "@:{'templates.generated.compat.user_admin.orders.transaction_history'}"
},
"other": "@:{'templates.generated.compat.user_admin.other'}",
"overview": {
"active_vehicles": "@:{'templates.generated.compat.user_admin.overview.active_vehicles'}",
"attributes": "@:{'templates.generated.compat.user_admin.overview.attributes'}",
"connected": "@:{'templates.generated.compat.user_admin.overview.connected'}",
"customer_management": "@:{'templates.generated.compat.user_admin.overview.customer_management'}",
"customer_number_required": "@:{'templates.generated.compat.user_admin.overview.customer_number_required'}",
"customer_rules": "@:{'templates.generated.compat.user_admin.overview.customer_rules'}",
"effective_access": "@:{'templates.generated.compat.user_admin.overview.effective_access'}",
"loaded": "@:{'templates.generated.compat.user_admin.overview.loaded'}",
"load_failed": "@:{'templates.generated.compat.user_admin.overview.load_failed'}",
"loading_user": "@:{'templates.generated.compat.user_admin.overview.loading_user'}",
"management_hub": "@:{'templates.generated.compat.user_admin.overview.management_hub'}",
"missing": "@:{'templates.generated.compat.user_admin.overview.missing'}",
"more_permissions": "@:{'templates.generated.compat.user_admin.overview.more_permissions'}",
"no_subscription_transactions": "@:{'templates.generated.compat.user_admin.overview.no_subscription_transactions'}",
"not_invoiced": "@:{'templates.generated.compat.user_admin.overview.not_invoiced'}",
"open_draft": "@:{'templates.generated.compat.user_admin.overview.open_draft'}",
"open_vehicles": "@:{'templates.generated.compat.user_admin.overview.open_vehicles'}",
"price_overrides": "@:{'templates.generated.compat.user_admin.overview.price_overrides'}",
"pricing": "@:{'templates.generated.compat.user_admin.overview.pricing'}",
"subscription_invoicing": "@:{'templates.generated.compat.user_admin.overview.subscription_invoicing'}",
"subtitle": "@:{'templates.generated.compat.user_admin.overview.subtitle'}",
"vehicles_load_failed": "@:{'templates.generated.compat.user_admin.overview.vehicles_load_failed'}",
"workspace_shortcuts": "@:{'templates.generated.compat.user_admin.overview.workspace_shortcuts'}"
},
"permissions": "@:{'templates.generated.compat.user_admin.permissions'}",
"subtitle": "@:user_admin.user_data",
"title": "@:{'templates.generated.compat.common.user'}",
+62 -4
View File
@@ -1290,6 +1290,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1304,6 +1307,7 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -1330,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": {
@@ -1360,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'}",
@@ -1814,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'}.",
@@ -3362,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'}",
@@ -4150,6 +4163,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturer samlet",
"split_by_month": "Del opp etter måned",
"split_error_title": "Månedsdeling mislyktes",
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
"split_success_title": "Månedsdeling fullført",
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
"title": "Ordrer fra flere måneder"
}
},
"invoicing": {
@@ -5848,6 +5870,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "@:{'templates.generated.compat.global.other'}",
"overview": {
"active_vehicles": "Aktive kjøretøy",
"attributes": "Attributter",
"connected": "Tilkoblet",
"customer_management": "Kundestyring",
"customer_number_required": "Et kundenummer kreves før kundeinnstillinger kan administreres.",
"customer_rules": "Kunderegler",
"effective_access": "Effektiv tilgang",
"loaded": "Lastet",
"load_failed": "Brukerdata kunne ikke lastes",
"loading_user": "Laster bruker",
"management_hub": "Administrasjon",
"missing": "Mangler",
"more_permissions": "+{count} flere tillatelser",
"no_subscription_transactions": "Ingen vaskeabonnementstransaksjoner er registrert for kunden.",
"not_invoiced": "Ikke fakturert",
"open_draft": "Åpent utkast",
"open_vehicles": "Åpne kjøretøy",
"price_overrides": "Prisoverstyringer",
"pricing": "Priser",
"subscription_invoicing": "Abonnementsfakturering",
"subtitle": "Oversikt over kundedata, tilgang, priser, kjøretøy og abonnementsfakturering.",
"vehicles_load_failed": "Kjøretøy kunne ikke lastes.",
"workspace_shortcuts": "Arbeidsområde"
},
"permissions": "@.capitalize:{'words.generated.tillatelser'}",
"user_data": "@.capitalize:{'words.generated.brukerdata'}",
"user_id": "@.capitalize:{'words.generated.bruker'}-@.upper:{'words.generated.id'}",
@@ -6118,6 +6165,9 @@
"title": "@.capitalize:{'words.generated.avdelingspriser'}",
"save": "@.capitalize:{'words.generated.lagre'} @:{'words.generated.priser'}",
"saved": "Prisene @:{'words.generated.er'} @:{'words.generated.lagret'}.",
"autosave_saving": "Lagrer priser...",
"autosave_pending": "Prisendringer venter.",
"autosave_saved": "Alle prisendringer er lagret.",
"product": "Produkt",
"price": "Avdelingspris",
"price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.ma'} ha @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.",
@@ -6132,6 +6182,7 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbeider'}",
"name": "Navn",
"email": "E-post",
"user_id": "Bruker-ID",
"phone": "Telefon",
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
"country_codes": {
@@ -6158,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": {
@@ -6188,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",
+62 -4
View File
@@ -1340,6 +1340,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1354,6 +1357,7 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -1380,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": {
@@ -1410,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'}",
@@ -1864,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'}.",
@@ -3412,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'}",
@@ -4200,6 +4213,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturera tillsammans",
"split_by_month": "Dela upp per månad",
"split_error_title": "Månadsuppdelning misslyckades",
"split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.",
"split_success_title": "Månadsuppdelning klar",
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
"title": "Ordrar från flera månader"
}
},
"invoicing": {
@@ -5898,6 +5920,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "Andet",
"overview": {
"active_vehicles": "Aktiva fordon",
"attributes": "Attribut",
"connected": "Ansluten",
"customer_management": "Kundhantering",
"customer_number_required": "Ett kundnummer krävs innan kundinställningar kan hanteras.",
"customer_rules": "Kundregler",
"effective_access": "Effektiv åtkomst",
"loaded": "Inläst",
"load_failed": "Användardata kunde inte läsas in",
"loading_user": "Läser in användare",
"management_hub": "Administration",
"missing": "Saknas",
"more_permissions": "+{count} fler behörigheter",
"no_subscription_transactions": "Inga tvättabonnemangstransaktioner är registrerade för kunden.",
"not_invoiced": "Ej fakturerat",
"open_draft": "Öppet utkast",
"open_vehicles": "Öppna fordon",
"price_overrides": "Prisöverskrivningar",
"pricing": "Priser",
"subscription_invoicing": "Abonnemangsfakturering",
"subtitle": "Översikt över kunddata, åtkomst, priser, fordon och abonnemangsfakturering.",
"vehicles_load_failed": "Fordon kunde inte läsas in.",
"workspace_shortcuts": "Arbetsyta"
},
"permissions": "Tilladelser",
"user_data": "Brugerdata",
"user_id": "Användar-@.upper:{'words.generated.id'}",
@@ -6168,6 +6215,9 @@
"title": "@.capitalize:{'words.generated.avdelningspriser'}",
"save": "@.capitalize:{'words.generated.spara'} @:{'words.generated.priser'}",
"saved": "Priserna @:{'words.generated.har'} @:{'words.generated.sparats'}.",
"autosave_saving": "Sparar priser...",
"autosave_pending": "Prisandringar vantar.",
"autosave_saved": "Alla prisandringar har sparats.",
"product": "Produkt",
"price": "Avdelningspris",
"price_required": "@.capitalize:{'words.generated.alla'} @:{'words.generated.produkter'} @:{'words.generated.maste'} ha ett icke-negativt numeriskt pris.",
@@ -6182,6 +6232,7 @@
"edit_title": "@:{'words.generated.redigera'} @:{'words.generated.medarbetare'}",
"name": "Namn",
"email": "E-post",
"user_id": "Användar-ID",
"phone": "Telefon",
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
"country_codes": {
@@ -6208,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": {
@@ -6238,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'}",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturer sammen",
"split_by_month": "Opdel efter måned",
"split_error_title": "Månedsopdeling mislykkedes",
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
"split_success_title": "Månedsopdeling fuldført",
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
"title": "Ordrer fra flere måneder"
}
}
}
}
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.compat.superuser.nav.orders'}"
},
"other": "@:{'phrases.compat.global.other'}",
"overview": {
"active_vehicles": "Aktive køretøjer",
"attributes": "Attributter",
"connected": "Forbundet",
"customer_management": "Kundestyring",
"customer_number_required": "Et kundenummer er påkrævet før kundeindstillinger kan administreres.",
"customer_rules": "Kunderegler",
"effective_access": "Effektiv adgang",
"loaded": "Indlæst",
"load_failed": "Brugerdata kunne ikke indlæses",
"loading_user": "Indlæser bruger",
"management_hub": "Administrationshub",
"missing": "Mangler",
"more_permissions": "+{count} flere tilladelser",
"no_subscription_transactions": "Der er ikke registreret vaskeabonnementstransaktioner for kunden.",
"not_invoiced": "Ikke faktureret",
"open_draft": "Åben kladde",
"open_vehicles": "Åbn køretøjer",
"price_overrides": "Prisoverstyringer",
"pricing": "Priser",
"subscription_invoicing": "Abonnementsfakturering",
"subtitle": "Overblik over kundedata, adgang, priser, køretøjer og abonnementsfakturering.",
"vehicles_load_failed": "Køretøjer kunne ikke indlæses.",
"workspace_shortcuts": "Arbejdsgange"
},
"permissions": "@.capitalize:{'terms.glossary.tilladelser'}",
"user_data": "@.capitalize:{'terms.glossary.brugerdata'}",
"user_id": "@.capitalize:{'terms.glossary.bruger'} @.upper:{'terms.glossary.id'}",
+33 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,7 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -72,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": {
@@ -102,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'}",
@@ -273,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.afdelingspriser'}",
"save": "@:{'terms.glossary.gem'} @:{'terms.glossary.priser'}",
"saved": "Priserne @:{'terms.glossary.er'} @:{'terms.glossary.gemt'}.",
"autosave_saving": "Gemmer priser...",
"autosave_pending": "Prisændringer afventer.",
"autosave_saved": "Alle prisændringer er gemt.",
"product": "Produkt",
"price": "Afdelingspris",
"price_required": "@.capitalize:{'terms.glossary.alle'} @:{'terms.glossary.produkter'} @:{'terms.glossary.skal'} have @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ikke'}-negativ numerisk pris.",
@@ -287,6 +301,7 @@
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbejder'}",
"name": "Navn",
"email": "Email",
"user_id": "Bruger-id",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
@@ -313,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": {
@@ -343,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'}",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Zusammen abrechnen",
"split_by_month": "Nach Monat aufteilen",
"split_error_title": "Monatsaufteilung fehlgeschlagen",
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
"split_success_title": "Monatsaufteilung abgeschlossen",
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
"title": "Aufträge aus mehreren Monaten"
}
}
}
}
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.compat.superuser.nav.orders'}"
},
"other": "@.capitalize:{'terms.glossary.andere'}",
"overview": {
"active_vehicles": "Aktive Fahrzeuge",
"attributes": "Attribute",
"connected": "Verbunden",
"customer_management": "Kundenverwaltung",
"customer_number_required": "Eine Kundennummer ist erforderlich, bevor Kundeneinstellungen verwaltet werden können.",
"customer_rules": "Kundenregeln",
"effective_access": "Effektiver Zugriff",
"loaded": "Geladen",
"load_failed": "Benutzerdaten konnten nicht geladen werden",
"loading_user": "Benutzer wird geladen",
"management_hub": "Verwaltung",
"missing": "Fehlt",
"more_permissions": "+{count} weitere Berechtigungen",
"no_subscription_transactions": "Für diesen Kunden sind keine Waschabonnement-Transaktionen registriert.",
"not_invoiced": "Nicht fakturiert",
"open_draft": "Offener Entwurf",
"open_vehicles": "Fahrzeuge öffnen",
"price_overrides": "Preisüberschreibungen",
"pricing": "Preise",
"subscription_invoicing": "Abonnementabrechnung",
"subtitle": "Überblick über Kundendaten, Zugriff, Preise, Fahrzeuge und Abonnementabrechnung.",
"vehicles_load_failed": "Fahrzeuge konnten nicht geladen werden.",
"workspace_shortcuts": "Arbeitsbereich"
},
"permissions": "Tilladelser",
"user_data": "Brugerdata",
"user_id": "@:{'terms.glossary.benutzer'}-@.upper:{'terms.glossary.id'}",
+33 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,7 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -72,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": {
@@ -102,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'}",
@@ -273,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.abteilungspreise'}",
"save": "@:{'terms.glossary.preise'} @:{'terms.glossary.speichern'}",
"saved": "@:{'terms.glossary.preise'} @:{'terms.glossary.gespeichert'}.",
"autosave_saving": "Preise werden gespeichert...",
"autosave_pending": "Preisaenderungen ausstehend.",
"autosave_saved": "Alle Preisaenderungen gespeichert.",
"product": "@.capitalize:{'terms.glossary.produkt'}",
"price": "Abteilungspreis",
"price_required": "Jedes @.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.muss'} einen @:{'terms.glossary.nicht'}-negativen numerischen Preis @:{'terms.glossary.haben'}.",
@@ -287,6 +301,7 @@
"edit_title": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"user_id": "Benutzer-ID",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
@@ -313,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": {
@@ -343,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'}",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Invoice together",
"split_by_month": "Split by month",
"split_error_title": "Monthly split failed",
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
"split_success_title": "Monthly split completed",
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
"title": "Orders from multiple months"
}
}
}
}
@@ -14,6 +14,31 @@
"transaction_history": "@.capitalize:{'terms.glossary.transaction'} @:{'terms.glossary.history'}"
},
"other": "@:{'phrases.compat.global.other'}",
"overview": {
"active_vehicles": "Active vehicles",
"attributes": "Attributes",
"connected": "Connected",
"customer_management": "Customer management",
"customer_number_required": "A customer number is required before customer settings can be managed.",
"customer_rules": "Customer rules",
"effective_access": "Effective access",
"loaded": "Loaded",
"load_failed": "User data could not be loaded",
"loading_user": "Loading user",
"management_hub": "Management hub",
"missing": "Missing",
"more_permissions": "+{count} more permissions",
"no_subscription_transactions": "No wash subscription transactions are registered for this customer.",
"not_invoiced": "Not invoiced",
"open_draft": "Open draft",
"open_vehicles": "Open vehicles",
"price_overrides": "Price overrides",
"pricing": "Pricing",
"subscription_invoicing": "Subscription invoicing",
"subtitle": "Overview of customer data, access, pricing, vehicles, and subscription invoicing.",
"vehicles_load_failed": "Vehicles could not be loaded.",
"workspace_shortcuts": "Workspace shortcuts"
},
"permissions": "@.capitalize:{'terms.glossary.permissions'}",
"user_data": "@.capitalize:{'terms.glossary.user'} @:{'terms.glossary.data'}",
"user_id": "@.capitalize:{'terms.glossary.user'} @.upper:{'terms.glossary.id'}",
+33 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,7 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -72,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": {
@@ -102,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'}",
@@ -273,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.prices'}",
"save": "@.capitalize:{'terms.glossary.save'} @:{'terms.glossary.prices'}",
"saved": "@.capitalize:{'terms.glossary.prices'} @:{'terms.glossary.saved'}.",
"autosave_saving": "Saving prices...",
"autosave_pending": "Price changes pending.",
"autosave_saved": "All price changes saved.",
"product": "@.capitalize:{'terms.glossary.product'}",
"price": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.price'}",
"price_required": "Every @:{'terms.glossary.product'} @:{'terms.glossary.must'} @:{'terms.glossary.have'} @:{'terms.glossary.a'} non-negative numeric @:{'terms.glossary.price'}.",
@@ -287,6 +301,7 @@
"edit_title": "@.capitalize:{'terms.glossary.edit'} @:{'terms.glossary.employee'}",
"name": "Name",
"email": "Email",
"user_id": "User ID",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
@@ -313,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": {
@@ -343,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'}",
@@ -0,0 +1,13 @@
{
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
"split_by_month": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
"split_error_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
"split_success_text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
"split_success_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
"text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.text'}",
"title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.title'}"
}
}
}
@@ -22,6 +22,31 @@
"transaction_history": "@:{'phrases.compat.user_admin.orders.transaction_history'}"
},
"other": "@:{'phrases.compat.user_admin.other'}",
"overview": {
"active_vehicles": "@:{'phrases.compat.user_admin.overview.active_vehicles'}",
"attributes": "@:{'phrases.compat.user_admin.overview.attributes'}",
"connected": "@:{'phrases.compat.user_admin.overview.connected'}",
"customer_management": "@:{'phrases.compat.user_admin.overview.customer_management'}",
"customer_number_required": "@:{'phrases.compat.user_admin.overview.customer_number_required'}",
"customer_rules": "@:{'phrases.compat.user_admin.overview.customer_rules'}",
"effective_access": "@:{'phrases.compat.user_admin.overview.effective_access'}",
"loaded": "@:{'phrases.compat.user_admin.overview.loaded'}",
"load_failed": "@:{'phrases.compat.user_admin.overview.load_failed'}",
"loading_user": "@:{'phrases.compat.user_admin.overview.loading_user'}",
"management_hub": "@:{'phrases.compat.user_admin.overview.management_hub'}",
"missing": "@:{'phrases.compat.user_admin.overview.missing'}",
"more_permissions": "@:{'phrases.compat.user_admin.overview.more_permissions'}",
"no_subscription_transactions": "@:{'phrases.compat.user_admin.overview.no_subscription_transactions'}",
"not_invoiced": "@:{'phrases.compat.user_admin.overview.not_invoiced'}",
"open_draft": "@:{'phrases.compat.user_admin.overview.open_draft'}",
"open_vehicles": "@:{'phrases.compat.user_admin.overview.open_vehicles'}",
"price_overrides": "@:{'phrases.compat.user_admin.overview.price_overrides'}",
"pricing": "@:{'phrases.compat.user_admin.overview.pricing'}",
"subscription_invoicing": "@:{'phrases.compat.user_admin.overview.subscription_invoicing'}",
"subtitle": "@:{'phrases.compat.user_admin.overview.subtitle'}",
"vehicles_load_failed": "@:{'phrases.compat.user_admin.overview.vehicles_load_failed'}",
"workspace_shortcuts": "@:{'phrases.compat.user_admin.overview.workspace_shortcuts'}"
},
"permissions": "@:{'phrases.compat.user_admin.permissions'}",
"subtitle": "@:user_admin.user_data",
"title": "@:{'phrases.compat.common.user'}",
@@ -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'}",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturer samlet",
"split_by_month": "Del opp etter måned",
"split_error_title": "Månedsdeling mislyktes",
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
"split_success_title": "Månedsdeling fullført",
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
"title": "Ordrer fra flere måneder"
}
}
}
}
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.compat.superuser.nav.orders'}"
},
"other": "@:{'phrases.compat.global.other'}",
"overview": {
"active_vehicles": "Aktive kjøretøy",
"attributes": "Attributter",
"connected": "Tilkoblet",
"customer_management": "Kundestyring",
"customer_number_required": "Et kundenummer kreves før kundeinnstillinger kan administreres.",
"customer_rules": "Kunderegler",
"effective_access": "Effektiv tilgang",
"loaded": "Lastet",
"load_failed": "Brukerdata kunne ikke lastes",
"loading_user": "Laster bruker",
"management_hub": "Administrasjon",
"missing": "Mangler",
"more_permissions": "+{count} flere tillatelser",
"no_subscription_transactions": "Ingen vaskeabonnementstransaksjoner er registrert for kunden.",
"not_invoiced": "Ikke fakturert",
"open_draft": "Åpent utkast",
"open_vehicles": "Åpne kjøretøy",
"price_overrides": "Prisoverstyringer",
"pricing": "Priser",
"subscription_invoicing": "Abonnementsfakturering",
"subtitle": "Oversikt over kundedata, tilgang, priser, kjøretøy og abonnementsfakturering.",
"vehicles_load_failed": "Kjøretøy kunne ikke lastes.",
"workspace_shortcuts": "Arbeidsområde"
},
"permissions": "@.capitalize:{'terms.glossary.tillatelser'}",
"user_data": "@.capitalize:{'terms.glossary.brukerdata'}",
"user_id": "@.capitalize:{'terms.glossary.bruker'}-@.upper:{'terms.glossary.id'}",
+33 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,7 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -72,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": {
@@ -102,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'}",
@@ -273,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.avdelingspriser'}",
"save": "@.capitalize:{'terms.glossary.lagre'} @:{'terms.glossary.priser'}",
"saved": "Prisene @:{'terms.glossary.er'} @:{'terms.glossary.lagret'}.",
"autosave_saving": "Lagrer priser...",
"autosave_pending": "Prisendringer venter.",
"autosave_saved": "Alle prisendringer er lagret.",
"product": "Produkt",
"price": "Avdelingspris",
"price_required": "@.capitalize:{'terms.glossary.alle'} @:{'terms.glossary.produkter'} @:{'terms.glossary.ma'} ha @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ikke'}-negativ numerisk pris.",
@@ -287,6 +301,7 @@
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbeider'}",
"name": "Navn",
"email": "E-post",
"user_id": "Bruker-ID",
"phone": "Telefon",
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
"country_codes": {
@@ -313,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": {
@@ -343,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'}",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturera tillsammans",
"split_by_month": "Dela upp per månad",
"split_error_title": "Månadsuppdelning misslyckades",
"split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.",
"split_success_title": "Månadsuppdelning klar",
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
"title": "Ordrar från flera månader"
}
}
}
}
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.compat.superuser.nav.orders'}"
},
"other": "Andet",
"overview": {
"active_vehicles": "Aktiva fordon",
"attributes": "Attribut",
"connected": "Ansluten",
"customer_management": "Kundhantering",
"customer_number_required": "Ett kundnummer krävs innan kundinställningar kan hanteras.",
"customer_rules": "Kundregler",
"effective_access": "Effektiv åtkomst",
"loaded": "Inläst",
"load_failed": "Användardata kunde inte läsas in",
"loading_user": "Läser in användare",
"management_hub": "Administration",
"missing": "Saknas",
"more_permissions": "+{count} fler behörigheter",
"no_subscription_transactions": "Inga tvättabonnemangstransaktioner är registrerade för kunden.",
"not_invoiced": "Ej fakturerat",
"open_draft": "Öppet utkast",
"open_vehicles": "Öppna fordon",
"price_overrides": "Prisöverskrivningar",
"pricing": "Priser",
"subscription_invoicing": "Abonnemangsfakturering",
"subtitle": "Översikt över kunddata, åtkomst, priser, fordon och abonnemangsfakturering.",
"vehicles_load_failed": "Fordon kunde inte läsas in.",
"workspace_shortcuts": "Arbetsyta"
},
"permissions": "Tilladelser",
"user_data": "Brugerdata",
"user_id": "Användar-@.upper:{'terms.glossary.id'}",
+33 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,7 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
@@ -72,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": {
@@ -102,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'}",
@@ -273,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.avdelningspriser'}",
"save": "@.capitalize:{'terms.glossary.spara'} @:{'terms.glossary.priser'}",
"saved": "Priserna @:{'terms.glossary.har'} @:{'terms.glossary.sparats'}.",
"autosave_saving": "Sparar priser...",
"autosave_pending": "Prisandringar vantar.",
"autosave_saved": "Alla prisandringar har sparats.",
"product": "Produkt",
"price": "Avdelningspris",
"price_required": "@.capitalize:{'terms.glossary.alla'} @:{'terms.glossary.produkter'} @:{'terms.glossary.maste'} ha ett icke-negativt numeriskt pris.",
@@ -287,6 +301,7 @@
"edit_title": "@:{'terms.glossary.redigera'} @:{'terms.glossary.medarbetare'}",
"name": "Namn",
"email": "E-post",
"user_id": "Användar-ID",
"phone": "Telefon",
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
"country_codes": {
@@ -313,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": {
@@ -343,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;
}
+139
View File
@@ -0,0 +1,139 @@
import Swal from "sweetalert2";
import i18n from "@/i18n";
export const MULTI_MONTH_INVOICE_ACTION = {
CONTINUE: "continue",
CANCEL: "cancel",
SPLIT: "split",
};
const toPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getDateKey = (value) => {
const rawValue = String(value ?? "").trim();
const directMatch = rawValue.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (directMatch) {
return `${directMatch[1]}-${directMatch[2]}-${directMatch[3]}`;
}
const parsedDate = new Date(rawValue);
if (Number.isNaN(parsedDate.getTime())) {
return null;
}
const year = parsedDate.getFullYear();
const month = String(parsedDate.getMonth() + 1).padStart(2, "0");
const day = String(parsedDate.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
export const getMonthKey = (value) => {
const dateKey = getDateKey(value);
return dateKey ? dateKey.slice(0, 7) : null;
};
const translate = (key, params = {}) => i18n.global.t(key, params);
const getSplitResponsePayload = (response = {}) => response?.data?.data ?? response?.data ?? {};
export const buildMultiMonthInvoiceContext = (
items = [],
{
getDate = (item) => item?.created_at ?? item?.date,
getInvoiceCollectionId = (item) => item?.invoice_collection_id,
} = {}
) => {
const months = new Set();
const dateKeys = [];
const invoiceCollectionIds = new Set();
(Array.isArray(items) ? items : []).forEach((item) => {
const dateValue = getDate(item);
const dateKey = getDateKey(dateValue);
if (dateKey) {
dateKeys.push(dateKey);
months.add(dateKey.slice(0, 7));
}
const invoiceCollectionId = toPositiveInteger(getInvoiceCollectionId(item));
if (invoiceCollectionId) {
invoiceCollectionIds.add(invoiceCollectionId);
}
});
dateKeys.sort();
return {
months: Array.from(months).sort(),
dateFrom: dateKeys[0] ?? null,
dateTo: dateKeys[dateKeys.length - 1] ?? null,
invoiceCollectionIds: Array.from(invoiceCollectionIds).sort((left, right) => left - right),
};
};
export const shouldWarnAboutMultiMonthInvoice = (context = {}) => (
Array.isArray(context.months) &&
context.months.length > 1 &&
Array.isArray(context.invoiceCollectionIds) &&
context.invoiceCollectionIds.length > 0 &&
Boolean(context.dateFrom) &&
Boolean(context.dateTo)
);
export const promptMultiMonthInvoiceWarning = async ({
context,
splitByMonth,
parseErrorMessage = (error) => error?.message ?? String(error),
} = {}) => {
if (!shouldWarnAboutMultiMonthInvoice(context)) {
return MULTI_MONTH_INVOICE_ACTION.CONTINUE;
}
const months = context.months.join(", ");
const confirmation = await Swal.fire({
icon: "warning",
title: translate("invoicing_period.multi_month_invoice_warning.title"),
text: translate("invoicing_period.multi_month_invoice_warning.text", { months }),
showCancelButton: true,
showDenyButton: true,
confirmButtonText: translate("invoicing_period.multi_month_invoice_warning.split_by_month"),
denyButtonText: translate("invoicing_period.multi_month_invoice_warning.invoice_together"),
cancelButtonText: translate("common.cancel"),
});
if (confirmation.isDenied) {
return MULTI_MONTH_INVOICE_ACTION.CONTINUE;
}
if (!confirmation.isConfirmed) {
return MULTI_MONTH_INVOICE_ACTION.CANCEL;
}
try {
const response = await splitByMonth(context.dateFrom, context.dateTo, {
invoiceCollectionIds: context.invoiceCollectionIds,
preview: false,
});
const result = getSplitResponsePayload(response);
await Swal.fire({
icon: "success",
title: translate("invoicing_period.multi_month_invoice_warning.split_success_title"),
text: translate("invoicing_period.multi_month_invoice_warning.split_success_text", {
processed: result.processed_count ?? 0,
changed: result.changed_count ?? 0,
skipped: result.skipped_count ?? 0,
}),
});
return MULTI_MONTH_INVOICE_ACTION.SPLIT;
} catch (error) {
await Swal.fire({
icon: "error",
title: translate("invoicing_period.multi_month_invoice_warning.split_error_title"),
text: parseErrorMessage(error),
});
return MULTI_MONTH_INVOICE_ACTION.CANCEL;
}
};
+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 = "";
@@ -651,6 +734,7 @@ onMounted(() => {
<table class="table is-fullwidth is-hoverable limited-employees-table" data-testid="limited-employees-table">
<thead>
<tr>
<th>{{ t("templates.limited_backoffice.employees.user_id") }}</th>
<th>{{ t("templates.limited_backoffice.employees.name") }}</th>
<th>{{ t("templates.limited_backoffice.employees.role") }}</th>
<th>{{ t("templates.limited_backoffice.employees.departments") }}</th>
@@ -660,6 +744,11 @@ onMounted(() => {
</thead>
<tbody>
<tr v-for="employee in employees" :key="employee.id" :data-testid="`limited-employee-row-${employee.id}`">
<td>
<span class="tag is-light" :data-testid="`limited-employee-user-id-${employee.id}`">
{{ employee.user_id || employee.id }}
</span>
</td>
<td>
<strong>{{ employee.display_name }}</strong>
<p v-if="employee.email" class="has-text-grey is-size-7">{{ employee.email }}</p>
@@ -691,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"
@@ -807,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>
@@ -876,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 {
@@ -38,6 +38,10 @@ const loadDepartments = async () => {
}
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
onMounted(() => {
void loadDepartments();
});
@@ -50,6 +54,7 @@ onMounted(() => {
:departments="departments"
:loading-departments="loading"
show-department-switcher
@change-department="changeDepartment"
>
<div v-if="loading" class="notification is-light" data-testid="limited-backoffice-loading">
{{ t("templates.limited_backoffice.loading") }}
+271 -50
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
@@ -16,6 +16,7 @@ import {
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const AUTOSAVE_DELAY_MS = 600;
const departments = ref([]);
const priceData = ref(null);
@@ -24,9 +25,14 @@ const loadingDepartments = ref(false);
const loadingPrices = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const setupRequired = ref(null);
const saveAttempted = ref(false);
const lastSavedPriceSnapshot = ref("");
let autosaveTimerId = null;
let saveAgainAfterCurrentRequest = false;
let nextSaveSequence = 0;
let lastAppliedSaveSequence = 0;
let nextLoadSequence = 0;
const selectedDepartmentId = computed(() => {
const departmentId = Number.parseInt(String(route.params.departmentId ?? ""), 10);
@@ -53,7 +59,122 @@ const invalidPriceCount = computed(
() => products.value.filter((product) => !isPriceValueValid(formPrices.value[product.id])).length
);
const canSave = computed(() => hasProducts.value && invalidPriceCount.value === 0 && !saving.value && !loadingPrices.value);
const buildFormPricesFromPayload = (payload) =>
productsFromPayload(payload).reduce((prices, product) => {
prices[product.id] = product.price === null || product.price === undefined ? "" : String(product.price);
return prices;
}, {});
const priceRowsFrom = (priceSource, productList = products.value) =>
productList.map((product) => ({
product_id: product.id,
price: String(priceSource[product.id] ?? "").trim(),
}));
const serializePriceRows = (rows) => JSON.stringify(rows);
const priceSnapshotFrom = (priceSource, productList = products.value) =>
serializePriceRows(priceRowsFrom(priceSource, productList));
const hydratePriceState = (payload) => {
const nextFormPrices = buildFormPricesFromPayload(payload);
const productList = productsFromPayload(payload);
priceData.value = payload;
formPrices.value = nextFormPrices;
lastSavedPriceSnapshot.value = priceSnapshotFrom(nextFormPrices, productList);
};
const currentPriceSnapshot = computed(() => (hasProducts.value ? priceSnapshotFrom(formPrices.value) : ""));
const hasUnsavedChanges = computed(
() => hasProducts.value && currentPriceSnapshot.value !== lastSavedPriceSnapshot.value
);
const canAutosave = computed(
() =>
Boolean(selectedDepartmentId.value) &&
hasProducts.value &&
invalidPriceCount.value === 0 &&
!loadingDepartments.value &&
!loadingPrices.value &&
!setupRequired.value &&
!hasForbiddenDepartment.value
);
const autosaveStatusMessage = computed(() => {
if (!hasProducts.value || loadingDepartments.value || loadingPrices.value || setupRequired.value) {
return "";
}
if (saving.value) {
return t("templates.limited_backoffice.prices.autosave_saving");
}
if (hasUnsavedChanges.value && invalidPriceCount.value === 0) {
return t("templates.limited_backoffice.prices.autosave_pending");
}
if (!hasUnsavedChanges.value && lastSavedPriceSnapshot.value !== "") {
return t("templates.limited_backoffice.prices.autosave_saved");
}
return "";
});
const autosaveStatusClass = computed(() => ({
"has-text-grey": saving.value,
"has-text-warning-dark": !saving.value && hasUnsavedChanges.value && invalidPriceCount.value === 0,
"has-text-success": !saving.value && !hasUnsavedChanges.value && lastSavedPriceSnapshot.value !== "",
}));
const autosaveStatusIcon = computed(() => {
if (saving.value) {
return "fa-spinner fa-spin";
}
return hasUnsavedChanges.value ? "fa-clock" : "fa-check";
});
const clearPendingAutosave = () => {
if (autosaveTimerId !== null) {
window.clearTimeout(autosaveTimerId);
autosaveTimerId = null;
}
};
const deduplicateCategoryProducts = (categoryList) => {
const seenProductIds = new Set();
return (Array.isArray(categoryList) ? categoryList : []).map((category) => ({
...category,
products: (Array.isArray(category.products) ? category.products : []).filter((product) => {
const productId = Number(product?.id);
if (!Number.isInteger(productId) || productId <= 0) {
return true;
}
if (seenProductIds.has(productId)) {
return false;
}
seenProductIds.add(productId);
return true;
}),
}));
};
const normalizePriceData = (payload) => {
if (!payload || typeof payload !== "object") {
return payload;
}
return {
...payload,
categories: deduplicateCategoryProducts(payload.categories),
};
};
const productsFromPayload = (payload) =>
deduplicateCategoryProducts(payload?.categories).flatMap((category) => category.products || []);
const loadDepartments = async () => {
loadingDepartments.value = true;
@@ -73,11 +194,12 @@ const loadDepartments = async () => {
};
const resetPriceState = () => {
nextLoadSequence += 1;
clearPendingAutosave();
priceData.value = null;
formPrices.value = {};
setupRequired.value = null;
successMessage.value = "";
saveAttempted.value = false;
lastSavedPriceSnapshot.value = "";
};
const loadPrices = async () => {
@@ -86,20 +208,28 @@ const loadPrices = async () => {
return;
}
const loadSequence = ++nextLoadSequence;
clearPendingAutosave();
loadingPrices.value = true;
errorMessage.value = "";
setupRequired.value = null;
successMessage.value = "";
priceData.value = null;
formPrices.value = {};
lastSavedPriceSnapshot.value = "";
try {
const response = await getLimitedBackofficeDepartmentPrices(selectedDepartmentId.value);
const payload = unwrapLimitedBackofficeResponse(response) || null;
priceData.value = payload;
formPrices.value = productsFromPayload(payload).reduce((prices, product) => {
prices[product.id] = product.price === null || product.price === undefined ? "" : String(product.price);
return prices;
}, {});
if (loadSequence !== nextLoadSequence) {
return;
}
const payload = normalizePriceData(unwrapLimitedBackofficeResponse(response) || null);
hydratePriceState(payload);
} catch (error) {
if (loadSequence !== nextLoadSequence) {
return;
}
const payload = error?.response?.data?.data || error?.response?.data || {};
if (Number(error?.response?.status) === 409 && payload?.code === "department_price_setup_required") {
setupRequired.value = payload;
@@ -107,6 +237,8 @@ const loadPrices = async () => {
department: payload.department || selectedDepartment.value,
categories: [],
};
formPrices.value = {};
lastSavedPriceSnapshot.value = "";
} else {
errorMessage.value = limitedBackofficeErrorMessage(
error,
@@ -114,45 +246,113 @@ const loadPrices = async () => {
);
}
} finally {
loadingPrices.value = false;
if (loadSequence === nextLoadSequence) {
loadingPrices.value = false;
}
}
};
const productsFromPayload = (payload) =>
(Array.isArray(payload?.categories) ? payload.categories : []).flatMap((category) => category.products || []);
const applySuccessfulSavePayload = (updatedPayload, requestedRows, requestedSnapshot) => {
lastSavedPriceSnapshot.value = requestedSnapshot;
const savePrices = async () => {
saveAttempted.value = true;
successMessage.value = "";
errorMessage.value = "";
if (!canSave.value || !selectedDepartmentId.value) {
if (!updatedPayload || currentPriceSnapshot.value !== requestedSnapshot) {
return;
}
const requestedPriceLookup = requestedRows.reduce((lookup, row) => {
lookup[row.product_id] = row.price;
return lookup;
}, {});
const productList = productsFromPayload(updatedPayload);
const nextFormPrices = productList.reduce((prices, product) => {
prices[product.id] =
requestedPriceLookup[product.id] ??
(product.price === null || product.price === undefined ? "" : String(product.price));
return prices;
}, {});
priceData.value = updatedPayload;
formPrices.value = nextFormPrices;
lastSavedPriceSnapshot.value = priceSnapshotFrom(nextFormPrices, productList);
};
const savePrices = async () => {
clearPendingAutosave();
if (!hasUnsavedChanges.value) {
return true;
}
if (!canAutosave.value) {
return false;
}
if (saving.value) {
saveAgainAfterCurrentRequest = true;
return false;
}
const departmentId = selectedDepartmentId.value;
const requestedRows = priceRowsFrom(formPrices.value);
const requestedSnapshot = serializePriceRows(requestedRows);
const saveSequence = ++nextSaveSequence;
let saveCompleted = false;
errorMessage.value = "";
saving.value = true;
try {
const payload = products.value.map((product) => ({
product_id: product.id,
price: String(formPrices.value[product.id]).trim(),
}));
const response = await updateLimitedBackofficeDepartmentPrices(selectedDepartmentId.value, payload);
const updatedPayload = unwrapLimitedBackofficeResponse(response) || null;
priceData.value = updatedPayload;
formPrices.value = productsFromPayload(updatedPayload).reduce((prices, product) => {
prices[product.id] = product.price === null || product.price === undefined ? "" : String(product.price);
return prices;
}, {});
saveAttempted.value = false;
successMessage.value = t("templates.limited_backoffice.prices.saved");
const response = await updateLimitedBackofficeDepartmentPrices(departmentId, requestedRows);
const updatedPayload = normalizePriceData(unwrapLimitedBackofficeResponse(response) || null);
if (selectedDepartmentId.value === departmentId && saveSequence >= lastAppliedSaveSequence) {
lastAppliedSaveSequence = saveSequence;
applySuccessfulSavePayload(updatedPayload, requestedRows, requestedSnapshot);
}
saveCompleted = true;
return true;
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(error, t("templates.limited_backoffice.errors.save_prices"));
if (selectedDepartmentId.value === departmentId) {
errorMessage.value = limitedBackofficeErrorMessage(error, t("templates.limited_backoffice.errors.save_prices"));
}
return false;
} finally {
saving.value = false;
const shouldSaveAgain = saveAgainAfterCurrentRequest;
saveAgainAfterCurrentRequest = false;
if (saveCompleted && (shouldSaveAgain || hasUnsavedChanges.value) && canAutosave.value) {
void savePrices();
}
}
};
const scheduleAutosave = () => {
clearPendingAutosave();
if (!hasUnsavedChanges.value || !canAutosave.value) {
return;
}
autosaveTimerId = window.setTimeout(() => {
autosaveTimerId = null;
void savePrices();
}, AUTOSAVE_DELAY_MS);
};
const flushAutosave = async () => {
return savePrices();
};
const changeDepartment = async (departmentId) => {
const flushed = await flushAutosave();
if (!flushed && hasUnsavedChanges.value) {
return;
}
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
@@ -167,6 +367,22 @@ watch(
await loadPrices();
}
);
watch(
formPrices,
() => {
scheduleAutosave();
},
{ deep: true }
);
onBeforeUnmount(() => {
clearPendingAutosave();
if (hasUnsavedChanges.value && canAutosave.value) {
void savePrices();
}
});
</script>
<template>
@@ -197,17 +413,17 @@ watch(
</div>
</div>
<div class="level-right">
<button
type="button"
class="button is-dark"
:class="{ 'is-loading': saving }"
:disabled="!canSave"
data-testid="limited-prices-save"
@click="savePrices"
<div
v-if="autosaveStatusMessage"
class="limited-backoffice-prices__autosave"
:class="autosaveStatusClass"
data-testid="limited-prices-autosave-status"
role="status"
aria-live="polite"
>
<span class="icon is-small"><i class="fas fa-save" aria-hidden="true"></i></span>
<span>{{ t("templates.limited_backoffice.prices.save") }}</span>
</button>
<span class="icon is-small"><i class="fas" :class="autosaveStatusIcon" aria-hidden="true"></i></span>
<span>{{ autosaveStatusMessage }}</span>
</div>
</div>
</div>
@@ -229,10 +445,6 @@ watch(
{{ errorMessage }}
</div>
<div v-if="successMessage" class="notification is-success is-light" data-testid="limited-prices-success">
{{ successMessage }}
</div>
<div
v-if="hasProducts && invalidPriceCount > 0"
class="notification is-danger is-light"
@@ -273,6 +485,7 @@ watch(
required
:aria-label="`${product.name} ${t('templates.limited_backoffice.prices.price')}`"
:data-testid="`limited-price-input-${product.id}`"
@blur="flushAutosave"
/>
</td>
</tr>
@@ -291,6 +504,14 @@ watch(
margin-bottom: 1rem;
}
.limited-backoffice-prices__autosave {
align-items: center;
display: inline-flex;
font-size: 0.875rem;
gap: 0.25rem;
min-height: 2.25rem;
}
.limited-price-category {
margin-top: 1.25rem;
}
@@ -1,7 +1,6 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import PageTitle from "@/components/global/PageTitle.vue";
const props = defineProps({
@@ -28,7 +27,6 @@ const props = defineProps({
});
const emit = defineEmits(["changeDepartment"]);
const router = useRouter();
const { t } = useI18n();
const pricesRoute = computed(() =>
@@ -50,14 +48,13 @@ const tabs = computed(() => [
},
]);
const selectDepartment = async (event) => {
const selectDepartment = (event) => {
const departmentId = Number.parseInt(String(event?.target?.value ?? ""), 10);
if (!Number.isInteger(departmentId) || departmentId <= 0) {
return;
}
emit("changeDepartment", departmentId);
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
</script>
+11 -9
View File
@@ -62,17 +62,19 @@ const hasAnyShortcutPermission = computed(() => {
// Check if subuser has any of the shortcut permissions
return (
SessionUser.hasPermission('BOOKINGS_ADD') ||
SessionUser.hasPermission('VEHICLES_ADD') ||
SessionUser.hasPermission('ORDERS_LIST') ||
SessionUser.hasPermission('SELFSERVE_ADD')
SessionUser.canAccessCustomerFeature("bookings", "add") ||
SessionUser.canAccessCustomerFeature("vehicles", "add") ||
SessionUser.canAccessCustomerFeature("orders", "list") ||
SessionUser.canAccessCustomerFeature("selfserve", "list") ||
SessionUser.canManageSubusers("list")
);
});
const canShowClassicCustomerShortcut = () => !isSubuser.value && SessionUser.hasPermission('user');
const canShowBookingShortcut = computed(() => canShowClassicCustomerShortcut() || SessionUser.hasPermission('BOOKINGS_ADD'));
const canShowVehicleShortcut = computed(() => canShowClassicCustomerShortcut() || SessionUser.hasPermission('VEHICLES_ADD'));
const canShowOrderShortcut = computed(() => canShowClassicCustomerShortcut() || SessionUser.hasPermission('ORDERS_LIST'));
const canShowBookingShortcut = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
const canShowVehicleShortcut = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
const canShowOrderShortcut = computed(() => SessionUser.canAccessCustomerFeature("orders", "list"));
const canShowSelfServeShortcut = computed(() => SessionUser.canAccessCustomerFeature("selfserve", "list"));
const canShowInvoiceShortcut = computed(() => canShowClassicCustomerShortcut());
const windowInnerWidth = ref(window.innerWidth);
@@ -167,7 +169,7 @@ const isPermissionsLoading = computed(() => {
{{ $t('messages.welcome') }} {{ SessionUser.getName() }}
</span>
</div>
<div class="column is-half-desktop is-hidden-desktop is-12-tablet">
<div class="column is-half-desktop is-hidden-desktop is-12-tablet" v-if="canShowSelfServeShortcut">
<!-- Start wash card -->
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
<template #header>
@@ -237,7 +239,7 @@ const isPermissionsLoading = computed(() => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link to="/user/vehicles" class="button is-link is-fullwidth" :class="classes.button" id="add-vehicle-button">
<router-link to="/user/vehicles/new" class="button is-link is-fullwidth" :class="classes.button" id="add-vehicle-button">
{{ $t('user_home.create_vehicle') }}
</router-link>
</div>
@@ -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";
@@ -22,6 +22,11 @@ import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import {
buildMultiMonthInvoiceContext,
MULTI_MONTH_INVOICE_ACTION,
promptMultiMonthInvoiceWarning,
} from "@/services/invoiceMonthSplitWarning.js";
import {
buildPossibleDuplicateGroups,
formatDuplicateDateLabel,
@@ -330,6 +335,13 @@ const parsePositiveInteger = (value: any) => {
const getCustomerNumber = (customer: any) => parsePositiveInteger(customer?.customer_number);
const markCustomerPeriodRefreshLoading = (customer: any) => {
const customerNumber = getCustomerNumber(customer);
if (customerNumber) {
invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []);
}
};
const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any = null) => {
const uniqueInvoiceCollectionIds = Array.from(
new Set(
@@ -351,12 +363,10 @@ const queueInvoiceCollections = (invoiceCollectionIds: number[], customer: any =
const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
const customerNumber = getCustomerNumber(customer);
if (customerNumber) {
invoiceQueue.markPeriodRefreshLoading?.([customerNumber], []);
}
try {
if (transactionIds.length === 0) {
markCustomerPeriodRefreshLoading(customer);
const month = dates.variables.start.value.getMonth() + 1;
const year = dates.variables.start.value.getFullYear();
@@ -392,10 +402,32 @@ const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
await fetchMissingInvoiceCollections(customer, transactionIds);
const invoiceCollectionIds = getInvoiceCollectionIdsForTransactionIds(customer, transactionIds);
if (invoiceCollectionIds.length === 0) {
invoiceQueue.finishPeriodRefresh?.([customerNumber], []);
return;
}
const invoiceWarningContext = buildMultiMonthInvoiceContext(
transactionIds
.map((transactionId) => getTransactionById(customer, transactionId))
.filter((transaction: any) => transaction !== null),
{
getDate: (transaction: any) => transaction?.date ?? transaction?.created_at,
getInvoiceCollectionId: (transaction: any) => getTransactionInvoiceCollectionId(transaction),
}
);
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
context: invoiceWarningContext,
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
parseErrorMessage: SessionUser.functions.parseErrorMessage,
});
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
reloadPeriodPage();
return;
}
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
return;
}
markCustomerPeriodRefreshLoading(customer);
queueInvoiceCollections(invoiceCollectionIds, customer);
} catch (error: any) {
invoiceQueue.finishPeriodRefresh?.([customerNumber], []);
@@ -1,61 +1,12 @@
<script setup>
import { useRouter} from "vue-router";
import { ref } from 'vue';
import { showAuthSignOutForm } from '@/components/forms/auth/authSignOutForm.vue';
import UserNavigation from "@/components/global/UserNavigation.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import NavigationMenuSuperUser from "@/components/models/navigation/menus/NavigationMenuSuperUser.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const router = useRouter();
const tabs = [
{ name: t('superuser_dashboard.nav.dashboard'), path: '/superuser' },
{ name: t('superuser_dashboard.nav.statistics'), path: '/superuser/statistics' },
{ name: t('common.departments'), path: '/superuser/departments' },
{ name: t('superuser_dashboard.nav.employees'), path: '/superuser/users' },
{ name: t('superuser_dashboard.nav.customers'), path: '/superuser/customers' },
{ name: t('superuser_dashboard.nav.complaints'), path: '/superuser/complaints' },
{ name: t('common.products'), path: '/superuser/products' },
{ name: t('common.invoices'), path: '/superuser/invoices' },
{ name: t('superuser_dashboard.nav.transaction_history'), path: '/superuser/orders' },
{ name: t('superuser_dashboard.nav.scanners'), path: '/superuser/scanners' },
{ name: t('superuser_dashboard.nav.roles'), path: '/superuser/roles' },
{ name: t('common.vehicles'), path: '/superuser/vehicles' },
{ name: SessionUser.objects.categories.meta.title, path: '/superuser/categories' },
{ name: t('superuser_dashboard.nav.xlvask'), path: '/superuser/xlvask' },
{ name: t('superuser_dashboard.nav.configuration'), path: '/superuser/configuration' }
];
// Get the current path
const currentPath = ref(router.currentRoute.value.path);
// Get the index of the active tab
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
// Change the tab
const changeTab = (index) => {
router.push(tabs[index].path);
};
</script>
<template>
<div>
<UserNavigation />
<NavigationMenuSuperUser />
<!--
<div class="tabs is-right">
<ul>
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<a>{{ tab.name }}</a>
</li>
Sign out button
<li>
<a @click="showAuthSignOutForm">Sign out</a>
</li>
</ul>
</div> -->
</div>
</template>
@@ -620,8 +620,10 @@ onMounted(async () => {
</b-message>
<b-message v-if="monthFallback.usedLegacyDistribution" type="is-warning" has-icon icon-pack="fas" class="mb-0">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_month') }}</p>
<p v-if="monthFallback.distributionFallbackReason" class="is-size-7">{{ monthFallback.distributionFallbackReason }}</p>
<div data-testid="distribution-month-legacy-fallback-warning">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_month') }}</p>
<p v-if="monthFallback.distributionFallbackReason" class="is-size-7">{{ monthFallback.distributionFallbackReason }}</p>
</div>
</b-message>
<b-tabs v-model="activeTab" expanded data-testid="distribution-month-tabs" class="distribution-tabs">
@@ -899,8 +901,10 @@ onMounted(async () => {
</b-message>
<b-message v-if="compareFallback.usedLegacyCompare" type="is-warning" has-icon icon-pack="fas" class="mb-3">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_compare_fallback') }}</p>
<p v-if="compareFallback.compareFallbackReason" class="is-size-7">{{ compareFallback.compareFallbackReason }}</p>
<div data-testid="distribution-compare-legacy-fallback-warning">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_compare_fallback') }}</p>
<p v-if="compareFallback.compareFallbackReason" class="is-size-7">{{ compareFallback.compareFallbackReason }}</p>
</div>
</b-message>
<section class="v3-compare-summary mb-3" v-if="sortedCompareRows.length">
@@ -209,7 +209,7 @@ onMounted(() => {
icon-pack="fas"
class="mb-0"
>
<div class="fallback-note">
<div class="fallback-note" data-testid="distribution-overview-legacy-fallback-warning">
<p>{{ t('superuser_invoice_distribution.warnings.legacy_distribution_fallback', { count: monthsUsingLegacyDistribution.length }) }}</p>
<p v-if="latestDistributionFallbackReason" class="is-size-7">{{ latestDistributionFallbackReason }}</p>
</div>
@@ -601,4 +601,3 @@ onMounted(() => {
outline-offset: 2px;
}
</style>
@@ -1,45 +1,45 @@
<script setup>
import { useRouter} from "vue-router";
import { ref } from 'vue';
import { showAuthSignOutForm } from '@/components/forms/auth/authSignOutForm.vue';
import UserNavigation from "@/components/global/UserNavigation.vue";
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const router = useRouter();
// Get the user from the route
const userId = ref(router.currentRoute.value.params.userId)
const tabs = [
{ name: 'Overview', path: '/superuser/users/' + userId.value },
{ name: 'Orders', path: '/superuser/users/' + userId.value + '/orders' },
{ name: 'Pricing', path: '/superuser/users/' + userId.value + '/pricing' },
{ name: 'Other', path: '/superuser/users/' + userId.value + '/other' },
{ name: 'Vehicles', path: '/superuser/users/' + userId.value + '/vehicles' },
{ name: SessionUser.superUser.modules.xlvask.meta.title, path: '/superuser/users/' + userId.value + '/xlvask' },
];
const route = useRoute();
const { t } = useI18n();
// Get the current path
const currentPath = ref(router.currentRoute.value.path);
const userId = computed(() => route.params.userId);
const basePath = computed(() => `/superuser/users/${userId.value}`);
// Get the index of the active tab
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
// Change the tab
const changeTab = (index) => {
router.push(tabs[index].path);
};
const tabs = computed(() => [
{ key: "overview", name: t("nav.overview"), path: basePath.value },
{ key: "orders", name: t("superuser.nav.orders"), path: `${basePath.value}/orders` },
{ key: "pricing", name: t("user_admin.overview.pricing"), path: `${basePath.value}/pricing` },
{ key: "other", name: t("user_admin.other"), path: `${basePath.value}/other` },
{ key: "vehicles", name: t("common.vehicles"), path: `${basePath.value}/vehicles` },
{ key: "xlvask", name: SessionUser.superUser.modules.xlvask.meta.title, path: `${basePath.value}/xlvask` },
]);
</script>
<template>
<div>
<div class="tabs is-right">
<nav class="user-detail-tabs" data-testid="superuser-user-tabs">
<div class="tabs is-right is-boxed">
<ul>
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<a>{{ tab.name }}</a>
<li v-for="tab in tabs" :key="tab.path" :class="{ 'is-active': route.path === tab.path }">
<router-link :to="tab.path" :data-testid="`superuser-user-tab-${tab.key}`">
{{ tab.name }}
</router-link>
</li>
</ul>
</div>
</div>
</nav>
</template>
<style scoped>
</style>
.user-detail-tabs {
overflow-x: auto;
}
.tabs ul {
flex-wrap: nowrap;
}
</style>
@@ -1,15 +1,91 @@
<script>
import {ref, watch} from "vue";
import { ref, watch } from "vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
// Get the user from the route
export const userId = ref(0);
export const isUserLoading = ref(false);
export const userLoadError = ref(null);
export const selectedUserLoaded = ref(false);
let activeLoadPromise = null;
let activeLoadUserId = null;
let loadRequestSequence = 0;
const normalizeUserId = (id) => {
const parsed = Number.parseInt(String(id ?? ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
};
const defaultKeys = () => ({
open_invoice_draft: null,
});
const economicFields = {
customerNumber: null,
name: null,
address: null,
zip: null,
city: null,
mobilePhone: null,
email: null,
cvr: null,
currency: null,
country: null,
barred: null,
};
const setEconomicData = (economicCustomer = {}) => {
user.economicData.customerNumber.value = economicCustomer?.customerNumber ?? null;
user.economicData.name.value = economicCustomer?.name ?? null;
user.economicData.address.value = economicCustomer?.address ?? null;
user.economicData.zip.value = economicCustomer?.zip ?? null;
user.economicData.city.value = economicCustomer?.city ?? null;
user.economicData.mobilePhone.value = economicCustomer?.mobilePhone ?? null;
user.economicData.email.value = economicCustomer?.email ?? null;
user.economicData.cvr.value = economicCustomer?.corporateIdentificationNumber ?? economicCustomer?.cvr ?? null;
user.economicData.currency.value = economicCustomer?.currency ?? null;
user.economicData.country.value = economicCustomer?.country ?? null;
user.economicData.barred.value = economicCustomer?.barred ?? false;
};
export const resetUser = ({ preserveUserId = false } = {}) => {
if (!preserveUserId) {
userId.value = 0;
}
user.id.value = "";
user.customer_number.value = "";
user.display_name.value = "";
user.email.value = "";
user.phone.value = null;
user.group_id.value = "";
user.created_at.value = "";
user.updated_at.value = "";
setEconomicData(economicFields);
user.permissions.value = [];
user.attributes.value = [];
user.discounts.value = [];
user.orders_not_invoiced.value = [];
user.keys.value = defaultKeys();
user.wash_subscription_transactions.value = [];
selectedUserLoaded.value = false;
};
export const setUser = (id) => {
userId.value = id;
// Load the user data
getUserData();
}
const normalizedId = normalizeUserId(id);
if (!normalizedId) {
userLoadError.value = new Error("Invalid user id");
resetUser();
return Promise.resolve(null);
}
if (userId.value !== normalizedId) {
resetUser();
userId.value = normalizedId;
}
return getUserData(normalizedId);
};
export const onUserChange = (callback) => {
watch(userId, (newValue) => {
@@ -17,48 +93,89 @@ export const onUserChange = (callback) => {
callback(newValue);
}
});
}
};
export const getUserCustomerNumber = () => {
return user.customer_number.value;
}
};
const applyUserData = (data = {}) => {
user.id.value = data.id ?? "";
user.customer_number.value = data.customer_number ?? "";
user.display_name.value = data.display_name ?? "";
user.email.value = data.email ?? "";
user.phone.value = data.phone ?? null;
user.group_id.value = data.group_id ?? "";
user.created_at.value = data.created_at ?? "";
user.updated_at.value = data.updated_at ?? "";
setEconomicData(data.economic_customer ?? {});
user.permissions.value = Array.isArray(data.permissions) ? data.permissions : [];
user.attributes.value = Array.isArray(data.attributes) ? data.attributes : [];
user.discounts.value = Array.isArray(data.discounts) ? data.discounts : [];
user.orders_not_invoiced.value = Array.isArray(data.orders_not_invoiced) ? data.orders_not_invoiced : [];
user.keys.value = data.keys && typeof data.keys === "object" ? { ...defaultKeys(), ...data.keys } : defaultKeys();
user.wash_subscription_transactions.value = Array.isArray(data.wash_subscription_transactions)
? data.wash_subscription_transactions
: [];
selectedUserLoaded.value = true;
};
// Get the user data
export const getUserData = async () => {
return await authenticatedRequest(`/superuser/user?user_id=${userId.value}`, "GET")
export const getUserData = async (id = userId.value) => {
const normalizedId = normalizeUserId(id);
if (!normalizedId) {
userLoadError.value = new Error("Invalid user id");
resetUser();
return null;
}
if (activeLoadPromise && activeLoadUserId === normalizedId) {
return activeLoadPromise;
}
const requestId = ++loadRequestSequence;
activeLoadUserId = normalizedId;
isUserLoading.value = true;
userLoadError.value = null;
activeLoadPromise = authenticatedRequest(`/superuser/user?user_id=${normalizedId}`, "GET")
.then((response) => {
user.id.value = response.data.data.id;
user.customer_number.value = response.data.data.customer_number;
user.group_id.value = response.data.data.group_id;
user.created_at.value = response.data.data.created_at;
user.updated_at.value = response.data.data.updated_at;
if (response.data.data.economic_customer.name) {
user.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber;
user.economicData.name.value = response.data.data.economic_customer.name;
user.economicData.address.value = response.data.data.economic_customer.address;
user.economicData.zip.value = response.data.data.economic_customer.zip;
user.economicData.city.value = response.data.data.economic_customer.city;
user.economicData.mobilePhone.value = response.data.data.economic_customer.mobilePhone;
user.economicData.email.value = response.data.data.economic_customer.email;
user.economicData.cvr.value = response.data.data.economic_customer.corporateIdentificationNumber;
user.economicData.currency.value = response.data.data.economic_customer.currency;
user.economicData.country.value = response.data.data.economic_customer.country;
user.economicData.barred.value = response.data.data.economic_customer.barred ?? false;
if (requestId !== loadRequestSequence) {
return user;
}
user.permissions.value = response.data.data.permissions;
user.attributes.value = response.data.data.attributes;
user.discounts.value = response.data.data.discounts;
user.orders_not_invoiced.value = response.data.data.orders_not_invoiced || null;
user.keys.value = response.data.data.keys;
user.wash_subscription_transactions.value = response.data.data.wash_subscription_transactions || null;
console.log(user.discounts.value);
applyUserData(response?.data?.data ?? {});
return user;
})
.catch((error) => {
console.error(error);
if (requestId === loadRequestSequence) {
resetUser({ preserveUserId: true });
userLoadError.value = error;
}
return null;
})
.finally(() => {
if (requestId === loadRequestSequence) {
isUserLoading.value = false;
activeLoadPromise = null;
activeLoadUserId = null;
}
});
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 */
@@ -133,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;
@@ -166,9 +296,27 @@ 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(''),
display_name: ref(''),
email: ref(''),
phone: ref(null),
group_id: ref(''),
created_at: ref(''),
updated_at: ref(''),
@@ -185,23 +333,23 @@ export const user = {
country: ref(null),
barred: ref(null),
},
permissions: ref(null),
attributes: ref(null),
discounts: ref(null),
orders_not_invoiced: ref(null),
keys: ref({
open_invoice_draft: ref(null),
}),
wash_subscription_transactions: ref(null),
permissions: ref([]),
attributes: ref([]),
discounts: ref([]),
orders_not_invoiced: ref([]),
keys: ref(defaultKeys()),
wash_subscription_transactions: ref([]),
functions: {
getUserData: getUserData,
getUserProductDiscount: getUserProductDiscount,
getUserCategoryDiscount: getUserCategoryDiscount,
getUserGlobalDiscount: getUserGlobalDiscount,
getUserProductOnlyDiscount: getUserProductOnlyDiscount,
getUserProductFixedPrice: getUserProductFixedPrice,
isProductAllowedToApplyCategoryDiscounts: isProductAllowedToApplyCategoryDiscounts,
getProductCategory: getProductCategory,
getProductBestApplicableDiscount: getProductBestApplicableDiscount,
getProductEffectivePrice: getProductEffectivePrice,
},
};
</script>
@@ -1,133 +1,506 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRouter } from 'vue-router'
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
// Get the user from the route
const router = useRouter()
import {user, getUserData, userId, setUser} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import UserVehicleSubscriptionsDisplay
from "@/views/dashboards/superUserDashboard/user/displays/vehicles/UserVehicleSubscriptionsDisplay.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import UserFixedPricing from "@/views/dashboards/superUserDashboard/user/displays/UserFixedPricing.vue";
import {
isUserLoading,
selectedUserLoaded,
setUser,
user,
userId,
userLoadError,
} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import UserDefaultDepartment from "@/views/dashboards/superUserDashboard/user/displays/UserDefaultDepartment.vue";
import UserFixedPricing from "@/views/dashboards/superUserDashboard/user/displays/UserFixedPricing.vue";
import UserOtherSpecialArrangement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue";
import UserOtherVaskeabonnement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue";
import UserVehicleSubscriptionsDisplay from "@/views/dashboards/superUserDashboard/user/displays/vehicles/UserVehicleSubscriptionsDisplay.vue";
// Set the user
setUser(router.currentRoute.value.params.userId);
const route = useRoute();
const { t } = useI18n();
const vehicles = ref([]);
const vehiclesLoading = ref(false);
const vehiclesError = ref(null);
const customerNumber = computed(() => Number.parseInt(String(user.customer_number.value || 0), 10) || 0);
const basePath = computed(() => `/superuser/users/${userId.value || route.params.userId}`);
const permissions = computed(() => (Array.isArray(user.permissions.value) ? user.permissions.value : []));
const attributes = computed(() => (Array.isArray(user.attributes.value) ? user.attributes.value : []));
const discounts = computed(() => (Array.isArray(user.discounts.value) ? user.discounts.value : []));
const ordersNotInvoiced = computed(() =>
Array.isArray(user.orders_not_invoiced.value) ? user.orders_not_invoiced.value : []
);
const washSubscriptionTransactions = computed(() =>
Array.isArray(user.wash_subscription_transactions.value) ? user.wash_subscription_transactions.value : []
);
const displayName = computed(() => {
return (
user.economicData.name.value ||
user.display_name.value ||
(userId.value ? `${t("user_admin.user_id")} ${userId.value}` : t("user_admin.overview.loading_user"))
);
});
const hasCustomerNumber = computed(() => customerNumber.value > 0);
const hasEconomicData = computed(() => Boolean(user.economicData.name.value || user.economicData.customerNumber.value));
const isEconomicBarred = computed(() => [true, 1, "1", "true"].includes(user.economicData.barred.value));
const activeVehicleSubscriptions = computed(() => vehicles.value.filter((vehicle) => vehicle?.wash_subscription).length);
const selfServiceVehicles = computed(() => vehicles.value.filter((vehicle) => vehicle?.xlvask).length);
const visibleVehicles = computed(() => vehicles.value.slice(0, 8));
const visiblePermissions = computed(() => permissions.value.slice(0, 12));
const hiddenPermissionCount = computed(() => Math.max(permissions.value.length - visiblePermissions.value.length, 0));
const userDetails = computed(() => [
{ label: t("user_admin.user_id"), value: userId.value },
{ label: t("user_admin.customer_number"), value: customerNumber.value || null },
{ label: t("user_admin.group_id"), value: user.group_id.value },
{ label: t("common.email"), value: user.email?.value || user.economicData.email.value },
{ label: t("common.created"), value: user.created_at.value },
{ label: t("user_admin.updated_at"), value: user.updated_at.value },
]);
const economicDetails = computed(() => [
{ label: t("user_admin.customer_number"), value: user.economicData.customerNumber.value },
{ label: t("common.name"), value: user.economicData.name.value },
{ label: t("common.address"), value: user.economicData.address.value },
{ label: t("user_admin.zip"), value: user.economicData.zip.value },
{ label: t("user_admin.city"), value: user.economicData.city.value },
{ label: t("user_admin.mobile_phone"), value: user.economicData.mobilePhone.value },
{ label: t("common.email"), value: user.economicData.email.value },
{ label: t("user_admin.cvr"), value: user.economicData.cvr.value },
{ label: t("user_admin.currency"), value: user.economicData.currency.value },
{ label: t("common.country"), value: user.economicData.country.value },
{ label: t("user_admin.barred"), value: isEconomicBarred.value ? t("common.yes") : t("common.no") },
]);
const overviewMetrics = computed(() => [
{
key: "customer",
icon: "fas fa-id-card",
label: t("user_admin.customer_number"),
value: customerNumber.value || t("global.no_data"),
status: hasCustomerNumber.value ? t("common.active") : t("user_admin.overview.missing"),
tone: hasCustomerNumber.value ? "is-success" : "is-warning",
},
{
key: "economic",
icon: "fas fa-building",
label: t("user_admin.economic_data"),
value: hasEconomicData.value ? t("user_admin.overview.connected") : t("global.no_data"),
status: isEconomicBarred.value ? t("user_admin.barred") : t("common.active"),
tone: isEconomicBarred.value ? "is-danger" : "is-success",
},
{
key: "vehicles",
icon: "fas fa-car",
label: t("common.vehicles"),
value: vehicles.value.length,
status: vehiclesLoading.value ? t("common.loading") : t("user_admin.overview.loaded"),
tone: vehiclesError.value ? "is-warning" : "is-info",
},
{
key: "subscriptions",
icon: "fas fa-car-side",
label: t("user_admin.wash_subscriptions"),
value: activeVehicleSubscriptions.value,
status: t("user_admin.overview.active_vehicles"),
tone: activeVehicleSubscriptions.value > 0 ? "is-success" : "is-light",
},
{
key: "orders",
icon: "fas fa-file-invoice-dollar",
label: t("user_admin.overview.not_invoiced"),
value: ordersNotInvoiced.value.length,
status: user.keys.value?.open_invoice_draft ? t("user_admin.overview.open_draft") : t("global.no_data"),
tone: ordersNotInvoiced.value.length > 0 ? "is-warning" : "is-success",
},
{
key: "discounts",
icon: "fas fa-tags",
label: t("common.discount"),
value: discounts.value.length,
status: t("user_admin.overview.price_overrides"),
tone: discounts.value.length > 0 ? "is-link" : "is-light",
},
{
key: "attributes",
icon: "fas fa-sliders-h",
label: t("user_admin.overview.attributes"),
value: attributes.value.length,
status: t("user_admin.overview.customer_rules"),
tone: attributes.value.length > 0 ? "is-info" : "is-light",
},
{
key: "permissions",
icon: "fas fa-shield-alt",
label: t("user_admin.permissions"),
value: permissions.value.length,
status: t("user_admin.overview.effective_access"),
tone: permissions.value.length > 0 ? "is-info" : "is-warning",
},
]);
const hubLinks = computed(() => [
{ key: "orders", icon: "fas fa-file-alt", label: t("superuser.nav.orders"), to: `${basePath.value}/orders` },
{ key: "pricing", icon: "fas fa-tags", label: t("user_admin.overview.pricing"), to: `${basePath.value}/pricing` },
{ key: "other", icon: "fas fa-sliders-h", label: t("user_admin.other"), to: `${basePath.value}/other` },
{ key: "vehicles", icon: "fas fa-car", label: t("common.vehicles"), to: `${basePath.value}/vehicles` },
{
key: "xlvask",
icon: "fas fa-water",
label: SessionUser.superUser.modules.xlvask.meta.title,
to: `${basePath.value}/xlvask`,
},
]);
const valueOrEmpty = (value) => {
if (value === null || value === undefined || value === "") {
return t("global.no_data");
}
return value;
};
const loadVehicles = async () => {
if (!hasCustomerNumber.value) {
vehicles.value = [];
vehiclesError.value = null;
return;
}
vehiclesLoading.value = true;
vehiclesError.value = null;
try {
const response = await SessionUser.objects.vehicles.get.user(customerNumber.value);
vehicles.value = Array.isArray(response) ? response : [];
} catch (error) {
vehicles.value = [];
vehiclesError.value = error;
} finally {
vehiclesLoading.value = false;
}
};
const reloadOverview = async () => {
await setUser(route.params.userId);
await loadVehicles();
};
watch(
customerNumber,
() => {
loadVehicles();
},
{ immediate: true }
);
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<UserSubPageWrapper>
<template #title>
<PageTitle :title="$t('user_admin.title')" :subtitle="$t('user_admin.subtitle')" />
<PageTitle :title="$t('user_admin.title')" :subtitle="$t('user_admin.overview.subtitle')" />
</template>
<div class="columns is-multiline">
<!-- Customer small -->
<div class="column is-12">
<div class="card">
<div class="card-header">
<!-- User icon -->
<div class="card-header-icon">
<span class="icon">
<i class="fas fa-user"></i>
<section class="user-overview" data-testid="superuser-user-overview-page">
<div v-if="isUserLoading && !selectedUserLoaded" class="box" data-testid="superuser-user-overview-loading">
<p class="has-text-weight-semibold">{{ $t("user_admin.overview.loading_user") }}</p>
<p class="has-text-grey">{{ $t("user_admin.orders.loading_subtitle") }}</p>
</div>
<div v-else-if="userLoadError" class="notification is-danger" data-testid="superuser-user-overview-error">
<p class="has-text-weight-semibold">{{ $t("user_admin.overview.load_failed") }}</p>
<p>{{ userLoadError?.response?.data?.message || userLoadError?.message || $t("common.unknown_error") }}</p>
<button class="button is-light mt-3" type="button" data-testid="superuser-user-overview-retry" @click="reloadOverview">
{{ $t("common.retry") }}
</button>
</div>
<template v-else-if="selectedUserLoaded">
<div class="user-overview__header box" data-testid="superuser-user-overview-header">
<div>
<p class="heading">{{ $t("user_admin.overview.management_hub") }}</p>
<h2 class="title is-3 mb-2">{{ displayName }}</h2>
<p class="subtitle is-6 mb-0">
{{ $t("user_admin.user_id") }} {{ userId }}
<span v-if="hasCustomerNumber">&middot; {{ $t("user_admin.customer_number") }} {{ customerNumber }}</span>
</p>
</div>
<div class="user-overview__header-actions">
<ActionSettingsWheelButton
:user_id="userId"
:customer_number="customerNumber || null"
data-testid="superuser-user-overview-actions"
/>
</div>
</div>
<div class="user-overview__metrics columns is-multiline" data-testid="superuser-user-overview-metrics">
<div v-for="metric in overviewMetrics" :key="metric.key" class="column is-3-desktop is-6-tablet">
<div class="box user-overview__metric" :data-testid="`superuser-user-overview-metric-${metric.key}`">
<div class="user-overview__metric-top">
<span class="icon"><i :class="metric.icon"></i></span>
<span class="tag is-light" :class="metric.tone">{{ metric.status }}</span>
</div>
<p class="heading">{{ metric.label }}</p>
<p class="title is-4">{{ metric.value }}</p>
</div>
</div>
</div>
<div class="box" data-testid="superuser-user-overview-shortcuts">
<div class="level is-mobile user-overview__section-title">
<div class="level-left">
<h3 class="title is-5 mb-0">{{ $t("user_admin.overview.workspace_shortcuts") }}</h3>
</div>
</div>
<div class="buttons">
<router-link
v-for="link in hubLinks"
:key="link.key"
class="button is-light"
:to="link.to"
:data-testid="`superuser-user-overview-link-${link.key}`"
>
<span class="icon"><i :class="link.icon"></i></span>
<span>{{ link.label }}</span>
</router-link>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-account">
<h3 class="title is-5">{{ $t("user_admin.user_data") }}</h3>
<dl class="user-overview__details">
<template v-for="detail in userDetails" :key="detail.label">
<dt>{{ detail.label }}</dt>
<dd>{{ valueOrEmpty(detail.value) }}</dd>
</template>
</dl>
</section>
</div>
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-economic">
<h3 class="title is-5">{{ $t("user_admin.economic_data") }}</h3>
<dl class="user-overview__details">
<template v-for="detail in economicDetails" :key="detail.label">
<dt>{{ detail.label }}</dt>
<dd>{{ valueOrEmpty(detail.value) }}</dd>
</template>
</dl>
</section>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-access">
<h3 class="title is-5">{{ $t("user_admin.permissions") }}</h3>
<div v-if="visiblePermissions.length" class="tags">
<span
v-for="permission in visiblePermissions"
:key="permission"
class="tag is-info is-light"
:data-testid="`superuser-user-overview-permission-${permission}`"
>
{{ permission }}
</span>
<span v-if="hiddenPermissionCount > 0" class="tag">
{{ $t("user_admin.overview.more_permissions", { count: hiddenPermissionCount }) }}
</span>
</div>
<p v-else class="has-text-grey">{{ $t("global.no_data") }}</p>
</section>
</div>
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-rules">
<h3 class="title is-5">{{ $t("user_admin.overview.customer_rules") }}</h3>
<ActionSettingsWheelButton
v-if="hasCustomerNumber"
:user_id="userId"
:customer_number="customerNumber"
:display-actions-directly="true"
data-testid="superuser-user-overview-direct-actions"
/>
<p v-else class="has-text-grey">{{ $t("user_admin.overview.customer_number_required") }}</p>
</section>
</div>
</div>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-customer-management">
<h3 class="title is-5">{{ $t("user_admin.overview.customer_management") }}</h3>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<UserFixedPricing :key="`fixed-${customerNumber}`" :customer_number="customerNumber" />
</div>
<div class="column is-6-desktop">
<UserDefaultDepartment :key="`department-${customerNumber}`" :customer_number="customerNumber" />
</div>
<div class="column is-6-desktop">
<UserOtherSpecialArrangement :key="`special-${userId}`" :user_id="userId" />
</div>
<div class="column is-6-desktop">
<UserOtherVaskeabonnement :key="`subscription-note-${userId}`" :user_id="userId" />
</div>
</div>
</section>
<section v-else class="notification is-warning" data-testid="superuser-user-overview-no-customer-number">
{{ $t("user_admin.overview.customer_number_required") }}
</section>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-vehicles">
<div class="level is-mobile user-overview__section-title">
<div class="level-left">
<h3 class="title is-5 mb-0">{{ $t("common.vehicles") }}</h3>
</div>
<div class="level-right">
<button
class="button is-small is-light"
type="button"
:disabled="vehiclesLoading"
data-testid="superuser-user-overview-vehicles-reload"
@click="loadVehicles"
>
{{ $t("global.reload") }}
</button>
</div>
</div>
<div v-if="vehiclesError" class="notification is-warning" data-testid="superuser-user-overview-vehicles-error">
{{ $t("user_admin.overview.vehicles_load_failed") }}
</div>
<div class="columns is-multiline">
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ $t("common.vehicles") }}</p>
<p class="title is-4">{{ vehicles.length }}</p>
</div>
</div>
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ $t("user_admin.wash_subscriptions") }}</p>
<p class="title is-4">{{ activeVehicleSubscriptions }}</p>
</div>
</div>
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ SessionUser.superUser.modules.xlvask.meta.title }}</p>
<p class="title is-4">{{ selfServiceVehicles }}</p>
</div>
</div>
</div>
<div v-if="visibleVehicles.length" class="tags" data-testid="superuser-user-overview-vehicle-preview">
<span
v-for="vehicle in visibleVehicles"
:key="vehicle.id || vehicle.reg"
class="tag is-light"
:data-testid="`superuser-user-overview-vehicle-${vehicle.id || vehicle.reg}`"
>
{{ vehicle.reg || vehicle.reference || `#${vehicle.id}` }}
</span>
</div>
<!-- User name -->
<div class="card-header-title">
{{ user.economicData.name }}
<p v-else-if="!vehiclesLoading" class="has-text-grey">{{ $t("global.no_data") }}</p>
<div class="buttons">
<router-link class="button is-light" :to="`${basePath}/vehicles`" data-testid="superuser-user-overview-open-vehicles">
<span class="icon"><i class="fas fa-list"></i></span>
<span>{{ $t("user_admin.overview.open_vehicles") }}</span>
</router-link>
<button
class="button is-light"
type="button"
data-testid="superuser-user-overview-add-vehicle"
@click="SessionUser.objects.vehicles.functions.showCreateObjectForCustomerForm(customerNumber, loadVehicles)"
>
<span class="icon"><i class="fas fa-plus"></i></span>
<span>{{ $t("user_vehicles.add_vehicle") }}</span>
</button>
</div>
<!-- User settings wheel -->
<div class="card-header-icon">
<ActionSettingsWheelButton
:user_id="userId"
>
<template #actions>
<!-- Since there's no additional actions, we can use the default slot -->
</template>
</ActionSettingsWheelButton>
</div>
</div>
</div>
</div>
<div class="column is-12">
<div class="box">
<!-- User fixed pricing -->
<div class="mb-2">
<UserFixedPricing v-if="user.customer_number.value && user.customer_number.value > 0" v-bind:customer_number="user.customer_number.value" />
</div>
<!-- Default department -->
<div class="mb-2">
<UserDefaultDepartment v-if="user.customer_number.value && user.customer_number.value > 0" v-bind:customer_number="user.customer_number.value" />
</div>
<!-- Vehicle subscriptions -->
<UserVehicleSubscriptionsDisplay
v-if="user.customer_number.value && user.customer_number.value > 0"
:user="user"
/>
</div>
</div>
<div class="column is-6">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.user_data') }}</h3>
<div class="content">
<p>{{ $t('user_admin.user_id') }}: {{ userId }}</p>
<p>{{ $t('user_admin.customer_number') }}: {{ user.customer_number }}</p>
<p>{{ $t('user_admin.group_id') }}: {{ user.group_id }}</p>
<p>{{ $t('common.created') }}: {{ user.created_at }}</p>
<p>{{ $t('user_admin.updated_at') }}: {{ user.updated_at }}</p>
</div>
</div>
</div>
<!-- Economic data -->
<div class="column is-6">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.economic_data') }} ({{ user.economicData.name ? user.economicData.name : $t('user_admin.no_data') }})</h3>
<div class="content">
<p>{{ $t('user_admin.customer_number') }}: {{ user.economicData.customerNumber }}</p>
<p>{{ $t('common.name') }}: {{ user.economicData.name }}</p>
<p>{{ $t('common.address') }}: {{ user.economicData.address }}</p>
<p>{{ $t('user_admin.zip') }}: {{ user.economicData.zip }}</p>
<p>{{ $t('user_admin.city') }}: {{ user.economicData.city }}</p>
<p>{{ $t('user_admin.mobile_phone') }}: {{ user.economicData.mobilePhone }}</p>
<p>{{ $t('common.email') }}: {{ user.economicData.email }}</p>
<p>{{ $t('user_admin.cvr') }}: {{ user.economicData.cvr }}</p>
<p>{{ $t('user_admin.currency') }}: {{ user.economicData.currency }}</p>
<p>{{ $t('common.country') }}: {{ user.economicData.country }}</p>
<p>{{ $t('user_admin.barred') }}: {{user.economicData.barred }}</p>
</div>
</div>
</div>
<!-- Permissions -->
<div class="column is-12">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.permissions') }}</h3>
<div class="content">
<ul>
<li v-for="permission in user.permissions.value" :key="permission.id">{{ permission }}</li>
</ul>
</div>
</div>
</div>
<!-- Variables -->
<div class="column is-12">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.variables') }}</h3>
<div class="content">
<p>SessionUser: {{ SessionUser.valueOf() }}</p>
<p>userId: {{ userId }}</p>
<p>user: {{ user.valueOf() }}</p>
</div>
</div>
</div>
</div>
</section>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-subscriptions">
<h3 class="title is-5">{{ $t("user_admin.overview.subscription_invoicing") }}</h3>
<UserVehicleSubscriptionsDisplay :key="`subscriptions-${customerNumber}`" :user="user" />
<p v-if="washSubscriptionTransactions.length === 0" class="has-text-grey">
{{ $t("user_admin.overview.no_subscription_transactions") }}
</p>
</section>
</template>
</section>
</UserSubPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
.user-overview {
padding-bottom: 2rem;
}
</style>
.user-overview__header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.user-overview__header-actions {
flex: 0 0 auto;
}
.user-overview__metric,
.user-overview__compact-stat {
height: 100%;
}
.user-overview__metric-top {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.user-overview__details {
display: grid;
gap: 0.5rem 1rem;
grid-template-columns: minmax(8rem, 38%) 1fr;
}
.user-overview__details dt {
color: #6b7280;
font-weight: 600;
}
.user-overview__details dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.user-overview__section-title {
margin-bottom: 1rem;
}
@media screen and (max-width: 768px) {
.user-overview__header {
display: block;
}
.user-overview__header-actions {
margin-top: 1rem;
}
.user-overview__details {
grid-template-columns: 1fr;
}
}
</style>
@@ -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>
@@ -1,12 +1,19 @@
<script setup>
import { watch } from "vue";
import SuperUserDashboardUserNavigation from "@/views/dashboards/superUserDashboard/user/SuperUserDashboardUserNavigation.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import {user, getUserData, userId, setUser, getUserCustomerNumber} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import {useRouter} from "vue-router";
const router = useRouter()
setUser(router.currentRoute.value.params.userId);
import { setUser } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { useRoute } from "vue-router";
const route = useRoute();
watch(
() => route.params.userId,
(nextUserId) => {
setUser(nextUserId);
},
{ immediate: true }
);
</script>
<template>
@@ -21,4 +28,4 @@ setUser(router.currentRoute.value.params.userId);
<style scoped>
</style>
</style>
@@ -2,7 +2,6 @@
import { ref, computed } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import Swal from "sweetalert2";
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
@@ -26,7 +25,6 @@ const getDefaultDepartment = async () => {
customer_number: props.customer_number,
}).then((response) => {
let responseData = response.data.data;
console.log(response.data.data, 'Default department');
department_value.value = responseData.department;
hasDefaultDepartment.value = true;
}).catch((error) => {
@@ -40,8 +38,7 @@ const onClickSubmit = async () => {
await SessionUser.request('/customer/department/default', 'POST', {
customer_number: props.customer_number,
department: department_value.value,
}).then((response) => {
console.log(response.data.data, 'Created default department');
}).then(() => {
// Get the newly created fixed pricing
getDefaultDepartment();
}).catch((error) => {
@@ -75,8 +72,7 @@ const onDeleteConfirmed = async () => {
// Delete the fixed pricing
await SessionUser.request('/customer/department/default', 'DELETE', {
customer_number: props.customer_number,
}).then((response) => {
console.log(response.data.data, 'Deleted default department');
}).then(() => {
// Get the newly created fixed pricing
getDefaultDepartment();
}).catch((error) => {
@@ -163,4 +159,4 @@ getDepartmentOptions();
<style scoped>
</style>
</style>
@@ -2,7 +2,6 @@
import { ref, computed } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import Swal from "sweetalert2";
@@ -27,8 +26,6 @@ const getFixedPricing = async () => {
await SessionUser.request('/customer/pricing/fixed', 'GET', {
customer_number: props.customer_number,
}).then((response) => {
let responseData = response.data.data;
console.log(response.data.data, 'Fixed pricing');
pricing.value = response.data.data.price;
description.value = response.data.data.description;
hasFixedPricing.value = true;
@@ -44,8 +41,7 @@ const onClickSubmit = async () => {
customer_number: props.customer_number,
price: inputPrice.value,
description: inputDescription.value,
}).then((response) => {
console.log(response.data.data, 'Fixed pricing');
}).then(() => {
// Get the newly created fixed pricing
getFixedPricing();
}).catch((error) => {
@@ -81,8 +77,7 @@ const onDeleteConfirmed = async () => {
// Delete the fixed pricing
await SessionUser.request('/customer/pricing/fixed', 'DELETE', {
customer_number: props.customer_number,
}).then((response) => {
console.log(response.data.data, 'Deleted fixed pricing');
}).then(() => {
// Get the newly created fixed pricing
getFixedPricing();
}).catch((error) => {
@@ -167,4 +162,4 @@ getFixedPricing();
<style scoped>
</style>
</style>
@@ -1,8 +1,8 @@
<script setup>
import { ref } from 'vue';
import { ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
const { t } = useI18n();
@@ -10,79 +10,73 @@ const props = defineProps({
user_id: Number,
readOnly: {
type: Boolean,
default: false
default: false,
},
textOnly: {
type: Boolean,
default: false
}
default: false,
},
});
const OtherSpecialArrangementValue = ref(null);
const userKeys = ref({});
const userKeys = ref([{
key: '',
value: ''
}]);
const showLoadError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unable_to_load"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const showSaveError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unknown_error"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const getKeys = async () => {
// Get the special arrangement
await SessionUser.request('/superuser/user/keys?user_id=' + props.user_id, 'GET').then((response) => {
userKeys.value = response.data.data;
console.log(userKeys.value);
// Check if the key exists
try {
if (userKeys.value.OtherSpecialArrangement) {
OtherSpecialArrangementValue.value = userKeys.value.OtherSpecialArrangement;
}
} catch (e) {
console.log('Key does not exist');
}
}).catch((error) => {
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved hentning af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
if (!props.user_id) {
userKeys.value = {};
OtherSpecialArrangementValue.value = null;
return;
}
try {
const response = await SessionUser.request(`/superuser/user/keys?user_id=${props.user_id}`, "GET");
userKeys.value = response?.data?.data || {};
OtherSpecialArrangementValue.value = userKeys.value.OtherSpecialArrangement || null;
} catch (error) {
showLoadError();
}
};
const setKey = async (key, value) => {
// Set the key
await SessionUser.request('/superuser/user/keys', 'POST', {
await SessionUser.request("/superuser/user/keys", "POST", {
user_id: props.user_id,
key: key,
value: value
}).then((response) => {
console.log(response);
getKeys();
value: value,
});
};
getKeys();
const saveForm = async () => {
if (props.readOnly) {
return;
}
// Save the form
console.log('Save the form');
await setKey('OtherSpecialArrangement', OtherSpecialArrangementValue.value ?? '').then((response) => {
console.log(response);
}).catch((error) => {
// Show the error with a sweetalert
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved gemningen af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
}
const parseLineBreaks = (text) => {
return text.replace(/\n/g, '<br>');
}
try {
await setKey("OtherSpecialArrangement", OtherSpecialArrangementValue.value ?? "");
await getKeys();
} catch (error) {
showSaveError();
}
};
watch(() => props.user_id, getKeys, { immediate: true });
</script>
<template>
@@ -90,7 +84,12 @@ const parseLineBreaks = (text) => {
<div class="field">
<label class="label is-size-3">{{ t('superuser.user.special_arrangement.label') }}</label>
<div class="control">
<textarea class="textarea" v-model="OtherSpecialArrangementValue" placeholder="Udfyld beskrivelse, hvis der er en intern aftale"></textarea>
<textarea
class="textarea"
v-model="OtherSpecialArrangementValue"
:placeholder="t('superuser.user.special_arrangement.label')"
data-testid="superuser-user-special-arrangement-input"
></textarea>
</div>
</div>
<div class="field" v-if="!props.readOnly">
@@ -103,7 +102,7 @@ const parseLineBreaks = (text) => {
<div class="message is-info" v-if="OtherSpecialArrangementValue">
<div class="message-body">
<span><strong>{{ t('superuser.user.special_arrangement.label') }}</strong><br></span>
<span v-if="OtherSpecialArrangementValue" v-html="parseLineBreaks(OtherSpecialArrangementValue)"></span>
<span v-if="OtherSpecialArrangementValue" class="preserve-lines">{{ OtherSpecialArrangementValue }}</span>
</div>
</div>
</div>
@@ -117,7 +116,7 @@ const parseLineBreaks = (text) => {
<div
class="message-body"
>
<p v-if="OtherSpecialArrangementValue" v-html="parseLineBreaks(OtherSpecialArrangementValue)"></p>
<p v-if="OtherSpecialArrangementValue" class="preserve-lines">{{ OtherSpecialArrangementValue }}</p>
</div>
</div>
</div>
@@ -125,5 +124,7 @@ const parseLineBreaks = (text) => {
</template>
<style scoped>
</style>
.preserve-lines {
white-space: pre-line;
}
</style>
@@ -1,8 +1,8 @@
<script setup>
import { ref, watch } from 'vue';
import { ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
const { t } = useI18n();
@@ -10,91 +10,86 @@ const props = defineProps({
user_id: Number,
readOnly: {
type: Boolean,
default: false
default: false,
},
textOnly: {
type: Boolean,
default: false
}
default: false,
},
});
const OtherVaskeabonnementValue = ref(null);
const userKeys = ref({});
const userKeys = ref([{
key: '',
value: ''
}]);
const showLoadError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unable_to_load"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const showSaveError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unknown_error"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const getKeys = async () => {
// Get the special arrangement
await SessionUser.request('/superuser/user/keys?user_id=' + props.user_id, 'GET').then((response) => {
userKeys.value = response.data.data;
console.log(userKeys.value);
// Check if the key exists
try {
if (userKeys.value.OtherVaskeabonnement) {
OtherVaskeabonnementValue.value = userKeys.value.OtherVaskeabonnement;
}
} catch (e) {
console.log('Key does not exist');
}
}).catch((error) => {
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved hentning af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
if (!props.user_id) {
userKeys.value = {};
OtherVaskeabonnementValue.value = null;
return;
}
try {
const response = await SessionUser.request(`/superuser/user/keys?user_id=${props.user_id}`, "GET");
userKeys.value = response?.data?.data || {};
OtherVaskeabonnementValue.value = userKeys.value.OtherVaskeabonnement || null;
} catch (error) {
showLoadError();
}
};
const setKey = async (key, value) => {
// Set the key
await SessionUser.request('/superuser/user/keys', 'POST', {
await SessionUser.request("/superuser/user/keys", "POST", {
user_id: props.user_id,
key: key,
value: value
}).then((response) => {
console.log(response);
getKeys();
value: value,
});
};
getKeys();
const saveForm = async () => {
if (props.readOnly) {
return;
}
// Save the form
console.log('Save the form');
await setKey('OtherVaskeabonnement', OtherVaskeabonnementValue.value ?? '').then((response) => {
console.log(response);
}).catch((error) => {
// Show the error with a sweetalert
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved gemningen af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
}
const parseLineBreaks = (text) => {
return text.replace(/\n/g, '<br>');
}
try {
await setKey("OtherVaskeabonnement", OtherVaskeabonnementValue.value ?? "");
await getKeys();
} catch (error) {
showSaveError();
}
};
watch(() => props.user_id, () => {
getKeys();
});
watch(() => props.user_id, getKeys, { immediate: true });
</script>
<template>
<form class="form" @submit.prevent="saveForm" v-if="!readOnly && !textOnly">
<form class="form" @submit.prevent="saveForm" v-if="!props.readOnly && !props.textOnly">
<div class="field">
<label class="label is-size-3">{{ t('superuser.user.wash_subscription.label') }}</label>
<div class="control">
<textarea class="textarea" v-model="OtherVaskeabonnementValue" placeholder="Udfyld beskrivelse, hvis der er en intern aftale"></textarea>
<textarea
class="textarea"
v-model="OtherVaskeabonnementValue"
:placeholder="t('superuser.user.wash_subscription.label')"
data-testid="superuser-user-wash-subscription-note-input"
></textarea>
</div>
</div>
<div class="field">
@@ -107,7 +102,7 @@ watch(() => props.user_id, () => {
<div class="message is-info" v-if="OtherVaskeabonnementValue">
<div class="message-body">
<span><strong>{{ t('superuser.user.wash_subscription.label') }}</strong><br></span>
<span v-if="OtherVaskeabonnementValue" v-html="parseLineBreaks(OtherVaskeabonnementValue)"></span>
<span v-if="OtherVaskeabonnementValue" class="preserve-lines">{{ OtherVaskeabonnementValue }}</span>
</div>
</div>
</div>
@@ -121,7 +116,7 @@ watch(() => props.user_id, () => {
<div
class="message-body"
>
<p v-if="OtherVaskeabonnementValue" v-html="parseLineBreaks(OtherVaskeabonnementValue)"></p>
<p v-if="OtherVaskeabonnementValue" class="preserve-lines">{{ OtherVaskeabonnementValue }}</p>
</div>
</div>
</div>
@@ -129,5 +124,7 @@ watch(() => props.user_id, () => {
</template>
<style scoped>
</style>
.preserve-lines {
white-space: pre-line;
}
</style>
@@ -35,7 +35,6 @@ const washSubscriptionVehicles = ref(null);
const load = () => {
if (!props.user.customer_number.value) {
console.log('No customer number found');
return;
}
// Load the vehicles
@@ -142,13 +141,8 @@ const showAddSubscriptionInvoiceFormOtherMonth = () => {
}).then((result) => {
if (result.isConfirmed) {
const selected = result.value;
console.log(selected);
const monthNumber = selected.substring(5, 7);
const yearNumber = selected.substring(0, 4);
// Redirect to the invoice creation page
console.log(
'Month: ' + monthNumber + ', Year: ' + yearNumber
);
createVehicleSubscriptionInvoice(monthNumber, yearNumber);
}
});
@@ -164,7 +158,6 @@ const createVehicleSubscriptionInvoice = (month, year) => {
year: year,
}
).then((response) => {
console.log(response);
// Reload the data
load();
// Redirect to the invoice creation page
@@ -1,6 +1,30 @@
<script setup>
import { computed, useAttrs } from "vue";
import DefaultPageWrapper from "@/views/dashboards/DefaultPageWrapper.vue";
import UserLeftMenu from "@/components/menus/user/UserLeftMenu.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
requiredFeature: {
type: String,
default: null,
},
requiredAction: {
type: String,
default: "list",
},
hasPermission: {
type: [Boolean, Function],
default: null,
},
});
const attrs = useAttrs();
const defaultSlots = [
{ name: 'header' },
{ name: 'buttons' },
@@ -9,20 +33,34 @@ const defaultSlots = [
{ name: 'without-formatting' },
]
const resolvedHasPermission = computed(() => {
if (props.hasPermission !== null) {
return typeof props.hasPermission === "function" ? Boolean(props.hasPermission()) : Boolean(props.hasPermission);
}
if (!props.requiredFeature) {
return true;
}
return SessionUser.canAccessCustomerFeature(props.requiredFeature, props.requiredAction);
});
</script>
<template>
<DefaultPageWrapper>
<template v-for="slot in defaultSlots" :key="slot.name">
<slot :name="slot.name"></slot>
</template>
<!-- Left menu -->
<template #left-menu>
<UserLeftMenu />
</template>
</DefaultPageWrapper>
<RestrictedPageWrapper :hasPermission="resolvedHasPermission">
<DefaultPageWrapper v-bind="attrs">
<template v-for="slot in defaultSlots" :key="slot.name">
<slot :name="slot.name"></slot>
</template>
<!-- Left menu -->
<template #left-menu>
<UserLeftMenu />
</template>
</DefaultPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
</style>
</style>

Some files were not shown because too many files have changed in this diff Show More