Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec613880a | ||
|
|
6ca00dfb32 |
@@ -112,8 +112,6 @@ jobs:
|
||||
env:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_VIDEO_MODE: on-first-retry
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
@@ -142,7 +140,6 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -155,13 +152,8 @@ jobs:
|
||||
else
|
||||
base_ref="$PUSH_BEFORE_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"
|
||||
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
@@ -231,10 +223,7 @@ jobs:
|
||||
--env CI="${CI:-}" \
|
||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
||||
--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" \
|
||||
|
||||
@@ -7,18 +7,14 @@ export const fallbackChangePatterns = [
|
||||
/^vite\.config\.js$/u,
|
||||
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
|
||||
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
|
||||
/^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/u,
|
||||
/^scripts\/run-playwright/u,
|
||||
/^tests\/e2e\/(?:support|fixtures)\//u,
|
||||
];
|
||||
|
||||
export const sourceMappings = [
|
||||
{
|
||||
name: "auth",
|
||||
patterns: [
|
||||
/^src\/(?:views|components|middleware)\/.*auth/iu,
|
||||
/^src\/views\/auth\//u,
|
||||
/^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u,
|
||||
],
|
||||
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
|
||||
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
@@ -28,25 +24,6 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/navigation.smoke.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "superuser-roles-permissions",
|
||||
patterns: [
|
||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u,
|
||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
||||
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: "booking",
|
||||
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||
@@ -65,19 +42,8 @@ export const sourceMappings = [
|
||||
},
|
||||
{
|
||||
name: "pos",
|
||||
patterns: [
|
||||
/\/pos[/-]/iu,
|
||||
/POS/iu,
|
||||
/^src\/assets\/pos\.css$/u,
|
||||
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
|
||||
/^src\/features\/customer\/customerProductRules\.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",
|
||||
],
|
||||
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"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
@@ -86,21 +52,6 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/edge-gateways.smoke.spec.js", "tests/e2e/edge-gateways.routes.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "admin-department-notifications",
|
||||
patterns: [
|
||||
/^src\/components\/displays\/department\/notifications\//u,
|
||||
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "limited-backoffice",
|
||||
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
|
||||
specs: ["tests/e2e/limited-backoffice.spec.ts"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "invoicing",
|
||||
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
|
||||
@@ -123,15 +74,6 @@ export const sourceMappings = [
|
||||
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
||||
projects: chromiumProjects,
|
||||
},
|
||||
{
|
||||
name: "superuser-department-pricing",
|
||||
patterns: [
|
||||
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u,
|
||||
/^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u,
|
||||
],
|
||||
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
|
||||
projects: ["chromium-desktop"],
|
||||
},
|
||||
{
|
||||
name: "self-serve",
|
||||
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
|
||||
|
||||
@@ -57,7 +57,6 @@ export const ownedFilesByRole = {
|
||||
admin: [
|
||||
"admin-bookings-mobile.spec.ts",
|
||||
"admin-daily-report.spec.ts",
|
||||
"admin-department-notifications.spec.ts",
|
||||
"admin-department-visibility.spec.ts",
|
||||
"admin-overview-mobile.spec.ts",
|
||||
"admin-overview-night-washes.spec.ts",
|
||||
@@ -101,14 +100,11 @@ 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",
|
||||
"superuser-departments-archive.spec.ts",
|
||||
"superuser-drafts.spec.ts",
|
||||
"superuser-products-layout.spec.ts",
|
||||
"superuser-roles-permissions.spec.ts",
|
||||
"superuser-system-status.smoke.spec.js",
|
||||
"superuser-users.spec.ts",
|
||||
"superuser-vehicles.smoke.spec.js",
|
||||
|
||||
@@ -261,8 +261,6 @@ function selectChangedTests(changedFiles) {
|
||||
specProjects: new Map(),
|
||||
mappedFiles: [],
|
||||
unmappedFiles: [],
|
||||
directSpecFiles: [],
|
||||
skippedDirectSpecFiles: [],
|
||||
fallback: false,
|
||||
};
|
||||
|
||||
@@ -272,7 +270,8 @@ function selectChangedTests(changedFiles) {
|
||||
const file = normalizePath(rawFile);
|
||||
|
||||
if (isE2eSpec(file)) {
|
||||
selection.directSpecFiles.push(file);
|
||||
addSpec(selection, file, selectedProjects);
|
||||
selection.mappedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -296,26 +295,13 @@ function selectChangedTests(changedFiles) {
|
||||
|
||||
selection.mappedFiles.push(file);
|
||||
for (const mapping of matches) {
|
||||
const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects;
|
||||
const projects = mappedProjects.filter((project) => selectedProjects.includes(project));
|
||||
if (projects.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
|
||||
for (const spec of mapping.specs) {
|
||||
addSpec(selection, spec, projects);
|
||||
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selection.specProjects.size === 0 && !selection.fallback) {
|
||||
for (const file of selection.directSpecFiles) {
|
||||
addSpec(selection, file, selectedProjects);
|
||||
selection.mappedFiles.push(file);
|
||||
}
|
||||
} else {
|
||||
selection.skippedDirectSpecFiles.push(...selection.directSpecFiles);
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
@@ -384,12 +370,6 @@ async function runChangedSelection(selection) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (selection.skippedDirectSpecFiles.length > 0) {
|
||||
console.log(
|
||||
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, group] of groups.entries()) {
|
||||
for (const project of group.projects) {
|
||||
const code = await runPlaywright({
|
||||
|
||||
@@ -6,7 +6,6 @@ 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);
|
||||
@@ -84,6 +83,23 @@ 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.
|
||||
@@ -146,19 +162,37 @@ const doesAddonHaveNoteRequirement = (addon) => {
|
||||
};
|
||||
|
||||
const isAddonRestricted = (addon) => {
|
||||
const addonProduct = getAddonProduct(addon);
|
||||
return isProductRestrictedForCustomer({
|
||||
...addonProduct,
|
||||
name: getAddonName(addon) || addonProduct.name,
|
||||
category: addonProduct.category ?? addon?.category,
|
||||
}, customerAttributesSafe.value);
|
||||
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 isProductRestricted = () => {
|
||||
return isProductRestrictedForCustomer({
|
||||
...props.product,
|
||||
name: props.name || props.product?.name,
|
||||
}, customerAttributesSafe.value);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const orderByOrderPriority = (addons) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useWindowSize } from "@vueuse/core";
|
||||
import { BMessage } from "buefy";
|
||||
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
type DateRange = {
|
||||
startDate: Date,
|
||||
@@ -116,11 +115,23 @@ const availableYears = computed(() => (
|
||||
));
|
||||
|
||||
const formatDateInputValue = (date: Date) => {
|
||||
return formatLocalDateOnly(date);
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
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 parseDateInputValue = (value: string) => {
|
||||
return parseLocalDateOnly(value);
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match) {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
};
|
||||
|
||||
const isSameDateInputValue = (left: Date, right: Date) => (
|
||||
|
||||
+16
-72
@@ -4,15 +4,12 @@ import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
|
||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||
import { loadList } from "@/components/pagination/paginatedList.vue";
|
||||
const props = defineProps({
|
||||
objects: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
loadList: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { ref } from 'vue';
|
||||
@@ -25,52 +22,6 @@ const su_object = SessionUser.objects.department_notification_sms;
|
||||
const hasUpdatePermission = () => {
|
||||
return (SessionUser.hasPermission('department_notification_sms_update') || SessionUser.canAccessSuperUser());
|
||||
};
|
||||
|
||||
const enabledSavingIds = ref(new Set());
|
||||
|
||||
const isEnabled = (value) => {
|
||||
return value === true || value === 1 || value === "1" || value === "true";
|
||||
};
|
||||
|
||||
const isEnabledSaving = (id) => {
|
||||
return enabledSavingIds.value.has(Number(id));
|
||||
};
|
||||
|
||||
const setEnabledSaving = (id, saving) => {
|
||||
const nextIds = new Set(enabledSavingIds.value);
|
||||
const normalizedId = Number(id);
|
||||
if (saving) {
|
||||
nextIds.add(normalizedId);
|
||||
} else {
|
||||
nextIds.delete(normalizedId);
|
||||
}
|
||||
enabledSavingIds.value = nextIds;
|
||||
};
|
||||
|
||||
const toggleEnabled = async (obj, event) => {
|
||||
const target = event?.target;
|
||||
const previousValue = isEnabled(obj.enabled);
|
||||
|
||||
if (!target || !hasUpdatePermission() || isEnabledSaving(obj.id)) {
|
||||
if (target) {
|
||||
target.checked = previousValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = Boolean(target.checked);
|
||||
setEnabledSaving(obj.id, true);
|
||||
|
||||
try {
|
||||
await su_object.set.enabled(obj.id, nextValue);
|
||||
await props.loadList();
|
||||
} catch (error) {
|
||||
target.checked = previousValue;
|
||||
console.error('Unable to update department notification SMS active state', error);
|
||||
} finally {
|
||||
setEnabledSaving(obj.id, false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,7 +42,7 @@ const toggleEnabled = async (obj, event) => {
|
||||
<!-- Label -->
|
||||
<EditableTableColumn
|
||||
:object="obj"
|
||||
:loadList="props.loadList"
|
||||
:loadList="loadList"
|
||||
:editFunction="su_object.showEditObjectFieldForm"
|
||||
column="label"
|
||||
:permission-check-function="hasUpdatePermission"
|
||||
@@ -99,7 +50,7 @@ const toggleEnabled = async (obj, event) => {
|
||||
<!-- Phone country code -->
|
||||
<EditableTableColumn
|
||||
:object="obj"
|
||||
:loadList="props.loadList"
|
||||
:loadList="loadList"
|
||||
:editFunction="su_object.showEditObjectFieldForm"
|
||||
column="phone_country_code"
|
||||
:permission-check-function="hasUpdatePermission"
|
||||
@@ -107,27 +58,22 @@ const toggleEnabled = async (obj, event) => {
|
||||
<!-- Phone -->
|
||||
<EditableTableColumn
|
||||
:object="obj"
|
||||
:loadList="props.loadList"
|
||||
:loadList="loadList"
|
||||
:editFunction="su_object.showEditObjectFieldForm"
|
||||
column="phone"
|
||||
:permission-check-function="hasUpdatePermission"
|
||||
/>
|
||||
<!-- Enabled -->
|
||||
<td>
|
||||
<input
|
||||
:id="`department-notification-sms-enabled-${obj.id}`"
|
||||
class="switch is-rounded is-small"
|
||||
type="checkbox"
|
||||
:checked="isEnabled(obj.enabled)"
|
||||
:disabled="!hasUpdatePermission() || isEnabledSaving(obj.id)"
|
||||
:data-testid="`department-notification-sms-enabled-toggle-${obj.id}`"
|
||||
:aria-label="`${su_object.columns.enabled.label}: ${obj.label}`"
|
||||
@change="toggleEnabled(obj, $event)"
|
||||
/>
|
||||
<label :for="`department-notification-sms-enabled-${obj.id}`">
|
||||
{{ isEnabled(obj.enabled) ? t('common.yes') : t('common.no') }}
|
||||
</label>
|
||||
</td>
|
||||
<EditableTableColumn
|
||||
:object="obj"
|
||||
:loadList="loadList"
|
||||
:editFunction="su_object.showEditObjectFieldForm"
|
||||
column="enabled"
|
||||
:parse-function="(value) => {
|
||||
return value ? t('common.yes') : t('common.no');
|
||||
}"
|
||||
:permission-check-function="hasUpdatePermission"
|
||||
/>
|
||||
<!-- Actions -->
|
||||
<td>
|
||||
<ActionSettingsWheelButton class="is-float-right">
|
||||
@@ -135,10 +81,9 @@ const toggleEnabled = async (obj, event) => {
|
||||
<RequiresPermission :permission="'department_notification_sms_delete'">
|
||||
<template #default>
|
||||
<ActionSettingsWheelItem
|
||||
@click="su_object.functions.showDeleteObjectForm(obj.id, props.loadList)"
|
||||
@click="su_object.functions.showDeleteObjectForm(obj.id, loadList)"
|
||||
:icon="SessionUser.objects.global.icons.delete"
|
||||
:label="t('global.delete')"
|
||||
:test-id="`department-notification-sms-delete-${obj.id}`"
|
||||
template="danger"
|
||||
/>
|
||||
</template>
|
||||
@@ -146,7 +91,6 @@ const toggleEnabled = async (obj, event) => {
|
||||
<ActionSettingsWheelItem
|
||||
:icon="SessionUser.objects.global.icons.delete"
|
||||
:label="t('global.delete')"
|
||||
:test-id="`department-notification-sms-delete-${obj.id}`"
|
||||
template="danger"
|
||||
disabled
|
||||
/>
|
||||
@@ -167,4 +111,4 @@ const toggleEnabled = async (obj, event) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,12 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
import {
|
||||
isBlankPosMetadataValue,
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { isBlankPosMetadataValue } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
|
||||
const props = defineProps({
|
||||
order_id: {
|
||||
@@ -126,7 +123,7 @@ const referenceField = createCustomerWishField({
|
||||
key: "reference",
|
||||
label: computed(() => t("common.reference")),
|
||||
source: () => props.reference,
|
||||
saveValue: (value) => saveOrderMetadataField("reference", value, props.order_id),
|
||||
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
|
||||
testIdBase: "pos-order-customer-wishes-reference",
|
||||
isRequired: () => props.referenceRequired,
|
||||
warningStateWhenEmpty: "danger",
|
||||
@@ -137,7 +134,7 @@ const poField = createCustomerWishField({
|
||||
key: "po",
|
||||
label: computed(() => t("admin.pos.po_number")),
|
||||
source: () => props.po,
|
||||
saveValue: (value) => saveOrderMetadataField("po", value, props.order_id),
|
||||
saveValue: (value) => SessionUser.objects.orders.set.po(props.order_id, value),
|
||||
testIdBase: "pos-order-customer-wishes-po",
|
||||
isRequired: () => props.poRequired,
|
||||
warningStateWhenEmpty: "warning",
|
||||
@@ -148,7 +145,7 @@ const safetySealField = createCustomerWishField({
|
||||
key: "safety_seal",
|
||||
label: computed(() => t("admin.pos.safety_seal")),
|
||||
source: () => props.safetySeal,
|
||||
saveValue: (value) => saveOrderMetadataField("safety_seal", value, props.order_id),
|
||||
saveValue: (value) => SessionUser.objects.orders.set.safety_seal(props.order_id, value),
|
||||
testIdBase: "pos-order-customer-wishes-safety-seal",
|
||||
isRequired: () => false,
|
||||
warningStateWhenEmpty: null,
|
||||
@@ -164,15 +161,6 @@ const safetySealField = createCustomerWishField({
|
||||
const fields = computed(() => {
|
||||
return props.showSafetySeal ? [referenceField, poField, safetySealField] : [referenceField, poField];
|
||||
});
|
||||
|
||||
const flushCustomerWishFields = async () => {
|
||||
await Promise.all(fields.value.map((field) => field.autosave.flushAndWait()));
|
||||
};
|
||||
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(flushCustomerWishFields);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unregisterStepSaveBarrier();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
import {
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
|
||||
const props = defineProps({
|
||||
order_id: {
|
||||
@@ -33,7 +30,8 @@ const isEditing = ref(false);
|
||||
const autosave = useOrderMetadataAutosave({
|
||||
source: () => props.note,
|
||||
saveValue: async (value) => {
|
||||
return saveOrderMetadataField("notes", value, props.order_id);
|
||||
await SessionUser.objects.orders.set.notes(props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (savedValue, requestedValue) => {
|
||||
if (typeof props.onSaved === "function") {
|
||||
@@ -70,12 +68,6 @@ const lostFocus = async () => {
|
||||
isEditing.value = false;
|
||||
await autosave.flush();
|
||||
};
|
||||
|
||||
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(() => autosave.flushAndWait());
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unregisterStepSaveBarrier();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||
import {
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
@@ -39,7 +36,8 @@ autosave = useOrderMetadataAutosave({
|
||||
source: () => props.modelValue,
|
||||
normalizeValue: normalizeRegistrationDraft,
|
||||
saveValue: async (value) => {
|
||||
return saveOrderMetadataField(fieldKey.value, value, props.order_id);
|
||||
await SessionUser.objects.orders.set[fieldKey.value](props.order_id, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async () => {
|
||||
await props.loadOrder();
|
||||
@@ -76,12 +74,6 @@ const closeEditor = async () => {
|
||||
isEditing.value = false;
|
||||
await autosave.flush();
|
||||
};
|
||||
|
||||
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(() => autosave.flushAndWait());
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unregisterStepSaveBarrier();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -30,7 +30,6 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/
|
||||
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
|
||||
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
|
||||
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
||||
import {
|
||||
@@ -378,8 +377,8 @@ const fetchDuplicateOrdersForContext = async (context) => {
|
||||
try {
|
||||
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
|
||||
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
|
||||
todayLocalDateOnly()
|
||||
},created_at-date_to:${todayLocalDateOnly()}`,
|
||||
new Date().toISOString().split("T")[0]
|
||||
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
setBackgroundColor,
|
||||
backgroundColors,
|
||||
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
|
||||
import { onBeforeUnmount, onMounted, ref, watch, computed } from "vue";
|
||||
import { onMounted, ref, watch, computed } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import PosDepartmentStepMobile2Product from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Product.vue";
|
||||
import PosDepartmentStepMobile2LastOrder from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2LastOrder.vue";
|
||||
@@ -43,8 +43,6 @@ import {
|
||||
customer_name,
|
||||
isAddonRestricted,
|
||||
canBuyAdditionalServices,
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||
@@ -198,7 +196,8 @@ const applyPendingBookingFromSelection = async () => {
|
||||
// Set PO number on the order if provided by the booking
|
||||
if (booking.po && typeof booking.po === "string" && booking.po.trim() !== "") {
|
||||
try {
|
||||
order_po.value = await saveOrderMetadataField("po", booking.po, order_id.value);
|
||||
await SessionUser.objects.orders.set.po(order_id.value, booking.po);
|
||||
order_po.value = booking.po.trim();
|
||||
} catch (e) {
|
||||
console.error("Failed to set PO on order from booking", e);
|
||||
}
|
||||
@@ -218,9 +217,9 @@ const applyPendingBookingFromSelection = async () => {
|
||||
const bookingReference = String(fullBookingData.reference ?? fullBookingData.reference_number ?? "").trim();
|
||||
if (bookingReference !== "") {
|
||||
try {
|
||||
const savedReference = await saveOrderMetadataField("reference", bookingReference, order_id.value);
|
||||
persistedReference.value = savedReference;
|
||||
metadata.setReference(savedReference);
|
||||
await SessionUser.objects.orders.set.reference(order_id.value, bookingReference);
|
||||
persistedReference.value = bookingReference;
|
||||
metadata.setReference(bookingReference);
|
||||
hydrateReferenceFromSources();
|
||||
} catch (setRefError) {
|
||||
console.error("Failed to set reference on order from booking:", setRefError);
|
||||
@@ -229,10 +228,10 @@ const applyPendingBookingFromSelection = async () => {
|
||||
const bookingNotes = String(fullBookingData.notes ?? fullBookingData.note ?? "").trim();
|
||||
if (bookingNotes !== "") {
|
||||
try {
|
||||
const savedNotes = await saveOrderMetadataField("notes", bookingNotes, order_id.value);
|
||||
order_notes.value = savedNotes;
|
||||
notes.value = savedNotes;
|
||||
metadata.setNotes(savedNotes);
|
||||
await SessionUser.objects.orders.set.notes(order_id.value, bookingNotes);
|
||||
order_notes.value = bookingNotes;
|
||||
notes.value = bookingNotes;
|
||||
metadata.setNotes(bookingNotes);
|
||||
} catch (setNotesError) {
|
||||
console.error("Failed to set notes on order from booking:", setNotesError);
|
||||
}
|
||||
@@ -244,11 +243,13 @@ const applyPendingBookingFromSelection = async () => {
|
||||
const currentReg2 = normalizeRegistrationValue(reg_2.value);
|
||||
|
||||
if (bookingReg1 && bookingReg1 !== currentReg1) {
|
||||
reg_1.value = await saveOrderMetadataField("reg_1", bookingReg1, order_id.value);
|
||||
reg_1.value = bookingReg1;
|
||||
await SessionUser.objects.orders.set.reg_1(order_id.value, bookingReg1);
|
||||
}
|
||||
|
||||
if (bookingReg2 !== currentReg2) {
|
||||
reg_2.value = await saveOrderMetadataField("reg_2", bookingReg2, order_id.value);
|
||||
reg_2.value = bookingReg2;
|
||||
await SessionUser.objects.orders.set.reg_2(order_id.value, bookingReg2);
|
||||
}
|
||||
|
||||
const bookingMatches = Array.isArray(vehicles.vehicle_1.value?.booking_matches)
|
||||
@@ -537,7 +538,8 @@ const notesAutosave = useOrderMetadataAutosave({
|
||||
return value;
|
||||
}
|
||||
|
||||
return saveOrderMetadataField("notes", value, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.notes(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
order_notes.value = value;
|
||||
@@ -553,7 +555,8 @@ const referenceAutosave = useOrderMetadataAutosave({
|
||||
return value;
|
||||
}
|
||||
|
||||
return saveOrderMetadataField("reference", value, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.reference(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
persistedReference.value = value;
|
||||
@@ -569,7 +572,8 @@ const safetySealAutosave = useOrderMetadataAutosave({
|
||||
return value;
|
||||
}
|
||||
|
||||
return saveOrderMetadataField("safety_seal", value, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.safety_seal(normalizedOrderId, value);
|
||||
return value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
order_safety_seal.value = value;
|
||||
@@ -581,18 +585,6 @@ const stepTwoNotesInput = notesAutosave.draft;
|
||||
const stepTwoReferenceInput = referenceAutosave.draft;
|
||||
const stepTwoSafetySealInput = safetySealAutosave.draft;
|
||||
const showSafetySealField = computed(() => transactionItems.containsWashCertificate());
|
||||
const flushStepTwoMetadataFields = async () => {
|
||||
await Promise.all([
|
||||
notesAutosave.flushAndWait(),
|
||||
referenceAutosave.flushAndWait(),
|
||||
safetySealAutosave.flushAndWait(),
|
||||
]);
|
||||
};
|
||||
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(flushStepTwoMetadataFields);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unregisterStepSaveBarrier();
|
||||
});
|
||||
|
||||
const resolveReferenceFromSources = () => {
|
||||
const orderReference = normalizeReferenceValue(persistedReference.value);
|
||||
|
||||
+7
-44
@@ -22,7 +22,7 @@ import PosDepartmentStep2MobileVehicleSelection
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
import PosDepartmentStepMobile2FloatingCart
|
||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
|
||||
import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -46,7 +46,6 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const canSelectAdditionalItems = computed(() => canBuyAdditionalServices());
|
||||
const checked = ref(props.defaultChecked);
|
||||
// Function to generate a summary from the last order
|
||||
function generateSummary(order: PosOrder): string {
|
||||
@@ -73,12 +72,6 @@ const displaySubtitle = computed(() => {
|
||||
});
|
||||
// Emit event on toggle
|
||||
function onToggle(isOpen: boolean) {
|
||||
if (isOpen && !canSelectAdditionalItems.value) {
|
||||
checked.value = false;
|
||||
pos.views.additionalItemSelection.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
checked.value = isOpen;
|
||||
// Open the additional item selection view if toggled open
|
||||
if (isOpen) {
|
||||
@@ -125,45 +118,17 @@ const getAvailableAdditionalItems = () => {
|
||||
}));
|
||||
}
|
||||
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
|
||||
const isAdditionalItemRestricted = (product: PosProduct) => {
|
||||
if (!canSelectAdditionalItems.value) {
|
||||
return true;
|
||||
}
|
||||
if (isProductRestricted(product)) {
|
||||
return true;
|
||||
}
|
||||
return isAddonRestricted(convertProductToAddon(product));
|
||||
}
|
||||
|
||||
watch(canSelectAdditionalItems, (canSelect) => {
|
||||
if (!canSelect) {
|
||||
checked.value = false;
|
||||
pos.views.additionalItemSelection.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Computed property to filter out restricted additional items based on customer attributes
|
||||
const filteredAdditionalItems = computed(() => {
|
||||
// If additional services are restricted, return empty array
|
||||
if (!canSelectAdditionalItems.value) {
|
||||
if (!canBuyAdditionalServices()) {
|
||||
return [];
|
||||
}
|
||||
// Filter out individually restricted addons
|
||||
return availableAdditionalItems.value.filter((addon: Addon) => {
|
||||
if (isAddonRestricted(addon)) {
|
||||
return false;
|
||||
}
|
||||
if (addon.product && isProductRestricted(addon.product)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon));
|
||||
});
|
||||
const onClickAddOtherProduct = () => {
|
||||
if (!canSelectAdditionalItems.value) {
|
||||
pos.views.additionalItemSelection.value = false;
|
||||
return;
|
||||
}
|
||||
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
|
||||
}
|
||||
|
||||
@@ -194,9 +159,7 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
|
||||
}, { deep: true });
|
||||
|
||||
const onClickAddProduct = async (product: PosProduct) => {
|
||||
if (isAdditionalItemRestricted(product)) {
|
||||
return;
|
||||
}
|
||||
// If the product requires note, open note input.
|
||||
pos.transactionItems.addAdditionalItem(product);
|
||||
// If the view is fullscreen, close it after adding
|
||||
//if (pos.views.additionalItemSelection.value) {
|
||||
@@ -208,7 +171,7 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
<template>
|
||||
<div data-testid="pos-mobile-additional-items">
|
||||
<!-- Minimal view, when not set as fullscreen view -->
|
||||
<WhiteBoxCard :toggleable="canSelectAdditionalItems && pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
|
||||
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
|
||||
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
|
||||
@toggle="onToggle"
|
||||
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)"
|
||||
@@ -238,7 +201,7 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
</div>
|
||||
</template>
|
||||
<!-- Footer -->
|
||||
<template #footer v-if="canSelectAdditionalItems">
|
||||
<template #footer>
|
||||
<!-- Select other product button -->
|
||||
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
|
||||
<span class="icon">
|
||||
@@ -249,7 +212,7 @@ const onClickAddProduct = async (product: PosProduct) => {
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
<!-- Fullscreen view, when selecting other products -->
|
||||
<template v-else-if="canSelectAdditionalItems">
|
||||
<template v-else>
|
||||
<div data-testid="pos-mobile-additional-items-selection">
|
||||
<!-- Categories of products -->
|
||||
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
|
||||
|
||||
+19
-35
@@ -20,7 +20,6 @@ import {
|
||||
hasAttribute,
|
||||
uploadAttachment,
|
||||
restoreStoredPosOrderId,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import * as POSDepartmentProcess from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { errors } from "@/components/request/HandleGlobalError.vue";
|
||||
@@ -97,30 +96,17 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const getActiveOrderId = () => {
|
||||
const parsedOrderId = Number.parseInt(String(order_id.value ?? ""), 10);
|
||||
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
|
||||
const updateReference = () => {
|
||||
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.reference(order_id.value, reference.value);
|
||||
};
|
||||
|
||||
const updateReference = async () => {
|
||||
const activeOrderId = getActiveOrderId();
|
||||
if (activeOrderId) {
|
||||
await saveOrderMetadataField("reference", reference.value, activeOrderId);
|
||||
}
|
||||
const updateNotes = () => {
|
||||
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
|
||||
};
|
||||
|
||||
const updateNotes = async () => {
|
||||
const activeOrderId = getActiveOrderId();
|
||||
if (activeOrderId) {
|
||||
await saveOrderMetadataField("notes", order_notes.value, activeOrderId);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSafetySeal = async () => {
|
||||
const activeOrderId = getActiveOrderId();
|
||||
if (activeOrderId) {
|
||||
await saveOrderMetadataField("safety_seal", order_safety_seal.value, activeOrderId);
|
||||
}
|
||||
const updateSafetySeal = () => {
|
||||
if (!!order_id.value && order_id.value > 0)
|
||||
SessionUser.objects.orders.set.safety_seal(order_id.value, order_safety_seal.value);
|
||||
};
|
||||
|
||||
// Helpers: extracted for clarity and reuse
|
||||
@@ -210,9 +196,9 @@ const step1 = async () => {
|
||||
// Reset the vehicle selection
|
||||
views.vehicleSelection.value = false;
|
||||
// Merge references and notes
|
||||
await mergeReferences();
|
||||
await mergeNotes();
|
||||
await mergeSafetySeal();
|
||||
mergeReferences();
|
||||
mergeNotes();
|
||||
mergeSafetySeal();
|
||||
// We only need to check if the customer requires reference, if the field is empty.
|
||||
if (!isNonEmptyString(reference.value)) {
|
||||
const isValid = await validateReferenceRequirements();
|
||||
@@ -228,39 +214,37 @@ const mergeReferences = () => {
|
||||
if (metadata.getReference() && metadata.getReference().length >= 1) {
|
||||
// If there is no reference, set it to the registration number of vehicle 1
|
||||
reference.value = metadata.getReference();
|
||||
return updateReference();
|
||||
updateReference();
|
||||
} else if (reference.value && reference.value.length >= 1) {
|
||||
// If there is a reference in the field, set it to the metadata
|
||||
metadata.setReference(reference.value);
|
||||
return updateReference();
|
||||
updateReference();
|
||||
} else if (vehicles.vehicle_1.value?.reference && vehicles.vehicle_1.value?.reference.length >= 1) {
|
||||
// If there is no reference, set it to the registration number of vehicle 1
|
||||
reference.value = vehicles.vehicle_1.value?.reference;
|
||||
metadata.setReference(reference.value);
|
||||
return updateReference();
|
||||
updateReference();
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
const mergeNotes = () => {
|
||||
if (metadata.getNotes() && metadata.getNotes().length >= 1) {
|
||||
order_notes.value = metadata.getNotes();
|
||||
return updateNotes();
|
||||
updateNotes();
|
||||
} else if (order_notes.value && order_notes.value.length >= 1) {
|
||||
metadata.setNotes(order_notes.value);
|
||||
return updateNotes();
|
||||
updateNotes();
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
const mergeSafetySeal = () => {
|
||||
const resolvedSafetySeal = getResolvedMobileSafetySeal();
|
||||
order_safety_seal.value = resolvedSafetySeal;
|
||||
syncMobileSafetySealState(resolvedSafetySeal);
|
||||
return updateSafetySeal();
|
||||
updateSafetySeal();
|
||||
};
|
||||
const finalizeStep1 = async () => {
|
||||
await mergeReferences();
|
||||
await mergeNotes();
|
||||
await mergeSafetySeal();
|
||||
mergeReferences();
|
||||
mergeNotes();
|
||||
mergeSafetySeal();
|
||||
// Upload the attachments if there are any (And the order id has been created)
|
||||
if (attachments.getBase64().length > 0 && order_id.value > 0) {
|
||||
const uploadResults = await Promise.all(
|
||||
|
||||
+3
-4
@@ -5,7 +5,6 @@ import {
|
||||
order_safety_seal,
|
||||
reference,
|
||||
clearStoredPosOrderId,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { metadata } from "./PosDepartmentStepMobileFlow.vue";
|
||||
|
||||
@@ -53,9 +52,9 @@ export const persistCurrentMobileOrderMetadata = async () => {
|
||||
order_notes.value = normalizedNotes;
|
||||
|
||||
await Promise.all([
|
||||
saveOrderMetadataField("reference", normalizedReference, normalizedOrderId),
|
||||
saveOrderMetadataField("notes", normalizedNotes, normalizedOrderId),
|
||||
saveOrderMetadataField("safety_seal", normalizedSafetySeal, normalizedOrderId),
|
||||
SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedReference),
|
||||
SessionUser.objects.orders.set.notes(normalizedOrderId, normalizedNotes),
|
||||
SessionUser.objects.orders.set.safety_seal(normalizedOrderId, normalizedSafetySeal),
|
||||
]);
|
||||
|
||||
return normalizedOrderId;
|
||||
|
||||
+5
-16
@@ -1,13 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import {
|
||||
order_id,
|
||||
reg_1,
|
||||
reg_2,
|
||||
reg_3,
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { order_id, reg_1, reg_2, reg_3, getOrderDetails } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import PosDepartmentStepMobile1RegistrationNumberInputField from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputField.vue";
|
||||
import BarcodeScanner from "@/components/viewport/elements/icons/BarcodeScanner.vue";
|
||||
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
|
||||
@@ -110,7 +103,9 @@ const createRegistrationAutosave = (vehicleIndex: number) =>
|
||||
return value;
|
||||
}
|
||||
|
||||
return saveOrderMetadataField(`reg_${vehicleIndex}`, value, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
|
||||
const refreshedOrder = await getOrderDetails(normalizedOrderId);
|
||||
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
|
||||
},
|
||||
onSaved: async (value) => {
|
||||
setPersistedRegistrationValue(vehicleIndex, value);
|
||||
@@ -199,11 +194,6 @@ const flushAllRegistrations = async () => {
|
||||
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flush()));
|
||||
};
|
||||
|
||||
const waitForAllRegistrationSaves = async () => {
|
||||
await Promise.all([1, 2, 3].map((vehicleIndex) => registrationAutosaves[vehicleIndex].flushAndWait()));
|
||||
};
|
||||
const unregisterStepSaveBarrier = registerPosStepSaveBarrier(waitForAllRegistrationSaves);
|
||||
|
||||
// Function to set a value if it is not null
|
||||
const createVehicleSelectionFromSearchResult = (value: PosSearchResult) => ({
|
||||
...value,
|
||||
@@ -276,12 +266,11 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unregisterStepSaveBarrier();
|
||||
void flushAllRegistrations();
|
||||
});
|
||||
|
||||
const closeManualInput = async () => {
|
||||
await waitForAllRegistrationSaves();
|
||||
await flushAllRegistrations();
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
+1
-2
@@ -12,7 +12,6 @@ import PosDepartmentStepMobileButtonNextStep from "@/components/displays/departm
|
||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
// Define the close event to emit when the component is closed
|
||||
const emit = defineEmits(["close"]);
|
||||
/** Display variables */
|
||||
@@ -127,7 +126,7 @@ const liveTransactions = ref(null);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const syncListTransactionHistory = async () => {
|
||||
let dateToday = todayLocalDateOnly(); // Get today's date in YYYY-MM-DD format
|
||||
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
|
||||
isLoading.value = true;
|
||||
// Fetch the list of orders created today for the current department
|
||||
SessionUser.objects.orders.get
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatLocalDateOnly } from '@/services/dateOnly.js';
|
||||
type startEndDate = {
|
||||
start: Date; // The start date of the range
|
||||
end: Date; // The end date of the range
|
||||
@@ -28,7 +27,7 @@ const datePresetFunctions = {
|
||||
month: {
|
||||
// Get the first day of the month based on the provided date first day at 00:00:01
|
||||
firstDayOfMonth: (date: Date): Date => {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); // Set to the first day of the month at 00:00:00
|
||||
return new Date(date.getFullYear(), date.getMonth(), 2, 0, 0, 1, 0); // Set to the first day of the month at 00:00:01
|
||||
},
|
||||
// Get the last day of the month based on the provided date last day at 23:59:59
|
||||
lastDayOfMonth: (date: Date): Date => {
|
||||
@@ -117,7 +116,7 @@ export const datePresets = <datePreset[]>[
|
||||
*/
|
||||
|
||||
const convertToISO = (date: Date): string => {
|
||||
return formatLocalDateOnly(date); // Convert to YYYY-MM-DD format
|
||||
return date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD format
|
||||
};
|
||||
|
||||
export const dateFunctions = {
|
||||
@@ -151,4 +150,4 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template></template>
|
||||
<template></template>
|
||||
+1
-1
@@ -47,7 +47,7 @@ if (router.currentRoute.value.params.departmentId) {
|
||||
<span>{{ t('common.add') }} {{ SessionUser.objects.department_notification_sms.meta.labels.single }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<DepartmentNotificationsPhoneTable :objects="list" :load-list="loadList" />
|
||||
<DepartmentNotificationsPhoneTable :objects="list" />
|
||||
</TableLabeledPagination>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BSwitch } from "buefy";
|
||||
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
@@ -64,7 +63,7 @@ const parseInitialDate = (value) => {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
const parsed = parseLocalDateOnly(value);
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
};
|
||||
|
||||
@@ -130,7 +129,7 @@ const dateFrom = ref(parseInitialDate(props.initialDateFrom));
|
||||
const dateTo = ref(parseInitialDate(props.initialDateTo));
|
||||
|
||||
const parsedDate = (date) => {
|
||||
return parseLocalDateOnly(date);
|
||||
return new Date(date);
|
||||
};
|
||||
|
||||
const reloadScheduled = ref(false);
|
||||
@@ -149,14 +148,14 @@ const actions = {
|
||||
from: {
|
||||
select: (date) => {
|
||||
dateFrom.value = parsedDate(date);
|
||||
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
|
||||
setFilter("StartTime-date_from", date.toISOString().split("T")[0], false);
|
||||
scheduleReload();
|
||||
}
|
||||
},
|
||||
to: {
|
||||
select: (date) => {
|
||||
dateTo.value = parsedDate(date);
|
||||
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
|
||||
setFilter("StartTime-date_to", date.toISOString().split("T")[0], false);
|
||||
scheduleReload();
|
||||
}
|
||||
},
|
||||
|
||||
+4
-4
@@ -109,8 +109,8 @@ import OrdersTable from "@/components/displays/department/pos/orders/ordersTable
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
|
||||
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
|
||||
import {now} from "@vueuse/core";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatLocalDateOnly, parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
@@ -176,8 +176,8 @@ if (props.autoLoad) {
|
||||
const date_from = ref(null);
|
||||
const date_to = ref(null);
|
||||
const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
|
||||
const formattedStartDate = formatLocalDateOnly(newSelectionStartDate);
|
||||
const formattedEndDate = formatLocalDateOnly(newSelectionToDate);
|
||||
const formattedStartDate = newSelectionStartDate.toISOString().split("T")[0];
|
||||
const formattedEndDate = newSelectionToDate.toISOString().split("T")[0];
|
||||
date_from.value = formattedStartDate;
|
||||
date_to.value = formattedEndDate;
|
||||
setFilter("created_at-date_from", formattedStartDate, true);
|
||||
@@ -361,7 +361,7 @@ const doesEndpointMatch = (matcher) => {
|
||||
<!-- Shortcuts for date filters -->
|
||||
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
|
||||
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
|
||||
v-bind:selection="{ startDate: parseLocalDateOnly(date_from || todayLocalDateOnly()), endDate: parseLocalDateOnly(date_to || todayLocalDateOnly()) }"/>
|
||||
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
|
||||
@@ -28,7 +28,6 @@ import {ref, watch} from "vue";
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter();
|
||||
@@ -53,7 +52,7 @@ watch(() => getFilter("status"), (value) => {
|
||||
// If the route starts with /user, set the endpoint to /user/bookings
|
||||
if (router.currentRoute.value.path.startsWith("/user")) {
|
||||
setEndpoint("/user/bookings", false);
|
||||
setFilter("date", todayLocalDateOnly(), false);
|
||||
setFilter("date", new Date().toISOString().split("T")[0], false);
|
||||
setOrder("created_at", "desc");
|
||||
}
|
||||
// If the route starts with /superuser, set the endpoint to /bookings
|
||||
@@ -82,7 +81,7 @@ loadList();
|
||||
const showingToday = ref(true);
|
||||
|
||||
watch(() => getFilter("date"), (value) => {
|
||||
showingToday.value = value === todayLocalDateOnly();
|
||||
showingToday.value = value === new Date().toISOString().split("T")[0];
|
||||
});
|
||||
|
||||
// If the screen is mobile, set the is small variable to true
|
||||
@@ -152,7 +151,7 @@ const showNewOrderBookingsPortal = () => {
|
||||
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? todayLocalDateOnly() : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
|
||||
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? new Date().toISOString().split('T')[0] : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
|
||||
<label for="today"></label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,4 +188,4 @@ const showNewOrderBookingsPortal = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -8,7 +8,6 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
|
||||
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { endOfLocalDate, startOfLocalDate, todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
/**
|
||||
@@ -53,9 +52,11 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
|
||||
setFilter("datetime-date_from", null, false);
|
||||
setFilter("datetime-date_to", null, false);
|
||||
} else {
|
||||
const startOfDay = new Date().setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date().setHours(23, 59, 59, 999);
|
||||
//setFilter('datetime', null, false);
|
||||
setFilter("datetime-date_from", startOfLocalDate(val).toISOString(), false);
|
||||
setFilter("datetime-date_to", endOfLocalDate(val).toISOString(), false);
|
||||
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
|
||||
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
|
||||
}
|
||||
if (autoLoadList) {
|
||||
loadList();
|
||||
@@ -94,7 +95,7 @@ onMounted(() => {
|
||||
departmentFilter.value = value;
|
||||
} else if (key === "only_today" && value === true) {
|
||||
setFilterKey = false; // Since the only_today filter is handled separately
|
||||
onOnlyTodayFilterChange({ target: { value: todayLocalDateOnly() } }, false);
|
||||
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
|
||||
}
|
||||
if (setFilterKey) {
|
||||
setFilter(key, value, false);
|
||||
@@ -169,7 +170,7 @@ onMounted(() => {
|
||||
<div class="select">
|
||||
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
||||
<option value="*">{{ t("common.all") }}</option>
|
||||
<option :value="todayLocalDateOnly()">{{ t("common.yes") }}</option>
|
||||
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +193,7 @@ onMounted(() => {
|
||||
@change="
|
||||
(event) => {
|
||||
onOnlyTodayFilterChange({
|
||||
target: { value: event.target.checked ? todayLocalDateOnly() : '*' },
|
||||
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
|
||||
});
|
||||
}
|
||||
"
|
||||
@@ -225,7 +226,7 @@ onMounted(() => {
|
||||
@change="
|
||||
(event) => {
|
||||
onOnlyTodayFilterChange(
|
||||
{ target: { value: event.target.checked ? todayLocalDateOnly() : '*' } },
|
||||
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
|
||||
false
|
||||
);
|
||||
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
|
||||
|
||||
@@ -52,10 +52,6 @@ loadList();
|
||||
:columnLabels="{ type: SessionUser.objects.vehicles.columns.type.label }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="vehicles-pagination__add-action">
|
||||
<label class="label is-small"> </label>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<!-- Create a new vehicle, if the route is /user -->
|
||||
<div class="vehicles-pagination__add-action"
|
||||
v-if="router.currentRoute.value.path.startsWith('/user')">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
|
||||
import { computed } from "vue";
|
||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
||||
import { ref, computed } from "vue";
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
@@ -20,7 +19,7 @@ defineExpose({
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return formatLocalDateOnly(date.value);
|
||||
return date.value.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// This component is used to display a date input in a pagination display item column.
|
||||
@@ -37,7 +36,8 @@ const formattedDate = computed(() => {
|
||||
class="input"
|
||||
:value="formattedDate"
|
||||
@input="(e) => {
|
||||
const newDate = parseLocalDateOnly(e.target.value);
|
||||
const newDate = new Date(e.target.value);
|
||||
console.log('Selected date:', newDate);
|
||||
emit('update:date', newDate);
|
||||
}"
|
||||
/>
|
||||
@@ -47,4 +47,4 @@ const formattedDate = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
|
||||
@@ -9,7 +10,7 @@ import PaginationDisplayTemplateButton
|
||||
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
|
||||
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
startDate: {
|
||||
type: Date,
|
||||
required: true,
|
||||
@@ -45,7 +46,6 @@ function updateStartDate(date: Date) {
|
||||
// Update the end date model when the date is changed
|
||||
function updateEndDate(date: Date) {
|
||||
endDateModel.value = date;
|
||||
emits('update:endDate', date);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -79,4 +79,4 @@ function updateEndDate(date: Date) {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -13,7 +13,6 @@ import { showDownloadWashCertificate } from "@/components/shop/DownloadWashCerti
|
||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||
import Swal from 'sweetalert2';
|
||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
const redirectBookingObjectPage = (objectId) => {
|
||||
// Send the user to the object page
|
||||
window.location.href = `/user/bookings/${objectId}`;
|
||||
@@ -238,8 +237,7 @@ window.addEventListener('resize', () => {
|
||||
* To organize the bookings, we want to show the bookings that are created today first, then yesterday, then all other days
|
||||
* @type {string}
|
||||
*/
|
||||
const currentDate = todayLocalDateOnly();
|
||||
const previousDate = yesterdayLocalDateOnly();
|
||||
const currentDate = new Date().toISOString().split("T")[0];
|
||||
|
||||
/**
|
||||
* Sort the bookings by date
|
||||
@@ -364,7 +362,7 @@ const canUserEditObject = (object) => {
|
||||
</span>
|
||||
<span class="has-text-grey">
|
||||
<!-- Human readable date (Today, Yesterday, etc.) -->
|
||||
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === previousDate ? $t('tables.bookings.yesterday') : object.date }}
|
||||
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0] ? $t('tables.bookings.yesterday') : object.date }}
|
||||
<!-- Number of bookings on the date -->
|
||||
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getProductsCategory,
|
||||
loadOrderItems,
|
||||
productCategoryAllowed,
|
||||
isProductRestricted,
|
||||
setProductsCategory,
|
||||
getUserProductDiscount,
|
||||
showFakeCreateOrderItem,
|
||||
@@ -14,8 +13,7 @@ import {
|
||||
getUserGlobalDiscount,
|
||||
getUserDiscounts,
|
||||
customer_id,
|
||||
customer_attributes,
|
||||
loadCustomerAttributes
|
||||
customer_attributes
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
|
||||
import {createOrderItem} from "@/components/shop/OrdersItems.vue";
|
||||
@@ -153,10 +151,6 @@ 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);
|
||||
@@ -599,7 +593,7 @@ const getRecommendedProductPrice = (productId) => {
|
||||
|
||||
const isRecommendedProductCategoryAllowed = (productId) => {
|
||||
const product = getProductById(productId);
|
||||
return !!product && !isProductRestricted(product);
|
||||
return !!product && productCategoryAllowed(product.category);
|
||||
};
|
||||
|
||||
const addRecommendedProductToOrder = async (productId) => {
|
||||
|
||||
@@ -8,9 +8,9 @@ const props = defineProps(['label', 'isText']);
|
||||
const label = computed(() => props.label || t('admin.pos.buttons.go_back'));
|
||||
|
||||
// Go to previous step
|
||||
const goToPreviousStep = async () => {
|
||||
const goToPreviousStep = () => {
|
||||
// Get the department id
|
||||
await previousStep();
|
||||
previousStep();
|
||||
}
|
||||
|
||||
const emptyIfText = (string) => props.isText ? '' : string;
|
||||
@@ -32,4 +32,4 @@ const emptyIfText = (string) => props.isText ? '' : string;
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,241 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const emit = defineEmits(["close", "created"]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedCustomer = ref(null);
|
||||
const registrationNumber = ref("");
|
||||
const vehicleType = ref("");
|
||||
const washSubscription = ref(false);
|
||||
const reference = ref("");
|
||||
const vehicleTypeOptions = ref([]);
|
||||
const isLoadingVehicleTypes = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const vehicleTypeError = ref("");
|
||||
|
||||
const getCustomerNumber = (customer) => {
|
||||
const parsedValue = Number.parseInt(
|
||||
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
|
||||
10
|
||||
);
|
||||
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const selectedCustomerNumber = computed(() => getCustomerNumber(selectedCustomer.value));
|
||||
|
||||
const normalizedVehicleType = computed(() => {
|
||||
if (vehicleType.value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(String(vehicleType.value), 10);
|
||||
return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : null;
|
||||
});
|
||||
|
||||
const normalizedRegistrationNumber = computed(() => {
|
||||
return registrationNumber.value.trim().toUpperCase();
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return (
|
||||
!isSubmitting.value &&
|
||||
!isLoadingVehicleTypes.value &&
|
||||
selectedCustomerNumber.value !== null &&
|
||||
normalizedRegistrationNumber.value.length > 0 &&
|
||||
normalizedVehicleType.value !== null
|
||||
);
|
||||
});
|
||||
|
||||
const parseErrorMessage = (error) => {
|
||||
return SessionUser.functions.parseErrorMessage(error) || t("vehicles.add_modal.error");
|
||||
};
|
||||
|
||||
const loadVehicleTypes = async () => {
|
||||
isLoadingVehicleTypes.value = true;
|
||||
vehicleTypeError.value = "";
|
||||
|
||||
try {
|
||||
vehicleTypeOptions.value = await SessionUser.objects.vehicles.columns.type.options();
|
||||
} catch (error) {
|
||||
vehicleTypeError.value = parseErrorMessage(error) || t("vehicles.add_modal.type_load_error");
|
||||
} finally {
|
||||
isLoadingVehicleTypes.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (!isSubmitting.value) {
|
||||
emit("close");
|
||||
}
|
||||
};
|
||||
|
||||
const submitVehicle = async () => {
|
||||
if (!canSubmit.value) {
|
||||
errorMessage.value = t("vehicles.add_modal.validation_error");
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
try {
|
||||
const response = await SessionUser.objects.vehicles.add(
|
||||
normalizedVehicleType.value,
|
||||
normalizedRegistrationNumber.value,
|
||||
washSubscription.value,
|
||||
selectedCustomerNumber.value,
|
||||
reference.value.trim() || null
|
||||
);
|
||||
|
||||
emit("created", response);
|
||||
} catch (error) {
|
||||
errorMessage.value = parseErrorMessage(error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadVehicleTypes();
|
||||
await nextTick();
|
||||
document.getElementById("superuser-add-vehicle-customer-search")?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal is-active" data-testid="superuser-add-vehicle-modal">
|
||||
<div class="modal-background" @click="closeModal"></div>
|
||||
<div class="modal-card superuser-add-vehicle-modal">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ t("vehicles.add_modal.title") }}</p>
|
||||
<button
|
||||
class="delete"
|
||||
type="button"
|
||||
:aria-label="t('common.close')"
|
||||
data-testid="superuser-add-vehicle-close"
|
||||
@click="closeModal"
|
||||
></button>
|
||||
</header>
|
||||
|
||||
<section class="modal-card-body">
|
||||
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="superuser-add-vehicle-error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<CustomerSearchSelect
|
||||
v-model="selectedCustomer"
|
||||
input-id="superuser-add-vehicle-customer-search"
|
||||
test-id-prefix="superuser-add-vehicle-customer"
|
||||
:disabled="isSubmitting"
|
||||
:placeholder="t('vehicles.add_modal.customer_placeholder')"
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="superuser-add-vehicle-registration">{{ t("vehicles.form.license_plate") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="superuser-add-vehicle-registration"
|
||||
v-model="registrationNumber"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('vehicles.add_modal.registration_placeholder')"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-registration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="superuser-add-vehicle-type">{{ t("vehicles.form.type") }}</label>
|
||||
<div class="control" :class="{ 'is-loading': isLoadingVehicleTypes }">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
id="superuser-add-vehicle-type"
|
||||
v-model="vehicleType"
|
||||
:disabled="isSubmitting || isLoadingVehicleTypes || vehicleTypeOptions.length === 0"
|
||||
data-testid="superuser-add-vehicle-type"
|
||||
>
|
||||
<option disabled value="">{{ t("vehicles.add_modal.type_placeholder") }}</option>
|
||||
<option v-for="option in vehicleTypeOptions" :key="option.id" :value="option.id">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="vehicleTypeError" class="help is-danger" data-testid="superuser-add-vehicle-type-error">
|
||||
{{ vehicleTypeError }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input
|
||||
v-model="washSubscription"
|
||||
type="checkbox"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-wash-subscription"
|
||||
/>
|
||||
{{ t("objects.vehicles.columns.wash_subscription") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="superuser-add-vehicle-reference">{{ t("common.reference") }}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
id="superuser-add-vehicle-reference"
|
||||
v-model="reference"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('vehicles.add_modal.reference_placeholder')"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-reference"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="modal-card-foot is-justify-content-flex-end">
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
data-testid="superuser-add-vehicle-cancel"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ t("common.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button is-link"
|
||||
type="button"
|
||||
:class="{ 'is-loading': isSubmitting }"
|
||||
:disabled="!canSubmit"
|
||||
data-testid="superuser-add-vehicle-submit"
|
||||
@click="submitVehicle"
|
||||
>
|
||||
{{ t("vehicles.add_modal.submit") }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.superuser-add-vehicle-modal {
|
||||
max-width: min(44rem, calc(100vw - 2rem));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-card-body {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -19,17 +19,17 @@ const SENSITIVE_QUERY_KEYS = /authorization|password|passwd|secret|token|api[_-]
|
||||
const FALLBACK_LABELS = {
|
||||
"error_report.button": "Report error",
|
||||
"error_report.title": "Report error",
|
||||
"error_report.subtitle": "Send recent error details to support. A screenshot is attached when available.",
|
||||
"error_report.subtitle": "Send the current screen and recent error details to support.",
|
||||
"error_report.before_error": "What were you doing before the error occurred?",
|
||||
"error_report.expected": "What did you expect would happen?",
|
||||
"error_report.actual": "What actually happened?",
|
||||
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
|
||||
"error_report.expected_placeholder": "Describe the result you expected to see.",
|
||||
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
|
||||
"error_report.consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
|
||||
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
|
||||
"error_report.submit": "Submit report",
|
||||
"error_report.submitted": "Error report submitted.",
|
||||
"error_report.capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
|
||||
"error_report.capture_failed": "The screen capture failed. Please try again.",
|
||||
"error_report.submit_failed": "The error report could not be submitted.",
|
||||
"error_report.required": "All fields and data collection acceptance are required.",
|
||||
};
|
||||
@@ -277,18 +277,15 @@ const submit = async () => {
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
let screenshot = null;
|
||||
let screenshot = "";
|
||||
try {
|
||||
screenshot = await captureScreenshot();
|
||||
} catch {
|
||||
captureError.value = tr("error_report.capture_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const context = buildContext();
|
||||
context.screenshot_attachment = {
|
||||
status: screenshot ? "stored" : "capture_failed",
|
||||
attached: Boolean(screenshot),
|
||||
};
|
||||
await submitErrorReport({
|
||||
before_error: form.before_error.trim(),
|
||||
expected: form.expected.trim(),
|
||||
@@ -343,7 +340,7 @@ const submit = async () => {
|
||||
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
|
||||
{{ tr("error_report.submitted") }}
|
||||
</div>
|
||||
<div v-if="captureError" class="notification is-warning is-light" data-testid="error-report-capture-warning">
|
||||
<div v-if="captureError" class="notification is-danger is-light">
|
||||
{{ captureError }}
|
||||
</div>
|
||||
<div v-if="submitError" class="notification is-danger is-light">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ref, watch, onMounted } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
// Get the department ID from the URL
|
||||
const route = useRoute();
|
||||
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
|
||||
@@ -74,9 +73,9 @@ const emphasis = ref({
|
||||
"department:" +
|
||||
department_id +
|
||||
",status:pending,date-date_from:" +
|
||||
todayLocalDateOnly() +
|
||||
new Date().toISOString().split("T")[0] +
|
||||
",date-date_to:" +
|
||||
todayLocalDateOnly(),
|
||||
new Date().toISOString().split("T")[0],
|
||||
limit: 1, // Limit to 1 booking, we only need to know if there are any bookings or not
|
||||
page: 1,
|
||||
})
|
||||
@@ -98,7 +97,7 @@ const emphasis = ref({
|
||||
* Check if the department has a daily report for today
|
||||
*/
|
||||
SessionUser.request(SessionUser.objects.department_daily_reports.meta.endpoint + "/get", "GET", {
|
||||
date: todayLocalDateOnly(),
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
id: department_id, // This refers to the department ID
|
||||
})
|
||||
.then((response) => {
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isSearching, searchCustomer, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inputId: {
|
||||
type: String,
|
||||
default: "customer-search-select-input",
|
||||
},
|
||||
testIdPrefix: {
|
||||
type: String,
|
||||
default: "customer-search-select",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "selected", "cleared"]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref("");
|
||||
const showResults = ref(false);
|
||||
const selectedResultIndex = ref(-1);
|
||||
|
||||
const getCustomerNumber = (customer) => {
|
||||
const parsedValue = Number.parseInt(
|
||||
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
|
||||
10
|
||||
);
|
||||
|
||||
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
|
||||
};
|
||||
|
||||
const getCustomerName = (customer) => {
|
||||
return String(customer?.name ?? customer?.customer_name ?? customer?.customerName ?? "").trim();
|
||||
};
|
||||
|
||||
const getCustomerCity = (customer) => {
|
||||
return String(customer?.city ?? customer?.address_city ?? "").trim();
|
||||
};
|
||||
|
||||
const formatCustomer = (customer) => {
|
||||
if (!customer) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const name = getCustomerName(customer);
|
||||
const customerNumber = getCustomerNumber(customer);
|
||||
|
||||
return [name, customerNumber ? `#${customerNumber}` : null].filter(Boolean).join(" - ");
|
||||
};
|
||||
|
||||
const selectedCustomerNumber = computed(() => getCustomerNumber(props.modelValue));
|
||||
const selectedCustomerName = computed(() => getCustomerName(props.modelValue));
|
||||
const selectedCustomerCity = computed(() => getCustomerCity(props.modelValue));
|
||||
const hasResults = computed(() => searchCustomerResults.value.length > 0);
|
||||
const placeholderText = computed(() => props.placeholder || t("vehicles.add_modal.customer_placeholder"));
|
||||
|
||||
const resetSearchResults = () => {
|
||||
searchCustomer(null);
|
||||
selectedResultIndex.value = -1;
|
||||
};
|
||||
|
||||
const setSelectedCustomer = (customer) => {
|
||||
emit("update:modelValue", customer);
|
||||
emit("selected", customer);
|
||||
searchQuery.value = formatCustomer(customer);
|
||||
showResults.value = false;
|
||||
selectedResultIndex.value = -1;
|
||||
};
|
||||
|
||||
const clearSelectedCustomer = async () => {
|
||||
emit("update:modelValue", null);
|
||||
emit("cleared");
|
||||
searchQuery.value = "";
|
||||
showResults.value = false;
|
||||
resetSearchResults();
|
||||
await nextTick();
|
||||
document.getElementById(props.inputId)?.focus();
|
||||
};
|
||||
|
||||
const handleSearchInput = () => {
|
||||
if (props.modelValue) {
|
||||
emit("update:modelValue", null);
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim();
|
||||
selectedResultIndex.value = -1;
|
||||
|
||||
if (!query) {
|
||||
showResults.value = false;
|
||||
resetSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
showResults.value = true;
|
||||
searchCustomer(query);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (searchQuery.value.trim() && hasResults.value) {
|
||||
showResults.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
window.setTimeout(() => {
|
||||
showResults.value = false;
|
||||
selectedResultIndex.value = -1;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (!showResults.value || !hasResults.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
selectedResultIndex.value = Math.min(selectedResultIndex.value + 1, searchCustomerResults.value.length - 1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
selectedResultIndex.value = Math.max(selectedResultIndex.value - 1, 0);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const selectedCustomer = searchCustomerResults.value[selectedResultIndex.value] || searchCustomerResults.value[0];
|
||||
if (selectedCustomer) {
|
||||
setSelectedCustomer(selectedCustomer);
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
showResults.value = false;
|
||||
selectedResultIndex.value = -1;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(customer) => {
|
||||
if (customer) {
|
||||
searchQuery.value = formatCustomer(customer);
|
||||
} else if (!document.activeElement || document.activeElement.id !== props.inputId) {
|
||||
searchQuery.value = "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetSearchResults();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customer-search-select">
|
||||
<div class="field">
|
||||
<label class="label" :for="inputId">{{ t("vehicles.add_modal.customer_label") }}</label>
|
||||
<div class="control has-icons-left" :class="{ 'is-loading': isSearching }">
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="searchQuery"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholderText"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="showResults && hasResults"
|
||||
:data-testid="`${testIdPrefix}-input`"
|
||||
@input="handleSearchInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showResults && hasResults"
|
||||
class="dropdown is-active customer-search-select__dropdown"
|
||||
:data-testid="`${testIdPrefix}-results`"
|
||||
>
|
||||
<div class="dropdown-menu customer-search-select__menu" role="listbox">
|
||||
<div class="dropdown-content">
|
||||
<button
|
||||
v-for="(customer, index) in searchCustomerResults"
|
||||
:key="getCustomerNumber(customer) || index"
|
||||
type="button"
|
||||
class="dropdown-item customer-search-select__option"
|
||||
:class="{ 'is-active': selectedResultIndex === index }"
|
||||
:data-testid="`${testIdPrefix}-option-${index}`"
|
||||
@mousedown.prevent="setSelectedCustomer(customer)"
|
||||
>
|
||||
<span class="customer-search-select__option-main">{{ getCustomerName(customer) }}</span>
|
||||
<span class="customer-search-select__option-meta">
|
||||
#{{ getCustomerNumber(customer) }}
|
||||
<template v-if="getCustomerCity(customer)"> · {{ getCustomerCity(customer) }}</template>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="modelValue" class="customer-search-select__selected" :data-testid="`${testIdPrefix}-selected`">
|
||||
<div>
|
||||
<p class="has-text-weight-semibold">{{ t("vehicles.add_modal.selected_customer") }}</p>
|
||||
<p>{{ selectedCustomerName }}</p>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
#{{ selectedCustomerNumber }}
|
||||
<span v-if="selectedCustomerCity"> · {{ selectedCustomerCity }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-light"
|
||||
:disabled="disabled"
|
||||
:data-testid="`${testIdPrefix}-clear`"
|
||||
@click="clearSelectedCustomer"
|
||||
>
|
||||
{{ t("common.clear") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-search-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.customer-search-select__dropdown,
|
||||
.customer-search-select__menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-search-select__dropdown {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 4.75rem;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.customer-search-select__option {
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-search-select__option-main {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.customer-search-select__option-meta {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.customer-search-select__selected {
|
||||
align-items: flex-start;
|
||||
background: #f5f8fc;
|
||||
border: 1px solid #d8e2ef;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,6 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {ref} from "vue";
|
||||
import i18n from '@/i18n';
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const t = (key) => i18n.global.t(key);
|
||||
|
||||
@@ -242,7 +241,7 @@ export const Bookings = {
|
||||
'/bookings',
|
||||
'GET',
|
||||
{
|
||||
filters: `department:${department},status:pending,date-date_from:${todayLocalDateOnly()},date-date_to:${todayLocalDateOnly()}`,
|
||||
filters: `department:${department},status:pending,date-date_from:${new Date().toISOString().split('T')[0]},date-date_to:${new Date().toISOString().split('T')[0]}`,
|
||||
limit: 100,
|
||||
page: 1
|
||||
}
|
||||
@@ -290,4 +289,4 @@ export const Bookings = {
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
@@ -5,7 +5,6 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {ref} from "vue";
|
||||
import i18n from '@/i18n';
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const t = (key) => i18n.global.t(key);
|
||||
|
||||
@@ -81,7 +80,7 @@ const t = (key) => i18n.global.t(key);
|
||||
water_usage_morning: parseInt(water_usage_morning),
|
||||
water_usage: parseInt(water_usage),
|
||||
notes: notes ? notes : 'Daily report',
|
||||
date: date ? date : todayLocalDateOnly()
|
||||
date: date ? date : new Date().toISOString().split('T')[0]
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script>
|
||||
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { formatLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
/**
|
||||
* The DepartmentTimeBookingsEntries object
|
||||
@@ -231,10 +230,10 @@ export const DepartmentTimeBookingsEntries = {
|
||||
if (!dateFrom) {
|
||||
const lastWeek = new Date();
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
dateFrom = formatLocalDateOnly(lastWeek); // Format as YYYY-MM-DD
|
||||
dateFrom = lastWeek.toISOString().split('T')[0]; // Format as YYYY-MM-DD
|
||||
}
|
||||
if (!dateTo) {
|
||||
dateTo = todayLocalDateOnly(); // Format as YYYY-MM-DD
|
||||
dateTo = new Date().toISOString().split('T')[0]; // Format as YYYY-MM-DD
|
||||
}
|
||||
console.log("Fetching public available times for department:", departmentId, "from", dateFrom, "to", dateTo);
|
||||
// Make the request to fetch public available times for the department
|
||||
@@ -280,4 +279,4 @@ export const DepartmentTimeBookingsEntries = {
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
@@ -108,14 +108,6 @@ export const Departments = {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
custom_pricing_only: {
|
||||
label: t("objects.departments.columns.custom_pricing_only"),
|
||||
type: "boolean",
|
||||
sortable: true,
|
||||
creation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
longitude: {
|
||||
label: t("objects.departments.columns.longitude"),
|
||||
type: "number",
|
||||
@@ -172,14 +164,6 @@ export const Departments = {
|
||||
archived: async (id, archived) => {
|
||||
return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "archived", ObjectsGlobal.parse.boolean(archived));
|
||||
},
|
||||
custom_pricing_only: async (id, custom_pricing_only) => {
|
||||
return ObjectsGlobal.set.column(
|
||||
Departments.meta.endpoint,
|
||||
id,
|
||||
"custom_pricing_only",
|
||||
ObjectsGlobal.parse.boolean(custom_pricing_only)
|
||||
);
|
||||
},
|
||||
longitude: async (id, longitude) => {
|
||||
return ObjectsGlobal.set.column(Departments.meta.endpoint, id, "longitude", parseFloat(longitude));
|
||||
},
|
||||
|
||||
@@ -10,10 +10,6 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -56,42 +52,6 @@ export const nextStepDelay = ref(0); // 2 seconds delay
|
||||
export const isCreatingOrder = ref(false);
|
||||
export const desktopStep1PreflightHandler = ref(null);
|
||||
let createOrderRequest = null;
|
||||
const posStepSaveBarriers = new Set();
|
||||
let latestOrderDetailsRequestId = 0;
|
||||
let latestOrderMetadataMutationSequence = 0;
|
||||
let activeOrderMetadataMutationCount = 0;
|
||||
|
||||
export const registerPosStepSaveBarrier = (handler) => {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
posStepSaveBarriers.add(handler);
|
||||
return () => {
|
||||
posStepSaveBarriers.delete(handler);
|
||||
};
|
||||
};
|
||||
|
||||
export const unregisterPosStepSaveBarrier = (handler) => {
|
||||
posStepSaveBarriers.delete(handler);
|
||||
};
|
||||
|
||||
export const flushPosStepSaveBarriers = async () => {
|
||||
const barriers = Array.from(posStepSaveBarriers);
|
||||
if (barriers.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(barriers.map((handler) => handler()));
|
||||
const failedResult = results.find((result) => result.status === "rejected");
|
||||
|
||||
if (failedResult) {
|
||||
parseError(failedResult.reason, "stepError");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const setDesktopStep1PreflightHandler = (handler = null) => {
|
||||
desktopStep1PreflightHandler.value = typeof handler === "function" ? handler : null;
|
||||
@@ -149,16 +109,10 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
|
||||
}
|
||||
// Set the delay to 2 seconds
|
||||
setNextStepDelay(2);
|
||||
if (!(await flushPosStepSaveBarriers())) {
|
||||
nextStepDelay.value = 0;
|
||||
return;
|
||||
}
|
||||
// Clear the errors
|
||||
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;
|
||||
@@ -234,22 +188,8 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
|
||||
step.value++;
|
||||
};
|
||||
|
||||
export const previousStep = async () => {
|
||||
if (nextStepDelay.value > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await flushPosStepSaveBarriers())) {
|
||||
return;
|
||||
}
|
||||
|
||||
step.value = Math.max(1, step.value - 1);
|
||||
|
||||
if (order_id.value) {
|
||||
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=${step.value}`);
|
||||
} else {
|
||||
pushPosRouteState(`step=${step.value}`);
|
||||
}
|
||||
export const previousStep = () => {
|
||||
step.value--;
|
||||
};
|
||||
|
||||
export const getCurrentStep = () => {
|
||||
@@ -844,129 +784,6 @@ watch([reg_1, reg_2, reg_3], () => {
|
||||
reg_3.value = normalizeRegistrationValue(reg_3.value);
|
||||
});
|
||||
|
||||
const applyOrderMetadataFieldToPosState = (field, value) => {
|
||||
if (field === "reference") {
|
||||
reference.value = normalizeOrderMetadataString(value);
|
||||
return reference.value;
|
||||
}
|
||||
|
||||
if (field === "notes") {
|
||||
order_notes.value = normalizeOrderMetadataString(value);
|
||||
return order_notes.value;
|
||||
}
|
||||
|
||||
if (field === "po") {
|
||||
order_po.value = normalizeOrderMetadataString(value);
|
||||
return order_po.value;
|
||||
}
|
||||
|
||||
if (field === "safety_seal") {
|
||||
order_safety_seal.value = normalizeOrderMetadataString(value);
|
||||
return order_safety_seal.value;
|
||||
}
|
||||
|
||||
if (field === "reg_1") {
|
||||
reg_1.value = normalizeRegistrationValue(value);
|
||||
return reg_1.value;
|
||||
}
|
||||
|
||||
if (field === "reg_2") {
|
||||
reg_2.value = normalizeRegistrationValue(value);
|
||||
return reg_2.value;
|
||||
}
|
||||
|
||||
if (field === "reg_3") {
|
||||
reg_3.value = normalizeRegistrationValue(value);
|
||||
return reg_3.value;
|
||||
}
|
||||
|
||||
return normalizeOrderMetadataString(value);
|
||||
};
|
||||
|
||||
export const applyOrderDetailsToPosState = (orderData, includes = {}) => {
|
||||
if (!orderData || typeof orderData !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const includedCustomer = includes?.customer ?? null;
|
||||
const normalizedOrderCustomerNumber = resolveCustomerNumber(orderData?.customer_id);
|
||||
const selectedCustomerBeforeHydration = getSelectedCustomerNumber();
|
||||
|
||||
department_id.value = orderData.department_id;
|
||||
applyOrderMetadataFieldToPosState("reference", orderData.reference);
|
||||
applyOrderMetadataFieldToPosState("notes", orderData.notes);
|
||||
applyOrderMetadataFieldToPosState("po", orderData.po);
|
||||
applyOrderMetadataFieldToPosState("safety_seal", orderData.safety_seal);
|
||||
applyOrderMetadataFieldToPosState("reg_1", orderData.reg_1);
|
||||
applyOrderMetadataFieldToPosState("reg_2", orderData.reg_2);
|
||||
applyOrderMetadataFieldToPosState("reg_3", orderData.reg_3);
|
||||
invoiceCollectionId.value = orderData.invoice_collection_id || null;
|
||||
completed_at.value = orderData.completed_at || null;
|
||||
|
||||
if (includedCustomer && typeof includedCustomer === "object") {
|
||||
const customerSource =
|
||||
includedCustomer.economic_customer && typeof includedCustomer.economic_customer === "object"
|
||||
? includedCustomer.economic_customer
|
||||
: includedCustomer;
|
||||
|
||||
selectCustomer(
|
||||
normalizeCustomerRecord(customerSource, {
|
||||
...includedCustomer,
|
||||
customerNumber: normalizedOrderCustomerNumber ?? resolveCustomerNumber(includedCustomer),
|
||||
name: includedCustomer.name ?? includedCustomer.customer_name ?? orderData.customer_name ?? customer_name.value,
|
||||
customer_name:
|
||||
includedCustomer.customer_name ?? includedCustomer.name ?? orderData.customer_name ?? customer_name.value,
|
||||
})
|
||||
);
|
||||
} else if (normalizedOrderCustomerNumber) {
|
||||
customer_id.value = normalizedOrderCustomerNumber;
|
||||
if (
|
||||
selectedCustomerBeforeHydration !== normalizedOrderCustomerNumber ||
|
||||
customer_name.value === "" ||
|
||||
!customer_data.value
|
||||
) {
|
||||
void searchAndSelectCustomer(normalizedOrderCustomerNumber, { forceRefresh: true });
|
||||
}
|
||||
} else {
|
||||
clearSelectedCustomerState();
|
||||
}
|
||||
|
||||
return orderData;
|
||||
};
|
||||
|
||||
export const saveOrderMetadataField = async (field, value, targetOrderId = order_id.value) => {
|
||||
const normalizedOrderId = toPositiveInteger(targetOrderId);
|
||||
const setter = SessionUser.objects.orders.set?.[field];
|
||||
|
||||
if (!normalizedOrderId || typeof setter !== "function") {
|
||||
throw new Error(`Unable to save order field: ${field}`);
|
||||
}
|
||||
|
||||
const mutationSequence = latestOrderMetadataMutationSequence + 1;
|
||||
latestOrderMetadataMutationSequence = mutationSequence;
|
||||
activeOrderMetadataMutationCount += 1;
|
||||
const isRegistrationField = field === "reg_1" || field === "reg_2" || field === "reg_3";
|
||||
const requestValue = isRegistrationField
|
||||
? String(value ?? "").toUpperCase()
|
||||
: normalizeOrderMetadataString(value);
|
||||
const normalizedValue = isRegistrationField ? normalizeRegistrationValue(value) : requestValue;
|
||||
try {
|
||||
const response = await setter(normalizedOrderId, requestValue);
|
||||
const updatedOrder = response?.data?.data;
|
||||
|
||||
if (updatedOrder && toPositiveInteger(updatedOrder.id) === normalizedOrderId) {
|
||||
if (mutationSequence === latestOrderMetadataMutationSequence && activeOrderMetadataMutationCount === 1) {
|
||||
applyOrderDetailsToPosState(updatedOrder, response?.data?.includes ?? {});
|
||||
}
|
||||
return applyOrderMetadataFieldToPosState(field, updatedOrder[field] ?? normalizedValue);
|
||||
}
|
||||
|
||||
return applyOrderMetadataFieldToPosState(field, normalizedValue);
|
||||
} finally {
|
||||
activeOrderMetadataMutationCount = Math.max(0, activeOrderMetadataMutationCount - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/** Get the order details by order id */
|
||||
export const getOrderDetails = async (id = null) => {
|
||||
const token = localStorage.getItem("token");
|
||||
@@ -974,9 +791,6 @@ export const getOrderDetails = async (id = null) => {
|
||||
if (!token || !targetOrderId) {
|
||||
return null;
|
||||
}
|
||||
const requestId = ++latestOrderDetailsRequestId;
|
||||
const metadataMutationSequenceAtRequest = latestOrderMetadataMutationSequence;
|
||||
const hadActiveMetadataMutationAtRequest = activeOrderMetadataMutationCount > 0;
|
||||
return axios
|
||||
.get(API_URL + "/order?id=" + targetOrderId, {
|
||||
headers: {
|
||||
@@ -985,21 +799,54 @@ export const getOrderDetails = async (id = null) => {
|
||||
})
|
||||
.then((response) => {
|
||||
const order_data = response.data.data;
|
||||
if (!order_data) {
|
||||
return null;
|
||||
}
|
||||
const includedCustomer = response?.data?.includes?.customer ?? null;
|
||||
const normalizedOrderCustomerNumber = resolveCustomerNumber(order_data?.customer_id);
|
||||
const selectedCustomerBeforeHydration = getSelectedCustomerNumber();
|
||||
// Set the order details
|
||||
department_id.value = order_data.department_id;
|
||||
reference.value = order_data.reference || "";
|
||||
order_notes.value = order_data.notes || "";
|
||||
order_po.value = order_data.po || "";
|
||||
order_safety_seal.value = normalizeOrderMetadataString(order_data.safety_seal);
|
||||
reg_1.value = order_data.reg_1 || "";
|
||||
reg_2.value = order_data.reg_2 || "";
|
||||
reg_3.value = order_data.reg_3 || "";
|
||||
invoiceCollectionId.value = order_data.invoice_collection_id || null;
|
||||
completed_at.value = order_data.completed_at || null;
|
||||
|
||||
const normalizedResponseOrderId = toPositiveInteger(order_data.id ?? targetOrderId);
|
||||
const normalizedCurrentOrderId = toPositiveInteger(order_id.value);
|
||||
const isStaleForDifferentOrder =
|
||||
requestId !== latestOrderDetailsRequestId &&
|
||||
normalizedCurrentOrderId &&
|
||||
normalizedResponseOrderId &&
|
||||
normalizedCurrentOrderId !== normalizedResponseOrderId;
|
||||
const isStaleBeforeMetadataMutation = metadataMutationSequenceAtRequest !== latestOrderMetadataMutationSequence;
|
||||
if (includedCustomer && typeof includedCustomer === "object") {
|
||||
const customerSource =
|
||||
includedCustomer.economic_customer && typeof includedCustomer.economic_customer === "object"
|
||||
? includedCustomer.economic_customer
|
||||
: includedCustomer;
|
||||
|
||||
if (!isStaleForDifferentOrder && !isStaleBeforeMetadataMutation && !hadActiveMetadataMutationAtRequest) {
|
||||
applyOrderDetailsToPosState(order_data, response?.data?.includes ?? {});
|
||||
selectCustomer(
|
||||
normalizeCustomerRecord(customerSource, {
|
||||
...includedCustomer,
|
||||
customerNumber: normalizedOrderCustomerNumber ?? resolveCustomerNumber(includedCustomer),
|
||||
name:
|
||||
includedCustomer.name ??
|
||||
includedCustomer.customer_name ??
|
||||
order_data.customer_name ??
|
||||
customer_name.value,
|
||||
customer_name:
|
||||
includedCustomer.customer_name ??
|
||||
includedCustomer.name ??
|
||||
order_data.customer_name ??
|
||||
customer_name.value,
|
||||
})
|
||||
);
|
||||
} else if (normalizedOrderCustomerNumber) {
|
||||
customer_id.value = normalizedOrderCustomerNumber;
|
||||
if (
|
||||
selectedCustomerBeforeHydration !== normalizedOrderCustomerNumber ||
|
||||
customer_name.value === "" ||
|
||||
!customer_data.value
|
||||
) {
|
||||
void searchAndSelectCustomer(normalizedOrderCustomerNumber, { forceRefresh: true });
|
||||
}
|
||||
} else {
|
||||
clearSelectedCustomerState();
|
||||
}
|
||||
// Return the order data
|
||||
return order_data;
|
||||
@@ -1491,7 +1338,7 @@ const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
|
||||
|
||||
const normalizedOrderId = toPositiveInteger(order_id.value);
|
||||
if (normalizedOrderId) {
|
||||
await saveOrderMetadataField("reference", normalizedSourceReference, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.reference(normalizedOrderId, normalizedSourceReference);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1619,7 +1466,24 @@ export const customerUsesPONumbers = () => {
|
||||
|
||||
/** Check if the product category is allowed */
|
||||
export const productCategoryAllowed = (category) => {
|
||||
return !isProductCategoryRestrictedForCustomer(category, customer_attributes.value);
|
||||
// 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;
|
||||
};
|
||||
|
||||
/** Check if the customer should be billed per order, or per billing period */
|
||||
@@ -1665,12 +1529,43 @@ export const hasOnlyTankCleaning = () => {
|
||||
|
||||
/** Check if a product is restricted based on customer attributes */
|
||||
export const isProductRestricted = (product) => {
|
||||
return isProductRestrictedForCustomer(product, customer_attributes.value);
|
||||
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;
|
||||
};
|
||||
|
||||
/** Check if an addon is restricted based on customer attributes */
|
||||
export const isAddonRestricted = (addon) => {
|
||||
return isProductRestrictedForCustomer(addon, customer_attributes.value);
|
||||
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;
|
||||
};
|
||||
|
||||
export function loadOrderDetails(onAfterSuccess = null, options = {}) {
|
||||
@@ -2313,27 +2208,32 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
|
||||
const bookingReference = getOrderBookingReferenceValue(booking);
|
||||
if (bookingReference !== "" && bookingReference !== reference.value) {
|
||||
reference.value = await saveOrderMetadataField("reference", bookingReference, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.reference(normalizedOrderId, bookingReference);
|
||||
reference.value = bookingReference;
|
||||
}
|
||||
|
||||
const bookingReg1 = getOrderBookingReg1Value(booking);
|
||||
if (bookingReg1 !== "" && bookingReg1 !== reg_1.value) {
|
||||
reg_1.value = await saveOrderMetadataField("reg_1", bookingReg1, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.reg_1(normalizedOrderId, bookingReg1);
|
||||
reg_1.value = bookingReg1;
|
||||
}
|
||||
|
||||
const bookingReg2 = getOrderBookingReg2Value(booking);
|
||||
if (bookingReg2 !== reg_2.value) {
|
||||
reg_2.value = await saveOrderMetadataField("reg_2", bookingReg2, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.reg_2(normalizedOrderId, bookingReg2);
|
||||
reg_2.value = bookingReg2;
|
||||
}
|
||||
|
||||
const bookingNotes = getOrderBookingNotesValue(booking);
|
||||
if (bookingNotes !== "" && bookingNotes !== order_notes.value) {
|
||||
order_notes.value = await saveOrderMetadataField("notes", bookingNotes, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.notes(normalizedOrderId, bookingNotes);
|
||||
order_notes.value = bookingNotes;
|
||||
}
|
||||
|
||||
const bookingPo = String(booking?.po ?? "").trim();
|
||||
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
|
||||
order_po.value = await saveOrderMetadataField("po", bookingPo, normalizedOrderId);
|
||||
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
|
||||
order_po.value = bookingPo;
|
||||
}
|
||||
|
||||
const currentOrderItems = await loadOrderItems();
|
||||
|
||||
@@ -23,12 +23,10 @@ export const useOrderMetadataAutosave = ({
|
||||
const draft = ref(normalizeValue(resolveSourceValue(source)));
|
||||
const lastSavedValue = ref(normalizeValue(resolveSourceValue(source)));
|
||||
const isSaving = ref(false);
|
||||
const lastError = ref(null);
|
||||
const isDirty = computed(() => normalizeValue(draft.value) !== normalizeValue(lastSavedValue.value));
|
||||
|
||||
let timerId = null;
|
||||
let saveAgainAfterCurrentRequest = false;
|
||||
let activeSavePromise = null;
|
||||
let nextSequence = 0;
|
||||
let lastAppliedSequence = 0;
|
||||
|
||||
@@ -68,57 +66,45 @@ export const useOrderMetadataAutosave = ({
|
||||
const sequence = ++nextSequence;
|
||||
let saveCompleted = false;
|
||||
isSaving.value = true;
|
||||
lastError.value = null;
|
||||
|
||||
const run = (async () => {
|
||||
try {
|
||||
const result = await saveValue(requestedValue);
|
||||
const savedValue = normalizeValue(result ?? requestedValue);
|
||||
try {
|
||||
const result = await saveValue(requestedValue);
|
||||
const savedValue = normalizeValue(result ?? requestedValue);
|
||||
|
||||
if (sequence >= lastAppliedSequence) {
|
||||
lastAppliedSequence = sequence;
|
||||
lastSavedValue.value = savedValue;
|
||||
if (sequence >= lastAppliedSequence) {
|
||||
lastAppliedSequence = sequence;
|
||||
lastSavedValue.value = savedValue;
|
||||
|
||||
if (normalizeValue(draft.value) === requestedValue) {
|
||||
draft.value = savedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof onSaved === "function") {
|
||||
await onSaved(savedValue, requestedValue);
|
||||
}
|
||||
|
||||
saveCompleted = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError.value = error;
|
||||
if (typeof onError === "function") {
|
||||
onError(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
|
||||
const shouldSaveAgain = saveAgainAfterCurrentRequest;
|
||||
saveAgainAfterCurrentRequest = false;
|
||||
|
||||
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
|
||||
void persist();
|
||||
if (normalizeValue(draft.value) === requestedValue) {
|
||||
draft.value = savedValue;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const trackedSavePromise = run.finally(() => {
|
||||
if (activeSavePromise === trackedSavePromise) {
|
||||
activeSavePromise = null;
|
||||
if (typeof onSaved === "function") {
|
||||
await onSaved(savedValue, requestedValue);
|
||||
}
|
||||
});
|
||||
activeSavePromise = trackedSavePromise;
|
||||
return trackedSavePromise;
|
||||
|
||||
saveCompleted = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (typeof onError === "function") {
|
||||
onError(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
|
||||
const shouldSaveAgain = saveAgainAfterCurrentRequest;
|
||||
saveAgainAfterCurrentRequest = false;
|
||||
|
||||
if (saveCompleted && (shouldSaveAgain || isDirty.value) && isDirty.value) {
|
||||
void persist();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSave = () => {
|
||||
@@ -137,34 +123,6 @@ export const useOrderMetadataAutosave = ({
|
||||
return persist();
|
||||
};
|
||||
|
||||
const flushAndWait = async () => {
|
||||
clearPendingSave();
|
||||
|
||||
while (isSaving.value && activeSavePromise) {
|
||||
await activeSavePromise;
|
||||
}
|
||||
|
||||
if (!isDirty.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const didSave = await persist();
|
||||
|
||||
if (!didSave && isDirty.value) {
|
||||
throw lastError.value || new Error("Unable to save order metadata");
|
||||
}
|
||||
|
||||
while (isSaving.value && activeSavePromise) {
|
||||
await activeSavePromise;
|
||||
}
|
||||
|
||||
if (isDirty.value) {
|
||||
return flushAndWait();
|
||||
}
|
||||
|
||||
return didSave;
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingSave();
|
||||
|
||||
@@ -181,12 +139,10 @@ export const useOrderMetadataAutosave = ({
|
||||
return {
|
||||
draft,
|
||||
lastSavedValue,
|
||||
lastError,
|
||||
isDirty,
|
||||
isSaving,
|
||||
scheduleSave,
|
||||
flush,
|
||||
flushAndWait,
|
||||
clearPendingSave,
|
||||
syncFromSource,
|
||||
};
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1190,14 +1190,6 @@
|
||||
"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'}",
|
||||
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -3463,13 +3455,6 @@
|
||||
"opening_hours": {
|
||||
"closed": "Stengt"
|
||||
},
|
||||
"pricing": {
|
||||
"custom_pricing_disabled": "Fallback aktiv",
|
||||
"custom_pricing_enabled": "Kun egne priser",
|
||||
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
|
||||
"custom_pricing_only": "Ingen fallback-priser",
|
||||
"effective_department_price": "Effektiv afdelingspris"
|
||||
},
|
||||
"search_placeholder": "@.capitalize:{'words.generated.søg'} @:{'words.generated.efter'} @:{'words.generated.afdelingsnavn'}",
|
||||
"select_department": "@.capitalize:{'words.generated.vælg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.afdeling'}",
|
||||
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.afdelinger'}",
|
||||
@@ -3493,17 +3478,17 @@
|
||||
"error_report": {
|
||||
"button": "Rapporter fejl",
|
||||
"title": "Rapporter fejl",
|
||||
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
|
||||
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
|
||||
"before_error": "Hvad lavede du, før fejlen opstod?",
|
||||
"expected": "Hvad forventede du, der ville ske?",
|
||||
"actual": "Hvad skete der faktisk?",
|
||||
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
|
||||
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
|
||||
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
|
||||
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
|
||||
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
|
||||
"submit": "Send rapport",
|
||||
"submitted": "Fejlrapporten er sendt.",
|
||||
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
|
||||
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
|
||||
"submit_failed": "Fejlrapporten kunne ikke sendes.",
|
||||
"required": "Alle felter og accept af dataindsamling er påkrævet.",
|
||||
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
|
||||
@@ -4300,7 +4285,6 @@
|
||||
"departments": {
|
||||
"columns": {
|
||||
"archived": "Arkiveret",
|
||||
"custom_pricing_only": "Ingen fallback-priser",
|
||||
"dimension": "Dimension",
|
||||
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.afdeling'}",
|
||||
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
|
||||
@@ -4862,97 +4846,6 @@
|
||||
"subtitle": "@.capitalize:{'words.generated.administreret'} @:{'words.replication.services.database'}-, @:{'words.replication.services.redis'}- @:{'words.generated.og'} @:{'words.replication.services.minio'}-@:{'words.generated.replikering'}"
|
||||
},
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Adgangsomrade",
|
||||
"actions": {
|
||||
"clear_all": "Ryd gruppe",
|
||||
"reload": "Genindlaes",
|
||||
"retry": "Prov igen",
|
||||
"select_all": "Vaelg gruppe"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktiveret",
|
||||
"permission": "Tilladelse",
|
||||
"route": "Rute",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Giver adgang til {name}.",
|
||||
"empty": "Der er ingen tilladelser tilgaengelige.",
|
||||
"empty_group": "Denne gruppe har ingen tilladelser.",
|
||||
"errors": {
|
||||
"load": "Rolletilladelser kunne ikke indlaeses.",
|
||||
"update": "En eller flere tilladelser kunne ikke opdateres. Rollens aktuelle tilladelser blev genindlaest."
|
||||
},
|
||||
"filtered_summary": "{count} matcher",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Sidetype- og afdelingsadgang.",
|
||||
"label": "Adgang"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Tilladelser til bookinger, tidsbookinger og planlaegning.",
|
||||
"label": "Bookinger"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Tilladelser til kunder, brugere, medarbejdere, roller og login.",
|
||||
"label": "Kunder og brugere"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Afdelingsdrift, baner, relaer, notifikationer, rapporter og mal.",
|
||||
"label": "Afdelinger og drift"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Tilladelser til fakturaer, faktureringsperioder, opkraevning og e-conomic.",
|
||||
"label": "Fakturering og e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Tilladelser til ordrer, POS, betalinger og ordrelinjer.",
|
||||
"label": "Ordrer og POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Tilladelser, der ikke matcher en anden gruppe.",
|
||||
"label": "Andet"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Tilladelser til produkter, priser, rabatter og valuta.",
|
||||
"label": "Produkter og priser"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Tilladelser til selvbetjening, vask, scannere, gateways og edge-udstyr.",
|
||||
"label": "Selvbetjening og edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, konfiguration, release, replikering og integrationer.",
|
||||
"label": "System og konfiguration"
|
||||
}
|
||||
},
|
||||
"no_description": "Der er ingen beskrivelse.",
|
||||
"no_search_results": "Ingen tilladelser matcher sogningen.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Giver adgang til administrationssider.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Giver adgang til superbrugersider.",
|
||||
"label": "Superbruger"
|
||||
},
|
||||
"user": {
|
||||
"description": "Giver adgang til brugersider.",
|
||||
"label": "Bruger"
|
||||
}
|
||||
},
|
||||
"search_label": "Sog i tilladelser",
|
||||
"search_placeholder": "Sog pa label, nogle, beskrivelse, rute eller metode",
|
||||
"selected_summary": "{selected} af {total} valgt",
|
||||
"status": {
|
||||
"disabled": "Deaktiveret",
|
||||
"enabled": "Aktiveret"
|
||||
},
|
||||
"subtitle": "Rolletilladelser",
|
||||
"title": "Tilladelser for rolle {id}",
|
||||
"title_with_name": "Tilladelser for {name}"
|
||||
},
|
||||
"subtitle": "@.capitalize:{'words.generated.brugerroller'}"
|
||||
},
|
||||
"self_wash": {
|
||||
@@ -5948,20 +5841,6 @@
|
||||
},
|
||||
"vehicles": {
|
||||
"add": "@:{'words.generated.tilføj'} @:{'words.generated.køretøj'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Kunde",
|
||||
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
|
||||
"error": "Køretøjet kunne ikke tilføjes.",
|
||||
"no_customer_results": "Ingen kunder fundet",
|
||||
"reference_placeholder": "Valgfri reference",
|
||||
"registration_placeholder": "Registreringsnummer",
|
||||
"selected_customer": "Valgt kunde",
|
||||
"submit": "Tilføj køretøj",
|
||||
"title": "Tilføj køretøj",
|
||||
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
|
||||
"type_placeholder": "Vælg køretøjstype",
|
||||
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
|
||||
},
|
||||
"brand": "@:{'templates.generated.compat.admin.pos.make'}",
|
||||
"color": "Farve",
|
||||
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
|
||||
@@ -6040,14 +5919,6 @@
|
||||
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbejder'}",
|
||||
"name": "Navn",
|
||||
"email": "Email",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
|
||||
"country_codes": {
|
||||
"denmark": "Danmark",
|
||||
"sweden": "Sverige",
|
||||
"norway": "Norge",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'words.generated.adgangskode'}",
|
||||
"password_create_help": "@.capitalize:{'words.generated.pakrævet'} @:{'words.generated.for'} nye @:{'words.generated.medarbejdere'}. Brug @:{'words.generated.mindst'} 8 tegn.",
|
||||
"password_update_help": "Lad feltet være tomt @:{'words.generated.for'} at beholde den nuværende @:{'words.generated.adgangskode'}.",
|
||||
|
||||
@@ -1301,14 +1301,6 @@
|
||||
"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'}",
|
||||
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -3597,17 +3589,17 @@
|
||||
"error_report": {
|
||||
"button": "Fehler melden",
|
||||
"title": "Fehler melden",
|
||||
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
|
||||
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
|
||||
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
|
||||
"expected": "Was hatten Sie erwartet?",
|
||||
"actual": "Was ist tatsächlich passiert?",
|
||||
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
|
||||
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
|
||||
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
|
||||
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
|
||||
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
|
||||
"submit": "Bericht senden",
|
||||
"submitted": "Fehlerbericht gesendet.",
|
||||
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
|
||||
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
|
||||
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
|
||||
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
|
||||
@@ -4965,97 +4957,6 @@
|
||||
"subtitle": "@.capitalize:{'words.generated.managed'} @:{'words.generated.database'} @:{'words.generated.and'} @:{'words.replication.services.redis'} @:{'words.generated.replication'}"
|
||||
},
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Zugriffsbereich",
|
||||
"actions": {
|
||||
"clear_all": "Gruppe leeren",
|
||||
"reload": "Neu laden",
|
||||
"retry": "Erneut versuchen",
|
||||
"select_all": "Gruppe auswaehlen"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktiviert",
|
||||
"permission": "Berechtigung",
|
||||
"route": "Route",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Erlaubt Zugriff auf {name}.",
|
||||
"empty": "Es sind keine Berechtigungen verfuegbar.",
|
||||
"empty_group": "Diese Gruppe hat keine Berechtigungen.",
|
||||
"errors": {
|
||||
"load": "Rollenberechtigungen konnten nicht geladen werden.",
|
||||
"update": "Eine oder mehrere Berechtigungen konnten nicht aktualisiert werden. Die aktuellen Rollenberechtigungen wurden neu geladen."
|
||||
},
|
||||
"filtered_summary": "{count} Treffer",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Seitentyp- und Abteilungszugriff.",
|
||||
"label": "Zugriff"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Berechtigungen fuer Buchungen, Zeitbuchungen und Planung.",
|
||||
"label": "Buchungen"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Berechtigungen fuer Kunden, Benutzer, Mitarbeiter, Rollen und Anmeldung.",
|
||||
"label": "Kunden und Benutzer"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Abteilungsbetrieb, Spuren, Relais, Benachrichtigungen, Berichte und Ziele.",
|
||||
"label": "Abteilungen und Betrieb"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Berechtigungen fuer Rechnungen, Abrechnungsperioden, Inkasso und e-conomic.",
|
||||
"label": "Rechnungen und e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Berechtigungen fuer Auftraege, POS, Zahlungen und Auftragszeilen.",
|
||||
"label": "Auftraege und POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Berechtigungen, die keiner anderen Gruppe entsprechen.",
|
||||
"label": "Sonstige"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Berechtigungen fuer Produkte, Preise, Rabatte und Waehrungen.",
|
||||
"label": "Produkte und Preise"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Berechtigungen fuer Self-Service, Waschen, Scanner, Gateways und Edge-Hardware.",
|
||||
"label": "Self-Service und Edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, Konfiguration, Release, Replikation und Integrationen.",
|
||||
"label": "System und Konfiguration"
|
||||
}
|
||||
},
|
||||
"no_description": "Keine Beschreibung verfuegbar.",
|
||||
"no_search_results": "Keine Berechtigungen entsprechen der Suche.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Erlaubt Zugriff auf Admin-Dashboardseiten.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Erlaubt Zugriff auf Superuser-Dashboardseiten.",
|
||||
"label": "Superuser"
|
||||
},
|
||||
"user": {
|
||||
"description": "Erlaubt Zugriff auf Benutzer-Dashboardseiten.",
|
||||
"label": "Benutzer"
|
||||
}
|
||||
},
|
||||
"search_label": "Berechtigungen suchen",
|
||||
"search_placeholder": "Nach Label, Schluessel, Beschreibung, Route oder Methode suchen",
|
||||
"selected_summary": "{selected} von {total} ausgewaehlt",
|
||||
"status": {
|
||||
"disabled": "Deaktiviert",
|
||||
"enabled": "Aktiviert"
|
||||
},
|
||||
"subtitle": "Rollenberechtigungen",
|
||||
"title": "Berechtigungen fuer Rolle {id}",
|
||||
"title_with_name": "Berechtigungen fuer {name}"
|
||||
},
|
||||
"subtitle": "Benutzerrollen"
|
||||
},
|
||||
"self_wash": {
|
||||
@@ -6129,14 +6030,6 @@
|
||||
"edit_title": "@:{'words.generated.mitarbeiter'} @:{'words.generated.bearbeiten'}",
|
||||
"name": "Name",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
|
||||
"country_codes": {
|
||||
"denmark": "Dänemark",
|
||||
"sweden": "Schweden",
|
||||
"norway": "Norwegen",
|
||||
"finland": "Finnland"
|
||||
},
|
||||
"password": "@:{'words.generated.passwort'}",
|
||||
"password_create_help": "@.capitalize:{'words.generated.fur'} neue @:{'words.generated.mitarbeiter'} @:{'words.generated.erforderlich'}. Verwenden @.capitalize:{'words.generated.sie'} @:{'words.generated.mindestens'} 8 Zeichen.",
|
||||
"password_update_help": "Leer lassen, um das vorhandene @:{'words.generated.passwort'} beizubehalten.",
|
||||
|
||||
@@ -1022,14 +1022,6 @@
|
||||
"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'}",
|
||||
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -3295,13 +3287,6 @@
|
||||
"opening_hours": {
|
||||
"closed": "@:{'templates.generated.compat.global.closed'}"
|
||||
},
|
||||
"pricing": {
|
||||
"custom_pricing_disabled": "Fallback enabled",
|
||||
"custom_pricing_enabled": "Custom only",
|
||||
"custom_pricing_missing_price": "Missing department prices resolve to 999999.",
|
||||
"custom_pricing_only": "No fallback pricing",
|
||||
"effective_department_price": "Effective department price"
|
||||
},
|
||||
"search_placeholder": "@.capitalize:{'words.generated.search'} @:{'words.generated.by'} @:{'words.generated.department'} @:{'words.generated.name'}",
|
||||
"select_department": "@.capitalize:{'words.generated.select'} @:{'words.generated.a'} @:{'words.generated.department'}",
|
||||
"subtitle": "@.capitalize:{'words.generated.manage'} @:{'words.generated.departments'}",
|
||||
@@ -3325,17 +3310,17 @@
|
||||
"error_report": {
|
||||
"button": "Report error",
|
||||
"title": "Report error",
|
||||
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
|
||||
"subtitle": "Send the current screen and recent error details to support.",
|
||||
"before_error": "What were you doing before the error occurred?",
|
||||
"expected": "What did you expect would happen?",
|
||||
"actual": "What actually happened?",
|
||||
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
|
||||
"expected_placeholder": "Describe the result you expected to see.",
|
||||
"actual_placeholder": "Describe what you saw instead, including any error text.",
|
||||
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
|
||||
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
|
||||
"submit": "Submit report",
|
||||
"submitted": "Error report submitted.",
|
||||
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
|
||||
"capture_failed": "The screen capture failed. Please try again.",
|
||||
"submit_failed": "The error report could not be submitted.",
|
||||
"required": "All fields and data collection acceptance are required.",
|
||||
"page_subtitle": "Reported screens, request failures, and Vue errors",
|
||||
@@ -4132,7 +4117,6 @@
|
||||
"departments": {
|
||||
"columns": {
|
||||
"archived": "@:{'words.generated.archived'}",
|
||||
"custom_pricing_only": "No fallback pricing",
|
||||
"dimension": "Dimension",
|
||||
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @.capitalize:{'words.generated.department'}",
|
||||
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
|
||||
@@ -4694,97 +4678,6 @@
|
||||
"subtitle": "@.capitalize:{'words.generated.managed'} @:{'words.replication.services.database'}, @:{'words.replication.services.redis'}, @:{'words.generated.and'} @:{'words.replication.services.minio'} @:{'words.generated.replication'}"
|
||||
},
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Access scope",
|
||||
"actions": {
|
||||
"clear_all": "Clear group",
|
||||
"reload": "Reload",
|
||||
"retry": "Try again",
|
||||
"select_all": "Select group"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Enabled",
|
||||
"permission": "Permission",
|
||||
"route": "Route",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Allows access to {name}.",
|
||||
"empty": "No permissions are available.",
|
||||
"empty_group": "This group has no permissions.",
|
||||
"errors": {
|
||||
"load": "Could not load role permissions.",
|
||||
"update": "Could not update one or more permissions. The current role permissions were reloaded."
|
||||
},
|
||||
"filtered_summary": "{count} matching",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Page type and department access permissions.",
|
||||
"label": "Access"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Booking, time booking, and planning permissions.",
|
||||
"label": "Bookings"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Customer, user, employee, role, and authentication permissions.",
|
||||
"label": "Customers and users"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Department operations, lanes, relays, notifications, reports, and goals.",
|
||||
"label": "Departments and operations"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Invoice, billing period, collection, and e-conomic permissions.",
|
||||
"label": "Invoicing and e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Order, POS, payment, and order line permissions.",
|
||||
"label": "Orders and POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Permissions that do not match another group.",
|
||||
"label": "Other"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Product, price, discount, and currency permissions.",
|
||||
"label": "Products and pricing"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Self-serve, wash, scanner, gateway, and edge hardware permissions.",
|
||||
"label": "Self-serve and edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, configuration, release, replication, and integration permissions.",
|
||||
"label": "System and configuration"
|
||||
}
|
||||
},
|
||||
"no_description": "No description is available.",
|
||||
"no_search_results": "No permissions match the current search.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Allows access to admin dashboard pages.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Allows access to superuser dashboard pages.",
|
||||
"label": "Superuser"
|
||||
},
|
||||
"user": {
|
||||
"description": "Allows access to user dashboard pages.",
|
||||
"label": "User"
|
||||
}
|
||||
},
|
||||
"search_label": "Search permissions",
|
||||
"search_placeholder": "Search by label, key, description, route, or method",
|
||||
"selected_summary": "{selected} of {total} selected",
|
||||
"status": {
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"subtitle": "Role permissions",
|
||||
"title": "Role {id} permissions",
|
||||
"title_with_name": "{name} permissions"
|
||||
},
|
||||
"subtitle": "@.capitalize:{'words.generated.user'} @:{'words.generated.roles'}"
|
||||
},
|
||||
"self_wash": {
|
||||
@@ -5780,20 +5673,6 @@
|
||||
},
|
||||
"vehicles": {
|
||||
"add": "@.capitalize:{'words.generated.add'} @:{'words.generated.vehicle'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Customer",
|
||||
"customer_placeholder": "Search by customer name or number",
|
||||
"error": "Unable to add vehicle.",
|
||||
"no_customer_results": "No customers found",
|
||||
"reference_placeholder": "Optional reference",
|
||||
"registration_placeholder": "Registration number",
|
||||
"selected_customer": "Selected customer",
|
||||
"submit": "Add vehicle",
|
||||
"title": "Add vehicle",
|
||||
"type_load_error": "Unable to load vehicle types.",
|
||||
"type_placeholder": "Select vehicle type",
|
||||
"validation_error": "Select a customer, registration number, and vehicle type."
|
||||
},
|
||||
"brand": "Brand",
|
||||
"color": "Color",
|
||||
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
|
||||
@@ -5872,14 +5751,6 @@
|
||||
"edit_title": "@.capitalize:{'words.generated.edit'} @:{'words.generated.employee'}",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"phone_validation": "Phone number must be 4-15 digits.",
|
||||
"country_codes": {
|
||||
"denmark": "Denmark",
|
||||
"sweden": "Sweden",
|
||||
"norway": "Norway",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'words.generated.password'}",
|
||||
"password_create_help": "@.capitalize:{'words.generated.required'} @:{'words.generated.for'} new @:{'words.generated.employees'}. Use @:{'words.generated.at'} @:{'words.generated.least'} 8 characters.",
|
||||
"password_update_help": "Leave blank @:{'words.generated.to'} keep @:{'words.replication.article.host_mention'} existing @:{'words.generated.password'}.",
|
||||
|
||||
@@ -2420,13 +2420,6 @@
|
||||
"title": "@:{'templates.generated.compat.departments.tab.opening_hours'}"
|
||||
},
|
||||
"phone": "@:common.phone",
|
||||
"pricing": {
|
||||
"custom_pricing_disabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_disabled'}",
|
||||
"custom_pricing_enabled": "@:{'templates.generated.compat.departments.pricing.custom_pricing_enabled'}",
|
||||
"custom_pricing_missing_price": "@:{'templates.generated.compat.departments.pricing.custom_pricing_missing_price'}",
|
||||
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
|
||||
"effective_department_price": "@:{'templates.generated.compat.departments.pricing.effective_department_price'}"
|
||||
},
|
||||
"products": {
|
||||
"new_product": "@:products.new_product"
|
||||
},
|
||||
@@ -3493,7 +3486,6 @@
|
||||
"departments": {
|
||||
"columns": {
|
||||
"archived": "@:{'templates.generated.compat.objects.departments.columns.archived'}",
|
||||
"custom_pricing_only": "@:{'templates.generated.compat.objects.departments.columns.custom_pricing_only'}",
|
||||
"dimension": "@:{'templates.generated.compat.objects.departments.columns.dimension'}",
|
||||
"economic_department": "@:{'templates.generated.compat.objects.departments.columns.economic_department'}",
|
||||
"latitude": "@:{'templates.generated.compat.objects.departments.columns.latitude'}",
|
||||
@@ -4263,97 +4255,6 @@
|
||||
"title": "@:replication.labels.replication"
|
||||
},
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "@:{'templates.generated.compat.roles.permissions.access_scope'}",
|
||||
"actions": {
|
||||
"clear_all": "@:{'templates.generated.compat.roles.permissions.actions.clear_all'}",
|
||||
"reload": "@:{'templates.generated.compat.roles.permissions.actions.reload'}",
|
||||
"retry": "@:{'templates.generated.compat.roles.permissions.actions.retry'}",
|
||||
"select_all": "@:{'templates.generated.compat.roles.permissions.actions.select_all'}"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "@:{'templates.generated.compat.roles.permissions.columns.enabled'}",
|
||||
"permission": "@:{'templates.generated.compat.roles.permissions.columns.permission'}",
|
||||
"route": "@:{'templates.generated.compat.roles.permissions.columns.route'}",
|
||||
"status": "@:{'templates.generated.compat.roles.permissions.columns.status'}"
|
||||
},
|
||||
"department_access_description": "@:{'templates.generated.compat.roles.permissions.department_access_description'}",
|
||||
"empty": "@:{'templates.generated.compat.roles.permissions.empty'}",
|
||||
"empty_group": "@:{'templates.generated.compat.roles.permissions.empty_group'}",
|
||||
"errors": {
|
||||
"load": "@:{'templates.generated.compat.roles.permissions.errors.load'}",
|
||||
"update": "@:{'templates.generated.compat.roles.permissions.errors.update'}"
|
||||
},
|
||||
"filtered_summary": "@:{'templates.generated.compat.roles.permissions.filtered_summary'}",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.access.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.access.label'}"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.bookings.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.bookings.label'}"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.customers_users.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.customers_users.label'}"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.departments_operations.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.departments_operations.label'}"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.invoicing_economic.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.invoicing_economic.label'}"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.orders_pos.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.orders_pos.label'}"
|
||||
},
|
||||
"other": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.other.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.other.label'}"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.products_pricing.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.products_pricing.label'}"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.selfserve_edge.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.selfserve_edge.label'}"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.groups.system_config.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.groups.system_config.label'}"
|
||||
}
|
||||
},
|
||||
"no_description": "@:{'templates.generated.compat.roles.permissions.no_description'}",
|
||||
"no_search_results": "@:{'templates.generated.compat.roles.permissions.no_search_results'}",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.page_access.admin.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.page_access.admin.label'}"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.page_access.superuser.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.page_access.superuser.label'}"
|
||||
},
|
||||
"user": {
|
||||
"description": "@:{'templates.generated.compat.roles.permissions.page_access.user.description'}",
|
||||
"label": "@:{'templates.generated.compat.roles.permissions.page_access.user.label'}"
|
||||
}
|
||||
},
|
||||
"search_label": "@:{'templates.generated.compat.roles.permissions.search_label'}",
|
||||
"search_placeholder": "@:{'templates.generated.compat.roles.permissions.search_placeholder'}",
|
||||
"selected_summary": "@:{'templates.generated.compat.roles.permissions.selected_summary'}",
|
||||
"status": {
|
||||
"disabled": "@:{'templates.generated.compat.roles.permissions.status.disabled'}",
|
||||
"enabled": "@:{'templates.generated.compat.roles.permissions.status.enabled'}"
|
||||
},
|
||||
"subtitle": "@:{'templates.generated.compat.roles.permissions.subtitle'}",
|
||||
"title": "@:{'templates.generated.compat.roles.permissions.title'}",
|
||||
"title_with_name": "@:{'templates.generated.compat.roles.permissions.title_with_name'}"
|
||||
},
|
||||
"subtitle": "@:{'templates.generated.compat.roles.subtitle'}",
|
||||
"title": "@:{'templates.generated.compat.common.roles'}"
|
||||
},
|
||||
@@ -4567,82 +4468,6 @@
|
||||
"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'}",
|
||||
@@ -5918,20 +5743,6 @@
|
||||
"vehicles": {
|
||||
"actions": "@:common.actions",
|
||||
"add": "@:{'templates.generated.compat.vehicles.add'}",
|
||||
"add_modal": {
|
||||
"customer_label": "@:{'templates.generated.compat.vehicles.add_modal.customer_label'}",
|
||||
"customer_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.customer_placeholder'}",
|
||||
"error": "@:{'templates.generated.compat.vehicles.add_modal.error'}",
|
||||
"no_customer_results": "@:{'templates.generated.compat.vehicles.add_modal.no_customer_results'}",
|
||||
"reference_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.reference_placeholder'}",
|
||||
"registration_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.registration_placeholder'}",
|
||||
"selected_customer": "@:{'templates.generated.compat.vehicles.add_modal.selected_customer'}",
|
||||
"submit": "@:{'templates.generated.compat.vehicles.add_modal.submit'}",
|
||||
"title": "@:{'templates.generated.compat.vehicles.add_modal.title'}",
|
||||
"type_load_error": "@:{'templates.generated.compat.vehicles.add_modal.type_load_error'}",
|
||||
"type_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.type_placeholder'}",
|
||||
"validation_error": "@:{'templates.generated.compat.vehicles.add_modal.validation_error'}"
|
||||
},
|
||||
"brand": "@:{'templates.generated.compat.vehicles.brand'}",
|
||||
"color": "@:{'templates.generated.compat.vehicles.color'}",
|
||||
"created_at": "@:{'templates.generated.compat.global.generated'}",
|
||||
|
||||
@@ -1304,14 +1304,6 @@
|
||||
"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'}",
|
||||
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -3600,17 +3592,17 @@
|
||||
"error_report": {
|
||||
"button": "Rapporter feil",
|
||||
"title": "Rapporter feil",
|
||||
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
|
||||
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
|
||||
"before_error": "Hva gjorde du før feilen oppstod?",
|
||||
"expected": "Hva forventet du at skulle skje?",
|
||||
"actual": "Hva skjedde faktisk?",
|
||||
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
|
||||
"expected_placeholder": "Beskriv resultatet du forventet å se.",
|
||||
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
|
||||
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
|
||||
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
|
||||
"submit": "Send rapport",
|
||||
"submitted": "Feilrapport sendt.",
|
||||
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
|
||||
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
|
||||
"submit_failed": "Feilrapporten kunne ikke sendes.",
|
||||
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
|
||||
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
|
||||
@@ -4968,97 +4960,6 @@
|
||||
"subtitle": "@.capitalize:{'words.generated.managed'} @:{'words.replication.services.database'} @:{'words.generated.and'} @:{'words.replication.services.redis'} @:{'words.generated.replication'}"
|
||||
},
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Tilgangsomrade",
|
||||
"actions": {
|
||||
"clear_all": "Tomm gruppe",
|
||||
"reload": "Last inn pa nytt",
|
||||
"retry": "Prov igjen",
|
||||
"select_all": "Velg gruppe"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktivert",
|
||||
"permission": "Tillatelse",
|
||||
"route": "Rute",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Gir tilgang til {name}.",
|
||||
"empty": "Ingen tillatelser er tilgjengelige.",
|
||||
"empty_group": "Denne gruppen har ingen tillatelser.",
|
||||
"errors": {
|
||||
"load": "Kunne ikke laste rolletillatelser.",
|
||||
"update": "En eller flere tillatelser kunne ikke oppdateres. De gjeldende rolletillatelsene ble lastet inn pa nytt."
|
||||
},
|
||||
"filtered_summary": "{count} treff",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Sidetype- og avdelingstilgang.",
|
||||
"label": "Tilgang"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Tillatelser for bookinger, tidsbookinger og planlegging.",
|
||||
"label": "Bookinger"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Tillatelser for kunder, brukere, ansatte, roller og innlogging.",
|
||||
"label": "Kunder og brukere"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Avdelingsdrift, baner, releer, varsler, rapporter og mal.",
|
||||
"label": "Avdelinger og drift"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Tillatelser for fakturaer, faktureringsperioder, innkreving og e-conomic.",
|
||||
"label": "Fakturering og e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Tillatelser for ordrer, POS, betalinger og ordrelinjer.",
|
||||
"label": "Ordrer og POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Tillatelser som ikke matcher en annen gruppe.",
|
||||
"label": "Annet"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Tillatelser for produkter, priser, rabatter og valuta.",
|
||||
"label": "Produkter og priser"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Tillatelser for selvbetjening, vask, skannere, gatewayer og edge-utstyr.",
|
||||
"label": "Selvbetjening og edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, konfigurasjon, release, replikering og integrasjoner.",
|
||||
"label": "System og konfigurasjon"
|
||||
}
|
||||
},
|
||||
"no_description": "Ingen beskrivelse er tilgjengelig.",
|
||||
"no_search_results": "Ingen tillatelser matcher soket.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Gir tilgang til administrasjonssider.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Gir tilgang til superbrukersider.",
|
||||
"label": "Superbruker"
|
||||
},
|
||||
"user": {
|
||||
"description": "Gir tilgang til brukersider.",
|
||||
"label": "Bruker"
|
||||
}
|
||||
},
|
||||
"search_label": "Sok i tillatelser",
|
||||
"search_placeholder": "Sok pa label, nokkel, beskrivelse, rute eller metode",
|
||||
"selected_summary": "{selected} av {total} valgt",
|
||||
"status": {
|
||||
"disabled": "Deaktivert",
|
||||
"enabled": "Aktivert"
|
||||
},
|
||||
"subtitle": "Rolletillatelser",
|
||||
"title": "Tillatelser for rolle {id}",
|
||||
"title_with_name": "Tillatelser for {name}"
|
||||
},
|
||||
"subtitle": "@.capitalize:{'words.generated.user'} roles"
|
||||
},
|
||||
"self_wash": {
|
||||
@@ -6132,14 +6033,6 @@
|
||||
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbeider'}",
|
||||
"name": "Navn",
|
||||
"email": "E-post",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
|
||||
"country_codes": {
|
||||
"denmark": "Danmark",
|
||||
"sweden": "Sverige",
|
||||
"norway": "Norge",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'words.generated.passord'}",
|
||||
"password_create_help": "@.capitalize:{'words.generated.kreves'} @:{'words.generated.for'} nye @:{'words.generated.medarbeidere'}. Bruk @:{'words.generated.minst'} 8 tegn.",
|
||||
"password_update_help": "La stå tomt @:{'words.generated.for'} å beholde nåværende @:{'words.generated.passord'}.",
|
||||
|
||||
@@ -1354,14 +1354,6 @@
|
||||
"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'}",
|
||||
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -3650,17 +3642,17 @@
|
||||
"error_report": {
|
||||
"button": "Rapportera fel",
|
||||
"title": "Rapportera fel",
|
||||
"subtitle": "Skicka de senaste feldetaljerna till support. En skärmbild bifogas när det är möjligt.",
|
||||
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
|
||||
"before_error": "Vad gjorde du innan felet uppstod?",
|
||||
"expected": "Vad förväntade du dig skulle hända?",
|
||||
"actual": "Vad hände faktiskt?",
|
||||
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
|
||||
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
|
||||
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
|
||||
"consent": "Jag accepterar att senaste request-fel, Vue-fel, webbläsardetaljer, mina svar och en app-skärmbild när det är möjligt samlas in för felsökning.",
|
||||
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
|
||||
"submit": "Skicka rapport",
|
||||
"submitted": "Felrapport skickad.",
|
||||
"capture_failed": "Skärmbilden kunde inte tas. Rapporten skickas utan skärmbild.",
|
||||
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
|
||||
"submit_failed": "Felrapporten kunde inte skickas.",
|
||||
"required": "Alla fält och godkännande av datainsamling krävs.",
|
||||
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
|
||||
@@ -5018,97 +5010,6 @@
|
||||
"subtitle": "@.capitalize:{'words.generated.managed'} @:{'words.generated.database'} @:{'words.generated.and'} @:{'words.replication.services.redis'} @:{'words.generated.replication'}"
|
||||
},
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Atkomstomrade",
|
||||
"actions": {
|
||||
"clear_all": "Rensa grupp",
|
||||
"reload": "Ladda om",
|
||||
"retry": "Forsok igen",
|
||||
"select_all": "Valj grupp"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktiverad",
|
||||
"permission": "Behorighet",
|
||||
"route": "Rutt",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Ger atkomst till {name}.",
|
||||
"empty": "Inga behorigheter ar tillgangliga.",
|
||||
"empty_group": "Den har gruppen har inga behorigheter.",
|
||||
"errors": {
|
||||
"load": "Kunde inte lasa in rollbehorigheter.",
|
||||
"update": "En eller flera behorigheter kunde inte uppdateras. Rollens aktuella behorigheter laddades om."
|
||||
},
|
||||
"filtered_summary": "{count} traffar",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Sidtyp- och avdelningsatkomst.",
|
||||
"label": "Atkomst"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Behorigheter for bokningar, tidsbokningar och planering.",
|
||||
"label": "Bokningar"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Behorigheter for kunder, anvandare, medarbetare, roller och inloggning.",
|
||||
"label": "Kunder och anvandare"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Avdelningsdrift, banor, relan, aviseringar, rapporter och mal.",
|
||||
"label": "Avdelningar och drift"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Behorigheter for fakturor, faktureringsperioder, inkasso och e-conomic.",
|
||||
"label": "Fakturering och e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Behorigheter for ordrar, POS, betalningar och orderrader.",
|
||||
"label": "Ordrar och POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Behorigheter som inte matchar nagon annan grupp.",
|
||||
"label": "Ovrigt"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Behorigheter for produkter, priser, rabatter och valuta.",
|
||||
"label": "Produkter och priser"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Behorigheter for sjalvbetjaning, tvatt, skannrar, gateways och edge-utrustning.",
|
||||
"label": "Sjalvbetjaning och edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, konfiguration, release, replikering och integrationer.",
|
||||
"label": "System och konfiguration"
|
||||
}
|
||||
},
|
||||
"no_description": "Ingen beskrivning ar tillganglig.",
|
||||
"no_search_results": "Inga behorigheter matchar aktuell sokning.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Ger atkomst till administrationssidor.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Ger atkomst till superuser-sidor.",
|
||||
"label": "Superuser"
|
||||
},
|
||||
"user": {
|
||||
"description": "Ger atkomst till anvandarsidor.",
|
||||
"label": "Anvandare"
|
||||
}
|
||||
},
|
||||
"search_label": "Sok behorigheter",
|
||||
"search_placeholder": "Sok pa label, nyckel, beskrivning, rutt eller metod",
|
||||
"selected_summary": "{selected} av {total} valda",
|
||||
"status": {
|
||||
"disabled": "Inaktiverad",
|
||||
"enabled": "Aktiverad"
|
||||
},
|
||||
"subtitle": "Rollbehorigheter",
|
||||
"title": "Behorigheter for roll {id}",
|
||||
"title_with_name": "Behorigheter for {name}"
|
||||
},
|
||||
"subtitle": "@.capitalize:{'words.generated.user'} @:{'words.generated.roles'}"
|
||||
},
|
||||
"self_wash": {
|
||||
@@ -6182,14 +6083,6 @@
|
||||
"edit_title": "@:{'words.generated.redigera'} @:{'words.generated.medarbetare'}",
|
||||
"name": "Namn",
|
||||
"email": "E-post",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
|
||||
"country_codes": {
|
||||
"denmark": "Danmark",
|
||||
"sweden": "Sverige",
|
||||
"norway": "Norge",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'words.generated.losenord'}",
|
||||
"password_create_help": "@.capitalize:{'words.generated.kravs'} @:{'words.generated.for'} nya @:{'words.generated.medarbetare'}. Använd @:{'words.generated.minst'} 8 tecken.",
|
||||
"password_update_help": "Lämna tomt @:{'words.generated.for'} att behålla nuvarande @:{'words.generated.losenord'}.",
|
||||
|
||||
@@ -21,13 +21,6 @@
|
||||
"opening_hours": {
|
||||
"closed": "Stengt"
|
||||
},
|
||||
"pricing": {
|
||||
"custom_pricing_disabled": "Fallback aktiv",
|
||||
"custom_pricing_enabled": "Kun egne priser",
|
||||
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
|
||||
"custom_pricing_only": "Ingen fallback-priser",
|
||||
"effective_department_price": "Effektiv afdelingspris"
|
||||
},
|
||||
"search_placeholder": "@.capitalize:{'terms.glossary.søg'} @:{'terms.glossary.efter'} @:{'terms.glossary.afdelingsnavn'}",
|
||||
"select_department": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.afdeling'}",
|
||||
"subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.afdelinger'}",
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"error_report": {
|
||||
"button": "Rapporter fejl",
|
||||
"title": "Rapporter fejl",
|
||||
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
|
||||
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
|
||||
"before_error": "Hvad lavede du, før fejlen opstod?",
|
||||
"expected": "Hvad forventede du, der ville ske?",
|
||||
"actual": "Hvad skete der faktisk?",
|
||||
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
|
||||
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
|
||||
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
|
||||
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
|
||||
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
|
||||
"submit": "Send rapport",
|
||||
"submitted": "Fejlrapporten er sendt.",
|
||||
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
|
||||
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
|
||||
"submit_failed": "Fejlrapporten kunne ikke sendes.",
|
||||
"required": "Alle felter og accept af dataindsamling er påkrævet.",
|
||||
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
"departments": {
|
||||
"columns": {
|
||||
"archived": "Arkiveret",
|
||||
"custom_pricing_only": "Ingen fallback-priser",
|
||||
"dimension": "Dimension",
|
||||
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.afdeling'}",
|
||||
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"compat": {
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Adgangsomrade",
|
||||
"actions": {
|
||||
"clear_all": "Ryd gruppe",
|
||||
"reload": "Genindlaes",
|
||||
"retry": "Prov igen",
|
||||
"select_all": "Vaelg gruppe"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktiveret",
|
||||
"permission": "Tilladelse",
|
||||
"route": "Rute",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Giver adgang til {name}.",
|
||||
"empty": "Der er ingen tilladelser tilgaengelige.",
|
||||
"empty_group": "Denne gruppe har ingen tilladelser.",
|
||||
"errors": {
|
||||
"load": "Rolletilladelser kunne ikke indlaeses.",
|
||||
"update": "En eller flere tilladelser kunne ikke opdateres. Rollens aktuelle tilladelser blev genindlaest."
|
||||
},
|
||||
"filtered_summary": "{count} matcher",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Sidetype- og afdelingsadgang.",
|
||||
"label": "Adgang"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Tilladelser til bookinger, tidsbookinger og planlaegning.",
|
||||
"label": "Bookinger"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Tilladelser til kunder, brugere, medarbejdere, roller og login.",
|
||||
"label": "Kunder og brugere"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Afdelingsdrift, baner, relaer, notifikationer, rapporter og mal.",
|
||||
"label": "Afdelinger og drift"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Tilladelser til fakturaer, faktureringsperioder, opkraevning og e-conomic.",
|
||||
"label": "Fakturering og e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Tilladelser til ordrer, POS, betalinger og ordrelinjer.",
|
||||
"label": "Ordrer og POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Tilladelser, der ikke matcher en anden gruppe.",
|
||||
"label": "Andet"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Tilladelser til produkter, priser, rabatter og valuta.",
|
||||
"label": "Produkter og priser"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Tilladelser til selvbetjening, vask, scannere, gateways og edge-udstyr.",
|
||||
"label": "Selvbetjening og edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, konfiguration, release, replikering og integrationer.",
|
||||
"label": "System og konfiguration"
|
||||
}
|
||||
},
|
||||
"no_description": "Der er ingen beskrivelse.",
|
||||
"no_search_results": "Ingen tilladelser matcher sogningen.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Giver adgang til administrationssider.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Giver adgang til superbrugersider.",
|
||||
"label": "Superbruger"
|
||||
},
|
||||
"user": {
|
||||
"description": "Giver adgang til brugersider.",
|
||||
"label": "Bruger"
|
||||
}
|
||||
},
|
||||
"search_label": "Sog i tilladelser",
|
||||
"search_placeholder": "Sog pa label, nogle, beskrivelse, rute eller metode",
|
||||
"selected_summary": "{selected} af {total} valgt",
|
||||
"status": {
|
||||
"disabled": "Deaktiveret",
|
||||
"enabled": "Aktiveret"
|
||||
},
|
||||
"subtitle": "Rolletilladelser",
|
||||
"title": "Tilladelser for rolle {id}",
|
||||
"title_with_name": "Tilladelser for {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,6 @@
|
||||
"compat": {
|
||||
"vehicles": {
|
||||
"add": "@:{'terms.glossary.tilføj'} @:{'terms.glossary.køretøj'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Kunde",
|
||||
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
|
||||
"error": "Køretøjet kunne ikke tilføjes.",
|
||||
"no_customer_results": "Ingen kunder fundet",
|
||||
"reference_placeholder": "Valgfri reference",
|
||||
"registration_placeholder": "Registreringsnummer",
|
||||
"selected_customer": "Valgt kunde",
|
||||
"submit": "Tilføj køretøj",
|
||||
"title": "Tilføj køretøj",
|
||||
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
|
||||
"type_placeholder": "Vælg køretøjstype",
|
||||
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
|
||||
},
|
||||
"brand": "@:{'phrases.compat.admin.pos.make'}",
|
||||
"color": "Farve",
|
||||
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
|
||||
|
||||
@@ -46,14 +46,6 @@
|
||||
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
|
||||
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
|
||||
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
|
||||
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -287,14 +279,6 @@
|
||||
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbejder'}",
|
||||
"name": "Navn",
|
||||
"email": "Email",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
|
||||
"country_codes": {
|
||||
"denmark": "Danmark",
|
||||
"sweden": "Sverige",
|
||||
"norway": "Norge",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'terms.glossary.adgangskode'}",
|
||||
"password_create_help": "@.capitalize:{'terms.glossary.pakrævet'} @:{'terms.glossary.for'} nye @:{'terms.glossary.medarbejdere'}. Brug @:{'terms.glossary.mindst'} 8 tegn.",
|
||||
"password_update_help": "Lad feltet være tomt @:{'terms.glossary.for'} at beholde den nuværende @:{'terms.glossary.adgangskode'}.",
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"error_report": {
|
||||
"button": "Fehler melden",
|
||||
"title": "Fehler melden",
|
||||
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
|
||||
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
|
||||
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
|
||||
"expected": "Was hatten Sie erwartet?",
|
||||
"actual": "Was ist tatsächlich passiert?",
|
||||
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
|
||||
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
|
||||
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
|
||||
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
|
||||
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
|
||||
"submit": "Bericht senden",
|
||||
"submitted": "Fehlerbericht gesendet.",
|
||||
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
|
||||
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
|
||||
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
|
||||
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"compat": {
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Zugriffsbereich",
|
||||
"actions": {
|
||||
"clear_all": "Gruppe leeren",
|
||||
"reload": "Neu laden",
|
||||
"retry": "Erneut versuchen",
|
||||
"select_all": "Gruppe auswaehlen"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktiviert",
|
||||
"permission": "Berechtigung",
|
||||
"route": "Route",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Erlaubt Zugriff auf {name}.",
|
||||
"empty": "Es sind keine Berechtigungen verfuegbar.",
|
||||
"empty_group": "Diese Gruppe hat keine Berechtigungen.",
|
||||
"errors": {
|
||||
"load": "Rollenberechtigungen konnten nicht geladen werden.",
|
||||
"update": "Eine oder mehrere Berechtigungen konnten nicht aktualisiert werden. Die aktuellen Rollenberechtigungen wurden neu geladen."
|
||||
},
|
||||
"filtered_summary": "{count} Treffer",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Seitentyp- und Abteilungszugriff.",
|
||||
"label": "Zugriff"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Berechtigungen fuer Buchungen, Zeitbuchungen und Planung.",
|
||||
"label": "Buchungen"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Berechtigungen fuer Kunden, Benutzer, Mitarbeiter, Rollen und Anmeldung.",
|
||||
"label": "Kunden und Benutzer"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Abteilungsbetrieb, Spuren, Relais, Benachrichtigungen, Berichte und Ziele.",
|
||||
"label": "Abteilungen und Betrieb"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Berechtigungen fuer Rechnungen, Abrechnungsperioden, Inkasso und e-conomic.",
|
||||
"label": "Rechnungen und e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Berechtigungen fuer Auftraege, POS, Zahlungen und Auftragszeilen.",
|
||||
"label": "Auftraege und POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Berechtigungen, die keiner anderen Gruppe entsprechen.",
|
||||
"label": "Sonstige"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Berechtigungen fuer Produkte, Preise, Rabatte und Waehrungen.",
|
||||
"label": "Produkte und Preise"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Berechtigungen fuer Self-Service, Waschen, Scanner, Gateways und Edge-Hardware.",
|
||||
"label": "Self-Service und Edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, Konfiguration, Release, Replikation und Integrationen.",
|
||||
"label": "System und Konfiguration"
|
||||
}
|
||||
},
|
||||
"no_description": "Keine Beschreibung verfuegbar.",
|
||||
"no_search_results": "Keine Berechtigungen entsprechen der Suche.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Erlaubt Zugriff auf Admin-Dashboardseiten.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Erlaubt Zugriff auf Superuser-Dashboardseiten.",
|
||||
"label": "Superuser"
|
||||
},
|
||||
"user": {
|
||||
"description": "Erlaubt Zugriff auf Benutzer-Dashboardseiten.",
|
||||
"label": "Benutzer"
|
||||
}
|
||||
},
|
||||
"search_label": "Berechtigungen suchen",
|
||||
"search_placeholder": "Nach Label, Schluessel, Beschreibung, Route oder Methode suchen",
|
||||
"selected_summary": "{selected} von {total} ausgewaehlt",
|
||||
"status": {
|
||||
"disabled": "Deaktiviert",
|
||||
"enabled": "Aktiviert"
|
||||
},
|
||||
"subtitle": "Rollenberechtigungen",
|
||||
"title": "Berechtigungen fuer Rolle {id}",
|
||||
"title_with_name": "Berechtigungen fuer {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,6 @@
|
||||
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
|
||||
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
|
||||
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
|
||||
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -287,14 +279,6 @@
|
||||
"edit_title": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.bearbeiten'}",
|
||||
"name": "Name",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
|
||||
"country_codes": {
|
||||
"denmark": "Dänemark",
|
||||
"sweden": "Schweden",
|
||||
"norway": "Norwegen",
|
||||
"finland": "Finnland"
|
||||
},
|
||||
"password": "@:{'terms.glossary.passwort'}",
|
||||
"password_create_help": "@.capitalize:{'terms.glossary.fur'} neue @:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.erforderlich'}. Verwenden @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.mindestens'} 8 Zeichen.",
|
||||
"password_update_help": "Leer lassen, um das vorhandene @:{'terms.glossary.passwort'} beizubehalten.",
|
||||
|
||||
@@ -21,13 +21,6 @@
|
||||
"opening_hours": {
|
||||
"closed": "@:{'phrases.compat.global.closed'}"
|
||||
},
|
||||
"pricing": {
|
||||
"custom_pricing_disabled": "Fallback enabled",
|
||||
"custom_pricing_enabled": "Custom only",
|
||||
"custom_pricing_missing_price": "Missing department prices resolve to 999999.",
|
||||
"custom_pricing_only": "No fallback pricing",
|
||||
"effective_department_price": "Effective department price"
|
||||
},
|
||||
"search_placeholder": "@.capitalize:{'terms.glossary.search'} @:{'terms.glossary.by'} @:{'terms.glossary.department'} @:{'terms.glossary.name'}",
|
||||
"select_department": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.a'} @:{'terms.glossary.department'}",
|
||||
"subtitle": "@.capitalize:{'terms.glossary.manage'} @:{'terms.glossary.departments'}",
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"error_report": {
|
||||
"button": "Report error",
|
||||
"title": "Report error",
|
||||
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
|
||||
"subtitle": "Send the current screen and recent error details to support.",
|
||||
"before_error": "What were you doing before the error occurred?",
|
||||
"expected": "What did you expect would happen?",
|
||||
"actual": "What actually happened?",
|
||||
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
|
||||
"expected_placeholder": "Describe the result you expected to see.",
|
||||
"actual_placeholder": "Describe what you saw instead, including any error text.",
|
||||
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
|
||||
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
|
||||
"submit": "Submit report",
|
||||
"submitted": "Error report submitted.",
|
||||
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
|
||||
"capture_failed": "The screen capture failed. Please try again.",
|
||||
"submit_failed": "The error report could not be submitted.",
|
||||
"required": "All fields and data collection acceptance are required.",
|
||||
"page_subtitle": "Reported screens, request failures, and Vue errors",
|
||||
|
||||
@@ -140,7 +140,6 @@
|
||||
"departments": {
|
||||
"columns": {
|
||||
"archived": "@:{'terms.glossary.archived'}",
|
||||
"custom_pricing_only": "No fallback pricing",
|
||||
"dimension": "Dimension",
|
||||
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @.capitalize:{'terms.glossary.department'}",
|
||||
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"compat": {
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Access scope",
|
||||
"actions": {
|
||||
"clear_all": "Clear group",
|
||||
"reload": "Reload",
|
||||
"retry": "Try again",
|
||||
"select_all": "Select group"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Enabled",
|
||||
"permission": "Permission",
|
||||
"route": "Route",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Allows access to {name}.",
|
||||
"empty": "No permissions are available.",
|
||||
"empty_group": "This group has no permissions.",
|
||||
"errors": {
|
||||
"load": "Could not load role permissions.",
|
||||
"update": "Could not update one or more permissions. The current role permissions were reloaded."
|
||||
},
|
||||
"filtered_summary": "{count} matching",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Page type and department access permissions.",
|
||||
"label": "Access"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Booking, time booking, and planning permissions.",
|
||||
"label": "Bookings"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Customer, user, employee, role, and authentication permissions.",
|
||||
"label": "Customers and users"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Department operations, lanes, relays, notifications, reports, and goals.",
|
||||
"label": "Departments and operations"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Invoice, billing period, collection, and e-conomic permissions.",
|
||||
"label": "Invoicing and e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Order, POS, payment, and order line permissions.",
|
||||
"label": "Orders and POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Permissions that do not match another group.",
|
||||
"label": "Other"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Product, price, discount, and currency permissions.",
|
||||
"label": "Products and pricing"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Self-serve, wash, scanner, gateway, and edge hardware permissions.",
|
||||
"label": "Self-serve and edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, configuration, release, replication, and integration permissions.",
|
||||
"label": "System and configuration"
|
||||
}
|
||||
},
|
||||
"no_description": "No description is available.",
|
||||
"no_search_results": "No permissions match the current search.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Allows access to admin dashboard pages.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Allows access to superuser dashboard pages.",
|
||||
"label": "Superuser"
|
||||
},
|
||||
"user": {
|
||||
"description": "Allows access to user dashboard pages.",
|
||||
"label": "User"
|
||||
}
|
||||
},
|
||||
"search_label": "Search permissions",
|
||||
"search_placeholder": "Search by label, key, description, route, or method",
|
||||
"selected_summary": "{selected} of {total} selected",
|
||||
"status": {
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"subtitle": "Role permissions",
|
||||
"title": "Role {id} permissions",
|
||||
"title_with_name": "{name} permissions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,6 @@
|
||||
"compat": {
|
||||
"vehicles": {
|
||||
"add": "@.capitalize:{'terms.glossary.add'} @:{'terms.glossary.vehicle'}",
|
||||
"add_modal": {
|
||||
"customer_label": "Customer",
|
||||
"customer_placeholder": "Search by customer name or number",
|
||||
"error": "Unable to add vehicle.",
|
||||
"no_customer_results": "No customers found",
|
||||
"reference_placeholder": "Optional reference",
|
||||
"registration_placeholder": "Registration number",
|
||||
"selected_customer": "Selected customer",
|
||||
"submit": "Add vehicle",
|
||||
"title": "Add vehicle",
|
||||
"type_load_error": "Unable to load vehicle types.",
|
||||
"type_placeholder": "Select vehicle type",
|
||||
"validation_error": "Select a customer, registration number, and vehicle type."
|
||||
},
|
||||
"brand": "Brand",
|
||||
"color": "Color",
|
||||
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
|
||||
|
||||
@@ -46,14 +46,6 @@
|
||||
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
|
||||
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
|
||||
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
|
||||
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -287,14 +279,6 @@
|
||||
"edit_title": "@.capitalize:{'terms.glossary.edit'} @:{'terms.glossary.employee'}",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"phone_validation": "Phone number must be 4-15 digits.",
|
||||
"country_codes": {
|
||||
"denmark": "Denmark",
|
||||
"sweden": "Sweden",
|
||||
"norway": "Norway",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'terms.glossary.password'}",
|
||||
"password_create_help": "@.capitalize:{'terms.glossary.required'} @:{'terms.glossary.for'} new @:{'terms.glossary.employees'}. Use @:{'terms.glossary.at'} @:{'terms.glossary.least'} 8 characters.",
|
||||
"password_update_help": "Leave blank @:{'terms.glossary.to'} keep @:{'terms.replication.article.host_mention'} existing @:{'terms.glossary.password'}.",
|
||||
|
||||
@@ -38,13 +38,6 @@
|
||||
"title": "@:{'phrases.compat.departments.tab.opening_hours'}"
|
||||
},
|
||||
"phone": "@:common.phone",
|
||||
"pricing": {
|
||||
"custom_pricing_disabled": "@:{'phrases.compat.departments.pricing.custom_pricing_disabled'}",
|
||||
"custom_pricing_enabled": "@:{'phrases.compat.departments.pricing.custom_pricing_enabled'}",
|
||||
"custom_pricing_missing_price": "@:{'phrases.compat.departments.pricing.custom_pricing_missing_price'}",
|
||||
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
|
||||
"effective_department_price": "@:{'phrases.compat.departments.pricing.effective_department_price'}"
|
||||
},
|
||||
"products": {
|
||||
"new_product": "@:products.new_product"
|
||||
},
|
||||
|
||||
@@ -194,7 +194,6 @@
|
||||
"departments": {
|
||||
"columns": {
|
||||
"archived": "@:{'phrases.compat.objects.departments.columns.archived'}",
|
||||
"custom_pricing_only": "@:{'phrases.compat.objects.departments.columns.custom_pricing_only'}",
|
||||
"dimension": "@:{'phrases.compat.objects.departments.columns.dimension'}",
|
||||
"economic_department": "@:{'phrases.compat.objects.departments.columns.economic_department'}",
|
||||
"latitude": "@:{'phrases.compat.objects.departments.columns.latitude'}",
|
||||
|
||||
@@ -1,96 +1,5 @@
|
||||
{
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "@:{'phrases.compat.roles.permissions.access_scope'}",
|
||||
"actions": {
|
||||
"clear_all": "@:{'phrases.compat.roles.permissions.actions.clear_all'}",
|
||||
"reload": "@:{'phrases.compat.roles.permissions.actions.reload'}",
|
||||
"retry": "@:{'phrases.compat.roles.permissions.actions.retry'}",
|
||||
"select_all": "@:{'phrases.compat.roles.permissions.actions.select_all'}"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "@:{'phrases.compat.roles.permissions.columns.enabled'}",
|
||||
"permission": "@:{'phrases.compat.roles.permissions.columns.permission'}",
|
||||
"route": "@:{'phrases.compat.roles.permissions.columns.route'}",
|
||||
"status": "@:{'phrases.compat.roles.permissions.columns.status'}"
|
||||
},
|
||||
"department_access_description": "@:{'phrases.compat.roles.permissions.department_access_description'}",
|
||||
"empty": "@:{'phrases.compat.roles.permissions.empty'}",
|
||||
"empty_group": "@:{'phrases.compat.roles.permissions.empty_group'}",
|
||||
"errors": {
|
||||
"load": "@:{'phrases.compat.roles.permissions.errors.load'}",
|
||||
"update": "@:{'phrases.compat.roles.permissions.errors.update'}"
|
||||
},
|
||||
"filtered_summary": "@:{'phrases.compat.roles.permissions.filtered_summary'}",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.access.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.access.label'}"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.bookings.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.bookings.label'}"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.customers_users.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.customers_users.label'}"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.departments_operations.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.departments_operations.label'}"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.invoicing_economic.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.invoicing_economic.label'}"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.orders_pos.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.orders_pos.label'}"
|
||||
},
|
||||
"other": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.other.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.other.label'}"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.products_pricing.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.products_pricing.label'}"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.selfserve_edge.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.selfserve_edge.label'}"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.groups.system_config.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.groups.system_config.label'}"
|
||||
}
|
||||
},
|
||||
"no_description": "@:{'phrases.compat.roles.permissions.no_description'}",
|
||||
"no_search_results": "@:{'phrases.compat.roles.permissions.no_search_results'}",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.page_access.admin.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.page_access.admin.label'}"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.page_access.superuser.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.page_access.superuser.label'}"
|
||||
},
|
||||
"user": {
|
||||
"description": "@:{'phrases.compat.roles.permissions.page_access.user.description'}",
|
||||
"label": "@:{'phrases.compat.roles.permissions.page_access.user.label'}"
|
||||
}
|
||||
},
|
||||
"search_label": "@:{'phrases.compat.roles.permissions.search_label'}",
|
||||
"search_placeholder": "@:{'phrases.compat.roles.permissions.search_placeholder'}",
|
||||
"selected_summary": "@:{'phrases.compat.roles.permissions.selected_summary'}",
|
||||
"status": {
|
||||
"disabled": "@:{'phrases.compat.roles.permissions.status.disabled'}",
|
||||
"enabled": "@:{'phrases.compat.roles.permissions.status.enabled'}"
|
||||
},
|
||||
"subtitle": "@:{'phrases.compat.roles.permissions.subtitle'}",
|
||||
"title": "@:{'phrases.compat.roles.permissions.title'}",
|
||||
"title_with_name": "@:{'phrases.compat.roles.permissions.title_with_name'}"
|
||||
},
|
||||
"subtitle": "@:{'phrases.compat.roles.subtitle'}",
|
||||
"title": "@:{'phrases.compat.common.roles'}"
|
||||
}
|
||||
|
||||
@@ -40,82 +40,6 @@
|
||||
"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'}",
|
||||
|
||||
@@ -2,20 +2,6 @@
|
||||
"vehicles": {
|
||||
"actions": "@:common.actions",
|
||||
"add": "@:{'phrases.compat.vehicles.add'}",
|
||||
"add_modal": {
|
||||
"customer_label": "@:{'phrases.compat.vehicles.add_modal.customer_label'}",
|
||||
"customer_placeholder": "@:{'phrases.compat.vehicles.add_modal.customer_placeholder'}",
|
||||
"error": "@:{'phrases.compat.vehicles.add_modal.error'}",
|
||||
"no_customer_results": "@:{'phrases.compat.vehicles.add_modal.no_customer_results'}",
|
||||
"reference_placeholder": "@:{'phrases.compat.vehicles.add_modal.reference_placeholder'}",
|
||||
"registration_placeholder": "@:{'phrases.compat.vehicles.add_modal.registration_placeholder'}",
|
||||
"selected_customer": "@:{'phrases.compat.vehicles.add_modal.selected_customer'}",
|
||||
"submit": "@:{'phrases.compat.vehicles.add_modal.submit'}",
|
||||
"title": "@:{'phrases.compat.vehicles.add_modal.title'}",
|
||||
"type_load_error": "@:{'phrases.compat.vehicles.add_modal.type_load_error'}",
|
||||
"type_placeholder": "@:{'phrases.compat.vehicles.add_modal.type_placeholder'}",
|
||||
"validation_error": "@:{'phrases.compat.vehicles.add_modal.validation_error'}"
|
||||
},
|
||||
"brand": "@:{'phrases.compat.vehicles.brand'}",
|
||||
"color": "@:{'phrases.compat.vehicles.color'}",
|
||||
"created_at": "@:{'phrases.compat.global.generated'}",
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"error_report": {
|
||||
"button": "Rapporter feil",
|
||||
"title": "Rapporter feil",
|
||||
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
|
||||
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
|
||||
"before_error": "Hva gjorde du før feilen oppstod?",
|
||||
"expected": "Hva forventet du at skulle skje?",
|
||||
"actual": "Hva skjedde faktisk?",
|
||||
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
|
||||
"expected_placeholder": "Beskriv resultatet du forventet å se.",
|
||||
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
|
||||
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
|
||||
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
|
||||
"submit": "Send rapport",
|
||||
"submitted": "Feilrapport sendt.",
|
||||
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
|
||||
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
|
||||
"submit_failed": "Feilrapporten kunne ikke sendes.",
|
||||
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
|
||||
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"compat": {
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Tilgangsomrade",
|
||||
"actions": {
|
||||
"clear_all": "Tomm gruppe",
|
||||
"reload": "Last inn pa nytt",
|
||||
"retry": "Prov igjen",
|
||||
"select_all": "Velg gruppe"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktivert",
|
||||
"permission": "Tillatelse",
|
||||
"route": "Rute",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Gir tilgang til {name}.",
|
||||
"empty": "Ingen tillatelser er tilgjengelige.",
|
||||
"empty_group": "Denne gruppen har ingen tillatelser.",
|
||||
"errors": {
|
||||
"load": "Kunne ikke laste rolletillatelser.",
|
||||
"update": "En eller flere tillatelser kunne ikke oppdateres. De gjeldende rolletillatelsene ble lastet inn pa nytt."
|
||||
},
|
||||
"filtered_summary": "{count} treff",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Sidetype- og avdelingstilgang.",
|
||||
"label": "Tilgang"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Tillatelser for bookinger, tidsbookinger og planlegging.",
|
||||
"label": "Bookinger"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Tillatelser for kunder, brukere, ansatte, roller og innlogging.",
|
||||
"label": "Kunder og brukere"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Avdelingsdrift, baner, releer, varsler, rapporter og mal.",
|
||||
"label": "Avdelinger og drift"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Tillatelser for fakturaer, faktureringsperioder, innkreving og e-conomic.",
|
||||
"label": "Fakturering og e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Tillatelser for ordrer, POS, betalinger og ordrelinjer.",
|
||||
"label": "Ordrer og POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Tillatelser som ikke matcher en annen gruppe.",
|
||||
"label": "Annet"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Tillatelser for produkter, priser, rabatter og valuta.",
|
||||
"label": "Produkter og priser"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Tillatelser for selvbetjening, vask, skannere, gatewayer og edge-utstyr.",
|
||||
"label": "Selvbetjening og edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, konfigurasjon, release, replikering og integrasjoner.",
|
||||
"label": "System og konfigurasjon"
|
||||
}
|
||||
},
|
||||
"no_description": "Ingen beskrivelse er tilgjengelig.",
|
||||
"no_search_results": "Ingen tillatelser matcher soket.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Gir tilgang til administrasjonssider.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Gir tilgang til superbrukersider.",
|
||||
"label": "Superbruker"
|
||||
},
|
||||
"user": {
|
||||
"description": "Gir tilgang til brukersider.",
|
||||
"label": "Bruker"
|
||||
}
|
||||
},
|
||||
"search_label": "Sok i tillatelser",
|
||||
"search_placeholder": "Sok pa label, nokkel, beskrivelse, rute eller metode",
|
||||
"selected_summary": "{selected} av {total} valgt",
|
||||
"status": {
|
||||
"disabled": "Deaktivert",
|
||||
"enabled": "Aktivert"
|
||||
},
|
||||
"subtitle": "Rolletillatelser",
|
||||
"title": "Tillatelser for rolle {id}",
|
||||
"title_with_name": "Tillatelser for {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,6 @@
|
||||
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
|
||||
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
|
||||
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
|
||||
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -287,14 +279,6 @@
|
||||
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbeider'}",
|
||||
"name": "Navn",
|
||||
"email": "E-post",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
|
||||
"country_codes": {
|
||||
"denmark": "Danmark",
|
||||
"sweden": "Sverige",
|
||||
"norway": "Norge",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'terms.glossary.passord'}",
|
||||
"password_create_help": "@.capitalize:{'terms.glossary.kreves'} @:{'terms.glossary.for'} nye @:{'terms.glossary.medarbeidere'}. Bruk @:{'terms.glossary.minst'} 8 tegn.",
|
||||
"password_update_help": "La stå tomt @:{'terms.glossary.for'} å beholde nåværende @:{'terms.glossary.passord'}.",
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"error_report": {
|
||||
"button": "Rapportera fel",
|
||||
"title": "Rapportera fel",
|
||||
"subtitle": "Skicka de senaste feldetaljerna till support. En skärmbild bifogas när det är möjligt.",
|
||||
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
|
||||
"before_error": "Vad gjorde du innan felet uppstod?",
|
||||
"expected": "Vad förväntade du dig skulle hända?",
|
||||
"actual": "Vad hände faktiskt?",
|
||||
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
|
||||
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
|
||||
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
|
||||
"consent": "Jag accepterar att senaste request-fel, Vue-fel, webbläsardetaljer, mina svar och en app-skärmbild när det är möjligt samlas in för felsökning.",
|
||||
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
|
||||
"submit": "Skicka rapport",
|
||||
"submitted": "Felrapport skickad.",
|
||||
"capture_failed": "Skärmbilden kunde inte tas. Rapporten skickas utan skärmbild.",
|
||||
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
|
||||
"submit_failed": "Felrapporten kunde inte skickas.",
|
||||
"required": "Alla fält och godkännande av datainsamling krävs.",
|
||||
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"compat": {
|
||||
"roles": {
|
||||
"permissions": {
|
||||
"access_scope": "Atkomstomrade",
|
||||
"actions": {
|
||||
"clear_all": "Rensa grupp",
|
||||
"reload": "Ladda om",
|
||||
"retry": "Forsok igen",
|
||||
"select_all": "Valj grupp"
|
||||
},
|
||||
"columns": {
|
||||
"enabled": "Aktiverad",
|
||||
"permission": "Behorighet",
|
||||
"route": "Rutt",
|
||||
"status": "Status"
|
||||
},
|
||||
"department_access_description": "Ger atkomst till {name}.",
|
||||
"empty": "Inga behorigheter ar tillgangliga.",
|
||||
"empty_group": "Den har gruppen har inga behorigheter.",
|
||||
"errors": {
|
||||
"load": "Kunde inte lasa in rollbehorigheter.",
|
||||
"update": "En eller flera behorigheter kunde inte uppdateras. Rollens aktuella behorigheter laddades om."
|
||||
},
|
||||
"filtered_summary": "{count} traffar",
|
||||
"groups": {
|
||||
"access": {
|
||||
"description": "Sidtyp- och avdelningsatkomst.",
|
||||
"label": "Atkomst"
|
||||
},
|
||||
"bookings": {
|
||||
"description": "Behorigheter for bokningar, tidsbokningar och planering.",
|
||||
"label": "Bokningar"
|
||||
},
|
||||
"customers_users": {
|
||||
"description": "Behorigheter for kunder, anvandare, medarbetare, roller och inloggning.",
|
||||
"label": "Kunder och anvandare"
|
||||
},
|
||||
"departments_operations": {
|
||||
"description": "Avdelningsdrift, banor, relan, aviseringar, rapporter och mal.",
|
||||
"label": "Avdelningar och drift"
|
||||
},
|
||||
"invoicing_economic": {
|
||||
"description": "Behorigheter for fakturor, faktureringsperioder, inkasso och e-conomic.",
|
||||
"label": "Fakturering och e-conomic"
|
||||
},
|
||||
"orders_pos": {
|
||||
"description": "Behorigheter for ordrar, POS, betalningar och orderrader.",
|
||||
"label": "Ordrar och POS"
|
||||
},
|
||||
"other": {
|
||||
"description": "Behorigheter som inte matchar nagon annan grupp.",
|
||||
"label": "Ovrigt"
|
||||
},
|
||||
"products_pricing": {
|
||||
"description": "Behorigheter for produkter, priser, rabatter och valuta.",
|
||||
"label": "Produkter och priser"
|
||||
},
|
||||
"selfserve_edge": {
|
||||
"description": "Behorigheter for sjalvbetjaning, tvatt, skannrar, gateways och edge-utrustning.",
|
||||
"label": "Sjalvbetjaning och edge"
|
||||
},
|
||||
"system_config": {
|
||||
"description": "System, konfiguration, release, replikering och integrationer.",
|
||||
"label": "System och konfiguration"
|
||||
}
|
||||
},
|
||||
"no_description": "Ingen beskrivning ar tillganglig.",
|
||||
"no_search_results": "Inga behorigheter matchar aktuell sokning.",
|
||||
"page_access": {
|
||||
"admin": {
|
||||
"description": "Ger atkomst till administrationssidor.",
|
||||
"label": "Admin"
|
||||
},
|
||||
"superuser": {
|
||||
"description": "Ger atkomst till superuser-sidor.",
|
||||
"label": "Superuser"
|
||||
},
|
||||
"user": {
|
||||
"description": "Ger atkomst till anvandarsidor.",
|
||||
"label": "Anvandare"
|
||||
}
|
||||
},
|
||||
"search_label": "Sok behorigheter",
|
||||
"search_placeholder": "Sok pa label, nyckel, beskrivning, rutt eller metod",
|
||||
"selected_summary": "{selected} av {total} valda",
|
||||
"status": {
|
||||
"disabled": "Inaktiverad",
|
||||
"enabled": "Aktiverad"
|
||||
},
|
||||
"subtitle": "Rollbehorigheter",
|
||||
"title": "Behorigheter for roll {id}",
|
||||
"title_with_name": "Behorigheter for {name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,14 +46,6 @@
|
||||
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
|
||||
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
|
||||
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
|
||||
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
|
||||
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
|
||||
"country_codes": {
|
||||
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
|
||||
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
|
||||
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
|
||||
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
|
||||
},
|
||||
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
|
||||
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
|
||||
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
|
||||
@@ -287,14 +279,6 @@
|
||||
"edit_title": "@:{'terms.glossary.redigera'} @:{'terms.glossary.medarbetare'}",
|
||||
"name": "Namn",
|
||||
"email": "E-post",
|
||||
"phone": "Telefon",
|
||||
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
|
||||
"country_codes": {
|
||||
"denmark": "Danmark",
|
||||
"sweden": "Sverige",
|
||||
"norway": "Norge",
|
||||
"finland": "Finland"
|
||||
},
|
||||
"password": "@.capitalize:{'terms.glossary.losenord'}",
|
||||
"password_create_help": "@.capitalize:{'terms.glossary.kravs'} @:{'terms.glossary.for'} nya @:{'terms.glossary.medarbetare'}. Använd @:{'terms.glossary.minst'} 8 tecken.",
|
||||
"password_update_help": "Lämna tomt @:{'terms.glossary.for'} att behålla nuvarande @:{'terms.glossary.losenord'}.",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const DATE_ONLY_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/;
|
||||
|
||||
const padDatePart = (value) => String(value).padStart(2, "0");
|
||||
|
||||
export const isValidDate = (value) => value instanceof Date && !Number.isNaN(value.getTime());
|
||||
|
||||
export const formatLocalDateOnly = (value = new Date()) => {
|
||||
if (typeof value === "string") {
|
||||
const directMatch = value.trim().match(DATE_ONLY_PREFIX_PATTERN);
|
||||
if (directMatch) {
|
||||
return directMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (!isValidDate(date)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
padDatePart(date.getMonth() + 1),
|
||||
padDatePart(date.getDate()),
|
||||
].join("-");
|
||||
};
|
||||
|
||||
export const parseLocalDateOnly = (value) => {
|
||||
if (value instanceof Date) {
|
||||
return isValidDate(value)
|
||||
? new Date(value.getFullYear(), value.getMonth(), value.getDate())
|
||||
: new Date(Number.NaN);
|
||||
}
|
||||
|
||||
const stringValue = String(value ?? "").trim();
|
||||
const match = DATE_ONLY_PATTERN.exec(stringValue);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const parsed = new Date(year, month - 1, day);
|
||||
|
||||
if (
|
||||
parsed.getFullYear() === year
|
||||
&& parsed.getMonth() === month - 1
|
||||
&& parsed.getDate() === day
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return new Date(Number.NaN);
|
||||
}
|
||||
|
||||
const parsed = new Date(stringValue);
|
||||
return isValidDate(parsed)
|
||||
? new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
|
||||
: parsed;
|
||||
};
|
||||
|
||||
export const todayLocalDateOnly = () => formatLocalDateOnly(new Date());
|
||||
|
||||
export const yesterdayLocalDateOnly = () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return formatLocalDateOnly(yesterday);
|
||||
};
|
||||
|
||||
export const startOfLocalDate = (value) => {
|
||||
const date = parseLocalDateOnly(value);
|
||||
if (!isValidDate(date)) {
|
||||
return date;
|
||||
}
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
};
|
||||
|
||||
export const endOfLocalDate = (value) => {
|
||||
const date = parseLocalDateOnly(value);
|
||||
if (!isValidDate(date)) {
|
||||
return date;
|
||||
}
|
||||
date.setHours(23, 59, 59, 999);
|
||||
return date;
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -16,12 +16,6 @@ import {
|
||||
} from "@/services/limitedBackoffice.js";
|
||||
|
||||
const ROLE_KEYS = ["viewer", "cashier", "booking_coordinator", "operations_lead", "department_admin"];
|
||||
const PHONE_COUNTRY_CODES = [
|
||||
{ value: "45", flag: "🇩🇰", labelKey: "denmark" },
|
||||
{ value: "46", flag: "🇸🇪", labelKey: "sweden" },
|
||||
{ value: "47", flag: "🇳🇴", labelKey: "norway" },
|
||||
{ value: "358", flag: "🇫🇮", labelKey: "finland" },
|
||||
];
|
||||
|
||||
const { t } = useI18n();
|
||||
const departments = ref([]);
|
||||
@@ -38,8 +32,6 @@ const showRolePermissions = ref(false);
|
||||
const form = ref({
|
||||
display_name: "",
|
||||
email: "",
|
||||
phone_country_code: "45",
|
||||
phone: "",
|
||||
password: "",
|
||||
role_key: "viewer",
|
||||
department_ids: [],
|
||||
@@ -220,41 +212,15 @@ const rolePermissionGroups = (role) =>
|
||||
|
||||
const visibleRoles = computed(() => roles.value.filter((role) => ROLE_KEYS.includes(role.key)));
|
||||
const isEditing = computed(() => Boolean(editingEmployee.value));
|
||||
const trimmedEmail = computed(() => String(form.value.email || "").trim());
|
||||
const isEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail.value));
|
||||
const normalizedPhone = computed(() => String(form.value.phone || "").replace(/\D/g, ""));
|
||||
const hasPhone = computed(() => normalizedPhone.value.length > 0);
|
||||
const isPhoneValid = computed(() => !hasPhone.value || /^\d{4,15}$/.test(normalizedPhone.value));
|
||||
const isPasswordValid = computed(() => isEditing.value || String(form.value.password || "").length >= 8);
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
String(form.value.display_name || "").trim().length > 0 &&
|
||||
isEmailValid.value &&
|
||||
isPhoneValid.value &&
|
||||
ROLE_KEYS.includes(form.value.role_key) &&
|
||||
form.value.department_ids.length > 0 &&
|
||||
isPasswordValid.value
|
||||
);
|
||||
|
||||
const countryCodeLabel = (countryCode) => {
|
||||
const option = PHONE_COUNTRY_CODES.find((item) => item.value === String(countryCode || ""));
|
||||
if (!option) {
|
||||
return "";
|
||||
}
|
||||
return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;
|
||||
};
|
||||
|
||||
const formatEmployeePhone = (employee) => {
|
||||
if (!employee?.phone_country_code || !employee?.phone) {
|
||||
return "";
|
||||
}
|
||||
return `+${employee.phone_country_code} ${employee.phone}`;
|
||||
};
|
||||
|
||||
const sanitizePhone = (event) => {
|
||||
form.value.phone = String(event?.target?.value || "").replace(/\D/g, "");
|
||||
};
|
||||
|
||||
const loadEmployees = async () => {
|
||||
const response = await getLimitedBackofficeEmployees({ includeInactive: includeInactive.value });
|
||||
employees.value = unwrapLimitedBackofficeResponse(response) || [];
|
||||
@@ -291,8 +257,6 @@ const resetForm = () => {
|
||||
form.value = {
|
||||
display_name: "",
|
||||
email: "",
|
||||
phone_country_code: "45",
|
||||
phone: "",
|
||||
password: "",
|
||||
role_key: roles.value[0]?.key || "viewer",
|
||||
department_ids: [],
|
||||
@@ -305,8 +269,6 @@ const editEmployee = (employee) => {
|
||||
form.value = {
|
||||
display_name: employee.display_name || "",
|
||||
email: employee.email || "",
|
||||
phone_country_code: employee.phone_country_code ? String(employee.phone_country_code) : "45",
|
||||
phone: employee.phone ? String(employee.phone) : "",
|
||||
password: "",
|
||||
role_key: employee.role?.key || roles.value[0]?.key || "viewer",
|
||||
department_ids: (employee.departments || []).map((department) => Number(department.id)),
|
||||
@@ -329,9 +291,7 @@ const setDepartmentChecked = (departmentId, checked) => {
|
||||
const buildPayload = () => {
|
||||
const payload = {
|
||||
display_name: String(form.value.display_name || "").trim(),
|
||||
email: trimmedEmail.value,
|
||||
phone_country_code: hasPhone.value ? Number.parseInt(form.value.phone_country_code, 10) : null,
|
||||
phone: hasPhone.value ? Number.parseInt(normalizedPhone.value, 10) : null,
|
||||
email: String(form.value.email || "").trim() || null,
|
||||
role_key: form.value.role_key,
|
||||
department_ids: form.value.department_ids,
|
||||
};
|
||||
@@ -456,51 +416,12 @@ onMounted(() => {
|
||||
id="limited-employee-email"
|
||||
v-model="form.email"
|
||||
class="input"
|
||||
:class="{ 'is-danger': formAttempted && !isEmailValid }"
|
||||
type="email"
|
||||
required
|
||||
data-testid="limited-employee-email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="limited-employee-phone">
|
||||
{{ t("templates.limited_backoffice.employees.phone") }}
|
||||
</label>
|
||||
<div class="limited-employee-phone">
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select
|
||||
id="limited-employee-phone-country-code"
|
||||
v-model="form.phone_country_code"
|
||||
data-testid="limited-employee-phone-country-code"
|
||||
>
|
||||
<option v-for="option in PHONE_COUNTRY_CODES" :key="option.value" :value="option.value">
|
||||
{{ countryCodeLabel(option.value) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<input
|
||||
id="limited-employee-phone"
|
||||
v-model="form.phone"
|
||||
class="input"
|
||||
:class="{ 'is-danger': formAttempted && !isPhoneValid }"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
data-testid="limited-employee-phone"
|
||||
@input="sanitizePhone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="formAttempted && !isPhoneValid" class="help is-danger">
|
||||
{{ t("templates.limited_backoffice.employees.phone_validation") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="limited-employee-password">
|
||||
{{ t("templates.limited_backoffice.employees.password") }}
|
||||
@@ -567,17 +488,15 @@ onMounted(() => {
|
||||
<div class="field">
|
||||
<label class="label">{{ t("templates.limited_backoffice.employees.departments") }}</label>
|
||||
<div class="limited-employee-departments" data-testid="limited-employee-departments">
|
||||
<b-switch
|
||||
v-for="department in departments"
|
||||
:key="department.id"
|
||||
size="is-small"
|
||||
type="is-link"
|
||||
:model-value="departmentChecked(department.id)"
|
||||
:data-testid="`limited-employee-department-${department.id}`"
|
||||
@update:model-value="setDepartmentChecked(department.id, $event)"
|
||||
>
|
||||
{{ department.name }}
|
||||
</b-switch>
|
||||
<label v-for="department in departments" :key="department.id" class="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="departmentChecked(department.id)"
|
||||
:data-testid="`limited-employee-department-${department.id}`"
|
||||
@change="setDepartmentChecked(department.id, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ department.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="formAttempted && form.department_ids.length === 0" class="help is-danger">
|
||||
{{ t("templates.limited_backoffice.employees.department_required") }}
|
||||
@@ -588,12 +507,12 @@ onMounted(() => {
|
||||
{{ t("templates.limited_backoffice.employees.validation") }}
|
||||
</div>
|
||||
|
||||
<div class="limited-employee-actions">
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="submit"
|
||||
class="button is-dark is-fullwidth"
|
||||
class="button is-dark"
|
||||
:class="{ 'is-loading': saving }"
|
||||
:disabled="saving || !isFormValid"
|
||||
:disabled="saving"
|
||||
data-testid="limited-employee-save"
|
||||
>
|
||||
<span class="icon is-small"><i class="fas fa-save" aria-hidden="true"></i></span>
|
||||
@@ -602,7 +521,7 @@ onMounted(() => {
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="button is-light is-fullwidth"
|
||||
class="button is-light"
|
||||
data-testid="limited-employee-cancel"
|
||||
@click="resetForm"
|
||||
>
|
||||
@@ -663,13 +582,6 @@ onMounted(() => {
|
||||
<td>
|
||||
<strong>{{ employee.display_name }}</strong>
|
||||
<p v-if="employee.email" class="has-text-grey is-size-7">{{ employee.email }}</p>
|
||||
<p
|
||||
v-if="formatEmployeePhone(employee)"
|
||||
class="has-text-grey is-size-7"
|
||||
:data-testid="`limited-employee-phone-${employee.id}`"
|
||||
>
|
||||
{{ formatEmployeePhone(employee) }}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ roleLabel(employee.role) }}</strong>
|
||||
@@ -821,14 +733,9 @@ onMounted(() => {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.limited-employee-phone {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9.5rem, 0.75fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.limited-employee-actions {
|
||||
display: grid;
|
||||
.limited-employee-departments .checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -959,10 +866,6 @@ onMounted(() => {
|
||||
.limited-employees-header__inactive {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.limited-employee-phone {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
|
||||
+3
-4
@@ -19,7 +19,6 @@ import DepartmentDashboardDailyReportNavigation from "@/views/dashboards/departm
|
||||
import DepartmentDashboardDailyReportProductSales from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue";
|
||||
import DepartmentDashboardDailyReportTodayForm from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportTodayForm.vue";
|
||||
import DepartmentWeather from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentWeather.vue";
|
||||
import { parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
import {
|
||||
complaints_metric_state,
|
||||
count_bookings,
|
||||
@@ -132,7 +131,7 @@ onMounted(() => {
|
||||
return [...accessibleDepartmentIds.value];
|
||||
})();
|
||||
|
||||
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : todayLocalDateOnly();
|
||||
const initialDateFrom = typeof query.dateFrom === "string" ? query.dateFrom : new Date().toISOString().split("T")[0];
|
||||
const initialDateTo = typeof query.dateTo === "string" ? query.dateTo : initialDateFrom;
|
||||
|
||||
initializeDailyReportFilters({
|
||||
@@ -171,7 +170,7 @@ watch(
|
||||
);
|
||||
|
||||
const formatDateShort = (dateString) => {
|
||||
const date = parseLocalDateOnly(dateString);
|
||||
const date = new Date(dateString);
|
||||
const dayName = date.toLocaleDateString("da-DK", { weekday: "long" });
|
||||
const dateValue = date.toLocaleDateString("da-DK", { day: "2-digit", month: "2-digit", year: "2-digit" });
|
||||
return `${dayName} d. ${dateValue}`;
|
||||
@@ -179,7 +178,7 @@ const formatDateShort = (dateString) => {
|
||||
|
||||
const formatDateRangeSubtitle = (startDateString, endDateString) => {
|
||||
const dateOptions = { day: "2-digit", month: "2-digit", year: "2-digit" };
|
||||
return `${parseLocalDateOnly(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${parseLocalDateOnly(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
|
||||
return `${new Date(startDateString).toLocaleDateString("da-DK", dateOptions)} - ${new Date(endDateString).toLocaleDateString("da-DK", dateOptions)}`;
|
||||
};
|
||||
|
||||
const formatSubtitle = (subtitle) => {
|
||||
|
||||
+5
-3
@@ -2,16 +2,18 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { createEmptyOutsideHours, normalizeOutsideHours } from "@/views/dashboards/departmentDashboard/modules/daily-report/outsideHours.js";
|
||||
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const GENERATE_UNIQUE_ID = () => Math.random().toString(36).slice(2, 11);
|
||||
|
||||
const DATE_TODAY = () => {
|
||||
return todayLocalDateOnly();
|
||||
const date = new Date();
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const DATE_YESTERDAY = () => {
|
||||
return yesterdayLocalDateOnly();
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 1);
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const date_shortcuts = ref({
|
||||
|
||||
+9
-3
@@ -17,7 +17,6 @@ import {
|
||||
isLoading as departmentsStoreLoading,
|
||||
} from "@/components/pagination/departmentTabs.vue";
|
||||
import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
|
||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
/**
|
||||
* Props:
|
||||
@@ -142,7 +141,14 @@ const toggleAllDepartments = () => {
|
||||
};
|
||||
|
||||
const formatDateSelectionValue = (date) => {
|
||||
return formatLocalDateOnly(date);
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
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 onDateSelectionChange = (startDate, endDate) => {
|
||||
@@ -155,7 +161,7 @@ const onDateSelectionChange = (startDate, endDate) => {
|
||||
<div class="column is-12" data-testid="daily-report-date-controls">
|
||||
<DatePeriodSelector
|
||||
:on-selection-change="onDateSelectionChange"
|
||||
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
|
||||
:selection="{ startDate: new Date(selected_date), endDate: new Date(selected_date_to) }"
|
||||
:visibility="{
|
||||
showDailySelector: true,
|
||||
showWeeklySelector: true,
|
||||
|
||||
+3
-4
@@ -5,7 +5,6 @@ import { parseError, getError, clearErrors} from "@/components/request/HandleGlo
|
||||
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
||||
import { selected_date, onChangeCall, selected_department_id} from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { parseLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const props = defineProps({
|
||||
department_id: Number,
|
||||
@@ -24,7 +23,7 @@ const created_at = ref(null);
|
||||
const id = ref(null);
|
||||
const message = ref('');
|
||||
const yesterday = ref({
|
||||
date: yesterdayLocalDateOnly(),
|
||||
date: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split('T')[0],
|
||||
water_usage: 0,
|
||||
water_usage_morning: 0,
|
||||
notes: null,
|
||||
@@ -105,7 +104,7 @@ const getDailyReport = async () => {
|
||||
// Check if the date is today
|
||||
const created_at_val = new Date(response.data.data.created_at);
|
||||
const today = new Date();
|
||||
if (created_at_val.toDateString() === (selected_date.value ? parseLocalDateOnly(selected_date.value) : today).toDateString()) {
|
||||
if (created_at_val.toDateString() === (selected_date.value ? new Date(selected_date.value) : today).toDateString()) {
|
||||
// The daily report is today
|
||||
// So we can set the values
|
||||
water_usage.value = parseInt(response.data.data.water_usage);
|
||||
@@ -213,4 +212,4 @@ onChangeCall(() => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
+3
-4
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { timeBookingsNewScheduler } from '@/views/dashboards/departmentDashboard/modules/time-bookings/book/TimeBookingsNewScheduler.vue';
|
||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
||||
const date = ref();
|
||||
// Set the initial date to the current date YYYY-MM-DD format
|
||||
const formatDate = (date_input) => {
|
||||
return formatLocalDateOnly(date_input);
|
||||
return date_input.toISOString().split('T')[0];
|
||||
};
|
||||
date.value = formatDate(new Date());
|
||||
|
||||
@@ -16,7 +15,7 @@ watch(timeBookingsNewScheduler.date.selected, (newDate) => {
|
||||
|
||||
watch(date, (newDate) => {
|
||||
// Update the selected date in the scheduler when the date input changes
|
||||
timeBookingsNewScheduler.date.selected.value = parseLocalDateOnly(newDate);
|
||||
timeBookingsNewScheduler.date.selected.value = new Date(newDate);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -64,4 +63,4 @@ watch(date, (newDate) => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
+2
-2
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const props = defineProps({
|
||||
department_id: {
|
||||
@@ -69,7 +68,8 @@ const getBookings = async () => {
|
||||
};
|
||||
|
||||
const getDate = () => {
|
||||
return todayLocalDateOnly();
|
||||
const date = new Date();
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Get the department
|
||||
|
||||
+3
-4
@@ -2,7 +2,6 @@
|
||||
|
||||
import { selected_date, selected_date_to, selectDate as selectDailyReportDate } from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
|
||||
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
|
||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
// Emits for parents that want to react to date changes (non-breaking if unused)
|
||||
const emit = defineEmits(['date-change']);
|
||||
@@ -17,12 +16,12 @@ const selectDate = (startIso, endIso) => {
|
||||
|
||||
<template>
|
||||
<DatePeriodSelector
|
||||
:on-selection-change="(startDate, endDate) => selectDate(formatLocalDateOnly(startDate), formatLocalDateOnly(endDate))"
|
||||
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
|
||||
:on-selection-change="(startDate, endDate) => selectDate(startDate.toISOString().split('T')[0], endDate.toISOString().split('T')[0])"
|
||||
:selection="{ startDate: new Date(selected_date), endDate: new Date(selected_date_to) }"
|
||||
:visibility="{ showDailySelector: true, showWeeklySelector: true, showMultipleMonthWarning: false, showUpdateButton: false }"
|
||||
:reverse-level-order="true"
|
||||
@update:selection="(newSelection) => {
|
||||
selectDate(formatLocalDateOnly(newSelection.startDate), formatLocalDateOnly(newSelection.endDate));
|
||||
selectDate(newSelection.startDate.toISOString().split('T')[0], newSelection.endDate.toISOString().split('T')[0]);
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
+3
-4
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref, watch} from 'vue';
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
|
||||
type fetch = {
|
||||
@@ -12,8 +11,8 @@ type fetch = {
|
||||
};
|
||||
/** Define the variables */
|
||||
export const show_this_week = ref(false);
|
||||
export const selected_date = ref(todayLocalDateOnly());
|
||||
export const selected_date_to = ref(todayLocalDateOnly());
|
||||
export const selected_date = ref(new Date().toISOString().split('T')[0]);
|
||||
export const selected_date_to = ref(new Date().toISOString().split('T')[0]);
|
||||
export const last_fetch_id = ref(0);
|
||||
export const fetches = ref<fetch[]>([]);
|
||||
/** Define the functions */
|
||||
@@ -54,4 +53,4 @@ watch([selected_date, selected_date_to], () => {
|
||||
export default defineComponent({
|
||||
name: 'DepartmentsOverviewObject'
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,53 +1,21 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { getOrders } from "@/components/shop/Orders.vue";
|
||||
import { showCreateOrderForm } from "@/components/forms/superUser/createOrderForm.vue";
|
||||
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import Orders from "@/components/displays/Orders.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import VehiclesPagination from "@/components/displays/pagination/models/UserDashboard/VehiclesPagination.vue";
|
||||
import SuperuserAddVehicleModal from "@/components/forms/superUser/SuperuserAddVehicleModal.vue";
|
||||
import { loadList } from "@/components/pagination/paginatedList.vue";
|
||||
|
||||
const isAddVehicleModalOpen = ref(false);
|
||||
|
||||
const openAddVehicleModal = () => {
|
||||
isAddVehicleModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeAddVehicleModal = () => {
|
||||
isAddVehicleModalOpen.value = false;
|
||||
};
|
||||
|
||||
const handleVehicleCreated = async () => {
|
||||
closeAddVehicleModal();
|
||||
await loadList();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<SuperUserDashboardNavigation />
|
||||
<VehiclesPagination>
|
||||
<template #actions>
|
||||
<button
|
||||
class="button is-link button-same-width superuser-vehicles__add-button"
|
||||
type="button"
|
||||
data-testid="superuser-vehicles-add"
|
||||
@click="openAddVehicleModal"
|
||||
>
|
||||
{{ $t("vehicles.add") }}
|
||||
</button>
|
||||
</template>
|
||||
</VehiclesPagination>
|
||||
<SuperuserAddVehicleModal
|
||||
v-if="isAddVehicleModalOpen"
|
||||
@close="closeAddVehicleModal"
|
||||
@created="handleVehicleCreated"
|
||||
/>
|
||||
<VehiclesPagination />
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.superuser-vehicles__add-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
</style>
|
||||
@@ -2,871 +2,34 @@
|
||||
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 { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { setDepartment, department, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
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 }
|
||||
);
|
||||
// Get the department from the route
|
||||
const router = useRouter()
|
||||
setDepartment(router.currentRoute.value.params.departmentId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<DepartmentSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle :title="pageTitle" :subtitle="pageSubtitle" />
|
||||
<PageTitle title="Department" subtitle="Department data" />
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</DepartmentSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.department-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.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>
|
||||
</style>
|
||||
@@ -3,15 +3,7 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||
import {
|
||||
CUSTOM_PRICING_MISSING_PRICE,
|
||||
setDepartment,
|
||||
getDepartmentPrices,
|
||||
getExplicitDepartmentPrice,
|
||||
editDepartmentPrice,
|
||||
isCustomPricingOnly,
|
||||
updateCustomPricingOnly,
|
||||
} from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
|
||||
import { setDepartment, department, departmentId, getDepartmentPrice, editDepartmentPrice } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref } from 'vue';
|
||||
import { getProducts } from "@/components/shop/Products.vue";
|
||||
@@ -19,40 +11,14 @@ import { getProducts } from "@/components/shop/Products.vue";
|
||||
// Get the department from the route
|
||||
const router = useRouter()
|
||||
setDepartment(router.currentRoute.value.params.departmentId);
|
||||
getDepartmentPrices();
|
||||
|
||||
// Set the products
|
||||
const products = ref([]);
|
||||
const isUpdatingCustomPricingOnly = ref(false);
|
||||
|
||||
// Get the products
|
||||
getProducts().then((response) => {
|
||||
products.value = response.data.data;
|
||||
});
|
||||
|
||||
const getDepartmentPriceDisplay = (product) => {
|
||||
const explicitPrice = getExplicitDepartmentPrice(product);
|
||||
if (explicitPrice !== null) {
|
||||
return explicitPrice;
|
||||
}
|
||||
return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : '-';
|
||||
};
|
||||
|
||||
const isMissingCustomPrice = (product) => {
|
||||
return isCustomPricingOnly() && getExplicitDepartmentPrice(product) === null;
|
||||
};
|
||||
|
||||
const toggleCustomPricingOnly = async (event) => {
|
||||
const enabled = event.target.checked;
|
||||
isUpdatingCustomPricingOnly.value = true;
|
||||
try {
|
||||
await updateCustomPricingOnly(enabled);
|
||||
} catch {
|
||||
event.target.checked = isCustomPricingOnly();
|
||||
} finally {
|
||||
isUpdatingCustomPricingOnly.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -62,38 +28,12 @@ const toggleCustomPricingOnly = async (event) => {
|
||||
<PageTitle title="Department" subtitle="Department pricing" />
|
||||
</template>
|
||||
<div>
|
||||
<section class="department-pricing-settings" data-testid="department-custom-pricing-settings">
|
||||
<div>
|
||||
<h2 class="title is-5 mb-1">{{ $t('departments.pricing.custom_pricing_only') }}</h2>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ $t('departments.pricing.custom_pricing_missing_price') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="department-pricing-toggle">
|
||||
<input
|
||||
id="department-custom-pricing-only"
|
||||
class="switch is-rounded is-info"
|
||||
type="checkbox"
|
||||
:checked="isCustomPricingOnly()"
|
||||
:disabled="isUpdatingCustomPricingOnly"
|
||||
data-testid="department-custom-pricing-only-toggle"
|
||||
@change="toggleCustomPricingOnly"
|
||||
/>
|
||||
<label for="department-custom-pricing-only">
|
||||
{{
|
||||
isCustomPricingOnly()
|
||||
? $t('departments.pricing.custom_pricing_enabled')
|
||||
: $t('departments.pricing.custom_pricing_disabled')
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<table class="is-fullwidth table table-striped is-hoverable is-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('common.product') }}</th>
|
||||
<th>{{ $t('tables.common.default_price') }}</th>
|
||||
<th>{{ $t('departments.pricing.effective_department_price') }}</th>
|
||||
<th>{{ $t('tables.common.department_price') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -103,45 +43,24 @@ const toggleCustomPricingOnly = async (event) => {
|
||||
<td
|
||||
@click="editDepartmentPrice(product)"
|
||||
class="is-clickable"
|
||||
:class="{ 'has-text-danger has-text-weight-semibold': isMissingCustomPrice(product) }"
|
||||
:data-testid="`department-price-cell-${product.id}`"
|
||||
>{{ getDepartmentPriceDisplay(product) }}
|
||||
>{{ getDepartmentPrice(product) === product.price ? '-' : getDepartmentPrice(product) }}
|
||||
<i class="is-pulled-right fas fa-edit"></i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
Department prices...
|
||||
<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>
|
||||
</DepartmentSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.department-pricing-settings {
|
||||
align-items: center;
|
||||
border: 1px solid #dbdbdb;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.department-pricing-toggle {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 16rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.department-pricing-settings {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.department-pricing-toggle {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
+24
-55
@@ -1,60 +1,31 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRouter} from "vue-router";
|
||||
import { ref } from 'vue';
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
// Get the user from the route
|
||||
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId))
|
||||
|
||||
const departmentId = computed(() => String(route.params.departmentId || ""));
|
||||
const departmentPath = computed(() => `/superuser/departments/${encodeURIComponent(departmentId.value)}`);
|
||||
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 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 current path
|
||||
const currentPath = ref(router.currentRoute.value.path);
|
||||
|
||||
const activeTab = computed(() => tabs.value.findIndex((tab) => tab.active(route.path)));
|
||||
// Get the index of the active tab
|
||||
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
|
||||
|
||||
// Change the tab
|
||||
const changeTab = (index) => {
|
||||
const tab = tabs.value[index];
|
||||
if (tab) {
|
||||
router.push(tab.path);
|
||||
}
|
||||
router.push(tabs[index].path);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -62,15 +33,13 @@ const changeTab = (index) => {
|
||||
<div>
|
||||
<div class="tabs is-right">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.path"
|
||||
:class="{ 'is-active': activeTab === index }"
|
||||
@click="changeTab(index)"
|
||||
>
|
||||
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
|
||||
<a>{{ tab.name }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
+3
-40
@@ -4,14 +4,12 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
|
||||
import Swal from "sweetalert2";
|
||||
// Define the department id
|
||||
export const departmentId = ref(0);
|
||||
export const CUSTOM_PRICING_MISSING_PRICE = 999999;
|
||||
|
||||
const default_department = {
|
||||
id: null,
|
||||
name: null,
|
||||
description: null,
|
||||
branding: null,
|
||||
custom_pricing_only: false,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
prices: []
|
||||
@@ -36,7 +34,6 @@ export const department = {
|
||||
id: ref(''),
|
||||
name: ref(''),
|
||||
description: ref(''),
|
||||
custom_pricing_only: ref(false),
|
||||
created_at: ref(''),
|
||||
updated_at: ref(''),
|
||||
prices: ref([])
|
||||
@@ -52,13 +49,6 @@ const isDepartmentPricesLoaded = ref(false);
|
||||
// Set the department id
|
||||
export const setDepartment = (id) => {
|
||||
departmentId.value = id;
|
||||
isDepartmentPricesLoaded.value = false;
|
||||
department.prices.value = [];
|
||||
department.custom_pricing_only.value = false;
|
||||
departmentAdvanced.value = {
|
||||
...default_department,
|
||||
...default_department_functions
|
||||
};
|
||||
// Load the department data
|
||||
return getDepartmentData();
|
||||
}
|
||||
@@ -70,13 +60,11 @@ export const getDepartmentData = async () => {
|
||||
department.id.value = response.data.data.id;
|
||||
department.name.value = response.data.data.name;
|
||||
department.description.value = response.data.data.description;
|
||||
department.custom_pricing_only.value = Boolean(response.data.data.custom_pricing_only);
|
||||
department.created_at.value = response.data.data.created_at;
|
||||
department.updated_at.value = response.data.data.updated_at;
|
||||
departmentAdvanced.value = {
|
||||
...default_department,
|
||||
...response.data.data,
|
||||
custom_pricing_only: Boolean(response.data.data.custom_pricing_only),
|
||||
...default_department_functions
|
||||
};
|
||||
})
|
||||
@@ -100,42 +88,17 @@ export const getDepartmentPrice = (product) => {
|
||||
getDepartmentPrices();
|
||||
}
|
||||
if (isDepartmentPricesLoaded.value) {
|
||||
const price = getExplicitDepartmentPrice(product);
|
||||
if (price !== null) {
|
||||
return price;
|
||||
}
|
||||
return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : product.price;
|
||||
const price = department.prices.value.find((price) => price.product_id === product.id);
|
||||
return price ? price.price : product.price;
|
||||
}
|
||||
return product.price;
|
||||
}
|
||||
|
||||
export const getExplicitDepartmentPrice = (product) => {
|
||||
const price = department.prices.value.find((price) => price.product_id === product.id);
|
||||
return price ? price.price : null;
|
||||
}
|
||||
|
||||
export const isCustomPricingOnly = () => Boolean(departmentAdvanced.value.custom_pricing_only || department.custom_pricing_only.value);
|
||||
|
||||
export const updateCustomPricingOnly = async (enabled) => {
|
||||
return authenticatedRequest(`/departments`, "PUT", {
|
||||
id: departmentId.value,
|
||||
custom_pricing_only: Boolean(enabled)
|
||||
}).then(async () => {
|
||||
department.custom_pricing_only.value = Boolean(enabled);
|
||||
departmentAdvanced.value = {
|
||||
...departmentAdvanced.value,
|
||||
custom_pricing_only: Boolean(enabled)
|
||||
};
|
||||
await getDepartmentData();
|
||||
});
|
||||
}
|
||||
|
||||
export const editDepartmentPrice = (product) => {
|
||||
const explicitPrice = getExplicitDepartmentPrice(product);
|
||||
Swal.fire({
|
||||
title: product.name + ' ( product: ' + product.id + ' )',
|
||||
input: 'number',
|
||||
inputValue: explicitPrice === null ? '' : explicitPrice,
|
||||
inputValue: getDepartmentPrice(product),
|
||||
inputLabel: 'Price',
|
||||
inputAttributes: {
|
||||
autocapitalize: 'off'
|
||||
|
||||
@@ -1,572 +1,286 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import {useRouter} from 'vue-router'
|
||||
import {ref, watch} from "vue";
|
||||
import {loadList, setEndpoint, setOrder} from "@/components/pagination/paginatedList.vue";
|
||||
import RolesSubPageWrapper from "@/views/dashboards/superUserDashboard/roles/RolesSubPageWrapper.vue";
|
||||
import {
|
||||
filterPermissionRecords,
|
||||
getPermissionDelta,
|
||||
groupPermissionRecords,
|
||||
normalizePermissionCatalog,
|
||||
pageAccessPermissionKeys,
|
||||
permissionGroupDefinitions,
|
||||
sanitizePermissionDomId,
|
||||
} from "@/views/dashboards/superUserDashboard/roles/rolePermissionCatalog.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const role = ref(null);
|
||||
// Lists
|
||||
const permissions = ref([]);
|
||||
const permissionsList = ref([]);
|
||||
const rolePermissions = ref([]);
|
||||
const departments = ref([]);
|
||||
const permissionRecords = ref([]);
|
||||
const selectedPermissions = ref(new Set());
|
||||
const busyPermissions = ref(new Set());
|
||||
const busyGroups = ref(new Set());
|
||||
const isLoading = ref(true);
|
||||
const loadError = ref("");
|
||||
const mutationError = ref("");
|
||||
const search = ref("");
|
||||
const activeGroup = ref("access");
|
||||
|
||||
const roleId = computed(() => Number.parseInt(String(route.params.roleId || ""), 10));
|
||||
const roleTitle = computed(() =>
|
||||
role.value?.name
|
||||
? t("roles.permissions.title_with_name", { name: role.value.name })
|
||||
: t("roles.permissions.title", { id: Number.isFinite(roleId.value) ? roleId.value : "" })
|
||||
);
|
||||
// Loaded state
|
||||
const isLoaded = ref(false);
|
||||
// Individually loaded states
|
||||
const isDepartmentsLoaded = ref(false);
|
||||
const isPermissionsLoaded = ref(false);
|
||||
const isRolePermissionsLoaded = ref(false);
|
||||
const isPermissionsListLoaded = ref(false);
|
||||
|
||||
const buildPermissionOverrides = (departmentList) => {
|
||||
const overrides = {};
|
||||
watch(() => departments.value, () => {
|
||||
console.log('Departments loaded');
|
||||
isDepartmentsLoaded.value = true;
|
||||
});
|
||||
|
||||
for (const permission of pageAccessPermissionKeys) {
|
||||
overrides[permission] = {
|
||||
label: t(`roles.permissions.page_access.${permission}.label`),
|
||||
description: t(`roles.permissions.page_access.${permission}.description`),
|
||||
groupKey: "access",
|
||||
isVirtual: true,
|
||||
};
|
||||
}
|
||||
watch(() => permissionsList.value, () => {
|
||||
console.log('Permissions list loaded');
|
||||
isPermissionsListLoaded.value = true;
|
||||
});
|
||||
|
||||
for (const department of departmentList) {
|
||||
const departmentId = Number.parseInt(String(department?.id ?? ""), 10);
|
||||
if (!Number.isFinite(departmentId)) {
|
||||
continue;
|
||||
watch(() => permissions.value, () => {
|
||||
console.log('Permissions loaded');
|
||||
isPermissionsLoaded.value = true;
|
||||
});
|
||||
|
||||
watch(() => rolePermissions.value, () => {
|
||||
console.log('Role permissions loaded');
|
||||
isRolePermissionsLoaded.value = true;
|
||||
});
|
||||
|
||||
// Get the department from the route
|
||||
const router = useRouter()
|
||||
const roleId = ref(parseInt(router.currentRoute.value.params.roleId));
|
||||
|
||||
// Set the endpoint
|
||||
setEndpoint(SessionUser.objects.department_categories.meta.endpoint + `?id=${roleId.value}`);
|
||||
// Set the order
|
||||
setOrder("category_id", "asc");
|
||||
// Load the list
|
||||
loadList();
|
||||
|
||||
const loadPermissionsList = async () => {
|
||||
const response = await SessionUser.objects.permissions.get.all().then((response) => {
|
||||
const tmpPermissionsList = [];
|
||||
const tmpPermissions = [];
|
||||
for (const i in response) {
|
||||
const endpoint = i;
|
||||
const methods = response[i];
|
||||
for (const method in methods) {
|
||||
const permissions = methods[method];
|
||||
for (const permission in permissions) {
|
||||
tmpPermissionsList.push(permission);
|
||||
tmpPermissions[endpoint] = tmpPermissions[endpoint] || {}; // Create the endpoint if it doesn't exist
|
||||
tmpPermissions[endpoint][method] = tmpPermissions[endpoint][method] || {}; // Create the method if it doesn't exist
|
||||
tmpPermissions[endpoint][method][permission] = permissions[permission];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const name = String(department?.name || departmentId);
|
||||
overrides[`department_access_${departmentId}`] = {
|
||||
label: name,
|
||||
description: t("roles.permissions.department_access_description", { name }),
|
||||
groupKey: "access",
|
||||
isVirtual: true,
|
||||
departmentId,
|
||||
};
|
||||
}
|
||||
|
||||
return overrides;
|
||||
};
|
||||
|
||||
const replaceSetValue = (targetRef, value, shouldAdd) => {
|
||||
const next = new Set(targetRef.value);
|
||||
if (shouldAdd) {
|
||||
next.add(value);
|
||||
} else {
|
||||
next.delete(value);
|
||||
}
|
||||
targetRef.value = next;
|
||||
};
|
||||
|
||||
const setPermissionBusy = (permission, isBusy) => {
|
||||
replaceSetValue(busyPermissions, permission, isBusy);
|
||||
};
|
||||
|
||||
const setGroupBusy = (groupKey, isBusy) => {
|
||||
replaceSetValue(busyGroups, groupKey, isBusy);
|
||||
};
|
||||
|
||||
const hasPermission = (permission) => selectedPermissions.value.has(permission);
|
||||
const isPermissionBusy = (permission) => busyPermissions.value.has(permission);
|
||||
const isGroupBusy = (groupKey) => busyGroups.value.has(groupKey);
|
||||
const permissionDomId = (permission) => `role-permission-${sanitizePermissionDomId(permission)}`;
|
||||
const permissionTestId = (permission) => `role-permission-row-${sanitizePermissionDomId(permission)}`;
|
||||
|
||||
const allRecordsByGroup = computed(() => groupPermissionRecords(permissionRecords.value));
|
||||
const filteredPermissionRecords = computed(() => filterPermissionRecords(permissionRecords.value, search.value));
|
||||
const filteredRecordsByGroup = computed(() => groupPermissionRecords(filteredPermissionRecords.value));
|
||||
|
||||
const groupTabs = computed(() =>
|
||||
permissionGroupDefinitions
|
||||
.map((group) => {
|
||||
const allRecords = allRecordsByGroup.value.get(group.key) || [];
|
||||
const filteredRecords = filteredRecordsByGroup.value.get(group.key) || [];
|
||||
|
||||
return {
|
||||
...group,
|
||||
label: t(`roles.permissions.groups.${group.key}.label`),
|
||||
description: t(`roles.permissions.groups.${group.key}.description`),
|
||||
totalCount: allRecords.length,
|
||||
filteredCount: filteredRecords.length,
|
||||
};
|
||||
})
|
||||
.filter((group) => group.totalCount > 0)
|
||||
);
|
||||
|
||||
const totalCount = computed(() => permissionRecords.value.length);
|
||||
const filteredCount = computed(() => filteredPermissionRecords.value.length);
|
||||
const selectedCount = computed(() => selectedPermissions.value.size);
|
||||
|
||||
const getAllGroupRecords = (groupKey) => allRecordsByGroup.value.get(groupKey) || [];
|
||||
const getFilteredGroupRecords = (groupKey) => filteredRecordsByGroup.value.get(groupKey) || [];
|
||||
|
||||
const isGroupFullySelected = (groupKey) => {
|
||||
const records = getAllGroupRecords(groupKey);
|
||||
return records.length > 0 && records.every((record) => selectedPermissions.value.has(record.permission));
|
||||
};
|
||||
|
||||
const isGroupFullyCleared = (groupKey) => {
|
||||
const records = getAllGroupRecords(groupKey);
|
||||
return records.length > 0 && records.every((record) => !selectedPermissions.value.has(record.permission));
|
||||
};
|
||||
|
||||
watch(
|
||||
groupTabs,
|
||||
(tabs) => {
|
||||
if (tabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tabs.some((tab) => tab.key === activeGroup.value)) {
|
||||
activeGroup.value = tabs[0].key;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
permissionsList.value = tmpPermissionsList;
|
||||
permissions.value = tmpPermissions;
|
||||
});
|
||||
}
|
||||
// Get all available permissions
|
||||
loadPermissionsList();
|
||||
const loadRolePermissions = async () => {
|
||||
ObjectsGlobal.clearCache();
|
||||
const selectedRole = await SessionUser.objects.roles.get.single(roleId.value);
|
||||
role.value = selectedRole || null;
|
||||
selectedPermissions.value = new Set(Array.isArray(selectedRole?.permissions) ? selectedRole.permissions : []);
|
||||
};
|
||||
|
||||
const loadPageData = async () => {
|
||||
isLoading.value = true;
|
||||
loadError.value = "";
|
||||
mutationError.value = "";
|
||||
|
||||
try {
|
||||
if (!Number.isFinite(roleId.value)) {
|
||||
throw new Error("Invalid role id");
|
||||
}
|
||||
|
||||
ObjectsGlobal.clearCache();
|
||||
const [permissionCatalog, selectedRole, departmentList] = await Promise.all([
|
||||
SessionUser.objects.permissions.get.all(),
|
||||
SessionUser.objects.roles.get.single(roleId.value),
|
||||
SessionUser.objects.departments.get.all(),
|
||||
]);
|
||||
const safeDepartments = Array.isArray(departmentList) ? departmentList : [];
|
||||
|
||||
role.value = selectedRole || null;
|
||||
departments.value = safeDepartments;
|
||||
selectedPermissions.value = new Set(Array.isArray(selectedRole?.permissions) ? selectedRole.permissions : []);
|
||||
permissionRecords.value = normalizePermissionCatalog(permissionCatalog || {}, {
|
||||
departments: safeDepartments,
|
||||
permissionOverrides: buildPermissionOverrides(safeDepartments),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load role permissions page", error);
|
||||
loadError.value = t("roles.permissions.errors.load");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const mutatePermissions = async ({ add = [], remove = [], groupKey = null }) => {
|
||||
const additions = add.filter((permission) => !selectedPermissions.value.has(permission) && !isPermissionBusy(permission));
|
||||
const removals = remove.filter((permission) => selectedPermissions.value.has(permission) && !isPermissionBusy(permission));
|
||||
const affectedPermissions = [...additions, ...removals];
|
||||
|
||||
if (affectedPermissions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutationError.value = "";
|
||||
|
||||
if (groupKey) {
|
||||
setGroupBusy(groupKey, true);
|
||||
}
|
||||
for (const permission of affectedPermissions) {
|
||||
setPermissionBusy(permission, true);
|
||||
}
|
||||
|
||||
const optimisticPermissions = new Set(selectedPermissions.value);
|
||||
for (const permission of additions) {
|
||||
optimisticPermissions.add(permission);
|
||||
}
|
||||
for (const permission of removals) {
|
||||
optimisticPermissions.delete(permission);
|
||||
}
|
||||
selectedPermissions.value = optimisticPermissions;
|
||||
|
||||
try {
|
||||
const operations = [
|
||||
...additions.map((permission) =>
|
||||
SessionUser.objects.roles.functions.permissions.add(roleId.value, permission).then(() => ({
|
||||
permission,
|
||||
action: "add",
|
||||
}))
|
||||
),
|
||||
...removals.map((permission) =>
|
||||
SessionUser.objects.roles.functions.permissions.remove(roleId.value, permission).then(() => ({
|
||||
permission,
|
||||
action: "remove",
|
||||
}))
|
||||
),
|
||||
];
|
||||
const results = await Promise.allSettled(operations);
|
||||
const failedResult = results.find((result) => result.status === "rejected");
|
||||
|
||||
if (failedResult) {
|
||||
throw failedResult.reason;
|
||||
}
|
||||
|
||||
ObjectsGlobal.clearCache();
|
||||
const nextPermissions = new Set(selectedPermissions.value);
|
||||
for (const permission of additions) {
|
||||
nextPermissions.add(permission);
|
||||
}
|
||||
for (const permission of removals) {
|
||||
nextPermissions.delete(permission);
|
||||
}
|
||||
selectedPermissions.value = nextPermissions;
|
||||
} catch (error) {
|
||||
console.error("Failed to update role permissions", error);
|
||||
mutationError.value = t("roles.permissions.errors.update");
|
||||
await loadRolePermissions().catch((reloadError) => {
|
||||
console.error("Failed to reload role permissions after mutation error", reloadError);
|
||||
});
|
||||
} finally {
|
||||
for (const permission of affectedPermissions) {
|
||||
setPermissionBusy(permission, false);
|
||||
}
|
||||
if (groupKey) {
|
||||
setGroupBusy(groupKey, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = async (record) => {
|
||||
if (!record?.permission || isPermissionBusy(record.permission)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAdd = !selectedPermissions.value.has(record.permission);
|
||||
await mutatePermissions({
|
||||
add: shouldAdd ? [record.permission] : [],
|
||||
remove: shouldAdd ? [] : [record.permission],
|
||||
await SessionUser.objects.roles.get.single(roleId.value).then((response) => {
|
||||
rolePermissions.value = response.permissions;
|
||||
});
|
||||
};
|
||||
}
|
||||
// Get the permissions for the role
|
||||
loadRolePermissions();
|
||||
|
||||
const applyGroupSelection = async (groupKey, shouldSelect) => {
|
||||
if (isGroupBusy(groupKey)) {
|
||||
return;
|
||||
const convertRouteToHumanReadable = (route) => {
|
||||
let arrow = " -> ";
|
||||
// Replace / with space
|
||||
let humanReadable = route.replace(/\//g, arrow);
|
||||
// Replace _ with space
|
||||
humanReadable = humanReadable.replace(/_/g, " ");
|
||||
// Capitalize the first letter
|
||||
humanReadable = humanReadable.charAt(0).toUpperCase() + humanReadable.slice(1);
|
||||
// If the route ends with an arrow, remove it
|
||||
if (humanReadable.endsWith(arrow)) {
|
||||
humanReadable = humanReadable.slice(0, -arrow.length);
|
||||
}
|
||||
// If the route starts with an arrow, remove it
|
||||
if (humanReadable.startsWith(arrow)) {
|
||||
humanReadable = humanReadable.slice(arrow.length);
|
||||
}
|
||||
return humanReadable;
|
||||
}
|
||||
|
||||
const delta = getPermissionDelta(getAllGroupRecords(groupKey), selectedPermissions.value, shouldSelect);
|
||||
await mutatePermissions({
|
||||
...delta,
|
||||
groupKey,
|
||||
const endpointMethodColors = {
|
||||
GET: "is-success",
|
||||
POST: "is-info",
|
||||
PUT: "is-warning",
|
||||
PATCH: "is-warning",
|
||||
DELETE: "is-danger",
|
||||
OPTIONS: "is-light",
|
||||
}
|
||||
|
||||
const hasPermission = (permission) => {
|
||||
return rolePermissions.value.includes(permission);
|
||||
}
|
||||
|
||||
const togglePermission = (permission) => {
|
||||
if (hasPermission(permission)) {
|
||||
SessionUser.objects.roles.functions.permissions.remove(roleId.value, permission).then((response) => {
|
||||
// Remove the permission
|
||||
rolePermissions.value = rolePermissions.value.filter((value) => value !== permission);
|
||||
});
|
||||
} else {
|
||||
SessionUser.objects.roles.functions.permissions.add(roleId.value, permission).then((response) => {
|
||||
// Add the permission
|
||||
rolePermissions.value.push(permission);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const loadDepartments = async () => {
|
||||
const response = await SessionUser.objects.departments.get.all().then((response) => {
|
||||
departments.value = response;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(loadPageData);
|
||||
loadDepartments();
|
||||
|
||||
watch(() => isDepartmentsLoaded.value && isPermissionsListLoaded.value && isPermissionsLoaded.value && isRolePermissionsLoaded.value, () => {
|
||||
console.log('Loaded:', 'Departments:', departments.value.length, 'Permissions:', permissionsList.value.length, 'Role permissions:', rolePermissions.value.length);
|
||||
isLoaded.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<RolesSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle :title="roleTitle" :subtitle="t('roles.permissions.subtitle')">
|
||||
<PageTitle :title="`Role ${roleId}`" subtitle="Role permissions">
|
||||
<template #buttons>
|
||||
<button class="button is-dark" type="button" :disabled="isLoading" @click="loadPageData">
|
||||
<span class="icon">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</span>
|
||||
<span>{{ t("roles.permissions.actions.reload") }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</PageTitle>
|
||||
</template>
|
||||
|
||||
<section class="role-permissions-page" data-testid="role-permissions-page">
|
||||
<div v-if="loadError" class="notification is-danger is-light" data-testid="role-permissions-load-error">
|
||||
<p>{{ loadError }}</p>
|
||||
<button class="button is-danger is-light mt-3" type="button" @click="loadPageData">
|
||||
{{ t("roles.permissions.actions.retry") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading" class="role-permissions-loading" data-testid="role-permissions-loading">
|
||||
<progress class="progress is-small is-dark" max="100"></progress>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="mutationError" class="notification is-danger is-light" data-testid="role-permission-error">
|
||||
{{ mutationError }}
|
||||
</div>
|
||||
|
||||
<div class="role-permissions-toolbar">
|
||||
<div class="field role-permissions-search">
|
||||
<label class="label" for="role-permissions-search">
|
||||
{{ t("roles.permissions.search_label") }}
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input
|
||||
id="role-permissions-search"
|
||||
v-model="search"
|
||||
class="input"
|
||||
type="search"
|
||||
:placeholder="t('roles.permissions.search_placeholder')"
|
||||
data-testid="role-permissions-search"
|
||||
/>
|
||||
<span class="icon is-left">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="role-permissions-summary" aria-live="polite">
|
||||
<span class="tag is-dark is-light" data-testid="role-permissions-selected-count">
|
||||
{{ t("roles.permissions.selected_summary", { selected: selectedCount, total: totalCount }) }}
|
||||
</span>
|
||||
<span v-if="search" class="tag is-info is-light" data-testid="role-permissions-filtered-count">
|
||||
{{ t("roles.permissions.filtered_summary", { count: filteredCount }) }}
|
||||
</span>
|
||||
<!-- Department access permissions -->
|
||||
<div class="box" v-if="isLoaded">
|
||||
<p class="is-pulled-right">
|
||||
<span class="tag is-dark is-large">
|
||||
Department access
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-2"> Department dependent permissions applies to:</p>
|
||||
<div class="columns is-multiline">
|
||||
<div v-for="department in departments" :key="department.id" class="column is-3">
|
||||
<span class="tag is-dark">
|
||||
{{ department.name }}
|
||||
</span>
|
||||
<!-- Enable switch -->
|
||||
<div class="field">
|
||||
<input
|
||||
:id="'enable_department_' + department.id + '_access'"
|
||||
type="checkbox"
|
||||
class="switch is-rounded"
|
||||
@change="togglePermission('department_access_' + department.id)"
|
||||
:checked="hasPermission('department_access_' + department.id)"
|
||||
/>
|
||||
<label :for="'enable_department_' + department.id + '_access'"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-tabs
|
||||
v-if="groupTabs.length"
|
||||
v-model="activeGroup"
|
||||
expanded
|
||||
type="is-boxed"
|
||||
data-testid="role-permissions-tabs"
|
||||
>
|
||||
<b-tab-item
|
||||
v-for="group in groupTabs"
|
||||
:key="group.key"
|
||||
:value="group.key"
|
||||
:label="`${group.label} (${group.filteredCount})`"
|
||||
:icon="group.icon"
|
||||
icon-pack="fas"
|
||||
>
|
||||
<section class="role-permission-group" :data-testid="`role-permissions-group-${group.key}`">
|
||||
<div class="role-permission-group-header">
|
||||
<div>
|
||||
<h2 class="title is-5">{{ group.label }}</h2>
|
||||
<p class="role-permission-group-description">{{ group.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="buttons role-permission-group-actions">
|
||||
<button
|
||||
class="button is-dark is-small"
|
||||
type="button"
|
||||
:disabled="isGroupBusy(group.key) || isGroupFullySelected(group.key)"
|
||||
:data-testid="`role-permissions-select-all-${group.key}`"
|
||||
@click="applyGroupSelection(group.key, true)"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-check-square"></i>
|
||||
</span>
|
||||
<span>{{ t("roles.permissions.actions.select_all") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="button is-light is-small"
|
||||
type="button"
|
||||
:disabled="isGroupBusy(group.key) || isGroupFullyCleared(group.key)"
|
||||
:data-testid="`role-permissions-clear-all-${group.key}`"
|
||||
@click="applyGroupSelection(group.key, false)"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-minus-square"></i>
|
||||
</span>
|
||||
<span>{{ t("roles.permissions.actions.clear_all") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="getFilteredGroupRecords(group.key).length === 0"
|
||||
class="notification is-light"
|
||||
:data-testid="`role-permissions-empty-${group.key}`"
|
||||
>
|
||||
{{ search ? t("roles.permissions.no_search_results") : t("roles.permissions.empty_group") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table class="table is-fullwidth is-hoverable role-permissions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("roles.permissions.columns.enabled") }}</th>
|
||||
<th>{{ t("roles.permissions.columns.permission") }}</th>
|
||||
<th>{{ t("roles.permissions.columns.route") }}</th>
|
||||
<th>{{ t("roles.permissions.columns.status") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="record in getFilteredGroupRecords(group.key)"
|
||||
:key="record.permission"
|
||||
:class="{ 'role-permission-selected': hasPermission(record.permission) }"
|
||||
:data-testid="permissionTestId(record.permission)"
|
||||
>
|
||||
<td class="role-permission-toggle-cell">
|
||||
<div class="field">
|
||||
<input
|
||||
:id="permissionDomId(record.permission)"
|
||||
class="switch is-rounded"
|
||||
type="checkbox"
|
||||
:checked="hasPermission(record.permission)"
|
||||
:disabled="isPermissionBusy(record.permission)"
|
||||
:data-testid="`role-permission-toggle-${sanitizePermissionDomId(record.permission)}`"
|
||||
@change="togglePermission(record)"
|
||||
/>
|
||||
<label :for="permissionDomId(record.permission)"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ record.label }}</strong>
|
||||
<p class="role-permission-description">
|
||||
{{ record.description || t("roles.permissions.no_description") }}
|
||||
</p>
|
||||
<code>{{ record.permission }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div class="role-permission-route">
|
||||
<span>{{ record.endpointLabel || t("roles.permissions.access_scope") }}</span>
|
||||
<span v-if="record.method" class="tag is-small" :class="record.methodColor">
|
||||
{{ record.method }}
|
||||
</span>
|
||||
</div>
|
||||
<code v-if="record.endpoint">{{ record.endpoint }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="tag"
|
||||
:class="hasPermission(record.permission) ? 'is-success is-light' : 'is-light'"
|
||||
>
|
||||
{{
|
||||
hasPermission(record.permission)
|
||||
? t("roles.permissions.status.enabled")
|
||||
: t("roles.permissions.status.disabled")
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</b-tab-item>
|
||||
</b-tabs>
|
||||
|
||||
<div v-else class="notification is-light" data-testid="role-permissions-empty">
|
||||
{{ t("roles.permissions.empty") }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Page type permissions -->
|
||||
<div class="box">
|
||||
<p class="is-pulled-right">
|
||||
<span class="tag is-dark is-large">
|
||||
Page type access
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-2"> Page type dependent permissions applies to:</p>
|
||||
<div class="columns is-multiline">
|
||||
<!-- User -->
|
||||
<div class="column is-3">
|
||||
<span class="tag is-dark">
|
||||
User
|
||||
</span>
|
||||
<!-- Enable switch -->
|
||||
<div class="field">
|
||||
<input
|
||||
id="enable_user"
|
||||
type="checkbox"
|
||||
class="switch is-rounded"
|
||||
@change="togglePermission('user')"
|
||||
:checked="hasPermission('user')"
|
||||
/>
|
||||
<label for="enable_user"></label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
<!-- Admin -->
|
||||
<div class="column is-3">
|
||||
<span class="tag is-dark">
|
||||
Admin
|
||||
</span>
|
||||
<!-- Enable switch -->
|
||||
<div class="field">
|
||||
<input
|
||||
id="enable_admin"
|
||||
type="checkbox"
|
||||
class="switch is-rounded"
|
||||
@change="togglePermission('admin')"
|
||||
:checked="hasPermission('admin')"
|
||||
/>
|
||||
<label for="enable_admin"></label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Super user -->
|
||||
<div class="column is-3">
|
||||
<span class="tag is-dark">
|
||||
Super user
|
||||
</span>
|
||||
<!-- Enable switch -->
|
||||
<div class="field">
|
||||
<input
|
||||
id="enable_superuser"
|
||||
type="checkbox"
|
||||
class="switch is-rounded"
|
||||
@change="togglePermission('superuser')"
|
||||
:checked="hasPermission('superuser')"
|
||||
/>
|
||||
<label for="enable_superuser"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Permissions -->
|
||||
<div v-for="([endpoint, permission]) of Object.entries(permissions).sort((a, b) => a[0].localeCompare(b[0]))" class="box" v-if="isLoaded">
|
||||
<p class="is-pulled-right">
|
||||
<span class="tag is-dark is-large">
|
||||
{{ endpoint }}
|
||||
</span>
|
||||
</p>
|
||||
<!-- Permissions -->
|
||||
<div class="mt-2" v-for="(permission, method) in permission" :key="method">
|
||||
<span class="tag mt-3" :class="endpointMethodColors[method]">
|
||||
<strong class="has-text-black">{{ method }}</strong>
|
||||
</span>
|
||||
<strong> {{ endpoint}}</strong> {{ convertRouteToHumanReadable(endpoint) }}
|
||||
<!--{{ permission }} -->
|
||||
<p class="mt-1"> Permissions </p>
|
||||
<div v-for="(permission, permission_node) in permission" :key="permission_node">
|
||||
<span class="tag is-dark">
|
||||
{{ permission_node }}
|
||||
</span>
|
||||
{{ permission }}
|
||||
<!-- Enable switch -->
|
||||
<div class="field">
|
||||
<input
|
||||
:id="'enable_' + permission_node"
|
||||
type="checkbox"
|
||||
class="switch is-rounded"
|
||||
:checked="hasPermission(permission_node)"
|
||||
@change="togglePermission(permission_node)"
|
||||
/>
|
||||
<label :for="'enable_' + permission_node"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--<pre>{{ permission }}</pre> -->
|
||||
</div>
|
||||
</RolesSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-permissions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.role-permissions-loading {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.role-permissions-toolbar {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.role-permissions-search {
|
||||
flex: 1 1 28rem;
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.role-permissions-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.role-permission-group {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.role-permission-group-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.role-permission-group-description,
|
||||
.role-permission-description {
|
||||
color: #5f6672;
|
||||
}
|
||||
|
||||
.role-permission-group-actions {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.role-permissions-table th:first-child,
|
||||
.role-permission-toggle-cell {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.role-permission-route {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.role-permission-selected {
|
||||
background: #f2fbf5;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.role-permissions-toolbar,
|
||||
.role-permission-group-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.role-permissions-search {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.role-permissions-summary,
|
||||
.role-permission-group-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -1,297 +0,0 @@
|
||||
export const endpointMethodColors = {
|
||||
GET: "is-success",
|
||||
POST: "is-info",
|
||||
PUT: "is-warning",
|
||||
PATCH: "is-warning",
|
||||
DELETE: "is-danger",
|
||||
OPTIONS: "is-light",
|
||||
};
|
||||
|
||||
export const pageAccessPermissionKeys = ["user", "admin", "superuser"];
|
||||
|
||||
export const permissionGroupDefinitions = [
|
||||
{
|
||||
key: "access",
|
||||
icon: "unlock",
|
||||
patterns: [/^(user|admin|superuser)$/u, /^department_access_\d+$/u],
|
||||
},
|
||||
{
|
||||
key: "orders_pos",
|
||||
icon: "cash-register",
|
||||
patterns: [/order/iu, /pos/iu, /stripe/iu, /payment/iu, /xlvask/iu],
|
||||
},
|
||||
{
|
||||
key: "bookings",
|
||||
icon: "calendar-alt",
|
||||
patterns: [/booking/iu, /time[-_]?booking/iu],
|
||||
},
|
||||
{
|
||||
key: "customers_users",
|
||||
icon: "users",
|
||||
patterns: [/customer/iu, /user/iu, /subuser/iu, /employee/iu, /auth/iu, /role/iu, /permission/iu],
|
||||
},
|
||||
{
|
||||
key: "departments_operations",
|
||||
icon: "building",
|
||||
patterns: [/department/iu, /lane/iu, /relay/iu, /daily[-_]?report/iu, /complaint/iu, /goal/iu, /notification/iu],
|
||||
},
|
||||
{
|
||||
key: "products_pricing",
|
||||
icon: "tags",
|
||||
patterns: [/product/iu, /price/iu, /pricing/iu, /discount/iu, /currency/iu],
|
||||
},
|
||||
{
|
||||
key: "invoicing_economic",
|
||||
icon: "file-invoice",
|
||||
patterns: [/invoice/iu, /economic/iu, /billing/iu, /collection/iu],
|
||||
},
|
||||
{
|
||||
key: "selfserve_edge",
|
||||
icon: "network-wired",
|
||||
patterns: [/self[-_]?serve/iu, /selfserve/iu, /edge/iu, /gateway/iu, /scanner/iu, /wash/iu, /shelly/iu],
|
||||
},
|
||||
{
|
||||
key: "system_config",
|
||||
icon: "cogs",
|
||||
patterns: [/system/iu, /config/iu, /configuration/iu, /replication/iu, /release/iu, /coolify/iu, /module/iu, /backup/iu, /workfeed/iu, /error[-_]?report/iu],
|
||||
},
|
||||
{
|
||||
key: "other",
|
||||
icon: "key",
|
||||
patterns: [],
|
||||
},
|
||||
];
|
||||
|
||||
const permissionGroupByKey = new Map(permissionGroupDefinitions.map((group) => [group.key, group]));
|
||||
const permissionGroupMatchPriority = [
|
||||
"access",
|
||||
"orders_pos",
|
||||
"bookings",
|
||||
"departments_operations",
|
||||
"products_pricing",
|
||||
"invoicing_economic",
|
||||
"selfserve_edge",
|
||||
"system_config",
|
||||
"customers_users",
|
||||
];
|
||||
const permissionGroupsForMatching = permissionGroupDefinitions
|
||||
.filter((group) => group.key !== "other")
|
||||
.sort(
|
||||
(left, right) =>
|
||||
permissionGroupMatchPriority.indexOf(left.key) - permissionGroupMatchPriority.indexOf(right.key)
|
||||
);
|
||||
|
||||
const normalizeText = (value) =>
|
||||
String(value ?? "")
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/gu, "")
|
||||
.trim();
|
||||
|
||||
export const sanitizePermissionDomId = (value) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || "permission";
|
||||
|
||||
const splitHumanWords = (value) =>
|
||||
String(value ?? "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_./:-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
export const humanizePermissionKey = (permission) => {
|
||||
const words = splitHumanWords(permission);
|
||||
if (!words) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
};
|
||||
|
||||
export const humanizeEndpoint = (endpoint) => {
|
||||
const words = splitHumanWords(String(endpoint ?? "").replace(/^\/+|\/+$/g, ""));
|
||||
if (!words) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
};
|
||||
|
||||
export const getPermissionGroupKey = ({ permission = "", endpoint = "", description = "" } = {}) => {
|
||||
const haystack = `${permission} ${endpoint} ${description}`;
|
||||
const match = permissionGroupsForMatching.find((group) =>
|
||||
group.patterns.some((pattern) => pattern.test(haystack))
|
||||
);
|
||||
|
||||
return match?.key || "other";
|
||||
};
|
||||
|
||||
const buildSearchText = (record) =>
|
||||
normalizeText(
|
||||
[
|
||||
record.permission,
|
||||
record.label,
|
||||
record.description,
|
||||
record.endpoint,
|
||||
record.endpointLabel,
|
||||
record.method,
|
||||
record.groupKey,
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
const createPermissionRecord = ({
|
||||
permission,
|
||||
description = "",
|
||||
endpoint = "",
|
||||
method = "",
|
||||
permissionOverrides = {},
|
||||
}) => {
|
||||
const key = String(permission || "").trim();
|
||||
const override = permissionOverrides[key] || {};
|
||||
const record = {
|
||||
id: key,
|
||||
permission: key,
|
||||
label: override.label || humanizePermissionKey(key),
|
||||
description: override.description || String(description || ""),
|
||||
endpoint: String(endpoint || ""),
|
||||
endpointLabel: humanizeEndpoint(endpoint),
|
||||
method: String(method || ""),
|
||||
methodColor: endpointMethodColors[String(method || "").toUpperCase()] || "is-light",
|
||||
groupKey: override.groupKey || getPermissionGroupKey({ permission: key, endpoint, description }),
|
||||
isVirtual: Boolean(override.isVirtual),
|
||||
departmentId: override.departmentId || null,
|
||||
};
|
||||
|
||||
record.searchText = buildSearchText(record);
|
||||
return record;
|
||||
};
|
||||
|
||||
const createAccessPermissionRecords = ({ departments = [], permissionOverrides = {} }) => {
|
||||
const pageRecords = pageAccessPermissionKeys.map((permission) =>
|
||||
createPermissionRecord({
|
||||
permission,
|
||||
permissionOverrides: {
|
||||
...permissionOverrides,
|
||||
[permission]: {
|
||||
...(permissionOverrides[permission] || {}),
|
||||
groupKey: "access",
|
||||
isVirtual: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const departmentRecords = departments.map((department) => {
|
||||
const departmentId = Number.parseInt(String(department?.id ?? ""), 10);
|
||||
const permission = `department_access_${departmentId}`;
|
||||
const override = permissionOverrides[permission] || {};
|
||||
|
||||
return createPermissionRecord({
|
||||
permission,
|
||||
permissionOverrides: {
|
||||
...permissionOverrides,
|
||||
[permission]: {
|
||||
label: override.label || String(department?.name || permission),
|
||||
description: override.description || "",
|
||||
groupKey: "access",
|
||||
isVirtual: true,
|
||||
departmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return [...pageRecords, ...departmentRecords].filter((record) => record.permission && record.permission !== "department_access_NaN");
|
||||
};
|
||||
|
||||
export const normalizePermissionCatalog = (catalog = {}, options = {}) => {
|
||||
const permissionOverrides = options.permissionOverrides || {};
|
||||
const recordsByPermission = new Map();
|
||||
|
||||
for (const record of createAccessPermissionRecords({
|
||||
departments: options.departments || [],
|
||||
permissionOverrides,
|
||||
})) {
|
||||
recordsByPermission.set(record.permission, record);
|
||||
}
|
||||
|
||||
for (const [endpoint, methods] of Object.entries(catalog || {})) {
|
||||
if (!methods || typeof methods !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [method, permissions] of Object.entries(methods)) {
|
||||
if (!permissions || typeof permissions !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [permission, description] of Object.entries(permissions)) {
|
||||
const key = String(permission || "").trim();
|
||||
if (!key || recordsByPermission.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
recordsByPermission.set(
|
||||
key,
|
||||
createPermissionRecord({
|
||||
permission: key,
|
||||
description,
|
||||
endpoint,
|
||||
method,
|
||||
permissionOverrides,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...recordsByPermission.values()].sort((left, right) => {
|
||||
const groupCompare =
|
||||
permissionGroupDefinitions.findIndex((group) => group.key === left.groupKey) -
|
||||
permissionGroupDefinitions.findIndex((group) => group.key === right.groupKey);
|
||||
if (groupCompare !== 0) {
|
||||
return groupCompare;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label, undefined, { sensitivity: "base" });
|
||||
});
|
||||
};
|
||||
|
||||
export const filterPermissionRecords = (records = [], search = "") => {
|
||||
const normalizedSearch = normalizeText(search);
|
||||
if (!normalizedSearch) {
|
||||
return [...records];
|
||||
}
|
||||
|
||||
return records.filter((record) => record.searchText.includes(normalizedSearch));
|
||||
};
|
||||
|
||||
export const groupPermissionRecords = (records = []) => {
|
||||
const groups = new Map(permissionGroupDefinitions.map((group) => [group.key, []]));
|
||||
|
||||
for (const record of records) {
|
||||
const groupKey = permissionGroupByKey.has(record.groupKey) ? record.groupKey : "other";
|
||||
groups.get(groupKey).push(record);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
export const getPermissionDelta = (records = [], selectedPermissions = [], shouldSelect = true) => {
|
||||
const selectedSet = selectedPermissions instanceof Set ? selectedPermissions : new Set(selectedPermissions);
|
||||
const uniquePermissions = [...new Set(records.map((record) => record.permission).filter(Boolean))];
|
||||
|
||||
if (shouldSelect) {
|
||||
return {
|
||||
add: uniquePermissions.filter((permission) => !selectedSet.has(permission)),
|
||||
remove: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
add: [],
|
||||
remove: uniquePermissions.filter((permission) => selectedSet.has(permission)),
|
||||
};
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import StatisticsDepartmentGoal
|
||||
from "@/views/dashboards/superUserDashboard/statistics/displays/overview/StatisticsDepartmentGoal.vue";
|
||||
import {departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -71,8 +70,7 @@ getDepartments()
|
||||
// Get the statistics
|
||||
const getStatistics = async () => {
|
||||
// Get the statistics
|
||||
const today = todayLocalDateOnly();
|
||||
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + today + '&end_date=' + today)
|
||||
const response = await authenticatedRequest('/statistics/income/departments?start_date=' + (new Date()).toISOString().split('T')[0] + '&end_date=' + (new Date()).toISOString().split('T')[0])
|
||||
// Get the sent data
|
||||
statistics_department_today.value = response.data.data
|
||||
// Log the data
|
||||
@@ -133,4 +131,4 @@ getStatistics()
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -4,7 +4,6 @@ import {ref} from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale } from 'chart.js'
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
||||
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
|
||||
@@ -17,7 +16,7 @@ const chartOptions = {
|
||||
// Shortcut to statistics
|
||||
|
||||
// Set the time for the statistics to today
|
||||
SessionUser.adminUser.statistics.set_time(todayLocalDateOnly(), todayLocalDateOnly())
|
||||
SessionUser.adminUser.statistics.set_time((new Date()).toISOString().split('T')[0], (new Date()).toISOString().split('T')[0])
|
||||
|
||||
|
||||
|
||||
@@ -34,4 +33,4 @@ SessionUser.adminUser.statistics.set_time(todayLocalDateOnly(), todayLocalDateOn
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,165 +0,0 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
type NotificationSmsRow = {
|
||||
id: number;
|
||||
department: number;
|
||||
label: string;
|
||||
phone_country_code: number;
|
||||
phone: number;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const setupNotificationSmsApi = async (page: Page) => {
|
||||
let listRequestCount = 0;
|
||||
const mutations: Array<{ method: string; body?: Record<string, unknown>; id?: number }> = [];
|
||||
let rows: NotificationSmsRow[] = [
|
||||
{
|
||||
id: 501,
|
||||
department: 1,
|
||||
label: "Dispatch line",
|
||||
phone_country_code: 45,
|
||||
phone: 12345678,
|
||||
enabled: true,
|
||||
created_at: "2026-07-06 12:00:00",
|
||||
},
|
||||
];
|
||||
|
||||
await page.route(apiPathPattern("/department/notification/sms"), async (route) => {
|
||||
const request = route.request();
|
||||
const method = request.method();
|
||||
|
||||
if (method === "GET") {
|
||||
listRequestCount += 1;
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: rows,
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: rows.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "PUT") {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
mutations.push({ method, body });
|
||||
rows = rows.map((row) =>
|
||||
Number(row.id) === Number(body.id)
|
||||
? {
|
||||
...row,
|
||||
enabled: body.enabled === true,
|
||||
}
|
||||
: row
|
||||
);
|
||||
|
||||
await route.fulfill(json({ data: { message: "Department notification SMS updated" } }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
const id = Number(new URL(request.url()).searchParams.get("id") || 0);
|
||||
mutations.push({ method, id });
|
||||
rows = rows.filter((row) => Number(row.id) !== id);
|
||||
|
||||
await route.fulfill(json({ data: { message: "Department notification SMS deleted" } }));
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill(json({ message: "Unsupported notification SMS method" }, 405));
|
||||
});
|
||||
|
||||
return {
|
||||
mutations,
|
||||
get listRequestCount() {
|
||||
return listRequestCount;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test("@smoke department notification SMS active toggle and delete refresh the table", async ({ page }) => {
|
||||
const notificationErrors: Array<{ status: number; url: string }> = [];
|
||||
page.on("response", (response) => {
|
||||
if (response.url().includes("/department/notification/sms") && response.status() >= 400) {
|
||||
notificationErrors.push({
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await seedAuthenticatedState(page, "admin-department-notifications-token");
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: [
|
||||
"admin",
|
||||
"department_access_1",
|
||||
"department_notification_sms_get",
|
||||
"department_notification_sms_update",
|
||||
"department_notification_sms_delete",
|
||||
"department_notification_sms_add",
|
||||
],
|
||||
});
|
||||
const notificationSmsApi = await setupNotificationSmsApi(page);
|
||||
|
||||
await page.goto("/admin/1/modules/notifications", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const row = page.locator("tr", { hasText: "Dispatch line" });
|
||||
await expect(row).toBeVisible();
|
||||
const enabledToggle = page.getByTestId("department-notification-sms-enabled-toggle-501");
|
||||
await expect(enabledToggle).toBeChecked();
|
||||
|
||||
const listRequestsBeforeToggle = notificationSmsApi.listRequestCount;
|
||||
const updateRequestPromise = page.waitForRequest(
|
||||
(request) => request.method() === "PUT" && request.url().includes("/department/notification/sms")
|
||||
);
|
||||
await enabledToggle.uncheck({ force: true });
|
||||
|
||||
const updateRequest = await updateRequestPromise;
|
||||
expect(updateRequest.postDataJSON()).toMatchObject({
|
||||
id: 501,
|
||||
enabled: false,
|
||||
});
|
||||
await expect.poll(() => notificationSmsApi.listRequestCount).toBeGreaterThan(listRequestsBeforeToggle);
|
||||
await expect(enabledToggle).not.toBeChecked();
|
||||
|
||||
const listRequestsBeforeDelete = notificationSmsApi.listRequestCount;
|
||||
await row.locator('button[aria-haspopup="true"]').click();
|
||||
const deleteRequestPromise = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && request.url().includes("/department/notification/sms")
|
||||
);
|
||||
await page.getByTestId("department-notification-sms-delete-501").click();
|
||||
await expect(page.locator(".swal2-popup")).toBeVisible();
|
||||
await page.locator(".swal2-confirm").click();
|
||||
|
||||
const deleteRequest = await deleteRequestPromise;
|
||||
expect(new URL(deleteRequest.url()).searchParams.get("id")).toBe("501");
|
||||
await expect.poll(() => notificationSmsApi.listRequestCount).toBeGreaterThan(listRequestsBeforeDelete);
|
||||
await expect(page.locator("tr", { hasText: "Dispatch line" })).toHaveCount(0);
|
||||
expect(notificationErrors).toEqual([]);
|
||||
expect(notificationSmsApi.mutations).toEqual([
|
||||
{
|
||||
method: "PUT",
|
||||
body: {
|
||||
id: 501,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
id: 501,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -109,58 +109,6 @@ async function acceleratePageTimers(page, timerScale = 0.01) {
|
||||
}, timerScale);
|
||||
}
|
||||
|
||||
async function dismissUnexpectedSweetAlert(page) {
|
||||
const overlays = page.locator(".swal2-container.swal2-backdrop-show");
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const overlay = overlays.first();
|
||||
if (!(await overlay.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dismissed = false;
|
||||
for (const selector of [".swal2-close", ".swal2-cancel", ".swal2-deny", ".swal2-confirm"]) {
|
||||
const action = overlay.locator(selector).first();
|
||||
if (await action.isVisible().catch(() => false)) {
|
||||
await action.click({ force: true });
|
||||
dismissed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dismissed) {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await expect(overlays)
|
||||
.toHaveCount(0, { timeout: 5_000 })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function generateGatewayInstaller(page) {
|
||||
const button = page.getByTestId("gateway-installer-generate");
|
||||
await expect(button).toBeVisible({ timeout: 15_000 });
|
||||
await expect(button).toBeEnabled({ timeout: 15_000 });
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await dismissUnexpectedSweetAlert(page);
|
||||
|
||||
try {
|
||||
await button.click({ timeout: 15_000 });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!/swal2-container|intercepts pointer events/i.test(error?.message || "")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
test.describe("Edge gateway management smoke", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
@@ -245,7 +193,7 @@ test.describe("Edge gateway management smoke", () => {
|
||||
|
||||
await page.getByTestId("gateway-installer-department").selectOption("1");
|
||||
await page.getByTestId("gateway-installer-label").fill("Copy Test Pi");
|
||||
await generateGatewayInstaller(page);
|
||||
await page.getByTestId("gateway-installer-generate").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
|
||||
await page.getByTestId("gateway-installer-copy").click();
|
||||
@@ -298,7 +246,7 @@ test.describe("Edge gateway management smoke", () => {
|
||||
|
||||
await page.getByTestId("gateway-installer-department").selectOption("1");
|
||||
await page.getByTestId("gateway-installer-label").fill("Canary Pi");
|
||||
await generateGatewayInstaller(page);
|
||||
await page.getByTestId("gateway-installer-generate").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
|
||||
await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running");
|
||||
@@ -326,7 +274,7 @@ test.describe("Edge gateway management smoke", () => {
|
||||
|
||||
await page.getByTestId("gateway-installer-department").selectOption("1");
|
||||
await page.getByTestId("gateway-installer-label").fill("CPH Edge 01");
|
||||
await generateGatewayInstaller(page);
|
||||
await page.getByTestId("gateway-installer-generate").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
|
||||
await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running");
|
||||
@@ -370,7 +318,7 @@ test.describe("Edge gateway management smoke", () => {
|
||||
|
||||
await page.getByTestId("gateway-installer-department").selectOption("1");
|
||||
await page.getByTestId("gateway-installer-label").fill("Broken Pi");
|
||||
await generateGatewayInstaller(page);
|
||||
await page.getByTestId("gateway-installer-generate").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
|
||||
await expect
|
||||
@@ -434,7 +382,7 @@ test.describe("Edge gateway management smoke", () => {
|
||||
|
||||
await page.getByTestId("gateway-installer-department").selectOption("1");
|
||||
await page.getByTestId("gateway-installer-label").fill("Canary Pi");
|
||||
await generateGatewayInstaller(page);
|
||||
await page.getByTestId("gateway-installer-generate").click();
|
||||
|
||||
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
|
||||
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 });
|
||||
|
||||
@@ -15,20 +15,16 @@ function isApiUrl(url: string) {
|
||||
);
|
||||
}
|
||||
|
||||
const html2CanvasModulePattern = /\/(?:node_modules\/.*)?html2canvas(?:\.[\w-]+)?\.js(?:\?.*)?$/i;
|
||||
|
||||
async function mockHtml2Canvas(page, { alwaysFail = false } = {}) {
|
||||
await page.unroute(html2CanvasModulePattern).catch(() => {});
|
||||
await page.route(html2CanvasModulePattern, async (route) => {
|
||||
async function mockHtml2Canvas(page) {
|
||||
await page.route(/\/node_modules\/html2canvas\/dist\/html2canvas\.esm\.js(?:\?.*)?$/i, async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "text/javascript",
|
||||
body: `
|
||||
const alwaysFail = ${alwaysFail ? "true" : "false"};
|
||||
let html2canvasAttempts = 0;
|
||||
export default async function html2canvas() {
|
||||
html2canvasAttempts += 1;
|
||||
if (alwaysFail || html2canvasAttempts === 1) {
|
||||
throw new Error(alwaysFail ? "Simulated permanent capture failure" : "Simulated first capture failure");
|
||||
if (html2canvasAttempts === 1) {
|
||||
throw new Error("Simulated first capture failure");
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 8;
|
||||
@@ -167,68 +163,10 @@ test.describe("Authenticated error reports", () => {
|
||||
expect(Array.isArray(body.vue_errors)).toBe(true);
|
||||
expect(body.context).toMatchObject({
|
||||
data_collection_policy_version: "error-report-v1",
|
||||
screenshot_attachment: {
|
||||
status: "stored",
|
||||
attached: true,
|
||||
},
|
||||
});
|
||||
await expect(page.getByTestId("error-report-submitted")).toBeVisible();
|
||||
});
|
||||
|
||||
test("submits without a screenshot when capture never succeeds", async ({ page }) => {
|
||||
await mockHtml2Canvas(page, { alwaysFail: true });
|
||||
const submittedBodies: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route(/\/error-reports(?:\?.*)?$/i, async (route) => {
|
||||
if (!isApiUrl(route.request().url())) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
if (route.request().method().toUpperCase() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
submittedBodies.push(body);
|
||||
await route.fulfill(
|
||||
json(
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
id: 100,
|
||||
status: "open",
|
||||
screenshot: null,
|
||||
},
|
||||
},
|
||||
201
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "error-report-user-token", bootPath: "/admin" });
|
||||
await openErrorReportLauncher(page);
|
||||
|
||||
await page.getByLabel("What were you doing before the error occurred?").fill("Opening the orders page");
|
||||
await page.getByLabel("What did you expect would happen?").fill("The orders should load");
|
||||
await page.getByLabel("What actually happened?").fill("The page showed an error");
|
||||
await page.getByTestId("error-report-consent").check();
|
||||
await page.getByTestId("error-report-submit").click();
|
||||
|
||||
await expect.poll(() => submittedBodies.length).toBe(1);
|
||||
const body = submittedBodies[0];
|
||||
expect(body.screenshot).toBeNull();
|
||||
expect(body.context).toMatchObject({
|
||||
screenshot_attachment: {
|
||||
status: "capture_failed",
|
||||
attached: false,
|
||||
},
|
||||
});
|
||||
await expect(page.getByTestId("error-report-capture-warning")).toContainText(
|
||||
"The screen capture failed. The report will be sent without a screenshot."
|
||||
);
|
||||
await expect(page.getByTestId("error-report-submitted")).toBeVisible();
|
||||
});
|
||||
|
||||
test("lets superusers inspect and resolve submitted reports", async ({ page }) => {
|
||||
const report: Record<string, any> = {
|
||||
id: 12,
|
||||
@@ -339,90 +277,4 @@ test.describe("Authenticated error reports", () => {
|
||||
]);
|
||||
await expect(page.getByTestId("error-report-detail")).toContainText("resolved");
|
||||
});
|
||||
|
||||
test("lets superusers inspect submitted reports without screenshots", async ({ page }) => {
|
||||
const report: Record<string, any> = {
|
||||
id: 13,
|
||||
status: "open",
|
||||
reporter: {
|
||||
type: "user",
|
||||
user_id: 7,
|
||||
customer_number: 12345,
|
||||
name: "Error Reporter",
|
||||
email: "reporter@example.test",
|
||||
},
|
||||
route_path: "/user/orders",
|
||||
page_url: "https://app.example.test/user/orders",
|
||||
release_trace_id: "trace-no-screenshot",
|
||||
frontend_version: "front-1",
|
||||
api_version: "api-1",
|
||||
screenshot: null,
|
||||
answers: {
|
||||
before_error: "Opening the orders page",
|
||||
expected: "Orders should load",
|
||||
actual: "The table stayed empty",
|
||||
},
|
||||
request_error_count: 0,
|
||||
vue_error_count: 0,
|
||||
request_errors: [],
|
||||
vue_errors: [],
|
||||
runtime_context: {
|
||||
screenshot_attachment: {
|
||||
status: "capture_failed",
|
||||
attached: false,
|
||||
mime_type: null,
|
||||
size_bytes: 0,
|
||||
},
|
||||
},
|
||||
created_at: "2026-05-19 08:00:00",
|
||||
updated_at: "2026-05-19 08:00:00",
|
||||
resolved_at: null,
|
||||
resolved_by_user_id: null,
|
||||
resolution_note: null,
|
||||
};
|
||||
|
||||
await page.route(/\/superuser\/error-reports(?:\?.*)?$/i, async (route) => {
|
||||
if (!isApiUrl(route.request().url())) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
if (route.request().method().toUpperCase() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
json({
|
||||
success: true,
|
||||
data: {
|
||||
items: [report],
|
||||
counts: {
|
||||
open: 1,
|
||||
resolved: 0,
|
||||
all: 1,
|
||||
},
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(/\/superuser\/error-reports\/13$/i, async (route) => {
|
||||
if (!isApiUrl(route.request().url())) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill(json({ success: true, data: report }));
|
||||
});
|
||||
|
||||
await primeMockSession(page, { token: "error-report-superuser-token", bootPath: "/superuser/error-reports" });
|
||||
await expect(page.getByTestId("error-reports-page")).toBeVisible();
|
||||
await expect(page.getByTestId("error-report-list")).toContainText("/user/orders");
|
||||
|
||||
await page.getByTestId("error-report-view").click();
|
||||
const detail = page.getByTestId("error-report-detail");
|
||||
await expect(detail).toContainText("Opening the orders page");
|
||||
await expect(detail).toContainText("trace-no-screenshot");
|
||||
await expect(detail.locator("img")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,8 +213,6 @@ const employeesPayload = [
|
||||
customer_number: 900000501,
|
||||
display_name: "Casey Clerk",
|
||||
email: "casey@example.com",
|
||||
phone_country_code: 45,
|
||||
phone: 12345678,
|
||||
active: true,
|
||||
role: { key: "cashier", label: "Cashier", description: "Can sell." },
|
||||
departments: [{ id: 1, name: "Assigned Depot" }],
|
||||
@@ -230,17 +228,10 @@ 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 = {}) {
|
||||
async function mockLimitedBackofficeApi(page, authSessionData = sessionData) {
|
||||
const calls: string[] = [];
|
||||
const forbiddenCalls: string[] = [];
|
||||
const priceUpdateCalls: unknown[] = [];
|
||||
const employeeCreateCalls: unknown[] = [];
|
||||
const employeeUpdateCalls: unknown[] = [];
|
||||
const currentEmployees = {
|
||||
value: cloneJson(options.employeesPayload ?? employeesPayload),
|
||||
};
|
||||
|
||||
await page.route(API_HOST, async (route) => {
|
||||
const request = route.request();
|
||||
@@ -307,56 +298,7 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/limited-backoffice/employees") && method === "GET") {
|
||||
await route.fulfill(json({ data: currentEmployees.value }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/limited-backoffice/employees") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
employeeCreateCalls.push(body);
|
||||
const role = rolesPayload.find((item) => item.key === body.role_key) || rolesPayload[0];
|
||||
const created = {
|
||||
id: 900 + currentEmployees.value.length + 1,
|
||||
customer_number: 900001000 + currentEmployees.value.length + 1,
|
||||
display_name: body.display_name,
|
||||
email: body.email,
|
||||
phone_country_code: body.phone_country_code ?? null,
|
||||
phone: body.phone ?? null,
|
||||
active: true,
|
||||
role,
|
||||
departments: assignedDepartments.filter((department) => body.department_ids?.includes(department.id)),
|
||||
created_at: "2026-01-01 00:00:00",
|
||||
updated_at: "2026-01-01 00:00:00",
|
||||
};
|
||||
currentEmployees.value.unshift(created);
|
||||
await route.fulfill(json({ data: created }));
|
||||
return;
|
||||
}
|
||||
|
||||
const employeeMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)$/);
|
||||
if (employeeMatch && method === "PUT") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
employeeUpdateCalls.push(body);
|
||||
const employeeId = Number(employeeMatch[1]);
|
||||
const target = currentEmployees.value.find((employee) => Number(employee.id) === employeeId);
|
||||
if (!target) {
|
||||
await route.fulfill(json({ message: "Employee not found" }, 404));
|
||||
return;
|
||||
}
|
||||
|
||||
const role = rolesPayload.find((item) => item.key === body.role_key) || target.role;
|
||||
Object.assign(target, {
|
||||
display_name: body.display_name ?? target.display_name,
|
||||
email: body.email ?? target.email,
|
||||
phone_country_code: body.phone_country_code ?? null,
|
||||
phone: body.phone ?? null,
|
||||
role,
|
||||
departments: Array.isArray(body.department_ids)
|
||||
? assignedDepartments.filter((department) => body.department_ids.includes(department.id))
|
||||
: target.departments,
|
||||
updated_at: "2026-01-01 01:00:00",
|
||||
});
|
||||
await route.fulfill(json({ data: target }));
|
||||
await route.fulfill(json({ data: employeesPayload }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -367,8 +309,6 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
|
||||
calls,
|
||||
forbiddenCalls,
|
||||
priceUpdateCalls,
|
||||
employeeCreateCalls,
|
||||
employeeUpdateCalls,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -438,7 +378,6 @@ 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-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");
|
||||
await expect(page.locator("body")).not.toContainText("department_access_1");
|
||||
@@ -497,93 +436,6 @@ test.describe("Limited backoffice", () => {
|
||||
await expect(page.getByTestId("limited-role-permissions-modal")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("validates employee fields and submits optional phone details", async ({ page }) => {
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page);
|
||||
|
||||
await page.goto("/backoffice/employees");
|
||||
|
||||
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
|
||||
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
|
||||
await expect(page.getByTestId("limited-employee-save")).toHaveClass(/is-fullwidth/);
|
||||
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(1);
|
||||
await expect(page.getByTestId("limited-employee-phone-country-code")).toContainText("+45");
|
||||
|
||||
await page.getByTestId("limited-employee-name").fill("No Phone Worker");
|
||||
await page.getByTestId("limited-employee-email").fill("no-phone@example.com");
|
||||
await page.getByTestId("limited-employee-password").fill("Secret123!");
|
||||
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
|
||||
|
||||
await page.getByTestId("limited-employee-department-1").click();
|
||||
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
|
||||
await page.getByTestId("limited-employee-save").click();
|
||||
|
||||
await expect.poll(() => api.employeeCreateCalls.length).toBe(1);
|
||||
expect(api.employeeCreateCalls[0]).toEqual({
|
||||
display_name: "No Phone Worker",
|
||||
email: "no-phone@example.com",
|
||||
phone_country_code: null,
|
||||
phone: null,
|
||||
password: "Secret123!",
|
||||
role_key: "viewer",
|
||||
department_ids: [1],
|
||||
});
|
||||
await expect(page.getByTestId("limited-employee-row-902")).toContainText("No Phone Worker");
|
||||
|
||||
await page.getByTestId("limited-employee-name").fill("Phone Worker");
|
||||
await page.getByTestId("limited-employee-email").fill("phone@example.com");
|
||||
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
|
||||
await page.getByTestId("limited-employee-phone").fill("87654321");
|
||||
await page.getByTestId("limited-employee-password").fill("Secret123!");
|
||||
await page.getByTestId("limited-employee-department-1").click();
|
||||
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
|
||||
await page.getByTestId("limited-employee-save").click();
|
||||
|
||||
await expect.poll(() => api.employeeCreateCalls.length).toBe(2);
|
||||
expect(api.employeeCreateCalls[1]).toEqual({
|
||||
display_name: "Phone Worker",
|
||||
email: "phone@example.com",
|
||||
phone_country_code: 358,
|
||||
phone: 87654321,
|
||||
password: "Secret123!",
|
||||
role_key: "viewer",
|
||||
department_ids: [1],
|
||||
});
|
||||
await expect(page.getByTestId("limited-employee-row-903")).toContainText("Phone Worker");
|
||||
await expect(page.getByTestId("limited-employee-phone-903")).toHaveText("+358 87654321");
|
||||
});
|
||||
|
||||
test("edits employee contact details without requiring a new password", async ({ page }) => {
|
||||
await seedLimitedBackofficeSession(page);
|
||||
const api = await mockLimitedBackofficeApi(page);
|
||||
|
||||
await page.goto("/backoffice/employees");
|
||||
|
||||
await expect(page.getByTestId("limited-employee-row-501")).toBeVisible();
|
||||
await page.getByTestId("limited-employee-edit-501").click();
|
||||
|
||||
await expect(page.getByTestId("limited-employee-password")).toHaveValue("");
|
||||
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
|
||||
await page.getByTestId("limited-employee-name").fill("Casey Lead");
|
||||
await page.getByTestId("limited-employee-email").fill("casey.lead@example.com");
|
||||
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
|
||||
await page.getByTestId("limited-employee-phone").fill("87654321");
|
||||
await page.getByTestId("limited-employee-save").click();
|
||||
|
||||
await expect.poll(() => api.employeeUpdateCalls.length).toBe(1);
|
||||
expect(api.employeeUpdateCalls[0]).toEqual({
|
||||
display_name: "Casey Lead",
|
||||
email: "casey.lead@example.com",
|
||||
phone_country_code: 358,
|
||||
phone: 87654321,
|
||||
role_key: "cashier",
|
||||
department_ids: [1],
|
||||
});
|
||||
expect(api.employeeUpdateCalls[0]).not.toHaveProperty("password");
|
||||
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Lead");
|
||||
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+358 87654321");
|
||||
});
|
||||
|
||||
test("does not render data for a department outside the manager scope", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
|
||||
|
||||
@@ -257,70 +257,3 @@ 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);
|
||||
});
|
||||
|
||||
@@ -50,8 +50,6 @@ function createCardOrderFixture(overrides = {}) {
|
||||
customer_id: CARD_CUSTOMER_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
reference: "CARD-REF-54518",
|
||||
po: "",
|
||||
safety_seal: "",
|
||||
notes: "Paid at terminal",
|
||||
reg_1: "ZZ99999",
|
||||
reg_2: "",
|
||||
@@ -238,61 +236,6 @@ test.describe("POS desktop card payments", () => {
|
||||
await expect(stepThree.getByTestId("pos-stripe-create-intent")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("step 2 customer wishes stay visible after moving to card payment step 3", async ({ page }) => {
|
||||
const stepTwoReference = "CARD-STEP-2-REF";
|
||||
const stepTwoPo = "PO-CARD-STEP-2";
|
||||
const fixture = createCardOrderFixture({
|
||||
ordersById: {
|
||||
[ORDER_ID]: {
|
||||
...createCardOrderFixture().ordersById[ORDER_ID],
|
||||
reference: "",
|
||||
po: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: DESKTOP_POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: fixture,
|
||||
});
|
||||
await primeSession(page, `desktop-card-step2-${Date.now()}`);
|
||||
await page.goto(`/admin/${DEPARTMENT_ID}/modules/pos?id=${ORDER_ID}&customer_id=${CARD_CUSTOMER_ID}&step=2`);
|
||||
|
||||
const stepTwo = page.getByTestId("pos-step-2");
|
||||
await expect(stepTwo).toBeVisible({ timeout: 10_000 });
|
||||
await expect(stepTwo.getByTestId("pos-order-metadata-grid")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await stepTwo.getByTestId("pos-order-customer-wishes-reference").click();
|
||||
await stepTwo.getByTestId("pos-order-customer-wishes-reference-input").fill(stepTwoReference);
|
||||
await stepTwo.getByTestId("pos-order-customer-wishes-po").click();
|
||||
await stepTwo.getByTestId("pos-order-customer-wishes-po-input").fill(stepTwoPo);
|
||||
await stepTwo.getByTestId("pos-next-step").click();
|
||||
|
||||
const stepThree = page.getByTestId("pos-step-3");
|
||||
await expect(stepThree).toBeVisible({ timeout: 10_000 });
|
||||
await expect(stepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(stepTwoReference, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(stepThree.getByTestId("pos-order-customer-wishes-po")).toContainText(stepTwoPo, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect.poll(() => fixture.ordersById[ORDER_ID].reference, { timeout: 10_000 }).toBe(stepTwoReference);
|
||||
await expect.poll(() => fixture.ordersById[ORDER_ID].po, { timeout: 10_000 }).toBe(stepTwoPo);
|
||||
|
||||
await page.reload();
|
||||
|
||||
const reloadedStepThree = page.getByTestId("pos-step-3");
|
||||
await expect(reloadedStepThree).toBeVisible({ timeout: 10_000 });
|
||||
await expect(reloadedStepThree.getByTestId("pos-order-customer-wishes-reference")).toContainText(stepTwoReference, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(reloadedStepThree.getByTestId("pos-order-customer-wishes-po")).toContainText(stepTwoPo, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("idle state renders grouped terminal statuses and only keeps ready terminals selectable", async ({ page }) => {
|
||||
const fixture = createCardOrderFixture({
|
||||
readers: [
|
||||
|
||||
@@ -3272,66 +3272,6 @@ test.describe("POS mobile order flow", () => {
|
||||
expect(createdProductIds).toEqual([53, 71, 91]);
|
||||
});
|
||||
|
||||
test("manual step 2 blocks additional items when customer restricts additional services", async ({ page }) => {
|
||||
const orderId = 9414;
|
||||
const fixture = createMobilePosFixture({
|
||||
customerAttributesByNumber: {
|
||||
[REGULAR_CUSTOMER_ID]: [
|
||||
{
|
||||
id: 941401,
|
||||
customer_number: REGULAR_CUSTOMER_ID,
|
||||
attribute: "restrictAdditionalServices",
|
||||
},
|
||||
],
|
||||
},
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "STEP2-RESTRICT-ADDITIONAL",
|
||||
reg_1: "ZZ00000",
|
||||
}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-step2-restrict-additional-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "ZZ00000",
|
||||
reference: "STEP2-RESTRICT-ADDITIONAL",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(0);
|
||||
await selectPrimaryProduct(page, 53);
|
||||
await expect(page.getByTestId("pos-mobile-additional-items-open")).toHaveCount(1);
|
||||
|
||||
await page.getByTestId("pos-mobile-additional-items-open").click();
|
||||
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0);
|
||||
|
||||
await longPressAdditionalItems(page);
|
||||
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return snapshot?.transactionItems?.additionalItems || [];
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
test("clear all deletes the order and returns to the scanner", async ({ page }) => {
|
||||
const orderId = 9403;
|
||||
const fixture = createMobilePosFixture({
|
||||
|
||||
@@ -124,39 +124,6 @@ async function seedSavedProgress(page, overrides = {}) {
|
||||
await page.addInitScript((savedProgress) => {
|
||||
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
|
||||
}, payload);
|
||||
|
||||
try {
|
||||
await page.evaluate((savedProgress) => {
|
||||
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
|
||||
}, payload);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function expectFinishingOrCompletedWash(page) {
|
||||
const finishing = page.getByTestId("self-serve-finishing-wash");
|
||||
const completed = page.getByTestId("self-serve-completed-step");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await finishing.isVisible().catch(() => false)) {
|
||||
return "finishing";
|
||||
}
|
||||
if (await completed.isVisible().catch(() => false)) {
|
||||
return "completed";
|
||||
}
|
||||
return "pending";
|
||||
},
|
||||
{
|
||||
message: "expected the wash to show the finishing state or complete",
|
||||
timeout: 15_000,
|
||||
}
|
||||
)
|
||||
.toMatch(/^(finishing|completed)$/);
|
||||
|
||||
if (await finishing.isVisible().catch(() => false)) {
|
||||
await expect(finishing).toContainText("Afslutter vask, porten åbnes automatisk");
|
||||
}
|
||||
}
|
||||
|
||||
function captureSelfServeGatewayRequests(page) {
|
||||
@@ -309,7 +276,9 @@ test.describe("Self-serve wash", () => {
|
||||
const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP");
|
||||
const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE");
|
||||
await page.getByTestId("self-serve-nav-complete").click();
|
||||
await expectFinishingOrCompletedWash(page);
|
||||
await expect(page.getByTestId("self-serve-finishing-wash")).toContainText(
|
||||
"Afslutter vask, porten åbnes automatisk"
|
||||
);
|
||||
const stopCommandRequest = await stopCommandRequestPromise;
|
||||
const exitGateCommandRequest = await exitGateCommandRequestPromise;
|
||||
expect(stopCommandRequest.postDataJSON?.()).toMatchObject({
|
||||
@@ -691,18 +660,11 @@ test.describe("Self-serve wash", () => {
|
||||
});
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
|
||||
await page.getByTestId("self-serve-question-21-yes").click();
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-nav-confirm").click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("self-serve-lane-option-7")).toContainText("Tilgængelig");
|
||||
await expect(page.getByTestId("self-serve-lane-option-8")).toContainText("Vaskebanen er ikke tilgængelig");
|
||||
await expect(page.getByRole("radio", { name: /Maskine/i })).toBeEnabled();
|
||||
await expect(page.getByRole("radio", { name: /Maskine/i })).toBeDisabled();
|
||||
await expect(page.getByTestId("self-serve-machine-unavailable-guidance")).toHaveCount(0);
|
||||
await expect(page.locator("body")).not.toContainText(removedMachineUnavailableGuidanceText);
|
||||
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
|
||||
|
||||
await page.getByTestId("self-serve-lane-option-7").click();
|
||||
await page.getByTestId("self-serve-wash-type-manual").click();
|
||||
@@ -1185,7 +1147,9 @@ test.describe("Self-serve wash", () => {
|
||||
const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP");
|
||||
const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE");
|
||||
await page.getByTestId("self-serve-nav-complete").click();
|
||||
await expectFinishingOrCompletedWash(page);
|
||||
await expect(page.getByTestId("self-serve-finishing-wash")).toContainText(
|
||||
"Afslutter vask, porten åbnes automatisk"
|
||||
);
|
||||
const stopCommandRequest = await stopCommandRequestPromise;
|
||||
const exitGateCommandRequest = await exitGateCommandRequestPromise;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user