Add customer pricing labels and update date selection components

This commit is contained in:
Jeppe Bundgaard
2026-07-07 18:16:45 +02:00
parent a82176c855
commit 8c12fce187
142 changed files with 7488 additions and 2581 deletions
+24 -2
View File
@@ -30,7 +30,12 @@ export const sourceMappings = [
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\//u, /^src\/services\/limitedBackoffice\.js$/u],
patterns: [
/^src\/views\/backoffice\//u,
/^src\/services\/limitedBackoffice\.js$/u,
/^src\/services\/departmentCustomerPricing\.js$/u,
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
@@ -167,12 +172,29 @@ export const sourceMappings = [
{
name: "superuser-department-pricing",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|DepartmentCustomerPricing|SuperUserSelectedDepartmentObject|SuperUserDashboardDepartmentNavigation)\.vue$/u,
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
/^src\/services\/departmentCustomerPricing\.js$/u,
/^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u,
],
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
projects: ["chromium-desktop"],
},
{
name: "superuser-department-shells",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentCategories\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentGatewaysWorkspacePage\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentProfile\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/DepartmentModulesSetup\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/SuperUserDashboardDepartmentModulesNavigation\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeSetup\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeTerminalsReaders\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/SuperUserDashboardDepartmentStripeNavigation\.vue$/u,
],
specs: ["tests/e2e/superuser-department-shells.spec.js"],
projects: chromiumProjects,
},
{
name: "self-serve",
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
+8 -36
View File
@@ -1,12 +1,11 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {clearErrors, hasError, parseError} from "@/components/request/HandleGlobalError.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import bulmaCalendar from "bulma-calendar";
import 'bulma-calendar/src/scss/index.scss';
import Swal from "sweetalert2";
import {IS_DEV} from "@/config.js";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({
form_identifier: {
@@ -106,28 +105,6 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
}
});
// Initialize the calendar
const createCalendar = (element, field) => {
const calendar = bulmaCalendar.attach(element, {
startDate: new Date(),
dateFormat: 'yyyy-MM-dd',
});
}
// Load all date fields
const renderCalendars = () => {
console.log('renderCalendars');
// Get all date fields
const dateFields = document.querySelectorAll('.date-calendar-input');
// Loop through the date fields
dateFields.forEach((field) => {
console.log(field);
// Create the calendar
createCalendar(field);
});
}
const isUserAdmin = ref(false);
const randomElementId = Math.random().toString(36).substring(7)
const fieldErrors = ref([]);
@@ -519,10 +496,6 @@ SessionUser.objects.forms.get.single(props.form_identifier).then((response) => {
}
fields.value = fields_tmp;
setDefaultValues();
// Wait one tick before rendering the calendars
setTimeout(() => {
renderCalendars();
}, 0);
}).catch((error) => {
console.error(error);
@@ -696,17 +669,16 @@ const debugGetForm = () => {
</div>
<!-- Date -->
<div v-else-if="getValidator(field.validation).type === 'date'">
<input
class="input is-link"
:type="getValidator(field.validation).type"
<BuefyDateField
v-model="fieldValues[field.id]"
value-type="string"
:required="isFieldRequired(field)"
:name="field.id"
:id="field.id"
@change="onFieldChange(field, $event.target.value)"
v-model="fieldValues[field.id]"
:disabled="isFieldLocked(field)"
v-bind:placeholder="field.metadata.placeholder"
>
:placeholder="field.metadata.placeholder"
@change="(value) => onFieldChange(field, value)"
/>
</div>
</template>
<!-- If the validator is not defined -->
@@ -93,6 +93,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
directSectionKeys: {
type: Array,
default: () => [],
},
allowBookingCompletion: {
type: Boolean,
default: false,
@@ -2407,6 +2411,15 @@ const standaloneMenuActions = computed(() => {
return actions;
});
const directBuiltInMenuSections = computed(() => {
if (!props.displayActionsDirectly || !Array.isArray(props.directSectionKeys) || props.directSectionKeys.length === 0) {
return flatBuiltInMenuSections.value;
}
const allowedKeys = new Set(props.directSectionKeys.map((key) => String(key)));
return flatBuiltInMenuSections.value.filter((section) => allowedKeys.has(String(section.key)));
});
const desktopFlyoutMenuSections = computed(() => {
const mergedSections = [];
const mergedSectionsByLabel = new Map();
@@ -2624,7 +2637,7 @@ const syncDesktopFlyoutPosition = () => {
</div>
</template>
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
<template v-for="section in directBuiltInMenuSections" :key="section.key">
<ActionSettingsWheelItemLabel :label="section.label" />
<template v-for="item in section.items" :key="item.key">
<ActionSettingsWheelToggleItem
@@ -1,22 +1,33 @@
<script setup lang="ts">
import { IS_DEV } from "@/config.js";
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useWindowSize } from "@vueuse/core";
import { BMessage } from "buefy";
import { BTooltip } from "buefy";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import { formatLocalDateOnly } from "@/services/dateOnly.js";
import { buildRelativeDateShortcuts } from "@/services/relativeDateShortcuts.js";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
import BuefyMonthField from "@/components/forms/BuefyMonthField.vue";
type DateRange = {
startDate: Date,
endDate: Date,
startDate: Date | null,
endDate: Date | null,
};
type Shortcut = {
id: string,
label: string,
getRange: () => DateRange,
};
type DropdownAction = {
id: string,
label: string,
onClick: () => void | Promise<void>,
};
const props = defineProps({
selection: {
type: Object as () => DateRange,
@@ -30,8 +41,8 @@ const props = defineProps({
type: Function,
required: true,
default: (
startDate: Date,
endDate: Date
startDate: Date | null,
endDate: Date | null
) => {
if (IS_DEV) {
console.warn("onSelectionChange not provided", "startDate:", startDate, "endDate:", endDate);
@@ -84,6 +95,34 @@ const props = defineProps({
type: Boolean,
default: true,
},
allowEmptySelection: {
type: Boolean,
default: false,
},
shortcutIds: {
type: Array as () => string[],
default: () => buildRelativeDateShortcuts().map((shortcut) => shortcut.key),
},
primaryShortcutIds: {
type: Array as () => string[],
default: () => ["anytime", "today", "yesterday", "this_week", "last_week"],
},
unselectableDates: {
type: [Array, Function],
default: null,
},
selectableDates: {
type: [Array, Function],
default: null,
},
events: {
type: Array,
default: () => [],
},
indicators: {
type: String,
default: "dots",
},
});
const visibility = computed(() => ({
@@ -103,29 +142,18 @@ const visibility = computed(() => ({
const emits = defineEmits(["update:selection"]);
const { width } = useWindowSize();
const { t } = useI18n({ useScope: "global" });
const startDate = ref(props.selection.startDate);
const endDate = ref(props.selection.endDate);
const currentMonthSelection = ref(startDate.value.getMonth() + 1);
const currentMonthSelection = ref((startDate.value || new Date()).getMonth() + 1);
const monthFieldRef = ref<InstanceType<typeof BuefyMonthField> | null>(null);
const isPendingEmit = ref(false);
const isMobileLayout = computed(() => width.value <= 768 || SessionUser.functions.device.isMobile());
const showMobileAdvancedControls = computed(() => visibility.value.showMonthSelector || visibility.value.showYearSelector);
const isMobileAdvancedExpanded = ref(false);
const availableYears = computed(() => (
Array.from({ length: 21 }, (_, index) => new Date().getFullYear() - 10 + index)
));
const formatDateInputValue = (date: Date) => {
return formatLocalDateOnly(date);
};
const parseDateInputValue = (value: string) => {
return parseLocalDateOnly(value);
};
const isSameDateInputValue = (left: Date, right: Date) => (
formatDateInputValue(left) === formatDateInputValue(right)
);
const formatDateInputValue = (date: Date | null) => (date ? formatLocalDateOnly(date) : "");
const isSameDateInputValue = (left: Date | null, right: Date | null) => formatDateInputValue(left) === formatDateInputValue(right);
const updateSelection = () => {
emits("update:selection", { startDate: startDate.value, endDate: endDate.value });
@@ -133,10 +161,10 @@ const updateSelection = () => {
isPendingEmit.value = false;
};
const applyDateRange = (newStartDate: Date, newEndDate: Date) => {
const applyDateRange = (newStartDate: Date | null, newEndDate: Date | null) => {
startDate.value = newStartDate;
endDate.value = newEndDate;
currentMonthSelection.value = newStartDate.getMonth() + 1;
currentMonthSelection.value = (newStartDate || new Date()).getMonth() + 1;
isPendingEmit.value = true;
if (props.autoEmitChange) {
@@ -147,7 +175,7 @@ const applyDateRange = (newStartDate: Date, newEndDate: Date) => {
watch(() => props.selection, (newSelection) => {
startDate.value = newSelection.startDate;
endDate.value = newSelection.endDate;
currentMonthSelection.value = newSelection.startDate.getMonth() + 1;
currentMonthSelection.value = (newSelection.startDate || new Date()).getMonth() + 1;
}, { immediate: true });
watch(isMobileLayout, (isMobile) => {
@@ -159,35 +187,25 @@ watch(isMobileLayout, (isMobile) => {
isMobileAdvancedExpanded.value = false;
}, { immediate: true });
const isStartDateValid = computed(() => {
if (props.restrictions.minDate) {
return startDate.value >= props.restrictions.minDate;
}
const isStartDateValid = computed(() => (
!startDate.value || !props.restrictions.minDate || startDate.value >= props.restrictions.minDate
));
return true;
});
const isEndDateValid = computed(() => {
if (props.restrictions.maxDate) {
return endDate.value <= props.restrictions.maxDate;
}
return true;
});
const isEndDateValid = computed(() => (
!endDate.value || !props.restrictions.maxDate || endDate.value <= props.restrictions.maxDate
));
const isSelectionValid = computed(() => (
isStartDateValid.value
&& isEndDateValid.value
&& startDate.value <= endDate.value
&& (
!startDate.value
|| !endDate.value
|| startDate.value <= endDate.value
)
));
const handleStartDateChange = (event) => {
const newDate = parseDateInputValue(event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid start date:", event.target.value);
return;
}
const handleStartDateChange = (newDate: Date | null) => {
if (isSameDateInputValue(newDate, startDate.value)) {
return;
}
@@ -195,13 +213,7 @@ const handleStartDateChange = (event) => {
applyDateRange(newDate, endDate.value);
};
const handleEndDateChange = (event) => {
const newDate = parseDateInputValue(event.target.value);
if (Number.isNaN(newDate.getTime())) {
console.warn("Invalid end date:", event.target.value);
return;
}
const handleEndDateChange = (newDate: Date | null) => {
if (isSameDateInputValue(newDate, endDate.value)) {
return;
}
@@ -220,159 +232,101 @@ const setMonth = (month: number) => {
return;
}
const year = startDate.value.getFullYear();
const year = (startDate.value || new Date()).getFullYear();
const range = getFullMonthRange(year, month);
applyDateRange(range.startDate, range.endDate);
};
const handleMonthChange = (event) => {
const month = parseInt(event.target.value, 10);
if (Number.isNaN(month)) {
return;
}
setMonth(month);
};
const setCurrentSelectionEntireMonth = () => {
setMonth(currentMonthSelection.value);
};
const handleYearChange = (event) => {
const year = parseInt(event.target.value, 10);
if (Number.isNaN(year)) {
const selectedMonthDate = computed({
get: () => {
const baseDate = startDate.value || new Date();
return new Date(baseDate.getFullYear(), currentMonthSelection.value - 1, 1);
},
set: (value: Date | null) => {
if (!value) {
return;
}
const range = getFullMonthRange(value.getFullYear(), value.getMonth() + 1);
applyDateRange(range.startDate, range.endDate);
},
});
const handleMonthPickerChange = (value: Date | null) => {
if (!value) {
return;
}
const newStartDate = new Date(startDate.value);
newStartDate.setFullYear(year);
const newEndDate = new Date(endDate.value);
newEndDate.setFullYear(year);
applyDateRange(newStartDate, newEndDate);
selectedMonthDate.value = value;
};
const months = computed(() => (
Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: new Date(0, index).toLocaleString("da-DK", { month: "long" }),
}))
const isCurrentSelectionEntireMonth = computed(() => (
startDate.value
&& endDate.value
&& startDate.value.getDate() === 1
&& endDate.value.getDate() === new Date(endDate.value.getFullYear(), endDate.value.getMonth() + 1, 0).getDate()
));
const isCurrentSelectionEntireMonth = computed(() => {
const isFirstDayOfMonth = startDate.value.getDate() === 1;
const isLastDayOfMonth = (
endDate.value.getDate()
=== new Date(endDate.value.getFullYear(), endDate.value.getMonth() + 1, 0).getDate()
);
return isFirstDayOfMonth && isLastDayOfMonth;
});
const isMultiMonthSelection = computed(() => (
startDate.value.getMonth() !== endDate.value.getMonth()
|| startDate.value.getFullYear() !== endDate.value.getFullYear()
startDate.value
&& endDate.value
&& (
startDate.value.getMonth() !== endDate.value.getMonth()
|| startDate.value.getFullYear() !== endDate.value.getFullYear()
)
));
const shortcuts = computed<Shortcut[]>(() => {
const dailyShortcuts: Shortcut[] = [
{
label: SessionUser.objects.global.language.text.today,
getRange: () => {
const today = new Date();
return {
startDate: new Date(today.setHours(23, 59, 59, 999)),
endDate: new Date(today.setHours(23, 59, 59, 999)),
};
},
},
{
label: SessionUser.objects.global.language.text.yesterday,
getRange: () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return {
startDate: new Date(yesterday.setHours(23, 59, 59, 999)),
endDate: new Date(yesterday.setHours(23, 59, 59, 999)),
};
},
},
];
const enabledShortcutIds = computed(() => (
props.allowEmptySelection
? props.shortcutIds
: props.shortcutIds.filter((shortcutId) => shortcutId !== "anytime")
));
const weeklyShortcuts: Shortcut[] = [
{
label: SessionUser.objects.global.language.text.this_week,
getRange: () => {
const today = new Date();
const firstDayOfWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1));
const lastDayOfWeek = new Date(firstDayOfWeek);
lastDayOfWeek.setDate(firstDayOfWeek.getDate() + 6);
return {
startDate: new Date(firstDayOfWeek.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfWeek.setHours(23, 59, 59, 999)),
};
},
},
{
label: SessionUser.objects.global.language.text.last_week,
getRange: () => {
const today = new Date();
const firstDayOfLastWeek = new Date(today.setDate(today.getDate() - today.getDay() - 6));
const lastDayOfLastWeek = new Date(firstDayOfLastWeek);
lastDayOfLastWeek.setDate(firstDayOfLastWeek.getDate() + 6);
return {
startDate: new Date(firstDayOfLastWeek.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastWeek.setHours(23, 59, 59, 999)),
};
},
},
{
label: SessionUser.objects.global.language.text.this_month,
getRange: () => {
const today = new Date();
return getFullMonthRange(today.getFullYear(), today.getMonth() + 1);
},
},
{
label: SessionUser.objects.global.language.text.last_month,
getRange: () => {
const today = new Date();
return getFullMonthRange(today.getFullYear(), today.getMonth());
},
},
{
label: SessionUser.objects.global.language.text.same_week_last_year,
getRange: () => {
const today = new Date();
const firstDayOfThisWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1));
const firstDayOfLastYearSameWeek = new Date(firstDayOfThisWeek);
firstDayOfLastYearSameWeek.setFullYear(firstDayOfThisWeek.getFullYear() - 1);
const lastDayOfLastYearSameWeek = new Date(firstDayOfLastYearSameWeek);
lastDayOfLastYearSameWeek.setDate(firstDayOfLastYearSameWeek.getDate() + 6);
return {
startDate: new Date(firstDayOfLastYearSameWeek.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastYearSameWeek.setHours(23, 59, 59, 999)),
};
},
},
{
label: SessionUser.objects.global.language.text.same_month_last_year,
getRange: () => {
const today = new Date();
return getFullMonthRange(today.getFullYear() - 1, today.getMonth() + 1);
},
},
];
const shortcuts = computed<Shortcut[]>(() => (
buildRelativeDateShortcuts()
.filter((shortcut) => enabledShortcutIds.value.includes(shortcut.key))
.map((shortcut) => ({
id: shortcut.key,
label: shortcut.label,
getRange: shortcut.getRange,
}))
));
return [
...((visibility.value.showDailySelector || visibility.value.showShortCuts) ? dailyShortcuts : []),
...((visibility.value.showWeeklySelector || visibility.value.showShortCuts) ? weeklyShortcuts : []),
];
const relativeShortcutBuckets = computed(() => {
const primaryShortcutIds = new Set(props.primaryShortcutIds);
const allShortcuts = buildRelativeDateShortcuts().filter((shortcut) => enabledShortcutIds.value.includes(shortcut.key));
const mapShortcut = (shortcut) => ({
id: shortcut.key,
label: shortcut.label,
getRange: shortcut.getRange,
});
return {
primary: allShortcuts
.filter((shortcut) => primaryShortcutIds.has(shortcut.key))
.map(mapShortcut),
other: allShortcuts
.filter((shortcut) => !primaryShortcutIds.has(shortcut.key))
.map(mapShortcut),
};
});
const selectedShortcutLabel = computed(() => (
shortcuts.value.find((shortcut) => (
formatDateInputValue(shortcut.getRange().startDate) === formatDateInputValue(startDate.value)
&& formatDateInputValue(shortcut.getRange().endDate) === formatDateInputValue(endDate.value)
))?.label || ""
const primaryShortcuts = computed(() => relativeShortcutBuckets.value.primary);
const otherShortcuts = computed(() => relativeShortcutBuckets.value.other);
const selectedShortcutId = computed(() => (
shortcuts.value.find((shortcut) => {
const range = shortcut.getRange();
return formatDateInputValue(range.startDate) === formatDateInputValue(startDate.value)
&& formatDateInputValue(range.endDate) === formatDateInputValue(endDate.value);
})?.id || ""
));
const isOtherShortcutSelected = computed(() => (
otherShortcuts.value.some((shortcut) => shortcut.id === selectedShortcutId.value)
));
const applyShortcut = (shortcut: Shortcut) => {
@@ -380,9 +334,45 @@ const applyShortcut = (shortcut: Shortcut) => {
applyDateRange(range.startDate, range.endDate);
};
const focusMonthField = () => {
const monthFieldElement = monthFieldRef.value?.$el as HTMLElement | undefined;
const monthInput = monthFieldElement?.querySelector("input");
if (monthInput instanceof HTMLInputElement) {
monthInput.focus();
}
};
const openMonthSelector = async () => {
if (isMobileLayout.value) {
isMobileAdvancedExpanded.value = true;
}
await nextTick();
focusMonthField();
};
const otherDropdownActions = computed<DropdownAction[]>(() => {
const actions = otherShortcuts.value.map((shortcut) => ({
id: shortcut.id,
label: shortcut.label,
onClick: () => applyShortcut(shortcut),
}));
if (showMobileAdvancedControls.value) {
actions.unshift({
id: "other_month",
label: t("global.text.other_month"),
onClick: openMonthSelector,
});
}
return actions;
});
const handleShortcutSelection = (event) => {
const selectedLabel = String(event.target.value || "");
const selectedShortcut = shortcuts.value.find((shortcut) => shortcut.label === selectedLabel);
const selectedId = String(event.target.value || "");
const selectedShortcut = shortcuts.value.find((shortcut) => shortcut.id === selectedId);
if (!selectedShortcut) {
return;
}
@@ -394,21 +384,53 @@ const handleShortcutSelection = (event) => {
<template>
<div class="date-period-selector">
<div v-if="isMobileLayout" class="date-period-selector__mobile" data-testid="date-period-mobile-layout">
<div class="field" v-if="shortcuts.length > 0">
<label class="label is-small">Periode</label>
<div class="field" v-if="primaryShortcuts.length > 0 || otherDropdownActions.length > 0">
<label class="label is-small">{{ t("date_period.labels.period") }}</label>
<div class="control">
<div class="select is-fullwidth">
<select
data-testid="date-period-shortcuts"
:value="selectedShortcutLabel"
:disabled="props.isDisabled || props.isReadonly"
@change="handleShortcutSelection"
<div class="date-period-selector__mobile-shortcuts">
<div v-if="primaryShortcuts.length > 0" class="select is-fullwidth">
<select
data-testid="date-period-shortcuts"
:value="selectedShortcutId"
:disabled="props.isDisabled || props.isReadonly"
@change="handleShortcutSelection"
>
<option value="" disabled>{{ t("date_period.labels.select_period") }}</option>
<option v-for="shortcut in primaryShortcuts" :key="shortcut.id" :value="shortcut.id">
{{ shortcut.label }}
</option>
</select>
</div>
<b-dropdown
v-if="otherDropdownActions.length > 0"
aria-role="list"
position="is-bottom-left"
>
<option value="" disabled>Vælg periode</option>
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
{{ shortcut.label }}
</option>
</select>
<template #trigger>
<button
class="button is-light is-fullwidth"
:class="{ 'is-info': isOtherShortcutSelected }"
type="button"
data-testid="date-period-other-dropdown-trigger"
:disabled="props.isDisabled || props.isReadonly"
>
<span>{{ t("global.other") }}</span>
<span class="icon is-small"><i class="fas fa-chevron-down" aria-hidden="true"></i></span>
</button>
</template>
<b-dropdown-item
v-for="action in otherDropdownActions"
:key="action.id"
:value="action.id"
aria-role="listitem"
paddingless
@click="action.onClick"
>
<span class="date-period-selector__dropdown-button" :data-testid="`date-period-other-${action.id}`">
{{ action.label }}
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
@@ -416,15 +438,19 @@ const handleShortcutSelection = (event) => {
<div class="field" v-if="visibility.showStartDate">
<label class="label is-small">{{ SessionUser.objects.global.language.text.date_from }}</label>
<div class="control">
<input
type="date"
class="input"
<BuefyDateField
:model-value="startDate"
value-type="date"
data-testid="date-period-start"
:value="formatDateInputValue(startDate)"
@input="handleStartDateChange"
@change="handleStartDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
:min-date="props.restrictions.minDate"
:max-date="props.restrictions.maxDate"
:unselectable-dates="props.unselectableDates"
:selectable-dates="props.selectableDates"
:events="props.events"
:indicators="props.indicators"
:clearable="props.allowEmptySelection"
@update:model-value="handleStartDateChange"
/>
</div>
</div>
@@ -432,15 +458,19 @@ const handleShortcutSelection = (event) => {
<div class="field" v-if="visibility.showEndDate">
<label class="label is-small">{{ SessionUser.objects.global.language.text.date_to }}</label>
<div class="control">
<input
type="date"
class="input"
<BuefyDateField
:model-value="endDate"
value-type="date"
data-testid="date-period-end"
:value="formatDateInputValue(endDate)"
@input="handleEndDateChange"
@change="handleEndDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
:min-date="props.restrictions.minDate"
:max-date="props.restrictions.maxDate"
:unselectable-dates="props.unselectableDates"
:selectable-dates="props.selectableDates"
:events="props.events"
:indicators="props.indicators"
:clearable="props.allowEmptySelection"
@update:model-value="handleEndDateChange"
/>
</div>
</div>
@@ -453,7 +483,7 @@ const handleShortcutSelection = (event) => {
data-testid="date-period-advanced-toggle"
@click="isMobileAdvancedExpanded = !isMobileAdvancedExpanded"
>
<span>Advanced period</span>
<span>{{ t("date_period.labels.advanced_period") }}</span>
<span class="icon">
<i :class="isMobileAdvancedExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</span>
@@ -462,38 +492,22 @@ const handleShortcutSelection = (event) => {
</div>
<div v-if="showMobileAdvancedControls && isMobileAdvancedExpanded" class="date-period-selector__advanced-panel">
<div class="field" v-if="visibility.showMonthSelector">
<div class="field" v-if="visibility.showMonthSelector || visibility.showYearSelector">
<label class="label is-small">{{ SessionUser.objects.global.language.text.month }}</label>
<div class="control">
<div class="select is-fullwidth">
<select
:value="currentMonthSelection"
:disabled="props.isDisabled || props.isReadonly"
@change="handleMonthChange"
>
<option value="" disabled>Select Month</option>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ SessionUser.functions.ucFirst(month.label) }}
</option>
</select>
</div>
</div>
</div>
<div class="field" v-if="visibility.showYearSelector">
<label class="label is-small">{{ SessionUser.objects.global.language.text.year }}</label>
<div class="control">
<div class="select is-fullwidth">
<select
:value="startDate.getFullYear()"
:disabled="props.isDisabled || props.isReadonly"
@change="handleYearChange"
>
<option v-for="year in availableYears" :key="year" :value="year">
{{ year }}
</option>
</select>
</div>
<BuefyMonthField
ref="monthFieldRef"
:model-value="selectedMonthDate"
value-type="date"
data-testid="date-period-month"
:placeholder="t('date_period.labels.select_month')"
:disabled="props.isDisabled || props.isReadonly"
:min-date="props.restrictions.minDate"
:max-date="props.restrictions.maxDate"
:events="props.events"
:indicators="props.indicators"
@update:model-value="handleMonthPickerChange"
/>
</div>
</div>
</div>
@@ -507,7 +521,7 @@ const handleShortcutSelection = (event) => {
@click="updateSelection"
:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
>
Update Selection
{{ t("date_period.labels.update_selection") }}
</button>
</div>
</div>
@@ -516,79 +530,57 @@ const handleShortcutSelection = (event) => {
<div class="level is-align-items-center">
<div class="level-left">
<div class="level-item" v-if="visibility.showStartDate">
<input
type="date"
class="input"
<BuefyDateField
:model-value="startDate"
value-type="date"
data-testid="date-period-start"
:value="formatDateInputValue(startDate)"
@input="handleStartDateChange"
@change="handleStartDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
:min-date="props.restrictions.minDate"
:max-date="props.restrictions.maxDate"
:unselectable-dates="props.unselectableDates"
:selectable-dates="props.selectableDates"
:events="props.events"
:indicators="props.indicators"
:clearable="props.allowEmptySelection"
@update:model-value="handleStartDateChange"
/>
</div>
<div class="level-item" v-if="visibility.showStartDate && visibility.showEndDate && visibility.showToLabel">
<span class="label">To</span>
<span class="label">{{ t("date_period.labels.to") }}</span>
</div>
<div class="level-item" v-if="visibility.showEndDate">
<input
type="date"
class="input"
<BuefyDateField
:model-value="endDate"
value-type="date"
data-testid="date-period-end"
:value="formatDateInputValue(endDate)"
@input="handleEndDateChange"
@change="handleEndDateChange"
:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
:min-date="props.restrictions.minDate"
:max-date="props.restrictions.maxDate"
:unselectable-dates="props.unselectableDates"
:selectable-dates="props.selectableDates"
:events="props.events"
:indicators="props.indicators"
:clearable="props.allowEmptySelection"
@update:model-value="handleEndDateChange"
/>
</div>
<div class="level-item" v-if="!visibility.showStartDate && !visibility.showEndDate && visibility.showToLabel">
<span class="label">No date selection available</span>
<span class="label">{{ t("date_period.labels.no_date_selection") }}</span>
</div>
<div class="level-item" v-if="visibility.showMonthSelector">
<div class="field has-addons">
<p class="control">
<span class="select">
<select
:value="currentMonthSelection"
:disabled="props.isDisabled || props.isReadonly"
@change="handleMonthChange"
>
<option value="" disabled>Select Month</option>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ SessionUser.functions.ucFirst(month.label) }}
</option>
</select>
</span>
</p>
<p class="control">
<a class="button is-static">
{{ SessionUser.objects.global.language.text.month }}
</a>
</p>
</div>
</div>
<div class="level-item" v-if="visibility.showYearSelector">
<div class="field has-addons">
<p class="control">
<span class="select">
<select
:value="startDate.getFullYear()"
:disabled="props.isDisabled || props.isReadonly"
@change="handleYearChange"
>
<option v-for="year in availableYears" :key="year" :value="year">
{{ year }}
</option>
</select>
</span>
</p>
<p class="control">
<a class="button is-static">
{{ SessionUser.objects.global.language.text.year }}
</a>
</p>
</div>
<div class="level-item" v-if="visibility.showMonthSelector || visibility.showYearSelector">
<BuefyMonthField
ref="monthFieldRef"
:model-value="selectedMonthDate"
value-type="date"
data-testid="date-period-month"
:placeholder="t('date_period.labels.select_month')"
:disabled="props.isDisabled || props.isReadonly"
:min-date="props.restrictions.minDate"
:max-date="props.restrictions.maxDate"
:events="props.events"
:indicators="props.indicators"
@update:model-value="handleMonthPickerChange"
/>
</div>
<slot name="left"></slot>
</div>
@@ -600,27 +592,59 @@ const handleShortcutSelection = (event) => {
@click="updateSelection"
:disabled="props.isDisabled || props.isReadonly || !isSelectionValid || !isPendingEmit"
>
Update Selection
{{ t("date_period.labels.update_selection") }}
</button>
</div>
</div>
</div>
<div class="navbar is-white">
<div class="navbar is-white" v-if="primaryShortcuts.length > 0 || otherShortcuts.length > 0">
<div class="navbar-menu">
<div class="navbar-start">
<div class="navbar-item">
<div class="buttons">
<button
v-for="shortcut in shortcuts"
:key="shortcut.label"
v-for="shortcut in primaryShortcuts"
:key="shortcut.id"
:disabled="props.isDisabled || props.isReadonly"
class="button is-light"
:class="{ 'is-info': selectedShortcutLabel === shortcut.label }"
:class="{ 'is-info': selectedShortcutId === shortcut.id }"
@click="applyShortcut(shortcut)"
>
{{ shortcut.label }}
</button>
<b-dropdown
v-if="otherDropdownActions.length > 0"
aria-role="list"
position="is-bottom-left"
>
<template #trigger>
<button
class="button is-light"
:class="{ 'is-info': isOtherShortcutSelected }"
type="button"
data-testid="date-period-other-dropdown-trigger"
:disabled="props.isDisabled || props.isReadonly"
>
<span>{{ t("global.other") }}</span>
<span class="icon">
<i class="fas fa-chevron-down" aria-hidden="true"></i>
</span>
</button>
</template>
<b-dropdown-item
v-for="action in otherDropdownActions"
:key="action.id"
:value="action.id"
aria-role="listitem"
paddingless
@click="action.onClick"
>
<span class="date-period-selector__dropdown-button" :data-testid="`date-period-other-${action.id}`">
{{ action.label }}
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
@@ -635,7 +659,7 @@ const handleShortcutSelection = (event) => {
iconPack="fas"
has-icon
>
Invalid selection! Start date must be before end date. Please adjust your selection to ensure the start date is not after the end date, and that both dates are within any specified restrictions.
{{ t("date_period.messages.invalid_selection") }}
</b-message>
<b-message
@@ -645,7 +669,7 @@ const handleShortcutSelection = (event) => {
iconPack="fas"
has-icon
>
Selection spans multiple months! This may lead to unexpected results. Consider selecting an entire month or a shorter period for more accurate insights.
{{ t("date_period.messages.multi_month") }}
</b-message>
<b-message
@@ -655,30 +679,32 @@ const handleShortcutSelection = (event) => {
iconPack="fas"
has-icon
>
Selection is not an entire month <span class="has-text-weight-bold is-clickable" data-testid="date-period-set-entire-month" @click="setCurrentSelectionEntireMonth">Click to set entire month</span>.
When a partial month selection is made, it may not accurately represent the intended time period. Consider selecting an entire month for more precise results.
{{ t("date_period.messages.partial_month_prefix") }}
<BTooltip :label="t('date_period.messages.partial_month_tooltip')" type="is-dark">
<span class="has-text-weight-bold is-clickable" data-testid="date-period-set-entire-month" @click="setCurrentSelectionEntireMonth">
{{ t("date_period.messages.partial_month_action") }}
</span>.
</BTooltip>
{{ t("date_period.messages.partial_month_suffix") }}
</b-message>
</div>
</div>
</template>
<style scoped>
.date-period-selector__mobile-shortcuts,
.date-period-selector__mobile {
display: grid;
gap: 0.75rem;
}
.date-period-selector__advanced-toggle {
display: flex;
align-items: center;
display: flex;
justify-content: space-between;
}
.date-period-selector__advanced-panel {
display: grid;
gap: 0.75rem;
}
.date-period-selector__advanced-panel,
.date-period-selector__mobile-actions {
display: grid;
gap: 0.75rem;
@@ -687,4 +713,5 @@ const handleShortcutSelection = (event) => {
.date-period-selector__messages {
margin-top: 0.75rem;
}
</style>
@@ -0,0 +1,566 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import {
getLimitedBackofficeDepartmentCustomerPricing,
getSuperuserDepartmentCustomerPricing,
unwrapDepartmentCustomerPricingResponse,
updateLimitedBackofficeDepartmentCustomerPricing,
updateSuperuserDepartmentCustomerPricing,
} from "@/services/departmentCustomerPricing.js";
const props = defineProps({
scope: {
type: String,
required: true,
validator: (value) => ["superuser", "limited"].includes(value),
},
departmentId: {
type: [Number, String],
required: true,
},
customPricingEnabled: {
type: Boolean,
default: false,
},
canRead: {
type: Boolean,
default: false,
},
canEdit: {
type: Boolean,
default: false,
},
initialCustomerNumber: {
type: [Number, String],
default: "",
},
});
const { t, locale } = useI18n({ useScope: "global" });
const customerNumber = ref(String(props.initialCustomerNumber || ""));
const pricingData = ref(null);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const departmentId = computed(() => {
const parsed = Number.parseInt(String(props.departmentId || ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const parsedCustomerNumber = computed(() => {
const parsed = Number.parseInt(String(customerNumber.value || "").trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const canLoadPricing = computed(
() => props.canRead && props.customPricingEnabled && departmentId.value !== null && parsedCustomerNumber.value !== null
);
const categories = computed(() => (Array.isArray(pricingData.value?.categories) ? pricingData.value.categories : []));
const hasProducts = computed(() => categories.value.some((category) => (category.products || []).length > 0));
const customer = computed(() => pricingData.value?.customer || null);
const customerDisplay = computed(() => {
if (!customer.value) {
return "";
}
return [customer.value.display_name, customer.value.customer_number ? `#${customer.value.customer_number}` : null]
.filter(Boolean)
.join(" - ");
});
const formatPrice = (price) => {
if (price === null || price === undefined || price === "") {
return "-";
}
return new Intl.NumberFormat(locale.value || undefined, {
style: "currency",
currency: "DKK",
maximumFractionDigits: 0,
}).format(Number(price || 0));
};
const overrideKey = (isCategory, objectId) => `${isCategory ? 1 : 0}:${String(objectId)}`;
const overridesByKey = computed(() => {
const map = new Map();
(pricingData.value?.overrides || []).forEach((override) => {
map.set(overrideKey(Boolean(override.is_category), override.product_or_category_id), {
is_category: Boolean(override.is_category),
product_or_category_id: override.product_or_category_id,
percentage: Number.parseInt(String(override.percentage ?? 0), 10) || 0,
fixed_price:
override.fixed_price === null || override.fixed_price === undefined
? null
: Number.parseInt(String(override.fixed_price), 10),
});
});
return map;
});
const getOverride = (isCategory, objectId) =>
overridesByKey.value.get(overrideKey(Boolean(isCategory), objectId)) || {
is_category: Boolean(isCategory),
product_or_category_id: objectId,
percentage: 0,
fixed_price: null,
};
const getDiscountDisplay = (override) => (override.percentage > 0 ? `${override.percentage}%` : "-");
const getFixedPriceDisplay = (override) => (override.fixed_price !== null ? formatPrice(override.fixed_price) : "-");
const customerIdentifier = computed(() => ({ customerNumber: parsedCustomerNumber.value }));
const requestLoad = () =>
props.scope === "limited"
? getLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value)
: getSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value);
const requestSave = (overrides) =>
props.scope === "limited"
? updateLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides)
: updateSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides);
const resetPricingData = () => {
pricingData.value = null;
errorMessage.value = "";
};
const loadPricing = async () => {
if (!canLoadPricing.value) {
resetPricingData();
return;
}
loading.value = true;
errorMessage.value = "";
try {
const response = await requestLoad();
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
pricingData.value = null;
errorMessage.value =
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
t("departments.customer_pricing.errors.load");
} finally {
loading.value = false;
}
};
const normalizedOverrides = (nextOverride) => {
const map = new Map(overridesByKey.value);
const key = overrideKey(nextOverride.is_category, nextOverride.product_or_category_id);
const percentage = Number.parseInt(String(nextOverride.percentage ?? 0), 10) || 0;
const fixedPrice =
nextOverride.fixed_price === null || nextOverride.fixed_price === undefined || nextOverride.fixed_price === ""
? null
: Number.parseInt(String(nextOverride.fixed_price), 10);
const normalized = {
is_category: Boolean(nextOverride.is_category),
product_or_category_id: nextOverride.product_or_category_id,
percentage: Math.min(100, Math.max(0, percentage)),
fixed_price: nextOverride.is_category ? null : fixedPrice,
};
if (normalized.percentage <= 0 && normalized.fixed_price === null) {
map.delete(key);
} else {
map.set(key, normalized);
}
return [...map.values()];
};
const saveOverride = async (nextOverride) => {
if (!props.canEdit || !canLoadPricing.value) {
return;
}
saving.value = true;
errorMessage.value = "";
try {
const response = await requestSave(normalizedOverrides(nextOverride));
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
errorMessage.value =
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
t("departments.customer_pricing.errors.save");
} finally {
saving.value = false;
}
};
const promptInteger = async ({ title, inputLabel, inputValue, allowEmpty = false, min = 0, max = null }) => {
const result = await Swal.fire({
title,
input: "number",
inputLabel,
inputValue,
inputAttributes: {
min,
...(max === null ? {} : { max }),
step: 1,
autocapitalize: "off",
},
showCancelButton: true,
confirmButtonText: t("common.save"),
showLoaderOnConfirm: true,
inputValidator: (value) => {
const normalized = String(value ?? "").trim();
if (allowEmpty && normalized === "") {
return null;
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isInteger(parsed) || parsed < min || (max !== null && parsed > max)) {
return max === null
? t("departments.customer_pricing.fixed_price_validation")
: t("departments.customer_pricing.discount_validation");
}
return null;
},
});
if (!result.isConfirmed) {
return undefined;
}
const normalized = String(result.value ?? "").trim();
return allowEmpty && normalized === "" ? null : Number.parseInt(normalized, 10);
};
const editDiscount = async (isCategory, objectId, label) => {
const override = getOverride(isCategory, objectId);
const value = await promptInteger({
title: label,
inputLabel: t("departments.customer_pricing.discount"),
inputValue: override.percentage > 0 ? override.percentage : "",
allowEmpty: true,
max: 100,
});
if (value === undefined) {
return;
}
await saveOverride({
...override,
percentage: value ?? 0,
});
};
const editFixedPrice = async (product) => {
const override = getOverride(false, product.id);
const value = await promptInteger({
title: product.name,
inputLabel: t("departments.customer_pricing.fixed_price"),
inputValue: override.fixed_price === null ? "" : override.fixed_price,
allowEmpty: true,
max: null,
});
if (value === undefined) {
return;
}
await saveOverride({
...override,
fixed_price: value,
});
};
watch(
() => props.initialCustomerNumber,
(value) => {
customerNumber.value = String(value || "");
if (canLoadPricing.value) {
void loadPricing();
}
},
{ immediate: true }
);
watch(
() => [props.departmentId, props.customPricingEnabled, props.canRead],
() => {
if (canLoadPricing.value) {
void loadPricing();
} else if (!props.customPricingEnabled || !props.canRead) {
resetPricingData();
}
}
);
</script>
<template>
<section class="department-customer-pricing" data-testid="department-customer-pricing-editor">
<div v-if="!props.canRead" class="notification is-danger is-light" data-testid="department-customer-pricing-forbidden">
{{ t("departments.customer_pricing.no_permission") }}
</div>
<div
v-else-if="!props.customPricingEnabled"
class="notification is-warning is-light"
data-testid="department-customer-pricing-disabled"
>
{{ t("departments.customer_pricing.not_enabled") }}
</div>
<template v-else>
<div class="department-customer-pricing__toolbar">
<div class="field department-customer-pricing__customer-field">
<label class="label" for="department-customer-pricing-customer-number">
{{ t("departments.customer_pricing.customer_number") }}
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
id="department-customer-pricing-customer-number"
v-model="customerNumber"
class="input"
type="number"
min="1"
step="1"
inputmode="numeric"
:placeholder="t('departments.customer_pricing.customer_number')"
data-testid="department-customer-pricing-customer-number"
@keydown.enter.prevent="loadPricing"
/>
</div>
<div class="control">
<b-tooltip :label="t('departments.customer_pricing.load_customer')" position="is-bottom" type="is-dark">
<button
class="button is-info"
type="button"
:disabled="!canLoadPricing || loading"
data-testid="department-customer-pricing-load"
@click="loadPricing"
>
<span class="icon is-small">
<i class="fas" :class="loading ? 'fa-spinner fa-spin' : 'fa-search'" aria-hidden="true"></i>
</span>
</button>
</b-tooltip>
</div>
</div>
</div>
</div>
<div v-if="loading" class="notification is-light" data-testid="department-customer-pricing-loading">
{{ t("common.loading") }}
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-customer-pricing-error">
{{ errorMessage }}
</div>
<div v-if="pricingData" class="department-customer-pricing__content">
<div class="level department-customer-pricing__summary">
<div class="level-left">
<div>
<h2 class="title is-5" data-testid="department-customer-pricing-customer">
{{ customerDisplay }}
</h2>
<p class="subtitle is-6">{{ pricingData.department?.name }}</p>
</div>
</div>
<div class="level-right">
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_global_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
data-testid="department-customer-pricing-global-discount"
@click="editDiscount(true, 'global', t('departments.customer_pricing.global_discount'))"
>
<span class="icon is-small"><i class="fas fa-percent" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, "global")) }}</span>
</button>
</span>
</b-tooltip>
</div>
</div>
<div v-if="saving" class="notification is-light py-2" role="status" data-testid="department-customer-pricing-saving">
{{ t("departments.customer_pricing.saving") }}
</div>
<div v-if="!hasProducts" class="notification is-light">
{{ t("departments.customer_pricing.no_products") }}
</div>
<div v-for="category in categories" :key="category.id" class="department-customer-pricing__category">
<div class="department-customer-pricing__category-header">
<h3 class="title is-6">{{ category.name }}</h3>
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_category_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-category-discount-${category.id}`"
@click="editDiscount(true, category.id, category.name)"
>
<span class="icon is-small"><i class="fas fa-tags" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, category.id)) }}</span>
</button>
</span>
</b-tooltip>
</div>
<div class="table-container">
<table class="table is-fullwidth is-hoverable is-striped department-customer-pricing__table">
<thead>
<tr>
<th>{{ t("common.product") }}</th>
<th>{{ t("departments.customer_pricing.department_price") }}</th>
<th>{{ t("departments.customer_pricing.fixed_price") }}</th>
<th>{{ t("departments.customer_pricing.item_discount") }}</th>
<th>{{ t("departments.customer_pricing.effective_price") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="product in category.products"
:key="product.id"
:data-testid="`department-customer-pricing-product-${product.id}`"
>
<td>
<strong>{{ product.name }}</strong>
<p v-if="product.description" class="is-size-7 has-text-grey">{{ product.description }}</p>
</td>
<td :class="{ 'has-text-danger has-text-weight-semibold': product.missing_department_price }">
{{ formatPrice(product.department_price) }}
</td>
<td>
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_fixed_price') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
<span>
<button
class="button is-small is-white department-customer-pricing__cell-button"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-fixed-price-${product.id}`"
@click="editFixedPrice(product)"
>
<span>{{ getFixedPriceDisplay(getOverride(false, product.id)) }}</span>
<span class="icon is-small"><i class="fas fa-edit" aria-hidden="true"></i></span>
</button>
</span>
</b-tooltip>
</td>
<td>
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_item_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
<span>
<button
class="button is-small is-white department-customer-pricing__cell-button"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-item-discount-${product.id}`"
@click="editDiscount(false, product.id, product.name)"
>
<span>{{ getDiscountDisplay(getOverride(false, product.id)) }}</span>
<span class="icon is-small"><i class="fas fa-edit" aria-hidden="true"></i></span>
</button>
</span>
</b-tooltip>
</td>
<td>{{ formatPrice(product.effective_price) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.department-customer-pricing {
display: flex;
flex-direction: column;
gap: 1rem;
}
.department-customer-pricing__toolbar,
.department-customer-pricing__summary,
.department-customer-pricing__category-header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.department-customer-pricing__customer-field {
max-width: 420px;
width: 100%;
}
.department-customer-pricing__content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.department-customer-pricing__category {
margin-top: 0.5rem;
}
.department-customer-pricing__category-header {
align-items: center;
margin-bottom: 0.5rem;
}
.department-customer-pricing__category-header .title {
margin-bottom: 0;
}
.department-customer-pricing__table th,
.department-customer-pricing__table td {
vertical-align: middle;
}
.department-customer-pricing__cell-button {
justify-content: space-between;
min-width: 7rem;
width: 100%;
}
@media screen and (max-width: 768px) {
.department-customer-pricing__toolbar,
.department-customer-pricing__summary,
.department-customer-pricing__category-header {
align-items: stretch;
flex-direction: column;
}
}
</style>
@@ -16,6 +16,7 @@ import InvoicesTable from "@/components/displays/user/invoices/invoicesTable.vue
import CollectedOrderInvoicesTable from "@/components/displays/superuser/tables/collectedOrderInvoicesTable.vue";
import CollectedOrderInvoicesPagination
from "@/components/displays/pagination/models/SuperUserDashboard/CollectedOrderInvoicesPagination.vue";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({
customer_id: {
type: Number,
@@ -172,7 +173,7 @@ const tabs = ref([
<div class="field">
<label class="label">{{SessionUser.objects.global.language.select}} {{SessionUser.objects.global.language.invoice.toLowerCase()}} {{SessionUser.objects.global.language.time.date.toLowerCase()}}</label>
<div class="control">
<input class="input" type="date" v-model="selectedDate" />
<BuefyDateField v-model="selectedDate" value-type="string" data-testid="invoice-collection-date" />
</div>
</div>
<!-- Create invoice collection button -->
@@ -81,7 +81,7 @@ const props = defineProps({
}
})
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
import { computed, ref, provide } from "vue";
import { computed, ref, provide, watch } from "vue";
import { useRouter } from "vue-router";
import {
usePaginatedList,
@@ -100,6 +100,11 @@ const {
setPage,
search,
endpoint,
filter,
metaSearch,
additionalQueryParameters,
orderBy,
orderDirection,
setFilter,
setAdditionalQueryParameters,
setOrder,
@@ -109,8 +114,15 @@ 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 BuefyDateField from "@/components/forms/BuefyDateField.vue";
import { useI18n } from 'vue-i18n'
import { formatLocalDateOnly, parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
buildOrderDateEventRequestParams,
buildOrderDateEvents,
stripOrderDateFilters,
} from "@/services/orderDateEvents.js";
const { t } = useI18n()
const router = useRouter();
@@ -173,24 +185,101 @@ if (props.autoLoad) {
loadList();
}
const date_from = ref(null);
const date_to = ref(null);
const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
const formattedStartDate = formatLocalDateOnly(newSelectionStartDate);
const formattedEndDate = formatLocalDateOnly(newSelectionToDate);
date_from.value = formattedStartDate;
date_to.value = formattedEndDate;
setFilter("created_at-date_from", formattedStartDate, true);
setFilter("created_at-date_to", formattedEndDate, true);
loadList();
const date_from = ref(props.dates?.dateFrom ?? null);
const date_to = ref(props.dates?.dateTo ?? null);
const orderDateEvents = ref([]);
const orderDateEventRequestId = ref(0);
const orderDateEventSignature = computed(() => JSON.stringify({
endpoint: endpoint.value,
filters: stripOrderDateFilters(filter.value),
search: metaSearch.value,
additionalQueryParameters: additionalQueryParameters.value,
}));
const setDateFilter = (filterKey, value, autoLoad = true) => {
setFilter(filterKey, value || "*", autoLoad);
};
const onDateFilterChange = (filterKey, value) => {
setDateFilter(filterKey, value, true);
};
const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
const formattedStartDate = newSelectionStartDate ? formatLocalDateOnly(newSelectionStartDate) : "";
const formattedEndDate = newSelectionToDate ? formatLocalDateOnly(newSelectionToDate) : "";
date_from.value = formattedStartDate;
date_to.value = formattedEndDate;
setDateFilter("created_at-date_from", formattedStartDate, false);
setDateFilter("created_at-date_to", formattedEndDate, false);
loadList();
};
const doesEndpointMatch = (matcher) => {
// Check if the endpoint matches the current endpoint
return endpoint.value === matcher;
}
const loadOrderDateEvents = async () => {
if (!doesEndpointMatch("/orders")) {
orderDateEvents.value = [];
return;
}
const requestId = orderDateEventRequestId.value + 1;
orderDateEventRequestId.value = requestId;
try {
const rows = [];
let page = 1;
let totalPages = 1;
const limit = 1000;
while (page <= totalPages) {
const response = await authenticatedRequest(
"/orders",
"GET",
buildOrderDateEventRequestParams({
filters: filter.value,
search: metaSearch.value,
additionalQueryParameters: additionalQueryParameters.value,
orderBy: orderBy.value,
orderDirection: orderDirection.value,
page,
limit,
})
);
if (requestId !== orderDateEventRequestId.value) {
return;
}
const pageRows = Array.isArray(response?.data?.data) ? response.data.data : [];
rows.push(...pageRows);
const pagination = response?.data?.meta?.pagination || {};
const perPage = Number.parseInt(pagination.per_page, 10) || limit;
const total = Number.parseInt(pagination.total, 10);
totalPages = Number.isFinite(total) && total > 0
? Math.max(1, Math.ceil(total / Math.max(1, perPage)))
: 1;
page += 1;
}
if (requestId === orderDateEventRequestId.value) {
orderDateEvents.value = buildOrderDateEvents(rows);
}
} catch (error) {
if (requestId === orderDateEventRequestId.value) {
console.warn("Unable to load order date events", error);
orderDateEvents.value = [];
}
}
};
watch(orderDateEventSignature, () => {
loadOrderDateEvents();
}, { immediate: true });
</script>
<template>
@@ -345,14 +434,30 @@ const doesEndpointMatch = (matcher) => {
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.from_date') }}</label>
<div class="control">
<input type="date" class="input" @change="setFilter('created_at-date_from', $event.target.value, true)" v-model="date_from" />
<BuefyDateField
v-model="date_from"
value-type="string"
data-testid="invoice-orders-date-from"
clearable
:events="orderDateEvents"
indicators="dots"
@change="(value) => onDateFilterChange('created_at-date_from', value)"
/>
</div>
</div>
<!-- Date to -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.to_date') }}</label>
<div class="control">
<input type="date" class="input" @change="setFilter('created_at-date_to', $event.target.value, true)" v-model="date_to" />
<BuefyDateField
v-model="date_to"
value-type="string"
data-testid="invoice-orders-date-to"
clearable
:events="orderDateEvents"
indicators="dots"
@change="(value) => onDateFilterChange('created_at-date_to', value)"
/>
</div>
</div>
</template>
@@ -361,7 +466,9 @@ 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:allow-empty-selection="true"
v-bind:events="orderDateEvents"
v-bind:selection="{ startDate: date_from ? parseLocalDateOnly(date_from) : null, endDate: date_to ? parseLocalDateOnly(date_to) : null }"/>
</div>
</template>
<template #default>
@@ -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 BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({
label: {
type: String,
@@ -19,27 +18,19 @@ defineExpose({
date,
});
const formattedDate = computed(() => {
return formatLocalDateOnly(date.value);
});
// This component is used to display a date input in a pagination display item column.
// It allows the user to select a date, which will be emitted to the parent component.
// The date is displayed in a YYYY-MM-DD format, which is compatible with the HTML date
// input type.
const updateDate = (newDate: Date) => {
emit('update:date', newDate);
};
</script>
<template>
<PaginationDisplayItemColumn :label="props.label">
<template #control>
<input
type="date"
class="input"
:value="formattedDate"
@input="(e) => {
const newDate = parseLocalDateOnly(e.target.value);
emit('update:date', newDate);
}"
<BuefyDateField
v-model="date"
value-type="date"
data-testid="pagination-date-picker"
@change="updateDate"
/>
</template>
</PaginationDisplayItemColumn>
@@ -0,0 +1,72 @@
<script setup>
const props = defineProps({
items: {
type: Array,
required: true,
},
});
</script>
<template>
<div class="superuser-overview-action-grid">
<slot name="before"></slot>
<template v-for="item in props.items" :key="item.key">
<router-link
v-if="item.to"
class="superuser-overview-action-grid__item"
:to="item.to"
:data-testid="item.testId"
>
<span class="icon" v-if="item.icon"><i :class="item.icon" /></span>
<span>{{ item.label }}</span>
</router-link>
<button
v-else
type="button"
class="superuser-overview-action-grid__item"
:data-testid="item.testId"
@click="item.onClick?.()"
>
<span class="icon" v-if="item.icon"><i :class="item.icon" /></span>
<span>{{ item.label }}</span>
</button>
</template>
<slot name="after"></slot>
</div>
</template>
<style scoped>
.superuser-overview-action-grid {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.superuser-overview-action-grid__item {
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;
}
.superuser-overview-action-grid__item:hover,
.superuser-overview-action-grid__item:focus {
border-color: #0f766e;
color: #0f766e;
}
@media (max-width: 640px) {
.superuser-overview-action-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,75 @@
<script setup>
const props = defineProps({
rows: {
type: Array,
required: true,
},
});
</script>
<template>
<dl class="superuser-overview-definition-list">
<div v-for="row in props.rows" :key="row.key || row.label" class="superuser-overview-definition-list__row">
<dt>{{ row.label }}</dt>
<dd>
<b-tooltip
v-if="row.tooltip"
:label="row.tooltip"
multilined
position="is-bottom"
type="is-dark"
>
<span>{{ row.value }}</span>
</b-tooltip>
<span v-else>{{ row.value }}</span>
</dd>
</div>
</dl>
</template>
<style scoped>
.superuser-overview-definition-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
}
.superuser-overview-definition-list__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;
}
.superuser-overview-definition-list__row dt {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.superuser-overview-definition-list__row dd {
color: #0f172a;
font-weight: 800;
margin: 0;
max-width: 58%;
overflow-wrap: anywhere;
text-align: right;
}
@media (max-width: 640px) {
.superuser-overview-definition-list__row {
align-items: flex-start;
flex-direction: column;
gap: 0.35rem;
}
.superuser-overview-definition-list__row dd {
max-width: 100%;
text-align: left;
}
}
</style>
@@ -0,0 +1,95 @@
<script setup>
const props = defineProps({
icon: {
type: String,
default: "",
},
label: {
type: String,
required: true,
},
value: {
type: [String, Number],
required: true,
},
secondary: {
type: String,
default: "",
},
status: {
type: String,
default: "",
},
tone: {
type: String,
default: "",
},
});
</script>
<template>
<article class="superuser-overview-metric-card">
<div class="superuser-overview-metric-card__icon" v-if="props.icon">
<i :class="props.icon" />
</div>
<div class="superuser-overview-metric-card__content">
<div class="superuser-overview-metric-card__top">
<span class="superuser-overview-metric-card__label">{{ props.label }}</span>
<span v-if="props.status" class="tag is-light" :class="props.tone">{{ props.status }}</span>
</div>
<strong class="superuser-overview-metric-card__value">{{ props.value }}</strong>
<span v-if="props.secondary" class="superuser-overview-metric-card__secondary">{{ props.secondary }}</span>
</div>
</article>
</template>
<style scoped>
.superuser-overview-metric-card {
align-items: center;
background: #ffffff;
border: 1px solid #dbe3ec;
border-radius: 8px;
display: flex;
gap: 0.75rem;
min-height: 6rem;
padding: 1rem;
}
.superuser-overview-metric-card__icon {
align-items: center;
background: #ecfeff;
border-radius: 8px;
color: #0f766e;
display: inline-flex;
height: 2.5rem;
justify-content: center;
width: 2.5rem;
}
.superuser-overview-metric-card__content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.superuser-overview-metric-card__top {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: space-between;
}
.superuser-overview-metric-card__label,
.superuser-overview-metric-card__secondary {
color: #64748b;
font-size: 0.82rem;
}
.superuser-overview-metric-card__value {
color: #0f172a;
font-size: 1.35rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
</style>
@@ -0,0 +1,87 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
default: "",
},
count: {
type: [String, Number],
default: null,
},
});
</script>
<template>
<section class="superuser-overview-panel">
<header class="superuser-overview-panel__header">
<div class="superuser-overview-panel__copy">
<h2>{{ props.title }}</h2>
<p v-if="props.subtitle">{{ props.subtitle }}</p>
</div>
<div class="superuser-overview-panel__actions">
<span v-if="props.count !== null && props.count !== undefined" class="superuser-overview-panel__count">
{{ props.count }}
</span>
<slot name="actions"></slot>
</div>
</header>
<div class="superuser-overview-panel__content">
<slot />
</div>
</section>
</template>
<style scoped>
.superuser-overview-panel {
background: #ffffff;
border: 1px solid #dbe3ec;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.superuser-overview-panel__header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.superuser-overview-panel__copy h2 {
color: #0f172a;
font-size: 1.05rem;
font-weight: 800;
margin: 0;
}
.superuser-overview-panel__copy p {
color: #64748b;
font-size: 0.82rem;
margin: 0.2rem 0 0;
}
.superuser-overview-panel__actions {
align-items: center;
display: flex;
gap: 0.5rem;
}
.superuser-overview-panel__count {
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;
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script setup>
import { computed, useAttrs } from "vue";
import { useI18n } from "vue-i18n";
import { BDatepicker } from "buefy";
import {
formatDatepickerDateForApi,
formatDatepickerDateForLocale,
normalizeDatepickerDate,
parseDatepickerInput,
} from "@/services/buefyDatepicker.js";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: {
type: [String, Date],
default: "",
},
valueType: {
type: String,
default: "string",
validator: (value) => ["string", "date", "nullable-string"].includes(value),
},
placeholder: {
type: String,
default: "",
},
icon: {
type: String,
default: "calendar",
},
disabled: Boolean,
readonly: Boolean,
required: Boolean,
expanded: {
type: Boolean,
default: true,
},
appendToBody: {
type: Boolean,
default: true,
},
position: {
type: String,
default: "is-bottom-left",
},
openOnFocus: {
type: Boolean,
default: true,
},
clearable: Boolean,
minDate: {
type: Date,
default: null,
},
maxDate: {
type: Date,
default: null,
},
unselectableDates: {
type: [Array, Function],
default: null,
},
selectableDates: {
type: [Array, Function],
default: null,
},
events: {
type: Array,
default: () => [],
},
indicators: {
type: String,
default: "dots",
},
dataTestid: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "change"]);
const { locale } = useI18n({ useScope: "global" });
const attrs = useAttrs();
const datepickerTestId = computed(() => props.dataTestid || attrs["data-testid"] || undefined);
const selectedDate = computed({
get: () => normalizeDatepickerDate(props.modelValue),
set: (value) => {
const normalized = normalizeDatepickerDate(value);
let nextValue = "";
if (props.valueType === "date") {
nextValue = normalized;
} else if (props.valueType === "nullable-string") {
nextValue = normalized ? formatDatepickerDateForApi(normalized) : null;
} else {
nextValue = normalized ? formatDatepickerDateForApi(normalized) : "";
}
emit("update:modelValue", nextValue);
emit("change", nextValue);
},
});
const showClearIcon = computed(() => (
props.clearable
&& selectedDate.value !== null
&& !props.disabled
&& !props.readonly
));
const clearSelectedDate = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
selectedDate.value = null;
};
const formatter = (value) => formatDatepickerDateForLocale(value, locale.value);
const parser = (value) => parseDatepickerInput(value, locale.value);
</script>
<template>
<div :data-testid="datepickerTestId">
<BDatepicker
v-bind="attrs"
v-model="selectedDate"
icon-pack="fas"
:icon="icon"
:locale="locale"
:placeholder="placeholder"
:position="position"
:open-on-focus="openOnFocus"
:disabled="disabled"
:readonly="readonly"
:required="required"
:expanded="expanded"
:append-to-body="appendToBody"
:min-date="minDate"
:max-date="maxDate"
:unselectable-dates="unselectableDates"
:selectable-dates="selectableDates"
:events="events"
:indicators="indicators"
:icon-right="showClearIcon ? 'times-circle' : undefined"
:icon-right-clickable="showClearIcon"
:mobile-native="false"
:editable="true"
:date-formatter="formatter"
:date-parser="parser"
@icon-right-click="clearSelectedDate"
/>
</div>
</template>
+151
View File
@@ -0,0 +1,151 @@
<script setup>
import { computed, useAttrs } from "vue";
import { useI18n } from "vue-i18n";
import { BDatepicker } from "buefy";
import {
formatDatepickerMonthForApi,
formatDatepickerMonthForLocale,
normalizeDatepickerMonth,
parseMonthpickerInput,
} from "@/services/buefyDatepicker.js";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: {
type: [String, Date],
default: "",
},
valueType: {
type: String,
default: "string",
validator: (value) => ["string", "date", "nullable-string"].includes(value),
},
placeholder: {
type: String,
default: "",
},
disabled: Boolean,
readonly: Boolean,
expanded: {
type: Boolean,
default: true,
},
appendToBody: {
type: Boolean,
default: true,
},
position: {
type: String,
default: "is-bottom-left",
},
openOnFocus: {
type: Boolean,
default: true,
},
clearable: Boolean,
minDate: {
type: Date,
default: null,
},
maxDate: {
type: Date,
default: null,
},
unselectableDates: {
type: [Array, Function],
default: null,
},
selectableDates: {
type: [Array, Function],
default: null,
},
events: {
type: Array,
default: () => [],
},
indicators: {
type: String,
default: "dots",
},
dataTestid: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "change"]);
const { locale } = useI18n({ useScope: "global" });
const attrs = useAttrs();
const datepickerTestId = computed(() => props.dataTestid || attrs["data-testid"] || undefined);
const selectedMonth = computed({
get: () => normalizeDatepickerMonth(props.modelValue),
set: (value) => {
const normalized = normalizeDatepickerMonth(value);
let nextValue = "";
if (props.valueType === "date") {
nextValue = normalized;
} else if (props.valueType === "nullable-string") {
nextValue = normalized ? formatDatepickerMonthForApi(normalized) : null;
} else {
nextValue = normalized ? formatDatepickerMonthForApi(normalized) : "";
}
emit("update:modelValue", nextValue);
emit("change", nextValue);
},
});
const showClearIcon = computed(() => (
props.clearable
&& selectedMonth.value !== null
&& !props.disabled
&& !props.readonly
));
const clearSelectedMonth = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
selectedMonth.value = null;
};
const formatter = (value) => formatDatepickerMonthForLocale(value, locale.value);
const parser = (value) => parseMonthpickerInput(value, locale.value);
</script>
<template>
<div :data-testid="datepickerTestId">
<BDatepicker
v-bind="attrs"
v-model="selectedMonth"
type="month"
icon-pack="fas"
icon="calendar"
:locale="locale"
:placeholder="placeholder"
:position="position"
:open-on-focus="openOnFocus"
:disabled="disabled"
:readonly="readonly"
:expanded="expanded"
:append-to-body="appendToBody"
:min-date="minDate"
:max-date="maxDate"
:unselectable-dates="unselectableDates"
:selectable-dates="selectableDates"
:events="events"
:indicators="indicators"
:icon-right="showClearIcon ? 'times-circle' : undefined"
:icon-right-clickable="showClearIcon"
:mobile-native="false"
:editable="true"
:date-formatter="formatter"
:date-parser="parser"
@icon-right-click="clearSelectedMonth"
/>
</div>
</template>
+66 -28
View File
@@ -1,37 +1,64 @@
<script>
import { ref } from 'vue'
import { editUser } from '@/components/session/Session.vue'
import Swal from "sweetalert2";
//
import { editUser } from "@/components/session/Session.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import i18n from "@/i18n";
const t = (key, values = {}) => i18n.global.t(key, values);
const escapeHtml = (value) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const buildRoleOptions = (roles, selectedRoleId) =>
roles
.map((item) => {
const roleId = String(item?.id ?? "");
const roleName = item?.name || `#${roleId}`;
const selected = roleId === String(selectedRoleId ?? "") ? " selected" : "";
return `<option value="${escapeHtml(roleId)}"${selected}>${escapeHtml(roleName)}</option>`;
})
.join("");
export const showEditUserForm = async (id, customer_number, display_name, role, options = {}) => {
const lockedRoleFields = options.limitedBackofficeManaged ? 'disabled' : '';
const isRoleLocked = Boolean(options.limitedBackofficeManaged);
const lockedRoleFields = isRoleLocked ? "disabled" : "";
await Swal.fire({
title: 'Edit User',
const roles = await SessionUser.objects.roles.get.all().catch(() => []);
const result = await Swal.fire({
title: t("user_admin.edit_user"),
html: `
<form id="editNumberPlateScannerForm">
<div class="field">
<label class="label">Customer Number</label>
<div class="field has-text-left">
<label class="label" for="customer_number">${escapeHtml(t("user_admin.customer_number"))}</label>
<div class="control">
<input class="input" type="text" id="customer_number" value="${customer_number}" ${lockedRoleFields} />
<input class="input" type="text" id="customer_number" value="${escapeHtml(customer_number)}" ${lockedRoleFields} />
</div>
</div>
<div class="field">
<label class="label">Display Name</label>
<div class="field has-text-left">
<label class="label" for="display_name">${escapeHtml(t("common.name"))}</label>
<div class="control">
<input class="input" type="text" id="display_name" value="${display_name}" />
<input class="input" type="text" id="display_name" value="${escapeHtml(display_name)}" />
</div>
</div>
<div class="field">
<label class="label">Role</label>
<div class="field has-text-left">
<label class="label" for="role">${escapeHtml(t("user_admin.group_id"))}</label>
<div class="control">
<input class="input" type="text" id="role" value="${role}" ${lockedRoleFields} />
<div class="select is-fullwidth ${roles.length === 0 ? "is-loading" : ""}">
<select id="role" ${lockedRoleFields}>
${buildRoleOptions(Array.isArray(roles) ? roles : [], role)}
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="field has-text-left">
<label class="label" for="password">${escapeHtml(t("global.password"))}</label>
<div class="control">
<input class="input" type="password" id="password" />
</div>
@@ -39,17 +66,28 @@ export const showEditUserForm = async (id, customer_number, display_name, role,
</form>
`,
showCancelButton: true,
confirmButtonText: 'Edit User',
preConfirm: () => {
editUser(id, document.getElementById('customer_number').value, document.getElementById('display_name').value, document.getElementById('role').value, document.getElementById('password').value)
.then(() => {
Swal.fire('User edited successfully');
})
.catch((e) => {
Swal.fire('Error', e.response.data.error ?? e.response, 'error');
});
}
confirmButtonText: t("user_admin.edit_user"),
preConfirm: async () => {
try {
await editUser(
id,
document.getElementById("customer_number").value,
document.getElementById("display_name").value,
document.getElementById("role").value,
document.getElementById("password").value
);
return true;
} catch (error) {
Swal.showValidationMessage(
error?.response?.data?.error ?? error?.response?.data?.message ?? error?.response ?? t("common.unknown_error")
);
return false;
}
},
});
}
if (result.isConfirmed) {
await Swal.fire(t("user_admin.user_edited_successfully"));
}
};
</script>
+133 -1
View File
@@ -2,11 +2,14 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import axios from "axios";
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import i18n from "@/i18n";
import router from "@/router";
import {
buildReleaseSessionSummary,
getReleaseRuntimeApiBaseUrl,
resolveReleaseApiUrl,
} from "@/services/releaseTimeline.js";
import { buildRequestErrorTraceText } from "@/services/requestErrorTrace.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
clearErrorRequests,
@@ -54,10 +57,22 @@ const pingLatencyMs = ref(null);
const pingIsUnavailable = ref(false);
const lastShiftKeyPressedAtMs = ref(0);
const shiftKeyPressCount = ref(0);
const copyTraceStatusByRequestId = ref({});
let tickerTimer = null;
let pingTimer = null;
const LABEL_FALLBACKS = Object.freeze({
"maintenance_menu.copy_trace": "Copy trace",
"maintenance_menu.copy_trace_copied": "Copied",
"maintenance_menu.copy_trace_failed": "Copy failed",
});
const tr = (key) => {
const value = i18n.global.t(key);
return value === key ? LABEL_FALLBACKS[key] || key : value;
};
const hasOutstandingRequests = computed(() => requestQueueState.pending + requestQueueState.active > 0);
const processedRequests = computed(() => requestQueueState.batchCompleted + requestQueueState.batchFailed);
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
@@ -547,12 +562,87 @@ const handleWindowKeydown = (event) => {
const handleClearErrors = () => {
clearErrorRequests();
copyTraceStatusByRequestId.value = {};
};
const handleClearMissingPermissions = () => {
clearMissingPermissions();
};
const getCurrentRouteContext = () => {
const route = router?.currentRoute?.value || null;
return {
name: route?.name ? String(route.name) : "",
fullPath: route?.fullPath || (typeof window !== "undefined" ? window.location.pathname : ""),
path: route?.path || (typeof window !== "undefined" ? window.location.pathname : ""),
};
};
const writeClipboardText = async (text) => {
if (typeof navigator !== "undefined" && typeof navigator.clipboard?.writeText === "function") {
await navigator.clipboard.writeText(text);
return;
}
if (typeof document === "undefined" || typeof document.execCommand !== "function") {
throw new Error("Clipboard API is unavailable.");
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "readonly");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
const copied = document.execCommand("copy");
if (!copied) {
throw new Error("Clipboard copy failed.");
}
} finally {
textarea.remove();
}
};
const setCopyTraceStatus = (requestId, status) => {
copyTraceStatusByRequestId.value = {
...copyTraceStatusByRequestId.value,
[String(requestId)]: status,
};
};
const getCopyTraceStatus = (request) => copyTraceStatusByRequestId.value[String(request?.id)] || "idle";
const getCopyTraceLabel = (request) => {
const status = getCopyTraceStatus(request);
if (status === "copied") {
return tr("maintenance_menu.copy_trace_copied");
}
if (status === "failed") {
return tr("maintenance_menu.copy_trace_failed");
}
return tr("maintenance_menu.copy_trace");
};
const handleCopyTrace = async (request) => {
if (!request?.id) {
return;
}
try {
const traceText = buildRequestErrorTraceText(request, {
routeContext: getCurrentRouteContext(),
});
await writeClipboardText(traceText);
setCopyTraceStatus(request.id, "copied");
} catch (error) {
console.warn("Failed to copy request error trace", error);
setCopyTraceStatus(request.id, "failed");
}
};
const getPermissionGrantStatus = (permission) =>
permissionGrantStatusByKey.value[String(permission || "")] || "idle";
@@ -682,6 +772,18 @@ onBeforeUnmount(() => {
<span class="request-queue-progress__error-meta">
{{ request.statusCode || "n/a" }} - {{ formatDuration(request.requestDurationMs) }}
</span>
<button
class="request-queue-progress__copy-trace-button"
:class="{
'request-queue-progress__copy-trace-button--copied': getCopyTraceStatus(request) === 'copied',
'request-queue-progress__copy-trace-button--failed': getCopyTraceStatus(request) === 'failed',
}"
:data-testid="`request-queue-copy-trace-${request.id}`"
type="button"
@click="handleCopyTrace(request)"
>
{{ getCopyTraceLabel(request) }}
</button>
</div>
<details class="request-queue-progress__error-details">
@@ -1281,7 +1383,7 @@ onBeforeUnmount(() => {
.request-queue-progress__error-summary {
display: grid;
grid-template-columns: 110px 1fr auto;
grid-template-columns: 110px minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
font-size: 12px;
@@ -1292,6 +1394,36 @@ onBeforeUnmount(() => {
color: rgba(255, 255, 255, 0.75);
}
.request-queue-progress__copy-trace-button {
border: 1px solid rgba(125, 211, 252, 0.55);
background: rgba(14, 165, 233, 0.16);
color: #e0f2fe;
border-radius: 999px;
font-size: 11px;
line-height: 1;
padding: 4px 8px;
cursor: pointer;
white-space: nowrap;
}
.request-queue-progress__copy-trace-button:hover,
.request-queue-progress__copy-trace-button:focus-visible {
background: rgba(14, 165, 233, 0.28);
border-color: rgba(125, 211, 252, 0.85);
}
.request-queue-progress__copy-trace-button--copied {
border-color: rgba(74, 222, 128, 0.65);
background: rgba(22, 163, 74, 0.2);
color: #dcfce7;
}
.request-queue-progress__copy-trace-button--failed {
border-color: rgba(248, 113, 113, 0.7);
background: rgba(220, 38, 38, 0.2);
color: #fee2e2;
}
.request-queue-progress__error-details {
margin-top: 4px;
}
+9 -2
View File
@@ -3,8 +3,10 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import i18n from "@/i18n";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -145,6 +147,11 @@ const checkEmphasis = (forcefully = false) => {
}
};
const getBookingsLabel = () =>
t("nav.bookings")
|| SessionUser.objects.order_bookings?.meta?.title
|| "Bookings";
checkEmphasis(); // Initial check for emphasis
// Call the checkEmphasis function on mounted
@@ -228,7 +235,7 @@ const menu_items = ref([
hidden: SessionUser.functions.getDepartmentIdFromUrl() === undefined,
},
{
name: "Bookinger",
name: getBookingsLabel(),
route: "/admin/" + SessionUser.functions.getDepartmentIdFromUrl() + "/modules/bookings",
icon: "fas fa-exchange-alt",
children: [],
@@ -250,7 +257,7 @@ const menu_items = ref([
permissions: ["department_timebookings_create"],
},
{
name: "Bookinger",
name: getBookingsLabel(),
route: "/admin/" + SessionUser.functions.getDepartmentIdFromUrl() + "/modules/time-bookings",
icon: "fas fa-exchange-alt",
children: [],
+1 -1
View File
@@ -279,7 +279,7 @@ export function usePaginatedList() {
filter.value = filter.value ? `${filter.value},${newFilter}:${value}` : `${newFilter}:${value}`;
}
// Remove duplicates
filter.value = [...new Set(filter.value.split(','))].join(',');
filter.value = filter.value ? [...new Set(filter.value.split(','))].join(',') : null;
// If the filter is empty, set it to null
if (filter.value === '') {
filter.value = null;
@@ -218,6 +218,7 @@ const buildRequestQueueOptions = (url, method, options = {}) => {
recordRecentOnSuccess: options?.recordRecentOnSuccess,
trackActiveRequest: options?.trackActiveRequest,
trackProgressCounters: options?.trackProgressCounters,
traceContext: options?.traceContext,
};
if (isSelfServeHardwareMutation(url, method)) {
@@ -1,291 +1,150 @@
<script>
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import i18n from "@/i18n";
const t = (key) => i18n.global.t(key);
/**
* The Branding object
*/
export const Branding = {
meta: {
title: t("objects.branding.title"),
icon: "fas fa-list",
description: t("objects.branding.description"),
endpoint: "/branding",
labels: {
single: t("objects.branding.single"),
multiple: t("objects.branding.multiple"),
},
},
columns: {
id: {
label: t("objects.columns.id"),
type: "number",
sortable: true,
creation: { required: false },
},
name: {
label: t("objects.columns.name"),
type: "string",
sortable: true,
creation: { required: true },
},
description: {
label: t("objects.columns.description"),
type: "string",
sortable: true,
creation: { required: true },
},
cvr: {
label: t("user_admin.cvr"),
type: "number",
sortable: true,
creation: { required: true },
},
address: {
label: t("objects.columns.address"),
type: "string",
sortable: true,
creation: { required: false },
},
phone_country_code: {
label: t("global.phone_country_code"),
type: "number",
sortable: true,
creation: { required: false },
},
phone: {
label: t("objects.columns.phone"),
type: "number",
sortable: true,
creation: { required: false },
},
email: {
label: t("objects.columns.email"),
type: "string",
sortable: true,
creation: { required: false },
},
website: {
label: t("tables.common.website"),
type: "string",
sortable: true,
creation: { required: false },
},
banner: {
label: t("objects.columns.image"),
type: "string",
sortable: false,
creation: { required: false },
},
logo: {
label: t("objects.columns.image"),
type: "string",
sortable: false,
creation: { required: false },
},
favicon: {
label: t("objects.columns.image"),
type: "string",
sortable: false,
creation: { required: false },
},
signature: {
label: t("objects.columns.notes"),
type: "string",
sortable: false,
creation: { required: false },
},
},
add: async (brandingOrName, description = null, cvr = null) => {
const payload =
typeof brandingOrName === "object" && brandingOrName !== null
? brandingOrName
: {
name: brandingOrName,
description,
cvr,
};
/**
* The Branding object
*/
export const Branding = {
meta: {
title: "Branding",
icon: "fas fa-list",
description: "Oversigt over branding",
endpoint: "/branding",
labels: {
single: "branding",
multiple: "brands",
}
},
columns: {
id: {
label: "ID",
type: "number",
sortable: true,
creation: {
required: false
}
},
name: {
label: "Navn",
type: "string",
sortable: true,
creation: {
required: true
},
},
description: {
label: "Beskrivelse",
type: "string",
sortable: true,
creation: {
required: true
},
},
cvr: {
label: "CVR",
type: "number",
sortable: true,
creation: {
required: true
},
},
address: {
label: "Adresse",
type: "string",
sortable: true,
creation: {
required: false
},
},
phone_country_code: {
label: "Landekode",
type: "number",
sortable: true,
creation: {
required: false
},
},
phone: {
label: "Telefonnummer",
type: "number",
sortable: true,
creation: {
required: false
},
},
email: {
label: "Email",
type: "string",
sortable: true,
creation: {
required: false
},
},
website: {
label: "Hjemmeside",
type: "string",
sortable: true,
creation: {
required: false
},
},
banner: {
label: "Banner",
type: "string",
sortable: false,
creation: {
required: false
},
},
logo: {
label: "Logo",
type: "string",
sortable: false,
creation: {
required: false
},
},
favicon: {
label: "Favicon",
type: "string",
sortable: false,
creation: {
required: false
},
},
signature: {
label: "Signatur",
type: "string",
sortable: false,
creation: {
required: false
},
},
},
add: async (brandingOrName, description = null, cvr = null) => {
const payload = typeof brandingOrName === "object" && brandingOrName !== null
? brandingOrName
: {
name: brandingOrName,
description: description,
cvr: cvr
};
return ObjectsGlobal.add.object(
Branding.meta.endpoint,
payload
).then((response) => response.data.data);
},
set: {
all: async (id, branding) => {
ObjectsGlobal.clearCache();
return authenticatedRequest(
Branding.meta.endpoint,
"PUT",
{
id: parseInt(id),
...branding,
}
).then((response) => {
ObjectsGlobal.clearCache();
return response.data.data;
});
},
name: async (id, name) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"name",
name
)
},
description: async (id, description) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"description",
description
)
},
cvr: async (id, cvr) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"cvr",
parseInt(cvr)
)
},
address: async (id, address) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"address",
address
)
},
phone_country_code: async (id, phone_country_code) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"phone_country_code",
parseInt(phone_country_code)
)
},
phone: async (id, phone) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"phone",
parseInt(phone)
)
},
email: async (id, email) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"email",
email
)
},
website: async (id, website) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"website",
website
)
},
banner: async (id, banner) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"banner",
banner
)
},
logo: async (id, logo) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"logo",
logo
)
},
favicon: async (id, favicon) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"favicon",
favicon
)
},
signature: async (id, signature) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"signature",
signature
)
},
},
get: {
all: async () => {
return ObjectsGlobal.get.objects(Branding.meta.endpoint);
},
single: async (id) => {
return ObjectsGlobal.get.object(Branding.meta.endpoint, parseInt(id));
}
},
delete: async (id) => {
return ObjectsGlobal.delete.object(Branding.meta.endpoint, parseInt(id));
},
functions: {
},
/**
* Show the create object form
* @param onAfterSubmit
* @returns {Promise<SweetAlertResult<Awaited<any>>>}
*/
showCreateObjectForm: (onAfterSubmit = null) => {
return ObjectsGlobal.showCreateObjectForm(Branding, onAfterSubmit);
},
/**
* Show the edit object field form
* @param id
* @param column
* @param value
* @param onAfterSubmit
* @returns {Promise<SweetAlertResult<Awaited<any>>>}
*/
showEditObjectFieldForm: (id, column, value, onAfterSubmit = null) => {
return ObjectsGlobal.showEditObjectFieldForm(
Branding,
id,
column,
value,
onAfterSubmit
);
}
};
return ObjectsGlobal.add.object(Branding.meta.endpoint, payload).then((response) => response.data.data);
},
set: {
all: async (id, branding) => {
ObjectsGlobal.clearCache();
return authenticatedRequest(Branding.meta.endpoint, "PUT", {
id: parseInt(id, 10),
...branding,
}).then((response) => {
ObjectsGlobal.clearCache();
return response.data.data;
});
},
name: async (id, name) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "name", name),
description: async (id, description) =>
ObjectsGlobal.set.column(Branding.meta.endpoint, id, "description", description),
cvr: async (id, cvr) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "cvr", parseInt(cvr, 10)),
address: async (id, address) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "address", address),
phone_country_code: async (id, phone_country_code) =>
ObjectsGlobal.set.column(Branding.meta.endpoint, id, "phone_country_code", parseInt(phone_country_code, 10)),
phone: async (id, phone) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "phone", parseInt(phone, 10)),
email: async (id, email) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "email", email),
website: async (id, website) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "website", website),
banner: async (id, banner) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "banner", banner),
logo: async (id, logo) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "logo", logo),
favicon: async (id, favicon) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "favicon", favicon),
signature: async (id, signature) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "signature", signature),
},
get: {
all: async () => ObjectsGlobal.get.objects(Branding.meta.endpoint),
single: async (id) => ObjectsGlobal.get.object(Branding.meta.endpoint, parseInt(id, 10)),
},
delete: async (id) => ObjectsGlobal.delete.object(Branding.meta.endpoint, parseInt(id, 10)),
functions: {},
showCreateObjectForm: (onAfterSubmit = null) => ObjectsGlobal.showCreateObjectForm(Branding, onAfterSubmit),
showEditObjectFieldForm: (id, column, value, onAfterSubmit = null) =>
ObjectsGlobal.showEditObjectFieldForm(Branding, id, column, value, onAfterSubmit),
};
</script>
@@ -3,7 +3,7 @@ import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import {createApp, h, ref, watch} from "vue";
import i18n from '@/i18n';
import {
buildCollectedInvoiceEconomicPayload,
@@ -16,8 +16,8 @@ import {
retryEconomicTransferJob,
} from "@/services/economicTransferQueue.js";
import {createApp} from "vue";
import PickCustomerInvoiceCollectionModal from "@/components/displays/modals/PickCustomerInvoiceCollectionModal.vue";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const t = (key) => i18n.global.t(key);
@@ -177,16 +177,63 @@ const normalizeClosedAtForApi = (value) => {
return `${parsedDate.getFullYear()}-${padTwoDigits(parsedDate.getMonth() + 1)}-${padTwoDigits(parsedDate.getDate())}`;
};
const mountSweetAlertDateField = ({ mountId, inputId, initialValue, dataTestid }) => {
const mountElement = document.getElementById(mountId);
const inputElement = document.getElementById(inputId);
if (!mountElement || !inputElement) {
return {
cleanup: () => {},
setValue: () => {},
};
}
const value = ref(initialValue || "");
const app = createApp({
setup() {
watch(value, (nextValue) => {
inputElement.value = nextValue || "";
}, { immediate: true });
return () => h(BuefyDateField, {
modelValue: value.value,
valueType: "string",
dataTestid,
"onUpdate:modelValue": (nextValue) => {
value.value = nextValue || "";
},
});
},
});
app.use(i18n);
app.mount(mountElement);
return {
cleanup: () => {
app.unmount();
},
setValue: (nextValue) => {
const normalizedValue = nextValue || "";
value.value = normalizedValue;
inputElement.value = normalizedValue;
},
};
};
const showEditClosedAtObjectFieldForm = async (id, value, onAfterSubmit = null) => {
const inputId = 'collected-order-invoice-closed-at-input';
const pickerId = 'collected-order-invoice-closed-at-picker';
const clearButtonId = 'collected-order-invoice-closed-at-clear-button';
let dateField = null;
return Swal.fire({
title: ObjectsGlobal.language.field(CollectedOrderInvoices, 'closed_at'),
html: `<div class="field">
<label class="label has-text-black">${CollectedOrderInvoices.columns.closed_at.label}</label>
<div class="control mb-3">
<input class="input has-background-light has-text-black" type="date" id="${inputId}" value="${normalizeClosedAtForDateInput(value)}">
<input type="hidden" id="${inputId}" value="${normalizeClosedAtForDateInput(value)}">
<div id="${pickerId}" data-testid="collected-order-invoice-closed-at-picker"></div>
</div>
<div class="control">
<button id="${clearButtonId}" type="button" class="button is-light is-small">${ObjectsGlobal.language.clear}</button>
@@ -196,14 +243,23 @@ const showEditClosedAtObjectFieldForm = async (id, value, onAfterSubmit = null)
confirmButtonText: ObjectsGlobal.language.save,
cancelButtonText: ObjectsGlobal.language.cancel,
didOpen: () => {
dateField = mountSweetAlertDateField({
mountId: pickerId,
inputId,
initialValue: normalizeClosedAtForDateInput(value),
dataTestid: "collected-order-invoice-closed-at-input",
});
const clearButton = document.getElementById(clearButtonId);
const inputElement = document.getElementById(inputId);
if (clearButton && inputElement) {
if (clearButton) {
clearButton.addEventListener('click', () => {
inputElement.value = '';
dateField?.setValue('');
});
}
},
willClose: () => {
dateField?.cleanup();
dateField = null;
},
preConfirm: () => {
const inputElement = document.getElementById(inputId);
if (!inputElement) {
@@ -1,7 +1,9 @@
<script>
import Swal from "sweetalert2";
import { createApp, h, ref, watch } from "vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
import i18n from "@/i18n";
import {
COMPLAINT_CUSTOMER_SEARCH_DEBOUNCE_MS,
@@ -23,6 +25,8 @@ const EDIT_COMPLAINT_CUSTOMER_RESULTS_WRAPPER_ID = "superuser-complaint-customer
const EDIT_COMPLAINT_CUSTOMER_RESULTS_ID = "superuser-complaint-customer-results";
const EDIT_COMPLAINT_CUSTOMER_HELP_ID = "superuser-complaint-customer-help";
const EDIT_COMPLAINT_CUSTOMER_CLEAR_ID = "superuser-complaint-customer-clear";
const EDIT_COMPLAINT_WASH_DATE_INPUT_ID = "superuser-complaint-wash-date";
const EDIT_COMPLAINT_WASH_DATE_PICKER_ID = "superuser-complaint-wash-date-picker";
const EDIT_COMPLAINT_CUSTOMER_SEARCH_WRAPPER_STYLE = "position: relative;";
const EDIT_COMPLAINT_CUSTOMER_RESULTS_WRAPPER_STYLE = "position: absolute; top: calc(100% + 4px); left: 0; right: 0; width: 100%; z-index: 30;";
const EDIT_COMPLAINT_CUSTOMER_RESULTS_MENU_STYLE = "display: block; position: static; width: 100%; padding-top: 0;";
@@ -67,6 +71,41 @@ const initialComplaintCustomerSelection = (complaint) => normalizeComplaintCusto
const initialComplaintWashDate = (complaint) => String(complaint?.wash_date ?? "").trim();
const initialComplaintCategory = (complaint) => String(complaint?.category ?? "").trim();
const mountSweetAlertDateField = ({ mountId, inputId, initialValue, dataTestid }) => {
const mountElement = document.getElementById(mountId);
const inputElement = document.getElementById(inputId);
if (!mountElement || !inputElement) {
return () => {};
}
const app = createApp({
setup() {
const value = ref(initialValue || "");
watch(value, (nextValue) => {
inputElement.value = nextValue || "";
}, { immediate: true });
return () => h(BuefyDateField, {
modelValue: value.value,
valueType: "string",
dataTestid,
"onUpdate:modelValue": (nextValue) => {
value.value = nextValue || "";
},
});
},
});
app.use(i18n);
app.mount(mountElement);
return () => {
app.unmount();
};
};
const buildEditComplaintForm = (complaint, departments) => {
const selectedCustomer = initialComplaintCustomerSelection(complaint);
const selectedCustomerLabel = formatComplaintCustomerLabel(selectedCustomer);
@@ -136,15 +175,14 @@ const buildEditComplaintForm = (complaint, departments) => {
></p>
</div>
<div class="field">
<label class="label has-text-black" for="superuser-complaint-wash-date">Dato for vask</label>
<label class="label has-text-black" for="${EDIT_COMPLAINT_WASH_DATE_INPUT_ID}">Dato for vask</label>
<div class="control">
<input
id="superuser-complaint-wash-date"
data-testid="superuser-complaint-edit-wash-date"
class="input has-background-light has-text-black"
type="date"
id="${EDIT_COMPLAINT_WASH_DATE_INPUT_ID}"
type="hidden"
value="${escapeHtml(washDate)}"
>
<div id="${EDIT_COMPLAINT_WASH_DATE_PICKER_ID}" data-testid="superuser-complaint-edit-wash-date"></div>
</div>
</div>
<div class="field">
@@ -497,6 +535,7 @@ export const DepartmentDailyReportComplaints = {
);
const customerLookupState = createComplaintCustomerLookupState(complaint);
let cleanupCustomerLookup = null;
let cleanupDateField = null;
return Swal.fire({
title: t("superuser.pages.complaints.edit_title"),
@@ -507,12 +546,22 @@ export const DepartmentDailyReportComplaints = {
focusConfirm: false,
didOpen: () => {
cleanupCustomerLookup = setupComplaintCustomerLookupField(customerLookupState);
cleanupDateField = mountSweetAlertDateField({
mountId: EDIT_COMPLAINT_WASH_DATE_PICKER_ID,
inputId: EDIT_COMPLAINT_WASH_DATE_INPUT_ID,
initialValue: initialComplaintWashDate(complaint),
dataTestid: "superuser-complaint-edit-wash-date-picker",
});
},
willClose: () => {
if (typeof cleanupCustomerLookup === "function") {
cleanupCustomerLookup();
cleanupCustomerLookup = null;
}
if (typeof cleanupDateField === "function") {
cleanupDateField();
cleanupDateField = null;
}
},
preConfirm: async () => {
try {
@@ -525,7 +574,7 @@ export const DepartmentDailyReportComplaints = {
}
const washDate = (
document.getElementById("superuser-complaint-wash-date")?.value || ""
document.getElementById(EDIT_COMPLAINT_WASH_DATE_INPUT_ID)?.value || ""
).trim();
if (washDate === "") {
throw new Error("Dato for vask er påkrævet.");
@@ -3,6 +3,13 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const normalizeDateFilterValue = (value) => {
const formatted = formatLocalDateOnly(value);
return DATE_ONLY_PATTERN.test(formatted) ? formatted : null;
};
/**
* The DepartmentTimeBookingsEntries object
*/
@@ -209,21 +216,23 @@ export const DepartmentTimeBookingsEntries = {
getPublicEntries: async (departmentId, dateFrom = null, dateTo = null) => {
/**
* Example usage:
* https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
* https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=start-date_from:2025-04-01,start-date_to:2025-05-30&order=start:asc
* This will return all public available times for the department with ID 1
* The dateFrom and dateTo parameters are optional and can be used to filter the results by date range.
* If not provided, it will return all available times.
*/
// Parse the dateFrom and dateTo parameters to ensure they are in the correct format (YYYY-MM-DD)
if (!departmentId || isNaN(departmentId)) {
console.warn('Invalid department ID:', departmentId);
return [];
}
if (dateFrom && !/^\d{4}-\d{2}-\d{2}$/.test(dateFrom)) {
dateFrom = normalizeDateFilterValue(dateFrom);
dateTo = normalizeDateFilterValue(dateTo);
if (dateFrom && !DATE_ONLY_PATTERN.test(dateFrom)) {
console.warn('Invalid dateFrom format:', dateFrom, 'Expected format: YYYY-MM-DD');
dateFrom = null;
}
if (dateTo && !/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) {
if (dateTo && !DATE_ONLY_PATTERN.test(dateTo)) {
console.warn('Invalid dateTo format:', dateTo, 'Expected format: YYYY-MM-DD');
dateTo = null;
}
@@ -243,7 +252,8 @@ export const DepartmentTimeBookingsEntries = {
'GET',
{
id: departmentId,
filters: `created_at-date_from:${dateFrom || ''},created_at-date_to:${dateTo || ''}`,
filters: `start-date_from:${dateFrom || ''},start-date_to:${dateTo || ''}`,
order: "start:asc",
},
).then(response => {
return response.data.data;
@@ -4,24 +4,27 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
const t = (key) => i18n.global.t(key);
/**
* The DepartmentVariables object
*/
export const DepartmentVariables = {
meta: {
title: "Afdelingsvariabler",
title: t("objects.department_variables.title"),
icon: "fas fa-list",
description: "Oversigt over afdelingsvariabler",
description: t("objects.department_variables.description"),
endpoint: "/superuser/department/variables",
labels: {
single: "variabel",
multiple: "variabler"
single: t("objects.department_variables.single"),
multiple: t("objects.department_variables.multiple"),
}
},
columns: {
id: {
label: "ID",
label: t("objects.columns.id"),
type: "number",
sortable: true,
creation: {
@@ -29,7 +32,7 @@ export const DepartmentVariables = {
}
},
department_id: {
label: "Afdeling",
label: t("objects.columns.department_id"),
type: "select",
sortable: true,
creation: {
@@ -40,7 +43,7 @@ export const DepartmentVariables = {
}
},
variable: {
label: "Variabel",
label: t("objects.columns.key"),
type: "string",
sortable: true,
creation: {
@@ -48,7 +51,7 @@ export const DepartmentVariables = {
}
},
value: {
label: "Værdi",
label: t("objects.columns.value"),
type: "string",
sortable: true,
creation: {
@@ -110,4 +113,4 @@ export const DepartmentVariables = {
);
}
};
</script>
</script>
@@ -241,6 +241,7 @@ export const ObjectsGlobal = {
return {
get at_hour() { return t('global.time.at_hour'); },
get today() { return t('common.today'); },
get anytime() { return t('global.text.anytime'); },
get yesterday() { return t('global.time.yesterday'); },
get this_month() { return t('global.time.this_month'); },
get last_month() { return t('global.time.last_month'); },
@@ -307,6 +308,7 @@ export const ObjectsGlobal = {
get last_week() { return t('global.text.last_week'); },
get this_month() { return t('global.text.this_month'); },
get last_month() { return t('global.text.last_month'); },
get other_month() { return t('global.text.other_month'); },
get this_year() { return t('global.text.this_year'); },
get same_week_last_year() { return t('global.text.same_week_last_year'); },
get same_month_last_year() { return t('global.text.same_month_last_year'); },
@@ -2,6 +2,13 @@
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import i18n from "@/i18n";
const t = (key) => i18n.global.t(key);
const tf = (key, fallback) => {
const value = t(key);
return value === key ? fallback : value;
};
const normalizeCompletionSafetySeal = (safetySeal) => {
if (safetySeal === null || safetySeal === undefined) {
@@ -27,13 +34,22 @@ const normalizeCompletionSafetySeal = (safetySeal) => {
*/
export const OrderBookings = {
meta: {
title: "Bookinger",
get title() {
return tf("nav.bookings", "Bookings");
},
icon: "fas fa-list",
description: "Oversigt over order bookinger",
get description() {
const multipleLabel = tf("nav.bookings", "Bookings");
return `Oversigt over ${String(multipleLabel).toLowerCase()}`;
},
endpoint: "/order-bookings",
labels: {
single: "booking",
multiple: "bookinger",
get single() {
return tf("objects.order_bookings.single", "Booking");
},
get multiple() {
return tf("nav.bookings", "Bookings");
},
fields: {
names: {
customer_number: {
@@ -125,3 +125,25 @@ export const getCustomerRuleDefinitions = () => CUSTOMER_RULE_DEFINITIONS;
export const getCustomerRuleDefinition = (attribute) =>
CUSTOMER_RULE_DEFINITIONS.find((rule) => rule.attribute === attribute) ?? null;
export const getCustomerRuleAttributeKey = (attributeEntry) => {
if (typeof attributeEntry === "string") {
const normalizedKey = attributeEntry.trim();
return normalizedKey || "";
}
if (attributeEntry && typeof attributeEntry === "object") {
const normalizedKey = String(attributeEntry.attribute ?? attributeEntry.key ?? "").trim();
return normalizedKey || "";
}
return "";
};
export const getCustomerRuleAttributeKeys = (attributeEntries = []) => (
Array.isArray(attributeEntries)
? attributeEntries
.map((entry) => getCustomerRuleAttributeKey(entry))
.filter(Boolean)
: []
);
@@ -16,6 +16,8 @@ import {
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { listShellyRelayOptions } from "@/services/shellyRelayOptions.js";
import { formatLocaleDateTime } from "@/services/localeFormatting.js";
import { useI18n } from "vue-i18n";
const props = defineProps({
departmentId: {
@@ -26,6 +28,7 @@ const props = defineProps({
const route = useRoute();
const router = useRouter();
const { locale } = useI18n({ useScope: "global" });
const state = reactive({
loading: false,
@@ -1482,7 +1485,9 @@ const getRelayConsumerContextLabel = (context) => {
>{{ state.rotatedKeys[scanner.id] }}</pre
>
<ul v-if="scanner.recent_scans?.length" class="department-hardware-workspace__list">
<li v-for="scan in scanner.recent_scans" :key="scan.id">{{ scan.created_at }} - {{ scan.plate }}</li>
<li v-for="scan in scanner.recent_scans" :key="scan.id">
{{ formatLocaleDateTime(scan.created_at, locale.value) || scan.created_at }} - {{ scan.plate }}
</li>
</ul>
</article>
</section>
+59 -4
View File
@@ -1166,6 +1166,7 @@
"cancel": "@:{'templates.generated.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'templates.generated.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'templates.generated.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'templates.generated.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -1438,6 +1439,14 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -1915,7 +1924,7 @@
"view_lane_new_tab": "@:{'words.generated.abn'} @:{'words.generated.bane'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_lane_setup_new_tab": "@:{'words.generated.abn'} baneopsætning @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_order_new_tab": "@:{'words.generated.abn'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_vehicle_new_tab": "?@:{'words.generated.bn'} @:{'words.generated.k'}?@:{'words.generated.ret'}?j {reg} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_vehicle_new_tab": "Åbn køretøjet i ny fane",
"accept_self_serve_wash": "Godkend selvbetjent vask",
"accept_self_serve_wash_confirm": "Flyt denne kladde til kunde #{customerNumber} med selvbetjeningsdetaljerne?",
"accept_self_serve_wash_success": "Den selvbetjente vask blev godkendt.",
@@ -3550,10 +3559,10 @@
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_disabled": "Anvender standardpriser",
"custom_pricing_enabled": "Kun egne priser",
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"custom_pricing_missing_price": "Når denne er aktiveret, erstattes manglende afdelingspriser med 999999. (I stedet for at bruge standardprisen)",
"custom_pricing_only": "Kun egne priser",
"effective_department_price": "Effektiv afdelingspris"
},
"search_placeholder": "@.capitalize:{'words.generated.søg'} @:{'words.generated.efter'} @:{'words.generated.afdelingsnavn'}",
@@ -3938,8 +3947,10 @@
"tank_cleaning": "@.capitalize:{'words.generated.tankcleaning'}",
"text": {
"all": "Altid",
"anytime": "Når som helst",
"last_7_days": "@.capitalize:{'words.generated.sidste'} 7 @:{'words.generated.dage'}",
"last_month": "@.capitalize:{'words.generated.sidste'} @:{'words.generated.maned'}",
"other_month": "@.capitalize:{'words.generated.andet'} @:{'words.generated.maned'}",
"last_week": "@.capitalize:{'words.generated.sidste'} @:{'words.generated.uge'}",
"same_month_last_year": "@.capitalize:{'words.generated.samme'} @:{'words.generated.maned'} @:{'words.generated.sidste'} @:{'words.generated.ar'}",
"same_week_last_year": "@.capitalize:{'words.generated.samme'} @:{'words.generated.uge'} @:{'words.generated.sidste'} @:{'words.generated.ar'}",
@@ -4160,6 +4171,9 @@
},
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlaes nu?",
"copy_trace_copied": "Kopieret",
"copy_trace_failed": "Kopiering fejlede",
"copy_trace": "Kopier trace",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
@@ -5219,6 +5233,38 @@
"new_department_subtitle": "@:{'words.generated.opret'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ny'} @:{'words.generated.afdeling'}",
"subtitle": "@:{'templates.generated.compat.departments.subtitle'}"
},
"department_pages": {
"branding": {
"subtitle": "Branding, kontaktoplysninger og afdelingsprofil"
},
"categories": {
"subtitle": "Gennemse og vedligehold afdelingens kategorikatalog"
},
"gateways": {
"subtitle": "Gateway-dækning, bane-bindinger, scannere og relæstatus"
},
"modules": {
"subtitle": "Skift afdelingens modulindstillinger og integrationsflag",
"variables": {
"bookingsystem_enabled": "Bookingsystem aktiveret",
"bookingsystem_time_based_enabled": "Tidsbaseret bookingsystem aktiveret",
"bookingsystem_time_based_password": "Kodeord til tidsbaseret bookingsystem",
"exclude_from_invoicing": "Udelukket fra fakturering",
"workfeed_department_id": "Workfeed-afdeling"
}
},
"pricing": {
"subtitle": "Gennemse afdelingens eksplicitte prisoverskrivelser"
},
"stripe": {
"readers": {
"subtitle": "Betalingsterminaler, der er knyttet til denne afdeling"
},
"setup": {
"subtitle": "Tilknyt Stripe-placeringen for denne afdeling"
}
}
},
"employees": {
"new_employee_subtitle": "@:{'words.generated.opret'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ny'} @:{'words.generated.medarbejder'}",
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.medarbejdere'} @:{'words.generated.pa'} @:{'words.generated.tværs'} @:{'words.generated.af'} @:{'words.generated.afdelinger'}"
@@ -6153,6 +6199,7 @@
"cancel": "Annuller",
"nav": {
"prices": "@.capitalize:{'words.generated.priser'}",
"customer_pricing": "Kundepriser",
"employees": "@.capitalize:{'words.generated.medarbejdere'}"
},
"forbidden": {
@@ -6425,6 +6472,14 @@
"label": "Administrer afdelingspriser",
"description": "Kan redigere eksplicitte priser for tildelte afdelinger."
},
"view_customer_pricing": {
"label": "Se kundepriser",
"description": "Kan se afdelingsspecifikke kundepriser og rabatter for tildelte afdelinger."
},
"manage_customer_pricing": {
"label": "Administrer kundepriser",
"description": "Kan redigere afdelingsspecifikke kundepriser og rabatter for tildelte afdelinger."
},
"manage_employee_access": {
"label": "Administrer medarbejderadgang",
"description": "Kan oprette, redigere og deaktivere begrænsede backofficemedarbejdere."
+23
View File
@@ -1277,6 +1277,7 @@
"cancel": "@:{'templates.generated.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'templates.generated.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'templates.generated.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'templates.generated.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -1549,6 +1550,14 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -4049,8 +4058,10 @@
"tank_cleaning": "@:{'words.generated.tankreinigung'}",
"text": {
"all": "Immer",
"anytime": "Jederzeit",
"last_7_days": "@.capitalize:{'words.generated.letzte'} 7 Tage",
"last_month": "@:{'words.generated.letzter'} @:{'words.generated.monat'}",
"other_month": "Anderer @:{'words.generated.monat'}",
"last_week": "@.capitalize:{'words.generated.letzte'} @:{'words.generated.woche'}",
"same_month_last_year": "Gleicher @:{'words.generated.monat'} @:{'words.generated.im'} @:{'words.generated.vorjahr'}",
"same_week_last_year": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.woche'} @:{'words.generated.im'} @:{'words.generated.vorjahr'}",
@@ -4271,6 +4282,9 @@
},
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"copy_trace_copied": "Kopiert",
"copy_trace_failed": "Kopieren fehlgeschlagen",
"copy_trace": "Trace kopieren",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
@@ -6264,6 +6278,7 @@
"cancel": "Abbrechen",
"nav": {
"prices": "@:{'words.generated.preise'}",
"customer_pricing": "Kundenpreise",
"employees": "@:{'words.generated.mitarbeiter'}"
},
"forbidden": {
@@ -6536,6 +6551,14 @@
"label": "Abteilungspreise verwalten",
"description": "Kann explizite Preise für zugewiesene Abteilungen bearbeiten."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Mitarbeiterzugang verwalten",
"description": "Kann begrenzte Backoffice-Mitarbeiter erstellen, bearbeiten und deaktivieren."
+55
View File
@@ -998,6 +998,7 @@
"cancel": "@:{'templates.generated.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'templates.generated.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'templates.generated.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'templates.generated.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -1270,6 +1271,14 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -3770,8 +3779,10 @@
"tank_cleaning": "@.capitalize:{'words.generated.tank'} @:{'words.generated.cleaning'}",
"text": {
"all": "Always",
"anytime": "Anytime",
"last_7_days": "@.capitalize:{'words.generated.last'} 7 @:{'words.generated.days'}",
"last_month": "@.capitalize:{'words.generated.last'} @:{'words.generated.month'}",
"other_month": "@.capitalize:{'words.generated.other'} @:{'words.generated.month'}",
"last_week": "@.capitalize:{'words.generated.last'} @:{'words.generated.week'}",
"same_month_last_year": "@.capitalize:{'words.generated.same'} @:{'words.generated.month'} @:{'words.generated.last'} @:{'words.generated.year'}",
"same_week_last_year": "@.capitalize:{'words.generated.same'} @:{'words.generated.week'} @:{'words.generated.last'} @:{'words.generated.year'}",
@@ -3992,6 +4003,9 @@
},
"maintenance_menu": {
"confirm_clear_local": "Clear all local app data and reload now?",
"copy_trace_copied": "Copied",
"copy_trace_failed": "Copy failed",
"copy_trace": "Copy trace",
"force_update_clear_hint": "Clears local storage, session storage, service workers, and browser caches.",
"force_update_clear": "Force update and clear all local",
"title": "Maintenance",
@@ -5051,6 +5065,38 @@
"new_department_subtitle": "@.capitalize:{'words.generated.create'} @:{'words.generated.a'} @:{'words.generated.new'} @:{'words.generated.department'}",
"subtitle": "@:{'templates.generated.compat.departments.subtitle'}"
},
"department_pages": {
"branding": {
"subtitle": "Brand identity, contact details, and department profile"
},
"categories": {
"subtitle": "Browse and maintain the department's category catalogue"
},
"gateways": {
"subtitle": "Gateway coverage, lane bindings, scanners, and relay health"
},
"modules": {
"subtitle": "Toggle department-wide module settings and integration flags",
"variables": {
"bookingsystem_enabled": "Bookingsystem enabled",
"bookingsystem_time_based_enabled": "Time-based bookingsystem enabled",
"bookingsystem_time_based_password": "Time-based bookingsystem password",
"exclude_from_invoicing": "Excluded from invoicing",
"workfeed_department_id": "Workfeed department"
}
},
"pricing": {
"subtitle": "Review the department's explicit price overrides"
},
"stripe": {
"readers": {
"subtitle": "Payment terminals currently associated with this department"
},
"setup": {
"subtitle": "Assign the Stripe location used by this department"
}
}
},
"employees": {
"new_employee_subtitle": "@.capitalize:{'words.generated.create'} @:{'words.generated.a'} @:{'words.generated.new'} @:{'words.generated.employee'}",
"subtitle": "@.capitalize:{'words.generated.manage'} @:{'words.generated.employees'} @:{'words.generated.across'} @:{'words.generated.departments'}"
@@ -5985,6 +6031,7 @@
"cancel": "Cancel",
"nav": {
"prices": "@.capitalize:{'words.generated.prices'}",
"customer_pricing": "Customer pricing",
"employees": "@.capitalize:{'words.generated.employees'}"
},
"forbidden": {
@@ -6257,6 +6304,14 @@
"label": "Manage department prices",
"description": "Can edit explicit prices for assigned departments."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Manage employee access",
"description": "Can create, edit, and deactivate limited backoffice employees."
+171 -2
View File
@@ -2437,6 +2437,33 @@
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"effective_department_price": "@:{'templates.generated.compat.departments.pricing.effective_department_price'}"
},
"customer_pricing": {
"customer_number": "Customer number",
"department_price": "Department price",
"discount": "Discount percent",
"discount_validation": "Discount must be between 0 and 100.",
"edit_category_discount": "Edit category discount",
"edit_disabled": "Editing requires price management permission",
"edit_fixed_price": "Edit fixed price",
"edit_global_discount": "Edit global discount",
"edit_item_discount": "Edit item discount",
"effective_price": "Effective price",
"errors": {
"load": "Unable to load customer pricing",
"save": "Unable to save customer pricing"
},
"fixed_price": "Fixed price",
"fixed_price_validation": "Fixed price must be zero or more.",
"global_discount": "Global discount",
"item_discount": "Item discount",
"load_customer": "Load customer pricing",
"no_permission": "You do not have access to customer pricing for this department.",
"no_products": "No products are configured for this department.",
"not_enabled": "Customer pricing is only available when custom-only department pricing is enabled.",
"open": "Open customer pricing",
"saving": "Saving customer pricing...",
"title": "Customer pricing"
},
"products": {
"new_product": "@:products.new_product"
},
@@ -2697,6 +2724,37 @@
},
"title": "@:{'templates.generated.compat.global_search.title'}"
},
"date_period": {
"labels": {
"period": "Period",
"select_period": "Select period",
"advanced_period": "Advanced period",
"select_month": "Select month",
"update_selection": "Update selection",
"to": "@:common.to",
"no_date_selection": "No date selected"
},
"messages": {
"invalid_selection": "The selected date range is invalid.",
"multi_month": "This selection spans multiple months.",
"partial_month_prefix": "Need a full month?",
"partial_month_action": "Use the full selected month",
"partial_month_tooltip": "Set the range to the first and last day of the selected month",
"partial_month_suffix": "to align reporting and invoicing periods."
},
"shortcuts": {
"anytime": "@:global.text.anytime",
"today": "@:common.today",
"yesterday": "@:common.yesterday",
"this_week": "@:global.text.this_week",
"last_week": "@:global.text.last_week",
"last_seven_days": "@:global.text.last_7_days",
"this_month": "@:global.text.this_month",
"last_month": "@:global.text.last_month",
"same_week_last_year": "@:global.text.same_week_last_year",
"same_month_last_year": "@:global.text.same_month_last_year"
}
},
"global": {
"abnormal": "@:{'templates.generated.compat.global.abnormal'}",
"actions": "@:common.actions",
@@ -2917,8 +2975,10 @@
"tax": "@:common.tax",
"text": {
"all": "@:{'templates.generated.compat.global.text.all'}",
"anytime": "@:{'templates.generated.compat.global.text.anytime'}",
"last_7_days": "@:{'templates.generated.compat.global.text.last_7_days'}",
"last_month": "@:{'templates.generated.compat.global.text.last_month'}",
"other_month": "@:{'templates.generated.compat.global.text.other_month'}",
"last_week": "@:{'templates.generated.compat.global.text.last_week'}",
"month": "@:{'templates.generated.compat.global.month'}",
"same_month_last_year": "@:{'templates.generated.compat.global.text.same_month_last_year'}",
@@ -3199,6 +3259,9 @@
},
"maintenance_menu": {
"confirm_clear_local": "@:{'templates.generated.compat.maintenance_menu.confirm_clear_local'}",
"copy_trace_copied": "@:{'templates.generated.compat.maintenance_menu.copy_trace_copied'}",
"copy_trace_failed": "@:{'templates.generated.compat.maintenance_menu.copy_trace_failed'}",
"copy_trace": "@:{'templates.generated.compat.maintenance_menu.copy_trace'}",
"force_update_clear_hint": "@:{'templates.generated.compat.maintenance_menu.force_update_clear_hint'}",
"force_update_clear": "@:{'templates.generated.compat.maintenance_menu.force_update_clear'}",
"title": "@:{'templates.generated.compat.maintenance_menu.title'}",
@@ -3607,9 +3670,9 @@
},
"description": "@:{'templates.generated.compat.objects.orders.description'}",
"entries": "@:global.wash_multiple",
"multiple": "@:{'templates.generated.compat.common.bookings'}",
"multiple": "@:global.wash_multiple",
"single": "@:{'templates.generated.compat.common.order'}",
"title": "@:{'templates.generated.compat.common.bookings'}"
"title": "@:global.wash_multiple"
},
"permissions": {
"description": "@:{'templates.generated.compat.objects.permissions.description'}",
@@ -4609,8 +4672,44 @@
"gateways": "@.capitalize:{'words.generated.gateways'}",
"stripe": "@:{'words.generated.stripe'}",
"pricing": "Pricing",
"customer_pricing": "Customer pricing",
"categories": "Categories"
},
"department_pages": {
"branding": {
"subtitle": "Brand identity, contact details, and department profile"
},
"categories": {
"subtitle": "Browse and maintain the department's category catalogue"
},
"gateways": {
"subtitle": "Gateway coverage, lane bindings, scanners, and relay health"
},
"modules": {
"subtitle": "Toggle department-wide module settings and integration flags",
"variables": {
"bookingsystem_enabled": "Bookingsystem enabled",
"bookingsystem_time_based_enabled": "Time-based bookingsystem enabled",
"bookingsystem_time_based_password": "Time-based bookingsystem password",
"exclude_from_invoicing": "Excluded from invoicing",
"workfeed_department_id": "Workfeed department"
}
},
"pricing": {
"subtitle": "Review the department's explicit price overrides"
},
"customer_pricing": {
"subtitle": "Maintain customer-specific prices and discounts for this department"
},
"stripe": {
"readers": {
"subtitle": "Payment terminals currently associated with this department"
},
"setup": {
"subtitle": "Assign the Stripe location used by this department"
}
}
},
"department_overview": {
"title": "Department overview",
"subtitle": "Operational overview for the selected department",
@@ -5111,6 +5210,74 @@
"error_occurred": "@:{'templates.generated.compat.superuser.statistics.error_occurred'}",
"loading": "@:{'templates.generated.compat.messages.loading'}"
},
"user_detail": {
"overview_header_help": "Use the workspace shortcuts and direct actions below to manage this customer account.",
"workspace_shortcuts_help": "Jump straight into the user-specific workspaces for this account.",
"attribute_help": "Hover an attribute to see what it changes for the customer.",
"subtitles": {
"overview": "Customer overview and management workspace",
"orders": "Invoice handling, open drafts, and order history",
"pricing": "Customer-specific discounts and fixed product prices",
"other": "Special arrangements and internal customer notes",
"security": "Permissions and customer rule access",
"vehicles": "Vehicles, subscriptions, and mass registration tools",
"xlvask": "XLVask account details and related imported vehicles"
},
"orders": {
"summary_title": "Order invoicing overview",
"summary_subtitle": "Review how this customer is invoiced before opening the full order list.",
"invoicing_mode": "Invoicing mode",
"open_draft": "Open invoice draft"
},
"pricing": {
"summary_title": "Pricing overview",
"summary_subtitle": "Inspect the customer-wide discount policy and product-specific overrides.",
"table_title": "Product pricing overrides",
"table_subtitle": "Adjust fixed prices and discount rules for each available product.",
"global_discount": "Global discount",
"product_overrides": "Product overrides",
"discount_percent": "Discount percent",
"fixed_price_label": "Fixed price",
"fixed_price_validation": "Please enter a valid non-negative whole number.",
"category_discount_disabled": "Category discount disabled",
"fixed_price_tooltip": "Edit the fixed price for this product.",
"item_discount_tooltip": "Edit the customer-specific discount for this product.",
"category_discount_tooltip": "Edit the category discount used for this product."
},
"other": {
"summary_title": "Special arrangements",
"summary_subtitle": "Store account-specific notes and billing details that affect day-to-day operations."
},
"vehicles": {
"summary_title": "Vehicle workspace",
"summary_subtitle": "Review the customer's vehicles, subscriptions, and import workflows.",
"subscription_invoicing_subtitle": "Manage active wash subscriptions and create invoice drafts for other periods."
},
"overview": {
"customer_actions_title": "Customer actions",
"customer_actions_subtitle": "Run customer-specific actions without leaving the overview.",
"customer_shortcuts_title": "Customer shortcuts",
"customer_shortcuts_subtitle": "Jump to the most common customer workspaces from the overview.",
"customer_flags_title": "Customer flags",
"customer_flags_subtitle": "Create billing-period flags for this customer when follow-up is needed.",
"fixed_pricing_title": "Fixed pricing",
"fixed_pricing_subtitle": "Manage the customer-wide fixed pricing agreement.",
"default_department_title": "Default department",
"default_department_subtitle": "Choose which department should be preselected for this customer.",
"special_arrangement_title": "Special arrangement",
"special_arrangement_subtitle": "Store operational notes about the customer's special agreement.",
"wash_subscription_note_title": "Wash subscription note",
"wash_subscription_note_subtitle": "Store the internal note used for wash subscription invoicing."
},
"xlvask": {
"summary_title": "XLVask customer profile",
"summary_subtitle": "Inspect the linked XLVask customer record before importing vehicles.",
"vehicles_title": "XLVask vehicles",
"vehicles_subtitle": "Vehicles available in XLVask for this linked customer.",
"not_found": "No XLVask customer record is linked to this account yet.",
"customer_id": "XLVask ID"
}
},
"user": {
"default_department": {
"not_implemented_desc": "@:superuser.user.fixed_pricing.not_implemented_desc",
@@ -5642,6 +5809,7 @@
"currency": "@:{'templates.generated.compat.user_admin.currency'}",
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
"cvr": "@:{'templates.generated.compat.user_admin.cvr'}",
"edit_user": "Edit user",
"economic_data": "@:{'templates.generated.compat.user_admin.economic_data'}",
"email": "@:common.email",
"group_id": "@:{'templates.generated.compat.user_admin.group_id'}",
@@ -5686,6 +5854,7 @@
"title": "@:{'templates.generated.compat.common.user'}",
"updated_at": "@:{'templates.generated.compat.objects.columns.updated_at'}",
"user_data": "@:{'templates.generated.compat.user_admin.user_data'}",
"user_edited_successfully": "User edited successfully",
"user_id": "@:{'templates.generated.compat.user_admin.user_id'}",
"variables": "@:{'templates.generated.compat.user_admin.variables'}",
"wash_subscriptions": "@:{'templates.generated.compat.user_admin.wash_subscriptions'}",
+23
View File
@@ -1280,6 +1280,7 @@
"cancel": "@:{'templates.generated.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'templates.generated.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'templates.generated.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'templates.generated.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -1552,6 +1553,14 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -4052,8 +4061,10 @@
"tank_cleaning": "@.capitalize:{'words.generated.tankrengjøring'}",
"text": {
"all": "Alltid",
"anytime": "Når som helst",
"last_7_days": "@:{'words.generated.siste'} 7 @:{'words.generated.dager'}",
"last_month": "@.capitalize:{'words.generated.forrige'} @:{'words.generated.maned'}",
"other_month": "@.capitalize:{'words.generated.annen'} @:{'words.generated.maned'}",
"last_week": "@.capitalize:{'words.generated.forrige'} @:{'words.generated.uke'}",
"same_month_last_year": "@.capitalize:{'words.generated.samme'} @:{'words.generated.maned'} @:{'words.generated.i'} @:{'words.generated.fjor'}",
"same_week_last_year": "@.capitalize:{'words.generated.samme'} @:{'words.generated.uke'} @:{'words.generated.i'} @:{'words.generated.fjor'}",
@@ -4274,6 +4285,9 @@
},
"maintenance_menu": {
"confirm_clear_local": "Fjern alle lokale appdata og last inn på nytt nå?",
"copy_trace_copied": "Kopiert",
"copy_trace_failed": "Kopiering feilet",
"copy_trace": "Kopier trace",
"force_update_clear_hint": "Fjerner local storage, session storage, service workers og nettlesercacher.",
"force_update_clear": "Tving oppdatering og fjern alt lokalt",
"title": "Vedlikehold",
@@ -6267,6 +6281,7 @@
"cancel": "Avbryt",
"nav": {
"prices": "@.capitalize:{'words.generated.priser'}",
"customer_pricing": "Kundepriser",
"employees": "@.capitalize:{'words.generated.medarbeidere'}"
},
"forbidden": {
@@ -6539,6 +6554,14 @@
"label": "Administrer avdelingspriser",
"description": "Kan redigere eksplisitte priser for tildelte avdelinger."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Administrer ansattilgang",
"description": "Kan opprette, redigere og deaktivere begrensede backofficeansatte."
+23
View File
@@ -1330,6 +1330,7 @@
"cancel": "@:{'templates.generated.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'templates.generated.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'templates.generated.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'templates.generated.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -1602,6 +1603,14 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -4102,8 +4111,10 @@
"tank_cleaning": "@.capitalize:{'words.generated.tank'} @:{'words.generated.cleaning'}",
"text": {
"all": "Alltid",
"anytime": "När som helst",
"last_7_days": "@.capitalize:{'words.generated.last_2'} 7 days",
"last_month": "@.capitalize:{'words.generated.forra'} @:{'words.generated.manaden'}",
"other_month": "@.capitalize:{'words.generated.annan'} @:{'words.generated.manad'}",
"last_week": "@.capitalize:{'words.generated.forra'} veckan",
"same_month_last_year": "@.capitalize:{'words.generated.samma'} @:{'words.generated.manad'} @:{'words.generated.forra'} @:{'words.generated.aret'}",
"same_week_last_year": "@.capitalize:{'words.generated.same'} @:{'words.generated.week'} @:{'words.generated.last_2'} year",
@@ -4324,6 +4335,9 @@
},
"maintenance_menu": {
"confirm_clear_local": "Rensa all lokal appdata och ladda om nu?",
"copy_trace_copied": "Kopierat",
"copy_trace_failed": "Kopiering misslyckades",
"copy_trace": "Kopiera trace",
"force_update_clear_hint": "Rensar local storage, session storage, service workers och webbläsarcacher.",
"force_update_clear": "Tvinga uppdatering och rensa allt lokalt",
"title": "Underhåll",
@@ -6317,6 +6331,7 @@
"cancel": "Avbryt",
"nav": {
"prices": "@.capitalize:{'words.generated.priser'}",
"customer_pricing": "Kundpriser",
"employees": "@.capitalize:{'words.generated.medarbetare'}"
},
"forbidden": {
@@ -6589,6 +6604,14 @@
"label": "Hantera avdelningspriser",
"description": "Kan redigera uttryckliga priser för tilldelade avdelningar."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Hantera medarbetaråtkomst",
"description": "Kan skapa, redigera och inaktivera begränsade backofficemedarbetare."
@@ -29,7 +29,7 @@
"view_lane_new_tab": "@:{'terms.glossary.abn'} @:{'terms.glossary.bane'} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_lane_setup_new_tab": "@:{'terms.glossary.abn'} baneopsætning @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_order_new_tab": "@:{'terms.glossary.abn'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_vehicle_new_tab": "?@:{'terms.glossary.bn'} @:{'terms.glossary.k'}?@:{'terms.glossary.ret'}?j {reg} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}"
"view_vehicle_new_tab": "Åbn køretøjet i ny fane"
},
"show_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.detaljer'}",
"show_order_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.ordredetaljer'}",
@@ -22,10 +22,10 @@
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_disabled": "Anvender standardpriser",
"custom_pricing_enabled": "Kun egne priser",
"custom_pricing_missing_price": "Manglende afdelingspriser bliver 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"custom_pricing_missing_price": "Når denne er aktiveret, erstattes manglende afdelingspriser med 999999. (I stedet for at bruge standardprisen)",
"custom_pricing_only": "Kun egne priser",
"effective_department_price": "Effektiv afdelingspris"
},
"search_placeholder": "@.capitalize:{'terms.glossary.søg'} @:{'terms.glossary.efter'} @:{'terms.glossary.afdelingsnavn'}",
@@ -174,8 +174,10 @@
"tank_cleaning": "@.capitalize:{'terms.glossary.tankcleaning'}",
"text": {
"all": "Altid",
"anytime": "Når som helst",
"last_7_days": "@.capitalize:{'terms.glossary.sidste'} 7 @:{'terms.glossary.dage'}",
"last_month": "@.capitalize:{'terms.glossary.sidste'} @:{'terms.glossary.maned'}",
"other_month": "@.capitalize:{'terms.glossary.andet'} @:{'terms.glossary.maned'}",
"last_week": "@.capitalize:{'terms.glossary.sidste'} @:{'terms.glossary.uge'}",
"same_month_last_year": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.maned'} @:{'terms.glossary.sidste'} @:{'terms.glossary.ar'}",
"same_week_last_year": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.uge'} @:{'terms.glossary.sidste'} @:{'terms.glossary.ar'}",
@@ -2,6 +2,9 @@
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Ryd alle lokale appdata og genindlaes nu?",
"copy_trace_copied": "Kopieret",
"copy_trace_failed": "Kopiering fejlede",
"copy_trace": "Kopier trace",
"force_update_clear_hint": "Rydder local storage, session storage, service workers og browsercaches.",
"force_update_clear": "Tving opdatering og ryd alt lokalt",
"title": "Vedligeholdelse",
@@ -30,6 +30,38 @@
"new_department_subtitle": "@:{'terms.glossary.opret'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ny'} @:{'terms.glossary.afdeling'}",
"subtitle": "@:{'phrases.compat.departments.subtitle'}"
},
"department_pages": {
"branding": {
"subtitle": "Branding, kontaktoplysninger og afdelingsprofil"
},
"categories": {
"subtitle": "Gennemse og vedligehold afdelingens kategorikatalog"
},
"gateways": {
"subtitle": "Gateway-dækning, bane-bindinger, scannere og relæstatus"
},
"modules": {
"subtitle": "Skift afdelingens modulindstillinger og integrationsflag",
"variables": {
"bookingsystem_enabled": "Bookingsystem aktiveret",
"bookingsystem_time_based_enabled": "Tidsbaseret bookingsystem aktiveret",
"bookingsystem_time_based_password": "Kodeord til tidsbaseret bookingsystem",
"exclude_from_invoicing": "Udelukket fra fakturering",
"workfeed_department_id": "Workfeed-afdeling"
}
},
"pricing": {
"subtitle": "Gennemse afdelingens eksplicitte prisoverskrivelser"
},
"stripe": {
"readers": {
"subtitle": "Betalingsterminaler, der er knyttet til denne afdeling"
},
"setup": {
"subtitle": "Tilknyt Stripe-placeringen for denne afdeling"
}
}
},
"employees": {
"new_employee_subtitle": "@:{'terms.glossary.opret'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ny'} @:{'terms.glossary.medarbejder'}",
"subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.medarbejdere'} @:{'terms.glossary.pa'} @:{'terms.glossary.tværs'} @:{'terms.glossary.af'} @:{'terms.glossary.afdelinger'}"
+18
View File
@@ -22,6 +22,7 @@
"cancel": "@:{'phrases.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'phrases.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'phrases.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'phrases.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -294,6 +295,14 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -339,6 +348,7 @@
"cancel": "Annuller",
"nav": {
"prices": "@.capitalize:{'terms.glossary.priser'}",
"customer_pricing": "Kundepriser",
"employees": "@.capitalize:{'terms.glossary.medarbejdere'}"
},
"forbidden": {
@@ -611,6 +621,14 @@
"label": "Administrer afdelingspriser",
"description": "Kan redigere eksplicitte priser for tildelte afdelinger."
},
"view_customer_pricing": {
"label": "Se kundepriser",
"description": "Kan se afdelingsspecifikke kundepriser og rabatter for tildelte afdelinger."
},
"manage_customer_pricing": {
"label": "Administrer kundepriser",
"description": "Kan redigere afdelingsspecifikke kundepriser og rabatter for tildelte afdelinger."
},
"manage_employee_access": {
"label": "Administrer medarbejderadgang",
"description": "Kan oprette, redigere og deaktivere begrænsede backofficemedarbejdere."
@@ -174,8 +174,10 @@
"tank_cleaning": "@:{'terms.glossary.tankreinigung'}",
"text": {
"all": "Immer",
"anytime": "Jederzeit",
"last_7_days": "@.capitalize:{'terms.glossary.letzte'} 7 Tage",
"last_month": "@:{'terms.glossary.letzter'} @:{'terms.glossary.monat'}",
"other_month": "Anderer @:{'terms.glossary.monat'}",
"last_week": "@.capitalize:{'terms.glossary.letzte'} @:{'terms.glossary.woche'}",
"same_month_last_year": "Gleicher @:{'terms.glossary.monat'} @:{'terms.glossary.im'} @:{'terms.glossary.vorjahr'}",
"same_week_last_year": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.woche'} @:{'terms.glossary.im'} @:{'terms.glossary.vorjahr'}",
@@ -2,6 +2,9 @@
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Alle lokalen App-Daten löschen und jetzt neu laden?",
"copy_trace_copied": "Kopiert",
"copy_trace_failed": "Kopieren fehlgeschlagen",
"copy_trace": "Trace kopieren",
"force_update_clear_hint": "Löscht Local Storage, Session Storage, Service Worker und Browser-Caches.",
"force_update_clear": "Update erzwingen und alles Lokale löschen",
"title": "Wartung",
+18
View File
@@ -22,6 +22,7 @@
"cancel": "@:{'phrases.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'phrases.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'phrases.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'phrases.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -294,6 +295,14 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -339,6 +348,7 @@
"cancel": "Abbrechen",
"nav": {
"prices": "@:{'terms.glossary.preise'}",
"customer_pricing": "Kundenpreise",
"employees": "@:{'terms.glossary.mitarbeiter'}"
},
"forbidden": {
@@ -611,6 +621,14 @@
"label": "Abteilungspreise verwalten",
"description": "Kann explizite Preise für zugewiesene Abteilungen bearbeiten."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Mitarbeiterzugang verwalten",
"description": "Kann begrenzte Backoffice-Mitarbeiter erstellen, bearbeiten und deaktivieren."
@@ -174,8 +174,10 @@
"tank_cleaning": "@.capitalize:{'terms.glossary.tank'} @:{'terms.glossary.cleaning'}",
"text": {
"all": "Always",
"anytime": "Anytime",
"last_7_days": "@.capitalize:{'terms.glossary.last'} 7 @:{'terms.glossary.days'}",
"last_month": "@.capitalize:{'terms.glossary.last'} @:{'terms.glossary.month'}",
"other_month": "@.capitalize:{'terms.glossary.other'} @:{'terms.glossary.month'}",
"last_week": "@.capitalize:{'terms.glossary.last'} @:{'terms.glossary.week'}",
"same_month_last_year": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.month'} @:{'terms.glossary.last'} @:{'terms.glossary.year'}",
"same_week_last_year": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.week'} @:{'terms.glossary.last'} @:{'terms.glossary.year'}",
@@ -2,6 +2,9 @@
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Clear all local app data and reload now?",
"copy_trace_copied": "Copied",
"copy_trace_failed": "Copy failed",
"copy_trace": "Copy trace",
"force_update_clear_hint": "Clears local storage, session storage, service workers, and browser caches.",
"force_update_clear": "Force update and clear all local",
"title": "Maintenance",
@@ -30,6 +30,38 @@
"new_department_subtitle": "@.capitalize:{'terms.glossary.create'} @:{'terms.glossary.a'} @:{'terms.glossary.new'} @:{'terms.glossary.department'}",
"subtitle": "@:{'phrases.compat.departments.subtitle'}"
},
"department_pages": {
"branding": {
"subtitle": "Brand identity, contact details, and department profile"
},
"categories": {
"subtitle": "Browse and maintain the department's category catalogue"
},
"gateways": {
"subtitle": "Gateway coverage, lane bindings, scanners, and relay health"
},
"modules": {
"subtitle": "Toggle department-wide module settings and integration flags",
"variables": {
"bookingsystem_enabled": "Bookingsystem enabled",
"bookingsystem_time_based_enabled": "Time-based bookingsystem enabled",
"bookingsystem_time_based_password": "Time-based bookingsystem password",
"exclude_from_invoicing": "Excluded from invoicing",
"workfeed_department_id": "Workfeed department"
}
},
"pricing": {
"subtitle": "Review the department's explicit price overrides"
},
"stripe": {
"readers": {
"subtitle": "Payment terminals currently associated with this department"
},
"setup": {
"subtitle": "Assign the Stripe location used by this department"
}
}
},
"employees": {
"new_employee_subtitle": "@.capitalize:{'terms.glossary.create'} @:{'terms.glossary.a'} @:{'terms.glossary.new'} @:{'terms.glossary.employee'}",
"subtitle": "@.capitalize:{'terms.glossary.manage'} @:{'terms.glossary.employees'} @:{'terms.glossary.across'} @:{'terms.glossary.departments'}"
+18
View File
@@ -22,6 +22,7 @@
"cancel": "@:{'phrases.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'phrases.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'phrases.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'phrases.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -294,6 +295,14 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -339,6 +348,7 @@
"cancel": "Cancel",
"nav": {
"prices": "@.capitalize:{'terms.glossary.prices'}",
"customer_pricing": "Customer pricing",
"employees": "@.capitalize:{'terms.glossary.employees'}"
},
"forbidden": {
@@ -611,6 +621,14 @@
"label": "Manage department prices",
"description": "Can edit explicit prices for assigned departments."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Manage employee access",
"description": "Can create, edit, and deactivate limited backoffice employees."
@@ -45,6 +45,33 @@
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"effective_department_price": "@:{'phrases.compat.departments.pricing.effective_department_price'}"
},
"customer_pricing": {
"customer_number": "Customer number",
"department_price": "Department price",
"discount": "Discount percent",
"discount_validation": "Discount must be between 0 and 100.",
"edit_category_discount": "Edit category discount",
"edit_disabled": "Editing requires price management permission",
"edit_fixed_price": "Edit fixed price",
"edit_global_discount": "Edit global discount",
"edit_item_discount": "Edit item discount",
"effective_price": "Effective price",
"errors": {
"load": "Unable to load customer pricing",
"save": "Unable to save customer pricing"
},
"fixed_price": "Fixed price",
"fixed_price_validation": "Fixed price must be zero or more.",
"global_discount": "Global discount",
"item_discount": "Item discount",
"load_customer": "Load customer pricing",
"no_permission": "You do not have access to customer pricing for this department.",
"no_products": "No products are configured for this department.",
"not_enabled": "Customer pricing is only available when custom-only department pricing is enabled.",
"open": "Open customer pricing",
"saving": "Saving customer pricing...",
"title": "Customer pricing"
},
"products": {
"new_product": "@:products.new_product"
},
@@ -1,4 +1,35 @@
{
"date_period": {
"labels": {
"period": "Period",
"select_period": "Select period",
"advanced_period": "Advanced period",
"select_month": "Select month",
"update_selection": "Update selection",
"to": "@:common.to",
"no_date_selection": "No date selected"
},
"messages": {
"invalid_selection": "The selected date range is invalid.",
"multi_month": "This selection spans multiple months.",
"partial_month_prefix": "Need a full month?",
"partial_month_action": "Use the full selected month",
"partial_month_tooltip": "Set the range to the first and last day of the selected month",
"partial_month_suffix": "to align reporting and invoicing periods."
},
"shortcuts": {
"anytime": "@:global.text.anytime",
"today": "@:common.today",
"yesterday": "@:common.yesterday",
"this_week": "@:global.text.this_week",
"last_week": "@:global.text.last_week",
"last_seven_days": "@:global.text.last_7_days",
"this_month": "@:global.text.this_month",
"last_month": "@:global.text.last_month",
"same_week_last_year": "@:global.text.same_week_last_year",
"same_month_last_year": "@:global.text.same_month_last_year"
}
},
"global": {
"abnormal": "@:{'phrases.compat.global.abnormal'}",
"actions": "@:common.actions",
@@ -219,8 +250,10 @@
"tax": "@:common.tax",
"text": {
"all": "@:{'phrases.compat.global.text.all'}",
"anytime": "@:{'phrases.compat.global.text.anytime'}",
"last_7_days": "@:{'phrases.compat.global.text.last_7_days'}",
"last_month": "@:{'phrases.compat.global.text.last_month'}",
"other_month": "@:{'phrases.compat.global.text.other_month'}",
"last_week": "@:{'phrases.compat.global.text.last_week'}",
"month": "@:{'phrases.compat.global.month'}",
"same_month_last_year": "@:{'phrases.compat.global.text.same_month_last_year'}",
@@ -1,6 +1,9 @@
{
"maintenance_menu": {
"confirm_clear_local": "@:{'phrases.compat.maintenance_menu.confirm_clear_local'}",
"copy_trace_copied": "@:{'phrases.compat.maintenance_menu.copy_trace_copied'}",
"copy_trace_failed": "@:{'phrases.compat.maintenance_menu.copy_trace_failed'}",
"copy_trace": "@:{'phrases.compat.maintenance_menu.copy_trace'}",
"force_update_clear_hint": "@:{'phrases.compat.maintenance_menu.force_update_clear_hint'}",
"force_update_clear": "@:{'phrases.compat.maintenance_menu.force_update_clear'}",
"title": "@:{'phrases.compat.maintenance_menu.title'}",
@@ -289,9 +289,9 @@
},
"description": "@:{'phrases.compat.objects.orders.description'}",
"entries": "@:global.wash_multiple",
"multiple": "@:{'phrases.compat.common.bookings'}",
"multiple": "@:global.wash_multiple",
"single": "@:{'phrases.compat.common.order'}",
"title": "@:{'phrases.compat.common.bookings'}"
"title": "@:global.wash_multiple"
},
"permissions": {
"description": "@:{'phrases.compat.objects.permissions.description'}",
@@ -152,6 +152,74 @@
"error_occurred": "@:{'phrases.compat.superuser.statistics.error_occurred'}",
"loading": "@:{'phrases.compat.messages.loading'}"
},
"user_detail": {
"overview_header_help": "Use the workspace shortcuts and direct actions below to manage this customer account.",
"workspace_shortcuts_help": "Jump straight into the user-specific workspaces for this account.",
"attribute_help": "Hover an attribute to see what it changes for the customer.",
"subtitles": {
"overview": "Customer overview and management workspace",
"orders": "Invoice handling, open drafts, and order history",
"pricing": "Customer-specific discounts and fixed product prices",
"other": "Special arrangements and internal customer notes",
"security": "Permissions and customer rule access",
"vehicles": "Vehicles, subscriptions, and mass registration tools",
"xlvask": "XLVask account details and related imported vehicles"
},
"orders": {
"summary_title": "Order invoicing overview",
"summary_subtitle": "Review how this customer is invoiced before opening the full order list.",
"invoicing_mode": "Invoicing mode",
"open_draft": "Open invoice draft"
},
"pricing": {
"summary_title": "Pricing overview",
"summary_subtitle": "Inspect the customer-wide discount policy and product-specific overrides.",
"table_title": "Product pricing overrides",
"table_subtitle": "Adjust fixed prices and discount rules for each available product.",
"global_discount": "Global discount",
"product_overrides": "Product overrides",
"discount_percent": "Discount percent",
"fixed_price_label": "Fixed price",
"fixed_price_validation": "Please enter a valid non-negative whole number.",
"category_discount_disabled": "Category discount disabled",
"fixed_price_tooltip": "Edit the fixed price for this product.",
"item_discount_tooltip": "Edit the customer-specific discount for this product.",
"category_discount_tooltip": "Edit the category discount used for this product."
},
"other": {
"summary_title": "Special arrangements",
"summary_subtitle": "Store account-specific notes and billing details that affect day-to-day operations."
},
"vehicles": {
"summary_title": "Vehicle workspace",
"summary_subtitle": "Review the customer's vehicles, subscriptions, and import workflows.",
"subscription_invoicing_subtitle": "Manage active wash subscriptions and create invoice drafts for other periods."
},
"overview": {
"customer_actions_title": "Customer actions",
"customer_actions_subtitle": "Run customer-specific actions without leaving the overview.",
"customer_shortcuts_title": "Customer shortcuts",
"customer_shortcuts_subtitle": "Jump to the most common customer workspaces from the overview.",
"customer_flags_title": "Customer flags",
"customer_flags_subtitle": "Create billing-period flags for this customer when follow-up is needed.",
"fixed_pricing_title": "Fixed pricing",
"fixed_pricing_subtitle": "Manage the customer-wide fixed pricing agreement.",
"default_department_title": "Default department",
"default_department_subtitle": "Choose which department should be preselected for this customer.",
"special_arrangement_title": "Special arrangement",
"special_arrangement_subtitle": "Store operational notes about the customer's special agreement.",
"wash_subscription_note_title": "Wash subscription note",
"wash_subscription_note_subtitle": "Store the internal note used for wash subscription invoicing."
},
"xlvask": {
"summary_title": "XLVask customer profile",
"summary_subtitle": "Inspect the linked XLVask customer record before importing vehicles.",
"vehicles_title": "XLVask vehicles",
"vehicles_subtitle": "Vehicles available in XLVask for this linked customer.",
"not_found": "No XLVask customer record is linked to this account yet.",
"customer_id": "XLVask ID"
}
},
"user": {
"default_department": {
"not_implemented_desc": "@:superuser.user.fixed_pricing.not_implemented_desc",
@@ -47,8 +47,44 @@
"gateways": "@.capitalize:{'terms.glossary.gateways'}",
"stripe": "@:{'terms.glossary.stripe'}",
"pricing": "Pricing",
"customer_pricing": "Customer pricing",
"categories": "Categories"
},
"department_pages": {
"branding": {
"subtitle": "Brand identity, contact details, and department profile"
},
"categories": {
"subtitle": "Browse and maintain the department's category catalogue"
},
"gateways": {
"subtitle": "Gateway coverage, lane bindings, scanners, and relay health"
},
"modules": {
"subtitle": "Toggle department-wide module settings and integration flags",
"variables": {
"bookingsystem_enabled": "Bookingsystem enabled",
"bookingsystem_time_based_enabled": "Time-based bookingsystem enabled",
"bookingsystem_time_based_password": "Time-based bookingsystem password",
"exclude_from_invoicing": "Excluded from invoicing",
"workfeed_department_id": "Workfeed department"
}
},
"pricing": {
"subtitle": "Review the department's explicit price overrides"
},
"customer_pricing": {
"subtitle": "Maintain customer-specific prices and discounts for this department"
},
"stripe": {
"readers": {
"subtitle": "Payment terminals currently associated with this department"
},
"setup": {
"subtitle": "Assign the Stripe location used by this department"
}
}
},
"department_overview": {
"title": "Department overview",
"subtitle": "Operational overview for the selected department",
@@ -8,6 +8,7 @@
"currency": "@:{'phrases.compat.user_admin.currency'}",
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"cvr": "@:{'phrases.compat.user_admin.cvr'}",
"edit_user": "Edit user",
"economic_data": "@:{'phrases.compat.user_admin.economic_data'}",
"email": "@:common.email",
"group_id": "@:{'phrases.compat.user_admin.group_id'}",
@@ -52,6 +53,7 @@
"title": "@:{'phrases.compat.common.user'}",
"updated_at": "@:{'phrases.compat.objects.columns.updated_at'}",
"user_data": "@:{'phrases.compat.user_admin.user_data'}",
"user_edited_successfully": "User edited successfully",
"user_id": "@:{'phrases.compat.user_admin.user_id'}",
"variables": "@:{'phrases.compat.user_admin.variables'}",
"wash_subscriptions": "@:{'phrases.compat.user_admin.wash_subscriptions'}",
@@ -174,8 +174,10 @@
"tank_cleaning": "@.capitalize:{'terms.glossary.tankrengjøring'}",
"text": {
"all": "Alltid",
"anytime": "Når som helst",
"last_7_days": "@:{'terms.glossary.siste'} 7 @:{'terms.glossary.dager'}",
"last_month": "@.capitalize:{'terms.glossary.forrige'} @:{'terms.glossary.maned'}",
"other_month": "@.capitalize:{'terms.glossary.annen'} @:{'terms.glossary.maned'}",
"last_week": "@.capitalize:{'terms.glossary.forrige'} @:{'terms.glossary.uke'}",
"same_month_last_year": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.maned'} @:{'terms.glossary.i'} @:{'terms.glossary.fjor'}",
"same_week_last_year": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.uke'} @:{'terms.glossary.i'} @:{'terms.glossary.fjor'}",
@@ -2,6 +2,9 @@
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Fjern alle lokale appdata og last inn på nytt nå?",
"copy_trace_copied": "Kopiert",
"copy_trace_failed": "Kopiering feilet",
"copy_trace": "Kopier trace",
"force_update_clear_hint": "Fjerner local storage, session storage, service workers og nettlesercacher.",
"force_update_clear": "Tving oppdatering og fjern alt lokalt",
"title": "Vedlikehold",
+18
View File
@@ -22,6 +22,7 @@
"cancel": "@:{'phrases.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'phrases.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'phrases.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'phrases.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -294,6 +295,14 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -339,6 +348,7 @@
"cancel": "Avbryt",
"nav": {
"prices": "@.capitalize:{'terms.glossary.priser'}",
"customer_pricing": "Kundepriser",
"employees": "@.capitalize:{'terms.glossary.medarbeidere'}"
},
"forbidden": {
@@ -611,6 +621,14 @@
"label": "Administrer avdelingspriser",
"description": "Kan redigere eksplisitte priser for tildelte avdelinger."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Administrer ansattilgang",
"description": "Kan opprette, redigere og deaktivere begrensede backofficeansatte."
@@ -174,8 +174,10 @@
"tank_cleaning": "@.capitalize:{'terms.glossary.tank'} @:{'terms.glossary.cleaning'}",
"text": {
"all": "Alltid",
"anytime": "När som helst",
"last_7_days": "@.capitalize:{'terms.glossary.last_2'} 7 days",
"last_month": "@.capitalize:{'terms.glossary.forra'} @:{'terms.glossary.manaden'}",
"other_month": "@.capitalize:{'terms.glossary.annan'} @:{'terms.glossary.manad'}",
"last_week": "@.capitalize:{'terms.glossary.forra'} veckan",
"same_month_last_year": "@.capitalize:{'terms.glossary.samma'} @:{'terms.glossary.manad'} @:{'terms.glossary.forra'} @:{'terms.glossary.aret'}",
"same_week_last_year": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.week'} @:{'terms.glossary.last_2'} year",
@@ -2,6 +2,9 @@
"compat": {
"maintenance_menu": {
"confirm_clear_local": "Rensa all lokal appdata och ladda om nu?",
"copy_trace_copied": "Kopierat",
"copy_trace_failed": "Kopiering misslyckades",
"copy_trace": "Kopiera trace",
"force_update_clear_hint": "Rensar local storage, session storage, service workers och webbläsarcacher.",
"force_update_clear": "Tvinga uppdatering och rensa allt lokalt",
"title": "Underhåll",
+18
View File
@@ -22,6 +22,7 @@
"cancel": "@:{'phrases.compat.limited_backoffice.cancel'}",
"nav": {
"prices": "@:{'phrases.compat.limited_backoffice.nav.prices'}",
"customer_pricing": "@:{'phrases.compat.limited_backoffice.nav.customer_pricing'}",
"employees": "@:{'phrases.compat.limited_backoffice.nav.employees'}"
},
"forbidden": {
@@ -294,6 +295,14 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_department_prices.description'}"
},
"view_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description'}"
},
"manage_customer_pricing": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description'}"
},
"manage_employee_access": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.manage_employee_access.description'}"
@@ -339,6 +348,7 @@
"cancel": "Avbryt",
"nav": {
"prices": "@.capitalize:{'terms.glossary.priser'}",
"customer_pricing": "Kundpriser",
"employees": "@.capitalize:{'terms.glossary.medarbetare'}"
},
"forbidden": {
@@ -611,6 +621,14 @@
"label": "Hantera avdelningspriser",
"description": "Kan redigera uttryckliga priser för tilldelade avdelningar."
},
"view_customer_pricing": {
"label": "View customer pricing",
"description": "Can view department-specific customer prices and discounts for assigned departments."
},
"manage_customer_pricing": {
"label": "Manage customer pricing",
"description": "Can edit department-specific customer prices and discounts for assigned departments."
},
"manage_employee_access": {
"label": "Hantera medarbetaråtkomst",
"description": "Kan skapa, redigera och inaktivera begränsade backofficemedarbetare."
+34
View File
@@ -91,6 +91,7 @@ const UserOrders = lazyView('@/views/dashboards/superUserDashboard/user/UserOrde
const UserPricing = lazyView('@/views/dashboards/superUserDashboard/user/UserPricing.vue');
const Department = lazyView('@/views/dashboards/superUserDashboard/department/Department.vue');
const DepartmentPricing = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentPricing.vue');
const DepartmentCustomerPricing = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentCustomerPricing.vue');
const MyOrder = lazyView('@/views/dashboards/userDashboard/orders/MyOrder.vue');
const StatisticsOverview = lazyView('@/views/dashboards/superUserDashboard/statistics/StatisticsOverview.vue');
const Configuration = lazyView('@/views/dashboards/superUserDashboard/configuration/Configuration.vue');
@@ -117,6 +118,7 @@ const InvoiceDistributionMonthView = lazyView('@/views/dashboards/superUserDashb
const DepartmentDailyReport = lazyView('@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue');
const SuperUserDashboardProduct = lazyView('@/views/dashboards/superUserDashboard/products/SuperUserDashboardProduct.vue');
const UserOther = lazyView('@/views/dashboards/superUserDashboard/user/UserOther.vue');
const UserSecurity = lazyView('@/views/dashboards/superUserDashboard/user/UserSecurity.vue');
//const MyBookingsNew = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsNew.vue');
const DepartmentCompleteBooking = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentCompleteBooking.vue');
const GuestHome = lazyView('@/views/guest/GuestHome.vue');
@@ -185,8 +187,10 @@ const SubuserGrants = lazyView('@/views/dashboards/superUserDashboard/SubuserGra
const SubuserLogin = lazyView('@/views/auth/SubuserLogin.vue');
const SystemSearchRecordPage = lazyView('@/views/search/SystemSearchRecordPage.vue');
const EconomicQueuePlaywrightHarness = lazyView('@/views/testing/EconomicQueuePlaywrightHarness.vue');
const DatePeriodSelectorPlaywrightHarness = lazyView('@/views/testing/DatePeriodSelectorPlaywrightHarness.vue');
const LimitedBackofficeHome = lazyView('@/views/backoffice/LimitedBackofficeHome.vue');
const LimitedBackofficePrices = lazyView('@/views/backoffice/LimitedBackofficePrices.vue');
const LimitedBackofficeCustomerPricing = lazyView('@/views/backoffice/LimitedBackofficeCustomerPricing.vue');
const LimitedBackofficeEmployees = lazyView('@/views/backoffice/LimitedBackofficeEmployees.vue');
/**
@@ -209,6 +213,12 @@ export const router = createRouter({
path: '/__e2e/economic-queue',
component: EconomicQueuePlaywrightHarness,
meta: { template: 'clear-main' }
},
{
name: 'e2eDatePeriodSelectorHarness',
path: '/__e2e/date-period-selector',
component: DatePeriodSelectorPlaywrightHarness,
meta: { template: 'clear-main' }
}
] : []),
{
@@ -440,6 +450,12 @@ export const router = createRouter({
component: LimitedBackofficePrices,
meta: { middleware: authMiddleware, titleKey: 'templates.limited_backoffice.prices.title' }
},
{
name: 'limitedBackofficeCustomerPricing',
path: '/backoffice/departments/:departmentId/customer-pricing',
component: LimitedBackofficeCustomerPricing,
meta: { middleware: authMiddleware, titleKey: 'departments.customer_pricing.title' }
},
{
name: 'limitedBackofficeEmployees',
path: '/backoffice/departments/:departmentId/employees',
@@ -882,9 +898,21 @@ export const router = createRouter({
{
name: 'departmentspricing',
path: '/superuser/departments/:departmentId/pricing',
redirect: (to) => `/superuser/departments/${encodeURIComponent(String(to.params.departmentId))}/prices`,
meta: { middleware: superUserMiddleware }
},
{
name: 'departmentsprices',
path: '/superuser/departments/:departmentId/prices',
component: DepartmentPricing,
meta: { middleware: superUserMiddleware }
},
{
name: 'departmentscustomerpricing',
path: '/superuser/departments/:departmentId/customer-pricing',
component: DepartmentCustomerPricing,
meta: { middleware: superUserMiddleware }
},
{
name: 'departmentsbranding',
path: '/superuser/departments/:departmentId/branding',
@@ -971,6 +999,12 @@ export const router = createRouter({
component: UserOther,
meta: { middleware: superUserMiddleware }
},
{
name: 'usersecurity',
path: '/superuser/users/:userId/security',
component: UserSecurity,
meta: { middleware: superUserMiddleware }
},
{
name: 'uservehicles',
path: '/superuser/users/:userId/vehicles',
+140
View File
@@ -0,0 +1,140 @@
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const MONTH_ONLY_PATTERN = /^(\d{4})-(\d{2})$/;
const normalizeLocale = (locale) => String(locale || "da").trim() || "da";
const padDatePart = (value) => String(value).padStart(2, "0");
export const normalizeDatepickerDate = (value) => {
if (value instanceof Date) {
return Number.isNaN(value.getTime())
? null
: new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
const stringValue = String(value ?? "").trim();
if (!stringValue) {
return null;
}
const parsed = parseLocalDateOnly(stringValue);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const normalizeDatepickerMonth = (value) => {
if (value instanceof Date) {
return Number.isNaN(value.getTime())
? null
: new Date(value.getFullYear(), value.getMonth(), 1);
}
const stringValue = String(value ?? "").trim();
if (!stringValue) {
return null;
}
const monthMatch = MONTH_ONLY_PATTERN.exec(stringValue);
if (monthMatch) {
const year = Number(monthMatch[1]);
const month = Number(monthMatch[2]);
const parsed = new Date(year, month - 1, 1);
return parsed.getFullYear() === year && parsed.getMonth() === month - 1 ? parsed : null;
}
const dateMatch = DATE_ONLY_PATTERN.exec(stringValue);
if (dateMatch) {
return normalizeDatepickerDate(stringValue);
}
const parsed = new Date(stringValue);
return Number.isNaN(parsed.getTime()) ? null : new Date(parsed.getFullYear(), parsed.getMonth(), 1);
};
export const formatDatepickerDateForApi = (value) => {
const date = normalizeDatepickerDate(value);
return date ? formatLocalDateOnly(date) : "";
};
export const formatDatepickerMonthForApi = (value) => {
const date = normalizeDatepickerMonth(value);
if (!date) {
return "";
}
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}`;
};
export const formatDatepickerDateForLocale = (value, locale) => {
const date = normalizeDatepickerDate(value);
if (!date) {
return "";
}
return new Intl.DateTimeFormat(normalizeLocale(locale), {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
};
export const formatDatepickerMonthForLocale = (value, locale) => {
const date = normalizeDatepickerMonth(value);
if (!date) {
return "";
}
return new Intl.DateTimeFormat(normalizeLocale(locale), {
year: "numeric",
month: "long",
}).format(date);
};
export const parseDatepickerInput = (value, locale) => {
const stringValue = String(value ?? "").trim();
if (!stringValue) {
return null;
}
const direct = normalizeDatepickerDate(stringValue);
if (direct) {
return direct;
}
const parts = new Intl.DateTimeFormat(normalizeLocale(locale))
.formatToParts(new Date(2000, 11, 25))
.filter((part) => part.type !== "literal")
.map((part) => part.type);
const numbers = stringValue.match(/\d+/g)?.map(Number) || [];
if (numbers.length < 3) {
return null;
}
const values = {};
parts.forEach((type, index) => {
values[type] = numbers[index];
});
const year = values.year;
const month = values.month;
const day = values.day;
if (!year || !month || !day) {
return null;
}
const parsed = new Date(year, month - 1, day);
return parsed.getFullYear() === year && parsed.getMonth() === month - 1 && parsed.getDate() === day
? parsed
: null;
};
export const parseMonthpickerInput = (value, locale) => {
const direct = normalizeDatepickerMonth(value);
if (direct) {
return direct;
}
const parsed = new Date(String(value ?? ""));
return Number.isNaN(parsed.getTime()) ? null : new Date(parsed.getFullYear(), parsed.getMonth(), 1);
};
+4
View File
@@ -6,6 +6,10 @@ 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 (value === null) {
return "";
}
if (typeof value === "string") {
const directMatch = value.trim().match(DATE_ONLY_PREFIX_PATTERN);
if (directMatch) {
+64
View File
@@ -0,0 +1,64 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { limitedBackofficeRequest } from "@/services/limitedBackoffice.js";
const positiveIntegerOrNull = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const customerIdentifierPayload = ({ userId = null, customerNumber = null } = {}) => {
const parsedUserId = positiveIntegerOrNull(userId);
if (parsedUserId) {
return { user_id: parsedUserId };
}
const parsedCustomerNumber = positiveIntegerOrNull(customerNumber);
if (parsedCustomerNumber) {
return { customer_number: parsedCustomerNumber };
}
return {};
};
const queryStringFrom = (params) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
});
return searchParams.toString();
};
export const getSuperuserDepartmentCustomerPricing = (departmentId, customerIdentifier) => {
const query = queryStringFrom({
department_id: departmentId,
...customerIdentifierPayload(customerIdentifier),
});
return authenticatedRequest(`/superuser/department/customer-pricing?${query}`, "GET");
};
export const updateSuperuserDepartmentCustomerPricing = (departmentId, customerIdentifier, overrides) =>
authenticatedRequest("/superuser/department/customer-pricing", "PUT", {
department_id: positiveIntegerOrNull(departmentId),
...customerIdentifierPayload(customerIdentifier),
overrides,
});
export const getLimitedBackofficeDepartmentCustomerPricing = (departmentId, customerIdentifier) => {
const query = queryStringFrom(customerIdentifierPayload(customerIdentifier));
return limitedBackofficeRequest(
`/departments/${encodeURIComponent(String(departmentId))}/customer-pricing?${query}`,
"GET"
);
};
export const updateLimitedBackofficeDepartmentCustomerPricing = (departmentId, customerIdentifier, overrides) =>
limitedBackofficeRequest(`/departments/${encodeURIComponent(String(departmentId))}/customer-pricing`, "PUT", {
...customerIdentifierPayload(customerIdentifier),
overrides,
});
export const unwrapDepartmentCustomerPricingResponse = (response) => response?.data?.data ?? response?.data ?? null;
+1
View File
@@ -58,6 +58,7 @@ export const installAxiosRequestQueue = () => {
data: adapterConfig?.data ?? config?.data ?? null,
headers: adapterConfig?.headers ?? config?.headers ?? null,
},
traceContext: adapterConfig?.__traceContext ?? config?.__traceContext ?? null,
signal: adapterConfig?.signal ?? config?.signal,
});
return config;
+87
View File
@@ -0,0 +1,87 @@
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const normalizeLocale = (locale) => String(locale || "en").trim() || "en";
const toDate = (value) => {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
const stringValue = String(value ?? "").trim();
if (!stringValue) {
return null;
}
if (DATE_ONLY_PATTERN.test(stringValue)) {
const [year, month, day] = stringValue.split("-").map((part) => Number(part));
const parsed = new Date(year, month - 1, day);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
const parsed = new Date(stringValue.replace(" ", "T"));
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const formatLocaleNumber = (value, locale, options = {}) => {
const parsed = Number(value ?? 0);
const safeValue = Number.isFinite(parsed) ? parsed : 0;
return new Intl.NumberFormat(normalizeLocale(locale), options).format(safeValue);
};
export const formatLocaleDate = (value, locale, options = {}) => {
const date = toDate(value);
if (!date) {
return "";
}
return new Intl.DateTimeFormat(normalizeLocale(locale), {
year: "numeric",
month: "short",
day: "numeric",
...options,
}).format(date);
};
export const formatLocaleDateTime = (value, locale, options = {}) => {
const date = toDate(value);
if (!date) {
return "";
}
return new Intl.DateTimeFormat(normalizeLocale(locale), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
...options,
}).format(date);
};
export const formatLocaleMonthLabel = (value, locale, options = {}) => {
const date = toDate(value);
if (!date) {
return "";
}
return new Intl.DateTimeFormat(normalizeLocale(locale), {
month: "long",
year: "numeric",
...options,
}).format(date);
};
export const formatLocaleDateRange = (from, to, locale) => {
const start = formatLocaleDate(from, locale);
const end = formatLocaleDate(to, locale);
if (!start) {
return end;
}
if (!end || start === end) {
return start;
}
return `${start} - ${end}`;
};
+53
View File
@@ -0,0 +1,53 @@
import { parseLocalDateOnly } from "@/services/dateOnly.js";
const ORDER_DATE_FILTER_KEYS = new Set(["created_at-date_from", "created_at-date_to"]);
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export const ORDER_DATE_EVENT_TYPE = "is-info";
export const stripOrderDateFilters = (filters = "") => {
const filterParts = String(filters || "")
.split(",")
.map((filter) => filter.trim())
.filter(Boolean);
const retainedFilters = filterParts.filter((filter) => {
const [key] = filter.split(":");
return !ORDER_DATE_FILTER_KEYS.has(key);
});
return retainedFilters.length > 0 ? retainedFilters.join(",") : null;
};
export const buildOrderDateEvents = (orders = []) => {
const dates = new Set();
orders.forEach((order) => {
const datePart = String(order?.created_at || "").slice(0, 10);
if (DATE_ONLY_PATTERN.test(datePart)) {
dates.add(datePart);
}
});
return [...dates].sort().map((datePart) => ({
date: parseLocalDateOnly(datePart),
type: ORDER_DATE_EVENT_TYPE,
}));
};
export const buildOrderDateEventRequestParams = ({
filters = null,
search = null,
additionalQueryParameters = {},
orderBy = "created_at",
orderDirection = "desc",
page = 1,
limit = 1000,
} = {}) => ({
page,
limit,
search,
filters: stripOrderDateFilters(filters),
order: `${orderBy || "created_at"}:${orderDirection || "desc"}`,
...additionalQueryParameters,
});
+142
View File
@@ -0,0 +1,142 @@
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { endOfLocalDate, startOfLocalDate } from "@/services/dateOnly.js";
const today = () => new Date();
const cloneDate = (value) => new Date(value.getTime());
const shiftDays = (value, days) => {
const date = cloneDate(value);
date.setDate(date.getDate() + days);
return date;
};
const startOfWeek = (value) => {
const date = cloneDate(value);
const day = date.getDay();
const offset = day === 0 ? -6 : 1 - day;
date.setDate(date.getDate() + offset);
return startOfLocalDate(date);
};
const endOfWeek = (value) => {
const start = startOfWeek(value);
const end = shiftDays(start, 6);
return endOfLocalDate(end);
};
const startOfMonth = (value) => startOfLocalDate(new Date(value.getFullYear(), value.getMonth(), 1));
const endOfMonth = (value) => endOfLocalDate(new Date(value.getFullYear(), value.getMonth() + 1, 0));
const relativeDayRange = (offsetDays = 0) => {
const date = shiftDays(today(), offsetDays);
return {
startDate: startOfLocalDate(date),
endDate: endOfLocalDate(date),
};
};
const relativeWeekRange = (offsetWeeks = 0) => {
const weekStart = shiftDays(startOfWeek(today()), offsetWeeks * 7);
return {
startDate: startOfLocalDate(weekStart),
endDate: endOfWeek(weekStart),
};
};
const relativeMonthRange = (offsetMonths = 0) => {
const date = today();
date.setMonth(date.getMonth() + offsetMonths);
return {
startDate: startOfMonth(date),
endDate: endOfMonth(date),
};
};
const lastSevenDaysRange = () => {
const endDate = endOfLocalDate(today());
const startDate = startOfLocalDate(shiftDays(endDate, -6));
return { startDate, endDate };
};
export const buildRelativeDateShortcuts = () => [
{
key: "anytime",
label: SessionUser.objects.global.language.text.anytime,
getRange: () => ({
startDate: null,
endDate: null,
}),
},
{
key: "today",
label: SessionUser.objects.global.language.text.today,
getRange: () => relativeDayRange(0),
},
{
key: "yesterday",
label: SessionUser.objects.global.language.text.yesterday,
getRange: () => relativeDayRange(-1),
},
{
key: "last_seven_days",
label: SessionUser.objects.global.language.text.last_7_days,
getRange: () => lastSevenDaysRange(),
},
{
key: "this_week",
label: SessionUser.objects.global.language.text.this_week,
getRange: () => relativeWeekRange(0),
},
{
key: "last_week",
label: SessionUser.objects.global.language.text.last_week,
getRange: () => relativeWeekRange(-1),
},
{
key: "this_month",
label: SessionUser.objects.global.language.text.this_month,
getRange: () => relativeMonthRange(0),
},
{
key: "last_month",
label: SessionUser.objects.global.language.text.last_month,
getRange: () => relativeMonthRange(-1),
},
{
key: "same_week_last_year",
label: SessionUser.objects.global.language.text.same_week_last_year,
getRange: () => {
const thisWeekStart = startOfWeek(today());
thisWeekStart.setFullYear(thisWeekStart.getFullYear() - 1);
const thisWeekEnd = shiftDays(thisWeekStart, 6);
return {
startDate: startOfLocalDate(thisWeekStart),
endDate: endOfLocalDate(thisWeekEnd),
};
},
},
{
key: "same_month_last_year",
label: SessionUser.objects.global.language.text.same_month_last_year,
getRange: () => relativeMonthRange(-12),
},
];
export const splitRelativeDateShortcuts = (shortcuts, primaryKeys = []) => {
const primary = [];
const other = [];
const primaryKeySet = new Set(primaryKeys);
shortcuts.forEach((shortcut) => {
if (primaryKeySet.has(shortcut.key)) {
primary.push(shortcut);
return;
}
other.push(shortcut);
});
return { primary, other };
};
+222
View File
@@ -0,0 +1,222 @@
import { DEFAULT_STABLE_API_URL } from "@/config.js";
import {
getRecentFrontendFailureEvents,
redactReleasePayload,
} from "@/services/releaseTimeline.js";
const REDACTED_VALUE = "[redacted]";
const EMPTY_VALUE = "none";
const NOT_CAPTURED_VALUE = "not captured";
const SENSITIVE_QUERY_KEYS = /authorization|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
const STABLE_API_PREFIX = DEFAULT_STABLE_API_URL.replace(/\/+$/, "");
const TRACE_TEXT_MAX_CHARS = 4000;
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value || {}, key);
const truncateTraceText = (value) => {
const text = String(value ?? "");
return text.length > TRACE_TEXT_MAX_CHARS ? `${text.slice(0, TRACE_TEXT_MAX_CHARS)}\n... [truncated]` : text;
};
const normalizeWhitespace = (value) => String(value || "").replace(/\s+/g, " ").trim();
const currentWindowUrl = () => {
if (typeof window === "undefined" || !window.location) {
return "";
}
return window.location.href || "";
};
export const redactTraceUrl = (value = currentWindowUrl()) => {
const rawUrl = String(value || "");
if (!rawUrl) {
return NOT_CAPTURED_VALUE;
}
try {
const url = new URL(rawUrl, typeof window !== "undefined" ? window.location.origin : "https://truckwash.io");
Array.from(url.searchParams.keys()).forEach((key) => {
if (SENSITIVE_QUERY_KEYS.test(key)) {
url.searchParams.set(key, REDACTED_VALUE);
}
});
if (url.hash && SENSITIVE_QUERY_KEYS.test(url.hash)) {
url.hash = REDACTED_VALUE;
}
return url.toString().replace(/%5Bredacted%5D/gi, REDACTED_VALUE);
} catch {
return SENSITIVE_QUERY_KEYS.test(rawUrl) ? REDACTED_VALUE : rawUrl;
}
};
export const stripStableApiPrefix = (value = "") => {
const url = String(value || "").trim();
if (!url) {
return "(unknown endpoint)";
}
if (url === STABLE_API_PREFIX) {
return "/";
}
if (url.startsWith(`${STABLE_API_PREFIX}/`)) {
return url.slice(STABLE_API_PREFIX.length);
}
return url;
};
const formatDuration = (value) => {
const milliseconds = Number(value);
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
return null;
}
if (milliseconds < 1000) {
return `${Number.isInteger(milliseconds) ? milliseconds : milliseconds.toFixed(1)} ms`;
}
return `${(milliseconds / 1000).toFixed(2)} s`;
};
const parseServerTimingDurations = (value) => {
if (typeof value !== "string" || value.trim().length === 0) {
return [];
}
return value.split(",").flatMap((part) => {
const [rawMetric, ...rawParams] = part.trim().split(";");
const metric = rawMetric.trim();
const durationParam = rawParams.find((param) => param.trim().toLowerCase().startsWith("dur="));
if (!metric || !durationParam) {
return [];
}
const duration = Number.parseFloat(durationParam.split("=").slice(1).join("="));
if (!Number.isFinite(duration)) {
return [];
}
return [{ metric, duration }];
});
};
export const formatServerResponseTime = (request = {}) => {
const durations = parseServerTimingDurations(request?.serverTiming);
if (durations.length > 0) {
return durations
.slice(0, 6)
.map(({ metric, duration }) => `${metric} ${formatDuration(duration)}`)
.join("; ");
}
const clientDuration = formatDuration(request?.requestDurationMs);
return clientDuration ? `unknown (client observed ${clientDuration})` : "unknown";
};
const formatTraceValue = (value, emptyLabel = EMPTY_VALUE) => {
if (value === null || value === undefined || value === "") {
return emptyLabel;
}
const redacted = redactReleasePayload(value);
if (redacted === null || redacted === undefined || redacted === "") {
return emptyLabel;
}
if (typeof redacted === "string") {
return truncateTraceText(redacted);
}
try {
return truncateTraceText(JSON.stringify(redacted, null, 2));
} catch {
return truncateTraceText(String(redacted));
}
};
const normalizeTraceContext = (value) => {
if (typeof value === "string") {
return { component: normalizeWhitespace(value), trace: "" };
}
if (!value || typeof value !== "object") {
return { component: "", trace: "" };
}
return {
component: normalizeWhitespace(value.component || value.owner || value.source || ""),
trace: String(value.trace || value.componentTrace || value.stack || "").trim(),
};
};
const routeContextLabel = (routeContext = {}) => {
const name = normalizeWhitespace(routeContext.name || routeContext.routeName || "");
const path = normalizeWhitespace(routeContext.fullPath || routeContext.path || "");
if (name && path) {
return `${name} (${path})`;
}
return name || path || "";
};
const latestFrontendTrace = () => {
const [event] = getRecentFrontendFailureEvents();
if (!event) {
return "";
}
return String(event.payload?.trace || event.payload?.stack || "").trim();
};
const requestBodyForTrace = (request = {}) => {
if (request?.request && hasOwn(request.request, "data")) {
return request.request.data;
}
return null;
};
const responseBodyForTrace = (request = {}) => {
if (request?.response && hasOwn(request.response, "data")) {
return request.response.data;
}
return request?.responseText || null;
};
const responseStatusForTrace = (request = {}) =>
request?.response?.status ?? request?.statusCode ?? "n/a";
export const buildRequestErrorTraceText = (request = {}, options = {}) => {
const traceContext = normalizeTraceContext(request?.traceContext);
const activeUrl = redactTraceUrl(options.activeUrl);
const endpoint = stripStableApiPrefix(request?.url);
const method = String(request?.method || "GET").toUpperCase();
const component = traceContext.component || routeContextLabel(options.routeContext) || NOT_CAPTURED_VALUE;
const componentTrace = traceContext.trace || latestFrontendTrace() || NOT_CAPTURED_VALUE;
const requestBody = formatTraceValue(requestBodyForTrace(request));
const responseBody = formatTraceValue(responseBodyForTrace(request), "none captured");
const serverResponseTime = formatServerResponseTime(request);
const recreate = `Open the active URL, repeat the action in ${component}, and observe ${method} ${endpoint} failing.`;
return [
"Request Error Trace",
`Active URL: ${activeUrl}`,
`Initializing component: ${component}`,
"Component trace:",
componentTrace,
"Request:",
`Method: ${method}`,
`Endpoint: ${endpoint}`,
"Request body:",
requestBody,
"Response:",
`Status: ${responseStatusForTrace(request)}`,
"Response body:",
responseBody,
`Server response time: ${serverResponseTime}`,
`Recreate: ${recreate}`,
].join("\n");
};
+34 -5
View File
@@ -1,6 +1,6 @@
import { reactive, readonly } from "vue";
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import { recordReleaseTimelineEvent } from "@/services/releaseTimeline.js";
import { recordReleaseTimelineEvent, redactReleasePayload } from "@/services/releaseTimeline.js";
const cloneObject = (value) => ({ ...(value || {}) });
@@ -90,6 +90,27 @@ const normalizeInsightKey = (value) => {
return value.trim().toLowerCase();
};
const normalizeTraceContext = (value) => {
if (typeof value === "string") {
return { component: value.trim(), trace: null };
}
if (!value || typeof value !== "object") {
return null;
}
const component = String(value.component || value.owner || value.source || "").trim();
const trace = String(value.trace || value.componentTrace || value.stack || "").trim();
if (!component && !trace) {
return null;
}
return {
component: component || null,
trace: trace || null,
};
};
const normalizeUrl = (value) => {
if (typeof value !== "string" || value.trim().length === 0) {
return "(unknown endpoint)";
@@ -137,6 +158,8 @@ const toSafeText = (value) => {
return `${text.slice(0, maxChars)}\n... [truncated]`;
};
const toRedactedPayload = (value) => redactReleasePayload(value);
const measureTextBytes = (value) => {
const text = toSafeText(value);
if (!text) {
@@ -668,21 +691,21 @@ const runJob = (job) => {
const statusCode = getErrorStatusCode(error);
const attemptCount = Math.max(1, Number(error?.__queueAttemptCount) || 1);
const requestSnapshot = {
const requestSnapshot = toRedactedPayload({
method,
url: job.url,
params: job.requestData?.params ?? null,
data: job.requestData?.data ?? null,
headers: redactHeaders(job.requestData?.headers ?? null),
};
const responseSnapshot = {
});
const responseSnapshot = toRedactedPayload({
status: statusCode,
statusText: error?.response?.statusText ?? null,
code: error?.code ?? null,
message: error?.message ?? "Request failed",
data: error?.response?.data ?? null,
headers: redactHeaders(error?.response?.headers ?? null),
};
});
if (job.trackProgressCounters !== false) {
requestQueueStateMutable.batchFailed += 1;
}
@@ -712,6 +735,9 @@ const runJob = (job) => {
attemptCount,
requestDurationMs: Math.max(0, completedAt - startedAt),
serverTiming: getServerTimingHeader(error?.response),
request: requestSnapshot,
response: responseSnapshot,
traceContext: job.traceContext,
requestText: toSafeText(requestSnapshot),
responseText: toSafeText(responseSnapshot),
});
@@ -724,11 +750,13 @@ const runJob = (job) => {
requestDurationMs: Math.max(0, completedAt - startedAt),
request: requestSnapshot,
response: responseSnapshot,
trace_context: job.traceContext,
}, {
severity: "error",
moduleKey: "requestqueue",
requestId: String(job.id),
route: job.url,
component: job.traceContext?.component || null,
});
const missingPermissions = extractMissingPermissions(error);
@@ -817,6 +845,7 @@ export const enqueueRequest = (requestFactory, options = {}) => {
url,
enqueuedAt: Date.now(),
requestData: options.requestData || null,
traceContext: normalizeTraceContext(options.traceContext),
retryByStatusCode: options.retryByStatusCode || null,
shouldRetry: typeof options.shouldRetry === "function" ? options.shouldRetry : null,
queueGroup: normalizeQueueGroup(options.queueGroup),
@@ -0,0 +1,115 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import DepartmentCustomerPricingEditor from "@/components/displays/department/pricing/DepartmentCustomerPricingEditor.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
getLimitedBackofficeDepartments,
limitedBackofficeErrorMessage,
unwrapLimitedBackofficeResponse,
} from "@/services/limitedBackoffice.js";
import LimitedBackofficeLayout from "@/views/backoffice/components/LimitedBackofficeLayout.vue";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const departments = ref([]);
const loadingDepartments = ref(false);
const errorMessage = ref("");
const selectedDepartmentId = computed(() => {
const departmentId = Number.parseInt(String(route.params.departmentId ?? ""), 10);
return Number.isInteger(departmentId) && departmentId > 0 ? departmentId : null;
});
const selectedDepartment = computed(() =>
departments.value.find((department) => Number(department.id) === selectedDepartmentId.value) || null
);
const hasForbiddenDepartment = computed(
() => !loadingDepartments.value && selectedDepartmentId.value !== null && departments.value.length > 0 && !selectedDepartment.value
);
const canAccessLimitedBackoffice = computed(() => SessionUser.hasPermission("limited_backoffice_access"));
const canReadCustomerPricing = computed(
() => canAccessLimitedBackoffice.value && SessionUser.hasPermission("limited_backoffice_customer_pricing_view")
);
const canEditCustomerPricing = computed(
() => canAccessLimitedBackoffice.value && SessionUser.hasPermission("limited_backoffice_customer_pricing_manage")
);
const customPricingEnabled = computed(() => Boolean(selectedDepartment.value?.custom_pricing_only));
const initialCustomerNumber = computed(() => route.query.customer_number || "");
const loadDepartments = async () => {
loadingDepartments.value = true;
errorMessage.value = "";
try {
const response = await getLimitedBackofficeDepartments();
departments.value = unwrapLimitedBackofficeResponse(response) || [];
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(
error,
t("templates.limited_backoffice.errors.load_departments")
);
} finally {
loadingDepartments.value = false;
}
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/customer-pricing`);
};
onMounted(loadDepartments);
watch(
() => selectedDepartmentId.value,
() => {
errorMessage.value = "";
}
);
</script>
<template>
<RestrictedPageWrapper :hasPermission="canReadCustomerPricing">
<LimitedBackofficeLayout
active-tab="customer-pricing"
:departments="departments"
:selected-department-id="selectedDepartmentId"
:loading-departments="loadingDepartments"
:show-customer-pricing-tab="customPricingEnabled && canReadCustomerPricing"
show-department-switcher
@change-department="changeDepartment"
>
<div v-if="hasForbiddenDepartment" class="notification is-danger is-light" data-testid="limited-customer-pricing-forbidden">
<strong>{{ t("templates.limited_backoffice.forbidden.title") }}</strong>
<p>{{ t("templates.limited_backoffice.forbidden.department") }}</p>
</div>
<div v-else>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="limited-customer-pricing-error">
{{ errorMessage }}
</div>
<DepartmentCustomerPricingEditor
v-if="!loadingDepartments && selectedDepartmentId"
scope="limited"
:department-id="selectedDepartmentId"
:custom-pricing-enabled="customPricingEnabled"
:can-read="canReadCustomerPricing"
:can-edit="canEditCustomerPricing"
:initial-customer-number="initialCustomerNumber"
/>
<div v-else class="notification is-light" data-testid="limited-customer-pricing-loading">
{{ t("templates.limited_backoffice.loading") }}
</div>
</div>
</LimitedBackofficeLayout>
</RestrictedPageWrapper>
</template>
@@ -305,6 +305,14 @@ const roleCapabilityMessages = computed(() => ({
label: t("templates.limited_backoffice.role_permissions.capabilities.manage_department_prices.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.manage_department_prices.description"),
},
view_customer_pricing: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_pricing.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_pricing.description"),
},
manage_customer_pricing: {
label: t("templates.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.manage_customer_pricing.description"),
},
manage_employee_access: {
label: t("templates.limited_backoffice.role_permissions.capabilities.manage_employee_access.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.manage_employee_access.description"),
@@ -24,6 +24,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
showCustomerPricingTab: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["changeDepartment"]);
@@ -41,18 +45,33 @@ const employeesRoute = computed(() =>
: "/backoffice/employees"
);
const tabs = computed(() => [
{
key: "prices",
label: t("templates.limited_backoffice.nav.prices"),
to: pricesRoute.value,
},
{
key: "employees",
label: t("templates.limited_backoffice.nav.employees"),
to: employeesRoute.value,
},
]);
const customerPricingRoute = computed(() =>
props.selectedDepartmentId
? `/backoffice/departments/${encodeURIComponent(String(props.selectedDepartmentId))}/customer-pricing`
: "/backoffice"
);
const tabs = computed(() =>
[
{
key: "prices",
label: t("templates.limited_backoffice.nav.prices"),
to: pricesRoute.value,
},
props.showCustomerPricingTab
? {
key: "customer-pricing",
label: t("templates.limited_backoffice.nav.customer_pricing"),
to: customerPricingRoute.value,
}
: null,
{
key: "employees",
label: t("templates.limited_backoffice.nav.employees"),
to: employeesRoute.value,
},
].filter(Boolean)
);
const selectDepartment = (event) => {
const departmentId = Number.parseInt(String(event?.target?.value ?? ""), 10);
@@ -1,5 +1,5 @@
<script setup>
import { BDatepicker, BSkeleton } from "buefy";
import { BSkeleton } from "buefy";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { departments } from "@/components/pagination/departmentTabs.vue";
@@ -12,6 +12,7 @@ import {
searchComplaintCustomers,
} from "@/services/departmentDailyReportComplaintCustomers.js";
import { DEPARTMENT_DAILY_REPORT_COMPLAINT_CATEGORY_OPTIONS } from "@/services/departmentDailyReportComplaintCategories.js";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
import {
refreshOverview,
selected_date,
@@ -47,7 +48,6 @@ const props = defineProps({
});
const CREATE_COMPLAINT_PERMISSION = "create_department_daily_report_complaints";
const padDateNumber = (value) => String(value).padStart(2, "0");
const showModal = ref(false);
const selectedDepartmentId = ref(0);
@@ -100,77 +100,6 @@ const defaultWashDate = computed(() => (
));
const complaintCategoryOptions = DEPARTMENT_DAILY_REPORT_COMPLAINT_CATEGORY_OPTIONS;
const createLocalDate = (year, month, day) => {
const parsedYear = Number.parseInt(String(year), 10);
const parsedMonth = Number.parseInt(String(month), 10);
const parsedDay = Number.parseInt(String(day), 10);
if (!Number.isFinite(parsedYear) || !Number.isFinite(parsedMonth) || !Number.isFinite(parsedDay)) {
return null;
}
const date = new Date(parsedYear, parsedMonth - 1, parsedDay, 12, 0, 0, 0);
if (
Number.isNaN(date.getTime())
|| date.getFullYear() !== parsedYear
|| date.getMonth() !== parsedMonth - 1
|| date.getDate() !== parsedDay
) {
return null;
}
return date;
};
const formatDateToIso = (date) => (
`${date.getFullYear()}-${padDateNumber(date.getMonth() + 1)}-${padDateNumber(date.getDate())}`
);
const formatComplaintWashDateDisplay = (date) => {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "";
}
return `${padDateNumber(date.getDate())}/${padDateNumber(date.getMonth() + 1)}/${date.getFullYear()}`;
};
const parseComplaintWashDateInput = (value) => {
if (value instanceof Date) {
return Number.isNaN(value.getTime())
? null
: createLocalDate(value.getFullYear(), value.getMonth() + 1, value.getDate());
}
const normalizedValue = String(value || "").trim();
if (normalizedValue === "") {
return null;
}
const isoMatch = normalizedValue.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (isoMatch) {
return createLocalDate(isoMatch[1], isoMatch[2], isoMatch[3]);
}
const localMatch = normalizedValue.match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{4})$/);
if (localMatch) {
return createLocalDate(localMatch[3], localMatch[2], localMatch[1]);
}
return null;
};
const selectedWashDateModel = computed({
get: () => parseComplaintWashDateInput(selectedWashDate.value),
set: (value) => {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
selectedWashDate.value = "";
return;
}
selectedWashDate.value = formatDateToIso(value);
},
});
const syncSelectedDepartment = () => {
const firstDepartmentId = availableDepartments.value[0]?.id || 0;
const hasSelectedDepartment = availableDepartments.value.some((department) => department.id === Number(selectedDepartmentId.value));
@@ -626,18 +555,11 @@ onBeforeUnmount(() => {
<div class="field">
<label class="label" for="daily-report-complaints-wash-date-input">Dato for vask</label>
<div class="control">
<BDatepicker
<BuefyDateField
id="daily-report-complaints-wash-date-input"
v-model="selectedWashDateModel"
v-model="selectedWashDate"
value-type="string"
data-testid="daily-report-complaints-wash-date-input"
icon-pack="fas"
icon="calendar"
placeholder="DD/MM/YYYY"
:editable="true"
:expanded="true"
:mobile-native="false"
:date-formatter="formatComplaintWashDateDisplay"
:date-parser="parseComplaintWashDateInput"
/>
</div>
</div>
@@ -145,6 +145,18 @@ const formatDateSelectionValue = (date) => {
return formatLocalDateOnly(date);
};
const concreteDateShortcutIds = [
"today",
"yesterday",
"last_seven_days",
"this_week",
"last_week",
"this_month",
"last_month",
"same_week_last_year",
"same_month_last_year",
];
const onDateSelectionChange = (startDate, endDate) => {
selectDate(formatDateSelectionValue(startDate), formatDateSelectionValue(endDate));
};
@@ -162,6 +174,7 @@ const onDateSelectionChange = (startDate, endDate) => {
showMultipleMonthWarning: false,
showUpdateButton: false,
}"
:shortcut-ids="concreteDateShortcutIds"
:reverse-level-order="true"
>
<template v-slot:right>
@@ -17,6 +17,7 @@ import {
TARGET_DURATION_MODE_WEEKS,
TARGET_DURATION_MODE_YEARS,
} from "@/views/dashboards/departmentDashboard/modules/goals/functions/goalCriteriaPayload";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({
initialDepartments: {
@@ -451,22 +452,24 @@ const selectedDepartments = computed(() =>
<div class="column is-6">
<div class="field">
<label class="label">Start Dato</label>
<div class="control has-icons-left">
<input class="input" type="date" v-model="form.criteria.start" />
<div class="icon is-small is-left">
<i class="fas fa-calendar"></i>
</div>
<div class="control">
<BuefyDateField
v-model="form.criteria.start"
value-type="string"
data-testid="goal-start-date"
/>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Slut Dato</label>
<div class="control has-icons-left">
<input class="input" type="date" v-model="form.criteria.end" />
<div class="icon is-small is-left">
<i class="fas fa-calendar-check"></i>
</div>
<div class="control">
<BuefyDateField
v-model="form.criteria.end"
value-type="string"
data-testid="goal-end-date"
/>
</div>
</div>
</div>
@@ -2,6 +2,7 @@
import { ref, watch } from 'vue';
import { timeBookingsNewScheduler } from '@/views/dashboards/departmentDashboard/modules/time-bookings/book/TimeBookingsNewScheduler.vue';
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const date = ref();
// Set the initial date to the current date YYYY-MM-DD format
const formatDate = (date_input) => {
@@ -40,7 +41,7 @@ watch(date, (newDate) => {
<!-- Date selection -->
<div class="field">
<div class="control">
<input class="input" type="date" v-model="date"/>
<BuefyDateField v-model="date" value-type="string" data-testid="time-bookings-grid-date" />
</div>
</div>
</div>
@@ -6,6 +6,17 @@ 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']);
const concreteDateShortcutIds = [
"today",
"yesterday",
"last_seven_days",
"this_week",
"last_week",
"this_month",
"last_month",
"same_week_last_year",
"same_month_last_year",
];
const selectDate = (startIso, endIso) => {
console.warn('DepartmentDashboardOverviewNavigation selectDate', startIso, endIso);
@@ -20,6 +31,7 @@ const selectDate = (startIso, endIso) => {
:on-selection-change="(startDate, endDate) => selectDate(formatLocalDateOnly(startDate), formatLocalDateOnly(endDate))"
:selection="{ startDate: parseLocalDateOnly(selected_date), endDate: parseLocalDateOnly(selected_date_to) }"
:visibility="{ showDailySelector: true, showWeeklySelector: true, showMultipleMonthWarning: false, showUpdateButton: false }"
:shortcut-ids="concreteDateShortcutIds"
:reverse-level-order="true"
@update:selection="(newSelection) => {
selectDate(formatLocalDateOnly(newSelection.startDate), formatLocalDateOnly(newSelection.endDate));
@@ -7,6 +7,17 @@ import { departments, getDepartments } from '@/components/pagination/departmentT
import Swal from "sweetalert2";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const concreteDateShortcutIds = [
"today",
"yesterday",
"last_seven_days",
"this_week",
"last_week",
"this_month",
"last_month",
"same_week_last_year",
"same_month_last_year",
];
const onSelectionChange = (startDate: Date, endDate: Date) => {
console.log("Selection changed:", startDate, endDate);
showReloadAnimation();
@@ -19,6 +30,7 @@ import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
const isReloadAnimationActive = ref(false);
const isMonthlySplitInProgress = ref(false);
const isReloadButtonLoading = computed(() => isReloadAnimationActive.value || periodPaging.isDateLoading);
const shouldShowTopReloadButton = computed(() => view.variables.currentView.value === "home");
const showReloadAnimation = () => {
isReloadAnimationActive.value = true;
};
@@ -175,6 +187,7 @@ const onSplitCollectedInvoicesByMonth = async () => {
showUpdateButton: false,
showSelectionValidity: true,
}"
v-bind:shortcut-ids="concreteDateShortcutIds"
v-bind:on-selection-change="onSelectionChange"
v-bind:auto-emit-change="true"
>
@@ -224,7 +237,7 @@ const onSplitCollectedInvoicesByMonth = async () => {
</div>
<!-- Reload button with animation -->
<div class="level-item">
<div v-if="shouldShowTopReloadButton" class="level-item">
<button
class="button is-dark is-inverted"
:class="{'is-loading': isReloadButtonLoading}"
@@ -841,14 +841,6 @@ const getTransactionQueryParameters = () => {
</span>
</button>
</div>
<div v-if="periodPaging.isRefreshing" class="column is-narrow pl-0">
<span class="tag is-info is-light period-refreshing-tag" data-testid="invoicing-period-refreshing">
<span class="icon is-small">
<i class="fas fa-sync-alt fa-spin"></i>
</span>
<span>Refreshing</span>
</span>
</div>
</div>
<div v-if="isPossibleDuplicatesView" class="py-2">
<div class="columns is-vcentered is-multiline is-mobile">
@@ -1,8 +1,14 @@
<script setup>
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import SuperuserOverviewActionGrid from "@/components/displays/superuser/overview/SuperuserOverviewActionGrid.vue";
import SuperuserOverviewDefinitionList from "@/components/displays/superuser/overview/SuperuserOverviewDefinitionList.vue";
import SuperuserOverviewMetricCard from "@/components/displays/superuser/overview/SuperuserOverviewMetricCard.vue";
import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getEdgeGatewayDepartmentWorkspace } from "@/services/edgeGateways.js";
import { formatLocaleDateRange, formatLocaleDateTime, formatLocaleNumber } from "@/services/localeFormatting.js";
import { getSuperuserDepartmentOverview } from "@/services/superuserDepartmentOverview.js";
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
import { computed, ref, watch } from "vue";
@@ -11,9 +17,20 @@ import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { t, locale } = useI18n({ useScope: "global" });
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const CONCRETE_DATE_SHORTCUT_IDS = [
"today",
"yesterday",
"last_seven_days",
"this_week",
"last_week",
"this_month",
"last_month",
"same_week_last_year",
"same_month_last_year",
];
const formatDateInput = (value = new Date()) => {
const date = value instanceof Date ? value : new Date(value);
@@ -27,12 +44,6 @@ const formatDateInput = (value = new Date()) => {
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);
@@ -52,14 +63,11 @@ const pageTitle = computed(() => department.value?.name || t("superuser_dashboar
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 periodLabel = computed(() => formatLocaleDateRange(dateFrom.value, dateTo.value, locale.value));
const datePeriodSelection = computed(() => ({
startDate: new Date(`${dateFrom.value}T00:00:00`),
endDate: new Date(`${dateTo.value}T23:59:59`),
}));
const metricDefinitions = computed(() => [
{
@@ -160,13 +168,13 @@ function toNumber(value) {
}
function formatNumber(value) {
return new Intl.NumberFormat("da-DK").format(toNumber(value));
return formatLocaleNumber(toNumber(value), locale.value);
}
function formatDecimal(value) {
return new Intl.NumberFormat("da-DK", {
return formatLocaleNumber(toNumber(value), locale.value, {
maximumFractionDigits: 2,
}).format(toNumber(value));
});
}
function formatCurrency(value) {
@@ -187,12 +195,7 @@ function formatDateTime(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);
return formatLocaleDateTime(value, locale.value) || String(value);
}
function metricPayload(key) {
@@ -311,9 +314,7 @@ async function replaceRange(from, to) {
});
}
const applyDateInputs = () => replaceRange(dateFrom.value, dateTo.value);
const setToday = () => replaceRange(today(), today());
const setLastSevenDays = () => replaceRange(addDays(today(), -6), today());
const onDateRangeSelected = (start, end) => replaceRange(formatDateInput(start), formatDateInput(end));
async function loadOverview() {
if (!hasDepartmentId.value) {
@@ -386,46 +387,18 @@ watch(
<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>
<DatePeriodSelector
:selection="datePeriodSelection"
:on-selection-change="onDateRangeSelected"
:shortcut-ids="CONCRETE_DATE_SHORTCUT_IDS"
:visibility="{
showUpdateButton: false,
showSelectionValidity: false,
showMonthSelector: false,
showYearSelector: false,
showMultipleMonthWarning: false,
}"
/>
<div class="period-chip" data-testid="department-overview-period">
<span class="icon"><i class="fa-solid fa-clock-rotate-left" /></span>
<span>{{ periodLabel }}</span>
@@ -443,32 +416,24 @@ watch(
<template v-else>
<div class="kpi-grid">
<article
<SuperuserOverviewMetricCard
v-for="metric in metricDefinitions"
:key="metric.key"
class="kpi-card"
:icon="`fa-solid ${metric.icon}`"
:label="metric.label"
:value="metricValue(metric)"
:secondary="metricSecondary(metric)"
: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>
<SuperuserOverviewPanel
:title="t('superuser_dashboard.department_overview.products.title')"
:subtitle="t('superuser_dashboard.department_overview.products.subtitle')"
:count="productTiles.length"
data-testid="department-overview-products"
>
<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">
@@ -490,31 +455,25 @@ watch(
<div v-else class="empty-state">
{{ t("superuser_dashboard.department_overview.products.empty") }}
</div>
</section>
</SuperuserOverviewPanel>
<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>
<SuperuserOverviewPanel
:title="t('superuser_dashboard.department_overview.profile.title')"
:subtitle="department?.description || t('superuser_dashboard.department_overview.profile.no_description')"
data-testid="department-overview-profile"
>
<SuperuserOverviewDefinitionList :rows="departmentProfileRows" />
</SuperuserOverviewPanel>
<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>
<SuperuserOverviewPanel
v-if="hasHardwareSummary"
:title="t('superuser_dashboard.department_overview.hardware.title')"
:subtitle="t('superuser_dashboard.department_overview.hardware.subtitle')"
data-testid="department-overview-hardware"
>
<template #actions>
<span v-if="hardwareLoading" class="icon"><i class="fa-solid fa-spinner fa-spin" /></span>
</div>
</template>
<div class="hardware-grid">
<div class="hardware-stat">
<span>{{ t("superuser_dashboard.department_overview.hardware.gateways") }}</span>
@@ -541,28 +500,23 @@ watch(
<strong>{{ formatNumber(hardwareSummary.issues) }}</strong>
</div>
</div>
</section>
</SuperuserOverviewPanel>
<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>
<SuperuserOverviewPanel
class="quick-links-panel"
:title="t('superuser_dashboard.department_overview.quick_links.title')"
:subtitle="t('superuser_dashboard.department_overview.quick_links.subtitle')"
data-testid="department-overview-quick-links"
>
<SuperuserOverviewActionGrid
:items="quickLinks.map((link) => ({
key: link.path,
label: link.label,
icon: `fa-solid ${link.icon}`,
onClick: () => openQuickLink(link.path),
}))"
/>
</SuperuserOverviewPanel>
</div>
</template>
</section>
@@ -583,22 +537,6 @@ watch(
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,
@@ -643,90 +581,12 @@ watch(
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;
@@ -846,11 +706,6 @@ watch(
}
@media (max-width: 640px) {
.date-field,
.overview-period .button {
width: 100%;
}
.hardware-grid,
.quick-link-grid {
grid-template-columns: 1fr;
@@ -3,15 +3,18 @@ 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 { setDepartment, department, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { departmentAdvanced, setDepartment, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from 'vue-router'
import {ref} from "vue";
import { list, setEndpoint, loadList, setOrder} from "@/components/pagination/paginatedList.vue";
import DepartmentCategoriesTable from "@/components/displays/superuser/tables/departmentCategoriesTable.vue";
// Get the department from the route
const router = useRouter()
const { t } = useI18n({ useScope: "global" });
setDepartment(parseInt(router.currentRoute.value.params.departmentId));
const departmentTitle = computed(() => departmentAdvanced.value.name || t("superuser_dashboard.department_navigation.categories"));
// Set the endpoint
setEndpoint(SessionUser.objects.department_categories.meta.endpoint + `?id=${departmentId.value}`, false);
@@ -25,7 +28,10 @@ loadList();
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<DepartmentSubPageWrapper>
<template #title>
<PageTitle :title="$t('department.title')" :subtitle="SessionUser.objects.department_categories.meta.title">
<PageTitle
:title="departmentTitle"
:subtitle="$t('superuser_dashboard.department_pages.categories.subtitle')"
>
<template #buttons>
<button class="button is-dark" @click="SessionUser.objects.department_categories.functions.showCreateObjectFormDepartment(departmentId, loadList)">
<span class="icon">
@@ -45,4 +51,4 @@ loadList();
<style scoped>
</style>
</style>
@@ -0,0 +1,55 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import DepartmentCustomerPricingEditor from "@/components/displays/department/pricing/DepartmentCustomerPricingEditor.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
import {
department,
isCustomPricingOnly,
setDepartment,
} from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
setDepartment(route.params.departmentId);
const canReadCustomerPricing = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_fetch_department_customer_pricing")
);
const canEditCustomerPricing = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_set_department_customer_pricing")
);
const customPricingEnabled = computed(() => isCustomPricingOnly());
const departmentTitle = computed(
() => department.name.value || t("departments.customer_pricing.title")
);
const initialCustomerNumber = computed(() => route.query.customer_number || "");
</script>
<template>
<RestrictedPageWrapper :hasPermission="canReadCustomerPricing">
<DepartmentSubPageWrapper>
<template #title>
<PageTitle
:title="departmentTitle"
:subtitle="$t('superuser_dashboard.department_pages.customer_pricing.subtitle')"
/>
</template>
<DepartmentCustomerPricingEditor
scope="superuser"
:department-id="route.params.departmentId"
:custom-pricing-enabled="customPricingEnabled"
:can-read="canReadCustomerPricing"
:can-edit="canEditCustomerPricing"
:initial-customer-number="initialCustomerNumber"
/>
</DepartmentSubPageWrapper>
</RestrictedPageWrapper>
</template>
@@ -4,13 +4,26 @@ 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 { computed, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { department, setDepartment } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
defineProps({
const props = defineProps({
departmentId: {
type: Number,
required: true,
},
});
const { t } = useI18n({ useScope: "global" });
const departmentTitle = computed(() => department.name.value || t("superuser_dashboard.department_navigation.gateways"));
onMounted(() => setDepartment(props.departmentId));
watch(
() => props.departmentId,
(nextDepartmentId) => setDepartment(nextDepartmentId)
);
</script>
<template>
@@ -18,8 +31,8 @@ defineProps({
<DepartmentSubPageWrapper>
<template #title>
<PageTitle
title="Department Hardware Workspace"
subtitle="Overview, self-serve readiness, gates, scanners, and gateway coverage for this department"
:title="departmentTitle"
:subtitle="$t('superuser_dashboard.department_pages.gateways.subtitle')"
/>
</template>
<EdgeGatewayDepartmentWorkspace :department-id="departmentId" />
@@ -5,6 +5,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
import {
CUSTOM_PRICING_MISSING_PRICE,
department,
setDepartment,
getDepartmentPrices,
getExplicitDepartmentPrice,
@@ -13,17 +14,42 @@ import {
updateCustomPricingOnly,
} from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from 'vue-router'
import { ref } from 'vue';
import { computed, ref, watchEffect } from 'vue';
import { useI18n } from "vue-i18n";
import { getProducts } from "@/components/shop/Products.vue";
import { formatLocaleNumber } from "@/services/localeFormatting.js";
// Get the department from the route
const router = useRouter()
const { t, locale } = useI18n({ useScope: "global" });
const departmentTitle = () => department.name.value || t("superuser_dashboard.department_navigation.pricing");
setDepartment(router.currentRoute.value.params.departmentId);
getDepartmentPrices();
const canFetchDepartmentPrices = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_fetch_department_prices")
);
const canEditDepartmentPrices = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_set_department_prices")
);
const canEditDepartmentSettings = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("edit_department")
);
const customerPricingPath = computed(
() => `/superuser/departments/${encodeURIComponent(String(router.currentRoute.value.params.departmentId))}/customer-pricing`
);
// Set the products
const products = ref([]);
const isUpdatingCustomPricingOnly = ref(false);
const hasRequestedDepartmentPrices = ref(false);
watchEffect(() => {
if (!canFetchDepartmentPrices.value || hasRequestedDepartmentPrices.value) {
return;
}
hasRequestedDepartmentPrices.value = true;
getDepartmentPrices();
});
// Get the products
getProducts().then((response) => {
@@ -33,9 +59,11 @@ getProducts().then((response) => {
const getDepartmentPriceDisplay = (product) => {
const explicitPrice = getExplicitDepartmentPrice(product);
if (explicitPrice !== null) {
return explicitPrice;
return formatLocaleNumber(explicitPrice, locale.value);
}
return isCustomPricingOnly() ? CUSTOM_PRICING_MISSING_PRICE : '-';
return isCustomPricingOnly()
? formatLocaleNumber(CUSTOM_PRICING_MISSING_PRICE, locale.value)
: '-';
};
const isMissingCustomPrice = (product) => {
@@ -43,6 +71,11 @@ const isMissingCustomPrice = (product) => {
};
const toggleCustomPricingOnly = async (event) => {
if (!canEditDepartmentSettings.value) {
event.target.checked = isCustomPricingOnly();
return;
}
const enabled = event.target.checked;
isUpdatingCustomPricingOnly.value = true;
try {
@@ -53,13 +86,21 @@ const toggleCustomPricingOnly = async (event) => {
isUpdatingCustomPricingOnly.value = false;
}
};
const editPriceIfAllowed = (product) => {
if (!canEditDepartmentPrices.value) {
return;
}
editDepartmentPrice(product);
};
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<DepartmentSubPageWrapper>
<template #title>
<PageTitle title="Department" subtitle="Department pricing" />
<PageTitle :title="departmentTitle()" :subtitle="$t('superuser_dashboard.department_pages.pricing.subtitle')" />
</template>
<div>
<section class="department-pricing-settings" data-testid="department-custom-pricing-settings">
@@ -75,7 +116,7 @@ const toggleCustomPricingOnly = async (event) => {
class="switch is-rounded is-info"
type="checkbox"
:checked="isCustomPricingOnly()"
:disabled="isUpdatingCustomPricingOnly"
:disabled="isUpdatingCustomPricingOnly || !canEditDepartmentSettings"
data-testid="department-custom-pricing-only-toggle"
@change="toggleCustomPricingOnly"
/>
@@ -88,6 +129,18 @@ const toggleCustomPricingOnly = async (event) => {
</label>
</div>
</section>
<div v-if="isCustomPricingOnly()" class="buttons department-pricing-customer-link">
<b-tooltip :label="$t('departments.customer_pricing.open')" position="is-right" type="is-dark">
<router-link
class="button is-small is-info is-light"
:to="customerPricingPath"
data-testid="department-customer-pricing-link"
>
<span class="icon is-small"><i class="fas fa-user-tag" aria-hidden="true"></i></span>
<span>{{ $t('superuser_dashboard.department_navigation.customer_pricing') }}</span>
</router-link>
</b-tooltip>
</div>
<table class="is-fullwidth table table-striped is-hoverable is-bordered">
<thead>
<tr>
@@ -99,14 +152,20 @@ const toggleCustomPricingOnly = async (event) => {
<tbody>
<tr v-for="product in products" :key="product.id">
<td>{{ product.name }}</td>
<td>{{ product.price }}</td>
<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) }}
<i class="is-pulled-right fas fa-edit"></i>
<td>{{ formatLocaleNumber(product.price, locale.value) }}</td>
<td class="is-clickable" :data-testid="`department-price-cell-${product.id}`">
<b-tooltip :label="$t('common.edit')" position="is-left">
<button
class="button is-white is-small department-pricing__price-button"
type="button"
:disabled="!canEditDepartmentPrices"
@click="editPriceIfAllowed(product)"
:class="{ 'has-text-danger has-text-weight-semibold': isMissingCustomPrice(product) }"
>
<span>{{ getDepartmentPriceDisplay(product) }}</span>
<i class="is-pulled-right fas fa-edit"></i>
</button>
</b-tooltip>
</td>
</tr>
</tbody>
@@ -134,6 +193,15 @@ const toggleCustomPricingOnly = async (event) => {
min-width: 16rem;
}
.department-pricing-customer-link {
margin-bottom: 1rem;
}
.department-pricing__price-button {
justify-content: space-between;
width: 100%;
}
@media (max-width: 768px) {
.department-pricing-settings {
align-items: flex-start;
@@ -10,9 +10,11 @@ import {
setDepartment
} from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
const router = useRouter();
const { t } = useI18n({ useScope: "global" });
const defaultBranding = {
id: null,
@@ -30,20 +32,20 @@ const defaultBranding = {
signature: "",
};
const brandingFields = [
{ key: "name", label: "Name", type: "text", required: true },
{ key: "description", label: "Description", type: "text", required: true },
{ key: "cvr", label: "CVR", type: "number", required: true },
{ key: "address", label: "Address", type: "text" },
{ key: "phone_country_code", label: "Phone country code", type: "number" },
{ key: "phone", label: "Phone", type: "number" },
{ key: "email", label: "Email", type: "email" },
{ key: "website", label: "Website", type: "url" },
{ key: "banner", label: "Banner", type: "url" },
{ key: "logo", label: "Logo", type: "url" },
{ key: "favicon", label: "Favicon", type: "url" },
{ key: "signature", label: "Signature", type: "textarea" },
];
const brandingFields = computed(() => [
{ key: "name", label: t("objects.columns.name"), type: "text", required: true },
{ key: "description", label: t("objects.columns.description"), type: "text", required: true },
{ key: "cvr", label: t("user_admin.cvr"), type: "number", required: true },
{ key: "address", label: t("objects.columns.address"), type: "text" },
{ key: "phone_country_code", label: t("global.phone_country_code"), type: "number" },
{ key: "phone", label: t("objects.columns.phone"), type: "number" },
{ key: "email", label: t("objects.columns.email"), type: "email" },
{ key: "website", label: t("tables.common.website"), type: "url" },
{ key: "banner", label: t("objects.columns.image"), type: "url" },
{ key: "logo", label: t("objects.columns.image"), type: "url" },
{ key: "favicon", label: t("objects.columns.image"), type: "url" },
{ key: "signature", label: t("objects.columns.notes"), type: "textarea" },
]);
const integerFields = new Set(["cvr", "phone_country_code", "phone"]);
const imageFields = ["banner", "logo", "favicon", "signature"];
@@ -60,9 +62,11 @@ const statusMessage = ref("");
const departmentId = computed(() => Number(router.currentRoute.value.params.departmentId || 0));
const currentBrandingId = computed(() => Number(departmentAdvanced.value.getBranding?.() || 0));
const departmentTitle = computed(() => departmentAdvanced.value.name || t("superuser_dashboard.department_navigation.branding"));
const pageSubtitle = computed(() => t("superuser_dashboard.department_pages.branding.subtitle"));
const hasBranding = computed(() => currentBrandingId.value > 0);
const isDirty = computed(() => {
return brandingFields.some((field) => String(form.value[field.key] ?? "") !== String(originalForm.value[field.key] ?? ""));
return brandingFields.value.some((field) => String(form.value[field.key] ?? "") !== String(originalForm.value[field.key] ?? ""));
});
const normalizeFormValue = (value) => {
@@ -87,7 +91,7 @@ const resetForm = (branding = {}) => {
};
const buildPayload = () => {
return brandingFields.reduce((payload, field) => {
return brandingFields.value.reduce((payload, field) => {
const rawValue = form.value[field.key];
const normalized = typeof rawValue === "string" ? rawValue.trim() : rawValue;
@@ -221,15 +225,15 @@ watch(
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<DepartmentSubPageWrapper>
<template #title>
<PageTitle title="Department" subtitle="Profile & Branding" />
<PageTitle :title="departmentTitle" :subtitle="pageSubtitle" />
</template>
<section class="department-branding" data-testid="department-branding-system">
<div class="department-branding__header">
<div>
<h2 class="title is-4 mb-1">Profile & Branding</h2>
<h2 class="title is-4 mb-1">{{ pageSubtitle }}</h2>
<p class="has-text-grey" data-testid="department-branding-department-name">
{{ departmentAdvanced.name || "Department" }}
{{ departmentAdvanced.name || t("common.none") }}
</p>
</div>
<div class="buttons department-branding__actions">
@@ -301,31 +305,31 @@ watch(
<aside class="department-branding__panel department-branding__preview" data-testid="department-branding-preview">
<div class="department-branding__preview-header">
<p class="heading mb-1">Current brand</p>
<p class="heading mb-1">{{ t("objects.branding.title") }}</p>
<h3 class="title is-5 mb-0" data-testid="department-branding-preview-name">
{{ form.name || "Unassigned" }}
{{ form.name || t("common.none") }}
</h3>
</div>
<dl class="department-branding__summary">
<div>
<dt>CVR</dt>
<dt>{{ t("user_admin.cvr") }}</dt>
<dd data-testid="department-branding-preview-cvr">{{ form.cvr || "-" }}</dd>
</div>
<div>
<dt>Address</dt>
<dt>{{ t("objects.columns.address") }}</dt>
<dd>{{ form.address || "-" }}</dd>
</div>
<div>
<dt>Phone</dt>
<dt>{{ t("objects.columns.phone") }}</dt>
<dd>{{ [form.phone_country_code, form.phone].filter(Boolean).join(" ") || "-" }}</dd>
</div>
<div>
<dt>Email</dt>
<dt>{{ t("objects.columns.email") }}</dt>
<dd>{{ form.email || "-" }}</dd>
</div>
<div>
<dt>Website</dt>
<dt>{{ t("tables.common.website") }}</dt>
<dd>{{ form.website || "-" }}</dd>
</div>
</dl>
@@ -347,7 +351,7 @@ watch(
<section class="department-branding__panel department-branding__assignment">
<div class="field">
<label class="label" for="department-branding-selected-brand">Department brand</label>
<label class="label" for="department-branding-selected-brand">{{ t("objects.branding.single") }}</label>
<div class="field has-addons department-branding__assignment-controls">
<div class="control is-expanded">
<div class="select is-fullwidth">
@@ -357,7 +361,7 @@ watch(
data-testid="department-branding-select"
:disabled="assigning || saving"
>
<option value="">No brand</option>
<option value="">{{ t("common.none") }}</option>
<option v-for="brand in availableBrands" :key="brand.id" :value="String(brand.id)">
{{ brand.name }} #{{ brand.id }}
</option>
@@ -373,7 +377,7 @@ watch(
data-testid="department-branding-assign"
@click="assignBranding()"
>
Set brand
{{ t("common.save") }}
</button>
</div>
<div class="control">
@@ -384,7 +388,7 @@ watch(
data-testid="department-branding-clear"
@click="assignBranding(null)"
>
Clear
{{ t("common.clear") || t("common.cancel") }}
</button>
</div>
</div>
@@ -2,6 +2,7 @@
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { isCustomPricingOnly } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
const route = useRoute();
const router = useRouter();
@@ -10,43 +11,52 @@ const { t } = useI18n();
const departmentId = computed(() => String(route.params.departmentId || ""));
const departmentPath = computed(() => `/superuser/departments/${encodeURIComponent(departmentId.value)}`);
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`),
},
]);
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}/prices`,
active: (path) => path.startsWith(`${departmentPath.value}/prices`) || path.startsWith(`${departmentPath.value}/pricing`),
},
isCustomPricingOnly()
? {
name: t("superuser_dashboard.department_navigation.customer_pricing"),
path: `${departmentPath.value}/customer-pricing`,
active: (path) => path.startsWith(`${departmentPath.value}/customer-pricing`),
}
: null,
{
name: t("superuser_dashboard.department_navigation.categories"),
path: `${departmentPath.value}/categories`,
active: (path) => path.startsWith(`${departmentPath.value}/categories`),
},
].filter(Boolean)
);
const activeTab = computed(() => tabs.value.findIndex((tab) => tab.active(route.path)));
@@ -3,9 +3,10 @@ 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 { setDepartment, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { departmentAdvanced, setDepartment, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from "vue-router";
import { ref } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
import SuperUserDashboardDepartmentModulesNavigation
@@ -19,7 +20,10 @@ import {
// Get the department from the route
const router = useRouter();
const { t } = useI18n({ useScope: "global" });
setDepartment(router.currentRoute.value.params.departmentId);
const departmentTitle = computed(() => departmentAdvanced.value.name || t("superuser_dashboard.department_navigation.modules"));
const pageSubtitle = computed(() => t("superuser_dashboard.department_pages.modules.subtitle"));
const departmentVariables = ref([]);
const workfeedDepartmentOptions = ref([{ value: "", label: "No Workfeed department" }]);
@@ -29,6 +33,19 @@ const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false);
const selfServeEnabledError = ref("");
const departmentVariableDescriptions = computed(() => ({
bookingsystem_enabled: t("superuser_dashboard.department_pages.modules.variables.bookingsystem_enabled"),
bookingsystem_time_based_enabled: t("superuser_dashboard.department_pages.modules.variables.bookingsystem_time_based_enabled"),
bookingsystem_time_based_password: t("superuser_dashboard.department_pages.modules.variables.bookingsystem_time_based_password"),
exclude_from_invoicing: t("superuser_dashboard.department_pages.modules.variables.exclude_from_invoicing"),
workfeed_department_id: t("superuser_dashboard.department_pages.modules.variables.workfeed_department_id"),
}));
const departmentVariableRows = computed(() => departmentVariables.value.map((variable) => ({
key: `${variable.variable}:${variable.id}`,
label: departmentVariableDescriptions.value[variable.variable] || variable.variable,
value: variable.value,
})));
const getDepartmentVariables = async () => {
await SessionUser.request(
@@ -40,7 +57,6 @@ const getDepartmentVariables = async () => {
)
.then((response) => {
departmentVariables.value = response.data.data;
console.log(departmentVariables.value);
})
.catch((error) => {
console.log(error);
@@ -177,17 +193,27 @@ loadSelfServeEnabled();
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<DepartmentSubPageWrapper>
<template #title>
<PageTitle title="Department" subtitle="Moduler">
</PageTitle>
<PageTitle :title="departmentTitle" :subtitle="pageSubtitle" />
</template>
<div>
{{ departmentVariables }}
<SuperUserDashboardDepartmentModulesNavigation />
<section class="box mb-4" data-testid="department-module-variables">
<h2 class="title is-6 mb-2">{{ $t('objects.columns.value') }}</h2>
<div v-if="departmentVariableRows.length === 0" class="has-text-grey">
{{ $t('common.none') }}
</div>
<dl v-else class="department-modules-variables">
<div v-for="row in departmentVariableRows" :key="row.key" class="department-modules-variables__row">
<dt>{{ row.label }}</dt>
<dd>{{ row.value || $t('common.none') }}</dd>
</div>
</dl>
</section>
<ConfigurationCategory
module="Department setup"
title="Moduler"
:title="$t('superuser_dashboard.department_navigation.modules')"
icon="fas fa-cogs"
subtitle="Bookingsystem"
:subtitle="$t('superuser_dashboard.department_pages.modules.subtitle')"
>
<template #default>
<ConfigurationSwitch
@@ -236,9 +262,9 @@ loadSelfServeEnabled();
</ConfigurationCategory>
<ConfigurationCategory
module="Department setup"
title="Moduler"
:title="$t('superuser_dashboard.department_navigation.modules')"
icon="fas fa-cogs"
subtitle="Fakturering"
:subtitle="$t('superuser_dashboard.department_pages.modules.subtitle')"
>
<template #default>
<ConfigurationSwitch
@@ -259,9 +285,9 @@ loadSelfServeEnabled();
</ConfigurationCategory>
<ConfigurationCategory
module="Department setup"
title="Moduler"
:title="$t('superuser_dashboard.department_navigation.modules')"
icon="fas fa-soap"
subtitle="Selvvask"
:subtitle="$t('superuser_dashboard.department_pages.modules.subtitle')"
>
<template #default>
<ConfigurationSwitch
@@ -278,9 +304,9 @@ loadSelfServeEnabled();
</ConfigurationCategory>
<ConfigurationCategory
module="Workfeed"
title="Moduler"
:title="$t('superuser_dashboard.department_navigation.modules')"
icon="fas fa-users-cog"
subtitle="Afdeling"
:subtitle="$t('superuser_dashboard.department_pages.modules.subtitle')"
>
<template #default>
<ConfigurationSelect
@@ -1,25 +1,22 @@
<script setup>
import { useRouter} from "vue-router";
import { ref } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
// Get the user from the route
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId))
const { t } = useI18n({ useScope: "global" });
const tabs = [
{ name: 'Opsætning', path: '/superuser/departments/' + departmentId.value + '/modules' },
];
const departmentId = computed(() => parseInt(String(route.params.departmentId || ""), 10));
const tabs = computed(() => [
{ name: t("superuser_dashboard.department_navigation.modules"), path: `/superuser/departments/${departmentId.value}/modules` },
]);
// Get the current path
const currentPath = ref(router.currentRoute.value.path);
// Get the index of the active tab
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
const activeTab = computed(() => tabs.value.findIndex((tab) => tab.path === route.path));
// Change the tab
const changeTab = (index) => {
router.push(tabs[index].path);
router.push(tabs.value[index].path);
};
</script>
@@ -27,7 +24,7 @@ const changeTab = (index) => {
<div>
<div class="tabs is-left mb-3">
<ul>
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<li v-for="(tab, index) in tabs" :key="tab.path" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<a>{{ tab.name }}</a>
</li>
</ul>
@@ -36,4 +33,4 @@ const changeTab = (index) => {
</template>
<style scoped>
</style>
</style>
@@ -3,9 +3,10 @@ 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 { setDepartment, department, departmentId, getDepartmentPrice, editDepartmentPrice } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from 'vue-router'
import { ref } from 'vue';
import { department, departmentId, setDepartment } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from "vue-router";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import SuperUserDashboardDepartmentStripeNavigation
from "@/views/dashboards/superUserDashboard/department/stripe/SuperUserDashboardDepartmentStripeNavigation.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
@@ -14,7 +15,9 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
// Get the department from the route
const router = useRouter()
const { t } = useI18n({ useScope: "global" });
setDepartment(router.currentRoute.value.params.departmentId);
const departmentTitle = computed(() => department.name.value || t("superuser_dashboard.department_navigation.stripe"));
const stripeLocations = ref([]);
@@ -62,23 +65,23 @@ const setDepartmentStripeLocation = async (locationId) => {
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<DepartmentSubPageWrapper>
<template #title>
<PageTitle title="Department" subtitle="Stripe opsætning">
</PageTitle>
<PageTitle :title="departmentTitle" :subtitle="$t('superuser_dashboard.department_pages.stripe.setup.subtitle')" />
</template>
<div>
<SuperUserDashboardDepartmentStripeNavigation />
<!-- Department Stripe Location -->
<ConfigurationCategory
module="Stripe"
title="Afdelingsopsætning"
:title="$t('superuser_dashboard.department_navigation.stripe')"
icon="fas fa-cogs"
subtitle="Stripe placering"
:subtitle="$t('superuser_dashboard.department_pages.stripe.setup.subtitle')"
>
<template #default>
<!-- Location -->
<ConfigurationSelect
label="Stripe placering"
description="Vælg den placering i Stripe, som denne afdeling er tilknyttet"
data-testid="department-stripe-location-select"
:label="$t('superuser_dashboard.department_navigation.stripe')"
:description="$t('superuser_dashboard.department_pages.stripe.setup.subtitle')"
:options="stripeLocations"
:on-select="setDepartmentStripeLocation"
:icon="'fas fa-map-marker-alt'"
@@ -93,4 +96,4 @@ const setDepartmentStripeLocation = async (locationId) => {
<style scoped>
</style>
</style>
@@ -3,34 +3,36 @@ 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 { setDepartment, department, departmentId, getDepartmentPrice, editDepartmentPrice } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from 'vue-router'
import { ref } from 'vue';
import { department, departmentId, setDepartment } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue";
import { useRouter } from "vue-router";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import SuperUserDashboardDepartmentStripeNavigation
from "@/views/dashboards/superUserDashboard/department/stripe/SuperUserDashboardDepartmentStripeNavigation.vue";
import { parseError, getError } from "@/components/request/HandleGlobalError.vue";
import { parseError } from "@/components/request/HandleGlobalError.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
// Set the random unique id
const uniqueId = Math.random().toString(36).substring(7);
import { formatLocaleDateTime } from "@/services/localeFormatting.js";
// Get the department from the route
const router = useRouter()
const { t, locale } = useI18n({ useScope: "global" });
setDepartment(router.currentRoute.value.params.departmentId);
const departmentTitle = computed(() => department.name.value || t("superuser_dashboard.department_navigation.stripe"));
// Set the terminals
const terminals = ref([]);
const errorId = "department-stripe-terminals-error";
// Get the terminals
const getTerminals = async () => {
const response = await SessionUser.superUser.modules.stripe.departments.terminals.readers.list(departmentId.value).then((response) => {
console.log(response.data.data.data);
terminals.value = response.data.data.data;
})
.catch((error) => {
parseError(error, uniqueId);
});
}
await SessionUser.superUser.modules.stripe.departments.terminals.readers.list(departmentId.value)
.then((response) => {
terminals.value = response.data.data.data;
})
.catch((error) => {
parseError(error, errorId);
});
};
getTerminals();
</script>
@@ -39,14 +41,14 @@ getTerminals();
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<DepartmentSubPageWrapper>
<template #title>
<PageTitle title="Department" subtitle="Payment terminals">
<PageTitle :title="departmentTitle" :subtitle="$t('superuser_dashboard.department_pages.stripe.readers.subtitle')">
<template #buttons>
</template>
</PageTitle>
</template>
<div>
<SuperUserDashboardDepartmentStripeNavigation />
<show-error-field :error="uniqueId" />
<show-error-field :error="errorId" />
<table class="is-fullwidth table table-striped is-hoverable is-bordered">
<thead>
<tr>
@@ -63,7 +65,7 @@ getTerminals();
<td>{{ terminal.id }}</td>
<td>{{ terminal.device_type }}</td>
<td>{{ terminal.label }}</td>
<td>{{ new Date(terminal.last_seen_at).toLocaleString() }}</td>
<td>{{ formatLocaleDateTime(terminal.last_seen_at, locale.value) || "-" }}</td>
<td>{{ terminal.serial_number }}</td>
<td>{{ terminal.status }}</td>
</tr>
@@ -76,4 +78,4 @@ getTerminals();
<style scoped>
</style>
</style>
@@ -1,26 +1,29 @@
<script setup>
import { useRouter} from "vue-router";
import { ref } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
// Get the user from the route
const departmentId = ref(parseInt(router.currentRoute.value.params.departmentId))
const { t } = useI18n({ useScope: "global" });
const tabs = [
{ name: 'Betalingsterminaler', path: '/superuser/departments/' + departmentId.value + '/stripe/terminals/readers' },
{ name: 'Opsætning', path: '/superuser/departments/' + departmentId.value + '/stripe/setup' },
];
const departmentId = computed(() => parseInt(String(route.params.departmentId || ""), 10));
const tabs = computed(() => [
{
name: t("superuser_dashboard.department_pages.stripe.readers.subtitle"),
path: `/superuser/departments/${departmentId.value}/stripe/terminals/readers`,
},
{
name: t("superuser_dashboard.department_pages.stripe.setup.subtitle"),
path: `/superuser/departments/${departmentId.value}/stripe/setup`,
},
]);
// Get the current path
const currentPath = ref(router.currentRoute.value.path);
// Get the index of the active tab
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
const activeTab = computed(() => tabs.value.findIndex((tab) => tab.path === route.path));
// Change the tab
const changeTab = (index) => {
router.push(tabs[index].path);
router.push(tabs.value[index].path);
};
</script>
@@ -28,7 +31,7 @@ const changeTab = (index) => {
<div>
<div class="tabs is-left mb-3">
<ul>
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<li v-for="(tab, index) in tabs" :key="tab.path" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<a>{{ tab.name }}</a>
</li>
</ul>
@@ -37,4 +40,4 @@ const changeTab = (index) => {
</template>
<style scoped>
</style>
</style>
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import RestrictedPageWrapper from '@/components/page/wrappers/RestrictedPageWrapper.vue';
import PageTitle from '@/components/global/PageTitle.vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import BuefyMonthField from '@/components/forms/BuefyMonthField.vue';
import InvoiceDistributionCard from '@/views/dashboards/superUserDashboard/invoiceDistribution/components/InvoiceDistributionCard.vue';
import {
compareCollectedInvoicesForMonth,
@@ -89,6 +90,7 @@ const compareFallback = ref({
const syncingRouteQuery = ref(false);
const monthKey = computed(() => `${selectedYear.value}-${String(selectedMonth.value).padStart(2, '0')}`);
const monthKeyFromDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const monthOptions = computed(() => availableMonths.value.map((month) => ({
...month,
label: getYearMonthLabel(month.year, month.month, locale.value),
@@ -96,6 +98,14 @@ const monthOptions = computed(() => availableMonths.value.map((month) => ({
const selectedMonthLabel = computed(() => getYearMonthLabel(selectedYear.value, selectedMonth.value, locale.value));
const selectedMonthIndex = computed(() => monthOptions.value.findIndex((month) => month.key === monthKey.value));
const compareMonthOptions = computed(() => monthOptions.value.filter((month) => month.key !== monthKey.value));
const isDistributionMonthSelectable = (date) => monthOptions.value.some((month) => month.key === monthKeyFromDate(date));
const isCompareMonthSelectable = (date) => compareMonthOptions.value.some((month) => month.key === monthKeyFromDate(date));
const navigateToMonthKey = (value) => {
const parsed = parseMonthKey(value);
if (parsed) {
navigateTo(parsed.year, parsed.month, activeTab.value);
}
};
const departmentRows = computed(() => buildDepartmentAllocations({
departments: departments.value,
@@ -541,19 +551,14 @@ onMounted(async () => {
icon-left="chevron-left"
data-testid="distribution-month-prev"
/>
<b-select
<BuefyMonthField
:model-value="monthKey"
expanded
value-type="string"
:selectable-dates="isDistributionMonthSelectable"
:aria-label="t('superuser_invoice_distribution.monthly_page.toolbar_month_picker')"
@update:model-value="(value) => {
const parsed = parseMonthKey(value);
if (parsed) {
navigateTo(parsed.year, parsed.month, activeTab.value);
}
}"
>
<option v-for="option in monthOptions" :key="option.key" :value="option.key">{{ option.label }}</option>
</b-select>
data-testid="distribution-month-picker"
@change="navigateToMonthKey"
/>
<b-button
type="is-light"
class="control-button month-nav-button"
@@ -568,12 +573,15 @@ onMounted(async () => {
</div>
<b-field :label="t('superuser_invoice_distribution.compare.compare_month')" class="toolbar-field">
<b-select v-model="compareMonthKey" expanded :aria-label="t('superuser_invoice_distribution.compare.compare_month')" data-testid="distribution-compare-month-picker">
<option v-if="!compareMonthOptions.length" :value="null">
{{ t('superuser_invoice_distribution.empty.no_comparison_months') }}
</option>
<option v-for="option in compareMonthOptions" :key="option.key" :value="option.key">{{ option.label }}</option>
</b-select>
<BuefyMonthField
v-model="compareMonthKey"
value-type="nullable-string"
:placeholder="t('superuser_invoice_distribution.empty.no_comparison_months')"
:disabled="!compareMonthOptions.length"
:selectable-dates="isCompareMonthSelectable"
:aria-label="t('superuser_invoice_distribution.compare.compare_month')"
data-testid="distribution-compare-month-picker"
/>
</b-field>
<div class="toolbar-actions">
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { SuperuserInvoicingLocalStore } from "@/views/dashboards/superUserDashboard/invoicing/SuperuserInvoicingLocalStore";
import {BButton, BDatepicker} from "buefy";
import { BButton } from "buefy";
import { useI18n } from "vue-i18n";
import BuefyMonthField from "@/components/forms/BuefyMonthField.vue";
const { t } = useI18n({ useScope: "global" });
</script>
<template>
<div>
<b-datepicker
<div class="invoicing-month-selector">
<BuefyMonthField
v-model="SuperuserInvoicingLocalStore.month"
type="month"
placeholder="Click to select..."
icon-pack="fas"
icon="calendar"
rounded
:expanded="true"
>
<div class="buttons">
<!-- Previous month button -->
value-type="date"
:placeholder="t('date_period.labels.select_month')"
data-testid="invoicing-month-picker"
/>
<div class="buttons mt-2">
<b-button
size="is-small"
@click="SuperuserInvoicingLocalStore.month = new Date(new Date().setMonth(new Date().getMonth() - 1))"
@@ -24,9 +24,8 @@ import {BButton, BDatepicker} from "buefy";
outlined
rounded
>
Previous month
{{ t('global.text.last_month') }}
</b-button>
<!-- This month button -->
<b-button
size="is-small"
@click="SuperuserInvoicingLocalStore.month = new Date()"
@@ -36,9 +35,8 @@ import {BButton, BDatepicker} from "buefy";
outlined
rounded
>
This month
{{ t('global.text.this_month') }}
</b-button>
</div>
</b-datepicker>
</div>
</template>
@@ -15,6 +15,7 @@ const tabs = computed(() => [
{ key: "orders", name: t("superuser.nav.orders"), path: `${basePath.value}/orders` },
{ key: "pricing", name: t("user_admin.overview.pricing"), path: `${basePath.value}/pricing` },
{ key: "other", name: t("user_admin.other"), path: `${basePath.value}/other` },
{ key: "security", name: t("settings.security"), path: `${basePath.value}/security` },
{ key: "vehicles", name: t("common.vehicles"), path: `${basePath.value}/vehicles` },
{ key: "xlvask", name: SessionUser.superUser.modules.xlvask.meta.title, path: `${basePath.value}/xlvask` },
]);
@@ -0,0 +1,104 @@
<script setup>
import { computed, watch } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import PageTitle from "@/components/global/PageTitle.vue";
import { getSuperuserUserPageSubtitleKey } from "@/views/dashboards/superUserDashboard/user/superuserUserPageDefinitions.js";
const APP_TITLE = "Truck Wash";
const props = defineProps({
pageKey: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
userId: {
type: [String, Number],
default: "",
},
customerNumber: {
type: [String, Number],
default: null,
},
});
const { t } = useI18n({ useScope: "global" });
const subtitle = computed(() => t(getSuperuserUserPageSubtitleKey(props.pageKey)));
const metaItems = computed(() => {
const items = [
{
key: "userId",
label: t("user_admin.user_id"),
value: props.userId,
},
];
if (props.customerNumber) {
items.push({
key: "customerNumber",
label: t("user_admin.customer_number"),
value: props.customerNumber,
});
}
return items.filter((item) => item.value !== null && item.value !== undefined && item.value !== "");
});
const browserTitle = computed(() => `${props.displayName} | ${APP_TITLE}`);
useHead(() => ({
title: browserTitle.value,
}));
watch(browserTitle, (value) => {
document.title = value;
}, { immediate: true });
</script>
<template>
<div class="superuser-user-page-header">
<PageTitle :title="props.displayName" :subtitle="subtitle" />
<div v-if="metaItems.length" class="superuser-user-page-header__meta">
<span
v-for="item in metaItems"
:key="item.key"
class="superuser-user-page-header__chip"
>
<strong>{{ item.label }}:</strong>
<span>{{ item.value }}</span>
</span>
</div>
</div>
</template>
<style scoped>
.superuser-user-page-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.superuser-user-page-header__meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.superuser-user-page-header__chip {
align-items: center;
background: #eef2ff;
border: 1px solid #c7d2fe;
border-radius: 999px;
color: #3730a3;
display: inline-flex;
gap: 0.35rem;
min-height: 2rem;
padding: 0 0.75rem;
}
</style>
@@ -4,9 +4,14 @@ import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import SuperuserOverviewActionGrid from "@/components/displays/superuser/overview/SuperuserOverviewActionGrid.vue";
import SuperuserOverviewDefinitionList from "@/components/displays/superuser/overview/SuperuserOverviewDefinitionList.vue";
import SuperuserOverviewMetricCard from "@/components/displays/superuser/overview/SuperuserOverviewMetricCard.vue";
import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocaleDateTime } from "@/services/localeFormatting.js";
import SuperuserUserPageHeader from "@/views/dashboards/superUserDashboard/user/SuperuserUserPageHeader.vue";
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import {
isUserLoading,
@@ -16,18 +21,16 @@ import {
userId,
userLoadError,
} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import UserDefaultDepartment from "@/views/dashboards/superUserDashboard/user/displays/UserDefaultDepartment.vue";
import UserFixedPricing from "@/views/dashboards/superUserDashboard/user/displays/UserFixedPricing.vue";
import UserOtherSpecialArrangement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue";
import UserOtherVaskeabonnement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue";
import UserVehicleSubscriptionsDisplay from "@/views/dashboards/superUserDashboard/user/displays/vehicles/UserVehicleSubscriptionsDisplay.vue";
const route = useRoute();
const { t } = useI18n();
const { t, locale } = useI18n({ useScope: "global" });
const vehicles = ref([]);
const vehiclesLoading = ref(false);
const vehiclesError = ref(null);
const roleOptions = ref([]);
const rolesLoading = ref(false);
const CUSTOMER_GROUP_SENTINEL = "__customer__";
const customerNumber = computed(() => Number.parseInt(String(user.customer_number.value || 0), 10) || 0);
const basePath = computed(() => `/superuser/users/${userId.value || route.params.userId}`);
@@ -37,9 +40,6 @@ const discounts = computed(() => (Array.isArray(user.discounts.value) ? user.dis
const ordersNotInvoiced = computed(() =>
Array.isArray(user.orders_not_invoiced.value) ? user.orders_not_invoiced.value : []
);
const washSubscriptionTransactions = computed(() =>
Array.isArray(user.wash_subscription_transactions.value) ? user.wash_subscription_transactions.value : []
);
const displayName = computed(() => {
return (
@@ -52,18 +52,13 @@ const hasCustomerNumber = computed(() => customerNumber.value > 0);
const hasEconomicData = computed(() => Boolean(user.economicData.name.value || user.economicData.customerNumber.value));
const isEconomicBarred = computed(() => [true, 1, "1", "true"].includes(user.economicData.barred.value));
const activeVehicleSubscriptions = computed(() => vehicles.value.filter((vehicle) => vehicle?.wash_subscription).length);
const selfServiceVehicles = computed(() => vehicles.value.filter((vehicle) => vehicle?.xlvask).length);
const visibleVehicles = computed(() => vehicles.value.slice(0, 8));
const visiblePermissions = computed(() => permissions.value.slice(0, 12));
const hiddenPermissionCount = computed(() => Math.max(permissions.value.length - visiblePermissions.value.length, 0));
const userDetails = computed(() => [
{ label: t("user_admin.user_id"), value: userId.value },
{ label: t("user_admin.customer_number"), value: customerNumber.value || null },
{ label: t("user_admin.group_id"), value: user.group_id.value },
{ label: t("common.email"), value: user.email?.value || user.economicData.email.value },
{ label: t("common.created"), value: user.created_at.value },
{ label: t("user_admin.updated_at"), value: user.updated_at.value },
{ label: t("common.created"), value: formatLocaleDateTime(user.created_at.value, locale.value) || user.created_at.value },
{ label: t("user_admin.updated_at"), value: formatLocaleDateTime(user.updated_at.value, locale.value) || user.updated_at.value },
]);
const economicDetails = computed(() => [
@@ -148,17 +143,70 @@ const overviewMetrics = computed(() => [
]);
const hubLinks = computed(() => [
{ key: "orders", icon: "fas fa-file-alt", label: t("superuser.nav.orders"), to: `${basePath.value}/orders` },
{ key: "pricing", icon: "fas fa-tags", label: t("user_admin.overview.pricing"), to: `${basePath.value}/pricing` },
{ key: "other", icon: "fas fa-sliders-h", label: t("user_admin.other"), to: `${basePath.value}/other` },
{ key: "vehicles", icon: "fas fa-car", label: t("common.vehicles"), to: `${basePath.value}/vehicles` },
{ key: "orders", icon: "fas fa-file-alt", label: t("superuser.nav.orders"), to: `${basePath.value}/orders`, testId: "superuser-user-overview-link-orders" },
{ key: "pricing", icon: "fas fa-tags", label: t("user_admin.overview.pricing"), to: `${basePath.value}/pricing`, testId: "superuser-user-overview-link-pricing" },
{ key: "other", icon: "fas fa-sliders-h", label: t("user_admin.other"), to: `${basePath.value}/other`, testId: "superuser-user-overview-link-other" },
{ key: "security", icon: "fas fa-shield-alt", label: t("settings.security"), to: `${basePath.value}/security`, testId: "superuser-user-overview-link-security" },
{ key: "vehicles", icon: "fas fa-car", label: t("common.vehicles"), to: `${basePath.value}/vehicles`, testId: "superuser-user-overview-link-vehicles" },
{
key: "xlvask",
icon: "fas fa-water",
label: SessionUser.superUser.modules.xlvask.meta.title,
to: `${basePath.value}/xlvask`,
testId: "superuser-user-overview-link-xlvask",
},
]);
const actionOverviewSections = computed(() => [
{
key: "customer",
title: t("superuser.user_detail.overview.customer_actions_title"),
subtitle: t("superuser.user_detail.overview.customer_actions_subtitle"),
sectionKeys: ["customer"],
testId: "superuser-user-overview-customer-actions",
},
{
key: "shortcuts",
title: t("superuser.user_detail.overview.customer_shortcuts_title"),
subtitle: t("superuser.user_detail.overview.customer_shortcuts_subtitle"),
sectionKeys: ["shortcuts"],
testId: "superuser-user-overview-customer-shortcuts",
},
]);
const normalizedCurrentGroupId = computed(() => {
const currentGroupId = user.group_id.value;
return currentGroupId === null || currentGroupId === undefined || currentGroupId === ""
? CUSTOMER_GROUP_SENTINEL
: String(currentGroupId);
});
const resolvedRoleOptions = computed(() => {
const options = Array.isArray(roleOptions.value) ? [...roleOptions.value] : [];
if (normalizedCurrentGroupId.value === CUSTOMER_GROUP_SENTINEL) {
return [
{
value: CUSTOMER_GROUP_SENTINEL,
label: t("global.customer"),
},
...options,
];
}
return options;
});
const hasRoleOptions = computed(() => resolvedRoleOptions.value.length > 0);
const currentGroupLabel = computed(() => {
if (rolesLoading.value) {
return t("common.loading");
}
const selectedRole = resolvedRoleOptions.value.find((role) => role.value === normalizedCurrentGroupId.value);
if (selectedRole) {
return selectedRole.label;
}
return normalizedCurrentGroupId.value === CUSTOMER_GROUP_SENTINEL
? t("global.customer")
: `#${normalizedCurrentGroupId.value}`;
});
const valueOrEmpty = (value) => {
if (value === null || value === undefined || value === "") {
@@ -168,6 +216,25 @@ const valueOrEmpty = (value) => {
return value;
};
const loadRoleOptions = async () => {
rolesLoading.value = true;
try {
const roles = await SessionUser.objects.roles.get.all();
roleOptions.value = Array.isArray(roles)
? roles
.map((role) => ({
value: String(role.id),
label: role.name || `#${role.id}`,
}))
.sort((left, right) => Number(left.value) - Number(right.value))
: [];
} catch (_error) {
roleOptions.value = [];
} finally {
rolesLoading.value = false;
}
};
const loadVehicles = async () => {
if (!hasCustomerNumber.value) {
vehicles.value = [];
@@ -201,13 +268,20 @@ watch(
},
{ immediate: true }
);
loadRoleOptions();
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<UserSubPageWrapper>
<template #title>
<PageTitle :title="$t('user_admin.title')" :subtitle="$t('user_admin.overview.subtitle')" />
<SuperuserUserPageHeader
page-key="overview"
:display-name="displayName"
:user-id="userId"
:customer-number="customerNumber || null"
/>
</template>
<section class="user-overview" data-testid="superuser-user-overview-page">
@@ -225,216 +299,104 @@ watch(
</div>
<template v-else-if="selectedUserLoaded">
<div class="user-overview__header box" data-testid="superuser-user-overview-header">
<div>
<p class="heading">{{ $t("user_admin.overview.management_hub") }}</p>
<h2 class="title is-3 mb-2">{{ displayName }}</h2>
<p class="subtitle is-6 mb-0">
{{ $t("user_admin.user_id") }} {{ userId }}
<span v-if="hasCustomerNumber">&middot; {{ $t("user_admin.customer_number") }} {{ customerNumber }}</span>
</p>
</div>
<div class="user-overview__header-actions">
<ActionSettingsWheelButton
:user_id="userId"
:customer_number="customerNumber || null"
data-testid="superuser-user-overview-actions"
<SuperuserOverviewPanel
:title="$t('user_admin.overview.management_hub')"
:subtitle="displayName"
data-testid="superuser-user-overview-header"
>
<template #actions>
<b-tooltip :label="$t('global.actions')" position="is-bottom" type="is-dark">
<ActionSettingsWheelButton
:user_id="userId"
:customer_number="customerNumber || null"
data-testid="superuser-user-overview-actions"
/>
</b-tooltip>
</template>
<p class="has-text-grey">{{ $t("superuser.user_detail.overview_header_help") }}</p>
</SuperuserOverviewPanel>
<div class="user-overview__metrics columns is-multiline" data-testid="superuser-user-overview-metrics">
<div v-for="metric in overviewMetrics" :key="metric.key" class="column is-3-desktop is-6-tablet">
<SuperuserOverviewMetricCard
:icon="metric.icon"
:label="metric.label"
:value="metric.value"
:status="metric.status"
:tone="metric.tone"
:data-testid="`superuser-user-overview-metric-${metric.key}`"
/>
</div>
</div>
<div class="user-overview__metrics columns is-multiline" data-testid="superuser-user-overview-metrics">
<div v-for="metric in overviewMetrics" :key="metric.key" class="column is-3-desktop is-6-tablet">
<div class="box user-overview__metric" :data-testid="`superuser-user-overview-metric-${metric.key}`">
<div class="user-overview__metric-top">
<span class="icon"><i :class="metric.icon"></i></span>
<span class="tag is-light" :class="metric.tone">{{ metric.status }}</span>
</div>
<p class="heading">{{ metric.label }}</p>
<p class="title is-4">{{ metric.value }}</p>
</div>
</div>
</div>
<SuperuserOverviewPanel
:title="$t('user_admin.overview.workspace_shortcuts')"
:subtitle="$t('superuser.user_detail.workspace_shortcuts_help')"
data-testid="superuser-user-overview-shortcuts"
>
<SuperuserOverviewActionGrid :items="hubLinks" />
</SuperuserOverviewPanel>
<div class="box" data-testid="superuser-user-overview-shortcuts">
<div class="level is-mobile user-overview__section-title">
<div class="level-left">
<h3 class="title is-5 mb-0">{{ $t("user_admin.overview.workspace_shortcuts") }}</h3>
</div>
</div>
<div class="buttons">
<router-link
v-for="link in hubLinks"
:key="link.key"
class="button is-light"
:to="link.to"
:data-testid="`superuser-user-overview-link-${link.key}`"
>
<span class="icon"><i :class="link.icon"></i></span>
<span>{{ link.label }}</span>
</router-link>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-account">
<h3 class="title is-5">{{ $t("user_admin.user_data") }}</h3>
<dl class="user-overview__details">
<template v-for="detail in userDetails" :key="detail.label">
<dt>{{ detail.label }}</dt>
<dd>{{ valueOrEmpty(detail.value) }}</dd>
</template>
</dl>
</section>
</div>
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-economic">
<h3 class="title is-5">{{ $t("user_admin.economic_data") }}</h3>
<dl class="user-overview__details">
<template v-for="detail in economicDetails" :key="detail.label">
<dt>{{ detail.label }}</dt>
<dd>{{ valueOrEmpty(detail.value) }}</dd>
</template>
</dl>
</section>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-access">
<h3 class="title is-5">{{ $t("user_admin.permissions") }}</h3>
<div v-if="visiblePermissions.length" class="tags">
<span
v-for="permission in visiblePermissions"
:key="permission"
class="tag is-info is-light"
:data-testid="`superuser-user-overview-permission-${permission}`"
>
{{ permission }}
</span>
<span v-if="hiddenPermissionCount > 0" class="tag">
{{ $t("user_admin.overview.more_permissions", { count: hiddenPermissionCount }) }}
</span>
</div>
<p v-else class="has-text-grey">{{ $t("global.no_data") }}</p>
</section>
</div>
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-rules">
<h3 class="title is-5">{{ $t("user_admin.overview.customer_rules") }}</h3>
<ActionSettingsWheelButton
v-if="hasCustomerNumber"
:user_id="userId"
:customer_number="customerNumber"
:display-actions-directly="true"
data-testid="superuser-user-overview-direct-actions"
<div class="user-overview__two-column-grid">
<div>
<SuperuserOverviewPanel :title="$t('user_admin.user_data')" data-testid="superuser-user-overview-account">
<SuperuserOverviewDefinitionList
:rows="userDetails.map((detail) => ({ ...detail, value: valueOrEmpty(detail.value) }))"
/>
<p v-else class="has-text-grey">{{ $t("user_admin.overview.customer_number_required") }}</p>
</section>
<div class="user-overview__field">
<span class="user-overview__field-label">
{{ $t("user_admin.group_id") }}
</span>
<div class="user-overview__field-control">
<span
id="superuser-user-overview-group-id"
class="tag is-light is-medium user-overview__group-label"
data-testid="superuser-user-overview-group-id-label"
>
{{ currentGroupLabel }}
</span>
<p v-if="!hasRoleOptions && !rolesLoading" class="help is-warning">
{{ $t("global.no_data") }}
</p>
</div>
</div>
</SuperuserOverviewPanel>
</div>
<div>
<SuperuserOverviewPanel :title="$t('user_admin.economic_data')" data-testid="superuser-user-overview-economic">
<SuperuserOverviewDefinitionList
:rows="economicDetails.map((detail) => ({ ...detail, value: valueOrEmpty(detail.value) }))"
/>
</SuperuserOverviewPanel>
</div>
</div>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-customer-management">
<h3 class="title is-5">{{ $t("user_admin.overview.customer_management") }}</h3>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<UserFixedPricing :key="`fixed-${customerNumber}`" :customer_number="customerNumber" />
</div>
<div class="column is-6-desktop">
<UserDefaultDepartment :key="`department-${customerNumber}`" :customer_number="customerNumber" />
</div>
<div class="column is-6-desktop">
<UserOtherSpecialArrangement :key="`special-${userId}`" :user_id="userId" />
</div>
<div class="column is-6-desktop">
<UserOtherVaskeabonnement :key="`subscription-note-${userId}`" :user_id="userId" />
</div>
</div>
</section>
<div
v-if="hasCustomerNumber"
class="user-overview__two-column-grid"
data-testid="superuser-user-overview-action-sections"
>
<SuperuserOverviewPanel
v-for="section in actionOverviewSections"
:key="section.key"
:title="section.title"
:subtitle="section.subtitle"
:data-testid="section.testId"
>
<ActionSettingsWheelButton
:user_id="userId"
:customer_number="customerNumber"
:display-actions-directly="true"
:direct-section-keys="section.sectionKeys"
:data-testid="`superuser-user-overview-direct-actions-${section.key}`"
/>
</SuperuserOverviewPanel>
</div>
<section v-else class="notification is-warning" data-testid="superuser-user-overview-no-customer-number">
<div v-else class="notification is-warning" data-testid="superuser-user-overview-no-customer-number">
{{ $t("user_admin.overview.customer_number_required") }}
</section>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-vehicles">
<div class="level is-mobile user-overview__section-title">
<div class="level-left">
<h3 class="title is-5 mb-0">{{ $t("common.vehicles") }}</h3>
</div>
<div class="level-right">
<button
class="button is-small is-light"
type="button"
:disabled="vehiclesLoading"
data-testid="superuser-user-overview-vehicles-reload"
@click="loadVehicles"
>
{{ $t("global.reload") }}
</button>
</div>
</div>
<div v-if="vehiclesError" class="notification is-warning" data-testid="superuser-user-overview-vehicles-error">
{{ $t("user_admin.overview.vehicles_load_failed") }}
</div>
<div class="columns is-multiline">
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ $t("common.vehicles") }}</p>
<p class="title is-4">{{ vehicles.length }}</p>
</div>
</div>
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ $t("user_admin.wash_subscriptions") }}</p>
<p class="title is-4">{{ activeVehicleSubscriptions }}</p>
</div>
</div>
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ SessionUser.superUser.modules.xlvask.meta.title }}</p>
<p class="title is-4">{{ selfServiceVehicles }}</p>
</div>
</div>
</div>
<div v-if="visibleVehicles.length" class="tags" data-testid="superuser-user-overview-vehicle-preview">
<span
v-for="vehicle in visibleVehicles"
:key="vehicle.id || vehicle.reg"
class="tag is-light"
:data-testid="`superuser-user-overview-vehicle-${vehicle.id || vehicle.reg}`"
>
{{ vehicle.reg || vehicle.reference || `#${vehicle.id}` }}
</span>
</div>
<p v-else-if="!vehiclesLoading" class="has-text-grey">{{ $t("global.no_data") }}</p>
<div class="buttons">
<router-link class="button is-light" :to="`${basePath}/vehicles`" data-testid="superuser-user-overview-open-vehicles">
<span class="icon"><i class="fas fa-list"></i></span>
<span>{{ $t("user_admin.overview.open_vehicles") }}</span>
</router-link>
<button
class="button is-light"
type="button"
data-testid="superuser-user-overview-add-vehicle"
@click="SessionUser.objects.vehicles.functions.showCreateObjectForCustomerForm(customerNumber, loadVehicles)"
>
<span class="icon"><i class="fas fa-plus"></i></span>
<span>{{ $t("user_vehicles.add_vehicle") }}</span>
</button>
</div>
</section>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-subscriptions">
<h3 class="title is-5">{{ $t("user_admin.overview.subscription_invoicing") }}</h3>
<UserVehicleSubscriptionsDisplay :key="`subscriptions-${customerNumber}`" :user="user" />
<p v-if="washSubscriptionTransactions.length === 0" class="has-text-grey">
{{ $t("user_admin.overview.no_subscription_transactions") }}
</p>
</section>
</div>
</template>
</section>
</UserSubPageWrapper>
@@ -446,61 +408,58 @@ watch(
padding-bottom: 2rem;
}
.user-overview__header {
align-items: flex-start;
.user-overview__two-column-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.user-overview__field {
align-items: center;
border-top: 1px solid #edf2f7;
display: flex;
gap: 1rem;
justify-content: space-between;
min-height: 3.25rem;
padding-top: 0.55rem;
}
.user-overview__header-actions {
flex: 0 0 auto;
}
.user-overview__metric,
.user-overview__compact-stat {
height: 100%;
}
.user-overview__metric-top {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.user-overview__details {
display: grid;
gap: 0.5rem 1rem;
grid-template-columns: minmax(8rem, 38%) 1fr;
}
.user-overview__details dt {
color: #6b7280;
font-weight: 600;
}
.user-overview__details dd {
.user-overview__field-label {
color: #64748b;
font-size: 0.82rem;
margin: 0;
min-width: 0;
}
.user-overview__field-control {
margin-left: auto;
max-width: 58%;
min-width: 14rem;
}
.user-overview__group-label {
justify-content: flex-start;
max-width: 100%;
min-height: 2rem;
overflow-wrap: anywhere;
white-space: normal;
}
.user-overview__section-title {
margin-bottom: 1rem;
}
@media screen and (max-width: 768px) {
.user-overview__header {
display: block;
}
.user-overview__header-actions {
margin-top: 1rem;
}
.user-overview__details {
@media (max-width: 768px) {
.user-overview__two-column-grid {
grid-template-columns: 1fr;
}
.user-overview__field {
align-items: flex-start;
flex-direction: column;
gap: 0.35rem;
}
.user-overview__field-control {
margin-left: 0;
max-width: 100%;
min-width: 0;
width: 100%;
}
}
</style>
@@ -1,133 +1,91 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { useRouter } from 'vue-router'
import {user, getUserData, userId, setUser, getUserCustomerNumber} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import OrdersPagination from "@/components/displays/pagination/models/DepartmentPos/OrdersPagination.vue";
import {watch} from "vue";
import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue";
import PageLoader from "@/components/global/PageLoader.vue";
import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
import InvoiceOrdersPagination
from "@/components/displays/pagination/models/SuperUserDashboard/InvoiceOrdersPagination.vue";
// Get the user from the route
const router = useRouter()
import SuperuserOverviewDefinitionList from "@/components/displays/superuser/overview/SuperuserOverviewDefinitionList.vue";
import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue";
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import SuperuserUserPageHeader from "@/views/dashboards/superUserDashboard/user/SuperuserUserPageHeader.vue";
import InvoiceOrdersPagination from "@/components/displays/pagination/models/SuperUserDashboard/InvoiceOrdersPagination.vue";
import {
user,
userId,
setUser,
} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { getCustomerRuleAttributeKeys, getCustomerRuleDefinition } from "@/features/customer/customerRuleRegistry.js";
// Set the user
setUser(router.currentRoute.value.params.userId);
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const invoiceOrders = async (orders) => {
console.log('Invoiced individual orders');
// Check if the order draft exists
await SessionUser.adminUser.invoices.draft.get(user.keys.value.open_invoice_draft).then(async (response) => {
console.log(response);
for (let i = 0; i < orders.length; i++) {
console.log(orders[i].id);
await SessionUser.adminUser.invoices.exportOrderToDraft(orders[i].id);
}
}).catch((error) => {
Swal.fire({
title: t('common.error'),
html: '<p>' + t('user_admin.orders.draft_not_found') + '</p>',
icon: 'error',
confirmButtonText: t('common.confirm')
});
console.log(error);
});
};
setUser(route.params.userId);
const invoicePerOrder = () => {
// Check if the customer has been loaded
if (user.attributes.value === null) {
return false;
}
return user.attributes.value.some(attribute => attribute.attribute === 'invoiceAllOrdersIndividually');
};
const customerNumber = computed(() => Number.parseInt(String(user.customer_number.value || 0), 10) || 0);
const displayName = computed(() => (
user.economicData.name.value
|| user.display_name.value
|| `${t("user_admin.user_id")} ${userId.value || route.params.userId}`
));
const attributeKeys = computed(() => getCustomerRuleAttributeKeys(user.attributes.value));
const invoicingMode = computed(() => {
const definition = getCustomerRuleDefinition("invoiceAllOrdersIndividually");
return attributeKeys.value.includes("invoiceAllOrdersIndividually")
? t(definition?.labelKey || "pagination.invoice_per_order")
: t("pagination.invoice_per_month");
});
const reload = () => {
// Reload the user data
getUserData();
};
const ordersSummaryRows = computed(() => [
{ label: t("superuser.user_detail.orders.invoicing_mode"), value: invoicingMode.value },
{ label: t("user_admin.overview.not_invoiced"), value: user.orders_not_invoiced.value?.length || 0 },
{
label: t("superuser.user_detail.orders.open_draft"),
value: user.keys.value?.open_invoice_draft || t("global.no_data"),
},
]);
</script>
<template>
<UserSubPageWrapper>
<template #title>
<PageTitle :title="$t('user_admin.title')" :subtitle="$t('user_admin.orders.transaction_history')" />
<SuperuserUserPageHeader
page-key="orders"
:display-name="displayName"
:user-id="userId"
:customer-number="customerNumber || null"
/>
</template>
<div>
<p>{{ $t('user_admin.user_id') }}: {{ userId }} ({{ $t('user_admin.customer_number') }}: {{ user.customer_number }})</p>
<!-- User invoicing -->
<!--
<div class="card">
<div class="card-header">
<p class="card-header-title">
<span class="icon"><i class="fas fa-file-invoice"></i></span>
Fakturering
</p>
<p class="card-header-icon" @click="console.log('Settings clicked')">
<span class="icon"><i class="fas fa-cog"></i></span>
</p>
</div>
<div class="card-content">
<p>Faktureringsmetode: <strong>{{ invoicePerOrder() ? 'Faktureres pr. ordre' : 'Faktureres samlet' }}</strong></p>
<p v-if="user.orders_not_invoiced.value">Transaktioner der ikke er faktureret: <strong>{{ user.orders_not_invoiced.value.length }}</strong></p>
<p v-else>Alle transaktioner er faktureret</p>
<p v-if="user.keys.value.open_invoice_draft">Aktiv faktura kladde: <strong>{{ user.keys.value.open_invoice_draft }}</strong></p>
</div>
<div class="card-footer">
<div class="card-footer-item">
<button class="card-footer-item button is-warning is-inverted mx-2"
v-if="!invoicePerOrder() && user.orders_not_invoiced.value && user.orders_not_invoiced.value.length > 0"
@click="invoiceOrders(user.orders_not_invoiced.value).then(reload())">Tilføj {{ user.orders_not_invoiced.value.length ?? 0 }} transaktioner til samlet faktura kladde
</button>
<button class="card-footer-item button is-dark mx-2"
v-else
disabled>Alle transaktioner er faktureret
</button>
<button class="card-footer-item button is-warning is-inverted mx-2"
v-if="invoicePerOrder() && user.orders_not_invoiced.value"
@click="invoiceOrders(user.orders_not_invoiced.value)">Opret individuelle faktura kladder: {{ user.orders_not_invoiced.value.length ?? 0 }} transaktioner
</button>
<button class="card-footer-item button is-success is-inverted mx-2"
v-if="!invoicePerOrder() && user.keys.value.open_invoice_draft"
@click="SessionUser.adminUser.invoices.draft.close(user.keys.value.open_invoice_draft).then(reload())">Luk faktura kladde ({{ user.keys.value.open_invoice_draft }})
</button>
</div>
</div>
</div> -->
<div class="superuser-user-subpage">
<SuperuserOverviewPanel
:title="$t('superuser.user_detail.orders.summary_title')"
:subtitle="$t('superuser.user_detail.orders.summary_subtitle')"
data-testid="superuser-user-orders-summary"
>
<SuperuserOverviewDefinitionList :rows="ordersSummaryRows" />
</SuperuserOverviewPanel>
<!-- <p>User orders not invoiced: {{ user.orders_not_invoiced.value.length }}</p> -->
<!-- "orders_not_invoiced": [ { "id": "429" }, { "id": "430" } ] } -->
<!--<p v-if="user.orders_not_invoiced">
<span v-for="order in user.orders_not_invoiced.value" :key="order.id">{{ order.id }}, </span>
</p> -->
<!-- <button @click="invoiceOrders(user.orders_not_invoiced.value)">Invoice orders</button> -->
<!-- Orders -->
<!--<OrdersPagination :hideSearch="true" :set-customer-filter="user.customer_number.value" :auto-load="false" v-if="user.customer_number.value" /> -->
<InvoiceOrdersPagination
v-if="user.customer_number.value"
:hideSearch="false"
:apply-default-filters="false"
:set-customer-filter="user.customer_number.value"
:auto-load="true"
:invoice-view="true"
:include-system-orders="true"
v-if="user.customer_number.value"
/>
<!-- While the user is not loaded, show a loading message -->
/>
<div v-else>
<page-loader :title="$t('user_admin.orders.loading_title')" :subtitle="$t('user_admin.orders.loading_subtitle')" />
<PageLoader :title="$t('user_admin.orders.loading_title')" :subtitle="$t('user_admin.orders.loading_subtitle')" />
</div>
</div>
</UserSubPageWrapper>
</template>
<style scoped>
</style>
.superuser-user-subpage {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
@@ -1,46 +1,120 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue";
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { useRouter } from 'vue-router'
import {user, getUserData, userId, setUser, getUserCustomerNumber} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import OrdersPagination from "@/components/displays/pagination/models/DepartmentPos/OrdersPagination.vue";
import {ref, watch} from "vue";
import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue";
import PageLoader from "@/components/global/PageLoader.vue";
import Swal from "sweetalert2";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import UserOtherSpecialArrangement
from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue";
import UserOtherVaskeabonnement
from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue";
// Get the user from the route
const router = useRouter()
import SuperuserUserPageHeader from "@/views/dashboards/superUserDashboard/user/SuperuserUserPageHeader.vue";
import {
user,
userId,
setUser,
} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import UserDefaultDepartment from "@/views/dashboards/superUserDashboard/user/displays/UserDefaultDepartment.vue";
import UserFixedPricing from "@/views/dashboards/superUserDashboard/user/displays/UserFixedPricing.vue";
import UserOtherSpecialArrangement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue";
import UserOtherVaskeabonnement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue";
// Set the user
setUser(router.currentRoute.value.params.userId);
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const reload = () => {
// Reload the user data
getUserData();
};
setUser(route.params.userId);
const customerNumber = computed(() => Number.parseInt(String(user.customer_number.value || 0), 10) || 0);
const hasCustomerNumber = computed(() => customerNumber.value > 0);
const displayName = computed(() => (
user.economicData.name.value
|| user.display_name.value
|| `${t("user_admin.user_id")} ${userId.value || route.params.userId}`
));
</script>
<template>
<UserSubPageWrapper>
<template #title>
<PageTitle :title="$t('user_admin.title')" :subtitle="$t('user_admin.other')" />
<SuperuserUserPageHeader
page-key="other"
:display-name="displayName"
:user-id="userId"
:customer-number="customerNumber || null"
/>
</template>
<div class="mx-3 mb-6">
<p>{{ $t('user_admin.user_id') }}: {{ userId }} ({{ $t('user_admin.customer_number') }}: {{ user.customer_number }})</p>
<userOtherSpecialArrangement :user_id="userId" class="mb-2"/>
<user-other-vaskeabonnement :user_id="userId" />
</div>
<div class="superuser-user-subpage">
<div v-if="hasCustomerNumber" class="superuser-user-subpage__grid">
<SuperuserOverviewPanel
:title="$t('superuser.user_detail.overview.default_department_title')"
:subtitle="$t('superuser.user_detail.overview.default_department_subtitle')"
data-testid="superuser-user-other-default-department"
>
<UserDefaultDepartment :key="`department-${customerNumber}`" :customer_number="customerNumber" />
</SuperuserOverviewPanel>
<SuperuserOverviewPanel
:title="$t('superuser.user_detail.overview.fixed_pricing_title')"
:subtitle="$t('superuser.user_detail.overview.fixed_pricing_subtitle')"
data-testid="superuser-user-other-fixed-pricing"
>
<UserFixedPricing :key="`fixed-${customerNumber}`" :customer_number="customerNumber" />
</SuperuserOverviewPanel>
</div>
<SuperuserOverviewPanel
:title="$t('superuser.user_detail.overview.special_arrangement_title')"
:subtitle="$t('superuser.user_detail.overview.special_arrangement_subtitle')"
data-testid="superuser-user-other-special-arrangement"
>
<UserOtherSpecialArrangement :user_id="userId" />
</SuperuserOverviewPanel>
<SuperuserOverviewPanel
:title="$t('superuser.user_detail.overview.wash_subscription_note_title')"
:subtitle="$t('superuser.user_detail.overview.wash_subscription_note_subtitle')"
data-testid="superuser-user-other-wash-subscription"
>
<UserOtherVaskeabonnement :user_id="userId" />
</SuperuserOverviewPanel>
<SuperuserOverviewPanel
v-if="hasCustomerNumber"
:title="$t('superuser.user_detail.overview.customer_flags_title')"
:subtitle="$t('superuser.user_detail.overview.customer_flags_subtitle')"
data-testid="superuser-user-other-customer-flags"
>
<ActionSettingsWheelButton
:user_id="userId"
:customer_number="customerNumber"
:display-actions-directly="true"
:direct-section-keys="['invoice-period-flags']"
data-testid="superuser-user-other-direct-actions-flags"
/>
</SuperuserOverviewPanel>
<div v-else class="notification is-warning" data-testid="superuser-user-other-no-customer-number">
{{ $t("user_admin.overview.customer_number_required") }}
</div>
</div>
</UserSubPageWrapper>
</template>
<style scoped>
.superuser-user-subpage {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
.superuser-user-subpage__grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 768px) {
.superuser-user-subpage__grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,68 +1,88 @@
<script setup>
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { useRouter } from 'vue-router'
import { user, getUserData, userId, setUser, getUserProductDiscount, getUserGlobalDiscount, getProductBestApplicableDiscount, getUserProductOnlyDiscount, getUserProductFixedPrice, getProductEffectivePrice, getProductCategory, getUserCategoryDiscount, isProductAllowedToApplyCategoryDiscounts } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { getProducts } from "@/components/shop/Products.vue";
import { ref } from 'vue';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
// Get the user from the route
const router = useRouter()
import SuperuserOverviewDefinitionList from "@/components/displays/superuser/overview/SuperuserOverviewDefinitionList.vue";
import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue";
import { getProducts } from "@/components/shop/Products.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import SuperuserUserPageHeader from "@/views/dashboards/superUserDashboard/user/SuperuserUserPageHeader.vue";
import {
user,
getUserData,
userId,
setUser,
getUserProductDiscount,
getUserGlobalDiscount,
getProductBestApplicableDiscount,
getUserProductOnlyDiscount,
getUserProductFixedPrice,
getProductEffectivePrice,
getProductCategory,
getUserCategoryDiscount,
isProductAllowedToApplyCategoryDiscounts,
} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
// Set the user
setUser(router.currentRoute.value.params.userId);
const route = useRoute();
const { t, locale } = useI18n({ useScope: "global" });
setUser(route.params.userId);
// Set the products
const products = ref([]);
// Get the products
getProducts().then((response) => {
products.value = response.data.data;
});
// Edit the discount
const customerNumber = computed(() => Number.parseInt(String(user.customer_number.value || 0), 10) || 0);
const displayName = computed(() => (
user.economicData.name.value
|| user.display_name.value
|| `${t("user_admin.user_id")} ${userId.value || route.params.userId}`
));
const pricingSummaryRows = computed(() => [
{ label: t("superuser.user_detail.pricing.global_discount"), value: `${getUserGlobalDiscount()}%` },
{ label: t("superuser.user_detail.pricing.product_overrides"), value: user.discounts.value?.length || 0 },
]);
const editDiscount = (product, isCategory) => {
const discount = getUserProductDiscount(product, isCategory, isCategory);
const productOrCategoryId = isCategory ? product.category : product.id;
Swal.fire({
title: (isCategory ? productOrCategoryId : product.name) + ' ( ' + (isCategory ? 'category' : 'product') + ': ' + product.id + ' )',
input: 'number',
title: `${isCategory ? getProductCategory(product) : product.name} (${t(isCategory ? "tables.common.category" : "common.product")} ${product.id})`,
input: "number",
inputValue: discount,
inputLabel: 'Discount (%)',
inputLabel: t("superuser.user_detail.pricing.discount_percent"),
inputAttributes: {
autocapitalize: 'off'
autocapitalize: "off",
},
showCancelButton: true,
confirmButtonText: 'Save',
confirmButtonText: t("common.save"),
showLoaderOnConfirm: true,
preConfirm: (discount) => {
return authenticatedRequest(`/superuser/user/discounts`, "POST", {
user_id: userId.value,
object_id: productOrCategoryId,
discount: discount,
is_category: isCategory
})
.then((response) => {
console.log(response);
getUserData();
})
.catch((error) => {
console.log(error);
});
},
allowOutsideClick: () => !Swal.isLoading()
preConfirm: (updatedDiscount) => authenticatedRequest("/superuser/user/discounts", "POST", {
user_id: userId.value,
object_id: productOrCategoryId,
discount: updatedDiscount,
is_category: isCategory,
}).then(() => {
getUserData();
}),
allowOutsideClick: () => !Swal.isLoading(),
});
}
};
const formatPrice = (price) => `${Number(price || 0).toFixed(0)} Kr.`;
const formatPrice = (price) => new Intl.NumberFormat(locale.value || undefined, {
style: "currency",
currency: "DKK",
maximumFractionDigits: 0,
}).format(Number(price || 0));
const getProductPriceDisplay = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
if (fixedPrice !== null) {
return `${formatPrice(fixedPrice)} (fast pris)`;
return `${formatPrice(fixedPrice)} (${t("superuser.user_detail.pricing.fixed_price_label").toLowerCase()})`;
}
const discount = getProductBestApplicableDiscount(product);
@@ -70,131 +90,153 @@ const getProductPriceDisplay = (product) => {
return formatPrice(product.price);
}
return `${formatPrice(getProductEffectivePrice(product))} (${discount}% rabat)`;
return `${formatPrice(getProductEffectivePrice(product))} (${discount}% ${t("common.discount").toLowerCase()})`;
};
const editFixedPrice = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
Swal.fire({
title: product.name + ' ( product: ' + product.id + ' )',
input: 'number',
inputValue: fixedPrice === null ? '' : fixedPrice,
inputLabel: 'Fast pris (Kr.)',
title: `${product.name} (${t("common.product")} ${product.id})`,
input: "number",
inputValue: fixedPrice === null ? "" : fixedPrice,
inputLabel: t("superuser.user_detail.pricing.fixed_price_label"),
inputAttributes: {
autocapitalize: 'off',
autocapitalize: "off",
min: 0,
step: 1
step: 1,
},
showCancelButton: true,
confirmButtonText: 'Save',
confirmButtonText: t("common.save"),
showLoaderOnConfirm: true,
inputValidator: (value) => {
const normalizedValue = String(value ?? '').trim();
if (normalizedValue === '') {
const normalizedValue = String(value ?? "").trim();
if (normalizedValue === "") {
return null;
}
const parsedValue = Number(normalizedValue);
if (!Number.isInteger(parsedValue) || parsedValue < 0) {
return 'Fast pris skal være et heltal eller tom.';
return t("superuser.user_detail.pricing.fixed_price_validation");
}
return null;
},
preConfirm: (value) => {
const normalizedValue = String(value ?? '').trim();
const fixedPriceValue = normalizedValue === '' ? null : Number.parseInt(normalizedValue, 10);
return authenticatedRequest(`/superuser/user/discounts`, "POST", {
const normalizedValue = String(value ?? "").trim();
const fixedPriceValue = normalizedValue === "" ? null : Number.parseInt(normalizedValue, 10);
return authenticatedRequest("/superuser/user/discounts", "POST", {
user_id: userId.value,
object_id: product.id,
discount: getUserProductOnlyDiscount(product),
fixed_price: fixedPriceValue,
is_category: false
})
.then((response) => {
console.log(response);
getUserData();
})
.catch((error) => {
console.log(error);
});
is_category: false,
}).then(() => {
getUserData();
});
},
allowOutsideClick: () => !Swal.isLoading()
allowOutsideClick: () => !Swal.isLoading(),
});
}
};
</script>
<template>
<UserSubPageWrapper>
<template #title>
<PageTitle title="User" subtitle="Pricing" />
<SuperuserUserPageHeader
page-key="pricing"
:display-name="displayName"
:user-id="userId"
:customer-number="customerNumber || null"
/>
</template>
<div>
<p>User ID: {{ userId }} (Customer number: {{ user.customer_number }})</p>
<p>Global rabat, hentet fra E-conomic: <strong>{{ getUserGlobalDiscount() }}%</strong></p>
<!-- Products -->
<table class="table is-fullwidth is-striped is-narrow is-hoverable is-fullwidth is-bordered mt-2">
<thead>
<tr>
<th>{{ $t('tables.common.product_id') }}</th>
<th>{{ $t('tables.common.category') }}</th>
<th>{{ $t('tables.common.product_name') }}</th>
<th>{{ $t('tables.common.price') }}</th>
<th>Fast pris</th>
<th>{{ $t('tables.common.discount_item') }}</th>
<th>{{ $t('tables.common.discount_category') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products">
<td>{{ product.id }}</td>
<td>{{ getProductCategory(product)}}</td>
<td>{{ product.name }}</td>
<!-- Price -->
<td>{{ getProductPriceDisplay(product) }}</td>
<!-- Fixed price -->
<td
class="is-clickable"
@click="editFixedPrice(product)"
:data-testid="`customer-fixed-price-${product.id}`"
><template v-if="getUserProductFixedPrice(product) !== null">{{ getUserProductFixedPrice(product) }} Kr.</template><template v-else>-</template>
<i class="is-pulled-right fas fa-edit"></i>
</td>
<div class="superuser-user-subpage">
<SuperuserOverviewPanel
:title="$t('superuser.user_detail.pricing.summary_title')"
:subtitle="$t('superuser.user_detail.pricing.summary_subtitle')"
data-testid="superuser-user-pricing-summary"
>
<SuperuserOverviewDefinitionList :rows="pricingSummaryRows" />
</SuperuserOverviewPanel>
<!-- Discount: Item -->
<td
class="is-clickable"
@click="editDiscount(product, false)"
v-if="getUserProductOnlyDiscount(product) > 0"
>{{ getUserProductOnlyDiscount(product) }} %
<i class="is-pulled-right fas fa-edit"></i>
</td>
<td
class="is-clickable"
@click="editDiscount(product, false)"
v-else>- <i class="is-pulled-right fas fa-edit"></i>
</td>
<!-- Discount: Category -->
<td
class="is-clickable"
@click="editDiscount(product, true)"
v-if="getUserCategoryDiscount(getProductCategory(product)) > 0">{{ getUserCategoryDiscount(getProductCategory(product)) }} % {{ isProductAllowedToApplyCategoryDiscounts(product) ? ' ' : ' - Disabled for item. ' }}
<i class="is-pulled-right fas fa-edit"></i>
</td>
<td
class="is-clickable"
@click="editDiscount(product, 1)"
v-else>- <i class="is-pulled-right fas fa-edit"></i>
</td>
</tr>
</tbody>
</table>
<SuperuserOverviewPanel
:title="$t('superuser.user_detail.pricing.table_title')"
:subtitle="$t('superuser.user_detail.pricing.table_subtitle')"
data-testid="superuser-user-pricing-table"
>
<table class="table is-fullwidth is-striped is-narrow is-hoverable is-bordered">
<thead>
<tr>
<th>{{ $t("tables.common.product_id") }}</th>
<th>{{ $t("tables.common.category") }}</th>
<th>{{ $t("tables.common.product_name") }}</th>
<th>{{ $t("tables.common.price") }}</th>
<th>{{ $t("superuser.user_detail.pricing.fixed_price_label") }}</th>
<th>{{ $t("tables.common.discount_item") }}</th>
<th>{{ $t("tables.common.discount_category") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products" :key="product.id">
<td>{{ product.id }}</td>
<td>{{ getProductCategory(product) }}</td>
<td>{{ product.name }}</td>
<td>{{ getProductPriceDisplay(product) }}</td>
<td class="is-clickable" :data-testid="`customer-fixed-price-${product.id}`" @click="editFixedPrice(product)">
<b-tooltip :label="$t('superuser.user_detail.pricing.fixed_price_tooltip')" position="is-bottom" type="is-dark">
<span class="pricing-cell-action">
<span>
<template v-if="getUserProductFixedPrice(product) !== null">
{{ formatPrice(getUserProductFixedPrice(product)) }}
</template>
<template v-else>-</template>
</span>
<i class="fas fa-edit" />
</span>
</b-tooltip>
</td>
<td class="is-clickable" @click="editDiscount(product, false)">
<b-tooltip :label="$t('superuser.user_detail.pricing.item_discount_tooltip')" position="is-bottom" type="is-dark">
<span class="pricing-cell-action">
<span>{{ getUserProductOnlyDiscount(product) > 0 ? `${getUserProductOnlyDiscount(product)} %` : "-" }}</span>
<i class="fas fa-edit" />
</span>
</b-tooltip>
</td>
<td class="is-clickable" @click="editDiscount(product, true)">
<b-tooltip :label="$t('superuser.user_detail.pricing.category_discount_tooltip')" position="is-bottom" type="is-dark">
<span class="pricing-cell-action">
<span>
{{
getUserCategoryDiscount(getProductCategory(product)) > 0
? `${getUserCategoryDiscount(getProductCategory(product))} %${isProductAllowedToApplyCategoryDiscounts(product) ? "" : ` - ${$t("superuser.user_detail.pricing.category_discount_disabled")}`}`
: "-"
}}
</span>
<i class="fas fa-edit" />
</span>
</b-tooltip>
</td>
</tr>
</tbody>
</table>
</SuperuserOverviewPanel>
</div>
</UserSubPageWrapper>
</template>
<style scoped>
.superuser-user-subpage {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pricing-cell-action {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: space-between;
width: 100%;
}
</style>

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