Remove configuration cache problems report

This commit is contained in:
Jeppe Bundgaard
2026-04-23 10:32:08 +02:00
parent 85f24bdb0c
commit fc26e12013
48 changed files with 4994 additions and 1125 deletions
File diff suppressed because one or more lines are too long
View File
@@ -11,6 +11,8 @@ import { useI18n } from "vue-i18n";
const { t } = useI18n();
const slots = useSlots();
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const emit = defineEmits(["deleted"]);
const emitDeleted = () => {
emit("deleted");
@@ -94,6 +96,13 @@ const shouldOpenDropdownUp = ref(false);
const dropdownMaxHeight = ref(null);
const isDesktopFlyoutLayout = ref(false);
const activeDesktopFlyoutSectionKey = ref(null);
const activeAttachmentId = ref(null);
const previewLoadingAttachmentId = ref(null);
const deletingAttachmentId = ref(null);
const previewSourcesById = ref({});
const previewRequestsInFlight = new Set();
const generatedObjectUrls = new Set();
let previewStateGeneration = 0;
const desktopFlyoutMinViewportWidth = 1400;
const desktopFlyoutRootPanelWidthRem = 15;
const desktopFlyoutSubmenuWidthRem = 17;
@@ -316,6 +325,7 @@ watch(isDropdownOpen, async (isOpen) => {
if (!isOpen) {
isDesktopFlyoutLayout.value = false;
activeDesktopFlyoutSectionKey.value = null;
clearAttachmentPreviewState();
resetDropdownLayout();
return;
}
@@ -391,6 +401,383 @@ const hasUser = computed(() => Boolean(props.user_id || props.customer_number ||
const attachmentsFromOrder = ref([]);
const attachmentsFromOrderError = ref(null);
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
const releaseObjectUrl = (value) => {
if (!isObjectUrl(value)) {
return;
}
URL.revokeObjectURL(value);
generatedObjectUrls.delete(value);
};
const clearAttachmentPreviewState = () => {
previewStateGeneration += 1;
previewRequestsInFlight.clear();
Object.values(previewSourcesById.value).forEach((value) => {
releaseObjectUrl(value);
});
previewSourcesById.value = {};
previewLoadingAttachmentId.value = null;
activeAttachmentId.value = null;
};
const getAttachmentLabel = (attachment) => {
return (
attachment?.content?.document ||
attachment?.content?.image ||
attachment?.content?.other ||
`Attachment ${attachment?.id ?? ""}`.trim()
);
};
const getAttachmentExtension = (attachment) => {
const match = getAttachmentLabel(attachment)
.toLowerCase()
.match(/(\.[a-z0-9]+)$/);
return match?.[1] || "";
};
const getAttachmentPreviewKind = (attachment) => {
const extension = getAttachmentExtension(attachment);
if (attachment?.content?.image || imageExtensions.includes(extension)) {
return "image";
}
if (attachment?.content?.document || extension === ".pdf") {
return officeExtensions.includes(extension) ? "office" : "document";
}
if (officeExtensions.includes(extension)) {
return "office";
}
if (String(attachment?.content?.other || "").startsWith("http")) {
return "link";
}
if (attachment?.content?.other) {
return "text";
}
return "none";
};
const activeAttachment = computed(() => {
return attachmentsFromOrder.value.find((attachment) => attachment.id === activeAttachmentId.value) || null;
});
const activeAttachmentPreviewKind = computed(() => {
if (!activeAttachment.value) {
return "none";
}
return getAttachmentPreviewKind(activeAttachment.value);
});
const activeAttachmentPreviewSource = computed(() => {
if (!activeAttachment.value) {
return null;
}
return previewSourcesById.value[activeAttachment.value.id] ?? null;
});
const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
};
const createEmbeddablePreviewUrl = async (downloadLink) => {
if (!downloadLink) {
return null;
}
try {
const response = await fetch(downloadLink, { method: "GET" });
if (!response.ok) {
return null;
}
const fileBlob = await response.blob();
if (!fileBlob || fileBlob.size === 0) {
return null;
}
const objectUrl = URL.createObjectURL(fileBlob);
generatedObjectUrls.add(objectUrl);
return objectUrl;
} catch (error) {
console.warn("Unable to create embeddable attachment preview blob", error);
return null;
}
};
const loadOrderAttachments = async () => {
if (!props.order_id) {
attachmentsFromOrder.value = [];
attachmentsFromOrderError.value = null;
clearAttachmentPreviewState();
return [];
}
attachmentsFromOrderError.value = null;
try {
let response = await SessionUser.objects.orders.functions.fetchAttachments(props.order_id);
if (!Array.isArray(response)) {
response = [];
}
attachmentsFromOrder.value = response;
if (!response.some((attachment) => attachment.id === activeAttachmentId.value)) {
activeAttachmentId.value = null;
}
return response;
} catch (error) {
const parsedErrorMessage = SessionUser.functions.parseErrorMessage(error);
console.warn("Error fetching attachments from order:", error, parsedErrorMessage);
attachmentsFromOrder.value = [];
attachmentsFromOrderError.value = `ERROR: ${parsedErrorMessage || "Failed to fetch attachments"}`;
clearAttachmentPreviewState();
return [];
}
};
const resolveAttachmentDownloadLink = async (attachment) => {
if (!props.order_id || !attachment?.id) {
return null;
}
return SessionUser.objects.orders.functions.downloadAttachment(props.order_id, attachment.id, false);
};
const ensurePreviewSource = async (attachment) => {
if (!attachment) {
return null;
}
const currentPreviewStateGeneration = previewStateGeneration;
const previewKind = getAttachmentPreviewKind(attachment);
if (!["image", "document"].includes(previewKind)) {
return null;
}
if (hasCachedPreviewSource(attachment.id) || previewRequestsInFlight.has(attachment.id)) {
return previewSourcesById.value[attachment.id] ?? null;
}
previewRequestsInFlight.add(attachment.id);
previewLoadingAttachmentId.value = attachment.id;
try {
const downloadLink = await resolveAttachmentDownloadLink(attachment);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
if (currentPreviewStateGeneration !== previewStateGeneration) {
releaseObjectUrl(previewSource);
return null;
}
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
};
return previewSource;
} catch (error) {
console.warn("Unable to load attachment preview", attachment.id, error);
if (currentPreviewStateGeneration === previewStateGeneration) {
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: null,
};
}
return null;
} finally {
previewRequestsInFlight.delete(attachment.id);
if (previewLoadingAttachmentId.value === attachment.id) {
previewLoadingAttachmentId.value = null;
}
}
};
const setActiveAttachment = async (attachment) => {
if (!attachment) {
activeAttachmentId.value = null;
return;
}
activeAttachmentId.value = attachment.id;
await ensurePreviewSource(attachment);
};
const openAttachmentInNewTab = (url) => {
if (!url) {
return null;
}
return window.open(url, "_blank", "noopener,noreferrer");
};
const downloadAttachmentFile = (url, attachment) => {
if (!url) {
return;
}
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.download = getAttachmentLabel(attachment);
document.body.appendChild(link);
link.click();
link.remove();
};
const previewAttachment = async (attachment) => {
try {
const previewKind = getAttachmentPreviewKind(attachment);
if (["image", "document"].includes(previewKind)) {
const previewSource = (await ensurePreviewSource(attachment)) || (await resolveAttachmentDownloadLink(attachment));
openAttachmentInNewTab(previewSource);
return;
}
const downloadLink = await resolveAttachmentDownloadLink(attachment);
openAttachmentInNewTab(downloadLink);
} catch (error) {
console.warn("Unable to preview attachment", attachment?.id, error);
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
}
};
const downloadAttachment = async (attachment) => {
try {
const downloadLink = await resolveAttachmentDownloadLink(attachment);
downloadAttachmentFile(downloadLink, attachment);
} catch (error) {
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
}
};
const printAttachment = async (attachment) => {
try {
const previewKind = getAttachmentPreviewKind(attachment);
const downloadLink = await resolveAttachmentDownloadLink(attachment);
if (!downloadLink) {
return;
}
if (!["image", "document"].includes(previewKind)) {
openAttachmentInNewTab(downloadLink);
return;
}
const printableSource = (await ensurePreviewSource(attachment)) || downloadLink;
const printWindow = window.open("", "_blank", "noopener,noreferrer,width=960,height=720");
if (!printWindow) {
openAttachmentInNewTab(printableSource);
return;
}
const escapedTitle = getAttachmentLabel(attachment).replace(/"/g, """);
const contentMarkup =
previewKind === "image"
? `<img src="${printableSource}" alt="" style="max-width: 100%; max-height: 100vh; object-fit: contain;" />`
: `<iframe src="${printableSource}" title="${escapedTitle}" style="width: 100%; height: 100vh; border: 0;"></iframe>`;
printWindow.document.write(`<!DOCTYPE html>
<html>
<head>
<title>${escapedTitle}</title>
<style>
html, body {
margin: 0;
min-height: 100%;
background: #ffffff;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
iframe {
display: block;
}
</style>
</head>
<body>${contentMarkup}</body>
</html>`);
printWindow.document.close();
printWindow.addEventListener(
"load",
() => {
printWindow.focus();
window.setTimeout(() => {
printWindow.print();
}, 150);
},
{ once: true }
);
} catch (error) {
console.warn("Unable to print attachment", attachment?.id, error);
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
}
};
const removeAttachment = async (attachment) => {
if (!props.order_id || !attachment?.id || deletingAttachmentId.value === attachment.id) {
return;
}
deletingAttachmentId.value = attachment.id;
try {
const response = await SessionUser.objects.orders.functions.removeAttachment(props.order_id, attachment.id);
if (!response) {
throw new Error("Attachment delete failed");
}
releaseObjectUrl(previewSourcesById.value[attachment.id]);
const nextPreviewSourcesById = { ...previewSourcesById.value };
delete nextPreviewSourcesById[attachment.id];
previewSourcesById.value = nextPreviewSourcesById;
await loadOrderAttachments();
await props.refreshFunction();
} catch (error) {
console.warn("Unable to delete attachment", attachment?.id, error);
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
icon: "error",
});
} finally {
deletingAttachmentId.value = null;
}
};
watch(
() => [attachmentsFromOrder.value.length, attachmentsFromOrderError.value, userIdFromCustomerNumber.value],
() => {
@@ -400,47 +787,19 @@ watch(
}
);
watch(
() => props.order_id,
() => {
void loadOrderAttachments();
},
{ immediate: true }
);
onMounted(() => {
document.addEventListener("click", onDocumentClick);
document.addEventListener("keydown", onDocumentKeydown);
window.addEventListener("resize", onViewportChange);
document.addEventListener("scroll", onViewportChange, true);
if (props.order_id) {
SessionUser.objects.orders.functions
.fetchAttachments(props.order_id)
.then((response) => {
/**
* [
* {
* "id": 4396,
* "object_type": "`orders`",
* "object_id": 30242,
* "content": {
* "image": null,
* "document": "pdf_6912ea1886f15.pdf",
* "relation": null,
* "other": "wash_certificate"
* },
* "created_at": "2025-11-11 08:47:36",
* "updated_at": null,
* "deleted_at": null
* }
* ]
*/
// Validate the response is an array
if (!Array.isArray(response)) {
response = [];
}
attachmentsFromOrder.value = response;
})
.catch((error) => {
const parsedErrorMessage = SessionUser.functions.parseErrorMessage(error);
console.warn("Error fetching attachments from order:", error, parsedErrorMessage);
// Check if there's an error message in the response
attachmentsFromOrderError.value = `ERROR: ${parsedErrorMessage || "Failed to fetch attachments"}`;
});
}
});
onBeforeUnmount(() => {
@@ -448,6 +807,7 @@ onBeforeUnmount(() => {
document.removeEventListener("keydown", onDocumentKeydown);
window.removeEventListener("resize", onViewportChange);
document.removeEventListener("scroll", onViewportChange, true);
clearAttachmentPreviewState();
});
const onShowImpersonationQRCode = (src, directLink) => {
@@ -838,8 +1198,8 @@ const buildMenuSection = (key, label, items) => {
};
};
const downloadOrderAttachment = (attachmentId) =>
SessionUser.objects.orders.functions.downloadAttachment(props.order_id, attachmentId, true).catch(() => {
const downloadOrderAttachment = (attachment) =>
downloadAttachment(attachment).catch(() => {
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
@@ -1119,21 +1479,24 @@ const flatBuiltInMenuSections = computed(() => {
if (vehicleSection) {
sections.push(vehicleSection);
}
}
if (attachmentsFromOrder.value.length > 0) {
const attachmentsSection = buildMenuSection("attachments", t("admin.pos.settings_wheel.attached_files"), [
...attachmentsFromOrder.value.map((attachment) =>
buildMenuAction(`attachment-${attachment.id}`, {
icon: "fas fa-paperclip",
label: `${attachment.content?.document || attachment.content?.other}`,
clickAction: () => downloadOrderAttachment(attachment.id),
label: getAttachmentLabel(attachment),
clickAction: () => downloadOrderAttachment(attachment),
})
),
]);
if (attachmentsSection) {
sections.push(attachmentsSection);
}
sections.push({
...attachmentsSection,
attachments: [...attachmentsFromOrder.value],
});
}
}
@@ -1260,6 +1623,10 @@ const syncDesktopFlyoutState = (triggerRect) => {
const setActiveDesktopFlyoutSection = (sectionKey) => {
activeDesktopFlyoutSectionKey.value = sectionKey;
if (sectionKey !== "attachments") {
activeAttachmentId.value = null;
}
};
</script>
@@ -1345,13 +1712,40 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
v-for="section in desktopFlyoutMenuSections"
:key="section.key"
class="action-settings-wheel-flyout__submenu"
:class="{ 'is-active': activeDesktopFlyoutSection?.key === section.key }"
:class="{
'is-active': activeDesktopFlyoutSection?.key === section.key,
'action-settings-wheel-flyout__submenu--attachments': section.key === 'attachments',
}"
:data-testid="`action-settings-wheel-submenu-${section.key}`"
>
<p class="action-settings-wheel-flyout__submenu-title">
{{ SessionUser.functions.ucFirst(section.label) }}
</p>
<div class="action-settings-wheel-flyout__submenu-items">
<template v-if="section.key === 'attachments'">
<button
v-for="attachment in section.attachments || []"
:key="`attachment-row-${attachment.id}`"
type="button"
class="action-settings-wheel-section-trigger action-settings-wheel-section-trigger--attachment"
:class="{ 'is-active': activeAttachmentId === attachment.id }"
:data-testid="`action-settings-wheel-attachment-row-${attachment.id}`"
@mouseenter="setActiveAttachment(attachment)"
@focus="setActiveAttachment(attachment)"
@click.stop.prevent="setActiveAttachment(attachment)"
>
<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__icon">
<i class="fas fa-paperclip" aria-hidden="true"></i>
</span>
<span class="action-settings-wheel-section-trigger__label">
{{ getAttachmentLabel(attachment) }}
</span>
</button>
</template>
<template v-else>
<ActionSettingsWheelItem
v-for="item in section.items"
:key="item.key"
@@ -1361,6 +1755,102 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
:click-action="item.clickAction"
:disabled="item.disabled"
/>
</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"
>
<p class="action-settings-wheel-attachment-panel__title">
{{ getAttachmentLabel(activeAttachment) }}
</p>
<div class="action-settings-wheel-attachment-panel__preview" data-testid="action-settings-wheel-attachment-preview">
<span
v-if="previewLoadingAttachmentId === activeAttachment.id"
class="action-settings-wheel-attachment-panel__text"
>
{{ t("global.loading") }}
</span>
<img
v-else-if="activeAttachmentPreviewKind === 'image' && activeAttachmentPreviewSource"
:src="activeAttachmentPreviewSource"
alt=""
class="action-settings-wheel-attachment-panel__image"
/>
<iframe
v-else-if="activeAttachmentPreviewKind === 'document' && activeAttachmentPreviewSource"
:src="activeAttachmentPreviewSource"
title="Attachment preview"
class="action-settings-wheel-attachment-panel__document"
></iframe>
<a
v-else-if="activeAttachmentPreviewKind === 'link'"
:href="activeAttachment.content?.other"
target="_blank"
rel="noopener noreferrer"
class="action-settings-wheel-attachment-panel__text action-settings-wheel-attachment-panel__link"
>
{{ activeAttachment.content?.other }}
</a>
<span v-else-if="activeAttachmentPreviewKind === 'office'" class="action-settings-wheel-attachment-panel__text">
{{ t("admin.pos.attachments_office_preview_unavailable") }}
</span>
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
{{ activeAttachment.content?.other }}
</span>
<span v-else class="action-settings-wheel-attachment-panel__text">
{{ t("admin.pos.attachments_no_preview") }}
</span>
</div>
<div class="action-settings-wheel-attachment-panel__actions">
<button
type="button"
class="action-settings-wheel-attachment-action"
:data-testid="`action-settings-wheel-attachment-action-preview-${activeAttachment.id}`"
@click.stop.prevent="previewAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-eye" aria-hidden="true"></i>
</span>
<span>{{ t("global.preview") }}</span>
</button>
<button
type="button"
class="action-settings-wheel-attachment-action"
:data-testid="`action-settings-wheel-attachment-action-download-${activeAttachment.id}`"
@click.stop.prevent="downloadAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-download" aria-hidden="true"></i>
</span>
<span>{{ t("global.download") }}</span>
</button>
<button
type="button"
class="action-settings-wheel-attachment-action"
:data-testid="`action-settings-wheel-attachment-action-print-${activeAttachment.id}`"
@click.stop.prevent="printAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-print" aria-hidden="true"></i>
</span>
<span>{{ t("global.print") }}</span>
</button>
<button
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
type="button"
class="action-settings-wheel-attachment-action action-settings-wheel-attachment-action--danger"
:disabled="deletingAttachmentId === activeAttachment.id"
:data-testid="`action-settings-wheel-attachment-action-delete-${activeAttachment.id}`"
@click.stop.prevent="removeAttachment(activeAttachment)"
>
<span class="action-settings-wheel-attachment-action__icon">
<i class="fas fa-trash-alt" aria-hidden="true"></i>
</span>
<span>{{ t("global.delete") }}</span>
</button>
</div>
</div>
</div>
</div>
@@ -1509,6 +1999,7 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
.action-settings-wheel-flyout__submenu {
grid-area: 1 / 1;
position: relative;
background: linear-gradient(180deg, #f9fbfd 0%, #f3f7fb 100%);
border: 1px solid #d9e4ef;
border-radius: 0.8rem;
@@ -1542,6 +2033,10 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
gap: 0.15rem;
}
.action-settings-wheel-flyout__submenu--attachments {
overflow: visible;
}
.action-settings-wheel-flyout__standalone-actions {
margin-top: 0.35rem;
padding-top: 0.35rem;
@@ -1580,14 +2075,147 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
flex-shrink: 0;
}
.action-settings-wheel-section-trigger__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.95rem;
color: #708097;
flex-shrink: 0;
}
.action-settings-wheel-section-trigger--attachment {
padding-right: 1rem;
}
.action-settings-wheel-section-trigger__label {
flex: 1;
text-align: left;
font-weight: 600;
overflow-wrap: anywhere;
}
.action-settings-wheel-attachment-panel {
position: absolute;
top: 0;
right: calc(100% + 0.75rem);
width: min(20rem, 48vw);
min-height: 20rem;
background: #ffffff;
border: 1px solid #cfd8e3;
border-radius: 0.9rem;
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 2;
}
.action-settings-wheel-attachment-panel__title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: #132339;
overflow-wrap: anywhere;
}
.action-settings-wheel-attachment-panel__preview {
min-height: 12.5rem;
border: 1px solid #e7edf5;
border-radius: 0.75rem;
background: linear-gradient(180deg, #fbfcfe 0%, #f3f6fa 100%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.action-settings-wheel-attachment-panel__image,
.action-settings-wheel-attachment-panel__document {
width: 100%;
height: 100%;
border: 0;
background: #ffffff;
}
.action-settings-wheel-attachment-panel__image {
object-fit: contain;
}
.action-settings-wheel-attachment-panel__text {
padding: 1rem;
text-align: center;
color: #4a5568;
overflow-wrap: anywhere;
}
.action-settings-wheel-attachment-panel__link {
text-decoration: none;
}
.action-settings-wheel-attachment-panel__actions {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.action-settings-wheel-attachment-action {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.72rem 0.85rem;
background: #f8fafc;
border: 1px solid #dde6f0;
border-radius: 0.7rem;
color: #25344d;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.action-settings-wheel-attachment-action:hover,
.action-settings-wheel-attachment-action:focus {
background: #f2f6fb;
border-color: #d2deea;
color: #132339;
outline: none;
}
.action-settings-wheel-attachment-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-settings-wheel-attachment-action--danger {
color: #8d2637;
border-color: #f0d4db;
background: #fff7f8;
}
.action-settings-wheel-attachment-action--danger:hover,
.action-settings-wheel-attachment-action--danger:focus {
background: #fff0f2;
border-color: #e7bcc7;
color: #6f1728;
}
.action-settings-wheel-attachment-action__icon {
width: 0.95rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dropdown-content :deep(.dropdown-item-action),
.dropdown-content :deep(.dropdown-item-label) {
border-radius: 0.55rem;
}
@media (hover: none) {
.action-settings-wheel-attachment-panel {
display: none;
}
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import GenericTag from "@/components/viewport/page/templates/generic/graphics/GenericTag.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import {onMounted, defineProps} from "vue";
import {onBeforeMount, defineProps} from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
// Function to fetch categories based on the department ID
const props = defineProps({
@@ -14,9 +14,13 @@ const props = defineProps({
});
// This function will be called when the component is mounted
const fetchCategories = async (departmentId: number) => {
await SessionUser.objects.department_categories.get.single(departmentId)
.then((response) => {
pos.categories.setLoading(true);
pos.categories.unselect();
pos.categories.clear();
pos.productList.clear();
try {
const response = await SessionUser.objects.department_categories.get.single(departmentId);
// Add the "Suggested" category
// pos.categories.add({
@@ -24,7 +28,9 @@ const fetchCategories = async (departmentId: number) => {
// name: "Suggested",
// });
// Add the categories to the pos.categories object
SessionUser.objects.department_categories.functions.orderCategories(response).forEach((result: {category: {id: number, name: string}}) => {
SessionUser.objects.department_categories.functions
.orderCategories(response)
.forEach((result: {category: {id: number, name: string}}) => {
// If restrictCategories prop is set, only add categories that are in the list
if (props.restrictCategories.length > 0 && !props.restrictCategories.includes(result.category.id)) {
return;
@@ -34,25 +40,48 @@ const fetchCategories = async (departmentId: number) => {
name: result.category.name,
});
});
// Select the category 6, if it exists, otherwise select the first category
const category6 = pos.categories.get().find(cat => cat.id === 6);
if (category6) {
pos.productList.setLoading(true);
pos.categories.select(category6);
return;
} else if (pos.categories.get().length > 0) {
pos.categories.select(pos.categories.get()[0]);
}
});
if (pos.categories.get().length > 0) {
pos.productList.setLoading(true);
pos.categories.select(pos.categories.get()[0]);
return;
}
} catch (error) {
console.warn("Unable to load POS categories", error);
pos.productList.setLoading(false);
} finally {
if (pos.categories.get().length === 0) {
pos.productList.setLoading(false);
}
pos.categories.setLoading(false);
}
};
onMounted(() => {
onBeforeMount(() => {
// Fetch categories when the component is mounted
fetchCategories(SessionUser.functions.getDepartmentIdFromUrl());
});
</script>
<template>
<div>
<div v-if="pos.categories.loading.value" class="pos-mobile-categories-loading" data-testid="pos-mobile-categories-loading">
<span class="pos-mobile-categories-loading__label">{{ SessionUser.objects.global.language.loading }}</span>
<div class="columns is-mobile is-multiline is-gapless">
<div v-for="index in 4" :key="`category-loading-${index}`" class="column is-narrow">
<div class="pos-mobile-categories-loading__chip custom-gap"></div>
</div>
</div>
</div>
<div v-else class="columns is-mobile is-multiline is-gapless">
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id" :data-testid="`pos-mobile-category-${category.id}`">
<GenericTag
:active="pos.categories.isCategorySelected(category)"
@@ -62,10 +91,42 @@ onMounted(() => {
</GenericTag>
</div>
</div>
</div>
</template>
<style scoped>
.custom-gap {
margin: 6px 5px;
}
.pos-mobile-categories-loading {
padding: 0.25rem 0 0.5rem;
}
.pos-mobile-categories-loading__label {
display: inline-block;
margin: 0 0 0.25rem 0.35rem;
color: #6b7280;
font-size: 0.875rem;
font-weight: 600;
}
.pos-mobile-categories-loading__chip {
width: 96px;
height: 34px;
border-radius: 8px;
background: linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%);
background-size: 200% 100%;
animation: pos-mobile-loading-shimmer 1.2s ease-in-out infinite;
}
@keyframes pos-mobile-loading-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
@@ -28,19 +28,41 @@ const props = defineProps({
},
});
const slots = defineSlots();
let latestProductsRequestId = 0;
// Function to fetch categories based on the department ID
// This function will be called when the component is mounted
const fetchProducts = async (category: PosCategory, departmentId: number) => {
await SessionUser.objects.products.get.category(category.id, departmentId, (customer_id.value ? parseInt(customer_id.value) : null), true)
.then((response) => {
const requestId = ++latestProductsRequestId;
pos.productList.setLoading(true);
pos.productList.clear();
try {
const response = await SessionUser.objects.products.get.category(
category.id,
departmentId,
(customer_id.value ? parseInt(customer_id.value) : null),
true
);
if (requestId !== latestProductsRequestId) {
return;
}
orderProducts(response).forEach((result: PosProduct) => {
pos.productList.add(result);
});
// TODO: Update prices of products based on customer pricing rules
pos.transactionItems.updateTransactionPrices();
});
} catch (error) {
if (requestId === latestProductsRequestId) {
console.warn("Unable to load POS products", error);
}
} finally {
if (requestId === latestProductsRequestId) {
pos.productList.setLoading(false);
}
}
};
// Function to fetch suggested products
@@ -106,10 +128,24 @@ watch(() => props.addons, (newAddons) => {
const filteredProducts = computed(() => {
return pos.productList.get().filter(product => !isProductRestricted(product));
});
const shouldShowLoadingState = computed(() => {
return pos.categories.loading.value || pos.productList.loading.value;
});
</script>
<template>
<template v-if="pos.categories.isSelected()">
<div v-if="shouldShowLoadingState" class="pos-mobile-products-loading" data-testid="pos-mobile-products-loading">
<span class="pos-mobile-products-loading__label">{{ SessionUser.objects.global.language.loading }}</span>
<div v-for="index in 4" :key="`product-loading-${index}`" class="pos-mobile-products-loading__item">
<div class="pos-mobile-products-loading__image"></div>
<div class="pos-mobile-products-loading__copy">
<div class="pos-mobile-products-loading__line pos-mobile-products-loading__line--title"></div>
<div class="pos-mobile-products-loading__line pos-mobile-products-loading__line--price"></div>
</div>
<div class="pos-mobile-products-loading__chevron"></div>
</div>
</div>
<template v-else-if="pos.categories.isSelected()">
<template v-if="!props.asAddons">
<!-- Display of products, without a "basket" (filtered for customer restrictions) -->
<template v-for="product in filteredProducts" :key="product.id">
@@ -134,4 +170,76 @@ const filteredProducts = computed(() => {
</template>
<style scoped>
.pos-mobile-products-loading {
padding: 0.5rem 0 1rem;
}
.pos-mobile-products-loading__label {
display: inline-block;
margin-bottom: 0.5rem;
color: #6b7280;
font-size: 0.875rem;
font-weight: 600;
}
.pos-mobile-products-loading__item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.875rem 0;
border-bottom: 1px solid #e5e7eb;
}
.pos-mobile-products-loading__image,
.pos-mobile-products-loading__line,
.pos-mobile-products-loading__chevron {
background: linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%);
background-size: 200% 100%;
animation: pos-mobile-products-loading-shimmer 1.2s ease-in-out infinite;
}
.pos-mobile-products-loading__image {
flex: 0 0 64px;
width: 64px;
height: 64px;
border-radius: 12px;
}
.pos-mobile-products-loading__copy {
flex: 1 1 auto;
}
.pos-mobile-products-loading__line {
height: 14px;
border-radius: 999px;
}
.pos-mobile-products-loading__line + .pos-mobile-products-loading__line {
margin-top: 0.65rem;
}
.pos-mobile-products-loading__line--title {
width: min(220px, 72%);
}
.pos-mobile-products-loading__line--price {
width: 96px;
}
.pos-mobile-products-loading__chevron {
flex: 0 0 18px;
width: 18px;
height: 18px;
clip-path: polygon(20% 10%, 80% 50%, 20% 90%, 0 70%, 40% 50%, 0 30%);
}
@keyframes pos-mobile-products-loading-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
@@ -1,21 +1,9 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { computed, defineEmits, onBeforeUnmount, reactive, ref, watch } from "vue";
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
import { watch } from "vue";
import { actionButtons, metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import {
applyPosAddCustomerSearchResult,
createPosAddCustomerFields,
createPosAddCustomerManualOverrides,
createPosAddCustomerRequestState,
isValidPosAddCustomerSearchResult,
nextPosAddCustomerRequestState,
resolvePosAddCustomerRequestState,
setPosAddCustomerManualOverride,
} from "./posAddCustomerAutofill.js";
import { usePosAddCustomerForm } from "@/composables/usePosAddCustomerForm.js";
const SEARCH_DEBOUNCE_MS = 300;
const ADD_CUSTOMER_SUBMIT_TEST_ID = "pos-mobile-add-customer-submit";
const { t } = useI18n();
@@ -24,38 +12,35 @@ const emit = defineEmits<{
(e: "close"): void;
}>();
const searchQuery = ref("");
const searchResult = ref<any | null>(null);
const searching = ref(false);
const requestState = ref(createPosAddCustomerRequestState());
const manualOverrides = ref(createPosAddCustomerManualOverrides());
const form = reactive(createPosAddCustomerFields());
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const isValidSearchResult = computed(() =>
isValidPosAddCustomerSearchResult(searchResult.value, t("admin.pos.not_found"))
);
const canCreateCustomer = computed(() => {
const requiredFields = [form.cvr, form.companyPhone, form.contactPhone];
return isValidSearchResult.value && requiredFields.every((field) => String(field).trim() !== "");
const {
searchQuery,
searchResult,
searching,
submitting,
errorMessage,
form,
isValidSearchResult,
canCreateCustomer,
handleAutofillFieldInput,
submitCustomer,
} = usePosAddCustomerForm({
onCustomerCreated: async (_selectedCustomer, createdCustomerNumber) => {
metadata.setCustomerId(createdCustomerNumber);
emit("close");
},
});
const clearQueuedSearch = () => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = null;
}
};
const syncPopupActionButtons = () => {
const popup = popups.get();
if (!popup) {
return;
}
if (submitting.value) {
popup.actionButtons = [];
return;
}
const nextButtons = [{ ...actionButtons.default.value.cancel }];
if (canCreateCustomer.value) {
@@ -70,126 +55,6 @@ const syncPopupActionButtons = () => {
popup.actionButtons = nextButtons;
};
const applySearchResult = (nextSearchResult: any | null) => {
searchResult.value = nextSearchResult;
if (!isValidPosAddCustomerSearchResult(nextSearchResult, t("admin.pos.not_found"))) {
return;
}
Object.assign(
form,
applyPosAddCustomerSearchResult({
fields: form,
manualOverrides: manualOverrides.value,
searchResult: nextSearchResult,
notFoundLabel: t("admin.pos.not_found"),
})
);
};
const runSearch = async (query: string) => {
const normalizedQuery = String(query ?? "").trim();
if (normalizedQuery === "") {
searchResult.value = null;
searching.value = false;
return;
}
const nextRequest = nextPosAddCustomerRequestState(requestState.value);
requestState.value = nextRequest.state;
try {
const response = await SessionUser.request("/cvr/search", "GET", {
query: normalizedQuery,
});
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
if (!resolution.shouldApply) {
return;
}
requestState.value = resolution.state;
applySearchResult(response?.data?.data ?? null);
} catch (error) {
console.error(error);
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
if (!resolution.shouldApply) {
return;
}
requestState.value = resolution.state;
searchResult.value = null;
} finally {
if (nextRequest.requestId === requestState.value.latestRequestedId) {
searching.value = false;
}
}
};
const queueSearch = (query: string) => {
clearQueuedSearch();
const normalizedQuery = String(query ?? "").trim();
popups.get().props.error = null;
if (normalizedQuery === "") {
searchResult.value = null;
searching.value = false;
return;
}
searching.value = true;
searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null;
runSearch(normalizedQuery);
}, SEARCH_DEBOUNCE_MS);
};
const handleAutofillFieldInput = (field: keyof typeof manualOverrides.value) => {
manualOverrides.value = setPosAddCustomerManualOverride(manualOverrides.value, field, form[field]);
popups.get().props.error = null;
};
const submitCustomer = async () => {
const popup = popups.get();
if (!popup) {
return;
}
popup.actionButtons = [];
try {
await SessionUser.request("/auth/register/cvr", "POST", {
cvr: form.cvr,
companyPhone: Number.parseInt(form.companyPhone, 10),
invoiceEmail: form.invoiceEmail,
contactEmail: form.contactEmail,
contactPhone: Number.parseInt(form.contactPhone, 10),
searchResult: searchResult.value,
});
await searchAndSelectCustomer(form.companyPhone);
metadata.setCustomerId(Number.parseInt(form.companyPhone, 10));
emit("close");
} catch (error) {
console.error(error);
const errorResponse = SessionUser.functions.parseErrorMessage(error);
if (errorResponse !== null) {
popups.get().props.error = t("admin.pos.customer_creation_error", { error: errorResponse });
} else {
popups.get().props.error = t("admin.pos.customer_creation_error_generic");
}
} finally {
syncPopupActionButtons();
}
};
watch(searchQuery, queueSearch);
watch(
() => [
form.cvr,
@@ -199,19 +64,17 @@ watch(
form.contactPhone,
canCreateCustomer.value,
searchResult.value,
errorMessage.value,
submitting.value,
],
syncPopupActionButtons,
{ immediate: true }
);
onBeforeUnmount(() => {
clearQueuedSearch();
});
</script>
<template>
<div
style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;"
style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px"
data-testid="pos-mobile-add-customer-popup"
>
<div class="mx-3">
@@ -227,6 +90,7 @@ onBeforeUnmount(() => {
}"
type="text"
:placeholder="$t('global.search_cvr')"
:disabled="submitting"
data-testid="pos-mobile-add-customer-search-input"
/>
<span
@@ -244,14 +108,16 @@ onBeforeUnmount(() => {
<p class="help" v-if="isValidSearchResult">{{ searchResult.name }}</p>
</div>
<div class="field">
<label class="label"><small>{{ $t("admin.pos.company_phone") }}</small></label>
<label class="label"
><small>{{ $t("admin.pos.company_phone") }}</small></label
>
<div class="control">
<input
v-model="form.companyPhone"
class="input"
type="text"
:placeholder="$t('admin.pos.company_phone')"
:disabled="searchResult === null"
:disabled="searchResult === null || submitting"
:class="{ 'is-danger': searchResult !== null && form.companyPhone === '' }"
data-testid="pos-mobile-add-customer-company-phone"
@input="handleAutofillFieldInput('companyPhone')"
@@ -259,54 +125,59 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="field">
<label class="label"><small>{{ $t("admin.pos.invoice_email") }}</small></label>
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
>
<div class="control">
<input
v-model="form.invoiceEmail"
class="input"
type="text"
:placeholder="$t('admin.pos.invoice_email')"
:disabled="searchResult === null"
:disabled="searchResult === null || submitting"
data-testid="pos-mobile-add-customer-invoice-email"
@input="handleAutofillFieldInput('invoiceEmail')"
/>
</div>
</div>
<div class="field">
<label class="label"><small>{{ $t("admin.pos.contact_email") }}</small></label>
<label class="label"
><small>{{ $t("admin.pos.contact_email") }}</small></label
>
<div class="control">
<input
v-model="form.contactEmail"
class="input"
type="text"
:placeholder="$t('admin.pos.contact_email')"
:disabled="searchResult === null"
:disabled="searchResult === null || submitting"
data-testid="pos-mobile-add-customer-contact-email"
@input="handleAutofillFieldInput('contactEmail')"
/>
</div>
</div>
<div class="field">
<label class="label"><small>{{ $t("admin.pos.contact_phone") }}</small></label>
<label class="label"
><small>{{ $t("admin.pos.contact_phone") }}</small></label
>
<div class="control">
<input
v-model="form.contactPhone"
class="input"
type="text"
:placeholder="$t('admin.pos.contact_phone')"
:disabled="searchResult === null"
:disabled="searchResult === null || submitting"
:class="{ 'is-danger': searchResult !== null && form.contactPhone === '' }"
data-testid="pos-mobile-add-customer-contact-phone"
@input="handleAutofillFieldInput('contactPhone')"
/>
</div>
</div>
<div class="notification is-danger" v-if="popups.get().props.error !== null">
{{ popups.get().props.error }}
<div class="notification is-danger" v-if="errorMessage !== null">
{{ errorMessage }}
</div>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -835,6 +835,7 @@ const transactionItems = {
/** Categories */
// Categories available for the transaction
const categoriesList = ref<PosCategory[]>([]);
const categoriesLoading = ref<boolean>(false);
// Function to add a new category
const addCategory = (category: PosCategory) => {
categoriesList.value.push(category);
@@ -876,15 +877,20 @@ const isCurrentCategorySelected = () => {
const isCategorySelected = (category: PosCategory) => {
return currentCategory.value === category;
};
const setCategoriesLoading = (state: boolean) => {
categoriesLoading.value = state;
};
// Exporting the categories object for use in other components
const categories = {
// Categories related functions
list: categoriesList,
loading: categoriesLoading,
add: addCategory,
remove: removeCategory,
clear: clearCategories,
get: getCategories,
setLoading: setCategoriesLoading,
// Current category-related functions
selected: currentCategory,
select: setCurrentCategory,
@@ -896,6 +902,7 @@ const categories = {
/** Products */
const products = ref<PosProduct[]>([]);
const productsLoading = ref<boolean>(false);
// Function to add a product
const addProduct = (product: PosProduct) => {
products.value.push(product);
@@ -919,14 +926,19 @@ const getProducts = () => {
const isProductInList = (product: PosProduct) => {
return products.value.includes(product);
};
const setProductsLoading = (state: boolean) => {
productsLoading.value = state;
};
// Exporting the products object for use in other components
const productList = {
list: products,
loading: productsLoading,
add: addProduct,
remove: removeProduct,
clear: clearProducts,
get: getProducts,
setLoading: setProductsLoading,
isInList: isProductInList,
};
@@ -1461,10 +1473,12 @@ const resetTransactionItems = () => {
const resetCategories = () => {
categories.list.value = [];
categories.selected.value = null;
categories.loading.value = false;
};
// Function to reset products
const resetProducts = () => {
productList.list.value = [];
productList.loading.value = false;
};
// Function to reset camera
const resetCamera = () => {
@@ -8,6 +8,14 @@ defineProps({
});
const { loadList } = usePaginatedListInstance();
const openDepartmentWorkspace = (departmentId) => {
if (!departmentId) {
return;
}
window.location = `/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways?tab=gates`;
};
</script>
<template>
@@ -32,6 +40,9 @@ const { loadList } = usePaginatedListInstance();
<EditableTableColumn :object="object" :loadList="loadList" column="is_exit" :edit-function="SessionUser.objects.department_gates.showEditObjectFieldForm" />
<EditableTableColumn :object="object" :loadList="loadList" column="config" :edit-function="SessionUser.objects.department_gates.showEditObjectFieldForm" :parse-function="(value) => JSON.stringify(value)" />
<td class="has-text-right">
<button class="button is-small is-light" type="button" @click="openDepartmentWorkspace(object.department)">
Workspace
</button>
<button class="button is-small is-danger" @click="SessionUser.objects.department_gates.functions.showDeleteObjectForm(object.id, loadList)">
<span class="icon is-small"><i class="fas fa-trash"></i></span>
</button>
@@ -28,6 +28,14 @@ const redirect = (path) => {
window.location = path;
};
const openDepartmentWorkspace = (departmentId, tab = "lanes") => {
if (!departmentId) {
return;
}
redirect(`/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways?tab=${encodeURIComponent(tab)}`);
};
const machineTypeNames = ref({});
const loadMachineTypes = async () => {
@@ -167,6 +175,15 @@ watch(() => props.objects, () => {
</span>
</button>
</div>
<div class="column is-narrow">
<button
class="button is-small is-light"
type="button"
@click="openDepartmentWorkspace(object.department, 'lanes')"
>
Workspace
</button>
</div>
<div class="column is-narrow">
<ActionSettingsWheelButton :department_lane_id="object.id" :department_id="object.department" :loadList="loadList">
<template #actions>
@@ -29,6 +29,14 @@ import { showEditNumberPlateScannerForm } from "@/components/forms/superUser/edi
const redirect = (path) => {
window.location = path;
}
const openDepartmentWorkspace = (departmentId) => {
if (!departmentId) {
return;
}
redirect(`/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways?tab=scanners`);
}
const showApiKey = (apiKey) => {
Swal.fire({
title: 'API Key',
@@ -46,6 +54,7 @@ const showApiKey = (apiKey) => {
<th>{{ $t('objects.columns.department') }}</th>
<th>{{ $t('tables.users.name') }}</th>
<th>{{ $t('objects.columns.notes') }}</th>
<th>Lane</th>
<th>{{ $t('tables.actions') }}</th>
</tr>
</thead>
@@ -55,9 +64,11 @@ const showApiKey = (apiKey) => {
<td>{{ scanner.department_id }}</td>
<td>{{ scanner.name }}</td>
<td>{{ scanner.notes }}</td>
<td>{{ scanner.lane_id || "-" }}</td>
<td>
<div class="buttons">
<button class="button is-small is-dark" @click="showEditNumberPlateScannerForm(scanner.id, scanner.department_id, scanner.name, scanner.notes)">{{ $t('global.edit') }}</button>
<button class="button is-small is-light" @click="openDepartmentWorkspace(scanner.department_id)">Workspace</button>
<button class="button is-small is-darker" @click="showApiKey(scanner.api_key)">API KEY</button>
</div>
</td>
@@ -90,61 +90,84 @@ const props = defineProps({
required: false
}
})
const normalizeCategoryIdentifier = (value) => {
if (value === null || value === undefined || value === '' || value === 'null') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
};
// Define the products
const products = ref([]);
onMounted(() => {
// If the category is not defined, get all products
if (getProductsCategory() === null) {
getProducts(department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
} else {
// Get the products by category
getProductCategory(getProductsCategory(), department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
const productsLoading = ref(true);
const latestProductsRequestId = ref(0);
const applyAttemptAutoSelect = () => {
// Wait a bit to ensure the DOM has settled
setTimeout(() => {
if (props.attemptAutoSelectId === null) {
return;
}
const matchingProduct = products.value.find((product) => product.id === props.attemptAutoSelectId);
if (matchingProduct === undefined) {
return;
}
// Check if the attempt auto select id is set
if (props.attemptAutoSelectId !== null) {
// Check if the product is in the products
if (products.value.find((product) => product.id === props.attemptAutoSelectId) !== undefined) {
// Set the selected id
selectedProductId.value = props.attemptAutoSelectId;
emits('onSelectProduct', products.value.find((product) => product.id === props.attemptAutoSelectId));
}
}
emits('onSelectProduct', matchingProduct);
}, 200);
});
};
const fetchProductsForCategory = async ({
category = getProductsCategory(),
departmentId = department_id.value,
customerNumber = props.customer_number
} = {}) => {
const normalizedCategory = normalizeCategoryIdentifier(category);
const requestId = ++latestProductsRequestId.value;
productsLoading.value = true;
products.value = [];
try {
const response = normalizedCategory === null
? await getProducts(departmentId, !!props.isCustomerBooking, customerNumber)
: await getProductCategory(normalizedCategory, departmentId, !!props.isCustomerBooking, customerNumber);
if (requestId !== latestProductsRequestId.value) {
return;
}
products.value = response?.data?.data || [];
applyAttemptAutoSelect();
} catch (error) {
if (requestId !== latestProductsRequestId.value) {
return;
}
products.value = [];
} finally {
if (requestId === latestProductsRequestId.value) {
productsLoading.value = false;
}
}
};
onMounted(() => {
void fetchProductsForCategory();
});
// React to department or category changes
watch(() => department_id.value, (newDeptId) => {
// Reload categories for the new department
loadCategories();
// Refetch products for the current category scope
if (getProductsCategory() === null) {
getProducts(newDeptId, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
} else {
getProductCategory(getProductsCategory(), newDeptId, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
}
void loadCategories();
void fetchProductsForCategory({ departmentId: newDeptId });
});
watch(() => getProductsCategory(), (newCategory) => {
if (newCategory === null) {
getProducts(department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
} else {
getProductCategory(newCategory, department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
}
void fetchProductsForCategory({ category: newCategory });
});
const getValidOrderId = () => {
@@ -224,40 +247,64 @@ const categories_static = ref([
// Set the categories
const categories = ref([]);
const categoriesLoading = ref(true);
const latestCategoriesRequestId = ref(0);
// Add the dynamic categories
const loadCategories = async () => {
const requestId = ++latestCategoriesRequestId.value;
const nextCategories = [...categories_static.value];
categoriesLoading.value = true;
categories.value = [];
// Add the static categories
categories.value.push(...categories_static.value);
// Prefer route param, otherwise fallback to global POS department_id
const departmentId = route?.params?.departmentId ?? department_id.value;
// If the department id is not set, return
if (departmentId === undefined || departmentId === null) {
if (requestId === latestCategoriesRequestId.value) {
categories.value = nextCategories;
categoriesLoading.value = false;
}
return;
}
// Get the categories
await SessionUser.objects.department_categories.get.single(
departmentId
).then((response) => {
try {
const response = await SessionUser.objects.department_categories.get.single(departmentId);
if (requestId !== latestCategoriesRequestId.value) {
return;
}
for (let i = 0; i < response.length; i++) {
// Check if the id is already in the categories by id,
// This is needed to prevent duplicate categories for higher privilege users
if (categories.value.find((category) => category.identifier === response[i].category.id) !== undefined) {
if (nextCategories.find((category) => category.identifier === response[i].category.id) !== undefined) {
// The id is already included in the list. Do nothing.
} else {
categories.value.push({
nextCategories.push({
name: response[i].category.name,
identifier: response[i].category.id
});
}
}
});
categories.value = nextCategories;
} catch (error) {
if (requestId !== latestCategoriesRequestId.value) {
return;
}
categories.value = nextCategories;
} finally {
if (requestId === latestCategoriesRequestId.value) {
categoriesLoading.value = false;
}
}
};
// Load the categories
onMounted(() => {
loadCategories();
void loadCategories();
});
@@ -679,16 +726,7 @@ const scrollSelectedIntoCenter = async (isRetry = false) => {
// Watch for changes to the customer number
watch(() => props.customer_number, (newCustomerNumber) => {
// Refetch products for the current category scope
if (getProductsCategory() === null) {
getProducts(department_id.value, !!props.isCustomerBooking, newCustomerNumber).then((response) => {
products.value = response.data.data;
});
} else {
getProductCategory(getProductsCategory(), department_id.value, !!props.isCustomerBooking, newCustomerNumber).then((response) => {
products.value = response.data.data;
});
}
void fetchProductsForCategory({ customerNumber: newCustomerNumber });
});
// When switching from grid to scroll, or when selection changes in scroll mode, ensure visibility
@@ -805,9 +843,19 @@ const filterProductsVisibleOnCustomerBooking = (productsList) => {
}
const filterProductsIsWash = (productsList) => {
const selectedCategory = Number(getProductsCategory());
return productsList.filter((product) => {
if (props.isCustomerBooking) {
return product.is_wash;
if (selectedCategory === 2) {
return true;
}
return (
product?.is_wash === true ||
product?.is_wash === 1 ||
product?.is_wash === "1" ||
product?.is_wash === "true"
);
}
return true;
});
@@ -832,6 +880,20 @@ const orderByOrderPriorityCategories = (productsList) => {
return productsList;
}
const displayedProducts = computed(() => {
return filterProductAddonsVisibleOnCustomerBooking(
filterProductsIsWash(
filterProductsVisibleOnCustomerBooking(
orderByOrderPriority(products.value)
)
)
);
});
const loadingProductPlaceholderCount = computed(() => {
return useCompactCard.value ? 4 : 6;
});
const hasAutoSelectedCategory = ref(false);
watch(() => categories.value, (newCategories) => {
@@ -839,30 +901,34 @@ watch(() => categories.value, (newCategories) => {
if (props.automaticallySelectCategory !== null && !hasAutoSelectedCategory.value) {
const targetId = Number(props.automaticallySelectCategory);
if (newCategories.find((category) => Number(category.identifier) === targetId) !== undefined) {
if (getProductsCategory() !== targetId) {
const currentCategory = normalizeCategoryIdentifier(getProductsCategory());
if (currentCategory !== targetId) {
setProductsCategory(targetId);
} else {
void fetchProductsForCategory({ category: targetId });
}
hasAutoSelectedCategory.value = true;
getProductCategory(targetId, department_id.value, true, props.customer_number)
.then((response) => {
products.value = response.data.data;
});
}
}
});
// Guarded category selection to avoid redundant reactive churn
const onSelectCategory = (rawId) => {
const targetId = normalizeCategoryIdentifier(rawId);
const currentCategory = normalizeCategoryIdentifier(getProductsCategory());
const wasShowingRecommended = isShowingRecommended.value;
hideRecommendedProducts();
// Handle 'null' from mobile <select>
const targetId = rawId === 'null' || rawId === null ? null : Number(rawId);
if (getProductsCategory() !== targetId) {
if (currentCategory !== targetId) {
setProductsCategory(targetId);
return;
}
if (wasShowingRecommended) {
void fetchProductsForCategory({ category: targetId });
}
getProductCategory(targetId, department_id.value, !!props.isCustomerBooking, props.customer_number)
.then((response) => {
products.value = response.data.data;
});
};
</script>
@@ -873,8 +939,18 @@ const onSelectCategory = (rawId) => {
<!-- Categories - Desktop -->
<WhiteBox style="padding-bottom: 0; padding-top: 0.6rem;" class="px-0 is-hidden-touch">
<template #default>
<div
v-if="categoriesLoading"
class="pos-category-loading"
data-testid="pos-product-categories-loading"
>
<span class="pos-category-loading__label">Indlæser kategorier...</span>
<div class="pos-category-loading__chips">
<span v-for="placeholderIndex in 5" :key="`category-loading-${placeholderIndex}`" class="pos-loading-shimmer pos-category-loading__chip"></span>
</div>
</div>
<!-- Tabs -->
<div class="tabs" style="overflow-x: auto;">
<div v-else class="tabs" style="overflow-x: auto;">
<ul>
<li
v-for="category in orderByOrderPriorityCategories(categories)"
@@ -884,6 +960,7 @@ const onSelectCategory = (rawId) => {
}">
<a
@click="onSelectCategory(category.identifier)"
:data-testid="`pos-product-category-tab-${category.identifier ?? 'all'}`"
:style="{ 'border-bottom-color': category.identifier === getProductsCategory() ? '#3273dc' : '#fff' }"
style="
border-bottom-width: 0.6rem;
@@ -902,19 +979,29 @@ const onSelectCategory = (rawId) => {
<template #default>
<div class="select is-fullwidth mb-2">
<select
data-testid="pos-product-category-select"
@change="onSelectCategory($event.target.value)"
:disabled="categoriesLoading"
:value="getProductsCategory() ?? 'null' "
>
<option v-if="categoriesLoading" value="null">Indlæser kategorier...</option>
<template v-else>
<option v-for="category in categories" :key="category.identifier" :value="category.identifier ?? 'null'">
{{ category.name }}
</option>
</template>
</select>
</div>
</template>
</WhiteBox>
</div>
</div>
<div class="tw-scroll-minimal" :class="{ 'tw-scroll-vertical': !isCompactMode, 'tw-scroll-horizontal': isCompactMode }" ref="scrollWrapRef">
<div
class="tw-scroll-minimal"
:class="{ 'tw-scroll-vertical': !isCompactMode, 'tw-scroll-horizontal': isCompactMode }"
:data-testid="productsLoading && !isShowingRecommended ? 'pos-products-loading' : undefined"
ref="scrollWrapRef"
>
<div class="columns is-mobile" :class="{ 'is-multiline': isGridLayout || !isCustomerBooking }">
<!-- Recommended products -->
<div class="column is-12" v-if="isShowingRecommended">
@@ -1017,7 +1104,27 @@ const onSelectCategory = (rawId) => {
</configurationCategory>
</div>
<!-- New product display -->
<template v-for="product in filterProductAddonsVisibleOnCustomerBooking(filterProductsIsWash(filterProductsVisibleOnCustomerBooking(orderByOrderPriority(products))))" :key="product.id" v-if="!isShowingRecommended">
<template v-if="productsLoading && !isShowingRecommended">
<div
v-for="placeholderIndex in loadingProductPlaceholderCount"
:key="`product-loading-${placeholderIndex}`"
class="column"
:class="{ 'is-3-desktop': useCompactCard, 'is-6-desktop': !useCompactCard, 'is-12-mobile': !useCompactCard, 'is-10-mobile': useCompactCard }"
>
<WhiteBox class="p-0 pos-product-box pos-product-box--loading" :class="{ 'is-compact': useCompactCard }">
<div class="pos-product-loading-card">
<span class="pos-loading-shimmer pos-product-loading-card__image"></span>
<div class="pos-product-loading-card__body">
<span class="pos-loading-shimmer pos-product-loading-card__title"></span>
<span class="pos-loading-shimmer pos-product-loading-card__text"></span>
<span class="pos-loading-shimmer pos-product-loading-card__text pos-product-loading-card__text--short"></span>
</div>
</div>
</WhiteBox>
</div>
</template>
<template v-else-if="!isShowingRecommended">
<template v-for="product in displayedProducts" :key="product.id">
<div class="column" :class="{ 'is-3-desktop': useCompactCard, 'is-6-desktop': !useCompactCard, 'is-12-mobile': !useCompactCard, 'is-10-mobile': useCompactCard }">
<WhiteBox
class="p-0 pos-product-box"
@@ -1051,6 +1158,7 @@ const onSelectCategory = (rawId) => {
</WhiteBox>
</div>
</template>
</template>
<!-- Products
<div class="column is-4-desktop" v-for="product in products" :key="product.id" v-if="!isShowingRecommended">
@@ -1114,6 +1222,40 @@ const onSelectCategory = (rawId) => {
</template>
<style scoped>
.pos-loading-shimmer {
background: linear-gradient(90deg, #eef2f7 0%, #dfe7f1 50%, #eef2f7 100%);
background-size: 200% 100%;
animation: pos-loading-shimmer 1.2s ease-in-out infinite;
}
.pos-category-loading {
padding: 0 1rem 0.85rem;
}
.pos-category-loading__label {
display: block;
margin-bottom: 0.7rem;
color: #607085;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pos-category-loading__chips {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.pos-category-loading__chip {
display: inline-block;
width: 7rem;
max-width: 100%;
height: 2.2rem;
border-radius: 999px;
}
/* is-compact and is-selected */
.pos-product-box.is-compact {
cursor: pointer;
@@ -1133,9 +1275,52 @@ const onSelectCategory = (rawId) => {
border: 0.5rem solid transparent;
cursor: pointer;
}
.pos-product-box--loading {
cursor: default;
}
.pos-product-box.is-selected {
border: 0.5rem solid #3273dc;
}
.pos-product-loading-card {
display: flex;
align-items: center;
gap: 1rem;
min-height: 10rem;
padding: 1.25rem;
}
.pos-product-loading-card__image {
display: inline-block;
width: 4.5rem;
height: 4.5rem;
border-radius: 1rem;
flex-shrink: 0;
}
.pos-product-loading-card__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.7rem;
}
.pos-product-loading-card__title {
display: inline-block;
width: 70%;
height: 1.15rem;
border-radius: 999px;
}
.pos-product-loading-card__text {
display: inline-block;
width: 100%;
height: 0.85rem;
border-radius: 999px;
}
.pos-product-loading-card__text--short {
width: 55%;
}
.tw-scroll-vertical {
overflow-y: auto;
overflow-x: hidden;
@@ -1167,5 +1352,14 @@ const onSelectCategory = (rawId) => {
.tw-scroll-horizontal .pos-product-box {
scroll-snap-align: center;
}
@keyframes pos-loading-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed, defineEmits, defineProps, nextTick, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
customer_id,
@@ -13,6 +13,7 @@ import CustomerSearchField from "@/components/search/economic/customerSearchFiel
import { isSearching, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
import { usePosAddCustomerForm } from "@/composables/usePosAddCustomerForm.js";
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
const { t } = useI18n();
@@ -44,6 +45,7 @@ const selectedDropdownItem = ref(-1);
const showSelector = ref(false);
const CUSTOMER_PICKER_MODE_INVOICE = "invoice";
const CUSTOMER_PICKER_MODE_NEW_CUSTOMER = "new_customer";
const CUSTOMER_PICKER_MODE_DRAFT = "draft";
const CUSTOMER_PICKER_MODE_CARD = "card";
@@ -76,18 +78,44 @@ const getResolvedQuickAction = () => {
watch(
[() => customer_id.value, draftTransactionCustomerNumber],
() => {
if (activeQuickAction.value === CUSTOMER_PICKER_MODE_NEW_CUSTOMER && !isCustomerSelected()) {
return;
}
activeQuickAction.value = getResolvedQuickAction();
},
{ immediate: true }
);
const isInvoiceMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_INVOICE);
const isNewCustomerMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_NEW_CUSTOMER);
const isDraftMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_DRAFT);
const isCardMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_CARD);
const isCardPaymentDisabled = computed(
() => props.showCardPaymentButton && !isCardPaymentAvailable.value
);
const isCardPaymentDisabled = computed(() => props.showCardPaymentButton && !isCardPaymentAvailable.value);
const shouldShowSearchInput = computed(() => isInvoiceMode.value && !isCustomerSelected());
const shouldShowNewCustomerForm = computed(() => isNewCustomerMode.value && !isCustomerSelected());
const {
searchQuery: addCustomerSearchQuery,
searchResult: addCustomerSearchResult,
searching: isAddCustomerSearching,
submitting: isAddCustomerSubmitting,
errorMessage: addCustomerErrorMessage,
form: addCustomerForm,
isValidSearchResult: isValidAddCustomerSearchResult,
canCreateCustomer: canCreateNewCustomer,
handleAutofillFieldInput: handleAddCustomerFieldInput,
submitCustomer: submitNewCustomer,
resetForm: resetNewCustomerForm,
} = usePosAddCustomerForm({
onCustomerCreated: async (selectedCustomer) => {
if (!selectedCustomer) {
return;
}
emitChange(selectedCustomer);
},
});
const focusCustomerSearchInput = async () => {
await nextTick();
@@ -124,6 +152,10 @@ const arrowKeyHandler = (event) => {
};
const emitChange = (customer) => {
if (!customer || !customer.customerNumber) {
return;
}
emit("update:customer_id", customer.customerNumber);
emit("onCustomerSelected", customer);
props.onCustomerSelected(customer);
@@ -150,6 +182,16 @@ const onSelectCustomerInvoice = async () => {
await focusCustomerSearchInput();
};
const onSelectNewCustomer = () => {
activeQuickAction.value = CUSTOMER_PICKER_MODE_NEW_CUSTOMER;
selectedDropdownItem.value = -1;
showSelector.value = false;
if (isCustomerSelected()) {
selectCustomer(null);
}
};
const onSelectDraftCustomer = async () => {
if (!hasDraftTransactionCustomer.value) {
return;
@@ -186,6 +228,17 @@ const onClearSelectedCustomer = async () => {
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
await focusCustomerSearchInput();
};
const onCancelNewCustomer = async () => {
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
await focusCustomerSearchInput();
};
watch(activeQuickAction, (nextMode, previousMode) => {
if (previousMode === CUSTOMER_PICKER_MODE_NEW_CUSTOMER && nextMode !== CUSTOMER_PICKER_MODE_NEW_CUSTOMER) {
resetNewCustomerForm();
}
});
</script>
<template>
@@ -204,6 +257,19 @@ const onClearSelectedCustomer = async () => {
</span>
<span>{{ t("pos.customer_picker.select_customer_invoice") }}</span>
</button>
<button
class="button is-light customer-quick-action customer-quick-action--new"
:class="{ 'is-selected': isNewCustomerMode }"
type="button"
data-testid="pos-new-customer-inline-action"
:aria-pressed="isNewCustomerMode ? 'true' : 'false'"
@click="onSelectNewCustomer"
>
<span class="icon is-small customer-quick-action__icon">
<i class="fas fa-user-plus"></i>
</span>
<span>{{ t("pos.customer_picker.select_new_customer") }}</span>
</button>
<button
v-if="hasDraftTransactionCustomer"
class="button is-light customer-quick-action customer-quick-action--draft"
@@ -246,7 +312,10 @@ const onClearSelectedCustomer = async () => {
type="text"
:disabled="isCustomerSelected()"
@keydown="arrowKeyHandler"
@focusout="selectedDropdownItem = -1; lostfocus()"
@focusout="
selectedDropdownItem = -1;
lostfocus();
"
@focusin="showSelector = true"
:tabindex="isCustomerSelected() ? -1 : 0"
autocomplete="off"
@@ -266,7 +335,10 @@ const onClearSelectedCustomer = async () => {
v-for="result in searchCustomerResults"
:key="result.id"
class="dropdown-item customer-drop-down-select customer-picker__result"
@click="selectCustomer(result); emitChange(result)"
@click="
selectCustomer(result);
emitChange(result);
"
:class="{
'is-active': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
'is-drop-down-selected': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
@@ -279,6 +351,138 @@ const onClearSelectedCustomer = async () => {
</div>
</div>
</div>
<div
v-if="shouldShowNewCustomerForm"
class="customer-picker__new-customer"
data-testid="pos-desktop-add-customer-inline-form"
>
<div class="field">
<label class="label"><small>CVR</small></label>
<div class="control has-icons-right" :class="{ 'is-loading': isAddCustomerSearching }">
<input
v-model="addCustomerSearchQuery"
class="input is-searched"
:class="{
'is-danger': addCustomerSearchResult === null || !isValidAddCustomerSearchResult,
'is-success': isValidAddCustomerSearchResult,
}"
type="text"
:placeholder="$t('global.search_cvr')"
:disabled="isAddCustomerSubmitting"
data-testid="pos-desktop-add-customer-search-input"
/>
<span
class="icon is-right"
:class="{
'has-text-danger': !isValidAddCustomerSearchResult && !isAddCustomerSearching,
'has-text-success': isValidAddCustomerSearchResult && !isAddCustomerSearching,
}"
>
<i class="fas fa-check" v-if="isValidAddCustomerSearchResult && !isAddCustomerSearching"></i>
<i
class="fas fa-exclamation-triangle"
v-else-if="!isValidAddCustomerSearchResult && !isAddCustomerSearching"
></i>
<i class="fas fa-search" v-else-if="!isAddCustomerSearching"></i>
</span>
</div>
<p class="help" v-if="isValidAddCustomerSearchResult">{{ addCustomerSearchResult.name }}</p>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.company_phone") }}</small></label
>
<div class="control">
<input
v-model="addCustomerForm.companyPhone"
class="input"
type="text"
:placeholder="$t('admin.pos.company_phone')"
:disabled="addCustomerSearchResult === null || isAddCustomerSubmitting"
:class="{ 'is-danger': addCustomerSearchResult !== null && addCustomerForm.companyPhone === '' }"
data-testid="pos-desktop-add-customer-company-phone"
@input="handleAddCustomerFieldInput('companyPhone')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
>
<div class="control">
<input
v-model="addCustomerForm.invoiceEmail"
class="input"
type="text"
:placeholder="$t('admin.pos.invoice_email')"
:disabled="addCustomerSearchResult === null || isAddCustomerSubmitting"
data-testid="pos-desktop-add-customer-invoice-email"
@input="handleAddCustomerFieldInput('invoiceEmail')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.contact_email") }}</small></label
>
<div class="control">
<input
v-model="addCustomerForm.contactEmail"
class="input"
type="text"
:placeholder="$t('admin.pos.contact_email')"
:disabled="addCustomerSearchResult === null || isAddCustomerSubmitting"
data-testid="pos-desktop-add-customer-contact-email"
@input="handleAddCustomerFieldInput('contactEmail')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.contact_phone") }}</small></label
>
<div class="control">
<input
v-model="addCustomerForm.contactPhone"
class="input"
type="text"
:placeholder="$t('admin.pos.contact_phone')"
:disabled="addCustomerSearchResult === null || isAddCustomerSubmitting"
:class="{ 'is-danger': addCustomerSearchResult !== null && addCustomerForm.contactPhone === '' }"
data-testid="pos-desktop-add-customer-contact-phone"
@input="handleAddCustomerFieldInput('contactPhone')"
/>
</div>
</div>
<div
v-if="addCustomerErrorMessage !== null"
class="notification is-danger"
data-testid="pos-desktop-add-customer-error"
>
{{ addCustomerErrorMessage }}
</div>
<div class="customer-picker__new-customer-actions">
<button
class="button is-success"
type="button"
data-testid="pos-desktop-add-customer-submit"
:class="{ 'is-loading': isAddCustomerSubmitting }"
:disabled="!canCreateNewCustomer"
@click="submitNewCustomer"
>
{{ t("admin.pos.add_customer") }}
</button>
<button
class="button is-light"
type="button"
data-testid="pos-desktop-add-customer-cancel"
:disabled="isAddCustomerSubmitting"
@click="onCancelNewCustomer"
>
{{ t("common.cancel") }}
</button>
</div>
</div>
</div>
<div v-if="isCustomerSelected()" class="field has-addons-right has-addons mt-1">
<p class="control is-expanded">
@@ -388,6 +592,11 @@ const onClearSelectedCustomer = async () => {
color: #1b4f92;
}
.customer-quick-action--new .customer-quick-action__icon {
background: #edf7f0;
color: #1a6a3d;
}
.customer-quick-action--card .customer-quick-action__icon {
background: #e4f7fa;
color: #0f6a7b;
@@ -437,6 +646,24 @@ const onClearSelectedCustomer = async () => {
max-height: min(13rem, 34vh);
}
.customer-picker__new-customer {
background: #f8fbff;
border: 1px solid #d7e3f5;
border-radius: 1rem;
box-shadow: 0 14px 28px rgba(17, 46, 92, 0.08);
padding: 1rem;
}
.customer-picker__new-customer-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.customer-picker__new-customer-actions .button {
min-width: 10rem;
}
.customer-picker__result {
overflow-wrap: anywhere;
white-space: normal;
@@ -15,7 +15,7 @@ export const getScanners = () => {
});
};
export const createScanner = (department_id, name, notes) => {
export const createScanner = (department_id, name, notes, lane_id = undefined) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -23,7 +23,8 @@ export const createScanner = (department_id, name, notes) => {
return axios.post(API_URL + '/numberplatescanners', {
department_id,
name,
notes
notes,
...(lane_id !== undefined ? { lane_id } : {})
}, {
headers: {
Authorization: `Bearer ${token}`
@@ -31,7 +32,7 @@ export const createScanner = (department_id, name, notes) => {
});
};
export const editScanner = (id, department_id, name, notes) => {
export const editScanner = (id, department_id, name, notes, lane_id = undefined) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -40,7 +41,8 @@ export const editScanner = (id, department_id, name, notes) => {
id,
department_id,
name,
notes
notes,
...(lane_id !== undefined ? { lane_id } : {})
}, {
headers: {
Authorization: `Bearer ${token}`
@@ -27,9 +27,29 @@ const validateDepartmentGateConfig = (config) => {
const normalizedType = String(normalizedConfig?.type ?? '').trim().toUpperCase();
if (normalizedType !== 'PHONE_CALL') {
if (normalizedType !== 'RELAY') {
return normalizedConfig;
}
const relayId = String(normalizedConfig?.relay_id ?? '').trim();
if (relayId === '') {
throw new Error("RELAY gates require a 'relay_id' value.");
}
if (normalizedConfig?.pulse_seconds !== undefined && normalizedConfig?.pulse_seconds !== null && normalizedConfig?.pulse_seconds !== '') {
const pulseSeconds = Number(normalizedConfig.pulse_seconds);
if (!Number.isFinite(pulseSeconds) || pulseSeconds <= 0) {
throw new Error("RELAY gates require a positive 'pulse_seconds' value when provided.");
}
}
return {
...normalizedConfig,
type: 'RELAY',
relay_id: relayId,
};
}
const phoneNumber = String(normalizedConfig?.phone_number ?? '').trim();
if (phoneNumber === '') {
throw new Error("PHONE_CALL gates require a 'phone_number' value.");
+63
View File
@@ -31,6 +31,62 @@ export const orderProducts = (products) => {
return products;
}
const BOOKING_EXTERIOR_CATEGORY_ID = 6;
const BOOKING_INTERIOR_CATEGORY_ID = 2;
const normalizeCategoryId = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(parsed) ? parsed : null;
};
const hasWashFlag = (product) => {
return (
product?.is_wash === true ||
product?.is_wash === 1 ||
product?.is_wash === "1" ||
product?.is_wash === "true"
);
};
const isInteriorBookingProduct = (product) => {
const productCategory = normalizeCategoryId(product?.category);
const productName = String(product?.name || "");
return productCategory === BOOKING_INTERIOR_CATEGORY_ID || /indvendig/i.test(productName);
};
const isExteriorBookingProduct = (product) => {
const productCategory = normalizeCategoryId(product?.category);
return productCategory === BOOKING_EXTERIOR_CATEGORY_ID || (hasWashFlag(product) && productCategory === 4);
};
const matchesBookingPseudoCategory = (product, category) => {
const normalizedCategory = normalizeCategoryId(category);
if (normalizedCategory === BOOKING_EXTERIOR_CATEGORY_ID) {
return isExteriorBookingProduct(product);
}
if (normalizedCategory === BOOKING_INTERIOR_CATEGORY_ID) {
return isInteriorBookingProduct(product);
}
return normalizeCategoryId(product?.category) === normalizedCategory;
};
const buildPseudoCategoryResponse = (response, category) => {
const filteredProducts = orderProducts(
[...(response?.data?.data || [])].filter((product) => matchesBookingPseudoCategory(product, category))
);
return {
...response,
data: {
...(response?.data || {}),
data: filteredProducts
}
};
};
export const getProductCategory = (category, departmentId = null, final_price = true, customer_number = null) => {
const token = localStorage.getItem('token');
if (!token) {
@@ -44,6 +100,13 @@ export const getProductCategory = (category, departmentId = null, final_price =
}
});
}
const normalizedCategory = normalizeCategoryId(category);
if (normalizedCategory === BOOKING_EXTERIOR_CATEGORY_ID || normalizedCategory === BOOKING_INTERIOR_CATEGORY_ID) {
return getProducts(departmentId, final_price, customer_number)
.then((response) => buildPseudoCategoryResponse(response, normalizedCategory));
}
return axios.get(API_URL + '/products?category=' + category + (departmentId ? '&department_id=' + departmentId : '') + (final_price ? '&final_price=true' : '') + (customer_number ? '&customer_id=' + customer_number : ''), {
headers: {
Authorization: `Bearer ${token}`
+211
View File
@@ -0,0 +1,211 @@
import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import {
applyPosAddCustomerSearchResult,
createPosAddCustomerFields,
createPosAddCustomerManualOverrides,
createPosAddCustomerRequestState,
isValidPosAddCustomerSearchResult,
nextPosAddCustomerRequestState,
resolvePosAddCustomerRequestState,
setPosAddCustomerManualOverride,
} from "@/components/displays/department/pos/steps/mobile/elements/posAddCustomerAutofill.js";
const SEARCH_DEBOUNCE_MS = 300;
export function usePosAddCustomerForm(options = {}) {
const { onCustomerCreated = null } = options;
const { t } = useI18n();
const searchQuery = ref("");
const searchResult = ref(null);
const searching = ref(false);
const submitting = ref(false);
const errorMessage = ref(null);
const requestState = ref(createPosAddCustomerRequestState());
const manualOverrides = ref(createPosAddCustomerManualOverrides());
const form = reactive(createPosAddCustomerFields());
let searchDebounceTimer = null;
const isValidSearchResult = computed(() =>
isValidPosAddCustomerSearchResult(searchResult.value, t("admin.pos.not_found"))
);
const canCreateCustomer = computed(() => {
const requiredFields = [form.cvr, form.companyPhone, form.contactPhone];
return (
!submitting.value && isValidSearchResult.value && requiredFields.every((field) => String(field).trim() !== "")
);
});
const clearQueuedSearch = () => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = null;
}
};
const applySearchResult = (nextSearchResult) => {
searchResult.value = nextSearchResult;
if (!isValidPosAddCustomerSearchResult(nextSearchResult, t("admin.pos.not_found"))) {
return;
}
Object.assign(
form,
applyPosAddCustomerSearchResult({
fields: form,
manualOverrides: manualOverrides.value,
searchResult: nextSearchResult,
notFoundLabel: t("admin.pos.not_found"),
})
);
};
const runSearch = async (query) => {
const normalizedQuery = String(query ?? "").trim();
if (normalizedQuery === "") {
searchResult.value = null;
searching.value = false;
return;
}
const nextRequest = nextPosAddCustomerRequestState(requestState.value);
requestState.value = nextRequest.state;
try {
const response = await SessionUser.request("/cvr/search", "GET", {
query: normalizedQuery,
});
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
if (!resolution.shouldApply) {
return;
}
requestState.value = resolution.state;
applySearchResult(response?.data?.data ?? null);
} catch (error) {
console.error(error);
const resolution = resolvePosAddCustomerRequestState(requestState.value, nextRequest.requestId);
if (!resolution.shouldApply) {
return;
}
requestState.value = resolution.state;
searchResult.value = null;
} finally {
if (nextRequest.requestId === requestState.value.latestRequestedId) {
searching.value = false;
}
}
};
const queueSearch = (query) => {
clearQueuedSearch();
errorMessage.value = null;
const normalizedQuery = String(query ?? "").trim();
if (normalizedQuery === "") {
searchResult.value = null;
searching.value = false;
return;
}
searching.value = true;
searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null;
runSearch(normalizedQuery);
}, SEARCH_DEBOUNCE_MS);
};
const handleAutofillFieldInput = (field) => {
manualOverrides.value = setPosAddCustomerManualOverride(manualOverrides.value, field, form[field]);
errorMessage.value = null;
};
const resetForm = () => {
clearQueuedSearch();
searchQuery.value = "";
searchResult.value = null;
searching.value = false;
submitting.value = false;
errorMessage.value = null;
requestState.value = createPosAddCustomerRequestState();
manualOverrides.value = createPosAddCustomerManualOverrides();
Object.assign(form, createPosAddCustomerFields());
};
const submitCustomer = async () => {
if (!canCreateCustomer.value) {
return null;
}
submitting.value = true;
errorMessage.value = null;
try {
await SessionUser.request("/auth/register/cvr", "POST", {
cvr: form.cvr,
companyPhone: Number.parseInt(form.companyPhone, 10),
invoiceEmail: form.invoiceEmail,
contactEmail: form.contactEmail,
contactPhone: Number.parseInt(form.contactPhone, 10),
searchResult: searchResult.value,
});
const selectedCustomer = await searchAndSelectCustomer(form.companyPhone);
const createdCustomerNumber = Number.parseInt(form.companyPhone, 10);
if (typeof onCustomerCreated === "function") {
await onCustomerCreated(selectedCustomer, createdCustomerNumber);
}
resetForm();
return selectedCustomer;
} catch (error) {
console.error(error);
const errorResponse =
typeof SessionUser?.functions?.parseErrorMessage === "function"
? SessionUser.functions.parseErrorMessage(error)
: null;
errorMessage.value =
errorResponse !== null
? t("admin.pos.customer_creation_error", { error: errorResponse })
: t("admin.pos.customer_creation_error_generic");
return null;
} finally {
submitting.value = false;
}
};
watch(searchQuery, queueSearch);
onBeforeUnmount(() => {
clearQueuedSearch();
});
return {
searchQuery,
searchResult,
searching,
submitting,
errorMessage,
requestState,
manualOverrides,
form,
isValidSearchResult,
canCreateCustomer,
handleAutofillFieldInput,
submitCustomer,
resetForm,
};
}
@@ -0,0 +1,789 @@
<script setup>
import { computed, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
import {
getEdgeGatewayDepartmentWorkspace,
rotateNumberPlateScannerKey,
updateNumberPlateScanner,
unwrapEdgeGatewayResponse,
} from "@/services/edgeGateways.js";
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
const props = defineProps({
departmentId: {
type: Number,
required: true,
},
});
const route = useRoute();
const router = useRouter();
const state = reactive({
loading: false,
error: null,
notice: null,
workspace: null,
scannerLaneDrafts: {},
scannerBusy: {},
rotatedKeys: {},
});
const tabs = [
{ id: "overview", label: "Overview" },
{ id: "lanes", label: "Lanes & Self-Serve" },
{ id: "gates", label: "Gates" },
{ id: "scanners", label: "Scanners" },
{ id: "gateways", label: "Gateways" },
{ id: "issues", label: "Issues" },
];
const activeTab = computed(() => {
const queryTab = String(route.query.tab || "overview");
return tabs.some((tab) => tab.id === queryTab) ? queryTab : "overview";
});
const department = computed(() => state.workspace?.department || null);
const summary = computed(() => state.workspace?.summary || {});
const lanes = computed(() => state.workspace?.lanes || []);
const selfServe = computed(() => state.workspace?.self_serve || {});
const gates = computed(() => state.workspace?.gates || []);
const scanners = computed(() => state.workspace?.scanners || []);
const issues = computed(() => state.workspace?.issues || []);
const actions = computed(() => state.workspace?.actions || []);
const loadWorkspace = async ({ resetTransient = true } = {}) => {
if (!props.departmentId) {
return;
}
state.loading = true;
state.error = null;
if (resetTransient) {
state.notice = null;
state.rotatedKeys = {};
}
try {
const response = await getEdgeGatewayDepartmentWorkspace(props.departmentId);
state.workspace = unwrapEdgeGatewayResponse(response, null);
state.scannerLaneDrafts = Object.fromEntries(
(state.workspace?.scanners || []).map((scanner) => [scanner.id, scanner.lane_id ?? ""])
);
} catch (requestError) {
state.error = normalizeEdgeGatewayError(requestError);
} finally {
state.loading = false;
}
};
watch(
() => props.departmentId,
() => {
void loadWorkspace();
},
{ immediate: true }
);
const setTab = async (tabId) => {
await router.replace({
query: {
...route.query,
tab: tabId,
},
});
};
const openPath = async (path) => {
if (!path) {
return;
}
if (/^https?:\/\//i.test(path)) {
window.location.href = path;
return;
}
await router.push(path);
};
const openGatewayPage = async (gatewayId) => {
if (!gatewayId) {
return;
}
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
};
const openPrimaryGatewayInventory = async () => {
const gatewayId = summary.value?.primary_gateway?.id || null;
if (!gatewayId) {
await router.push("/superuser/configuration/edgegateway");
return;
}
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/inventory`);
};
const saveScannerLane = async (scanner) => {
state.scannerBusy[scanner.id] = true;
state.notice = null;
state.error = null;
try {
await updateNumberPlateScanner(scanner.id, {
department_id: scanner.department_id,
name: scanner.name,
notes: scanner.notes,
lane_id:
state.scannerLaneDrafts[scanner.id] === "" || state.scannerLaneDrafts[scanner.id] === null
? null
: Number(state.scannerLaneDrafts[scanner.id]),
});
state.notice = {
kind: "success",
message: `Scanner ${scanner.name} lane assignment updated.`,
};
await loadWorkspace({ resetTransient: false });
await setTab("scanners");
} catch (requestError) {
state.error = normalizeEdgeGatewayError(requestError);
} finally {
state.scannerBusy[scanner.id] = false;
}
};
const rotateScannerKey = async (scanner) => {
state.scannerBusy[scanner.id] = true;
state.notice = null;
state.error = null;
try {
const response = await rotateNumberPlateScannerKey(scanner.id);
const payload = unwrapEdgeGatewayResponse(response, {});
const rotatedKey = payload?.api_key || payload?.scanner?.api_key || null;
state.notice = {
kind: "success",
message: `Scanner ${scanner.name} API key rotated.`,
};
await loadWorkspace({ resetTransient: false });
state.rotatedKeys[scanner.id] = rotatedKey;
await setTab("scanners");
} catch (requestError) {
state.error = normalizeEdgeGatewayError(requestError);
} finally {
state.scannerBusy[scanner.id] = false;
}
};
</script>
<template>
<section class="department-hardware-workspace" data-testid="department-hardware-workspace">
<header class="department-hardware-workspace__header">
<div>
<p class="department-hardware-workspace__eyebrow">Integrated hardware workspace</p>
<h2>{{ department?.name || `Department ${departmentId}` }}</h2>
<p>Gateways, lanes, self-serve readiness, gates, scanners, and setup gaps in one department view.</p>
</div>
<div class="department-hardware-workspace__header-actions">
<button
class="button is-light"
type="button"
data-testid="department-hardware-refresh"
:disabled="state.loading"
@click="loadWorkspace"
>
Refresh
</button>
<button
class="button is-dark"
type="button"
data-testid="department-hardware-open-fleet"
@click="openPath('/superuser/configuration/edgegateway')"
>
Open Fleet Landing
</button>
</div>
</header>
<div v-if="state.error" class="notification is-warning" data-testid="department-hardware-error">
<strong>{{ state.error.title }}</strong>
<p>{{ state.error.message }}</p>
</div>
<div
v-if="state.notice"
class="notification"
:class="state.notice.kind === 'success' ? 'is-success' : 'is-info'"
data-testid="department-hardware-notice"
>
{{ state.notice.message }}
</div>
<div
v-if="state.loading && !state.workspace"
class="department-hardware-workspace__empty"
data-testid="department-hardware-loading"
>
Loading hardware workspace...
</div>
<template v-else-if="state.workspace">
<div class="department-hardware-workspace__hero">
<article class="department-hardware-workspace__metric" data-testid="department-hardware-primary-gateway">
<span>Primary gateway</span>
<strong>{{ summary.primary_gateway?.label || "Missing" }}</strong>
<small>{{ summary.primary_gateway?.status || "Not configured" }}</small>
</article>
<article class="department-hardware-workspace__metric" data-testid="department-hardware-transport-mode">
<span>Transport mode</span>
<strong>{{ summary.transport_mode }}</strong>
<small>{{ summary.online_gateway_count }}/{{ summary.gateway_count }} gateways online</small>
</article>
<article class="department-hardware-workspace__metric" data-testid="department-hardware-binding-coverage">
<span>Binding coverage</span>
<strong>{{ summary.bound_relay_count }}/{{ summary.required_relay_count }}</strong>
<small>{{ summary.missing_binding_count }} missing</small>
</article>
<article class="department-hardware-workspace__metric" data-testid="department-hardware-selfserve">
<span>Self-serve</span>
<strong>{{ selfServe.readiness_state }}</strong>
<small>{{ selfServe.ready_lanes }}/{{ selfServe.lane_count }} lanes ready</small>
</article>
<article class="department-hardware-workspace__metric" data-testid="department-hardware-scanners">
<span>Scanners</span>
<strong>{{ summary.assigned_scanner_count }}/{{ summary.scanner_count }}</strong>
<small>assigned to default lanes</small>
</article>
<article class="department-hardware-workspace__metric" data-testid="department-hardware-issues">
<span>Issues</span>
<strong>{{ summary.issue_count }}</strong>
<small>{{ summary.health }}</small>
</article>
</div>
<nav class="department-hardware-workspace__tabs" data-testid="department-hardware-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="department-hardware-workspace__tab"
:class="{ 'is-active': activeTab === tab.id }"
type="button"
:data-testid="`department-hardware-tab-${tab.id}`"
@click="setTab(tab.id)"
>
{{ tab.label }}
</button>
</nav>
<section
v-if="activeTab === 'overview'"
class="department-hardware-workspace__panel"
data-testid="department-hardware-panel-overview"
>
<div class="department-hardware-workspace__overview-grid">
<article class="department-hardware-workspace__card">
<h3>Prioritized issues</h3>
<div v-if="issues.length === 0" class="department-hardware-workspace__empty">No current workspace issues.</div>
<ul v-else class="department-hardware-workspace__list">
<li v-for="issue in issues.slice(0, 5)" :key="`${issue.code}-${issue.target_id || 'global'}`">
<strong>{{ issue.severity }}</strong> {{ issue.message }}
</li>
</ul>
</article>
<article class="department-hardware-workspace__card">
<h3>Suggested actions</h3>
<div v-if="actions.length === 0" class="department-hardware-workspace__empty">No suggested follow-up actions.</div>
<div v-else class="department-hardware-workspace__button-list">
<button
v-for="action in actions"
:key="action.code"
class="button is-light"
type="button"
@click="openPath(action.path)"
>
{{ action.label }}
</button>
</div>
</article>
<article class="department-hardware-workspace__card">
<h3>Gate transport mix</h3>
<p>{{ summary.gate_transport_mix?.relay || 0 }} relay-backed gates</p>
<p>{{ summary.gate_transport_mix?.phone_call || 0 }} phone-call gates</p>
</article>
<article class="department-hardware-workspace__card">
<h3>Recent scanner activity</h3>
<div v-if="!summary.recent_scan_at" class="department-hardware-workspace__empty">No recent license plate scans recorded.</div>
<p v-else>{{ summary.recent_scan_at }}</p>
</article>
</div>
</section>
<section
v-else-if="activeTab === 'lanes'"
class="department-hardware-workspace__panel"
data-testid="department-hardware-panel-lanes"
>
<div class="department-hardware-workspace__panel-header">
<div>
<h3>Lanes and self-serve</h3>
<p>{{ selfServe.configured_task_count }} configured tasks across {{ selfServe.configured_product_count }} products.</p>
</div>
<div class="department-hardware-workspace__button-list">
<button
class="button is-light"
type="button"
data-testid="department-hardware-open-selfserve-studio"
@click="openPath(selfServe.links?.studio)"
>
Open Self-Serve Studio
</button>
<button
class="button is-light"
type="button"
data-testid="department-hardware-open-binding-inventory"
@click="openPrimaryGatewayInventory"
>
Open Binding Inventory
</button>
</div>
</div>
<article v-for="lane in lanes" :key="lane.id" class="department-hardware-workspace__row" :data-testid="`department-lane-${lane.id}`">
<div class="department-hardware-workspace__row-header">
<div>
<strong>{{ lane.name }}</strong>
<p>Status: {{ lane.status }} - Machine type: {{ lane.machine_type_id || "Unassigned" }}</p>
</div>
<span class="department-hardware-workspace__badge" :data-state="lane.binding_coverage.state">
{{ lane.binding_coverage.state }}
</span>
</div>
<p>Products: {{ lane.self_serve_products.length ? lane.self_serve_products.join(", ") : "No products configured" }}</p>
<div class="department-hardware-workspace__list-grid">
<div
v-for="slot in lane.relay_slots"
:key="`${lane.id}-${slot.slot}`"
class="department-hardware-workspace__detail"
>
<strong>{{ slot.slot }}</strong>
<span>{{ slot.relay_id }}</span>
<small>{{ slot.coverage.covered ? slot.coverage.primary_binding?.gateway_label || "Bound" : "Missing binding" }}</small>
</div>
</div>
<div class="department-hardware-workspace__button-list">
<button
class="button is-light is-small"
type="button"
:data-testid="`department-lane-open-studio-${lane.id}`"
@click="openPath(lane.links?.self_serve_studio)"
>
Open Studio
</button>
<button
class="button is-light is-small"
type="button"
:data-testid="`department-lane-open-legacy-${lane.id}`"
@click="openPath(lane.links?.legacy)"
>
Open Legacy Lane
</button>
</div>
</article>
</section>
<section
v-else-if="activeTab === 'gates'"
class="department-hardware-workspace__panel"
data-testid="department-hardware-panel-gates"
>
<div class="department-hardware-workspace__panel-header">
<div>
<h3>Gates</h3>
<p>Phone-call and relay-backed gates share the same department transport context.</p>
</div>
<button
class="button is-light"
type="button"
data-testid="department-hardware-open-legacy-gates"
@click="openPath('/superuser/department/gates')"
>
Open Legacy Gates
</button>
</div>
<article v-for="gate in gates" :key="gate.id" class="department-hardware-workspace__row" :data-testid="`department-gate-${gate.id}`">
<div class="department-hardware-workspace__row-header">
<div>
<strong>{{ gate.name }}</strong>
<p>
{{ gate.transport_type }}
<span v-if="gate.is_entrance"> - entrance</span>
<span v-if="gate.is_exit"> - exit</span>
</p>
</div>
<span class="department-hardware-workspace__badge" :data-state="gate.config_complete ? 'READY' : 'MISSING'">
{{ gate.config_complete ? "Configured" : "Incomplete" }}
</span>
</div>
<p v-if="gate.transport_type === 'PHONE_CALL'">
{{ gate.config.phone_number || "Missing phone number" }} - threshold {{ gate.config.call_duration_threshold ?? "n/a" }}
</p>
<p v-else>
Relay {{ gate.relay?.relay_id || gate.config.relay_id || "Missing" }} -
{{ gate.coverage?.covered ? gate.coverage.primary_binding?.gateway_label || "Bound" : "Missing binding" }}
</p>
</article>
</section>
<section
v-else-if="activeTab === 'scanners'"
class="department-hardware-workspace__panel"
data-testid="department-hardware-panel-scanners"
>
<div class="department-hardware-workspace__panel-header">
<div>
<h3>License plate scanners</h3>
<p>Assign each scanner to a default lane and rotate credentials without leaving the workspace.</p>
</div>
<button
class="button is-light"
type="button"
data-testid="department-hardware-open-legacy-scanners"
@click="openPath('/superuser/scanners')"
>
Open Legacy Scanners
</button>
</div>
<article v-for="scanner in scanners" :key="scanner.id" class="department-hardware-workspace__row" :data-testid="`department-scanner-${scanner.id}`">
<div class="department-hardware-workspace__row-header">
<div>
<strong>{{ scanner.name }}</strong>
<p>{{ scanner.notes || "No notes" }}</p>
</div>
<span class="department-hardware-workspace__badge" :data-state="scanner.assignment_state">
{{ scanner.assignment_state }}
</span>
</div>
<div class="department-hardware-workspace__scanner-grid">
<label class="department-hardware-workspace__field">
<span>Default lane</span>
<div class="select is-fullwidth">
<select v-model="state.scannerLaneDrafts[scanner.id]" :data-testid="`department-scanner-lane-${scanner.id}`">
<option value="">Unassigned</option>
<option v-for="lane in lanes" :key="lane.id" :value="lane.id">{{ lane.name }}</option>
</select>
</div>
</label>
<div class="department-hardware-workspace__detail">
<strong>Recent scan</strong>
<span>{{ scanner.recent_scan_at || "No recent scans" }}</span>
</div>
<div class="department-hardware-workspace__detail">
<strong>Lane coverage</strong>
<span>{{ scanner.assigned_lane?.binding_coverage?.state || "Unassigned" }}</span>
</div>
</div>
<div class="department-hardware-workspace__button-list">
<button
class="button is-dark is-small"
type="button"
:data-testid="`department-scanner-save-${scanner.id}`"
:disabled="state.scannerBusy[scanner.id]"
@click="saveScannerLane(scanner)"
>
Save Lane
</button>
<button
class="button is-light is-small"
type="button"
:data-testid="`department-scanner-rotate-${scanner.id}`"
:disabled="state.scannerBusy[scanner.id]"
@click="rotateScannerKey(scanner)"
>
Rotate API Key
</button>
</div>
<pre
v-if="state.rotatedKeys[scanner.id]"
class="department-hardware-workspace__secret"
:data-testid="`department-scanner-key-${scanner.id}`"
>{{ 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>
</ul>
</article>
</section>
<section
v-else-if="activeTab === 'gateways'"
class="department-hardware-workspace__panel"
data-testid="department-hardware-panel-gateways"
>
<EdgeGatewayManager
:department-id="departmentId"
:route-driven="false"
:allow-destructive="false"
@open-gateway-page="openGatewayPage"
/>
</section>
<section v-else class="department-hardware-workspace__panel" data-testid="department-hardware-panel-issues">
<div class="department-hardware-workspace__overview-grid">
<article class="department-hardware-workspace__card">
<h3>Issues</h3>
<div v-if="issues.length === 0" class="department-hardware-workspace__empty">No issues detected.</div>
<ul v-else class="department-hardware-workspace__list">
<li v-for="issue in issues" :key="`${issue.code}-${issue.target_id || 'global'}`">
<strong>{{ issue.severity }}</strong> {{ issue.message }}
</li>
</ul>
</article>
<article class="department-hardware-workspace__card">
<h3>Actions</h3>
<div class="department-hardware-workspace__button-list">
<button
v-for="action in actions"
:key="action.code"
class="button is-light"
type="button"
@click="openPath(action.path)"
>
{{ action.label }}
</button>
</div>
</article>
</div>
</section>
</template>
</section>
</template>
<style scoped>
.department-hardware-workspace {
display: grid;
gap: 1rem;
}
.department-hardware-workspace__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.department-hardware-workspace__header h2 {
margin: 0 0 0.35rem;
color: #0f172a;
}
.department-hardware-workspace__header p {
margin: 0;
color: #475569;
}
.department-hardware-workspace__eyebrow {
margin: 0 0 0.35rem;
color: #7c2d12;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.department-hardware-workspace__header-actions,
.department-hardware-workspace__button-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.department-hardware-workspace__hero {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.department-hardware-workspace__metric,
.department-hardware-workspace__card,
.department-hardware-workspace__panel,
.department-hardware-workspace__row {
border-radius: 22px;
background: #ffffff;
border: 1px solid #dbe4ea;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.07);
}
.department-hardware-workspace__metric {
padding: 1rem;
display: grid;
gap: 0.25rem;
}
.department-hardware-workspace__metric span,
.department-hardware-workspace__metric small {
color: #64748b;
}
.department-hardware-workspace__metric strong {
color: #0f172a;
font-size: 1.2rem;
}
.department-hardware-workspace__tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.department-hardware-workspace__tab {
border: 0;
border-radius: 999px;
padding: 0.7rem 1rem;
background: #e2e8f0;
color: #334155;
font-weight: 700;
}
.department-hardware-workspace__tab.is-active {
background: #0f172a;
color: #ffffff;
}
.department-hardware-workspace__panel {
padding: 1rem;
}
.department-hardware-workspace__overview-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.department-hardware-workspace__card,
.department-hardware-workspace__row {
padding: 1rem;
}
.department-hardware-workspace__card h3,
.department-hardware-workspace__panel-header h3 {
margin: 0 0 0.35rem;
color: #0f172a;
}
.department-hardware-workspace__card p,
.department-hardware-workspace__panel-header p {
margin: 0;
color: #475569;
}
.department-hardware-workspace__panel-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.department-hardware-workspace__row {
display: grid;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.department-hardware-workspace__row:last-child {
margin-bottom: 0;
}
.department-hardware-workspace__row-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.department-hardware-workspace__row-header p {
margin: 0.2rem 0 0;
color: #64748b;
}
.department-hardware-workspace__badge {
border-radius: 999px;
padding: 0.3rem 0.75rem;
background: #e2e8f0;
color: #334155;
font-size: 0.78rem;
font-weight: 700;
}
.department-hardware-workspace__badge[data-state="READY"],
.department-hardware-workspace__badge[data-state="Configured"] {
background: #dcfce7;
color: #166534;
}
.department-hardware-workspace__badge[data-state="PARTIAL"],
.department-hardware-workspace__badge[data-state="MISSING"] {
background: #fef3c7;
color: #92400e;
}
.department-hardware-workspace__badge[data-state="UNASSIGNED"],
.department-hardware-workspace__badge[data-state="INVALID"] {
background: #fee2e2;
color: #991b1b;
}
.department-hardware-workspace__list-grid,
.department-hardware-workspace__scanner-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.department-hardware-workspace__detail,
.department-hardware-workspace__field {
display: grid;
gap: 0.25rem;
}
.department-hardware-workspace__detail span,
.department-hardware-workspace__detail small,
.department-hardware-workspace__field span {
color: #64748b;
}
.department-hardware-workspace__list {
margin: 0;
padding-left: 1.1rem;
color: #334155;
}
.department-hardware-workspace__secret {
margin: 0;
border-radius: 14px;
padding: 0.75rem;
background: #0f172a;
color: #f8fafc;
overflow-x: auto;
}
.department-hardware-workspace__empty {
border-radius: 16px;
border: 1px dashed #cbd5e1;
padding: 1rem;
color: #64748b;
}
@media (max-width: 768px) {
.department-hardware-workspace__header,
.department-hardware-workspace__panel-header,
.department-hardware-workspace__row-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -0,0 +1,329 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import {
listEdgeGatewayDepartmentWorkspaces,
unwrapEdgeGatewayResponse,
} from "@/services/edgeGateways.js";
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
const router = useRouter();
const loading = ref(true);
const error = ref(null);
const summaries = ref([]);
const totals = computed(() => {
const rows = Array.isArray(summaries.value) ? summaries.value : [];
return rows.reduce(
(accumulator, summary) => {
accumulator.departments += 1;
accumulator.gateways += Number(summary?.gateway_count || 0);
accumulator.onlineGateways += Number(summary?.online_gateway_count || 0);
accumulator.missingBindings += Number(summary?.missing_binding_count || 0);
accumulator.scanners += Number(summary?.scanner_count || 0);
accumulator.assignedScanners += Number(summary?.assigned_scanner_count || 0);
accumulator.selfServeReady += Number(summary?.self_serve_ready_lanes || 0);
accumulator.selfServeLanes += Number(summary?.lane_count || 0);
return accumulator;
},
{
departments: 0,
gateways: 0,
onlineGateways: 0,
missingBindings: 0,
scanners: 0,
assignedScanners: 0,
selfServeReady: 0,
selfServeLanes: 0,
}
);
});
const loadSummaries = async () => {
loading.value = true;
error.value = null;
try {
const response = await listEdgeGatewayDepartmentWorkspaces();
summaries.value = unwrapEdgeGatewayResponse(response, []);
} catch (requestError) {
error.value = normalizeEdgeGatewayError(requestError);
} finally {
loading.value = false;
}
};
const openDepartmentWorkspace = async (departmentId) => {
await router.push(`/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways`);
};
const openPrimaryGateway = async (gatewayId) => {
if (!gatewayId) {
return;
}
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
};
onMounted(loadSummaries);
</script>
<template>
<section class="hardware-fleet-landing" data-testid="hardware-fleet-landing">
<header class="hardware-fleet-landing__header">
<div>
<p class="hardware-fleet-landing__eyebrow">Department hardware</p>
<h2>Fleet landing</h2>
<p>Coverage, setup gaps, and maintenance signals across gateways, lanes, gates, self-serve, and scanners.</p>
</div>
<button class="button is-light" type="button" :disabled="loading" @click="loadSummaries">Refresh</button>
</header>
<div v-if="error" class="notification is-warning" data-testid="hardware-fleet-error">
<strong>{{ error.title }}</strong>
<p>{{ error.message }}</p>
</div>
<div class="hardware-fleet-landing__metrics">
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-departments">
<span>Departments</span>
<strong>{{ totals.departments }}</strong>
<small>{{ totals.gateways }} gateways</small>
</article>
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-health">
<span>Gateway Health</span>
<strong>{{ totals.onlineGateways }}/{{ totals.gateways }}</strong>
<small>online now</small>
</article>
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-bindings">
<span>Binding Gaps</span>
<strong>{{ totals.missingBindings }}</strong>
<small>missing relay mappings</small>
</article>
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-scanners">
<span>Scanners</span>
<strong>{{ totals.assignedScanners }}/{{ totals.scanners }}</strong>
<small>assigned to lanes</small>
</article>
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-selfserve">
<span>Self-Serve</span>
<strong>{{ totals.selfServeReady }}/{{ totals.selfServeLanes }}</strong>
<small>lanes ready</small>
</article>
</div>
<div v-if="loading" class="hardware-fleet-landing__empty" data-testid="hardware-fleet-loading">Loading department hardware state...</div>
<div
v-else-if="summaries.length === 0"
class="hardware-fleet-landing__empty"
data-testid="hardware-fleet-empty"
>
No department hardware workspaces were returned.
</div>
<div v-else class="hardware-fleet-landing__list">
<article
v-for="summary in summaries"
:key="summary.department_id"
class="hardware-fleet-landing__row"
:data-testid="`hardware-fleet-department-${summary.department_id}`"
>
<div class="hardware-fleet-landing__summary">
<div>
<p class="hardware-fleet-landing__department">{{ summary.department_name }}</p>
<p class="hardware-fleet-landing__meta">
{{ summary.transport_mode }} mode
<span></span>
{{ summary.gateway_count }} gateway<span v-if="summary.gateway_count !== 1">s</span>
<span></span>
{{ summary.issue_count }} issue<span v-if="summary.issue_count !== 1">s</span>
</p>
</div>
<span class="hardware-fleet-landing__health" :data-state="summary.health">{{ summary.health }}</span>
</div>
<div class="hardware-fleet-landing__stats">
<span>{{ summary.online_gateway_count }}/{{ summary.gateway_count }} online</span>
<span>{{ summary.bound_relay_count }}/{{ summary.required_relay_count }} relays bound</span>
<span>{{ summary.assigned_scanner_count }}/{{ summary.scanner_count }} scanners assigned</span>
<span>{{ summary.self_serve_ready_lanes }}/{{ summary.lane_count }} lanes ready</span>
</div>
<div class="hardware-fleet-landing__actions">
<button
class="button is-dark is-small"
type="button"
:data-testid="`hardware-fleet-open-workspace-${summary.department_id}`"
@click="openDepartmentWorkspace(summary.department_id)"
>
Open Workspace
</button>
<button
v-if="summary.primary_gateway?.id"
class="button is-light is-small"
type="button"
:data-testid="`hardware-fleet-open-primary-${summary.department_id}`"
@click="openPrimaryGateway(summary.primary_gateway.id)"
>
Open Primary Gateway
</button>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.hardware-fleet-landing {
border: 1px solid #dbe4ea;
border-radius: 24px;
background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
padding: 1.25rem;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
.hardware-fleet-landing__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.hardware-fleet-landing__header h2 {
margin: 0 0 0.35rem;
color: #0f172a;
}
.hardware-fleet-landing__header p {
margin: 0;
color: #475569;
}
.hardware-fleet-landing__eyebrow {
margin: 0 0 0.35rem;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7c2d12;
}
.hardware-fleet-landing__metrics {
margin-top: 1rem;
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.hardware-fleet-landing__metric {
border-radius: 18px;
background: #ffffff;
border: 1px solid #e2e8f0;
padding: 0.9rem;
display: grid;
gap: 0.2rem;
}
.hardware-fleet-landing__metric span,
.hardware-fleet-landing__metric small {
color: #64748b;
}
.hardware-fleet-landing__metric strong {
font-size: 1.45rem;
color: #0f172a;
}
.hardware-fleet-landing__list {
margin-top: 1rem;
display: grid;
gap: 0.75rem;
}
.hardware-fleet-landing__row {
border-radius: 18px;
border: 1px solid #e2e8f0;
background: #ffffff;
padding: 1rem;
display: grid;
gap: 0.75rem;
}
.hardware-fleet-landing__summary {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.hardware-fleet-landing__department {
margin: 0;
font-weight: 700;
color: #0f172a;
}
.hardware-fleet-landing__meta {
margin: 0.2rem 0 0;
color: #64748b;
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
align-items: center;
}
.hardware-fleet-landing__health {
border-radius: 999px;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
font-weight: 700;
background: #e2e8f0;
color: #334155;
}
.hardware-fleet-landing__health[data-state="READY"] {
background: #dcfce7;
color: #166534;
}
.hardware-fleet-landing__health[data-state="PARTIAL"] {
background: #fef3c7;
color: #92400e;
}
.hardware-fleet-landing__health[data-state="AT_RISK"] {
background: #fee2e2;
color: #991b1b;
}
.hardware-fleet-landing__stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
color: #475569;
}
.hardware-fleet-landing__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.hardware-fleet-landing__empty {
margin-top: 1rem;
border-radius: 18px;
border: 1px dashed #cbd5e1;
padding: 1rem;
color: #64748b;
background: rgba(255, 255, 255, 0.75);
}
@media (max-width: 768px) {
.hardware-fleet-landing__header,
.hardware-fleet-landing__summary {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+2
View File
@@ -996,6 +996,7 @@
"phone": "Telefon",
"previous": "Forrige",
"previous_step": "Forrige trin",
"preview": "Forhåndsvisning",
"price": "Pris",
"print": "Udskriv",
"privacy": "Privatliv",
@@ -2802,6 +2803,7 @@
"customer_picker": {
"selected_customer": "Valgt kunde",
"select_customer_invoice": "Eksisterende kunde",
"select_new_customer": "Ny kunde",
"select_draft_customer": "Uds\u00e6t",
"select_card_payment": "Betalingskort",
"select_payment_form": "V\u00e6lg betalingsform"
+2
View File
@@ -962,6 +962,7 @@
"phone": "Telefon",
"previous": "Vorherige",
"previous_step": "Vorheriger Schritt",
"preview": "Vorschau",
"price": "Preis",
"print": "Drucken",
"privacy": "Datenschutz",
@@ -2768,6 +2769,7 @@
"customer_picker": {
"selected_customer": "Ausgew\u00e4hlter Kunde",
"select_customer_invoice": "Kundenrechnung",
"select_new_customer": "Neuer Kunde",
"select_draft_customer": "Später",
"select_card_payment": "Zahlungskarte",
"select_payment_form": "Zahlungsart wählen"
+2
View File
@@ -996,6 +996,7 @@
"phone": "Phone",
"previous": "Previous",
"previous_step": "Previous step",
"preview": "Preview",
"price": "Price",
"print": "Print",
"privacy": "Privacy",
@@ -2802,6 +2803,7 @@
"customer_picker": {
"selected_customer": "Selected customer",
"select_customer_invoice": "Customer invoice",
"select_new_customer": "New customer",
"select_draft_customer": "Defer",
"select_card_payment": "Payment card",
"select_payment_form": "Select payment form"
+2
View File
@@ -962,6 +962,7 @@
"phone": "Telefon",
"previous": "Forrige",
"previous_step": "Forrige trinn",
"preview": "Forhåndsvisning",
"price": "Pris",
"print": "Skriv ut",
"privacy": "Integritet",
@@ -2768,6 +2769,7 @@
"customer_picker": {
"selected_customer": "Valgt kunde",
"select_customer_invoice": "Kundefaktura",
"select_new_customer": "Ny kunde",
"select_draft_customer": "Utsett",
"select_card_payment": "Betalingskort",
"select_payment_form": "Velg betalingsform"
+2
View File
@@ -962,6 +962,7 @@
"phone": "Telefon",
"previous": "Föregående",
"previous_step": "Föregående steg",
"preview": "Förhandsvisning",
"price": "Pris",
"print": "Skriv ut",
"privacy": "Integritet",
@@ -2768,6 +2769,7 @@
"customer_picker": {
"selected_customer": "Vald kund",
"select_customer_invoice": "Kundfaktura",
"select_new_customer": "Ny kund",
"select_draft_customer": "Skjut upp",
"select_card_payment": "Betalkort",
"select_payment_form": "Välj betalningsform"
+15
View File
@@ -357,6 +357,12 @@ export const listEdgeGatewayDepartments = async ({ forceRefresh = false } = {})
return departmentsRequestInFlight;
};
export const listEdgeGatewayDepartmentWorkspaces = async () =>
authenticatedRequest("/modules/edge-gateways/workspace/departments", "GET", {});
export const getEdgeGatewayDepartmentWorkspace = async (departmentId) =>
authenticatedRequest(`/modules/edge-gateways/workspace/departments/${encodeURIComponent(String(departmentId))}`, "GET", {});
export const listEdgeGateways = async ({ departmentId = null, view = "summary", forceRefresh = false } = {}) => {
const requestKey = listCacheKey({ departmentId, view });
if (!forceRefresh && listRequestsInFlight.has(requestKey)) {
@@ -503,6 +509,15 @@ export const setDepartmentGatewayCutover = async (departmentId, transport_mode)
return response;
});
export const updateNumberPlateScanner = async (scannerId, payload = {}) =>
authenticatedRequest("/numberplatescanners", "PUT", {
id: Number(scannerId),
...payload,
});
export const rotateNumberPlateScannerKey = async (scannerId) =>
authenticatedRequest(`/numberplatescanners/${encodeURIComponent(String(scannerId))}/rotate-key`, "POST", {});
export const getEdgeGatewayModuleConfig = async () =>
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "GET", {}).then((response) => {
const entries = normalizeEntries(unwrapEdgeGatewayResponse(response, []));
@@ -3,11 +3,51 @@ import DepartmentGates from "@/components/displays/DepartmentGates.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
};
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<section class="hardware-legacy-notice" data-testid="department-gates-workspace-link">
<div>
<strong>Relay-backed gates now live in the hardware workspace.</strong>
<p>Use the integrated workspace to compare phone-call and relay transport, coverage, and gateway readiness per department.</p>
</div>
<button class="button is-dark" type="button" @click="openFleetLanding">Open integrated workspace</button>
</section>
<DepartmentGates />
</RestrictedPageWrapper>
</template>
<style scoped>
.hardware-legacy-notice {
margin: 0 0 1rem;
padding: 1rem;
border-radius: 18px;
border: 1px solid #dbe4ea;
background: #fffaf0;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.hardware-legacy-notice p {
margin: 0.35rem 0 0;
color: #475569;
}
@media (max-width: 768px) {
.hardware-legacy-notice {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -3,15 +3,51 @@ import DepartmentLanes from "@/components/displays/DepartmentLanes.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
};
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<section class="hardware-legacy-notice" data-testid="department-lanes-workspace-link">
<div>
<strong>Need binding and gateway context?</strong>
<p>Open the integrated hardware workspace to review lane coverage, self-serve readiness, scanners, and gateways per department.</p>
</div>
<button class="button is-dark" type="button" @click="openFleetLanding">Open integrated workspace</button>
</section>
<DepartmentLanes :advancedView="true"/>
</RestrictedPageWrapper>
</template>
<style scoped>
.hardware-legacy-notice {
margin: 0 0 1rem;
padding: 1rem;
border-radius: 18px;
border: 1px solid #dbe4ea;
background: #fffaf0;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.hardware-legacy-notice p {
margin: 0.35rem 0 0;
color: #475569;
}
@media (max-width: 768px) {
.hardware-legacy-notice {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -2,13 +2,52 @@
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SelfServeMachine from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachine.vue";
import DepartmentSelfServePagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentSelfServePagination.vue";
import { useRouter } from "vue-router";
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
};
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<section class="hardware-legacy-notice" data-testid="selfserve-workspace-link">
<div>
<strong>Self-serve readiness is now part of the department hardware workspace.</strong>
<p>Use the integrated workspace to review lane coverage, bindings, scanner mappings, and gateway cutover alongside self-serve status.</p>
</div>
<button class="button is-dark" type="button" @click="openFleetLanding">Open integrated workspace</button>
</section>
<DepartmentSelfServePagination />
</RestrictedPageWrapper>
</template>
<style scoped>
.hardware-legacy-notice {
margin: 0 0 1rem;
padding: 1rem;
border-radius: 18px;
border: 1px solid #dbe4ea;
background: #fffaf0;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.hardware-legacy-notice p {
margin: 0.35rem 0 0;
color: #475569;
}
@media (max-width: 768px) {
.hardware-legacy-notice {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
import EdgeGatewayFleetLanding from "@/features/edgeGateways/EdgeGatewayFleetLanding.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -179,6 +180,8 @@ onMounted(async () => {
</div>
</article>
<EdgeGatewayFleetLanding v-if="!moduleState.unavailable && !selectedGatewayId" />
<EdgeGatewayManager
v-if="!moduleState.unavailable"
:selected-gateway-id="selectedGatewayId"
@@ -1,20 +1,53 @@
<script setup>
import { getScanners } from "@/components/numberplatescanners/Scanners.vue";
import { showCreateNumberPlateScannerForm } from "@/components/forms/superUser/createNumberPlateScannerForm.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import NumberPlateScanners from "@/components/displays/NumberPlateScanners.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
};
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<section class="hardware-legacy-notice" data-testid="scanner-workspace-link">
<div>
<strong>Scanner assignment now belongs in the hardware workspace.</strong>
<p>Open the integrated workspace to assign default lanes, review scan activity, and rotate scanner keys per department.</p>
</div>
<button class="button is-dark" type="button" @click="openFleetLanding">Open integrated workspace</button>
</section>
<NumberPlateScanners />
</RestrictedPageWrapper>
</template>
<style scoped>
.hardware-legacy-notice {
margin: 0 0 1rem;
padding: 1rem;
border-radius: 18px;
border: 1px solid #dbe4ea;
background: #fffaf0;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.hardware-legacy-notice p {
margin: 0.35rem 0 0;
color: #475569;
}
@media (max-width: 768px) {
.hardware-legacy-notice {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -1,23 +1,15 @@
<script setup>
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
import EdgeGatewayDepartmentWorkspace from "@/features/edgeGateways/EdgeGatewayDepartmentWorkspace.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";
const router = useRouter();
const route = useRoute();
const departmentId = computed(() => Number(route.params.departmentId));
const openGatewayPage = async (gatewayId) => {
if (!gatewayId) {
return;
}
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
};
</script>
<template>
@@ -25,16 +17,11 @@ const openGatewayPage = async (gatewayId) => {
<DepartmentSubPageWrapper>
<template #title>
<PageTitle
title="Gateway for afdelingen"
subtitle="Overview, tasks, logs, statistics, and inventory without destructive controls"
title="Department Hardware Workspace"
subtitle="Overview, self-serve readiness, gates, scanners, and gateway coverage for this department"
/>
</template>
<EdgeGatewayManager
:department-id="departmentId"
:route-driven="false"
:allow-destructive="false"
@open-gateway-page="openGatewayPage"
/>
<EdgeGatewayDepartmentWorkspace :department-id="departmentId" />
</DepartmentSubPageWrapper>
</RestrictedPageWrapper>
</template>
+36 -1
View File
@@ -2288,7 +2288,9 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
expect((lastActionBox?.y ?? 0) + (lastActionBox?.height ?? 0)).toBeLessThanOrEqual(viewportHeight - 8);
});
test("shows desktop categories with left-side flyout submenus on wide viewports", async ({ page }) => {
test("shows desktop categories with left-side flyout submenus and attachment actions on wide viewports", async ({
page,
}) => {
await page.setViewportSize({ width: 1900, height: 900 });
await page.goto("/admin/12/modules/pos/orders");
await expect(page.locator("table")).toBeVisible();
@@ -2299,6 +2301,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
const sectionsRail = dropdownContent.getByTestId("action-settings-wheel-sections");
const customerSection = dropdownContent.getByTestId("action-settings-wheel-section-customer");
const vehicleSection = dropdownContent.getByTestId("action-settings-wheel-section-vehicle");
const attachmentsSection = dropdownContent.getByTestId("action-settings-wheel-section-attachments");
await expect(flyout).toBeVisible();
await expect(customerSection.locator(".fa-chevron-left")).toBeVisible();
@@ -2331,6 +2334,38 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
expect(flyoutBoxBefore).not.toBeNull();
expect(flyoutBoxAfter).not.toBeNull();
expect(Math.abs((flyoutBoxAfter?.height ?? 0) - (flyoutBoxBefore?.height ?? 0))).toBeLessThanOrEqual(1);
await attachmentsSection.hover();
const attachmentsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-attachments");
const attachmentRows = attachmentsSubmenu.locator('[data-testid^="action-settings-wheel-attachment-row-"]');
await expect(attachmentsSubmenu).toBeVisible();
await expect(attachmentRows).toHaveCount(3);
const firstAttachmentRow = attachmentRows.first();
await firstAttachmentRow.hover();
const attachmentPanel = dropdownContent.getByTestId("action-settings-wheel-attachment-panel");
const previewContainer = dropdownContent.getByTestId("action-settings-wheel-attachment-preview");
await expect(attachmentPanel).toBeVisible();
await expect(previewContainer.locator("img, iframe")).toBeVisible();
await expect(dropdownContent.locator('[data-testid^="action-settings-wheel-attachment-action-preview-"]')).toBeVisible();
await expect(dropdownContent.locator('[data-testid^="action-settings-wheel-attachment-action-download-"]')).toBeVisible();
await expect(dropdownContent.locator('[data-testid^="action-settings-wheel-attachment-action-print-"]')).toBeVisible();
await expect(dropdownContent.locator('[data-testid^="action-settings-wheel-attachment-action-delete-"]')).toBeVisible();
const [firstAttachmentRowBox, attachmentPanelBox] = await Promise.all([
firstAttachmentRow.boundingBox(),
attachmentPanel.boundingBox(),
]);
expect(firstAttachmentRowBox).not.toBeNull();
expect(attachmentPanelBox).not.toBeNull();
expect(attachmentPanelBox?.x ?? 0).toBeLessThan(firstAttachmentRowBox?.x ?? 0);
await dropdownContent.locator('[data-testid^="action-settings-wheel-attachment-action-delete-"]').click();
await expect(attachmentRows).toHaveCount(2);
await expect(flyout).toBeVisible();
await expect(attachmentsSection).toBeVisible();
});
test("keeps the first action reachable while async menu sections load", async ({ page }) => {
+20 -1
View File
@@ -49,6 +49,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
test("filters the fleet roster with search and summary chips", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway");
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
await page.getByTestId("gateway-fleet-search").fill("ode");
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
@@ -59,11 +60,29 @@ test.describe("Edge gateway routing and fleet navigation", () => {
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
});
test("opens a department workspace from the fleet landing", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway");
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
await expect(page.getByTestId("hardware-fleet-department-1")).toContainText("Copenhagen");
await page.getByTestId("hardware-fleet-open-workspace-1").click();
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways(?:\?.*)?$/);
await expect(page.getByTestId("department-hardware-workspace")).toBeVisible();
await expect(page.getByTestId("department-hardware-primary-gateway")).toContainText("CPH Edge 01");
});
test("keeps the department workspace on the safe subset and links into the full page", async ({ page }) => {
await page.goto("/superuser/departments/1/gateways");
await expect(page.getByTestId("department-hardware-workspace")).toBeVisible();
await expect(page.getByTestId("department-hardware-panel-overview")).toBeVisible();
await expect(page.getByTestId("department-hardware-tab-gateways")).toBeVisible();
await page.getByTestId("department-hardware-tab-gateways").click();
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-item-701")).toBeVisible();
await expect(page.getByTestId("gateway-tab-terminal")).toHaveCount(0);
await expect(page.getByTestId("gateway-tab-settings")).toHaveCount(0);
await page.getByTestId("gateway-tab-tasks").click();
+40
View File
@@ -305,6 +305,46 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.locator("body")).toContainText("Gateway deleted.");
});
test("@smoke updates the integrated workspace after binding and scanner assignment changes", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/departments/1/gateways?tab=lanes");
await expect(page.getByTestId("department-hardware-panel-lanes")).toBeVisible();
await expect(page.getByTestId("department-lane-8")).toContainText("MISSING");
await page.getByTestId("department-hardware-tab-gateways").click();
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
await page.getByTestId("gateway-tab-inventory").click();
await page.getByTestId("gateway-binding-add").click();
await page.locator('[data-testid^="gateway-binding-relay-"]').last().fill("M-8");
await page.locator('[data-testid^="gateway-binding-device-"]').last().selectOption("shelly-plus-01");
await page.locator('[data-testid^="gateway-binding-channel-"]').last().selectOption("1");
await page.locator('[data-testid^="gateway-binding-fallback-"]').last().selectOption("PREFER_LOCAL");
await page.getByTestId("gateway-bindings-save").click();
await expect(page.locator("body")).toContainText("Relay bindings saved.");
await page.getByTestId("department-hardware-tab-lanes").click();
await page.getByTestId("department-hardware-refresh").click();
await expect(page.getByTestId("department-lane-8")).toContainText("READY");
await page.getByTestId("department-hardware-tab-scanners").click();
await expect(page.getByTestId("department-hardware-panel-scanners")).toBeVisible();
await page.getByTestId("department-scanner-lane-2").selectOption("8");
await page.getByTestId("department-scanner-save-2").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("lane assignment updated");
await expect(page.getByTestId("department-scanner-2")).toContainText("READY");
await page.getByTestId("department-scanner-rotate-2").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("API key rotated");
await expect(page.getByTestId("department-scanner-key-2")).toContainText("rotated-scanner-key-2");
});
test("@smoke cancels an in-progress gateway task so a replacement task can be queued", async ({ page }) => {
await mockApi(page, {
authenticated: true,
+1
View File
@@ -32,6 +32,7 @@ test.describe("Edge gateway visuals", () => {
await page.goto("/superuser/configuration/edgegateway");
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible();
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
+268 -4
View File
@@ -120,6 +120,9 @@ function createPosFixture(overrides = {}) {
const baseFixture = {
customer,
customersByNumber: {
[customer.customerNumber]: customer,
},
products,
departmentCategories: [
{
@@ -158,6 +161,10 @@ function createPosFixture(overrides = {}) {
orderBookings: [],
orderBookingsDelayMs: 0,
duplicateOrders: [],
cvrSearchResponses: {},
cvrSearches: [],
customerRegistrationResponse: null,
customerRegistrations: [],
ordersById,
orderItemsByOrderId,
markCompletedOrderIds: [],
@@ -180,6 +187,10 @@ function createPosFixture(overrides = {}) {
...baseFixture,
...overrides,
customer: overrides.customer || baseFixture.customer,
customersByNumber: {
[(overrides.customer || baseFixture.customer).customerNumber]: overrides.customer || baseFixture.customer,
...(overrides.customersByNumber || {}),
},
products: overrides.products || baseFixture.products,
departmentCategories: overrides.departmentCategories || baseFixture.departmentCategories,
vehicles: overrides.vehicles || baseFixture.vehicles,
@@ -187,6 +198,10 @@ function createPosFixture(overrides = {}) {
...baseFixture.customerSuggestionsByReg,
...(overrides.customerSuggestionsByReg || {}),
},
cvrSearchResponses: {
...baseFixture.cvrSearchResponses,
...(overrides.cvrSearchResponses || {}),
},
ordersById: {
...baseFixture.ordersById,
...(overrides.ordersById || {}),
@@ -463,13 +478,100 @@ async function mockPosApi(page, fixture) {
return;
}
if (pathname.endsWith("/cvr/search") && method === "GET") {
const query = String(parsedUrl.searchParams.get("query") || "").trim();
fixture.cvrSearches.push({ query });
const responseConfig = fixture.cvrSearchResponses?.[query] || null;
const delayMs = Number(responseConfig?.delayMs ?? 0);
const status = Number(responseConfig?.status ?? 200);
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
if (status >= 400) {
await route.fulfill(
json(
{
success: false,
data: {
message: responseConfig?.message || "Unable to load CVR details",
},
},
status
)
);
return;
}
await route.fulfill(
json({
success: true,
data: responseConfig?.data ?? null,
})
);
return;
}
if (pathname.endsWith("/auth/register/cvr") && method === "POST") {
const body = request.postDataJSON?.() || {};
fixture.customerRegistrations.push(body);
const responseConfig = fixture.customerRegistrationResponse || {};
const status = Number(responseConfig.status ?? 200);
if (status >= 400) {
await route.fulfill(
json(
{
success: false,
data: {
message: responseConfig.message || "Unable to register customer",
},
},
status
)
);
return;
}
const customerNumber = toPositiveInteger(body.companyPhone);
if (customerNumber) {
fixture.customersByNumber[customerNumber] = {
id: customerNumber,
customerNumber,
name: body?.searchResult?.name || `Customer ${customerNumber}`,
address: body?.searchResult?.address || "Demo Street 1",
zip: String(body?.searchResult?.zipcode || "2630"),
city: body?.searchResult?.city || "Taastrup",
mobilePhone: String(body.contactPhone || body.companyPhone || ""),
email: body.contactEmail || body.invoiceEmail || "",
corporateIdentificationNumber: String(body.cvr || "").padStart(8, "0"),
barred: false,
};
}
await route.fulfill(
json({
success: true,
data: responseConfig.data ?? { success: true },
})
);
return;
}
if (pathname.endsWith("/users/customer") && method === "GET") {
const customerNumber =
toPositiveInteger(parsedUrl.searchParams.get("customer_number")) || fixture.customer.customerNumber;
const resolvedCustomer = fixture.customersByNumber?.[customerNumber] || null;
await route.fulfill(
json({
success: true,
data: {
customer_name: fixture.customer.name,
economic_customer: fixture.customer,
customer_name: resolvedCustomer?.name || fixture.customer.name,
economic_customer: resolvedCustomer || fixture.customer,
},
})
);
@@ -477,15 +579,29 @@ async function mockPosApi(page, fixture) {
}
if (pathname.endsWith("/customers") && method === "GET") {
const search = String(parsedUrl.searchParams.get("search") || "").toLowerCase();
const customers = Object.values(fixture.customersByNumber || {}).filter((entry) => {
if (!search) {
return true;
}
return (
String(entry.customerNumber || "").includes(search) ||
String(entry.name || "")
.toLowerCase()
.includes(search)
);
});
await route.fulfill(
json({
success: true,
data: [fixture.customer],
data: customers,
meta: {
pagination: {
page: 1,
limit: 10,
total: 1,
total: customers.length,
},
},
})
@@ -1125,6 +1241,154 @@ test.describe("POS flow", () => {
await expect(page.locator("#pos_select_customer_input:visible")).toHaveCount(1);
});
test("desktop inline Ny kunde flow autofills, preserves manual overrides, and selects the created customer", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS step 1 is validated on chromium-desktop.");
const fixture = createPosFixture({
cvrSearchResponses: {
41004355: {
data: {
vat: 41004355,
status: "Normal",
name: "Truckwash ApS",
address: "Letland Alle 2",
zipcode: 2630,
city: "Taastrup",
phone: "21754690",
email: "mikkel@truckwash.dk",
},
},
43423010: {
data: {
vat: 43423010,
status: "Normal",
name: "Wash Group ApS",
address: "Nordhavn 4",
zipcode: 2100,
city: "Kobenhavn O",
phone: "42331128",
email: "billing@wash-group.test",
},
},
},
});
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "AB12345",
customer_id: null,
customer_name: "",
last_order_id: null,
},
];
await setupDesktopPosPage(page, fixture, { token: "pos-inline-new-customer-create" });
await page.locator("#reg_1").fill("AB12345");
const invoiceButton = page.getByTestId("pos-customer-invoice-inline-action");
const newCustomerButton = page.getByTestId("pos-new-customer-inline-action");
const searchInput = page.getByTestId("pos-desktop-add-customer-search-input");
const companyPhone = page.getByTestId("pos-desktop-add-customer-company-phone");
const invoiceEmail = page.getByTestId("pos-desktop-add-customer-invoice-email");
const contactEmail = page.getByTestId("pos-desktop-add-customer-contact-email");
const contactPhone = page.getByTestId("pos-desktop-add-customer-contact-phone");
const submitButton = page.getByTestId("pos-desktop-add-customer-submit");
await expect(newCustomerButton).toBeVisible();
await newCustomerButton.click();
await expect(newCustomerButton).toHaveClass(/is-selected/);
await expect(page.locator("#pos_select_customer_input:visible")).toHaveCount(0);
await expect(page.getByTestId("pos-desktop-add-customer-inline-form")).toBeVisible();
await searchInput.fill("41004355");
await expect(companyPhone).toHaveValue("21754690", { timeout: 10_000 });
await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactPhone).toHaveValue("21754690");
await companyPhone.fill("55550000");
await contactEmail.fill("dispatch@truckwash.test");
await searchInput.fill("43423010");
await expect(companyPhone).toHaveValue("55550000", { timeout: 10_000 });
await expect(contactEmail).toHaveValue("dispatch@truckwash.test");
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
await expect(contactPhone).toHaveValue("42331128");
await expect(submitButton).toBeEnabled();
await submitButton.click();
await expect(invoiceButton).toHaveClass(/is-selected/);
await expect(newCustomerButton).not.toHaveClass(/is-selected/);
await expect(page.getByTestId("pos-desktop-add-customer-inline-form")).toHaveCount(0);
await expect(page.locator(".field.has-addons input[disabled]")).toHaveValue("Wash Group ApS", { timeout: 10_000 });
await expect(page.getByRole("button", { name: "Ryd" })).toBeVisible();
await expect.poll(() => fixture.customerRegistrations.length, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.cvrSearches.length, { timeout: 10_000 }).toBe(2);
});
test("desktop resets the inline Ny kunde form when the operator cancels out of the mode", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS step 1 is validated on chromium-desktop.");
const fixture = createPosFixture({
cvrSearchResponses: {
41004355: {
data: {
vat: 41004355,
status: "Normal",
name: "Truckwash ApS",
address: "Letland Alle 2",
zipcode: 2630,
city: "Taastrup",
phone: "21754690",
email: "mikkel@truckwash.dk",
},
},
},
});
fixture.vehicles = [
{
...fixture.vehicles[0],
reg: "AB12345",
customer_id: null,
customer_name: "",
last_order_id: null,
},
];
await setupDesktopPosPage(page, fixture, { token: "pos-inline-new-customer-reset" });
await page.locator("#reg_1").fill("AB12345");
const invoiceButton = page.getByTestId("pos-customer-invoice-inline-action");
const newCustomerButton = page.getByTestId("pos-new-customer-inline-action");
const searchInput = page.getByTestId("pos-desktop-add-customer-search-input");
const companyPhone = page.getByTestId("pos-desktop-add-customer-company-phone");
const cancelButton = page.getByTestId("pos-desktop-add-customer-cancel");
await newCustomerButton.click();
await searchInput.fill("41004355");
await expect(companyPhone).toHaveValue("21754690", { timeout: 10_000 });
await cancelButton.click();
await expect(invoiceButton).toHaveClass(/is-selected/);
await expect(page.getByTestId("pos-desktop-add-customer-inline-form")).toHaveCount(0);
await expect(page.locator("#pos_select_customer_input:visible")).toHaveCount(1);
await newCustomerButton.click();
await expect(searchInput).toHaveValue("");
await expect(companyPhone).toHaveValue("");
await expect(page.getByTestId("pos-desktop-add-customer-inline-form")).toBeVisible();
});
test("desktop disables the card quick action while all stripe readers are offline and re-enables it after polling", async ({
page,
}, testInfo) => {
+45
View File
@@ -2982,6 +2982,51 @@ test.describe("POS mobile order flow", () => {
.toBe("CD1234");
});
test("shows loading states while mobile step 2 categories and products resolve", async ({ page }) => {
const fixture = createMobilePosFixture({
departmentCategoriesDelayMs: 500,
productsDelayMsByCategory: {
4: 700,
8: 700,
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-loading-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-LOADING-REF",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
customerId: REGULAR_CUSTOMER_ID,
},
});
const categoryLoading = page.getByTestId("pos-mobile-categories-loading");
const productLoading = page.getByTestId("pos-mobile-products-loading");
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 });
await expect(categoryLoading).toBeVisible({ timeout: 10_000 });
await expect(productLoading).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-category-4")).toBeVisible({ timeout: 10_000 });
await expect(categoryLoading).toBeHidden({ timeout: 10_000 });
await expect(productLoading).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-product-53")).toBeVisible({ timeout: 10_000 });
await expect(productLoading).toBeHidden({ timeout: 10_000 });
await page.getByTestId("pos-mobile-category-8").click();
await expect(productLoading).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-product-91")).toBeVisible({ timeout: 10_000 });
await expect(productLoading).toBeHidden({ timeout: 10_000 });
});
test("manual step 2 selection supports addons and additional items", async ({ page }) => {
const orderId = 9402;
const fixture = createMobilePosFixture({
+28 -1
View File
@@ -152,8 +152,19 @@ async function fillBookingField(page: Page, toggleTestId: string, inputTestId: s
await input.press("Enter");
}
export async function completeBookingCreationFlow(page: Page, data: BookingFlowData) {
export async function goToBookingProductSelectionStep(page: Page, data: BookingFlowData) {
return goToBookingProductSelectionStepWithOptions(page, data);
}
export async function goToBookingProductSelectionStepWithOptions(
page: Page,
data: BookingFlowData,
options: {
selectInitialProduct?: boolean;
} = {}
) {
const { departmentId, registrationNumber, reference, poNumber, notes } = data;
const { selectInitialProduct = true } = options;
const bookWashButton = page.locator("#book-wash-button");
if (!(await bookWashButton.isVisible().catch(() => false))) {
@@ -190,6 +201,7 @@ export async function completeBookingCreationFlow(page: Page, data: BookingFlowD
await clickDesktopNext(page);
}
if (selectInitialProduct) {
const productCard = page.locator('[data-testid^="pos-product-card-"]:visible').first();
await expect(productCard).toBeVisible();
await productCard.click();
@@ -198,6 +210,21 @@ export async function completeBookingCreationFlow(page: Page, data: BookingFlowD
await expect(page.getByTestId("booking-mobile-next")).toBeEnabled();
await page.getByTestId("booking-mobile-next").click();
}
}
return {
departmentId,
registrationNumber,
reference,
poNumber,
notes,
mobileWizard,
};
}
export async function completeBookingCreationFlow(page: Page, data: BookingFlowData) {
const { departmentId, registrationNumber, reference, poNumber, notes, mobileWizard } =
await goToBookingProductSelectionStep(page, data);
const selectedDateTime = await selectBookingDateTime(page);
+13
View File
@@ -1325,6 +1325,10 @@ export async function mockMobilePosApi(page, fixture) {
const categories = departmentId
? fixture.departmentCategories.filter((entry) => Number(entry.department_id) === Number(departmentId))
: fixture.departmentCategories;
const delayMs = Number(fixture.departmentCategoriesDelayMs ?? 0);
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill(json({ success: true, data: clone(categories) }));
return;
}
@@ -1684,8 +1688,14 @@ export async function mockMobilePosApi(page, fixture) {
const category = toPositiveInteger(parsedUrl.searchParams.get("category"));
const isWash = parsedUrl.searchParams.get("is_wash");
const limit = Number(parsedUrl.searchParams.get("limit") || 0);
const delayMs = Number(
(category ? fixture.productsDelayMsByCategory?.[category] : null) ?? fixture.productsDelayMs ?? 0
);
if (productId) {
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill(
json({
success: true,
@@ -1706,6 +1716,9 @@ export async function mockMobilePosApi(page, fixture) {
if (Number.isFinite(limit) && limit > 0) {
products = products.slice(0, limit);
}
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill(json({ success: true, data: clone(products) }));
return;
}
+685 -2
View File
@@ -2114,6 +2114,9 @@ export function createPosFixture(overrides = {}) {
paymentIntentsByOrderId: {},
stripeReadersError: null,
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
departmentCategoriesDelayMs: 0,
productsDelayMs: 0,
productsDelayMsByCategory: {},
nextOrderId: 54519,
nextOrderItemId: 9200,
nextAttachmentId: 400,
@@ -2248,6 +2251,20 @@ function createPosStripeModuleOrder(posFixture, orderId, overrides = {}) {
};
}
function getFixtureDelayMs(value = 0) {
const parsed = Number(value || 0);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
async function maybeDelayFixtureResponse(delayMs = 0) {
const normalizedDelayMs = getFixtureDelayMs(delayMs);
if (!normalizedDelayMs) {
return;
}
await new Promise((resolve) => setTimeout(resolve, normalizedDelayMs));
}
async function handlePosRoute({ route, request, parsedUrl, pathname, method, posFixture }) {
if (!posFixture) {
return false;
@@ -2259,6 +2276,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
}
if (pathname.endsWith("/departments/categories") && method === "GET") {
await maybeDelayFixtureResponse(posFixture.departmentCategoriesDelayMs);
await route.fulfill(json({ success: true, data: posFixture.departmentCategories || [] }));
return true;
}
@@ -2573,6 +2591,14 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
if (pathname.endsWith("/products") && method === "GET") {
const productId = Number(parsedUrl.searchParams.get("id") || 0);
const category = Number(parsedUrl.searchParams.get("category") || 0);
const categoryDelayMap = posFixture.productsDelayMsByCategory || {};
const categoryDelayMs =
category > 0
? categoryDelayMap[category] ?? categoryDelayMap[String(category)] ?? posFixture.productsDelayMs
: posFixture.productsDelayMs;
await maybeDelayFixtureResponse(categoryDelayMs);
if (productId > 0) {
await route.fulfill(
json({ success: true, data: (posFixture.products || []).find((product) => product.id === productId) || null })
@@ -2826,8 +2852,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
}
if (pathname.endsWith("/orders/attachments") && method === "DELETE") {
const orderId = Number(parsedUrl.searchParams.get("order_id") || 0);
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || 0);
const body = request.postDataJSON?.() || {};
const orderId = Number(parsedUrl.searchParams.get("order_id") || body.order_id || 0);
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || body.attachment_id || 0);
posFixture.attachmentsByOrderId[orderId] = (posFixture.attachmentsByOrderId[orderId] || []).filter(
(attachment) => attachment.id !== attachmentId
);
@@ -3142,6 +3169,562 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
return false;
}
function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
if (!edgeGatewayFixture) {
return null;
}
if (!edgeGatewayFixture.hardwareWorkspace) {
edgeGatewayFixture.hardwareWorkspace = {
nextScannerId: 3,
departments: [
{
id: 1,
name: "Copenhagen",
description: "Primary launch department",
order_priority: 1,
self_serve_enabled: true,
lanes: [
{
id: 7,
department: 1,
name: "Lane 7",
status: "AVAILABLE",
machine_type_id: 1,
relay_in_id: null,
relay_out_id: null,
relay_machine_id: "M-7",
relay_machine_program_picker_id: null,
relay_machine_cleaner_id: null,
dynamic_image_id: 77,
self_serve_products: ["Truck", "Van"],
},
{
id: 8,
department: 1,
name: "Lane 8",
status: "FAULT",
machine_type_id: 1,
relay_in_id: null,
relay_out_id: null,
relay_machine_id: "M-8",
relay_machine_program_picker_id: null,
relay_machine_cleaner_id: null,
dynamic_image_id: 78,
self_serve_products: ["Truck"],
},
],
},
{
id: 2,
name: "Odense",
description: "Fallback transport department",
order_priority: 2,
self_serve_enabled: true,
lanes: [
{
id: 9,
department: 2,
name: "Lane 9",
status: "AVAILABLE",
machine_type_id: 1,
relay_in_id: null,
relay_out_id: null,
relay_machine_id: "M-9",
relay_machine_program_picker_id: null,
relay_machine_cleaner_id: null,
dynamic_image_id: 79,
self_serve_products: ["Truck", "Car"],
},
],
},
],
gates: [
{
id: 41,
department: 1,
name: "North Entrance",
is_entrance: true,
is_exit: false,
config: {
type: "RELAY",
relay_id: "M-7",
pulse_seconds: 1,
},
},
{
id: 42,
department: 1,
name: "Service Exit",
is_entrance: false,
is_exit: true,
config: {
type: "PHONE_CALL",
phone_number: "+4512345678",
call_duration_threshold: 3,
},
},
{
id: 43,
department: 2,
name: "Odense Main Gate",
is_entrance: true,
is_exit: false,
config: {
type: "PHONE_CALL",
phone_number: "+4598765432",
call_duration_threshold: 3,
},
},
],
scanners: [
{
id: 1,
department_id: 1,
name: "North scanner",
notes: "Mounted at the primary entry lane",
lane_id: 7,
api_key: "scanner-key-1",
},
{
id: 2,
department_id: 1,
name: "South scanner",
notes: "Waiting for lane assignment",
lane_id: null,
api_key: "scanner-key-2",
},
],
scans: [
{
id: 801,
department_id: 1,
plate_scanner_id: 1,
plate: "AB12345",
bay_id: "7",
created_at: "2026-04-08 08:44:07",
},
{
id: 802,
department_id: 1,
plate_scanner_id: 2,
plate: "CD67890",
bay_id: "8",
created_at: "2026-04-08 08:31:05",
},
],
};
}
return edgeGatewayFixture.hardwareWorkspace;
}
function buildEdgeGatewayHardwareBindingsIndex(edgeGatewayFixture, departmentId) {
const gateways = edgeGatewayFixture.gateways.filter((gateway) => Number(gateway.department_id) === Number(departmentId));
const bindingsByRelayId = {};
gateways.forEach((gateway) => {
(gateway.bindings || []).forEach((binding) => {
const relayId = String(binding?.relay_id || "").trim();
if (!relayId) {
return;
}
if (!bindingsByRelayId[relayId]) {
bindingsByRelayId[relayId] = [];
}
bindingsByRelayId[relayId].push({
...cloneJson(binding),
gateway_id: gateway.id,
gateway_label: gateway.label || `Gateway ${gateway.id}`,
gateway_status: gateway.status || "OFFLINE",
is_primary_gateway: Boolean(gateway.is_primary),
});
});
});
Object.values(bindingsByRelayId).forEach((bindings) => {
bindings.sort((left, right) => {
if (Number(Boolean(right.is_primary_gateway)) !== Number(Boolean(left.is_primary_gateway))) {
return Number(Boolean(right.is_primary_gateway)) - Number(Boolean(left.is_primary_gateway));
}
return Number(left.gateway_id || 0) - Number(right.gateway_id || 0);
});
});
return {
gateways,
bindingsByRelayId,
};
}
function buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) {
const bindings = bindingsByRelayId[String(relayId || "").trim()] || [];
return {
relay_id: relayId,
covered: bindings.length > 0,
status: bindings.length > 0 ? "BOUND" : "MISSING",
binding_count: bindings.length,
primary_binding: bindings[0] || null,
bindings,
};
}
function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, includeGateways = true) {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const department = (hardware?.departments || []).find((entry) => Number(entry.id) === Number(departmentId)) || null;
if (!department) {
return null;
}
const { gateways, bindingsByRelayId } = buildEdgeGatewayHardwareBindingsIndex(edgeGatewayFixture, departmentId);
const gatewayPayloads = gateways.map((gateway) =>
buildHttpEdgeGatewayGateway(edgeGatewayFixture, settleEdgeGatewayWork(edgeGatewayFixture, gateway.id), true)
);
const lanes = (department.lanes || []).map((lane) => {
const relaySlots = [
["ENTRY", lane.relay_in_id],
["EXIT", lane.relay_out_id],
["MACHINE", lane.relay_machine_id],
["PROGRAM_PICKER", lane.relay_machine_program_picker_id],
["CLEANER", lane.relay_machine_cleaner_id],
]
.filter(([, relayId]) => Boolean(relayId))
.map(([slot, relayId]) => ({
slot,
relay_id: relayId,
catalog: relayId ? { relay_id: relayId } : null,
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId),
}));
const requiredRelayCount = relaySlots.length;
const boundRelayCount = relaySlots.filter((slot) => slot.coverage.covered).length;
return {
id: lane.id,
department: lane.department,
name: lane.name,
relay_in_id: lane.relay_in_id,
relay_out_id: lane.relay_out_id,
relay_machine_id: lane.relay_machine_id,
relay_machine_program_picker_id: lane.relay_machine_program_picker_id,
relay_machine_cleaner_id: lane.relay_machine_cleaner_id,
dynamic_image_id: lane.dynamic_image_id,
machine_type_id: lane.machine_type_id,
status: lane.status,
self_serve_products: cloneJson(lane.self_serve_products || []),
relay_slots: relaySlots,
binding_coverage: {
required: requiredRelayCount,
bound: boundRelayCount,
missing: Math.max(0, requiredRelayCount - boundRelayCount),
state:
requiredRelayCount === 0 ? "NOT_REQUIRED" : boundRelayCount === requiredRelayCount ? "READY" : "MISSING",
},
links: {
legacy: `/superuser/department/lanes/${lane.id}`,
self_serve_studio: `/admin/${department.id}/modules/self-serve/studio`,
},
};
});
const selfServe = {
enabled: Boolean(department.self_serve_enabled),
lane_count: lanes.length,
ready_lanes: lanes.filter((lane) => String(lane.binding_coverage?.state || "") === "READY").length,
configured_task_count: lanes.reduce((sum, lane) => sum + (lane.self_serve_products?.length || 0), 0),
configured_product_count: new Set(lanes.flatMap((lane) => lane.self_serve_products || [])).size,
readiness_state: !department.self_serve_enabled
? "DISABLED"
: lanes.length === 0
? "UNCONFIGURED"
: lanes.every((lane) => String(lane.binding_coverage?.state || "") === "READY")
? "READY"
: "PARTIAL",
links: {
studio: `/admin/${department.id}/modules/self-serve/studio`,
legacy: "/superuser/selfserve",
},
};
const gates = (hardware.gates || [])
.filter((gate) => Number(gate.department) === Number(departmentId))
.map((gate) => {
const transportType = String(gate?.config?.type || "PHONE_CALL").toUpperCase();
const relayId = String(gate?.config?.relay_id || "").trim();
const coverage =
transportType === "RELAY" && relayId ? buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) : null;
return {
id: gate.id,
department: gate.department,
name: gate.name,
is_entrance: Boolean(gate.is_entrance),
is_exit: Boolean(gate.is_exit),
config: cloneJson(gate.config || {}),
transport_type: transportType,
config_complete:
transportType === "PHONE_CALL"
? Boolean(gate?.config?.phone_number) && Number(gate?.config?.call_duration_threshold || 0) > 0
: Boolean(relayId),
relay: relayId ? { relay_id: relayId } : null,
coverage,
};
});
const laneIndex = Object.fromEntries(lanes.map((lane) => [Number(lane.id), lane]));
const scansByScannerId = {};
(hardware.scans || [])
.filter((scan) => Number(scan.department_id) === Number(departmentId))
.sort((left, right) => new Date(String(right.created_at || 0)).getTime() - new Date(String(left.created_at || 0)).getTime())
.forEach((scan) => {
const scannerId = Number(scan.plate_scanner_id || 0);
if (!scansByScannerId[scannerId]) {
scansByScannerId[scannerId] = [];
}
if (scansByScannerId[scannerId].length >= 5) {
return;
}
scansByScannerId[scannerId].push(cloneJson(scan));
});
const scanners = (hardware.scanners || [])
.filter((scanner) => Number(scanner.department_id) === Number(departmentId))
.map((scanner) => {
const assignedLane = scanner.lane_id ? laneIndex[Number(scanner.lane_id)] || null : null;
const assignmentState = !scanner.lane_id
? "UNASSIGNED"
: !assignedLane
? "INVALID"
: Number(assignedLane?.binding_coverage?.missing || 0) === 0
? "READY"
: "PARTIAL";
const recentScans = scansByScannerId[Number(scanner.id)] || [];
return {
...cloneJson(scanner),
assigned_lane: assignedLane,
assignment_state: assignmentState,
recent_scan_at: recentScans[0]?.created_at || null,
recent_scans: recentScans,
recent_scan_count: recentScans.length,
};
});
const issues = [];
const onlineGatewayCount = gatewayPayloads.filter((gateway) => String(gateway?.status || "").toUpperCase() === "ONLINE").length;
const transportMode = String(gatewayPayloads[0]?.department_transport_mode || gateways[0]?.department_transport_mode || "cloud");
if (gatewayPayloads.length === 0) {
issues.push({ severity: "danger", code: "NO_GATEWAY", message: "No edge gateway has been claimed for this department." });
} else if (transportMode === "gateway" && onlineGatewayCount === 0) {
issues.push({
severity: "danger",
code: "NO_ONLINE_GATEWAY",
message: "Gateway transport mode is enabled, but no department gateway is currently online.",
});
}
lanes.forEach((lane) => {
if (Number(lane.binding_coverage?.missing || 0) > 0) {
issues.push({
severity: "warning",
code: "LANE_BINDING_GAP",
message: `Lane ${lane.name} is missing relay bindings.`,
target_type: "lane",
target_id: lane.id,
});
}
});
gates.forEach((gate) => {
if (!gate.config_complete) {
issues.push({
severity: "warning",
code: "GATE_CONFIG_INCOMPLETE",
message: `Gate ${gate.name} has incomplete transport configuration.`,
target_type: "gate",
target_id: gate.id,
});
return;
}
if (gate.transport_type === "RELAY" && !gate.coverage?.covered) {
issues.push({
severity: "warning",
code: "GATE_BINDING_MISSING",
message: `Gate ${gate.name} is assigned to an unbound relay.`,
target_type: "gate",
target_id: gate.id,
});
}
});
scanners.forEach((scanner) => {
if (scanner.assignment_state === "UNASSIGNED") {
issues.push({
severity: "warning",
code: "SCANNER_UNASSIGNED",
message: `Scanner ${scanner.name} is not assigned to a default lane.`,
target_type: "scanner",
target_id: scanner.id,
});
} else if (scanner.assignment_state === "PARTIAL") {
issues.push({
severity: "info",
code: "SCANNER_LANE_PARTIAL",
message: `Scanner ${scanner.name} is assigned to a lane with missing relay coverage.`,
target_type: "scanner",
target_id: scanner.id,
});
}
});
if (selfServe.enabled && Number(selfServe.ready_lanes || 0) < Number(selfServe.lane_count || 0)) {
issues.push({
severity: "warning",
code: "SELFSERVE_PARTIAL_READY",
message: "Self-serve is enabled, but one or more lanes are missing required relay coverage.",
});
}
const actions = [
{
code: "OPEN_GATEWAY_TAB",
label: "Open gateway controls",
path: `/superuser/departments/${departmentId}/gateways?tab=gateways`,
},
];
if (gatewayPayloads.length === 0) {
actions.push({
code: "INSTALL_GATEWAY",
label: "Install first edge gateway",
path: "/superuser/configuration/edgegateway",
});
}
if (lanes.some((lane) => Number(lane.binding_coverage?.missing || 0) > 0)) {
actions.push({
code: "REVIEW_LANE_BINDINGS",
label: "Resolve lane bindings",
path: `/superuser/departments/${departmentId}/gateways?tab=lanes`,
});
}
if (gates.some((gate) => gate.transport_type === "RELAY" && !gate.coverage?.covered)) {
actions.push({
code: "REVIEW_GATE_BINDINGS",
label: "Resolve gate relay bindings",
path: `/superuser/departments/${departmentId}/gateways?tab=gates`,
});
}
if (scanners.some((scanner) => scanner.assignment_state === "UNASSIGNED")) {
actions.push({
code: "ASSIGN_SCANNERS",
label: "Assign scanners to lanes",
path: `/superuser/departments/${departmentId}/gateways?tab=scanners`,
});
}
if (selfServe.enabled && lanes.length > 0) {
actions.push({
code: "OPEN_SELFSERVE_STUDIO",
label: "Open self-serve studio",
path: `/admin/${departmentId}/modules/self-serve/studio`,
});
}
const requiredRelayIds = new Set();
const coveredRelayIds = new Set();
lanes.forEach((lane) => {
(lane.relay_slots || []).forEach((slot) => {
if (!slot?.relay_id) {
return;
}
requiredRelayIds.add(slot.relay_id);
if (slot.coverage?.covered) {
coveredRelayIds.add(slot.relay_id);
}
});
});
gates.forEach((gate) => {
const relayId = String(gate?.relay?.relay_id || gate?.config?.relay_id || "").trim();
if (!relayId) {
return;
}
requiredRelayIds.add(relayId);
if (gate.coverage?.covered) {
coveredRelayIds.add(relayId);
}
});
const primaryGateway = gatewayPayloads.find((gateway) => gateway?.is_primary) || gatewayPayloads[0] || null;
const recentScanAt = scanners
.map((scanner) => scanner?.recent_scan_at)
.filter(Boolean)
.sort((left, right) => new Date(String(right || 0)).getTime() - new Date(String(left || 0)).getTime())[0] || null;
const health =
issues.some((issue) => issue.severity === "danger")
? "AT_RISK"
: issues.length > 0
? "PARTIAL"
: "READY";
const summary = {
department_id: department.id,
department_name: department.name,
order_priority: department.order_priority,
transport_mode: transportMode,
gateway_count: gatewayPayloads.length,
online_gateway_count: onlineGatewayCount,
primary_gateway: primaryGateway
? {
id: primaryGateway.id,
label: primaryGateway.label || `Gateway ${primaryGateway.id}`,
status: primaryGateway.status || "OFFLINE",
}
: null,
lane_count: lanes.length,
self_serve_enabled: selfServe.enabled,
self_serve_ready_lanes: selfServe.ready_lanes,
required_relay_count: requiredRelayIds.size,
bound_relay_count: coveredRelayIds.size,
missing_binding_count: Math.max(0, requiredRelayIds.size - coveredRelayIds.size),
gate_count: gates.length,
gate_transport_mix: {
relay: gates.filter((gate) => gate.transport_type === "RELAY").length,
phone_call: gates.filter((gate) => gate.transport_type === "PHONE_CALL").length,
},
scanner_count: scanners.length,
assigned_scanner_count: scanners.filter((scanner) => Number(scanner.lane_id || 0) > 0).length,
recent_scan_at: recentScanAt,
issue_count: issues.length,
health,
};
return {
department: {
id: department.id,
name: department.name,
description: department.description,
order_priority: department.order_priority,
},
summary,
gateways: includeGateways ? gatewayPayloads : [],
lanes,
self_serve: selfServe,
gates,
scanners,
issues,
actions,
};
}
async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, method, edgeGatewayFixture }) {
if (!edgeGatewayFixture) {
return false;
@@ -3167,6 +3750,8 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
return operation;
};
const edgeGatewayCollectionPattern = /\/(?:modules\/)?edge-gateways$/;
const edgeGatewayWorkspaceDepartmentsPattern = /\/modules\/edge-gateways\/workspace\/departments$/;
const edgeGatewayWorkspaceDepartmentPattern = /\/modules\/edge-gateways\/workspace\/departments\/(\d+)$/;
const edgeGatewayDetailPattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
const edgeGatewayTasksPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/tasks$/;
const edgeGatewayLogsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/logs$/;
@@ -3180,6 +3765,8 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
const edgeGatewayInstallTokenPattern = /\/(?:modules\/)?edge-gateways\/install-token$/;
const edgeGatewayInstallTokenStatusPattern = /\/(?:modules\/)?edge-gateways\/install-token\/(\d+)\/status$/;
const edgeGatewayBindingsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/bindings$/;
const numberPlateScannersPattern = /\/numberplatescanners$/;
const numberPlateScannerRotatePattern = /\/numberplatescanners\/(\d+)\/rotate-key$/;
const edgeGatewayDeletePattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
const edgeGatewayCutoverPattern =
/\/(?:modules\/edge-gateways\/departments\/(\d+)\/cutover|departments\/(\d+)\/gateway-cutover)$/;
@@ -3230,6 +3817,102 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
return true;
}
if (edgeGatewayWorkspaceDepartmentsPattern.test(pathname) && method === "GET") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const summaries = (hardware?.departments || [])
.map((department) => buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, department.id, false)?.summary)
.filter(Boolean)
.sort((left, right) => Number(left?.order_priority || 0) - Number(right?.order_priority || 0));
await route.fulfill(json({ data: summaries }));
return true;
}
if (edgeGatewayWorkspaceDepartmentPattern.test(pathname) && method === "GET") {
const departmentId = extractMatchId(edgeGatewayWorkspaceDepartmentPattern);
const payload = buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, true);
await route.fulfill(payload ? json({ data: payload }) : json({ message: "Department not found" }, 404));
return true;
}
if (numberPlateScannersPattern.test(pathname) && method === "GET") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const rows = cloneJson(hardware?.scanners || []);
const paginated = paginateRows(rows, parsedUrl.searchParams.get("page") || 1, parsedUrl.searchParams.get("limit") || 10);
await route.fulfill(
json({
success: true,
data: paginated.rows,
meta: paginated.meta,
})
);
return true;
}
if (numberPlateScannersPattern.test(pathname) && method === "POST") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const body = request.postDataJSON?.() || {};
const scanner = {
id: hardware.nextScannerId++,
department_id: Number(body.department_id || 0),
name: String(body.name || ""),
notes: String(body.notes || ""),
lane_id: body.lane_id === null || body.lane_id === undefined || body.lane_id === "" ? null : Number(body.lane_id),
api_key: `scanner-key-${Date.now()}`,
};
hardware.scanners.push(scanner);
await route.fulfill(json({ success: true, data: cloneJson(scanner) }, 201));
return true;
}
if (numberPlateScannersPattern.test(pathname) && method === "PUT") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const body = request.postDataJSON?.() || {};
const scannerId = Number(body.id || 0);
const scannerIndex = (hardware.scanners || []).findIndex((scanner) => Number(scanner.id) === scannerId);
if (scannerIndex === -1) {
await route.fulfill(json({ message: "Scanner not found" }, 404));
return true;
}
hardware.scanners[scannerIndex] = {
...hardware.scanners[scannerIndex],
department_id: Number(body.department_id || hardware.scanners[scannerIndex].department_id || 0),
name: body.name === undefined ? hardware.scanners[scannerIndex].name : String(body.name || ""),
notes: body.notes === undefined ? hardware.scanners[scannerIndex].notes : String(body.notes || ""),
lane_id:
body.lane_id === undefined
? hardware.scanners[scannerIndex].lane_id
: body.lane_id === null || body.lane_id === ""
? null
: Number(body.lane_id),
};
await route.fulfill(json({ success: true, data: cloneJson(hardware.scanners[scannerIndex]) }));
return true;
}
if (numberPlateScannerRotatePattern.test(pathname) && method === "POST") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const scannerId = extractMatchId(numberPlateScannerRotatePattern);
const scanner = (hardware.scanners || []).find((entry) => Number(entry.id) === scannerId) || null;
if (!scanner) {
await route.fulfill(json({ message: "Scanner not found" }, 404));
return true;
}
scanner.api_key = `rotated-scanner-key-${scanner.id}`;
await route.fulfill(
json({
success: true,
data: {
scanner: cloneJson(scanner),
api_key: scanner.api_key,
},
})
);
return true;
}
if (edgeGatewayCollectionPattern.test(pathname) && method === "GET") {
processPendingEdgeGatewayClaims(edgeGatewayFixture);
const departmentId = Number(parsedUrl.searchParams.get("department_id") || 0);
+50 -5
View File
@@ -1,9 +1,13 @@
import { test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { bookingTestData } from "./fixtures";
import { completeBookingCreationFlow } from "./support/bookingFlow";
import {
completeBookingCreationFlow,
goToBookingProductSelectionStep,
goToBookingProductSelectionStepWithOptions,
} from "./support/bookingFlow";
import { mockApi, primeMockSession } from "./support/network.js";
test.beforeEach(async ({ page }) => {
async function prepareBookingPage(page: Page, pos: true | Record<string, unknown> = true) {
await mockApi(page, {
authenticated: true,
sessionData: {
@@ -11,12 +15,53 @@ test.beforeEach(async ({ page }) => {
customer_number: 12345679,
permissions: ["user"],
},
pos: true,
pos,
});
await primeMockSession(page, { bootPath: "/user" });
});
}
test("[BOOKINGS][User][Creation] should create a new booking", async ({ page }) => {
await prepareBookingPage(page);
await completeBookingCreationFlow(page, bookingTestData);
});
test("[BOOKINGS][User][Creation] should show interior wash products on the interior category", async ({ page }) => {
await prepareBookingPage(page);
await goToBookingProductSelectionStep(page, bookingTestData);
const interiorCategoryTab = page.getByTestId("pos-product-category-tab-2");
if (await interiorCategoryTab.isVisible().catch(() => false)) {
await interiorCategoryTab.click();
} else {
await page.getByTestId("pos-product-category-select").selectOption("2");
}
await expect(page.getByTestId("pos-product-card-63")).toBeVisible();
await expect(page.getByText("Indvendig vask Forvogn")).toBeVisible();
});
test("[BOOKINGS][User][Creation] should show desktop loading states while categories and products resolve", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.toLowerCase().includes("desktop"), "Desktop only");
await prepareBookingPage(page, {
departmentCategoriesDelayMs: 700,
productsDelayMs: 900,
});
await goToBookingProductSelectionStepWithOptions(page, bookingTestData, {
selectInitialProduct: false,
});
await expect(page.getByTestId("pos-product-categories-loading")).toBeVisible();
await expect(page.getByTestId("pos-products-loading")).toBeVisible();
await expect(page.getByTestId("pos-product-categories-loading")).toHaveCount(0, { timeout: 10_000 });
await expect(page.getByTestId("pos-product-card-53")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-products-loading")).toHaveCount(0, { timeout: 10_000 });
await page.getByTestId("pos-product-category-tab-2").click();
await expect(page.getByTestId("pos-products-loading")).toBeVisible();
await expect(page.getByTestId("pos-product-card-63")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-products-loading")).toHaveCount(0, { timeout: 10_000 });
});
+232 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { createTestI18n } from "./helpers/mountWithApp.js";
@@ -73,6 +73,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
functions: {
fetchAttachments: vi.fn(() => Promise.resolve([])),
get_department_id: vi.fn(() => Promise.resolve(1)),
removeAttachment: vi.fn(() => Promise.resolve({ success: true })),
showAttachWashCertificateForm: vi.fn(() => Promise.resolve()),
showChangeCustomerForm: vi.fn(() => Promise.resolve()),
showChangeInvoiceCollectionForm: vi.fn(() => Promise.resolve()),
@@ -152,6 +153,13 @@ const SETTINGS_WHEEL_TRANSLATION_KEYS = [
"admin.pos.settings_wheel.view_lane_setup_new_tab",
"admin.pos.settings_wheel.view_order_new_tab",
"admin.pos.settings_wheel.view_vehicle_new_tab",
"admin.pos.attachments_no_preview",
"admin.pos.attachments_office_preview_unavailable",
"global.delete",
"global.download",
"global.loading",
"global.preview",
"global.print",
"superuser.department_lane.force_disable_machine",
"superuser.department_lane.force_enable_machine",
];
@@ -186,10 +194,114 @@ const flushMicrotasks = async () => {
await Promise.resolve();
};
const setDesktopFlyoutViewport = () => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
writable: true,
value: 1900,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
writable: true,
value: 900,
});
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: true,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
};
const mockMenuGeometry = () =>
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRectMock() {
if (this.classList?.contains("dropdown-trigger")) {
return {
x: 1750,
y: 96,
top: 96,
left: 1750,
right: 1850,
bottom: 136,
width: 100,
height: 40,
toJSON() {
return this;
},
};
}
return {
x: 1320,
y: 136,
top: 136,
left: 1320,
right: 1850,
bottom: 560,
width: 530,
height: 424,
toJSON() {
return this;
},
};
});
const mountDesktopFlyoutButton = (props = {}) => {
setDesktopFlyoutViewport();
return mount(ActionSettingsWheelButton, {
props: {
order_id: 42,
refreshFunction: vi.fn(() => Promise.resolve()),
...props,
},
slots: {
actions: "",
},
global: {
plugins: [i18n],
stubs: {
CustomerModal: { template: "<div />" },
},
},
});
};
const originalFetch = global.fetch;
const originalCreateObjectURL = global.URL.createObjectURL;
const originalRevokeObjectURL = global.URL.revokeObjectURL;
const originalMatchMedia = window.matchMedia;
describe("ActionSettingsWheelButton", () => {
beforeEach(() => {
getUserIdMock.mockClear();
SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm.mockClear();
SessionUser.objects.orders.functions.fetchAttachments.mockReset();
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([]);
SessionUser.objects.orders.functions.downloadAttachment.mockReset();
SessionUser.objects.orders.functions.downloadAttachment.mockResolvedValue("https://cdn.example.test/attachment");
SessionUser.objects.orders.functions.removeAttachment.mockReset();
SessionUser.objects.orders.functions.removeAttachment.mockResolvedValue({ success: true });
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
blob: () => Promise.resolve(new Blob(["preview"], { type: "application/pdf" })),
})
);
global.URL.createObjectURL = vi.fn((blob) => `blob:${blob.type}:${Date.now()}`);
global.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
global.URL.createObjectURL = originalCreateObjectURL;
global.URL.revokeObjectURL = originalRevokeObjectURL;
window.matchMedia = originalMatchMedia;
vi.restoreAllMocks();
});
it("resolves customer user id once on mount and not on unrelated rerenders", async () => {
@@ -322,4 +434,123 @@ describe("ActionSettingsWheelButton", () => {
windowOpenSpy.mockRestore();
});
it("builds attachment flyout rows and switches the preview panel on hover and focus", async () => {
SessionUser.objects.orders.functions.fetchAttachments.mockResolvedValue([
{
id: 301,
content: {
document: "safety-seal.pdf",
other: "safety-seal.pdf",
},
},
{
id: 302,
content: {
image: "truck.jpg",
other: "truck.jpg",
},
},
]);
SessionUser.objects.orders.functions.downloadAttachment.mockImplementation((orderId, attachmentId) =>
Promise.resolve(`https://cdn.example.test/orders/${orderId}/attachments/${attachmentId}`)
);
global.fetch = vi.fn((url) =>
Promise.resolve({
ok: true,
blob: () =>
Promise.resolve(
new Blob(["preview"], {
type: String(url).includes("/301") ? "application/pdf" : "image/png",
})
),
})
);
const geometrySpy = mockMenuGeometry();
const wrapper = mountDesktopFlyoutButton();
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
const attachmentsSection = wrapper.get('[data-testid="action-settings-wheel-section-attachments"]');
await attachmentsSection.trigger("mouseenter");
await flushMicrotasks();
const pdfRow = wrapper.get('[data-testid="action-settings-wheel-attachment-row-301"]');
const imageRow = wrapper.get('[data-testid="action-settings-wheel-attachment-row-302"]');
expect(pdfRow.text()).toContain("safety-seal.pdf");
expect(imageRow.text()).toContain("truck.jpg");
await pdfRow.trigger("mouseenter");
await flushMicrotasks();
await flushMicrotasks();
const previewPanel = wrapper.get('[data-testid="action-settings-wheel-attachment-panel"]');
expect(previewPanel.find("iframe").exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-preview-301"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-download-301"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-print-301"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="action-settings-wheel-attachment-action-delete-301"]').exists()).toBe(true);
await imageRow.trigger("focus");
await flushMicrotasks();
await flushMicrotasks();
expect(wrapper.get('[data-testid="action-settings-wheel-attachment-panel"]').find("img").exists()).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(2);
geometrySpy.mockRestore();
});
it("deletes attachments, refreshes the list, and clears preview object URLs on close", async () => {
const attachmentsBeforeDelete = [
{
id: 301,
content: {
document: "safety-seal.pdf",
other: "safety-seal.pdf",
},
},
];
SessionUser.objects.orders.functions.fetchAttachments
.mockResolvedValueOnce(attachmentsBeforeDelete)
.mockResolvedValueOnce([]);
SessionUser.objects.orders.functions.downloadAttachment.mockResolvedValue(
"https://cdn.example.test/orders/42/attachments/301"
);
const refreshFunction = vi.fn(() => Promise.resolve());
const geometrySpy = mockMenuGeometry();
const wrapper = mountDesktopFlyoutButton({ refreshFunction });
await flushMicrotasks();
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-section-attachments"]').trigger("mouseenter");
await flushMicrotasks();
await wrapper.get('[data-testid="action-settings-wheel-attachment-row-301"]').trigger("mouseenter");
await flushMicrotasks();
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1);
await wrapper.get('[data-testid="action-settings-wheel-attachment-action-delete-301"]').trigger("click");
await flushMicrotasks();
expect(SessionUser.objects.orders.functions.removeAttachment).toHaveBeenCalledTimes(1);
expect(SessionUser.objects.orders.functions.removeAttachment).toHaveBeenCalledWith(42, 301);
expect(refreshFunction).toHaveBeenCalledTimes(1);
expect(SessionUser.objects.orders.functions.fetchAttachments).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-testid="action-settings-wheel-section-attachments"]').exists()).toBe(false);
await wrapper.find(".dropdown-trigger button").trigger("click");
await flushMicrotasks();
expect(global.URL.revokeObjectURL).toHaveBeenCalledTimes(1);
wrapper.unmount();
geometrySpy.mockRestore();
});
});
@@ -0,0 +1,303 @@
// @vitest-environment jsdom
import { nextTick, ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const posState = vi.hoisted(() => ({
customerId: null,
customerName: null,
selectCustomer: vi.fn(),
searchAndSelectCustomer: vi.fn(),
}));
const searchState = vi.hoisted(() => ({
isSearching: null,
results: null,
}));
const draftState = vi.hoisted(() => ({
draftCustomerNumber: null,
hasDraft: null,
}));
const stripeState = vi.hoisted(() => ({
isCardPaymentAvailable: null,
}));
const sessionUserState = vi.hoisted(() => ({
request: vi.fn(),
parseErrorMessage: vi.fn((error) => error?.message ?? null),
}));
vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
posState.customerId = ref(null);
posState.customerName = ref("");
posState.selectCustomer.mockImplementation((customer) => {
posState.customerId.value = customer?.customerNumber ?? null;
posState.customerName.value = customer?.name ?? "";
});
posState.searchAndSelectCustomer.mockImplementation(async (customerNumber) => {
const selectedCustomer = {
customerNumber: Number(customerNumber),
name: `Customer ${customerNumber}`,
};
posState.selectCustomer(selectedCustomer);
return selectedCustomer;
});
return {
customer_id: posState.customerId,
customer_name: posState.customerName,
isCustomerBarred: vi.fn(() => false),
selectCustomer: posState.selectCustomer,
isCustomerSelected: () => Boolean(posState.customerId.value),
searchAndSelectCustomer: posState.searchAndSelectCustomer,
};
});
vi.mock("@/components/search/economic/customerSearch.vue", async () => {
searchState.isSearching = ref(false);
searchState.results = ref([]);
return {
isSearching: searchState.isSearching,
searchCustomerResults: searchState.results,
};
});
vi.mock("@/components/session/token/SessionUser.vue", () => {
const sessionUser = {
request: sessionUserState.request,
functions: {
parseErrorMessage: sessionUserState.parseErrorMessage,
},
objects: {
global: {
language: {
customer: "Kunde",
clear: "Clear",
},
},
},
};
return {
SessionUser: sessionUser,
default: sessionUser,
};
});
vi.mock("@/composables/useDraftTransactionCustomer.js", () => ({
useDraftTransactionCustomer: () => ({
draftTransactionCustomerNumber: draftState.draftCustomerNumber,
hasDraftTransactionCustomer: draftState.hasDraft,
}),
}));
vi.mock("@/composables/useStripeReaderAvailability.js", () => ({
useStripeReaderAvailability: () => ({
isCardPaymentAvailable: stripeState.isCardPaymentAvailable,
}),
}));
import CustomerSearchFieldPos from "@/components/forms/department/pos/input/customerSearchFieldPos.vue";
const CustomerSearchFieldStub = {
name: "CustomerSearchField",
template: '<input v-bind="$attrs" />',
};
async function flushComponentUpdates() {
await Promise.resolve();
await nextTick();
}
function mountComponent(props = {}) {
return mountWithApp(CustomerSearchFieldPos, {
props,
messages: {
en: {
pos: {
customer_picker: {
selected_customer: "Selected customer",
select_customer_invoice: "Customer invoice",
select_new_customer: "New customer",
select_draft_customer: "Defer",
select_card_payment: "Payment card",
},
},
admin: {
pos: {
company_phone: "Company phone",
invoice_email: "Invoice email",
contact_email: "Contact email",
contact_phone: "Contact phone",
add_customer: "Add customer",
customer_creation_error: "Error: {error}",
customer_creation_error_generic: "Unable to create customer",
not_found: "Not found",
},
},
global: {
search_cvr: "Search CVR",
},
common: {
cancel: "Cancel",
},
},
},
global: {
stubs: {
CustomerSearchField: CustomerSearchFieldStub,
"customer-search-field": CustomerSearchFieldStub,
},
},
});
}
describe("CustomerSearchFieldPos", () => {
beforeEach(() => {
vi.useFakeTimers();
posState.customerId.value = null;
posState.customerName.value = "";
posState.selectCustomer.mockClear();
posState.searchAndSelectCustomer.mockClear();
searchState.isSearching.value = false;
searchState.results.value = [];
draftState.draftCustomerNumber = ref(6001);
draftState.hasDraft = ref(true);
stripeState.isCardPaymentAvailable = ref(true);
sessionUserState.request.mockReset();
sessionUserState.parseErrorMessage.mockClear();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("shows the inline Ny kunde form and hides manual search while the mode is active", async () => {
const wrapper = mountComponent();
expect(wrapper.find("#pos_select_customer_input").exists()).toBe(true);
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await nextTick();
expect(wrapper.find("#pos_select_customer_input").exists()).toBe(false);
expect(wrapper.find('[data-testid="pos-desktop-add-customer-inline-form"]').exists()).toBe(true);
});
it("resets the inline add-customer form when the operator cancels out of Ny kunde mode", async () => {
sessionUserState.request.mockImplementation(async (path) => {
if (path === "/cvr/search") {
return {
data: {
data: {
vat: 41004355,
name: "Truckwash ApS",
phone: "21754690",
email: "mikkel@truckwash.dk",
},
},
};
}
throw new Error(`Unexpected request: ${path}`);
});
const wrapper = mountComponent();
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await wrapper.get('[data-testid="pos-desktop-add-customer-search-input"]').setValue("41004355");
await vi.advanceTimersByTimeAsync(300);
await flushComponentUpdates();
expect(wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').element.value).toBe("21754690");
await wrapper.get('[data-testid="pos-desktop-add-customer-cancel"]').trigger("click");
await nextTick();
expect(wrapper.find('[data-testid="pos-desktop-add-customer-inline-form"]').exists()).toBe(false);
expect(wrapper.find("#pos_select_customer_input").exists()).toBe(true);
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await nextTick();
expect(wrapper.get('[data-testid="pos-desktop-add-customer-search-input"]').element.value).toBe("");
expect(wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').element.value).toBe("");
});
it("submits a new customer inline and emits the selected customer after creation", async () => {
sessionUserState.request.mockImplementation(async (path, method, payload) => {
if (path === "/cvr/search") {
return {
data: {
data: {
vat: 43423010,
name: "Wash Group ApS",
phone: "42331128",
email: "billing@wash-group.test",
},
},
};
}
if (path === "/auth/register/cvr") {
expect(method).toBe("POST");
expect(payload).toMatchObject({
cvr: "43423010",
companyPhone: 55550000,
invoiceEmail: "billing@wash-group.test",
contactEmail: "dispatch@wash-group.test",
contactPhone: 42331128,
});
return {
data: {
data: {
success: true,
},
},
};
}
throw new Error(`Unexpected request: ${path}`);
});
posState.searchAndSelectCustomer.mockImplementation(async (customerNumber) => {
const selectedCustomer = {
customerNumber: Number(customerNumber),
name: "Wash Group ApS",
};
posState.selectCustomer(selectedCustomer);
return selectedCustomer;
});
const onCustomerSelected = vi.fn();
const wrapper = mountComponent({ onCustomerSelected });
await wrapper.get('[data-testid="pos-new-customer-inline-action"]').trigger("click");
await wrapper.get('[data-testid="pos-desktop-add-customer-search-input"]').setValue("43423010");
await vi.advanceTimersByTimeAsync(300);
await flushComponentUpdates();
await wrapper.get('[data-testid="pos-desktop-add-customer-company-phone"]').setValue("55550000");
await wrapper.get('[data-testid="pos-desktop-add-customer-contact-email"]').setValue("dispatch@wash-group.test");
await wrapper.get('[data-testid="pos-desktop-add-customer-submit"]').trigger("click");
await flushComponentUpdates();
expect(posState.searchAndSelectCustomer).toHaveBeenCalledWith("55550000");
expect(onCustomerSelected).toHaveBeenCalledWith(
expect.objectContaining({
customerNumber: 55550000,
name: "Wash Group ApS",
})
);
expect(wrapper.find('[data-testid="pos-desktop-add-customer-inline-form"]').exists()).toBe(false);
expect(wrapper.get('button[data-testid="pos-customer-invoice-inline-action"]').classes()).toContain("is-selected");
expect(wrapper.get("input[disabled]").element.value).toBe("Wash Group ApS");
});
});
+67
View File
@@ -10,9 +10,13 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
import {
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
getEdgeGatewayDepartmentWorkspace,
getEdgeGatewayInstallTokenStatus,
getEdgeGatewayModuleConfig,
listEdgeGatewayDepartmentWorkspaces,
rotateNumberPlateScannerKey,
setEdgeGatewayModuleConfig,
updateNumberPlateScanner,
} from "@/services/edgeGateways.js";
describe("edge gateway service", () => {
@@ -76,6 +80,69 @@ describe("edge gateway service", () => {
expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways/install-token/9001/status", "GET", {});
});
it("loads department workspace summaries through the module workspace endpoint", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
data: [],
},
});
await listEdgeGatewayDepartmentWorkspaces();
expect(authenticatedRequestMock).toHaveBeenCalledWith("/modules/edge-gateways/workspace/departments", "GET", {});
});
it("loads one department hardware workspace through the detail endpoint", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
data: {
department: { id: 1 },
},
},
});
await getEdgeGatewayDepartmentWorkspace(1);
expect(authenticatedRequestMock).toHaveBeenCalledWith("/modules/edge-gateways/workspace/departments/1", "GET", {});
});
it("updates scanner lane assignments through the shared scanner endpoint", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
data: {
id: 8,
},
},
});
await updateNumberPlateScanner(8, {
department_id: 1,
lane_id: 7,
name: "North scanner",
});
expect(authenticatedRequestMock).toHaveBeenCalledWith("/numberplatescanners", "PUT", {
id: 8,
department_id: 1,
lane_id: 7,
name: "North scanner",
});
});
it("rotates scanner API keys through the dedicated rotate action", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
data: {
api_key: "rotated-key",
},
},
});
await rotateNumberPlateScannerKey(8);
expect(authenticatedRequestMock).toHaveBeenCalledWith("/numberplatescanners/8/rotate-key", "POST", {});
});
it("keeps a stable browser cache namespace for workspace snapshots", () => {
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
+25 -3
View File
@@ -21,6 +21,14 @@ const edgeGatewaysPageSource = readFileSync(
join(process.cwd(), "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
"utf8"
);
const departmentWorkspaceSource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue"),
"utf8"
);
const fleetLandingSource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayFleetLanding.vue"),
"utf8"
);
const departmentGatewaysPageSource = readFileSync(
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
"utf8"
@@ -69,11 +77,25 @@ describe("edge gateway workspace contract", () => {
it("mounts a module workspace and a safe-subset department workspace", () => {
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
expect(edgeGatewaysPageSource).toContain("import EdgeGatewayFleetLanding");
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
expect(departmentGatewaysPageSource).toContain(':route-driven="false"');
expect(departmentGatewaysPageSource).toContain(':allow-destructive="false"');
expect(departmentGatewaysPageSource).toContain('@open-gateway-page="openGatewayPage"');
expect(departmentGatewaysPageSource).toContain("import EdgeGatewayDepartmentWorkspace");
expect(departmentGatewaysPageSource).toContain("Department Hardware Workspace");
expect(departmentGatewaysPageSource).toContain("<EdgeGatewayDepartmentWorkspace");
});
it("adds department-centric fleet landing and integrated hardware workspace surfaces", () => {
expect(fleetLandingSource).toContain('data-testid="hardware-fleet-landing"');
expect(fleetLandingSource).toContain("hardware-fleet-card-departments");
expect(fleetLandingSource).toContain("hardware-fleet-open-workspace");
expect(departmentWorkspaceSource).toContain('data-testid="department-hardware-workspace"');
expect(departmentWorkspaceSource).toContain('data-testid="department-hardware-tab-gateways"');
expect(departmentWorkspaceSource).toContain('data-testid="department-hardware-panel-scanners"');
expect(departmentWorkspaceSource).toContain('data-testid="department-scanner-save-');
expect(departmentWorkspaceSource).toContain('data-testid="department-scanner-rotate-');
expect(departmentWorkspaceSource).toContain("Rotate API Key");
expect(departmentWorkspaceSource).toContain("Open Fleet Landing");
});
it("registers canonical module routes and keeps legacy redirects", () => {
@@ -219,6 +219,12 @@ const CustomerSearchFieldPosStub = {
},
template: `
<div data-testid="customer-search-field-pos-stub">
<button
type="button"
data-testid="customer-search-new-customer-stub"
>
Ny kunde
</button>
<button
v-if="showCardPaymentButton"
type="button"
@@ -288,6 +294,7 @@ describe("SelectVehicleFormPOS", () => {
const wrapper = mountForm();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-new-customer-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
expect(wrapper.find('[data-testid="expandable-content-box-stub"]').exists()).toBe(false);
});
@@ -299,12 +306,14 @@ describe("SelectVehicleFormPOS", () => {
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-new-customer-stub"]')).toHaveLength(1);
expect(wrapper.find('[data-testid="expandable-content-box-stub"]').exists()).toBe(true);
await wrapper.get('[data-testid="expandable-toggle"]').trigger("click");
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-new-customer-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
});
@@ -320,6 +329,7 @@ describe("SelectVehicleFormPOS", () => {
await nextTick();
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-new-customer-stub"]')).toHaveLength(1);
expect(wrapper.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
});
});
+1
View File
@@ -0,0 +1 @@
hello
+1
View File
@@ -0,0 +1 @@
50420
+11 -5
View File
@@ -1,4 +1,4 @@
import { fileURLToPath, URL } from 'node:url'
import path from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
@@ -63,6 +63,7 @@ function patchBuefyCssMediaQuery() {
export default defineConfig(({ mode }) => {
const isProd = mode === 'production'
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
const workspaceRoot = process.cwd()
// Set COMMIT_HASH env var for use in the app
const version = process.env.npm_package_version || '0.0.0'
@@ -195,7 +196,9 @@ export default defineConfig(({ mode }) => {
].filter(Boolean),
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
// Anchor aliases to the active workspace so hardlinked worktrees do not
// accidentally resolve into a sibling checkout.
'@': path.resolve(workspaceRoot, 'src')
}
},
define: {
@@ -219,11 +222,14 @@ export default defineConfig(({ mode }) => {
ignored: ['**/output/playwright/**']
}
},
optimizeDeps: isPlaywrightRuntime
optimizeDeps: {
disabled: 'dev',
...(isPlaywrightRuntime
? {
noDiscovery: true
entries: ['index.html']
}
: {})
}
: undefined
}
})