Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a78e523b2 | ||
|
|
7f68471b85 | ||
|
|
6c1c1def78 | ||
|
|
bb375eb149 | ||
|
|
7e5e6ce680 | ||
|
|
1032f8b4f1 | ||
|
|
744a56c8b2 | ||
|
|
e58372a190 | ||
|
|
19d44b8666 | ||
|
|
92a52d6194 | ||
|
|
db1c0e25c0 | ||
|
|
04860a7304 | ||
|
|
d8d67301ed | ||
|
|
da86b69b2c | ||
|
|
44e3167278 | ||
|
|
5a5cc0bed3 | ||
|
|
d84e9e3406 | ||
|
|
b8959cb7bb |
@@ -158,6 +158,11 @@ jobs:
|
||||
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"
|
||||
else
|
||||
head_ref="$HEAD_SHA"
|
||||
fi
|
||||
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -187,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=""
|
||||
@@ -232,6 +238,7 @@ jobs:
|
||||
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
|
||||
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
||||
--env MATRIX_SUITE="$MATRIX_SUITE" \
|
||||
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
||||
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
|
||||
@@ -365,8 +372,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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,34 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/superuser-roles-permissions.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: [
|
||||
/^src\/services\/superuserDepartmentOverview\.js$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
|
||||
],
|
||||
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],
|
||||
@@ -55,8 +89,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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -101,6 +101,7 @@ export const ownedFilesByRole = {
|
||||
"superuser-customer-complaints.spec.ts",
|
||||
"superuser-customers-mass-import.spec.ts",
|
||||
"superuser-department-branding.spec.js",
|
||||
"superuser-department-overview.spec.js",
|
||||
"superuser-department-gates.spec.ts",
|
||||
"superuser-department-lanes.spec.ts",
|
||||
"superuser-department-pricing-custom-only.spec.ts",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+19
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"> </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"> </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,6 +5,7 @@ import {
|
||||
getProductsCategory,
|
||||
loadOrderItems,
|
||||
productCategoryAllowed,
|
||||
isProductRestricted,
|
||||
setProductsCategory,
|
||||
getUserProductDiscount,
|
||||
showFakeCreateOrderItem,
|
||||
@@ -13,7 +14,8 @@ import {
|
||||
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);
|
||||
@@ -593,7 +599,7 @@ const getRecommendedProductPrice = (productId) => {
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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": {
|
||||
@@ -6026,6 +6048,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 +6065,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 +6092,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 +6128,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",
|
||||
|
||||
@@ -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": {
|
||||
@@ -6115,6 +6137,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 +6154,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 +6181,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 +6217,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",
|
||||
|
||||
@@ -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": {
|
||||
@@ -5858,6 +5880,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 +5897,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 +5924,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 +5960,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",
|
||||
|
||||
@@ -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": {
|
||||
@@ -4567,6 +4578,82 @@
|
||||
"subtitle": "@:{'templates.generated.compat.superuser_dashboard.departments.subtitle'}",
|
||||
"title": "@:common.departments"
|
||||
},
|
||||
"department_navigation": {
|
||||
"overview": "Overview",
|
||||
"modules": "Modules",
|
||||
"branding": "Profile & Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"department_overview": {
|
||||
"title": "Department overview",
|
||||
"subtitle": "Operational overview for the selected department",
|
||||
"loading": "Loading department overview",
|
||||
"date_from": "From",
|
||||
"date_to": "To",
|
||||
"range_label": "{from} to {to}",
|
||||
"empty_value": "-",
|
||||
"out_of": "of {total}",
|
||||
"presets": {
|
||||
"today": "Today",
|
||||
"last_seven_days": "Last 7 days"
|
||||
},
|
||||
"metrics": {
|
||||
"bookings": "Bookings",
|
||||
"complaints": "Complaints",
|
||||
"night_washes": "Night washes",
|
||||
"overtime": "Overtime",
|
||||
"products_sold": "Products sold",
|
||||
"revenue": "Revenue",
|
||||
"transactions": "Transactions",
|
||||
"washes": "Washes",
|
||||
"water_usage": "Water"
|
||||
},
|
||||
"units": {
|
||||
"hours": "h",
|
||||
"liters": "L"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product mix",
|
||||
"subtitle": "Tracked wash products in the selected period",
|
||||
"empty": "No product activity for the selected period"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Department profile",
|
||||
"no_description": "No department description",
|
||||
"department_id": "Department ID",
|
||||
"economic_department_id": "Economic department",
|
||||
"branding": "Branding",
|
||||
"created_at": "Created",
|
||||
"updated_at": "Updated"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware readiness",
|
||||
"subtitle": "Gateway-backed lane and relay state",
|
||||
"gateways": "Gateways online",
|
||||
"lanes": "Lanes",
|
||||
"gates": "Gates",
|
||||
"relays": "Relays",
|
||||
"scanners": "Scanners",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"quick_links": {
|
||||
"title": "Department tools",
|
||||
"subtitle": "Open the focused setup areas for this department",
|
||||
"modules": "Modules",
|
||||
"branding": "Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"errors": {
|
||||
"invalid_department": "A valid department is required",
|
||||
"load": "Unable to load the department overview"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"edit_employee_title": "@:employees.edit_employee",
|
||||
"new_employee_subtitle": "@:{'templates.generated.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
||||
|
||||
@@ -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": {
|
||||
@@ -6118,6 +6140,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 +6157,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 +6184,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 +6220,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",
|
||||
|
||||
@@ -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": {
|
||||
@@ -6168,6 +6190,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 +6207,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 +6234,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 +6270,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,82 @@
|
||||
"subtitle": "@:{'phrases.compat.superuser_dashboard.departments.subtitle'}",
|
||||
"title": "@:common.departments"
|
||||
},
|
||||
"department_navigation": {
|
||||
"overview": "Overview",
|
||||
"modules": "Modules",
|
||||
"branding": "Profile & Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"department_overview": {
|
||||
"title": "Department overview",
|
||||
"subtitle": "Operational overview for the selected department",
|
||||
"loading": "Loading department overview",
|
||||
"date_from": "From",
|
||||
"date_to": "To",
|
||||
"range_label": "{from} to {to}",
|
||||
"empty_value": "-",
|
||||
"out_of": "of {total}",
|
||||
"presets": {
|
||||
"today": "Today",
|
||||
"last_seven_days": "Last 7 days"
|
||||
},
|
||||
"metrics": {
|
||||
"bookings": "Bookings",
|
||||
"complaints": "Complaints",
|
||||
"night_washes": "Night washes",
|
||||
"overtime": "Overtime",
|
||||
"products_sold": "Products sold",
|
||||
"revenue": "Revenue",
|
||||
"transactions": "Transactions",
|
||||
"washes": "Washes",
|
||||
"water_usage": "Water"
|
||||
},
|
||||
"units": {
|
||||
"hours": "h",
|
||||
"liters": "L"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product mix",
|
||||
"subtitle": "Tracked wash products in the selected period",
|
||||
"empty": "No product activity for the selected period"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Department profile",
|
||||
"no_description": "No department description",
|
||||
"department_id": "Department ID",
|
||||
"economic_department_id": "Economic department",
|
||||
"branding": "Branding",
|
||||
"created_at": "Created",
|
||||
"updated_at": "Updated"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware readiness",
|
||||
"subtitle": "Gateway-backed lane and relay state",
|
||||
"gateways": "Gateways online",
|
||||
"lanes": "Lanes",
|
||||
"gates": "Gates",
|
||||
"relays": "Relays",
|
||||
"scanners": "Scanners",
|
||||
"issues": "Issues"
|
||||
},
|
||||
"quick_links": {
|
||||
"title": "Department tools",
|
||||
"subtitle": "Open the focused setup areas for this department",
|
||||
"modules": "Modules",
|
||||
"branding": "Branding",
|
||||
"gateways": "Gateways",
|
||||
"stripe": "Stripe",
|
||||
"pricing": "Pricing",
|
||||
"categories": "Categories"
|
||||
},
|
||||
"errors": {
|
||||
"invalid_department": "A valid department is required",
|
||||
"load": "Unable to load the department overview"
|
||||
}
|
||||
},
|
||||
"employees": {
|
||||
"edit_employee_title": "@:employees.edit_employee",
|
||||
"new_employee_subtitle": "@:{'phrases.compat.superuser_dashboard.employees.new_employee_subtitle'}",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
export const getSuperuserDepartmentOverview = (departmentId, { date, dateTo } = {}) => {
|
||||
const normalizedDepartmentId = Number.parseInt(String(departmentId ?? ""), 10);
|
||||
|
||||
if (!Number.isInteger(normalizedDepartmentId) || normalizedDepartmentId <= 0) {
|
||||
return Promise.reject(new Error("A valid department id is required."));
|
||||
}
|
||||
|
||||
const params = {
|
||||
date,
|
||||
};
|
||||
|
||||
if (dateTo) {
|
||||
params.date_to = dateTo;
|
||||
}
|
||||
|
||||
return authenticatedRequest(
|
||||
`/superuser/departments/${encodeURIComponent(String(normalizedDepartmentId))}/overview`,
|
||||
"GET",
|
||||
params
|
||||
);
|
||||
};
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+36
-4
@@ -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>
|
||||
|
||||
|
||||
@@ -2,34 +2,871 @@
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { getEdgeGatewayDepartmentWorkspace } from "@/services/edgeGateways.js";
|
||||
import { getSuperuserDepartmentOverview } from "@/services/superuserDepartmentOverview.js";
|
||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||
import { setDepartment, department, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
// Get the department from the route
|
||||
const router = useRouter()
|
||||
setDepartment(router.currentRoute.value.params.departmentId);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const formatDateInput = (value = new Date()) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return formatDateInput(new Date());
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const addDays = (value, days) => {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return formatDateInput(date);
|
||||
};
|
||||
|
||||
const today = () => formatDateInput(new Date());
|
||||
const isDateValue = (value) => DATE_PATTERN.test(String(value || ""));
|
||||
const normalizeDate = (value, fallback = today()) => (isDateValue(value) ? String(value) : fallback);
|
||||
|
||||
const dateFrom = ref(normalizeDate(route.query.date));
|
||||
const dateTo = ref(normalizeDate(route.query.date_to, dateFrom.value));
|
||||
const department = ref(null);
|
||||
const overview = ref(null);
|
||||
const hardwareWorkspace = ref(null);
|
||||
const loading = ref(false);
|
||||
const hardwareLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const departmentId = computed(() => Number.parseInt(String(route.params.departmentId || ""), 10));
|
||||
const hasDepartmentId = computed(() => Number.isInteger(departmentId.value) && departmentId.value > 0);
|
||||
const pageTitle = computed(() => department.value?.name || t("superuser_dashboard.department_overview.title"));
|
||||
const pageSubtitle = computed(() => t("superuser_dashboard.department_overview.subtitle"));
|
||||
const overviewMetrics = computed(() => overview.value?.metrics || {});
|
||||
const productTiles = computed(() => (Array.isArray(overview.value?.products) ? overview.value.products : []));
|
||||
const periodLabel = computed(() =>
|
||||
dateFrom.value === dateTo.value
|
||||
? dateFrom.value
|
||||
: t("superuser_dashboard.department_overview.range_label", {
|
||||
from: dateFrom.value,
|
||||
to: dateTo.value,
|
||||
})
|
||||
);
|
||||
|
||||
const metricDefinitions = computed(() => [
|
||||
{
|
||||
key: "revenue",
|
||||
icon: "fa-sack-dollar",
|
||||
label: t("superuser_dashboard.department_overview.metrics.revenue"),
|
||||
formatter: formatCurrency,
|
||||
},
|
||||
{
|
||||
key: "washes",
|
||||
icon: "fa-truck",
|
||||
label: t("superuser_dashboard.department_overview.metrics.washes"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "transactions",
|
||||
icon: "fa-receipt",
|
||||
label: t("superuser_dashboard.department_overview.metrics.transactions"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "bookings",
|
||||
icon: "fa-calendar-check",
|
||||
label: t("superuser_dashboard.department_overview.metrics.bookings"),
|
||||
formatter: formatNumber,
|
||||
showOutOf: true,
|
||||
},
|
||||
{
|
||||
key: "products_sold",
|
||||
icon: "fa-boxes-stacked",
|
||||
label: t("superuser_dashboard.department_overview.metrics.products_sold"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "water_usage",
|
||||
icon: "fa-droplet",
|
||||
label: t("superuser_dashboard.department_overview.metrics.water_usage"),
|
||||
formatter: formatNumber,
|
||||
suffix: t("superuser_dashboard.department_overview.units.liters"),
|
||||
},
|
||||
{
|
||||
key: "complaints",
|
||||
icon: "fa-triangle-exclamation",
|
||||
label: t("superuser_dashboard.department_overview.metrics.complaints"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "night_washes",
|
||||
icon: "fa-moon",
|
||||
label: t("superuser_dashboard.department_overview.metrics.night_washes"),
|
||||
formatter: formatNumber,
|
||||
},
|
||||
{
|
||||
key: "overtime",
|
||||
icon: "fa-clock",
|
||||
label: t("superuser_dashboard.department_overview.metrics.overtime"),
|
||||
formatter: formatDecimal,
|
||||
suffix: t("superuser_dashboard.department_overview.units.hours"),
|
||||
},
|
||||
]);
|
||||
|
||||
const quickLinks = computed(() => [
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.modules"),
|
||||
icon: "fa-toggle-on",
|
||||
path: `/superuser/departments/${departmentId.value}/modules`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.branding"),
|
||||
icon: "fa-palette",
|
||||
path: `/superuser/departments/${departmentId.value}/branding`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.gateways"),
|
||||
icon: "fa-network-wired",
|
||||
path: `/superuser/departments/${departmentId.value}/gateways`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.stripe"),
|
||||
icon: "fa-credit-card",
|
||||
path: `/superuser/departments/${departmentId.value}/stripe/terminals/readers`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.pricing"),
|
||||
icon: "fa-tags",
|
||||
path: `/superuser/departments/${departmentId.value}/pricing`,
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.quick_links.categories"),
|
||||
icon: "fa-layer-group",
|
||||
path: `/superuser/departments/${departmentId.value}/categories`,
|
||||
},
|
||||
]);
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("da-DK").format(toNumber(value));
|
||||
}
|
||||
|
||||
function formatDecimal(value) {
|
||||
return new Intl.NumberFormat("da-DK", {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(toNumber(value));
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
const amount = toNumber(value);
|
||||
if (SessionUser.functions?.currency?.toLocal) {
|
||||
return SessionUser.functions.currency.toLocal(amount);
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("da-DK", {
|
||||
style: "currency",
|
||||
currency: "DKK",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return t("superuser_dashboard.department_overview.empty_value");
|
||||
}
|
||||
|
||||
if (SessionUser.functions?.date?.toLocal) {
|
||||
return SessionUser.functions.date.toLocal(value);
|
||||
}
|
||||
|
||||
const date = new Date(String(value).replace(" ", "T"));
|
||||
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat("da-DK").format(date);
|
||||
}
|
||||
|
||||
function metricPayload(key) {
|
||||
return overviewMetrics.value?.[key] || {
|
||||
state: "unavailable",
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
function metricValue(definition) {
|
||||
const metric = metricPayload(definition.key);
|
||||
if (metric.state && metric.state !== "ready") {
|
||||
return t("superuser_dashboard.department_overview.empty_value");
|
||||
}
|
||||
|
||||
const formatted = definition.formatter(metric.value);
|
||||
return definition.suffix ? `${formatted} ${definition.suffix}` : formatted;
|
||||
}
|
||||
|
||||
function metricSecondary(definition) {
|
||||
const metric = metricPayload(definition.key);
|
||||
if (!definition.showOutOf || metric.out_of === undefined || metric.out_of === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return t("superuser_dashboard.department_overview.out_of", {
|
||||
total: formatNumber(metric.out_of),
|
||||
});
|
||||
}
|
||||
|
||||
const asArray = (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const hardwareSummary = computed(() => {
|
||||
const workspace = hardwareWorkspace.value || {};
|
||||
const gateways = asArray(workspace.gateways);
|
||||
const lanes = asArray(workspace.lanes);
|
||||
const gates = asArray(workspace.gates);
|
||||
const relays = asArray(workspace.relays);
|
||||
const scanners = asArray(workspace.scanners || workspace.number_plate_scanners);
|
||||
const issues = asArray(workspace.issues || workspace.diagnostics || workspace.hardware?.issues);
|
||||
const onlineGateways = gateways.filter((gateway) => {
|
||||
const status = String(gateway?.status || gateway?.health || "").toLowerCase();
|
||||
return ["online", "ready", "ok", "healthy"].includes(status);
|
||||
}).length;
|
||||
|
||||
return {
|
||||
gateways: gateways.length,
|
||||
onlineGateways,
|
||||
lanes: lanes.length,
|
||||
gates: gates.length,
|
||||
relays: relays.length,
|
||||
scanners: scanners.length,
|
||||
issues: issues.length,
|
||||
};
|
||||
});
|
||||
|
||||
const hasHardwareSummary = computed(() => hardwareLoading.value || Boolean(hardwareWorkspace.value));
|
||||
const departmentProfileRows = computed(() => [
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.department_id"),
|
||||
value: department.value?.id ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.economic_department_id"),
|
||||
value: department.value?.economic_department_id ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.branding"),
|
||||
value: department.value?.branding ?? t("superuser_dashboard.department_overview.empty_value"),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.created_at"),
|
||||
value: formatDateTime(department.value?.created_at),
|
||||
},
|
||||
{
|
||||
label: t("superuser_dashboard.department_overview.profile.updated_at"),
|
||||
value: formatDateTime(department.value?.updated_at),
|
||||
},
|
||||
]);
|
||||
|
||||
function normalizeRange(from, to) {
|
||||
const normalizedFrom = normalizeDate(from);
|
||||
let normalizedTo = normalizeDate(to, normalizedFrom);
|
||||
|
||||
if (normalizedTo < normalizedFrom) {
|
||||
normalizedTo = normalizedFrom;
|
||||
}
|
||||
|
||||
return {
|
||||
from: normalizedFrom,
|
||||
to: normalizedTo,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceRange(from, to) {
|
||||
const normalized = normalizeRange(from, to);
|
||||
dateFrom.value = normalized.from;
|
||||
dateTo.value = normalized.to;
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
date: normalized.from,
|
||||
date_to: normalized.to === normalized.from ? undefined : normalized.to,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const applyDateInputs = () => replaceRange(dateFrom.value, dateTo.value);
|
||||
const setToday = () => replaceRange(today(), today());
|
||||
const setLastSevenDays = () => replaceRange(addDays(today(), -6), today());
|
||||
|
||||
async function loadOverview() {
|
||||
if (!hasDepartmentId.value) {
|
||||
errorMessage.value = t("superuser_dashboard.department_overview.errors.invalid_department");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await getSuperuserDepartmentOverview(departmentId.value, {
|
||||
date: dateFrom.value,
|
||||
dateTo: dateTo.value,
|
||||
});
|
||||
const payload = response?.data?.data || {};
|
||||
department.value = payload.department || null;
|
||||
overview.value = payload.overview || null;
|
||||
} catch (error) {
|
||||
department.value = null;
|
||||
overview.value = null;
|
||||
errorMessage.value =
|
||||
error?.response?.data?.message || error?.message || t("superuser_dashboard.department_overview.errors.load");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHardware() {
|
||||
if (!hasDepartmentId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
hardwareLoading.value = true;
|
||||
hardwareWorkspace.value = null;
|
||||
|
||||
try {
|
||||
const response = await getEdgeGatewayDepartmentWorkspace(departmentId.value);
|
||||
hardwareWorkspace.value = response?.data?.data || null;
|
||||
} catch (_error) {
|
||||
hardwareWorkspace.value = null;
|
||||
} finally {
|
||||
hardwareLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickLink(path) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.departmentId, route.query.date, route.query.date_to],
|
||||
() => {
|
||||
const normalized = normalizeRange(route.query.date, route.query.date_to || route.query.date);
|
||||
dateFrom.value = normalized.from;
|
||||
dateTo.value = normalized.to;
|
||||
loadOverview();
|
||||
loadHardware();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<DepartmentSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle title="Department" subtitle="Department data" />
|
||||
<PageTitle :title="pageTitle" :subtitle="pageSubtitle" />
|
||||
</template>
|
||||
<div>
|
||||
Department stuff
|
||||
<code>{{ departmentId }}</code>
|
||||
<code>{{ department.id }}</code>
|
||||
<code>{{ department.name }}</code>
|
||||
<code>{{ department.description }}</code>
|
||||
<code>{{ department.created_at }}</code>
|
||||
<code>{{ department.updated_at }}</code>
|
||||
</div>
|
||||
|
||||
<section class="department-overview" data-testid="superuser-department-overview">
|
||||
<div class="overview-toolbar">
|
||||
<div class="overview-period">
|
||||
<label class="date-field">
|
||||
<span>{{ t("superuser_dashboard.department_overview.date_from") }}</span>
|
||||
<input
|
||||
v-model="dateFrom"
|
||||
class="input"
|
||||
data-testid="department-overview-date-from"
|
||||
type="date"
|
||||
@change="applyDateInputs"
|
||||
/>
|
||||
</label>
|
||||
<label class="date-field">
|
||||
<span>{{ t("superuser_dashboard.department_overview.date_to") }}</span>
|
||||
<input
|
||||
v-model="dateTo"
|
||||
class="input"
|
||||
data-testid="department-overview-date-to"
|
||||
type="date"
|
||||
@change="applyDateInputs"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="department-overview-preset-today"
|
||||
type="button"
|
||||
@click="setToday"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-calendar-day" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.presets.today") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light"
|
||||
data-testid="department-overview-preset-week"
|
||||
type="button"
|
||||
@click="setLastSevenDays"
|
||||
>
|
||||
<span class="icon"><i class="fa-solid fa-calendar-week" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.presets.last_seven_days") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="period-chip" data-testid="department-overview-period">
|
||||
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
|
||||
<span>{{ periodLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-overview-error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !overview" class="overview-loading" data-testid="department-overview-loading">
|
||||
<span class="icon"><i class="fa-solid fa-spinner fa-spin" /></span>
|
||||
<span>{{ t("superuser_dashboard.department_overview.loading") }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="kpi-grid">
|
||||
<article
|
||||
v-for="metric in metricDefinitions"
|
||||
:key="metric.key"
|
||||
class="kpi-card"
|
||||
:data-testid="`department-overview-kpi-${metric.key}`"
|
||||
>
|
||||
<div class="kpi-icon">
|
||||
<i :class="['fa-solid', metric.icon]" />
|
||||
</div>
|
||||
<div class="kpi-content">
|
||||
<span class="kpi-label">{{ metric.label }}</span>
|
||||
<strong class="kpi-value">{{ metricValue(metric) }}</strong>
|
||||
<span v-if="metricSecondary(metric)" class="kpi-secondary">{{ metricSecondary(metric) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-main">
|
||||
<section class="overview-panel" data-testid="department-overview-products">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.products.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.products.subtitle") }}</p>
|
||||
</div>
|
||||
<span class="count-badge">{{ productTiles.length }}</span>
|
||||
</div>
|
||||
<div v-if="productTiles.length" class="product-list">
|
||||
<div v-for="product in productTiles" :key="product.slug || product.product_id" class="product-row">
|
||||
<div class="product-title">
|
||||
<strong>{{ product.title }}</strong>
|
||||
<span>{{ product.slug }}</span>
|
||||
</div>
|
||||
<div class="product-stat">
|
||||
<strong>{{ formatNumber(product.value) }}</strong>
|
||||
<span>
|
||||
{{
|
||||
t("superuser_dashboard.department_overview.out_of", {
|
||||
total: formatNumber(product.out_of),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
{{ t("superuser_dashboard.department_overview.products.empty") }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-panel" data-testid="department-overview-profile">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.profile.title") }}</h2>
|
||||
<p>{{ department?.description || t("superuser_dashboard.department_overview.profile.no_description") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="profile-list">
|
||||
<div v-for="row in departmentProfileRows" :key="row.label" class="profile-row">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="hasHardwareSummary" class="overview-panel" data-testid="department-overview-hardware">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.hardware.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.hardware.subtitle") }}</p>
|
||||
</div>
|
||||
<span v-if="hardwareLoading" class="icon"><i class="fa-solid fa-spinner fa-spin" /></span>
|
||||
</div>
|
||||
<div class="hardware-grid">
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.gateways") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.onlineGateways) }} / {{ formatNumber(hardwareSummary.gateways) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.lanes") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.lanes) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.gates") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.gates) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.relays") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.relays) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.scanners") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.scanners) }}</strong>
|
||||
</div>
|
||||
<div class="hardware-stat" :class="{ 'has-issues': hardwareSummary.issues > 0 }">
|
||||
<span>{{ t("superuser_dashboard.department_overview.hardware.issues") }}</span>
|
||||
<strong>{{ formatNumber(hardwareSummary.issues) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-panel quick-links-panel" data-testid="department-overview-quick-links">
|
||||
<div class="panel-heading-row">
|
||||
<div>
|
||||
<h2>{{ t("superuser_dashboard.department_overview.quick_links.title") }}</h2>
|
||||
<p>{{ t("superuser_dashboard.department_overview.quick_links.subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-link-grid">
|
||||
<button
|
||||
v-for="link in quickLinks"
|
||||
:key="link.path"
|
||||
class="quick-link"
|
||||
type="button"
|
||||
@click="openQuickLink(link.path)"
|
||||
>
|
||||
<span class="icon"><i :class="['fa-solid', link.icon]" /></span>
|
||||
<span>{{ link.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</DepartmentSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.department-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
.overview-toolbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.overview-period {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.date-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.date-field span,
|
||||
.kpi-label,
|
||||
.kpi-secondary,
|
||||
.panel-heading-row p,
|
||||
.product-title span,
|
||||
.product-stat span,
|
||||
.hardware-stat span,
|
||||
.profile-row dt {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
align-items: center;
|
||||
background: #eef2ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
color: #3730a3;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
gap: 0.35rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-loading,
|
||||
.empty-state {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
min-height: 5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.kpi-card,
|
||||
.overview-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-height: 6rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
align-items: center;
|
||||
background: #ecfeff;
|
||||
border-radius: 8px;
|
||||
color: #0f766e;
|
||||
display: inline-flex;
|
||||
height: 2.5rem;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.kpi-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
color: #0f172a;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(18rem, 0.75fr);
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-heading-row {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-heading-row h2 {
|
||||
color: #0f172a;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-heading-row p {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
align-items: center;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 999px;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
font-weight: 800;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.product-list,
|
||||
.profile-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-row,
|
||||
.profile-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid #edf2f7;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
min-height: 3.25rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.product-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-title strong {
|
||||
color: #1e293b;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.product-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 5.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.product-stat strong,
|
||||
.hardware-stat strong,
|
||||
.profile-row dd {
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-row dt,
|
||||
.profile-row dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-row dd {
|
||||
max-width: 58%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hardware-grid,
|
||||
.quick-link-grid {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hardware-stat {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 4.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.hardware-stat.has-issues {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
|
||||
.quick-links-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.quick-link {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ec;
|
||||
border-radius: 8px;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-weight: 800;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
min-height: 3rem;
|
||||
padding: 0 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quick-link:hover,
|
||||
.quick-link:focus {
|
||||
border-color: #0f766e;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.overview-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.date-field,
|
||||
.overview-period .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hardware-grid,
|
||||
.quick-link-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-row,
|
||||
.profile-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.product-stat,
|
||||
.profile-row dd {
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+55
-24
@@ -1,31 +1,60 @@
|
||||
<script setup>
|
||||
import { useRouter} from "vue-router";
|
||||
import { ref } from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
// Get the user from the route
|
||||
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId))
|
||||
const { t } = useI18n();
|
||||
|
||||
const tabs = [
|
||||
{ name: 'Overblik', path: '/superuser/departments/' + departmentId.value },
|
||||
{ name: 'Moduler', path: '/superuser/departments/' + departmentId.value + '/modules' },
|
||||
{ name: 'Profil & Branding', path: '/superuser/departments/' + departmentId.value + '/branding' },
|
||||
{ name: 'Gateways', path: '/superuser/departments/' + departmentId.value + '/gateways' },
|
||||
{ name: 'Stripe', path: '/superuser/departments/' + departmentId.value + '/stripe/terminals/readers' },
|
||||
{ name: 'Priser', path: '/superuser/departments/' + departmentId.value + '/pricing' },
|
||||
{ name: SessionUser.objects.categories.meta.title, path: '/superuser/departments/' + departmentId.value + '/categories' },
|
||||
];
|
||||
const departmentId = computed(() => String(route.params.departmentId || ""));
|
||||
const departmentPath = computed(() => `/superuser/departments/${encodeURIComponent(departmentId.value)}`);
|
||||
|
||||
// Get the current path
|
||||
const currentPath = ref(router.currentRoute.value.path);
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.overview"),
|
||||
path: departmentPath.value,
|
||||
active: (path) => path === departmentPath.value,
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.modules"),
|
||||
path: `${departmentPath.value}/modules`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/modules`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.branding"),
|
||||
path: `${departmentPath.value}/branding`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/branding`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.gateways"),
|
||||
path: `${departmentPath.value}/gateways`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/gateways`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.stripe"),
|
||||
path: `${departmentPath.value}/stripe/terminals/readers`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/stripe`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.pricing"),
|
||||
path: `${departmentPath.value}/pricing`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/pricing`),
|
||||
},
|
||||
{
|
||||
name: t("superuser_dashboard.department_navigation.categories"),
|
||||
path: `${departmentPath.value}/categories`,
|
||||
active: (path) => path.startsWith(`${departmentPath.value}/categories`),
|
||||
},
|
||||
]);
|
||||
|
||||
// Get the index of the active tab
|
||||
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
|
||||
const activeTab = computed(() => tabs.value.findIndex((tab) => tab.active(route.path)));
|
||||
|
||||
// Change the tab
|
||||
const changeTab = (index) => {
|
||||
router.push(tabs[index].path);
|
||||
const tab = tabs.value[index];
|
||||
if (tab) {
|
||||
router.push(tab.path);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -33,13 +62,15 @@ const changeTab = (index) => {
|
||||
<div>
|
||||
<div class="tabs is-right">
|
||||
<ul>
|
||||
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
|
||||
<li
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.path"
|
||||
:class="{ 'is-active': activeTab === index }"
|
||||
@click="changeTab(index)"
|
||||
>
|
||||
<a>{{ tab.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
+8
-4
@@ -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">
|
||||
|
||||
+1
-2
@@ -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,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>
|
||||
|
||||
@@ -6,7 +6,12 @@ import OrderBookingsPagination from "@/components/displays/pagination/models/Use
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.bookings.title')"
|
||||
:subtitle="$t('user_dashboard.bookings.subtitle')"
|
||||
required-feature="bookings"
|
||||
required-action="list"
|
||||
>
|
||||
<OrderBookingsPagination :filters="{ only_today: true }"/>
|
||||
<!-- TODO: Complete implementation! -->
|
||||
</UserDashboardPageWrapper>
|
||||
@@ -15,4 +20,4 @@ import OrderBookingsPagination from "@/components/displays/pagination/models/Use
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1877,7 +1877,12 @@ watch(customerNumber, (newVal) => {
|
||||
</div>
|
||||
</template>
|
||||
</ViewportResponsiveWrapper>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.book_subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.bookings.title')"
|
||||
:subtitle="$t('user_dashboard.bookings.book_subtitle')"
|
||||
required-feature="bookings"
|
||||
required-action="add"
|
||||
>
|
||||
|
||||
<template v-if="false">
|
||||
<!-- Debug -->
|
||||
|
||||
@@ -7,7 +7,12 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.bookings.title')"
|
||||
:subtitle="$t('user_dashboard.bookings.subtitle')"
|
||||
required-feature="bookings"
|
||||
required-action="list"
|
||||
>
|
||||
<BookingsPagination/>
|
||||
<!-- TODO: Complete implementation! -->
|
||||
</UserDashboardPageWrapper>
|
||||
@@ -16,4 +21,4 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,12 @@ import FormDisplay from "@/components/displays/FormDisplay.vue";
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.bookings.title')" :subtitle="$t('user_dashboard.bookings.book_subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.bookings.title')"
|
||||
:subtitle="$t('user_dashboard.bookings.book_subtitle')"
|
||||
required-feature="bookings"
|
||||
required-action="add"
|
||||
>
|
||||
<FormDisplay :form_identifier="'BOOK_WASH'" />
|
||||
</UserDashboardPageWrapper>
|
||||
</div>
|
||||
@@ -15,4 +20,4 @@ import FormDisplay from "@/components/displays/FormDisplay.vue";
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -162,13 +162,14 @@ const canEditBooking = (bookingobj) => {
|
||||
// Admins can always edit
|
||||
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission("edit_bookings")) return true;
|
||||
if (!!bookingobj.order_id) return false; // Only allow editing bookings without an order
|
||||
if (SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj)) return true;
|
||||
if (SessionUser.canAccessCustomerFeature("bookings", "edit") && isOwnBooking(bookingobj)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const canDeleteBooking = (bookingobj) => {
|
||||
if (!!bookingobj.order_id) return false;
|
||||
if (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) return true;
|
||||
return SessionUser.hasPermission("edit_own_bookings") && isOwnBooking(bookingobj);
|
||||
return SessionUser.canAccessCustomerFeature("bookings", "delete") && isOwnBooking(bookingobj);
|
||||
};
|
||||
|
||||
const sortedList = computed(() => {
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
|
||||
import MyInvoicesPagination from "@/components/displays/pagination/models/UserDashboard/MyInvoicesPagination.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.invoices.title')" :subtitle="$t('user_dashboard.invoices.subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.invoices.title')"
|
||||
:subtitle="$t('user_dashboard.invoices.subtitle')"
|
||||
:has-permission="() => !SessionUser.isSubuser.value && SessionUser.hasPermission('user')"
|
||||
>
|
||||
<MyInvoicesPagination />
|
||||
</UserDashboardPageWrapper>
|
||||
</div>
|
||||
@@ -14,4 +19,4 @@ import MyInvoicesPagination from "@/components/displays/pagination/models/UserDa
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ import MyMaterialCard from "@/views/dashboards/userDashboard/materials/displays/
|
||||
//import MyInvoicesPagination from "@/components/displays/pagination/models/UserDashboard/MyInvoicesPagination.vue";
|
||||
import type { MaterialDepartmentStorageType } from "@/components/types/MaterialDepartmentStorageType.vue";
|
||||
import type { MaterialType } from "@/components/types/MaterialType.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const material = ref<MaterialType>({
|
||||
label: "Materialelager 1",
|
||||
@@ -62,7 +63,11 @@ const material = ref<MaterialType>({
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.materials.title')" :subtitle="$t('user_dashboard.materials.subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.materials.title')"
|
||||
:subtitle="$t('user_dashboard.materials.subtitle')"
|
||||
:has-permission="() => !SessionUser.isSubuser.value && SessionUser.hasPermission('user')"
|
||||
>
|
||||
<MyMaterialCard
|
||||
v-bind:material
|
||||
/>
|
||||
@@ -72,4 +77,4 @@ const material = ref<MaterialType>({
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import UserDashboardHero from "@/views/dashboards/userDashboard/UserDashboardHero.vue";
|
||||
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
|
||||
import { useRouter} from "vue-router";
|
||||
import {ref, Component, computed } from "vue";
|
||||
@@ -406,7 +405,12 @@ loadOrder();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.orders.single_title') + ' ' + getOrderId()" :subtitle="$t('user_dashboard.orders.single_subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.orders.single_title') + ' ' + getOrderId()"
|
||||
:subtitle="$t('user_dashboard.orders.single_subtitle')"
|
||||
required-feature="orders"
|
||||
required-action="list"
|
||||
>
|
||||
<template v-if="props.visibleComponents.warnings">
|
||||
<div class="message is-warning mt-2" v-if="!isLoading && !economicModuleOrders.invoice_draft_id && !economicModuleOrders.invoice_id">
|
||||
<div class="message-body">
|
||||
@@ -439,7 +443,7 @@ loadOrder();
|
||||
column="po"
|
||||
:parse-function="(value) => order.po"
|
||||
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
|
||||
:permission-check-function="() => true"
|
||||
:permission-check-function="() => SessionUser.canAccessCustomerFeature('orders', 'edit')"
|
||||
:componentWrapper="'span'"
|
||||
/>
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
|
||||
import UserDashboardHero from "@/views/dashboards/userDashboard/UserDashboardHero.vue";
|
||||
import OrdersPagination from "@/components/displays/pagination/models/UserDashboard/OrdersPagination.vue";
|
||||
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
@@ -9,7 +8,12 @@ import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.orders.title')" :subtitle="$t('user_dashboard.orders.subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.orders.title')"
|
||||
:subtitle="$t('user_dashboard.orders.subtitle')"
|
||||
required-feature="orders"
|
||||
required-action="list"
|
||||
>
|
||||
<template #default>
|
||||
<OrdersPagination :allowDropdownToShowOrderContent="SessionUser.hasAttribute('showPricesOnBookingPage')"/>
|
||||
</template>
|
||||
@@ -19,4 +23,4 @@ import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,12 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.vehicles.single_title')" :subtitle="$t('user_dashboard.vehicles.subtitle')" >
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.vehicles.single_title')"
|
||||
:subtitle="$t('user_dashboard.vehicles.subtitle')"
|
||||
required-feature="vehicles"
|
||||
required-action="list"
|
||||
>
|
||||
<VehicleDisplay v-bind:vehicle="vehicle" v-if="vehicle" @reload="fetch" />
|
||||
<!--
|
||||
{{ SessionUser.user.customer_number.value }}
|
||||
@@ -44,4 +49,4 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,12 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.vehicles.title')" :subtitle="$t('user_dashboard.vehicles.subtitle')" >
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.vehicles.title')"
|
||||
:subtitle="$t('user_dashboard.vehicles.subtitle')"
|
||||
required-feature="vehicles"
|
||||
required-action="list"
|
||||
>
|
||||
<VehiclesPagination v-bind:customer_id="SessionUser.user.customer_number.value"/>
|
||||
</UserDashboardPageWrapper>
|
||||
</div>
|
||||
@@ -15,4 +20,4 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script setup>
|
||||
import AddVehicleForm from "@/components/displays/user/vehicles/addVehicleForm.vue";
|
||||
import UserDashboardHero from "@/views/dashboards/userDashboard/UserDashboardHero.vue";
|
||||
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserDashboardHero :title="t('user_vehicles.add_vehicle')" :subtitle="t('user_vehicles.add_vehicle_subtitle')" />
|
||||
<AddVehicleForm />
|
||||
<UserDashboardPageWrapper
|
||||
:title="t('user_vehicles.add_vehicle')"
|
||||
:subtitle="t('user_vehicles.add_vehicle_subtitle')"
|
||||
required-feature="vehicles"
|
||||
required-action="add"
|
||||
>
|
||||
<AddVehicleForm />
|
||||
</UserDashboardPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -97,7 +97,12 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.wash.title')" :subtitle="$t('user_dashboard.wash.subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.wash.title')"
|
||||
:subtitle="$t('user_dashboard.wash.subtitle')"
|
||||
required-feature="selfserve"
|
||||
required-action="list"
|
||||
>
|
||||
<PosDepartmentStepMobile1Location @location-updated="evaluateLocationDepartments" />
|
||||
<div class="columns is-multiline" data-testid="self-serve-wash-home">
|
||||
<div class="column is-12">
|
||||
|
||||
@@ -715,6 +715,7 @@ const activeWashRestoreTimeout = ref<ReturnType<typeof window.setTimeout> | null
|
||||
const recentCompletedRefreshTimeout = ref<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
const recentCompletedRefreshInterval = ref<ReturnType<typeof window.setInterval> | null>(null);
|
||||
const isMyWashStartUnmounted = ref(false);
|
||||
const getMyWashWindow = () => (typeof window === "undefined" ? null : window);
|
||||
|
||||
const registrationOptions = computed(() =>
|
||||
customerVehicles.value
|
||||
@@ -1327,20 +1328,32 @@ refreshRecentlyCompletedWashState();
|
||||
|
||||
const clearRecentCompletedRefreshTimeout = () => {
|
||||
if (recentCompletedRefreshTimeout.value !== null) {
|
||||
window.clearTimeout(recentCompletedRefreshTimeout.value);
|
||||
const timeoutId = recentCompletedRefreshTimeout.value;
|
||||
const hostWindow = getMyWashWindow();
|
||||
if (hostWindow) {
|
||||
hostWindow.clearTimeout(timeoutId);
|
||||
} else {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
}
|
||||
recentCompletedRefreshTimeout.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearRecentCompletedRefreshInterval = () => {
|
||||
if (recentCompletedRefreshInterval.value !== null) {
|
||||
window.clearInterval(recentCompletedRefreshInterval.value);
|
||||
const intervalId = recentCompletedRefreshInterval.value;
|
||||
const hostWindow = getMyWashWindow();
|
||||
if (hostWindow) {
|
||||
hostWindow.clearInterval(intervalId);
|
||||
} else {
|
||||
globalThis.clearInterval(intervalId);
|
||||
}
|
||||
recentCompletedRefreshInterval.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRecentCompletedWashRefresh = (attempt = 0) => {
|
||||
if (isMyWashStartUnmounted.value) {
|
||||
if (isMyWashStartUnmounted.value || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1350,7 +1363,12 @@ const scheduleRecentCompletedWashRefresh = (attempt = 0) => {
|
||||
return;
|
||||
}
|
||||
|
||||
recentCompletedRefreshTimeout.value = window.setTimeout(() => {
|
||||
const hostWindow = getMyWashWindow();
|
||||
if (!hostWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
recentCompletedRefreshTimeout.value = hostWindow.setTimeout(() => {
|
||||
if (isMyWashStartUnmounted.value) {
|
||||
return;
|
||||
}
|
||||
@@ -1360,9 +1378,18 @@ const scheduleRecentCompletedWashRefresh = (attempt = 0) => {
|
||||
};
|
||||
|
||||
const startRecentCompletedWashRefresh = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecentCompletedRefreshInterval();
|
||||
|
||||
recentCompletedRefreshInterval.value = window.setInterval(() => {
|
||||
const hostWindow = getMyWashWindow();
|
||||
if (!hostWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
recentCompletedRefreshInterval.value = hostWindow.setInterval(() => {
|
||||
if (isMyWashStartUnmounted.value) {
|
||||
clearRecentCompletedRefreshInterval();
|
||||
return;
|
||||
@@ -2242,7 +2269,12 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserDashboardPageWrapper :title="$t('user_dashboard.wash.title')" :subtitle="$t('user_dashboard.wash.subtitle')">
|
||||
<UserDashboardPageWrapper
|
||||
:title="$t('user_dashboard.wash.title')"
|
||||
:subtitle="$t('user_dashboard.wash.subtitle')"
|
||||
required-feature="selfserve"
|
||||
required-action="list"
|
||||
>
|
||||
<PosDepartmentStepMobile1Location @location-updated="evaluateLocationDepartments" />
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, primeMockSession } from "./support/network.js";
|
||||
|
||||
const FALLBACK_WARNING_TIMEOUT_MS = 30_000;
|
||||
|
||||
function currentUtcMonthStartIso() {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0)).toISOString();
|
||||
}
|
||||
|
||||
async function expectV2FallbackWarning(locator) {
|
||||
await expect(locator).toBeVisible({ timeout: FALLBACK_WARNING_TIMEOUT_MS });
|
||||
await expect(locator).toContainText(/v2/i, { timeout: FALLBACK_WARNING_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function suppressVueDevtoolsOverlay(page) {
|
||||
await page.addInitScript(() => {
|
||||
const STYLE_ID = "__e2e-hide-vue-devtools";
|
||||
@@ -165,25 +177,24 @@ test.describe("Invoice distribution smoke", () => {
|
||||
});
|
||||
|
||||
test("@smoke fallback mode keeps results and surfaces warning banners", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await prepareInvoiceDistributionPage(page, {
|
||||
invoiceDistributionFirstOrderDate: currentUtcMonthStartIso(),
|
||||
invoiceDistributionForceLegacyFallback: true,
|
||||
invoiceDistributionForceCompareFallback: true,
|
||||
});
|
||||
|
||||
await gotoInvoiceDistribution(page, "/superuser/invoices?activeTab=distribution");
|
||||
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId("distribution-overview-page")).toBeVisible({ timeout: 15_000 });
|
||||
await expectV2FallbackWarning(page.getByTestId("distribution-overview-legacy-fallback-warning"));
|
||||
|
||||
await gotoInvoiceDistribution(page, "/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
|
||||
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId("distribution-month-tabs")).toBeVisible({ timeout: 15_000 });
|
||||
await expectV2FallbackWarning(page.getByTestId("distribution-month-legacy-fallback-warning"));
|
||||
|
||||
await page.getByTestId("distribution-compare-submit").click();
|
||||
await expect(page.getByTestId("distribution-compare-table")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expectV2FallbackWarning(page.getByTestId("distribution-compare-legacy-fallback-warning"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1935,6 +1935,111 @@ test.describe("Invoicing period tab", () => {
|
||||
await expect(page).toHaveURL(/activeTab=period/);
|
||||
});
|
||||
|
||||
test("@smoke period view warns and splits selected multi-month invoice collections", async ({ page }) => {
|
||||
const splitRequests = [];
|
||||
const economicInvoiceRequests = [];
|
||||
await openPeriodView(page, {
|
||||
payloadFactory: () => ({
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
id: 31,
|
||||
customer_number: 4301,
|
||||
customer_name: "Multi Month Fleet",
|
||||
requires_action: true,
|
||||
transactions: [
|
||||
{
|
||||
id: 8801,
|
||||
date: "2026-03-28T10:00:00.000Z",
|
||||
amount: 120,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 88001,
|
||||
},
|
||||
{
|
||||
id: 8802,
|
||||
date: "2026-04-02T10:00:00.000Z",
|
||||
amount: 180,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 88001,
|
||||
},
|
||||
],
|
||||
queue: {
|
||||
has_active_job: false,
|
||||
statuses: [],
|
||||
invoice_collection_ids: [],
|
||||
is_action_blocked: false,
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
invoice_per_order: [],
|
||||
fixed_pricing: [],
|
||||
tank_cleaning: [],
|
||||
special_arrangements: [],
|
||||
vehicle_subscriptions: [],
|
||||
possible_duplicates: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await page.route("**/collected-invoices/split-by-month**", async (route) => {
|
||||
if (
|
||||
route.request().method() !== "POST" ||
|
||||
!matchesApiPath(route.request().url(), "/collected-invoices/split-by-month")
|
||||
) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(route.request().postData() || "{}");
|
||||
splitRequests.push(payload);
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
preview: false,
|
||||
processed_count: 1,
|
||||
changed_count: 1,
|
||||
skipped_count: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/collected-invoices/economic**", async (route) => {
|
||||
if (
|
||||
route.request().method() === "POST" &&
|
||||
matchesApiPath(route.request().url(), "/collected-invoices/economic")
|
||||
) {
|
||||
economicInvoiceRequests.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill(json({ data: {} }));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.getByTestId("invoicing-period-view-selector-all").click();
|
||||
await expect(page.getByTestId("invoicing-period-customer-4301")).toBeVisible();
|
||||
await page.getByTestId("invoicing-period-customer-invoice-4301").click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Orders from multiple months|Ordrer fra flere måneder/i })
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /Split by month|Opdel efter måned/i }).click();
|
||||
|
||||
await expect.poll(() => splitRequests.length).toBe(1);
|
||||
expect(splitRequests[0]).toEqual({
|
||||
dateFrom: "2026-03-28",
|
||||
dateTo: "2026-04-02",
|
||||
invoice_collection_ids: [88001],
|
||||
preview: false,
|
||||
});
|
||||
expect(economicInvoiceRequests).toEqual([]);
|
||||
await expect(page.getByText(/Monthly split completed|Månedsopdeling fuldført/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test("@smoke period view invoices multiple customers with page refresh and independent loading", async ({ page }) => {
|
||||
const token = "superuser-period-parallel-token";
|
||||
const periodRequests = [];
|
||||
|
||||
@@ -53,6 +53,15 @@ const superuserSessionData = {
|
||||
permissions: ["user", "superuser", "limited_backoffice_access", "department_access_1"],
|
||||
};
|
||||
|
||||
const adminLimitedSessionData = {
|
||||
...sessionData,
|
||||
id: 52,
|
||||
customer_number: 12347,
|
||||
email: "admin-manager@example.com",
|
||||
display_name: "Admin Manager",
|
||||
permissions: ["user", "admin", "limited_backoffice_access", "department_access_1"],
|
||||
};
|
||||
|
||||
const assignedDepartments = [{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false }];
|
||||
|
||||
const pricePayload = {
|
||||
@@ -82,6 +91,49 @@ const pricePayload = {
|
||||
],
|
||||
};
|
||||
|
||||
const duplicatePricePayload = {
|
||||
...pricePayload,
|
||||
categories: [
|
||||
{
|
||||
...pricePayload.categories[0],
|
||||
products: [
|
||||
pricePayload.categories[0].products[0],
|
||||
{ ...pricePayload.categories[0].products[0], price: 999 },
|
||||
pricePayload.categories[0].products[1],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
|
||||
const applyPriceRowsToPayload = (
|
||||
payload: typeof pricePayload,
|
||||
priceRows: Array<{ product_id: number; price: number | string }>
|
||||
) => {
|
||||
const updatedPayload = cloneJson(payload);
|
||||
const priceLookup = new Map(priceRows.map((row) => [Number(row.product_id), Number(row.price)]));
|
||||
|
||||
for (const category of updatedPayload.categories) {
|
||||
for (const product of category.products) {
|
||||
if (priceLookup.has(Number(product.id))) {
|
||||
product.price = priceLookup.get(Number(product.id)) ?? product.price;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedPayload;
|
||||
};
|
||||
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const rolePermissionGroups = {
|
||||
viewer: [{ key: "account", capabilities: ["sign_in", "view_own_permissions"] }],
|
||||
cashier: [
|
||||
@@ -210,6 +262,7 @@ const rolesPayload = [
|
||||
const employeesPayload = [
|
||||
{
|
||||
id: 501,
|
||||
user_id: 501,
|
||||
customer_number: 900000501,
|
||||
display_name: "Casey Clerk",
|
||||
email: "casey@example.com",
|
||||
@@ -230,14 +283,16 @@ async function seedLimitedBackofficeSession(page, token = "limited-backoffice-to
|
||||
await seedAuthenticatedState(page, token);
|
||||
}
|
||||
|
||||
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
|
||||
async function mockLimitedBackofficeApi(page, authSessionData = sessionData, options: any = {}) {
|
||||
const calls: string[] = [];
|
||||
const forbiddenCalls: string[] = [];
|
||||
const priceUpdateCalls: unknown[] = [];
|
||||
const employeeCreateCalls: unknown[] = [];
|
||||
const employeeUpdateCalls: unknown[] = [];
|
||||
const loginLinkCalls: string[] = [];
|
||||
const currentPricePayload = {
|
||||
value: cloneJson(options.pricePayload ?? pricePayload),
|
||||
};
|
||||
const currentEmployees = {
|
||||
value: cloneJson(options.employeesPayload ?? employeesPayload),
|
||||
};
|
||||
@@ -291,13 +346,26 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "GET") {
|
||||
await route.fulfill(json({ data: pricePayload }));
|
||||
await route.fulfill(json({ data: currentPricePayload.value }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "PUT") {
|
||||
priceUpdateCalls.push(request.postDataJSON?.() || null);
|
||||
await route.fulfill(json({ data: pricePayload }));
|
||||
const body = request.postDataJSON?.() || null;
|
||||
priceUpdateCalls.push(body);
|
||||
|
||||
if (typeof options.onPriceUpdate === "function") {
|
||||
await options.onPriceUpdate({
|
||||
route,
|
||||
request,
|
||||
body,
|
||||
currentPricePayload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
currentPricePayload.value = applyPriceRowsToPayload(currentPricePayload.value, body?.prices || []);
|
||||
await route.fulfill(json({ data: currentPricePayload.value }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,6 +401,27 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
|
||||
return;
|
||||
}
|
||||
|
||||
const loginLinkMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)\/login-link$/);
|
||||
if (loginLinkMatch && method === "POST") {
|
||||
const employeeId = loginLinkMatch[1];
|
||||
const status = options.loginLinkStatus ?? 200;
|
||||
loginLinkCalls.push(employeeId);
|
||||
await route.fulfill(
|
||||
json(
|
||||
status === 200
|
||||
? {
|
||||
data: {
|
||||
employee_id: Number(employeeId),
|
||||
login_path: `/login/qr?token=${"a".repeat(64)}`,
|
||||
},
|
||||
}
|
||||
: { message: options.loginLinkMessage ?? "Could not create employee login link." },
|
||||
status
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const employeeMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)$/);
|
||||
if (employeeMatch && method === "PUT") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
@@ -369,11 +458,68 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
|
||||
priceUpdateCalls,
|
||||
employeeCreateCalls,
|
||||
employeeUpdateCalls,
|
||||
loginLinkCalls,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Limited backoffice", () => {
|
||||
test("shows the limited backoffice desktop nav item without an icon for limited managers", async ({
|
||||
test("shows the limited backoffice header shortcut only on department-scoped admin pages", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedLimitedBackofficeSession(page);
|
||||
await mockLimitedBackofficeApi(page);
|
||||
|
||||
await page.goto("/admin/1");
|
||||
|
||||
const desktopNavigation = page.getByTestId("desktop-buefy-navigation");
|
||||
const backofficeNavItem = desktopNavigation.locator('a[href="/backoffice"]:visible');
|
||||
const headerShortcut = page.getByTestId("limited-backoffice-header-button");
|
||||
|
||||
await expect(backofficeNavItem).toHaveCount(0);
|
||||
await expect(headerShortcut).toBeVisible();
|
||||
await expect(headerShortcut).toHaveAttribute("href", "/backoffice/departments/1/prices");
|
||||
await expect(headerShortcut).toContainText("Backoffice");
|
||||
await expect(headerShortcut.locator(".icon i.fas.fa-tools")).toHaveCount(1);
|
||||
|
||||
await page.goto("/admin");
|
||||
|
||||
await expect(page.getByTestId("limited-backoffice-header-button")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("hides the limited backoffice header shortcut for superusers", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedLimitedBackofficeSession(page, "limited-backoffice-superuser-token");
|
||||
await mockLimitedBackofficeApi(page, superuserSessionData);
|
||||
|
||||
await page.goto("/admin/1");
|
||||
|
||||
await expect(page.getByTestId("desktop-buefy-navigation").locator('a[href="/backoffice"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByTestId("limited-backoffice-header-button")).toHaveCount(0);
|
||||
await expect(page.getByTestId("superuser-backoffice-header-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("links backoffice department pages to the matching admin department for admin-capable users", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedLimitedBackofficeSession(page, "admin-limited-backoffice-token");
|
||||
await mockLimitedBackofficeApi(page, adminLimitedSessionData);
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
const adminShortcut = page.getByTestId("admin-header-button");
|
||||
|
||||
await expect(adminShortcut).toBeVisible();
|
||||
await expect(adminShortcut).toHaveAttribute("href", "/admin/1");
|
||||
await expect(adminShortcut).toContainText("Departments");
|
||||
await expect(page.getByTestId("limited-backoffice-header-button")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("does not show the admin shortcut on backoffice department pages for limited-only users", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
@@ -383,26 +529,10 @@ test.describe("Limited backoffice", () => {
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
const backofficeNavItem = page.getByTestId("desktop-buefy-navigation").locator('a[href="/backoffice"]:visible');
|
||||
|
||||
await expect(backofficeNavItem).toHaveCount(1);
|
||||
await expect(backofficeNavItem.locator(".icon, i, svg")).toHaveCount(0);
|
||||
await expect(page.getByTestId("admin-header-button")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("hides the limited backoffice desktop nav item for superusers", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedLimitedBackofficeSession(page, "limited-backoffice-superuser-token");
|
||||
await mockLimitedBackofficeApi(page, superuserSessionData);
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
await expect(page.getByTestId("desktop-buefy-navigation").locator('a[href="/backoffice"]:visible')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("limits price management to assigned departments and explicit prices", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
test("limits price management to assigned departments and explicit prices", async ({ page }) => {
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page);
|
||||
|
||||
@@ -419,15 +549,169 @@ test.describe("Limited backoffice", () => {
|
||||
await expect(page.locator("body")).not.toContainText("department_access_1");
|
||||
await expect(page.locator("body")).not.toContainText("limited_backoffice_prices_manage");
|
||||
|
||||
await expect(page.getByTestId("limited-prices-save")).toBeEnabled();
|
||||
await expect(page.getByTestId("limited-prices-save")).toHaveCount(0);
|
||||
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/saved/i);
|
||||
await page.getByTestId("limited-price-input-101").fill("");
|
||||
await expect(page.getByTestId("limited-prices-save")).toBeDisabled();
|
||||
await expect(page.getByTestId("limited-price-validation")).toBeVisible();
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
expect(api.priceUpdateCalls).toEqual([]);
|
||||
expect(api.forbiddenCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("autosaves valid price changes after a typing pause without blur", async ({ page }) => {
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page);
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
const priceInput = page.getByTestId("limited-price-input-101");
|
||||
await expect(priceInput).toHaveValue("125");
|
||||
|
||||
await priceInput.fill("130");
|
||||
|
||||
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/pending/i);
|
||||
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
|
||||
await expect(priceInput).toBeFocused();
|
||||
expect(api.priceUpdateCalls[0]).toEqual({
|
||||
prices: [
|
||||
{ product_id: 101, price: "130" },
|
||||
{ product_id: 102, price: "95" },
|
||||
],
|
||||
});
|
||||
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/saved/i);
|
||||
});
|
||||
|
||||
test("flushes a valid pending price change on blur", async ({ page }) => {
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page);
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
const priceInput = page.getByTestId("limited-price-input-101");
|
||||
await priceInput.fill("133");
|
||||
await priceInput.blur();
|
||||
|
||||
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
|
||||
expect(api.priceUpdateCalls[0]).toEqual({
|
||||
prices: [
|
||||
{ product_id: 101, price: "133" },
|
||||
{ product_id: 102, price: "95" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps newer edits when an older autosave response resolves late", async ({ page }) => {
|
||||
const firstSave = deferred();
|
||||
let saveCount = 0;
|
||||
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page, sessionData, {
|
||||
onPriceUpdate: async ({ route, body, currentPricePayload }) => {
|
||||
saveCount += 1;
|
||||
|
||||
if (saveCount === 1) {
|
||||
await firstSave.promise;
|
||||
await route.fulfill(json({ data: pricePayload }));
|
||||
return;
|
||||
}
|
||||
|
||||
currentPricePayload.value = applyPriceRowsToPayload(currentPricePayload.value, body?.prices || []);
|
||||
await route.fulfill(json({ data: currentPricePayload.value }));
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
const priceInput = page.getByTestId("limited-price-input-101");
|
||||
await priceInput.fill("130");
|
||||
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
|
||||
|
||||
await priceInput.fill("140");
|
||||
await page.waitForTimeout(700);
|
||||
expect(api.priceUpdateCalls.length).toBe(1);
|
||||
|
||||
firstSave.resolve();
|
||||
|
||||
await expect.poll(() => api.priceUpdateCalls.length).toBe(2);
|
||||
await expect(priceInput).toHaveValue("140");
|
||||
expect(api.priceUpdateCalls[1]).toEqual({
|
||||
prices: [
|
||||
{ product_id: 101, price: "140" },
|
||||
{ product_id: 102, price: "95" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps failed autosaves dirty and retries on the next valid edit", async ({ page }) => {
|
||||
let shouldFail = true;
|
||||
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page, sessionData, {
|
||||
onPriceUpdate: async ({ route, body, currentPricePayload }) => {
|
||||
if (shouldFail) {
|
||||
shouldFail = false;
|
||||
await route.fulfill(json({ message: "Price save failed" }, 422));
|
||||
return;
|
||||
}
|
||||
|
||||
currentPricePayload.value = applyPriceRowsToPayload(currentPricePayload.value, body?.prices || []);
|
||||
await route.fulfill(json({ data: currentPricePayload.value }));
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
const priceInput = page.getByTestId("limited-price-input-101");
|
||||
await priceInput.fill("130");
|
||||
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
|
||||
await expect(page.getByTestId("limited-prices-error")).toContainText("Price save failed");
|
||||
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/pending/i);
|
||||
|
||||
await priceInput.fill("131");
|
||||
|
||||
await expect.poll(() => api.priceUpdateCalls.length).toBe(2);
|
||||
await expect(page.getByTestId("limited-prices-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/saved/i);
|
||||
expect(api.priceUpdateCalls[1]).toEqual({
|
||||
prices: [
|
||||
{ product_id: 101, price: "131" },
|
||||
{ product_id: 102, price: "95" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("deduplicates repeated products in the price configuration", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page, sessionData, { pricePayload: duplicatePricePayload });
|
||||
|
||||
await page.goto("/backoffice/departments/1/prices");
|
||||
|
||||
await expect(page.getByTestId("limited-price-row-101")).toHaveCount(1);
|
||||
await expect(page.getByTestId("limited-price-row-102")).toHaveCount(1);
|
||||
await expect(page.getByTestId("limited-price-input-101")).toHaveValue("125");
|
||||
|
||||
await page.getByTestId("limited-price-input-101").fill("130");
|
||||
|
||||
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
|
||||
expect(api.priceUpdateCalls[0]).toEqual({
|
||||
prices: [
|
||||
{ product_id: 101, price: "130" },
|
||||
{ product_id: 102, price: "95" },
|
||||
],
|
||||
});
|
||||
expect(api.priceUpdateCalls[0]).not.toEqual({
|
||||
prices: [
|
||||
{ product_id: 101, price: "130" },
|
||||
{ product_id: 101, price: "130" },
|
||||
{ product_id: 102, price: "95" },
|
||||
],
|
||||
});
|
||||
expect(api.forbiddenCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("shows only limited role presets in employee access", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
@@ -438,6 +722,7 @@ test.describe("Limited backoffice", () => {
|
||||
|
||||
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
|
||||
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk");
|
||||
await expect(page.getByTestId("limited-employee-user-id-501")).toHaveText("501");
|
||||
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+45 12345678");
|
||||
await expect(page.locator("#limited-employee-role option")).toHaveCount(5);
|
||||
await expect(page.getByTestId("limited-employee-role")).not.toContainText("Superuser");
|
||||
@@ -448,6 +733,49 @@ test.describe("Limited backoffice", () => {
|
||||
expect(api.forbiddenCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("generates an employee QR login link", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page);
|
||||
|
||||
await page.goto("/backoffice/employees");
|
||||
|
||||
await page.getByTestId("limited-employee-login-link-501").click();
|
||||
|
||||
const modal = page.getByTestId("limited-login-link-modal");
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal).toContainText("Employee login link");
|
||||
await expect(modal).toContainText("Casey Clerk");
|
||||
await expect(page.getByTestId("limited-login-link-qr")).toHaveAttribute("src", /^data:image\/png;base64,/);
|
||||
await expect(page.getByTestId("limited-login-link-url")).toHaveValue(/\/login\/qr\?token=a{64}$/);
|
||||
|
||||
await page.getByTestId("limited-login-link-copy").click();
|
||||
|
||||
await expect(page.getByTestId("limited-login-link-copy-message")).toHaveText("Link copied.");
|
||||
expect(api.loginLinkCalls).toEqual(["501"]);
|
||||
expect(api.forbiddenCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("shows an error when employee QR login link generation fails", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page, sessionData, {
|
||||
loginLinkMessage: "Missing employee management permission.",
|
||||
loginLinkStatus: 403,
|
||||
});
|
||||
|
||||
await page.goto("/backoffice/employees");
|
||||
|
||||
await page.getByTestId("limited-employee-login-link-501").click();
|
||||
|
||||
await expect(page.getByTestId("limited-employees-error")).toHaveText("Missing employee management permission.");
|
||||
await expect(page.getByTestId("limited-login-link-modal")).toHaveCount(0);
|
||||
expect(api.loginLinkCalls).toEqual(["501"]);
|
||||
expect(api.forbiddenCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("shows grouped human-readable role permission help without raw permission keys", async ({ page }, testInfo) => {
|
||||
await seedLimitedBackofficeSession(page);
|
||||
await mockLimitedBackofficeApi(page);
|
||||
|
||||
@@ -257,3 +257,70 @@ test("customer details renders economic customer mobilePhone object as number on
|
||||
await expect(phoneValue).not.toContainText("country_code");
|
||||
await expect(phoneValue).not.toContainText("number");
|
||||
});
|
||||
|
||||
test("only tankcleaning customers can only add tankcleaning products", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||
|
||||
const baseFixture = createPosFixture();
|
||||
const tankCleaningProduct = {
|
||||
id: 66,
|
||||
name: "Tank cleaning 4 spulehoveder",
|
||||
description: "Tankcleaning service",
|
||||
price: 799,
|
||||
subscription_allowed: true,
|
||||
category: 5,
|
||||
piktogram: "truck",
|
||||
apply_category_discount: true,
|
||||
requires_note: false,
|
||||
is_wash: false,
|
||||
display_in_booking_form: true,
|
||||
order_priority: 1,
|
||||
addons: [],
|
||||
};
|
||||
const fixture = createPosFixture({
|
||||
customerAttributesByNumber: {
|
||||
12345679: [{ id: 1, customer_number: 12345679, attribute: "onlyTankCleaning" }],
|
||||
},
|
||||
products: [...baseFixture.products, tankCleaningProduct],
|
||||
departmentCategories: [
|
||||
...baseFixture.departmentCategories,
|
||||
{ id: 13, department_id: 12, category: { id: 5, name: "Tankcleaning", meta: { products: [66] } } },
|
||||
],
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: fixture,
|
||||
});
|
||||
await primeOperatorSession(page, "pos-only-tankcleaning-token");
|
||||
|
||||
const customer = fixture.customersByNumber[12345679];
|
||||
const customerAttributesResponse = page.waitForResponse((response) => {
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
response.url().includes(`/customer/attributes?customer_number=${customer.customerNumber}`)
|
||||
);
|
||||
});
|
||||
await openPosAndSelectCustomer(page, customer);
|
||||
await customerAttributesResponse;
|
||||
|
||||
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await page.getByTestId("pos-product-card-53").first().click();
|
||||
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled();
|
||||
|
||||
await page.locator(".tabs li").filter({ hasText: "Tankcleaning" }).click();
|
||||
await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible();
|
||||
await page.getByTestId("pos-product-card-66").first().click();
|
||||
await expect(page.getByTestId("pos-add-to-cart-66").first()).toBeEnabled();
|
||||
|
||||
const addRequest = page.waitForRequest((request) => {
|
||||
return request.method() === "POST" && request.url().includes("/order/items");
|
||||
});
|
||||
await page.getByTestId("pos-add-to-cart-66").first().click();
|
||||
const request = await addRequest;
|
||||
expect(request.postDataJSON().product_id).toBe(66);
|
||||
});
|
||||
|
||||
@@ -591,6 +591,7 @@ async function mockPosApi(page, fixture) {
|
||||
city: body?.searchResult?.city || "Taastrup",
|
||||
mobilePhone: String(body.contactPhone || body.companyPhone || ""),
|
||||
email: body.contactEmail || body.invoiceEmail || "",
|
||||
ean: String(body.ean || ""),
|
||||
corporateIdentificationNumber: String(body.cvr || "").padStart(8, "0"),
|
||||
barred: false,
|
||||
};
|
||||
@@ -1503,6 +1504,7 @@ test.describe("POS flow", () => {
|
||||
const invoiceEmail = page.getByTestId("pos-desktop-add-customer-invoice-email");
|
||||
const contactEmail = page.getByTestId("pos-desktop-add-customer-contact-email");
|
||||
const contactPhone = page.getByTestId("pos-desktop-add-customer-contact-phone");
|
||||
const ean = page.getByTestId("pos-desktop-add-customer-ean");
|
||||
const submitButton = page.getByTestId("pos-desktop-add-customer-submit");
|
||||
|
||||
await expect(newCustomerButton).toBeVisible();
|
||||
@@ -1517,15 +1519,18 @@ test.describe("POS flow", () => {
|
||||
await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk");
|
||||
await expect(contactEmail).toHaveValue("mikkel@truckwash.dk");
|
||||
await expect(contactPhone).toHaveValue("21754690");
|
||||
await expect(ean).toHaveValue("");
|
||||
|
||||
await companyPhone.fill("55550000");
|
||||
await contactEmail.fill("dispatch@truckwash.test");
|
||||
await ean.fill("5790001234567");
|
||||
await searchInput.fill("43423010");
|
||||
|
||||
await expect(companyPhone).toHaveValue("55550000", { timeout: 10_000 });
|
||||
await expect(contactEmail).toHaveValue("dispatch@truckwash.test");
|
||||
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
|
||||
await expect(contactPhone).toHaveValue("42331128");
|
||||
await expect(ean).toHaveValue("5790001234567");
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
await submitButton.click();
|
||||
@@ -1536,6 +1541,7 @@ test.describe("POS flow", () => {
|
||||
await expect(page.locator(".field.has-addons input[disabled]")).toHaveValue("Wash Group ApS", { timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Ryd" })).toBeVisible();
|
||||
await expect.poll(() => fixture.customerRegistrations.length, { timeout: 10_000 }).toBe(1);
|
||||
expect(fixture.customerRegistrations[0]).toMatchObject({ ean: "5790001234567" });
|
||||
await expect.poll(() => fixture.cvrSearches.length, { timeout: 10_000 }).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -1015,6 +1015,7 @@ test.describe("POS mobile order flow", () => {
|
||||
const invoiceEmail = page.getByTestId("pos-mobile-add-customer-invoice-email");
|
||||
const contactEmail = page.getByTestId("pos-mobile-add-customer-contact-email");
|
||||
const contactPhone = page.getByTestId("pos-mobile-add-customer-contact-phone");
|
||||
const ean = page.getByTestId("pos-mobile-add-customer-ean");
|
||||
const submitButton = page.getByTestId("pos-mobile-add-customer-submit");
|
||||
|
||||
await searchInput.fill("41004355");
|
||||
@@ -1022,10 +1023,12 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk");
|
||||
await expect(contactEmail).toHaveValue("mikkel@truckwash.dk");
|
||||
await expect(contactPhone).toHaveValue("21754690");
|
||||
await expect(ean).toHaveValue("");
|
||||
await expect(submitButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await companyPhone.fill("55550000");
|
||||
await contactEmail.fill("dispatch@truckwash.test");
|
||||
await ean.fill("5790001234567");
|
||||
|
||||
await searchInput.fill("43423010");
|
||||
|
||||
@@ -1033,6 +1036,7 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect(contactEmail).toHaveValue("dispatch@truckwash.test");
|
||||
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
|
||||
await expect(contactPhone).toHaveValue("42331128");
|
||||
await expect(ean).toHaveValue("5790001234567");
|
||||
await expect(submitButton).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
const json = (body, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function installOverviewRoutes(page) {
|
||||
await page.route(apiPathPattern("/superuser/departments/1/overview"), async (route) => {
|
||||
const parsedUrl = new URL(route.request().url());
|
||||
expect(parsedUrl.searchParams.get("date")).toBe("2026-07-06");
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
department: {
|
||||
id: 1,
|
||||
name: "Esbjerg",
|
||||
description: "Skagerrakvej 15",
|
||||
economic_department_id: 42,
|
||||
branding: 14,
|
||||
created_at: "2026-01-01 00:00:00",
|
||||
updated_at: "2026-07-06 09:30:00",
|
||||
},
|
||||
overview: {
|
||||
department_ids: [1],
|
||||
date: "2026-07-06",
|
||||
date_to: "2026-07-06",
|
||||
metrics: {
|
||||
bookings: { state: "ready", value: 3, out_of: 4 },
|
||||
complaints: { state: "ready", value: 1 },
|
||||
night_washes: { state: "ready", value: 2 },
|
||||
revenue: { state: "ready", value: 1234 },
|
||||
washes: { state: "ready", value: 11 },
|
||||
products_sold: { state: "ready", value: 18 },
|
||||
transactions: { state: "ready", value: 9 },
|
||||
water_usage: { state: "ready", value: 250 },
|
||||
overtime: { state: "ready", value: 1.5 },
|
||||
},
|
||||
products: [
|
||||
{
|
||||
product_id: 24,
|
||||
slug: "spot-free-lastbil",
|
||||
title: "Spot Free",
|
||||
state: "ready",
|
||||
value: 4,
|
||||
out_of: 11,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(apiPathPattern("/modules/edge-gateways/workspace/departments/1"), async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
gateways: [
|
||||
{ id: 1, status: "ONLINE" },
|
||||
{ id: 2, status: "OFFLINE" },
|
||||
],
|
||||
lanes: [{ id: 1 }, { id: 2 }],
|
||||
gates: [{ id: 1 }],
|
||||
relays: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
scanners: [{ id: 1 }],
|
||||
issues: [{ key: "gateway-offline" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("superuser department overview", () => {
|
||||
test("renders the operational overview for a selected department", async ({ page }) => {
|
||||
await seedAuthenticatedState(page, "superuser-department-overview-token");
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
});
|
||||
await installOverviewRoutes(page);
|
||||
|
||||
await page.goto("/superuser/departments/1?date=2026-07-06", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("superuser-department-overview")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Esbjerg" })).toBeVisible();
|
||||
await expect(page.getByTestId("department-overview-kpi-revenue")).toContainText("1.234");
|
||||
await expect(page.getByTestId("department-overview-kpi-bookings")).toContainText("of 4");
|
||||
await expect(page.getByTestId("department-overview-products")).toContainText("Spot Free");
|
||||
await expect(page.getByTestId("department-overview-hardware")).toContainText("1 / 2");
|
||||
await expect(page.getByTestId("department-overview-quick-links")).toContainText("Gateways");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user