Enhance subuser management contact and action flows

This commit is contained in:
Jeppe Bundgaard
2026-07-13 15:11:49 +02:00
parent 5a7f902d01
commit 443f50c144
29 changed files with 3972 additions and 298 deletions
@@ -4,6 +4,7 @@ import { useSlots } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import ActionSettingsWheelToggleItem from "@/components/displays/buttons/ActionSettingsWheelToggleItem.vue";
import ActionSettingsWheelSelectItem from "@/components/displays/buttons/ActionSettingsWheelSelectItem.vue";
import Swal from "sweetalert2";
import { ref } from "vue";
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
@@ -143,6 +144,7 @@ const isDesktopFlyoutLayout = ref(false);
const isFixedPosition = ref(false);
const fixedPositionStyles = ref({});
const activeDesktopFlyoutSectionKey = ref(null);
const activeDesktopFlyoutSubsectionKey = ref(null);
const activeAttachmentId = ref(null);
const previewLoadingAttachmentId = ref(null);
const deletingAttachmentId = ref(null);
@@ -155,6 +157,7 @@ let dropdownLayoutUpdateId = 0;
const desktopFlyoutMinViewportWidth = 1400;
const desktopFlyoutRootPanelWidthRem = 15;
const desktopFlyoutSubmenuWidthRem = 17;
const desktopFlyoutNestedPanelWidthRem = 17;
const desktopFlyoutPanelGapPx = 12;
const closeDropdown = () => {
@@ -223,11 +226,22 @@ const dropdownMenuStyle = computed(() => {
});
const dropdownContentStyle = computed(() => {
if (isFixedPosition.value) {
return {
const fixedStyles = {
maxHeight: dropdownMaxHeight.value ? `${dropdownMaxHeight.value}px` : "95vh",
overflowY: "auto",
overscrollBehavior: "contain",
};
if (isDesktopFlyoutLayout.value) {
return {
...fixedStyles,
overflow: "visible",
};
}
return {
...fixedStyles,
overflowY: "auto",
};
}
if (!dropdownMaxHeight.value) {
@@ -1605,12 +1619,26 @@ const buildMenuToggleAction = (key, config) => ({
...config,
});
const buildMenuSelectAction = (key, config) => ({
key,
disabled: false,
options: [],
type: "select",
...config,
});
const buildMenuLabel = (key, config) => ({
key,
type: "label",
...config,
});
const buildMenuSubsection = (key, config) => ({
key,
type: "subsection",
...config,
});
const buildMenuSection = (key, label, items) => {
const visibleItems = items.filter(Boolean);
if (visibleItems.length === 0) {
@@ -1625,7 +1653,41 @@ const buildMenuSection = (key, label, items) => {
};
const isToggleMenuItem = (item) => item?.type === "toggle";
const isSelectMenuItem = (item) => item?.type === "select";
const isLabelMenuItem = (item) => item?.type === "label";
const isSubsectionMenuItem = (item) => item?.type === "subsection";
const getSubsectionMenuItems = (section) => (section?.items || []).filter((item) => isSubsectionMenuItem(item));
const hasSubsectionMenuItems = (section) => getSubsectionMenuItems(section).length > 0;
const normalizeExternalMenuItem = (item, itemKey) => {
if (!item || item.visible === false) {
return null;
}
if (isToggleMenuItem(item)) {
return buildMenuToggleAction(itemKey, item);
}
if (isSelectMenuItem(item)) {
return buildMenuSelectAction(itemKey, item);
}
if (isLabelMenuItem(item)) {
return buildMenuLabel(itemKey, item);
}
if (isSubsectionMenuItem(item)) {
const children = Array.isArray(item.items) ? item.items : [];
return buildMenuSubsection(itemKey, {
...item,
items: children
.map((child, childIndex) => normalizeExternalMenuItem(child, String(child?.key || `${itemKey}-item-${childIndex}`)))
.filter(Boolean),
});
}
return buildMenuAction(itemKey, item);
};
const externalMenuSections = computed(() =>
props.menuSections
@@ -1637,21 +1699,8 @@ const externalMenuSections = computed(() =>
sectionKey,
section?.label || sectionKey,
items.map((item, itemIndex) => {
if (!item || item.visible === false) {
return null;
}
const itemKey = String(item.key || `${sectionKey}-item-${itemIndex}`);
if (isToggleMenuItem(item)) {
return buildMenuToggleAction(itemKey, item);
}
if (isLabelMenuItem(item)) {
return buildMenuLabel(itemKey, item);
}
return buildMenuAction(itemKey, item);
const itemKey = String(item?.key || `${sectionKey}-item-${itemIndex}`);
return normalizeExternalMenuItem(item, itemKey);
})
);
})
@@ -2502,6 +2551,7 @@ const desktopFlyoutMenuSections = computed(() => {
watch(
[
activeDesktopFlyoutSectionKey,
activeDesktopFlyoutSubsectionKey,
activeAttachmentId,
isDesktopFlyoutLayout,
desktopFlyoutMenuSections,
@@ -2525,13 +2575,40 @@ const activeDesktopFlyoutSection = computed(() => {
);
});
const activeDesktopFlyoutSubsection = computed(() => {
const subsectionItems = getSubsectionMenuItems(activeDesktopFlyoutSection.value);
if (subsectionItems.length === 0) {
return null;
}
return (
subsectionItems.find((item) => item.key === activeDesktopFlyoutSubsectionKey.value) ??
subsectionItems[0] ??
null
);
});
const hasDesktopFlyoutSubsections = computed(() =>
desktopFlyoutMenuSections.value.some((section) => hasSubsectionMenuItems(section))
);
const hasDropdownContent = computed(
() => hasCustomActionsSlot.value || flatBuiltInMenuSections.value.length > 0 || standaloneMenuActions.value.length > 0
);
const getDesktopFlyoutEstimatedWidth = () => {
const rootFontSize = Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize || "16") || 16;
return (desktopFlyoutRootPanelWidthRem + desktopFlyoutSubmenuWidthRem) * rootFontSize + desktopFlyoutPanelGapPx;
const panelWidthRem =
desktopFlyoutRootPanelWidthRem +
desktopFlyoutSubmenuWidthRem +
(hasDesktopFlyoutSubsections.value ? desktopFlyoutNestedPanelWidthRem : 0);
const panelGapCount = hasDesktopFlyoutSubsections.value ? 2 : 1;
return panelWidthRem * rootFontSize + desktopFlyoutPanelGapPx * panelGapCount;
};
const setActiveDesktopFlyoutSubsectionForSection = (section) => {
activeDesktopFlyoutSubsectionKey.value = getSubsectionMenuItems(section)[0]?.key ?? null;
};
const syncDesktopFlyoutState = (triggerRect) => {
@@ -2554,6 +2631,7 @@ const syncDesktopFlyoutState = (triggerRect) => {
if (!nextDesktopFlyoutState) {
activeDesktopFlyoutSectionKey.value = null;
activeDesktopFlyoutSubsectionKey.value = null;
return;
}
@@ -2563,17 +2641,25 @@ const syncDesktopFlyoutState = (triggerRect) => {
if (!currentSectionStillExists) {
activeDesktopFlyoutSectionKey.value = desktopFlyoutMenuSections.value[0]?.key ?? null;
setActiveDesktopFlyoutSubsectionForSection(desktopFlyoutMenuSections.value[0] ?? null);
}
};
const setActiveDesktopFlyoutSection = (sectionKey) => {
activeDesktopFlyoutSectionKey.value = sectionKey;
setActiveDesktopFlyoutSubsectionForSection(
desktopFlyoutMenuSections.value.find((section) => section.key === sectionKey) ?? null
);
if (sectionKey !== "attachments") {
activeAttachmentId.value = null;
}
};
const setActiveDesktopFlyoutSubsection = (subsectionKey) => {
activeDesktopFlyoutSubsectionKey.value = subsectionKey;
};
const isOverflowClippingStyle = (style) =>
["overflow", "overflowX", "overflowY"].some((property) => {
const value = style[property];
@@ -2694,10 +2780,60 @@ const syncDesktopFlyoutPosition = () => {
<template v-for="section in directBuiltInMenuSections" :key="section.key">
<ActionSettingsWheelItemLabel :label="section.label" />
<template v-for="item in section.items" :key="item.key">
<div
v-if="isSubsectionMenuItem(item)"
class="action-settings-wheel-subsection"
:data-testid="item.testId || `action-settings-wheel-subsection-${item.key}`"
>
<p class="action-settings-wheel-subsection__title">{{ item.label }}</p>
<p v-if="item.description" class="action-settings-wheel-subsection__description">{{ item.description }}</p>
<template v-for="subItem in item.items || []" :key="subItem.key">
<ActionSettingsWheelItemLabel
v-if="isLabelMenuItem(subItem)"
:icon="subItem.icon"
:label="subItem.label"
:detail-value="subItem.detailValue"
:appearance="subItem.appearance"
:template="subItem.template"
:data-testid="subItem.testId || undefined"
/>
<ActionSettingsWheelToggleItem
v-else-if="isToggleMenuItem(subItem)"
:checked="subItem.value"
:click-action="subItem.clickAction"
:description="subItem.description"
:disabled="subItem.disabled"
:label="subItem.label"
:test-id="subItem.testId"
/>
<ActionSettingsWheelSelectItem
v-else-if="isSelectMenuItem(subItem)"
:icon="subItem.icon"
:label="subItem.label"
:value="subItem.value"
:options="subItem.options"
:change-action="subItem.changeAction"
:disabled="subItem.disabled"
:test-id="subItem.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="subItem.icon"
:label="subItem.label"
:detail-value="subItem.detailValue"
:template="subItem.template"
:click-action="subItem.clickAction"
:disabled="subItem.disabled"
:test-id="subItem.testId"
/>
</template>
</div>
<ActionSettingsWheelItemLabel
v-if="isLabelMenuItem(item)"
v-else-if="isLabelMenuItem(item)"
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:appearance="item.appearance"
:template="item.template"
:data-testid="item.testId || undefined"
/>
@@ -2722,10 +2858,21 @@ const syncDesktopFlyoutPosition = () => {
</CustomerRuleTooltip>
</template>
</ActionSettingsWheelToggleItem>
<ActionSettingsWheelSelectItem
v-else-if="isSelectMenuItem(item)"
:icon="item.icon"
:label="item.label"
:value="item.value"
:options="item.options"
:change-action="item.changeAction"
:disabled="item.disabled"
:test-id="item.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
@@ -2738,6 +2885,7 @@ const syncDesktopFlyoutPosition = () => {
:key="item.key"
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
@@ -2771,6 +2919,62 @@ const syncDesktopFlyoutPosition = () => {
<template v-if="hasDropdownContent">
<template v-if="isDesktopFlyoutLayout && desktopFlyoutMenuSections.length > 0">
<div class="action-settings-wheel-flyout" data-testid="action-settings-wheel-flyout">
<div
v-if="activeDesktopFlyoutSubsection"
class="action-settings-wheel-nested-panel"
:data-testid="
activeDesktopFlyoutSubsection.testId
? `${activeDesktopFlyoutSubsection.testId}-panel`
: `action-settings-wheel-subsection-panel-${activeDesktopFlyoutSubsection.key}`
"
>
<p class="action-settings-wheel-nested-panel__title">{{ activeDesktopFlyoutSubsection.label }}</p>
<p v-if="activeDesktopFlyoutSubsection.description" class="action-settings-wheel-nested-panel__description">
{{ activeDesktopFlyoutSubsection.description }}
</p>
<div class="action-settings-wheel-nested-panel__items">
<template v-for="subItem in activeDesktopFlyoutSubsection.items || []" :key="subItem.key">
<ActionSettingsWheelItemLabel
v-if="isLabelMenuItem(subItem)"
:icon="subItem.icon"
:label="subItem.label"
:detail-value="subItem.detailValue"
:appearance="subItem.appearance"
:template="subItem.template"
:data-testid="subItem.testId || undefined"
/>
<ActionSettingsWheelToggleItem
v-else-if="isToggleMenuItem(subItem)"
:checked="subItem.value"
:click-action="subItem.clickAction"
:description="subItem.description"
:disabled="subItem.disabled"
:label="subItem.label"
:test-id="subItem.testId"
/>
<ActionSettingsWheelSelectItem
v-else-if="isSelectMenuItem(subItem)"
:icon="subItem.icon"
:label="subItem.label"
:value="subItem.value"
:options="subItem.options"
:change-action="subItem.changeAction"
:disabled="subItem.disabled"
:test-id="subItem.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="subItem.icon"
:label="subItem.label"
:detail-value="subItem.detailValue"
:template="subItem.template"
:click-action="subItem.clickAction"
:disabled="subItem.disabled"
:test-id="subItem.testId"
/>
</template>
</div>
</div>
<div class="action-settings-wheel-flyout__submenu-stack">
<div
v-for="section in desktopFlyoutMenuSections"
@@ -2780,6 +2984,7 @@ const syncDesktopFlyoutPosition = () => {
'is-active': activeDesktopFlyoutSection?.key === section.key,
'action-settings-wheel-flyout__submenu--attachments': section.key === 'attachments',
'action-settings-wheel-flyout__submenu--rules': section.key === 'rules',
'action-settings-wheel-flyout__submenu--has-nested': hasSubsectionMenuItems(section),
}"
:data-testid="`action-settings-wheel-submenu-${section.key}`"
>
@@ -2811,53 +3016,86 @@ const syncDesktopFlyoutPosition = () => {
</span>
</button>
</template>
<template v-else>
<template v-else>
<template v-for="item in section.items" :key="item.key">
<ActionSettingsWheelItemLabel
v-if="isLabelMenuItem(item)"
:icon="item.icon"
:label="item.label"
:template="item.template"
:data-testid="item.testId || undefined"
/>
<ActionSettingsWheelToggleItem
v-else-if="isToggleMenuItem(item)"
:checked="item.value"
:click-action="item.clickAction"
:description="item.description"
:disabled="item.disabled"
:label="item.label"
:test-id="item.testId"
>
<template v-if="item.customerRuleAttribute" #label="{ label }">
<CustomerRuleTooltip
:attribute="item.customerRuleAttribute"
:active="item.customerRuleActive"
:customer-number="item.customerRuleCustomerNumber"
:department-id="item.customerRuleDepartmentId"
:test-id="`${item.testId}-tooltip`"
>
<span>{{ label }}</span>
</CustomerRuleTooltip>
</template>
</ActionSettingsWheelToggleItem>
<ActionSettingsWheelItem
v-else
:icon="item.icon"
:label="item.label"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
:test-id="item.testId"
/>
<button
v-if="isSubsectionMenuItem(item)"
type="button"
class="action-settings-wheel-section-trigger action-settings-wheel-section-trigger--nested"
:class="{ 'is-active': activeDesktopFlyoutSubsection?.key === item.key }"
:data-testid="item.testId || `action-settings-wheel-subsection-${item.key}`"
@mouseenter="setActiveDesktopFlyoutSubsection(item.key)"
@focus="setActiveDesktopFlyoutSubsection(item.key)"
@click.stop.prevent="setActiveDesktopFlyoutSubsection(item.key)"
>
<span class="action-settings-wheel-section-trigger__arrow">
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</span>
<span class="action-settings-wheel-section-trigger__text">
<span class="action-settings-wheel-section-trigger__label">{{ item.label }}</span>
<span v-if="item.description" class="action-settings-wheel-section-trigger__description">
{{ item.description }}
</span>
</span>
</button>
<ActionSettingsWheelItemLabel
v-else-if="isLabelMenuItem(item)"
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:appearance="item.appearance"
:template="item.template"
:data-testid="item.testId || undefined"
/>
<ActionSettingsWheelToggleItem
v-else-if="isToggleMenuItem(item)"
:checked="item.value"
:click-action="item.clickAction"
:description="item.description"
:disabled="item.disabled"
:label="item.label"
:test-id="item.testId"
>
<template v-if="item.customerRuleAttribute" #label="{ label }">
<CustomerRuleTooltip
:attribute="item.customerRuleAttribute"
:active="item.customerRuleActive"
:customer-number="item.customerRuleCustomerNumber"
:department-id="item.customerRuleDepartmentId"
:test-id="`${item.testId}-tooltip`"
>
<span>{{ label }}</span>
</CustomerRuleTooltip>
</template>
</ActionSettingsWheelToggleItem>
<ActionSettingsWheelSelectItem
v-else-if="isSelectMenuItem(item)"
:icon="item.icon"
:label="item.label"
:value="item.value"
:options="item.options"
:change-action="item.changeAction"
:disabled="item.disabled"
:test-id="item.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
:test-id="item.testId"
/>
</template>
</template>
</template>
</div>
<div
v-if="section.key === 'attachments' && activeDesktopFlyoutSection?.key === 'attachments' && activeAttachment"
class="action-settings-wheel-attachment-panel"
data-testid="action-settings-wheel-attachment-panel"
>
</div>
<div
v-if="section.key === 'attachments' && activeDesktopFlyoutSection?.key === 'attachments' && activeAttachment"
class="action-settings-wheel-attachment-panel"
data-testid="action-settings-wheel-attachment-panel"
>
<p class="action-settings-wheel-attachment-panel__title" :title="getAttachmentLabel(activeAttachment)">
{{ getAttachmentLabel(activeAttachment) }}
</p>
@@ -2987,6 +3225,7 @@ const syncDesktopFlyoutPosition = () => {
:key="item.key"
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
@@ -3001,10 +3240,60 @@ const syncDesktopFlyoutPosition = () => {
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
<ActionSettingsWheelItemLabel :label="section.label" :data-testid="`action-settings-wheel-section-${section.key}`" />
<template v-for="item in section.items" :key="item.key">
<div
v-if="isSubsectionMenuItem(item)"
class="action-settings-wheel-subsection"
:data-testid="item.testId || `action-settings-wheel-subsection-${item.key}`"
>
<p class="action-settings-wheel-subsection__title">{{ item.label }}</p>
<p v-if="item.description" class="action-settings-wheel-subsection__description">{{ item.description }}</p>
<template v-for="subItem in item.items || []" :key="subItem.key">
<ActionSettingsWheelItemLabel
v-if="isLabelMenuItem(subItem)"
:icon="subItem.icon"
:label="subItem.label"
:detail-value="subItem.detailValue"
:appearance="subItem.appearance"
:template="subItem.template"
:data-testid="subItem.testId || undefined"
/>
<ActionSettingsWheelToggleItem
v-else-if="isToggleMenuItem(subItem)"
:checked="subItem.value"
:click-action="subItem.clickAction"
:description="subItem.description"
:disabled="subItem.disabled"
:label="subItem.label"
:test-id="subItem.testId"
/>
<ActionSettingsWheelSelectItem
v-else-if="isSelectMenuItem(subItem)"
:icon="subItem.icon"
:label="subItem.label"
:value="subItem.value"
:options="subItem.options"
:change-action="subItem.changeAction"
:disabled="subItem.disabled"
:test-id="subItem.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="subItem.icon"
:label="subItem.label"
:detail-value="subItem.detailValue"
:template="subItem.template"
:click-action="subItem.clickAction"
:disabled="subItem.disabled"
:test-id="subItem.testId"
/>
</template>
</div>
<ActionSettingsWheelItemLabel
v-if="isLabelMenuItem(item)"
v-else-if="isLabelMenuItem(item)"
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:appearance="item.appearance"
:template="item.template"
:data-testid="item.testId || undefined"
/>
@@ -3029,10 +3318,21 @@ const syncDesktopFlyoutPosition = () => {
</CustomerRuleTooltip>
</template>
</ActionSettingsWheelToggleItem>
<ActionSettingsWheelSelectItem
v-else-if="isSelectMenuItem(item)"
:icon="item.icon"
:label="item.label"
:value="item.value"
:options="item.options"
:change-action="item.changeAction"
:disabled="item.disabled"
:test-id="item.testId"
/>
<ActionSettingsWheelItem
v-else
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
@@ -3045,6 +3345,7 @@ const syncDesktopFlyoutPosition = () => {
:key="item.key"
:icon="item.icon"
:label="item.label"
:detail-value="item.detailValue"
:template="item.template"
:click-action="item.clickAction"
:disabled="item.disabled"
@@ -3131,7 +3432,7 @@ const syncDesktopFlyoutPosition = () => {
.action-settings-wheel-flyout {
display: flex;
flex-direction: row;
align-items: flex-start;
align-items: stretch;
gap: 0.75rem;
min-width: 33rem;
z-index: 4003;
@@ -3194,10 +3495,50 @@ const syncDesktopFlyoutPosition = () => {
overflow: visible;
}
.action-settings-wheel-flyout__submenu--has-nested {
overflow: visible;
}
.action-settings-wheel-flyout__submenu--rules {
min-width: 18.75rem;
}
.action-settings-wheel-subsection {
border-top: 1px solid #e4ebf3;
margin-top: 0.25rem;
padding-top: 0.35rem;
}
.action-settings-wheel-subsection:first-child {
border-top: 0;
margin-top: 0;
padding-top: 0;
}
.action-settings-wheel-subsection__title {
color: #627187;
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.04em;
margin: 0;
overflow: hidden;
padding: 0.45rem 0.75rem 0.2rem;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.action-settings-wheel-subsection__description {
color: #7b8798;
font-size: 0.72rem;
font-weight: 600;
margin: -0.1rem 0 0.35rem;
overflow: hidden;
padding: 0 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-settings-wheel-flyout__standalone-actions {
margin-top: 0.35rem;
padding-top: 0.35rem;
@@ -3249,6 +3590,18 @@ const syncDesktopFlyoutPosition = () => {
padding-right: 1rem;
}
.action-settings-wheel-section-trigger--nested {
align-items: flex-start;
}
.action-settings-wheel-section-trigger__text {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.action-settings-wheel-section-trigger__label {
flex: 1;
min-width: 0;
@@ -3259,6 +3612,55 @@ const syncDesktopFlyoutPosition = () => {
text-overflow: ellipsis;
}
.action-settings-wheel-section-trigger__description {
color: #718095;
font-size: 0.72rem;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-settings-wheel-nested-panel {
align-self: stretch;
flex: 0 0 17rem;
width: 17rem;
background: #ffffff;
border: 1px solid #d9e4ef;
border-radius: 0.7rem;
padding: 0.35rem;
}
.action-settings-wheel-nested-panel__title {
color: #132339;
font-size: 0.85rem;
font-weight: 700;
margin: 0;
overflow: hidden;
padding: 0.45rem 0.75rem 0.1rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-settings-wheel-nested-panel__description {
color: #718095;
font-size: 0.72rem;
font-weight: 600;
margin: 0;
overflow: hidden;
padding: 0 0.75rem 0.45rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-settings-wheel-nested-panel__items {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.action-settings-wheel-attachment-panel {
position: absolute;
top: 0;
@@ -5,6 +5,10 @@ const props = defineProps({
clickAction: Function,
icon: String,
label: String,
detailValue: {
type: [String, Number],
default: "",
},
disabled: Boolean,
testId: {
type: String,
@@ -107,6 +111,10 @@ const getLabel = () => {
return props.label ?? 'Unavngivet handling';
}
const hasDetailValue = () => {
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
}
const getStyle = () => {
return styles[props.template] ?? styles.default;
}
@@ -140,18 +148,40 @@ const getLabelColor = () => {
<span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i>
</span>
<span class="ml-1"
<span class="dropdown-item-action__label ml-1"
:class="getLabelColor()"
>{{ getLabel() }}</span>
<span v-if="hasDetailValue()" class="dropdown-item-action__detail">
{{ props.detailValue }}
</span>
</button>
</template>
<style scoped>
.dropdown-item-action {
width: 100%;
text-align: left;
align-items: center;
background: transparent;
border: 0;
display: flex;
gap: 0.25rem;
text-align: left;
width: 100%;
}
.dropdown-item-action__label {
min-width: 0;
overflow-wrap: anywhere;
}
.dropdown-item-action__detail {
color: #718095;
font-size: 0.86rem;
font-weight: 600;
margin-left: auto;
min-width: 0;
overflow-wrap: anywhere;
padding-left: 0.75rem;
text-align: right;
}
.is-disabled {
@@ -8,6 +8,14 @@ const props = defineProps({
default: undefined,
},
label: String,
detailValue: {
type: [String, Number],
default: "",
},
appearance: {
type: String,
default: "default",
},
template: {
type: String,
default: 'default', // The style of the button (default, danger, success, warning, info, light)
@@ -86,6 +94,10 @@ const getLabel = () => {
return props.label ?? 'Unavngivet handling';
}
const hasDetailValue = () => {
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
}
const getStyle = () => {
return styles[props.template] ?? styles.default;
}
@@ -109,25 +121,55 @@ const getLabelColor = () => {
const hasIcon = () => {
return props.icon !== undefined && props.icon !== null;
}
const isMetadataAppearance = () => props.appearance === "metadata";
</script>
<template>
<a class="dropdown-item is-disabled">
<span class="icon" v-if="hasIcon()">
<i :class="getIcon() + ' ' + getIconColor()"></i>
</span>
<span class="ml-1"
:class="getLabelColor()"
>{{ SessionUser.functions.ucFirst(getLabel()) }}</span>
<a
class="dropdown-item dropdown-item-label is-disabled"
:class="{ 'dropdown-item-label--metadata': isMetadataAppearance() }"
>
<span class="icon" v-if="hasIcon()">
<i :class="getIcon() + ' ' + getIconColor()"></i>
</span>
<span class="dropdown-item-label__text ml-1" :class="getLabelColor()">
{{ SessionUser.functions.ucFirst(getLabel()) }}
</span>
<span v-if="hasDetailValue()" class="dropdown-item-label__detail">
{{ props.detailValue }}
</span>
</a>
</template>
<style scoped>
.is-disabled {
align-items: center;
display: flex;
justify-content: flex-start;
pointer-events: none;
text-align: left;
width: 100%;
opacity: 0.5;
}
.dropdown-item-label__text {
min-width: 0;
overflow-wrap: anywhere;
text-align: left;
}
.dropdown-item-label__detail {
color: #718095;
font-size: 0.86rem;
font-weight: 600;
margin-left: auto;
min-width: 0;
overflow-wrap: anywhere;
padding-left: 0.75rem;
text-align: right;
}
.is-disabled:hover {
background-color: transparent;
}
@@ -135,4 +177,22 @@ const hasIcon = () => {
.is-disabled .icon {
opacity: 0.5;
}
</style>
.dropdown-item-label--metadata {
opacity: 1;
}
.dropdown-item-label--metadata .icon {
opacity: 1;
}
.dropdown-item-label--metadata .dropdown-item-label__text {
color: #4a5b73;
font-weight: 600;
}
.dropdown-item-label--metadata .dropdown-item-label__detail {
color: #25344d;
font-weight: 700;
}
</style>
@@ -0,0 +1,129 @@
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
changeAction: {
type: Function,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: "fas fa-cog",
},
label: {
type: String,
default: "",
},
options: {
type: Array,
default: () => [],
},
testId: {
type: String,
default: "",
},
value: {
type: [String, Number, Boolean],
default: "",
},
});
const root = ref(null);
const normalizeSelectValue = (value) => String(value);
const localValue = ref(normalizeSelectValue(props.value));
const isProcessing = ref(false);
watch(
() => props.value,
(value) => {
localValue.value = normalizeSelectValue(value);
}
);
const isDisabled = computed(() => props.disabled || isProcessing.value);
const optionKey = (option) => `${typeof option?.value}:${normalizeSelectValue(option?.value)}`;
const getInputValue = (value) => value?.target?.value ?? value;
const onInput = async (value) => {
const nextValue = normalizeSelectValue(getInputValue(value));
if (isDisabled.value || nextValue === normalizeSelectValue(props.value)) {
return;
}
isProcessing.value = true;
try {
if (props.changeAction) {
await props.changeAction(nextValue);
}
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
} finally {
isProcessing.value = false;
}
};
</script>
<template>
<div
ref="root"
class="dropdown-item action-settings-wheel-select-item"
:class="{ 'is-disabled': isDisabled }"
:data-testid="props.testId || undefined"
>
<span class="icon action-settings-wheel-select-item__icon">
<i :class="props.icon"></i>
</span>
<span class="action-settings-wheel-select-item__label">
{{ props.label }}
</span>
<b-select
v-model="localValue"
size="is-small"
:disabled="isDisabled"
:aria-label="props.label"
:data-testid="props.testId ? `${props.testId}-select` : undefined"
@click.stop
@mousedown.stop
@update:modelValue="onInput"
>
<option v-for="option in props.options" :key="optionKey(option)" :value="normalizeSelectValue(option.value)">
{{ option.label }}
</option>
</b-select>
</div>
</template>
<style scoped>
.action-settings-wheel-select-item {
align-items: center;
display: flex;
gap: 0.35rem;
width: 100%;
}
.action-settings-wheel-select-item__icon {
flex: 0 0 auto;
}
.action-settings-wheel-select-item__label {
color: #25344d;
flex: 1 1 auto;
font-weight: 500;
min-width: 0;
overflow-wrap: anywhere;
}
.action-settings-wheel-select-item :deep(.select),
.action-settings-wheel-select-item :deep(select) {
max-width: 9.5rem;
}
.action-settings-wheel-select-item.is-disabled {
opacity: 0.55;
}
</style>
@@ -111,7 +111,7 @@ onBeforeUnmount(() => {
.pagination-other-filters {
overflow: visible;
position: relative;
z-index: 2;
z-index: 40;
}
.pagination-other-filters__button {
@@ -136,9 +136,10 @@ onBeforeUnmount(() => {
padding: 0 0.3rem;
pointer-events: none;
position: absolute;
right: -0.5rem;
top: -0.25rem;
z-index: 3;
right: 0;
top: 0;
transform: translate(50%, -50%);
z-index: 50;
}
.pagination-other-filters__menu {
@@ -587,7 +587,7 @@ watch(orderDateEventSignature, () => {
justify-content: flex-end;
overflow: visible;
position: relative;
z-index: 2;
z-index: 40;
}
@media screen and (min-width: 769px) {
File diff suppressed because it is too large Load Diff
@@ -278,8 +278,14 @@ const resetSessionState = () => {
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.email_verified.value = false;
SessionUser.subuser.email_verified_at.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.phone.verified.value = false;
SessionUser.subuser.phone.verified_at.value = null;
SessionUser.subuser.verification_state.value = null;
SessionUser.subuser.verification.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
@@ -579,8 +585,14 @@ export const getSubuserSessionData = async () => {
SessionUser.subuser.username.value = data.username;
SessionUser.subuser.name.value = data.name;
SessionUser.subuser.email.value = data.email;
SessionUser.subuser.email_verified.value = Boolean(data.email_verified);
SessionUser.subuser.email_verified_at.value = data.email_verified_at || null;
SessionUser.subuser.phone.country_code.value = data.phone_country_code;
SessionUser.subuser.phone.number.value = data.phone;
SessionUser.subuser.phone.verified.value = Boolean(data.phone_verified);
SessionUser.subuser.phone.verified_at.value = data.phone_verified_at || null;
SessionUser.subuser.verification_state.value = data.verification_state || data.verification?.state || null;
SessionUser.subuser.verification.value = data.verification || null;
SessionUser.subuser.grants.value = data.grants || [];
reconcileSelectedSubuserGrant(SessionUser.subuser.grants.value);
SessionUser.subuser.created_at.value = data.created_at;
@@ -819,10 +831,16 @@ export const SessionUser = {
username: ref(null),
name: ref(null),
email: ref(null),
email_verified: ref(false),
email_verified_at: ref(null),
phone: {
country_code: ref(null),
number: ref(null),
verified: ref(false),
verified_at: ref(null),
},
verification_state: ref(null),
verification: ref(null),
grants: ref([]),
created_at: ref(null),
updated_at: ref(null),
@@ -33,6 +33,31 @@ const absoluteLoginLink = (path) => {
}
};
const directLoginPayload = (context = {}) => ({
...(context.customer_number ? { customer_number: context.customer_number } : {}),
...(context.grant_id ? { grant_id: context.grant_id } : {}),
});
const driverContactText = (key, values = undefined) =>
t(`templates.generated.compat.superuser.driver_contact.${key}`, values);
const showDeliveryFeedback = async (response, options = {}) => {
const payload = response?.data?.data || response?.data || {};
const delivery = payload.delivery || {};
const sent = delivery.status === "sent";
await Swal.fire({
title: sent
? (options.sentTitle || driverContactText("link_sent_title"))
: (options.notSentTitle || driverContactText("link_not_sent_title")),
text: delivery.message || (sent
? driverContactText("link_sent_default")
: driverContactText("link_not_sent_default")),
icon: sent ? "success" : "warning",
confirmButtonText: driverContactText("close"),
});
};
const copyText = async (text) => {
if (navigator.clipboard?.writeText) {
try {
@@ -315,10 +340,108 @@ export const Subusers = {
return authenticatedRequest(`/superuser/subusers/${encodeURIComponent(subuserId)}/password`, "POST", { password });
},
async generateAdminLoginLink(subuserId, context = {}) {
return authenticatedRequest(`/superuser/subusers/${encodeURIComponent(subuserId)}/login-link`, "POST", {
...(context.customer_number ? { customer_number: context.customer_number } : {}),
...(context.grant_id ? { grant_id: context.grant_id } : {}),
});
return authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/login-link`,
"POST",
directLoginPayload(context)
);
},
async setAdminVerificationState(subuserId, channel, verified) {
try {
return await authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/verification/${encodeURIComponent(channel)}`,
"PATCH",
{ verified: Boolean(verified) }
);
} catch (error) {
await Swal.fire({
title: driverContactText("verification_update_failed_title"),
text: apiErrorMessage(error, driverContactText("verification_update_failed")),
icon: "error",
confirmButtonText: driverContactText("close"),
});
throw error;
}
},
async sendAdminPasswordGuideLink(subuserId, channel, context = {}) {
try {
const response = await authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/password-guide/${encodeURIComponent(channel)}/send`,
"POST",
directLoginPayload(context)
);
await showDeliveryFeedback(response, {
sentTitle: driverContactText("password_guide_sent_title"),
notSentTitle: driverContactText("password_guide_not_sent_title"),
});
return response;
} catch (error) {
await Swal.fire({
title: driverContactText("password_guide_failed_title"),
text: apiErrorMessage(error, driverContactText("link_send_failed")),
icon: "error",
confirmButtonText: driverContactText("close"),
});
throw error;
}
},
async sendAdminLoginLink(subuserId, channel, context = {}) {
try {
const response = await authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/login-link/${encodeURIComponent(channel)}/send`,
"POST",
directLoginPayload(context)
);
await showDeliveryFeedback(response, {
sentTitle: driverContactText("login_link_sent_title"),
notSentTitle: driverContactText("login_link_not_sent_title"),
});
return response;
} catch (error) {
await Swal.fire({
title: driverContactText("login_link_failed_title"),
text: apiErrorMessage(error, driverContactText("link_send_failed")),
icon: "error",
confirmButtonText: driverContactText("close"),
});
throw error;
}
},
async resendVerificationCode(subuser, channel, refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
const endpoint = superuser
? `/superuser/subusers/${encodeURIComponent(subuser.id)}/verification/${encodeURIComponent(channel)}/send`
: `/subusers/${encodeURIComponent(subuser.id)}/verification/${encodeURIComponent(channel)}/send`;
try {
const response = await authenticatedRequest(endpoint, "POST");
const payload = response?.data?.data || response?.data || {};
const delivery = payload.delivery || {};
const channelLabel = channel === "email" ? "e-mail" : "telefon";
await Swal.fire({
title: delivery.status === "sent" ? "Kode sendt" : "Kode kunne ikke sendes",
text: delivery.message || (delivery.status === "sent"
? `Verifikationskode er sendt til chaufførens ${channelLabel}.`
: `Verifikationskode kunne ikke sendes til chaufførens ${channelLabel}.`),
icon: delivery.status === "sent" ? "success" : "warning",
confirmButtonText: "Luk",
});
if (typeof refreshCallback === "function") {
await refreshCallback();
}
return response;
} catch (error) {
await Swal.fire({
title: "Kode kunne ikke sendes",
text: apiErrorMessage(error, "Der opstod en fejl."),
icon: "error",
confirmButtonText: "Luk",
});
throw error;
}
},
async showEditNameForm(subuser, refreshCallback = null) {
const result = await Swal.fire({
@@ -34,6 +34,14 @@ export const Config = {
return Config.set("invoiceLayoutNumber", layout);
},
},
invoiceDiscountLayoutNumber: {
get: async () => {
return Config.get("invoiceDiscountLayoutNumber");
},
set: async (layout) => {
return Config.set("invoiceDiscountLayoutNumber", layout);
},
},
},
fees: {
adminFeeMonthly: {
+32
View File
@@ -5626,6 +5626,38 @@
"custom_summary": "Tilpasset: {summary}"
}
},
"driver_contact": {
"close": "Luk",
"email_missing": "E-mail mangler",
"link_not_sent_default": "Linket kunne ikke sendes.",
"link_not_sent_title": "Link kunne ikke sendes",
"link_send_failed": "Linket kunne ikke sendes.",
"link_sent_default": "Linket er sendt.",
"link_sent_title": "Link sendt",
"login_link_failed_title": "Loginlink kunne ikke sendes",
"login_link_not_sent_title": "Loginlink kunne ikke sendes",
"login_link_sent_title": "Loginlink sendt",
"missing": "Mangler",
"password_guide_failed_title": "Guide kunne ikke sendes",
"password_guide_not_sent_title": "Guide kunne ikke sendes",
"password_guide_sent_title": "Guide sendt",
"phone_missing": "Telefon mangler",
"send_login_link": "Send forhåndsgodkendt loginlink",
"send_password_guide": "Send guide til ny adgangskode",
"status": "Status",
"unverified": "Ikke verificeret",
"verification_update_failed": "Verifikationsstatus kunne ikke opdateres.",
"verification_update_failed_title": "Status kunne ikke opdateres",
"verified": "Verificeret"
},
"driver_wheel": {
"access_section": "Adgange",
"account_section": "Konto",
"contact_section": "Kontakt",
"details_section": "Detaljer",
"identity_subsection": "Identitet",
"system_subsection": "System"
},
"invoicing_billing_period": {
"dashboard_right": "@.capitalize:{'words.generated.dashboard'} @.capitalize:{'words.generated.højre'} @.capitalize:{'words.generated.visning'}",
"fetch_period_data": "@:{'words.generated.hent'} periodedata"
+32
View File
@@ -6029,6 +6029,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
},
"system_status": {
+32
View File
@@ -5505,6 +5505,38 @@
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
},
"invoicing_billing_period": {
"dashboard_right": "@.capitalize:{'words.generated.dashboard'} @.capitalize:{'words.generated.right'} @.capitalize:{'words.generated.display'}",
"fetch_period_data": "@:{'words.generated.fetch'} @.capitalize:{'words.generated.period'} @.capitalize:{'words.generated.data'}"
+32
View File
@@ -5480,6 +5480,38 @@
"permission_count": "@:{'templates.generated.compat.superuser.driver_access.summary.permission_count'}"
}
},
"driver_contact": {
"close": "@:{'templates.generated.compat.superuser.driver_contact.close'}",
"email_missing": "@:{'templates.generated.compat.superuser.driver_contact.email_missing'}",
"link_not_sent_default": "@:{'templates.generated.compat.superuser.driver_contact.link_not_sent_default'}",
"link_not_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.link_not_sent_title'}",
"link_send_failed": "@:{'templates.generated.compat.superuser.driver_contact.link_send_failed'}",
"link_sent_default": "@:{'templates.generated.compat.superuser.driver_contact.link_sent_default'}",
"link_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.link_sent_title'}",
"login_link_failed_title": "@:{'templates.generated.compat.superuser.driver_contact.login_link_failed_title'}",
"login_link_not_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.login_link_not_sent_title'}",
"login_link_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.login_link_sent_title'}",
"missing": "@:{'templates.generated.compat.superuser.driver_contact.missing'}",
"password_guide_failed_title": "@:{'templates.generated.compat.superuser.driver_contact.password_guide_failed_title'}",
"password_guide_not_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.password_guide_not_sent_title'}",
"password_guide_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.password_guide_sent_title'}",
"phone_missing": "@:{'templates.generated.compat.superuser.driver_contact.phone_missing'}",
"send_login_link": "@:{'templates.generated.compat.superuser.driver_contact.send_login_link'}",
"send_password_guide": "@:{'templates.generated.compat.superuser.driver_contact.send_password_guide'}",
"status": "@:{'templates.generated.compat.superuser.driver_contact.status'}",
"unverified": "@:{'templates.generated.compat.superuser.driver_contact.unverified'}",
"verification_update_failed": "@:{'templates.generated.compat.superuser.driver_contact.verification_update_failed'}",
"verification_update_failed_title": "@:{'templates.generated.compat.superuser.driver_contact.verification_update_failed_title'}",
"verified": "@:{'templates.generated.compat.superuser.driver_contact.verified'}"
},
"driver_wheel": {
"access_section": "@:{'templates.generated.compat.superuser.driver_wheel.access_section'}",
"account_section": "@:{'templates.generated.compat.superuser.driver_wheel.account_section'}",
"contact_section": "@:{'templates.generated.compat.superuser.driver_wheel.contact_section'}",
"details_section": "@:{'templates.generated.compat.superuser.driver_wheel.details_section'}",
"identity_subsection": "@:{'templates.generated.compat.superuser.driver_wheel.identity_subsection'}",
"system_subsection": "@:{'templates.generated.compat.superuser.driver_wheel.system_subsection'}"
},
"form": {
"locked": "@:{'templates.generated.compat.objects.columns.suspended'}",
"required": "@:{'templates.generated.compat.global.required'}"
+32
View File
@@ -5984,6 +5984,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
},
"system_status": {
+32
View File
@@ -6082,6 +6082,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
},
"system_status": {
@@ -84,6 +84,38 @@
"custom_summary": "Tilpasset: {summary}"
}
},
"driver_contact": {
"close": "Luk",
"email_missing": "E-mail mangler",
"link_not_sent_default": "Linket kunne ikke sendes.",
"link_not_sent_title": "Link kunne ikke sendes",
"link_send_failed": "Linket kunne ikke sendes.",
"link_sent_default": "Linket er sendt.",
"link_sent_title": "Link sendt",
"login_link_failed_title": "Loginlink kunne ikke sendes",
"login_link_not_sent_title": "Loginlink kunne ikke sendes",
"login_link_sent_title": "Loginlink sendt",
"missing": "Mangler",
"password_guide_failed_title": "Guide kunne ikke sendes",
"password_guide_not_sent_title": "Guide kunne ikke sendes",
"password_guide_sent_title": "Guide sendt",
"phone_missing": "Telefon mangler",
"send_login_link": "Send forhåndsgodkendt loginlink",
"send_password_guide": "Send guide til ny adgangskode",
"status": "Status",
"unverified": "Ikke verificeret",
"verification_update_failed": "Verifikationsstatus kunne ikke opdateres.",
"verification_update_failed_title": "Status kunne ikke opdateres",
"verified": "Verificeret"
},
"driver_wheel": {
"access_section": "Adgange",
"account_section": "Konto",
"contact_section": "Kontakt",
"details_section": "Detaljer",
"identity_subsection": "Identitet",
"system_subsection": "System"
},
"invoicing_billing_period": {
"dashboard_right": "@.capitalize:{'terms.glossary.dashboard'} @.capitalize:{'terms.glossary.højre'} @.capitalize:{'terms.glossary.visning'}",
"fetch_period_data": "@:{'terms.glossary.hent'} periodedata"
@@ -328,6 +328,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
}
}
@@ -84,6 +84,38 @@
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
},
"invoicing_billing_period": {
"dashboard_right": "@.capitalize:{'terms.glossary.dashboard'} @.capitalize:{'terms.glossary.right'} @.capitalize:{'terms.glossary.display'}",
"fetch_period_data": "@:{'terms.glossary.fetch'} @.capitalize:{'terms.glossary.period'} @.capitalize:{'terms.glossary.data'}"
@@ -84,6 +84,38 @@
"permission_count": "@:{'phrases.compat.superuser.driver_access.summary.permission_count'}"
}
},
"driver_contact": {
"close": "@:{'phrases.compat.superuser.driver_contact.close'}",
"email_missing": "@:{'phrases.compat.superuser.driver_contact.email_missing'}",
"link_not_sent_default": "@:{'phrases.compat.superuser.driver_contact.link_not_sent_default'}",
"link_not_sent_title": "@:{'phrases.compat.superuser.driver_contact.link_not_sent_title'}",
"link_send_failed": "@:{'phrases.compat.superuser.driver_contact.link_send_failed'}",
"link_sent_default": "@:{'phrases.compat.superuser.driver_contact.link_sent_default'}",
"link_sent_title": "@:{'phrases.compat.superuser.driver_contact.link_sent_title'}",
"login_link_failed_title": "@:{'phrases.compat.superuser.driver_contact.login_link_failed_title'}",
"login_link_not_sent_title": "@:{'phrases.compat.superuser.driver_contact.login_link_not_sent_title'}",
"login_link_sent_title": "@:{'phrases.compat.superuser.driver_contact.login_link_sent_title'}",
"missing": "@:{'phrases.compat.superuser.driver_contact.missing'}",
"password_guide_failed_title": "@:{'phrases.compat.superuser.driver_contact.password_guide_failed_title'}",
"password_guide_not_sent_title": "@:{'phrases.compat.superuser.driver_contact.password_guide_not_sent_title'}",
"password_guide_sent_title": "@:{'phrases.compat.superuser.driver_contact.password_guide_sent_title'}",
"phone_missing": "@:{'phrases.compat.superuser.driver_contact.phone_missing'}",
"send_login_link": "@:{'phrases.compat.superuser.driver_contact.send_login_link'}",
"send_password_guide": "@:{'phrases.compat.superuser.driver_contact.send_password_guide'}",
"status": "@:{'phrases.compat.superuser.driver_contact.status'}",
"unverified": "@:{'phrases.compat.superuser.driver_contact.unverified'}",
"verification_update_failed": "@:{'phrases.compat.superuser.driver_contact.verification_update_failed'}",
"verification_update_failed_title": "@:{'phrases.compat.superuser.driver_contact.verification_update_failed_title'}",
"verified": "@:{'phrases.compat.superuser.driver_contact.verified'}"
},
"driver_wheel": {
"access_section": "@:{'phrases.compat.superuser.driver_wheel.access_section'}",
"account_section": "@:{'phrases.compat.superuser.driver_wheel.account_section'}",
"contact_section": "@:{'phrases.compat.superuser.driver_wheel.contact_section'}",
"details_section": "@:{'phrases.compat.superuser.driver_wheel.details_section'}",
"identity_subsection": "@:{'phrases.compat.superuser.driver_wheel.identity_subsection'}",
"system_subsection": "@:{'phrases.compat.superuser.driver_wheel.system_subsection'}"
},
"form": {
"locked": "@:{'phrases.compat.objects.columns.suspended'}",
"required": "@:{'phrases.compat.global.required'}"
@@ -328,6 +328,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
}
}
@@ -328,6 +328,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
}
}
@@ -196,6 +196,13 @@ load();
:value="getModuleConfigValue('invoiceLayoutNumber')"
:on-select="SessionUser.superUser.modules.economic.config.layouts.invoiceLayoutNumber.set"
/>
<ConfigurationSelect
:label="$t('configuration.economic.invoice_template') + ' - ' + $t('common.discount')"
:description="$t('configuration.economic.invoice_template_desc')"
:options="layouts"
:value="getModuleConfigValue('invoiceDiscountLayoutNumber')"
:on-select="SessionUser.superUser.modules.economic.config.layouts.invoiceDiscountLayoutNumber.set"
/>
<ConfigurationSelect
:label="$t('configuration.economic.default_distribution_department_id')"
:description="$t('configuration.economic.default_distribution_department_id_desc')"
@@ -19,6 +19,172 @@ import { setUseLargeTableHeaders, useLargeTableHeaders } from "@/services/tableH
const { t } = useI18n();
const isLoading = ref(false);
const subuserVerificationStatus = (channel) => {
if (channel === "email") {
return {
available: Boolean(SessionUser.subuser.email.value),
verified: Boolean(SessionUser.subuser.email_verified.value),
verifiedAt: SessionUser.subuser.email_verified_at.value,
destination: SessionUser.subuser.email.value,
};
}
return {
available: Boolean(SessionUser.subuser.phone.country_code.value && SessionUser.subuser.phone.number.value),
verified: Boolean(SessionUser.subuser.phone.verified.value),
verifiedAt: SessionUser.subuser.phone.verified_at.value,
destination: SessionUser.subuser.phone.country_code.value && SessionUser.subuser.phone.number.value
? `+${SessionUser.subuser.phone.country_code.value} ${SessionUser.subuser.phone.number.value}`
: "",
};
};
const verificationBadgeClass = (channel) => {
const status = subuserVerificationStatus(channel);
if (!status.available) {
return "is-light";
}
return status.verified ? "is-success is-light" : "is-warning is-light";
};
const verificationBadgeText = (channel) => {
const status = subuserVerificationStatus(channel);
if (!status.available) {
return "Mangler";
}
return status.verified ? "Verificeret" : "Ikke verificeret";
};
const applySubuserPayload = (subuser) => {
if (!subuser || typeof subuser !== "object") {
return;
}
if (Object.prototype.hasOwnProperty.call(subuser, "email")) {
SessionUser.subuser.email.value = subuser.email;
}
if (Object.prototype.hasOwnProperty.call(subuser, "email_verified")) {
SessionUser.subuser.email_verified.value = Boolean(subuser.email_verified);
}
if (Object.prototype.hasOwnProperty.call(subuser, "email_verified_at")) {
SessionUser.subuser.email_verified_at.value = subuser.email_verified_at || null;
}
if (Object.prototype.hasOwnProperty.call(subuser, "phone_verified")) {
SessionUser.subuser.phone.verified.value = Boolean(subuser.phone_verified);
}
if (Object.prototype.hasOwnProperty.call(subuser, "phone_verified_at")) {
SessionUser.subuser.phone.verified_at.value = subuser.phone_verified_at || null;
}
if (Object.prototype.hasOwnProperty.call(subuser, "verification_state")) {
SessionUser.subuser.verification_state.value = subuser.verification_state || null;
}
if (Object.prototype.hasOwnProperty.call(subuser, "verification")) {
SessionUser.subuser.verification.value = subuser.verification || null;
}
};
const applyVerificationPayload = (verification) => {
if (!verification || typeof verification !== "object") {
return;
}
SessionUser.subuser.verification.value = verification;
SessionUser.subuser.verification_state.value = verification.state || null;
SessionUser.subuser.email_verified.value = Boolean(verification.email?.verified);
SessionUser.subuser.email_verified_at.value = verification.email?.verified_at || null;
SessionUser.subuser.phone.verified.value = Boolean(verification.phone?.verified);
SessionUser.subuser.phone.verified_at.value = verification.phone?.verified_at || null;
};
const verificationErrorMessage = (error, fallback) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || fallback;
const onClickVerifySubuserContact = async (channel) => {
const status = subuserVerificationStatus(channel);
const label = channel === "email" ? "e-mail" : "telefonnummer";
if (!status.available) {
await Swal.fire({
title: "Kontaktoplysning mangler",
text: `Der er ikke angivet et ${label}, der kan verificeres.`,
icon: "warning",
confirmButtonText: "Luk",
});
return;
}
try {
const sendResponse = await SessionUser.request(`/subusers/me/verification/${channel}/send`, "POST");
const payload = sendResponse?.data?.data || sendResponse?.data || {};
const delivery = payload.delivery || {};
applyVerificationPayload(payload.verification);
if (delivery.status !== "sent") {
await Swal.fire({
title: "Kode kunne ikke sendes",
text: delivery.message || "Verifikationskoden kunne ikke sendes.",
icon: "error",
confirmButtonText: "Luk",
});
return;
}
} catch (error) {
await Swal.fire({
title: "Kode kunne ikke sendes",
text: verificationErrorMessage(error, "Verifikationskoden kunne ikke sendes."),
icon: "error",
confirmButtonText: "Luk",
});
return;
}
const result = await Swal.fire({
title: channel === "email" ? "Verificér e-mail" : "Verificér telefonnummer",
text: `Indtast den 6-cifrede kode, der blev sendt til ${status.destination}.`,
input: "text",
inputAttributes: {
inputmode: "numeric",
maxlength: "6",
autocomplete: "one-time-code",
},
showCancelButton: true,
confirmButtonText: "Verificér",
cancelButtonText: "Annuller",
preConfirm: async (value) => {
const code = String(value || "").trim();
if (!/^[0-9]{6}$/.test(code)) {
Swal.showValidationMessage("Koden skal være 6 cifre.");
return false;
}
try {
const verifyResponse = await SessionUser.request(`/subusers/me/verification/${channel}/verify`, "POST", {
code,
});
const payload = verifyResponse?.data?.data || verifyResponse?.data || {};
applyVerificationPayload(payload.verification);
applySubuserPayload(payload.subuser);
return payload;
} catch (error) {
Swal.showValidationMessage(verificationErrorMessage(error, "Koden kunne ikke verificeres."));
return false;
}
},
});
if (result.isConfirmed) {
await Swal.fire({
title: "Verificeret",
text: channel === "email" ? "Din e-mailadresse er verificeret." : "Dit telefonnummer er verificeret.",
icon: "success",
confirmButtonText: "Luk",
});
await SessionUser.getSessionData();
}
};
/** Email */
const onClickSaveEmail = async () => {
// Get the password from Swal
@@ -451,14 +617,18 @@ const onClickSaveSubuserEmail = async () => {
if (result.isConfirmed) {
const email = result.value;
SessionUser.request("/subusers/me", "PUT", { email: email })
.then((response) => {
.then(async (response) => {
if (response.status === 200) {
Swal.fire({
applySubuserPayload(response?.data?.data || response?.data || {});
SessionUser.subuser.email.value = email;
SessionUser.subuser.email_verified.value = false;
SessionUser.subuser.email_verified_at.value = null;
await Swal.fire({
title: "E-mail gemt",
text: "Din e-mailadresse er blevet opdateret.",
text: "Din e-mailadresse er blevet opdateret. Den skal verificeres før den markeres som gyldig.",
icon: "success",
});
SessionUser.subuser.email.value = email;
await onClickVerifySubuserContact("email");
} else {
Swal.fire({
title: "Fejl",
@@ -711,12 +881,35 @@ const onClickSaveWashCertificateEmail = async () => {
:value="SessionUser.subuser.email.value"
:disabled="true"
/>
<button class="button is-link is-small mt-2" @click="onClickSaveSubuserEmail" :disabled="isLoading">
<span>
<i class="fas fa-edit"></i>
<span class="ml-2">{{ $t("user_dashboard.profile.driver.edit_email") }}</span>
<div class="is-flex is-flex-wrap-wrap is-align-items-center gap-2 mt-2">
<span
class="tag"
:class="verificationBadgeClass('email')"
data-testid="subuser-email-verification-badge"
>
<i class="fas fa-check-circle mr-1" v-if="SessionUser.subuser.email_verified.value"></i>
<i class="fas fa-exclamation-triangle mr-1" v-else></i>
{{ verificationBadgeText("email") }}
</span>
</button>
<button class="button is-link is-small" @click="onClickSaveSubuserEmail" :disabled="isLoading">
<span>
<i class="fas fa-edit"></i>
<span class="ml-2">{{ $t("user_dashboard.profile.driver.edit_email") }}</span>
</span>
</button>
<button
v-if="subuserVerificationStatus('email').available && !SessionUser.subuser.email_verified.value"
class="button is-warning is-light is-small"
@click="onClickVerifySubuserContact('email')"
:disabled="isLoading"
data-testid="subuser-email-verify-button"
>
<span>
<i class="fas fa-envelope"></i>
<span class="ml-2">Verificér e-mail</span>
</span>
</button>
</div>
<hr class="my-4" />
<!-- Phone number (read-only) -->
<ConfigurationInput
@@ -725,6 +918,29 @@ const onClickSaveWashCertificateEmail = async () => {
:value="SessionUser.subuser.phone.number.value"
:disabled="true"
/>
<div class="is-flex is-flex-wrap-wrap is-align-items-center gap-2 mt-2">
<span
class="tag"
:class="verificationBadgeClass('phone')"
data-testid="subuser-phone-verification-badge"
>
<i class="fas fa-check-circle mr-1" v-if="SessionUser.subuser.phone.verified.value"></i>
<i class="fas fa-exclamation-triangle mr-1" v-else></i>
{{ verificationBadgeText("phone") }}
</span>
<button
v-if="subuserVerificationStatus('phone').available && !SessionUser.subuser.phone.verified.value"
class="button is-warning is-light is-small"
@click="onClickVerifySubuserContact('phone')"
:disabled="isLoading"
data-testid="subuser-phone-verify-button"
>
<span>
<i class="fas fa-mobile-alt"></i>
<span class="ml-2">Verificér telefon</span>
</span>
</button>
</div>
<!-- Country code (read-only) -->
<ConfigurationInput
:title="$t('user_dashboard.profile.driver.country_code')"
+116 -1
View File
@@ -92,8 +92,34 @@ function createSubuserSessionData(overrides: Record<string, unknown> = {}) {
username: "driver-user",
name: "E2E Driver",
email: "driver@example.com",
email_verified: false,
email_verified_at: null,
phone_country_code: 45,
phone: 12345678,
phone_verified: false,
phone_verified_at: null,
verification_state: "unverified",
verification: {
state: "unverified",
email: {
channel: "email",
value: "driver@example.com",
masked_value: "dr****@example.com",
available: true,
verified: false,
verified_at: null,
},
phone: {
channel: "phone",
country_code: 45,
phone: 12345678,
value: "4512345678",
masked_value: "******5678",
available: true,
verified: false,
verified_at: null,
},
},
grants: [],
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
@@ -167,7 +193,43 @@ async function installMockSubuserAuthRoutes(
phone_country_code:
"phoneCountryCode" in credentials ? Number.parseInt(credentials.phoneCountryCode, 10) || 45 : 45,
phone: "phone" in credentials ? Number.parseInt(credentials.phone, 10) || 12345678 : 12345678,
});
}) as Record<string, any>;
const refreshSubuserVerification = () => {
const emailAvailable = Boolean(sessionData.email);
const phoneAvailable = Boolean(sessionData.phone_country_code && sessionData.phone);
sessionData.verification = {
state:
emailAvailable && phoneAvailable && sessionData.email_verified && sessionData.phone_verified
? "verified"
: emailAvailable && phoneAvailable && (sessionData.email_verified || sessionData.phone_verified)
? "partial"
: emailAvailable && phoneAvailable
? "unverified"
: !emailAvailable
? "missing_email"
: "missing_phone",
email: {
channel: "email",
value: sessionData.email,
masked_value: sessionData.email ? "dr****@example.com" : null,
available: emailAvailable,
verified: Boolean(sessionData.email_verified),
verified_at: sessionData.email_verified_at,
},
phone: {
channel: "phone",
country_code: sessionData.phone_country_code,
phone: sessionData.phone,
value: phoneAvailable ? `${sessionData.phone_country_code}${sessionData.phone}` : null,
masked_value: phoneAvailable ? "******5678" : null,
available: phoneAvailable,
verified: Boolean(sessionData.phone_verified),
verified_at: sessionData.phone_verified_at,
},
};
sessionData.verification_state = sessionData.verification.state;
};
refreshSubuserVerification();
await mockApi(page, {
authenticated: false,
@@ -211,7 +273,12 @@ async function installMockSubuserAuthRoutes(
if (route.request().method() === "PUT") {
const body = route.request().postDataJSON?.() || {};
if (Object.prototype.hasOwnProperty.call(body, "email") && body.email !== sessionData.email) {
sessionData.email_verified = false;
sessionData.email_verified_at = null;
}
Object.assign(sessionData, body);
refreshSubuserVerification();
}
await fulfillJson(route, {
@@ -219,6 +286,54 @@ async function installMockSubuserAuthRoutes(
});
});
await page.route("**/subusers/me/verification/*/send", async (route) => {
const pathParts = new URL(route.request().url()).pathname.split("/");
const channel = pathParts[pathParts.length - 2];
await fulfillJson(route, {
data: {
delivery: {
channel,
status: "sent",
message: "Verification code sent.",
expires_in: 600,
},
verification: sessionData.verification,
},
});
});
await page.route("**/subusers/me/verification/*/verify", async (route) => {
const pathParts = new URL(route.request().url()).pathname.split("/");
const channel = pathParts[pathParts.length - 2];
const body = route.request().postDataJSON?.() || {};
if (body.code !== "123456") {
await fulfillJson(route, { data: { message: "Invalid verification code." } }, 400);
return;
}
if (channel === "email") {
sessionData.email_verified = true;
sessionData.email_verified_at = "2026-07-13 10:30:00";
}
if (channel === "phone") {
sessionData.phone_verified = true;
sessionData.phone_verified_at = "2026-07-13 10:30:00";
}
refreshSubuserVerification();
await fulfillJson(route, {
data: {
result: {
channel,
status: "verified",
message: "Contact value verified.",
},
verification: sessionData.verification,
subuser: sessionData,
},
});
});
await page.route("**/auth/session", async (route) => {
const authorization = route.request().headers().authorization;
if (authorization !== `Bearer ${MOCK_SUBUSER_TOKEN}`) {
File diff suppressed because it is too large Load Diff
+43 -28
View File
@@ -7,6 +7,12 @@ async function goToUserProfile(page) {
await expect(page).toHaveURL(/\/user\/profile(?:[?#].*)?$/);
}
async function openContactSection(page) {
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
}
// Subuser contact information display tests
test("[PROFILE][Subuser][Contact] should display contact information section", async ({ page }) => {
await loginAsSubuserByPhone(page);
@@ -20,10 +26,7 @@ test("[PROFILE][Subuser][Contact] should display email field", async ({ page })
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
// Expand contact section (second Chauffør section with address-card icon)
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
await openContactSection(page);
// Email field should be visible
await expect(page.getByText("E-mail", { exact: true })).toBeVisible();
@@ -33,10 +36,7 @@ test("[PROFILE][Subuser][Contact] should display edit email button", async ({ pa
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
// Expand contact section
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
await openContactSection(page);
// Edit email button should be visible for subuser
const editEmailButton = page.locator('button:has-text("Rediger e-mail")');
@@ -47,10 +47,7 @@ test("[PROFILE][Subuser][Contact] should open edit email dialog when clicking ed
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
// Expand contact section
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
await openContactSection(page);
// Click edit email button
const editEmailButton = page.locator('button:has-text("Rediger e-mail")');
@@ -65,10 +62,7 @@ test("[PROFILE][Subuser][Contact] should display phone number field (read-only)"
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
// Expand contact section
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
await openContactSection(page);
// Phone number field should be visible
await expect(page.getByText("Telefonnummer", { exact: true }).first()).toBeVisible();
@@ -78,25 +72,49 @@ test("[PROFILE][Subuser][Contact] should display country code field (read-only)"
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
// Expand contact section
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
await openContactSection(page);
// Country code field should be visible
await expect(page.getByText("Landekode", { exact: true })).toBeVisible();
});
test("[PROFILE][Subuser][Contact] should verify email with a code", async ({ page }) => {
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
await openContactSection(page);
await expect(page.getByTestId("subuser-email-verification-badge")).toContainText("Ikke verificeret");
await page.getByTestId("subuser-email-verify-button").click();
await expect(page.locator(".swal2-title")).toContainText("Verificér e-mail");
await page.fill(".swal2-input", "123456");
await page.click(".swal2-confirm");
await expect(page.locator(".swal2-title")).toContainText("Verificeret");
await page.getByRole("button", { name: "Luk" }).click();
await expect(page.getByTestId("subuser-email-verification-badge")).toContainText("Verificeret");
});
test("[PROFILE][Subuser][Contact] should verify phone with a code", async ({ page }) => {
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
await openContactSection(page);
await expect(page.getByTestId("subuser-phone-verification-badge")).toContainText("Ikke verificeret");
await page.getByTestId("subuser-phone-verify-button").click();
await expect(page.locator(".swal2-title")).toContainText("Verificér telefonnummer");
await page.fill(".swal2-input", "123456");
await page.click(".swal2-confirm");
await expect(page.locator(".swal2-title")).toContainText("Verificeret");
await page.getByRole("button", { name: "Luk" }).click();
await expect(page.getByTestId("subuser-phone-verification-badge")).toContainText("Verificeret");
});
/** Subuser Email Validation Tests */
test("[PROFILE][Subuser][Contact] should show validation error when email is empty", async ({ page }) => {
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
// Expand contact section
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
await openContactSection(page);
// Click edit email button
const editEmailButton = page.locator('button:has-text("Rediger e-mail")');
@@ -114,10 +132,7 @@ test("[PROFILE][Subuser][Contact] should show validation error for invalid email
await loginAsSubuserByPhone(page);
await goToUserProfile(page);
// Expand contact section
const contactHeader = page.locator(".card-header").filter({ has: page.locator(".fa-address-card") });
await contactHeader.click();
await page.waitForTimeout(200);
await openContactSection(page);
// Click edit email button
const editEmailButton = page.locator('button:has-text("Rediger e-mail")');
@@ -109,17 +109,15 @@ test.describe("superuser order date filters", () => {
const otherToAnytimeGap = shortcutBox.x - (buttonBox.x + buttonBox.width);
const dateShortcutGap = todayShortcutBox.x - (shortcutBox.x + shortcutBox.width);
const countRightProtrusion = countBox.x + countBox.width - (buttonBox.x + buttonBox.width);
const doesCountProtrudeFromTopRight = countBox.y < buttonBox.y && countRightProtrusion >= 5;
const doesCountProtrudeFromTopRight = countBox.y < buttonBox.y && countRightProtrusion >= 8;
const hasCountTopClearance = countBox.y >= shortcutActionsBox.y + 2;
const hasCountRightClearance = otherToAnytimeGap - countRightProtrusion >= 1.5;
return (
Math.abs(buttonCenterY - shortcutCenterY) <= 4 &&
Math.abs(buttonBox.height - shortcutBox.height) <= 4 &&
Math.abs(otherToAnytimeGap - dateShortcutGap) <= 2 &&
doesCountProtrudeFromTopRight &&
hasCountTopClearance &&
hasCountRightClearance
hasCountTopClearance
);
})
.toBe(true);
@@ -970,6 +970,37 @@ describe("ActionSettingsWheelButton", () => {
geometrySpy.mockRestore();
});
it("passes metadata appearance to label rows in external menu sections", async () => {
const wrapper = mountFlatDropdownButton({
order_id: null,
menuSections: [
{
key: "driver-details",
label: "Details",
items: [
{
key: "driver-id",
type: "label",
icon: "fas fa-hashtag",
label: "Driver ID",
detailValue: "51",
appearance: "metadata",
testId: "driver-id-row",
},
],
},
],
});
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
const row = wrapper.get('[data-testid="driver-id-row"]');
expect(row.classes()).toContain("dropdown-item-label--metadata");
expect(row.text()).toContain("Driver ID");
expect(row.text()).toContain("51");
});
it("does not render standalone booking completion for unlinked bookings", async () => {
const wrapper = mountFlatDropdownButton({
order_id: null,