Remove configuration cache problems report
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -11,6 +11,8 @@ import { useI18n } from "vue-i18n";
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const slots = useSlots();
|
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 emit = defineEmits(["deleted"]);
|
||||||
const emitDeleted = () => {
|
const emitDeleted = () => {
|
||||||
emit("deleted");
|
emit("deleted");
|
||||||
@@ -94,6 +96,13 @@ const shouldOpenDropdownUp = ref(false);
|
|||||||
const dropdownMaxHeight = ref(null);
|
const dropdownMaxHeight = ref(null);
|
||||||
const isDesktopFlyoutLayout = ref(false);
|
const isDesktopFlyoutLayout = ref(false);
|
||||||
const activeDesktopFlyoutSectionKey = ref(null);
|
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 desktopFlyoutMinViewportWidth = 1400;
|
||||||
const desktopFlyoutRootPanelWidthRem = 15;
|
const desktopFlyoutRootPanelWidthRem = 15;
|
||||||
const desktopFlyoutSubmenuWidthRem = 17;
|
const desktopFlyoutSubmenuWidthRem = 17;
|
||||||
@@ -316,6 +325,7 @@ watch(isDropdownOpen, async (isOpen) => {
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
isDesktopFlyoutLayout.value = false;
|
isDesktopFlyoutLayout.value = false;
|
||||||
activeDesktopFlyoutSectionKey.value = null;
|
activeDesktopFlyoutSectionKey.value = null;
|
||||||
|
clearAttachmentPreviewState();
|
||||||
resetDropdownLayout();
|
resetDropdownLayout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -391,6 +401,383 @@ const hasUser = computed(() => Boolean(props.user_id || props.customer_number ||
|
|||||||
|
|
||||||
const attachmentsFromOrder = ref([]);
|
const attachmentsFromOrder = ref([]);
|
||||||
const attachmentsFromOrderError = ref(null);
|
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(
|
watch(
|
||||||
() => [attachmentsFromOrder.value.length, attachmentsFromOrderError.value, userIdFromCustomerNumber.value],
|
() => [attachmentsFromOrder.value.length, attachmentsFromOrderError.value, userIdFromCustomerNumber.value],
|
||||||
() => {
|
() => {
|
||||||
@@ -400,47 +787,19 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.order_id,
|
||||||
|
() => {
|
||||||
|
void loadOrderAttachments();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener("click", onDocumentClick);
|
document.addEventListener("click", onDocumentClick);
|
||||||
document.addEventListener("keydown", onDocumentKeydown);
|
document.addEventListener("keydown", onDocumentKeydown);
|
||||||
window.addEventListener("resize", onViewportChange);
|
window.addEventListener("resize", onViewportChange);
|
||||||
document.addEventListener("scroll", onViewportChange, true);
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -448,6 +807,7 @@ onBeforeUnmount(() => {
|
|||||||
document.removeEventListener("keydown", onDocumentKeydown);
|
document.removeEventListener("keydown", onDocumentKeydown);
|
||||||
window.removeEventListener("resize", onViewportChange);
|
window.removeEventListener("resize", onViewportChange);
|
||||||
document.removeEventListener("scroll", onViewportChange, true);
|
document.removeEventListener("scroll", onViewportChange, true);
|
||||||
|
clearAttachmentPreviewState();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onShowImpersonationQRCode = (src, directLink) => {
|
const onShowImpersonationQRCode = (src, directLink) => {
|
||||||
@@ -838,8 +1198,8 @@ const buildMenuSection = (key, label, items) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadOrderAttachment = (attachmentId) =>
|
const downloadOrderAttachment = (attachment) =>
|
||||||
SessionUser.objects.orders.functions.downloadAttachment(props.order_id, attachmentId, true).catch(() => {
|
downloadAttachment(attachment).catch(() => {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: t("admin.pos.settings_wheel.error"),
|
title: t("admin.pos.settings_wheel.error"),
|
||||||
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
|
text: t("admin.pos.settings_wheel.error_downloading_attachment"),
|
||||||
@@ -1119,21 +1479,24 @@ const flatBuiltInMenuSections = computed(() => {
|
|||||||
if (vehicleSection) {
|
if (vehicleSection) {
|
||||||
sections.push(vehicleSection);
|
sections.push(vehicleSection);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (attachmentsFromOrder.value.length > 0) {
|
if (attachmentsFromOrder.value.length > 0) {
|
||||||
const attachmentsSection = buildMenuSection("attachments", t("admin.pos.settings_wheel.attached_files"), [
|
const attachmentsSection = buildMenuSection("attachments", t("admin.pos.settings_wheel.attached_files"), [
|
||||||
...attachmentsFromOrder.value.map((attachment) =>
|
...attachmentsFromOrder.value.map((attachment) =>
|
||||||
buildMenuAction(`attachment-${attachment.id}`, {
|
buildMenuAction(`attachment-${attachment.id}`, {
|
||||||
icon: "fas fa-paperclip",
|
icon: "fas fa-paperclip",
|
||||||
label: `${attachment.content?.document || attachment.content?.other}`,
|
label: getAttachmentLabel(attachment),
|
||||||
clickAction: () => downloadOrderAttachment(attachment.id),
|
clickAction: () => downloadOrderAttachment(attachment),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (attachmentsSection) {
|
if (attachmentsSection) {
|
||||||
sections.push(attachmentsSection);
|
sections.push({
|
||||||
}
|
...attachmentsSection,
|
||||||
|
attachments: [...attachmentsFromOrder.value],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,6 +1623,10 @@ const syncDesktopFlyoutState = (triggerRect) => {
|
|||||||
|
|
||||||
const setActiveDesktopFlyoutSection = (sectionKey) => {
|
const setActiveDesktopFlyoutSection = (sectionKey) => {
|
||||||
activeDesktopFlyoutSectionKey.value = sectionKey;
|
activeDesktopFlyoutSectionKey.value = sectionKey;
|
||||||
|
|
||||||
|
if (sectionKey !== "attachments") {
|
||||||
|
activeAttachmentId.value = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1345,13 +1712,40 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
|||||||
v-for="section in desktopFlyoutMenuSections"
|
v-for="section in desktopFlyoutMenuSections"
|
||||||
:key="section.key"
|
:key="section.key"
|
||||||
class="action-settings-wheel-flyout__submenu"
|
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}`"
|
:data-testid="`action-settings-wheel-submenu-${section.key}`"
|
||||||
>
|
>
|
||||||
<p class="action-settings-wheel-flyout__submenu-title">
|
<p class="action-settings-wheel-flyout__submenu-title">
|
||||||
{{ SessionUser.functions.ucFirst(section.label) }}
|
{{ SessionUser.functions.ucFirst(section.label) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="action-settings-wheel-flyout__submenu-items">
|
<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
|
<ActionSettingsWheelItem
|
||||||
v-for="item in section.items"
|
v-for="item in section.items"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
@@ -1361,6 +1755,102 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
|||||||
:click-action="item.clickAction"
|
:click-action="item.clickAction"
|
||||||
:disabled="item.disabled"
|
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1509,6 +1999,7 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
|||||||
|
|
||||||
.action-settings-wheel-flyout__submenu {
|
.action-settings-wheel-flyout__submenu {
|
||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
|
position: relative;
|
||||||
background: linear-gradient(180deg, #f9fbfd 0%, #f3f7fb 100%);
|
background: linear-gradient(180deg, #f9fbfd 0%, #f3f7fb 100%);
|
||||||
border: 1px solid #d9e4ef;
|
border: 1px solid #d9e4ef;
|
||||||
border-radius: 0.8rem;
|
border-radius: 0.8rem;
|
||||||
@@ -1542,6 +2033,10 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
|||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-settings-wheel-flyout__submenu--attachments {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.action-settings-wheel-flyout__standalone-actions {
|
.action-settings-wheel-flyout__standalone-actions {
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
padding-top: 0.35rem;
|
padding-top: 0.35rem;
|
||||||
@@ -1580,14 +2075,147 @@ const setActiveDesktopFlyoutSection = (sectionKey) => {
|
|||||||
flex-shrink: 0;
|
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 {
|
.action-settings-wheel-section-trigger__label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
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-action),
|
||||||
.dropdown-content :deep(.dropdown-item-label) {
|
.dropdown-content :deep(.dropdown-item-label) {
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.action-settings-wheel-attachment-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+69
-8
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import GenericTag from "@/components/viewport/page/templates/generic/graphics/GenericTag.vue";
|
import GenericTag from "@/components/viewport/page/templates/generic/graphics/GenericTag.vue";
|
||||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.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";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
// Function to fetch categories based on the department ID
|
// Function to fetch categories based on the department ID
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -14,9 +14,13 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
// This function will be called when the component is mounted
|
// This function will be called when the component is mounted
|
||||||
const fetchCategories = async (departmentId: number) => {
|
const fetchCategories = async (departmentId: number) => {
|
||||||
await SessionUser.objects.department_categories.get.single(departmentId)
|
pos.categories.setLoading(true);
|
||||||
.then((response) => {
|
pos.categories.unselect();
|
||||||
pos.categories.clear();
|
pos.categories.clear();
|
||||||
|
pos.productList.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await SessionUser.objects.department_categories.get.single(departmentId);
|
||||||
|
|
||||||
// Add the "Suggested" category
|
// Add the "Suggested" category
|
||||||
// pos.categories.add({
|
// pos.categories.add({
|
||||||
@@ -24,7 +28,9 @@ const fetchCategories = async (departmentId: number) => {
|
|||||||
// name: "Suggested",
|
// name: "Suggested",
|
||||||
// });
|
// });
|
||||||
// Add the categories to the pos.categories object
|
// 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 restrictCategories prop is set, only add categories that are in the list
|
||||||
if (props.restrictCategories.length > 0 && !props.restrictCategories.includes(result.category.id)) {
|
if (props.restrictCategories.length > 0 && !props.restrictCategories.includes(result.category.id)) {
|
||||||
return;
|
return;
|
||||||
@@ -34,25 +40,48 @@ const fetchCategories = async (departmentId: number) => {
|
|||||||
name: result.category.name,
|
name: result.category.name,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select the category 6, if it exists, otherwise select the first category
|
// Select the category 6, if it exists, otherwise select the first category
|
||||||
const category6 = pos.categories.get().find(cat => cat.id === 6);
|
const category6 = pos.categories.get().find(cat => cat.id === 6);
|
||||||
if (category6) {
|
if (category6) {
|
||||||
|
pos.productList.setLoading(true);
|
||||||
pos.categories.select(category6);
|
pos.categories.select(category6);
|
||||||
return;
|
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
|
// Fetch categories when the component is mounted
|
||||||
fetchCategories(SessionUser.functions.getDepartmentIdFromUrl());
|
fetchCategories(SessionUser.functions.getDepartmentIdFromUrl());
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 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}`">
|
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id" :data-testid="`pos-mobile-category-${category.id}`">
|
||||||
<GenericTag
|
<GenericTag
|
||||||
:active="pos.categories.isCategorySelected(category)"
|
:active="pos.categories.isCategorySelected(category)"
|
||||||
@@ -62,10 +91,42 @@ onMounted(() => {
|
|||||||
</GenericTag>
|
</GenericTag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.custom-gap {
|
.custom-gap {
|
||||||
margin: 6px 5px;
|
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>
|
</style>
|
||||||
|
|||||||
+112
-4
@@ -28,19 +28,41 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const slots = defineSlots();
|
const slots = defineSlots();
|
||||||
|
let latestProductsRequestId = 0;
|
||||||
|
|
||||||
// Function to fetch categories based on the department ID
|
// Function to fetch categories based on the department ID
|
||||||
// This function will be called when the component is mounted
|
// This function will be called when the component is mounted
|
||||||
const fetchProducts = async (category: PosCategory, departmentId: number) => {
|
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)
|
const requestId = ++latestProductsRequestId;
|
||||||
.then((response) => {
|
pos.productList.setLoading(true);
|
||||||
pos.productList.clear();
|
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) => {
|
orderProducts(response).forEach((result: PosProduct) => {
|
||||||
pos.productList.add(result);
|
pos.productList.add(result);
|
||||||
});
|
});
|
||||||
// TODO: Update prices of products based on customer pricing rules
|
// TODO: Update prices of products based on customer pricing rules
|
||||||
pos.transactionItems.updateTransactionPrices();
|
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
|
// Function to fetch suggested products
|
||||||
@@ -106,10 +128,24 @@ watch(() => props.addons, (newAddons) => {
|
|||||||
const filteredProducts = computed(() => {
|
const filteredProducts = computed(() => {
|
||||||
return pos.productList.get().filter(product => !isProductRestricted(product));
|
return pos.productList.get().filter(product => !isProductRestricted(product));
|
||||||
});
|
});
|
||||||
|
const shouldShowLoadingState = computed(() => {
|
||||||
|
return pos.categories.loading.value || pos.productList.loading.value;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<template v-if="!props.asAddons">
|
||||||
<!-- Display of products, without a "basket" (filtered for customer restrictions) -->
|
<!-- Display of products, without a "basket" (filtered for customer restrictions) -->
|
||||||
<template v-for="product in filteredProducts" :key="product.id">
|
<template v-for="product in filteredProducts" :key="product.id">
|
||||||
@@ -134,4 +170,76 @@ const filteredProducts = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
+46
-175
@@ -1,21 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { computed, defineEmits, onBeforeUnmount, reactive, ref, watch } from "vue";
|
import { watch } from "vue";
|
||||||
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
|
|
||||||
import { actionButtons, metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
|
import { actionButtons, metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
import { usePosAddCustomerForm } from "@/composables/usePosAddCustomerForm.js";
|
||||||
import {
|
|
||||||
applyPosAddCustomerSearchResult,
|
|
||||||
createPosAddCustomerFields,
|
|
||||||
createPosAddCustomerManualOverrides,
|
|
||||||
createPosAddCustomerRequestState,
|
|
||||||
isValidPosAddCustomerSearchResult,
|
|
||||||
nextPosAddCustomerRequestState,
|
|
||||||
resolvePosAddCustomerRequestState,
|
|
||||||
setPosAddCustomerManualOverride,
|
|
||||||
} from "./posAddCustomerAutofill.js";
|
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
|
||||||
const ADD_CUSTOMER_SUBMIT_TEST_ID = "pos-mobile-add-customer-submit";
|
const ADD_CUSTOMER_SUBMIT_TEST_ID = "pos-mobile-add-customer-submit";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -24,38 +12,35 @@ const emit = defineEmits<{
|
|||||||
(e: "close"): void;
|
(e: "close"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const searchQuery = ref("");
|
const {
|
||||||
const searchResult = ref<any | null>(null);
|
searchQuery,
|
||||||
const searching = ref(false);
|
searchResult,
|
||||||
const requestState = ref(createPosAddCustomerRequestState());
|
searching,
|
||||||
const manualOverrides = ref(createPosAddCustomerManualOverrides());
|
submitting,
|
||||||
const form = reactive(createPosAddCustomerFields());
|
errorMessage,
|
||||||
|
form,
|
||||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
isValidSearchResult,
|
||||||
|
canCreateCustomer,
|
||||||
const isValidSearchResult = computed(() =>
|
handleAutofillFieldInput,
|
||||||
isValidPosAddCustomerSearchResult(searchResult.value, t("admin.pos.not_found"))
|
submitCustomer,
|
||||||
);
|
} = usePosAddCustomerForm({
|
||||||
|
onCustomerCreated: async (_selectedCustomer, createdCustomerNumber) => {
|
||||||
const canCreateCustomer = computed(() => {
|
metadata.setCustomerId(createdCustomerNumber);
|
||||||
const requiredFields = [form.cvr, form.companyPhone, form.contactPhone];
|
emit("close");
|
||||||
|
},
|
||||||
return isValidSearchResult.value && requiredFields.every((field) => String(field).trim() !== "");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearQueuedSearch = () => {
|
|
||||||
if (searchDebounceTimer !== null) {
|
|
||||||
clearTimeout(searchDebounceTimer);
|
|
||||||
searchDebounceTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncPopupActionButtons = () => {
|
const syncPopupActionButtons = () => {
|
||||||
const popup = popups.get();
|
const popup = popups.get();
|
||||||
if (!popup) {
|
if (!popup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submitting.value) {
|
||||||
|
popup.actionButtons = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextButtons = [{ ...actionButtons.default.value.cancel }];
|
const nextButtons = [{ ...actionButtons.default.value.cancel }];
|
||||||
|
|
||||||
if (canCreateCustomer.value) {
|
if (canCreateCustomer.value) {
|
||||||
@@ -70,126 +55,6 @@ const syncPopupActionButtons = () => {
|
|||||||
popup.actionButtons = nextButtons;
|
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(
|
watch(
|
||||||
() => [
|
() => [
|
||||||
form.cvr,
|
form.cvr,
|
||||||
@@ -199,19 +64,17 @@ watch(
|
|||||||
form.contactPhone,
|
form.contactPhone,
|
||||||
canCreateCustomer.value,
|
canCreateCustomer.value,
|
||||||
searchResult.value,
|
searchResult.value,
|
||||||
|
errorMessage.value,
|
||||||
|
submitting.value,
|
||||||
],
|
],
|
||||||
syncPopupActionButtons,
|
syncPopupActionButtons,
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearQueuedSearch();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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"
|
data-testid="pos-mobile-add-customer-popup"
|
||||||
>
|
>
|
||||||
<div class="mx-3">
|
<div class="mx-3">
|
||||||
@@ -227,6 +90,7 @@ onBeforeUnmount(() => {
|
|||||||
}"
|
}"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('global.search_cvr')"
|
:placeholder="$t('global.search_cvr')"
|
||||||
|
:disabled="submitting"
|
||||||
data-testid="pos-mobile-add-customer-search-input"
|
data-testid="pos-mobile-add-customer-search-input"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
@@ -244,14 +108,16 @@ onBeforeUnmount(() => {
|
|||||||
<p class="help" v-if="isValidSearchResult">{{ searchResult.name }}</p>
|
<p class="help" v-if="isValidSearchResult">{{ searchResult.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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">
|
<div class="control">
|
||||||
<input
|
<input
|
||||||
v-model="form.companyPhone"
|
v-model="form.companyPhone"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('admin.pos.company_phone')"
|
:placeholder="$t('admin.pos.company_phone')"
|
||||||
:disabled="searchResult === null"
|
:disabled="searchResult === null || submitting"
|
||||||
:class="{ 'is-danger': searchResult !== null && form.companyPhone === '' }"
|
:class="{ 'is-danger': searchResult !== null && form.companyPhone === '' }"
|
||||||
data-testid="pos-mobile-add-customer-company-phone"
|
data-testid="pos-mobile-add-customer-company-phone"
|
||||||
@input="handleAutofillFieldInput('companyPhone')"
|
@input="handleAutofillFieldInput('companyPhone')"
|
||||||
@@ -259,54 +125,59 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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">
|
<div class="control">
|
||||||
<input
|
<input
|
||||||
v-model="form.invoiceEmail"
|
v-model="form.invoiceEmail"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('admin.pos.invoice_email')"
|
:placeholder="$t('admin.pos.invoice_email')"
|
||||||
:disabled="searchResult === null"
|
:disabled="searchResult === null || submitting"
|
||||||
data-testid="pos-mobile-add-customer-invoice-email"
|
data-testid="pos-mobile-add-customer-invoice-email"
|
||||||
@input="handleAutofillFieldInput('invoiceEmail')"
|
@input="handleAutofillFieldInput('invoiceEmail')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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">
|
<div class="control">
|
||||||
<input
|
<input
|
||||||
v-model="form.contactEmail"
|
v-model="form.contactEmail"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('admin.pos.contact_email')"
|
:placeholder="$t('admin.pos.contact_email')"
|
||||||
:disabled="searchResult === null"
|
:disabled="searchResult === null || submitting"
|
||||||
data-testid="pos-mobile-add-customer-contact-email"
|
data-testid="pos-mobile-add-customer-contact-email"
|
||||||
@input="handleAutofillFieldInput('contactEmail')"
|
@input="handleAutofillFieldInput('contactEmail')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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">
|
<div class="control">
|
||||||
<input
|
<input
|
||||||
v-model="form.contactPhone"
|
v-model="form.contactPhone"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('admin.pos.contact_phone')"
|
:placeholder="$t('admin.pos.contact_phone')"
|
||||||
:disabled="searchResult === null"
|
:disabled="searchResult === null || submitting"
|
||||||
:class="{ 'is-danger': searchResult !== null && form.contactPhone === '' }"
|
:class="{ 'is-danger': searchResult !== null && form.contactPhone === '' }"
|
||||||
data-testid="pos-mobile-add-customer-contact-phone"
|
data-testid="pos-mobile-add-customer-contact-phone"
|
||||||
@input="handleAutofillFieldInput('contactPhone')"
|
@input="handleAutofillFieldInput('contactPhone')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="notification is-danger" v-if="popups.get().props.error !== null">
|
<div class="notification is-danger" v-if="errorMessage !== null">
|
||||||
{{ popups.get().props.error }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
</style>
|
|
||||||
|
|||||||
+14
@@ -835,6 +835,7 @@ const transactionItems = {
|
|||||||
/** Categories */
|
/** Categories */
|
||||||
// Categories available for the transaction
|
// Categories available for the transaction
|
||||||
const categoriesList = ref<PosCategory[]>([]);
|
const categoriesList = ref<PosCategory[]>([]);
|
||||||
|
const categoriesLoading = ref<boolean>(false);
|
||||||
// Function to add a new category
|
// Function to add a new category
|
||||||
const addCategory = (category: PosCategory) => {
|
const addCategory = (category: PosCategory) => {
|
||||||
categoriesList.value.push(category);
|
categoriesList.value.push(category);
|
||||||
@@ -876,15 +877,20 @@ const isCurrentCategorySelected = () => {
|
|||||||
const isCategorySelected = (category: PosCategory) => {
|
const isCategorySelected = (category: PosCategory) => {
|
||||||
return currentCategory.value === category;
|
return currentCategory.value === category;
|
||||||
};
|
};
|
||||||
|
const setCategoriesLoading = (state: boolean) => {
|
||||||
|
categoriesLoading.value = state;
|
||||||
|
};
|
||||||
|
|
||||||
// Exporting the categories object for use in other components
|
// Exporting the categories object for use in other components
|
||||||
const categories = {
|
const categories = {
|
||||||
// Categories related functions
|
// Categories related functions
|
||||||
list: categoriesList,
|
list: categoriesList,
|
||||||
|
loading: categoriesLoading,
|
||||||
add: addCategory,
|
add: addCategory,
|
||||||
remove: removeCategory,
|
remove: removeCategory,
|
||||||
clear: clearCategories,
|
clear: clearCategories,
|
||||||
get: getCategories,
|
get: getCategories,
|
||||||
|
setLoading: setCategoriesLoading,
|
||||||
// Current category-related functions
|
// Current category-related functions
|
||||||
selected: currentCategory,
|
selected: currentCategory,
|
||||||
select: setCurrentCategory,
|
select: setCurrentCategory,
|
||||||
@@ -896,6 +902,7 @@ const categories = {
|
|||||||
|
|
||||||
/** Products */
|
/** Products */
|
||||||
const products = ref<PosProduct[]>([]);
|
const products = ref<PosProduct[]>([]);
|
||||||
|
const productsLoading = ref<boolean>(false);
|
||||||
// Function to add a product
|
// Function to add a product
|
||||||
const addProduct = (product: PosProduct) => {
|
const addProduct = (product: PosProduct) => {
|
||||||
products.value.push(product);
|
products.value.push(product);
|
||||||
@@ -919,14 +926,19 @@ const getProducts = () => {
|
|||||||
const isProductInList = (product: PosProduct) => {
|
const isProductInList = (product: PosProduct) => {
|
||||||
return products.value.includes(product);
|
return products.value.includes(product);
|
||||||
};
|
};
|
||||||
|
const setProductsLoading = (state: boolean) => {
|
||||||
|
productsLoading.value = state;
|
||||||
|
};
|
||||||
|
|
||||||
// Exporting the products object for use in other components
|
// Exporting the products object for use in other components
|
||||||
const productList = {
|
const productList = {
|
||||||
list: products,
|
list: products,
|
||||||
|
loading: productsLoading,
|
||||||
add: addProduct,
|
add: addProduct,
|
||||||
remove: removeProduct,
|
remove: removeProduct,
|
||||||
clear: clearProducts,
|
clear: clearProducts,
|
||||||
get: getProducts,
|
get: getProducts,
|
||||||
|
setLoading: setProductsLoading,
|
||||||
isInList: isProductInList,
|
isInList: isProductInList,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1461,10 +1473,12 @@ const resetTransactionItems = () => {
|
|||||||
const resetCategories = () => {
|
const resetCategories = () => {
|
||||||
categories.list.value = [];
|
categories.list.value = [];
|
||||||
categories.selected.value = null;
|
categories.selected.value = null;
|
||||||
|
categories.loading.value = false;
|
||||||
};
|
};
|
||||||
// Function to reset products
|
// Function to reset products
|
||||||
const resetProducts = () => {
|
const resetProducts = () => {
|
||||||
productList.list.value = [];
|
productList.list.value = [];
|
||||||
|
productList.loading.value = false;
|
||||||
};
|
};
|
||||||
// Function to reset camera
|
// Function to reset camera
|
||||||
const resetCamera = () => {
|
const resetCamera = () => {
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { loadList } = usePaginatedListInstance();
|
const { loadList } = usePaginatedListInstance();
|
||||||
|
|
||||||
|
const openDepartmentWorkspace = (departmentId) => {
|
||||||
|
if (!departmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location = `/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways?tab=gates`;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="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)" />
|
<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">
|
<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)">
|
<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>
|
<span class="icon is-small"><i class="fas fa-trash"></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ const redirect = (path) => {
|
|||||||
window.location = 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 machineTypeNames = ref({});
|
||||||
|
|
||||||
const loadMachineTypes = async () => {
|
const loadMachineTypes = async () => {
|
||||||
@@ -167,6 +175,15 @@ watch(() => props.objects, () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="column is-narrow">
|
||||||
<ActionSettingsWheelButton :department_lane_id="object.id" :department_id="object.department" :loadList="loadList">
|
<ActionSettingsWheelButton :department_lane_id="object.id" :department_id="object.department" :loadList="loadList">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ import { showEditNumberPlateScannerForm } from "@/components/forms/superUser/edi
|
|||||||
const redirect = (path) => {
|
const redirect = (path) => {
|
||||||
window.location = path;
|
window.location = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openDepartmentWorkspace = (departmentId) => {
|
||||||
|
if (!departmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(`/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways?tab=scanners`);
|
||||||
|
}
|
||||||
const showApiKey = (apiKey) => {
|
const showApiKey = (apiKey) => {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
title: 'API Key',
|
title: 'API Key',
|
||||||
@@ -46,6 +54,7 @@ const showApiKey = (apiKey) => {
|
|||||||
<th>{{ $t('objects.columns.department') }}</th>
|
<th>{{ $t('objects.columns.department') }}</th>
|
||||||
<th>{{ $t('tables.users.name') }}</th>
|
<th>{{ $t('tables.users.name') }}</th>
|
||||||
<th>{{ $t('objects.columns.notes') }}</th>
|
<th>{{ $t('objects.columns.notes') }}</th>
|
||||||
|
<th>Lane</th>
|
||||||
<th>{{ $t('tables.actions') }}</th>
|
<th>{{ $t('tables.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -55,9 +64,11 @@ const showApiKey = (apiKey) => {
|
|||||||
<td>{{ scanner.department_id }}</td>
|
<td>{{ scanner.department_id }}</td>
|
||||||
<td>{{ scanner.name }}</td>
|
<td>{{ scanner.name }}</td>
|
||||||
<td>{{ scanner.notes }}</td>
|
<td>{{ scanner.notes }}</td>
|
||||||
|
<td>{{ scanner.lane_id || "-" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="buttons">
|
<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-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>
|
<button class="button is-small is-darker" @click="showApiKey(scanner.api_key)">API KEY</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -90,61 +90,84 @@ const props = defineProps({
|
|||||||
required: false
|
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
|
// Define the products
|
||||||
const products = ref([]);
|
const products = ref([]);
|
||||||
onMounted(() => {
|
const productsLoading = ref(true);
|
||||||
// If the category is not defined, get all products
|
const latestProductsRequestId = ref(0);
|
||||||
if (getProductsCategory() === null) {
|
|
||||||
getProducts(department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
|
const applyAttemptAutoSelect = () => {
|
||||||
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;
|
|
||||||
// Wait a bit to ensure the DOM has settled
|
// Wait a bit to ensure the DOM has settled
|
||||||
setTimeout(() => {
|
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;
|
selectedProductId.value = props.attemptAutoSelectId;
|
||||||
emits('onSelectProduct', products.value.find((product) => product.id === props.attemptAutoSelectId));
|
emits('onSelectProduct', matchingProduct);
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 200);
|
}, 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
|
// React to department or category changes
|
||||||
watch(() => department_id.value, (newDeptId) => {
|
watch(() => department_id.value, (newDeptId) => {
|
||||||
// Reload categories for the new department
|
void loadCategories();
|
||||||
loadCategories();
|
void fetchProductsForCategory({ departmentId: newDeptId });
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => getProductsCategory(), (newCategory) => {
|
watch(() => getProductsCategory(), (newCategory) => {
|
||||||
if (newCategory === null) {
|
void fetchProductsForCategory({ category: newCategory });
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getValidOrderId = () => {
|
const getValidOrderId = () => {
|
||||||
@@ -224,40 +247,64 @@ const categories_static = ref([
|
|||||||
|
|
||||||
// Set the categories
|
// Set the categories
|
||||||
const categories = ref([]);
|
const categories = ref([]);
|
||||||
|
const categoriesLoading = ref(true);
|
||||||
|
const latestCategoriesRequestId = ref(0);
|
||||||
|
|
||||||
// Add the dynamic categories
|
// Add the dynamic categories
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
|
const requestId = ++latestCategoriesRequestId.value;
|
||||||
|
const nextCategories = [...categories_static.value];
|
||||||
|
|
||||||
|
categoriesLoading.value = true;
|
||||||
categories.value = [];
|
categories.value = [];
|
||||||
// Add the static categories
|
|
||||||
categories.value.push(...categories_static.value);
|
|
||||||
// Prefer route param, otherwise fallback to global POS department_id
|
// Prefer route param, otherwise fallback to global POS department_id
|
||||||
const departmentId = route?.params?.departmentId ?? department_id.value;
|
const departmentId = route?.params?.departmentId ?? department_id.value;
|
||||||
|
|
||||||
// If the department id is not set, return
|
// If the department id is not set, return
|
||||||
if (departmentId === undefined || departmentId === null) {
|
if (departmentId === undefined || departmentId === null) {
|
||||||
|
if (requestId === latestCategoriesRequestId.value) {
|
||||||
|
categories.value = nextCategories;
|
||||||
|
categoriesLoading.value = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Get the categories
|
|
||||||
await SessionUser.objects.department_categories.get.single(
|
try {
|
||||||
departmentId
|
const response = await SessionUser.objects.department_categories.get.single(departmentId);
|
||||||
).then((response) => {
|
if (requestId !== latestCategoriesRequestId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < response.length; i++) {
|
for (let i = 0; i < response.length; i++) {
|
||||||
// Check if the id is already in the categories by id,
|
// Check if the id is already in the categories by id,
|
||||||
// This is needed to prevent duplicate categories for higher privilege users
|
// 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.
|
// The id is already included in the list. Do nothing.
|
||||||
} else {
|
} else {
|
||||||
categories.value.push({
|
nextCategories.push({
|
||||||
name: response[i].category.name,
|
name: response[i].category.name,
|
||||||
identifier: response[i].category.id
|
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
|
// Load the categories
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCategories();
|
void loadCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -679,16 +726,7 @@ const scrollSelectedIntoCenter = async (isRetry = false) => {
|
|||||||
|
|
||||||
// Watch for changes to the customer number
|
// Watch for changes to the customer number
|
||||||
watch(() => props.customer_number, (newCustomerNumber) => {
|
watch(() => props.customer_number, (newCustomerNumber) => {
|
||||||
// Refetch products for the current category scope
|
void fetchProductsForCategory({ customerNumber: newCustomerNumber });
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When switching from grid to scroll, or when selection changes in scroll mode, ensure visibility
|
// 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 filterProductsIsWash = (productsList) => {
|
||||||
|
const selectedCategory = Number(getProductsCategory());
|
||||||
return productsList.filter((product) => {
|
return productsList.filter((product) => {
|
||||||
if (props.isCustomerBooking) {
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -832,6 +880,20 @@ const orderByOrderPriorityCategories = (productsList) => {
|
|||||||
return 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);
|
const hasAutoSelectedCategory = ref(false);
|
||||||
watch(() => categories.value, (newCategories) => {
|
watch(() => categories.value, (newCategories) => {
|
||||||
@@ -839,30 +901,34 @@ watch(() => categories.value, (newCategories) => {
|
|||||||
if (props.automaticallySelectCategory !== null && !hasAutoSelectedCategory.value) {
|
if (props.automaticallySelectCategory !== null && !hasAutoSelectedCategory.value) {
|
||||||
const targetId = Number(props.automaticallySelectCategory);
|
const targetId = Number(props.automaticallySelectCategory);
|
||||||
if (newCategories.find((category) => Number(category.identifier) === targetId) !== undefined) {
|
if (newCategories.find((category) => Number(category.identifier) === targetId) !== undefined) {
|
||||||
if (getProductsCategory() !== targetId) {
|
const currentCategory = normalizeCategoryIdentifier(getProductsCategory());
|
||||||
|
if (currentCategory !== targetId) {
|
||||||
setProductsCategory(targetId);
|
setProductsCategory(targetId);
|
||||||
|
} else {
|
||||||
|
void fetchProductsForCategory({ category: targetId });
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAutoSelectedCategory.value = true;
|
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
|
// Guarded category selection to avoid redundant reactive churn
|
||||||
const onSelectCategory = (rawId) => {
|
const onSelectCategory = (rawId) => {
|
||||||
|
const targetId = normalizeCategoryIdentifier(rawId);
|
||||||
|
const currentCategory = normalizeCategoryIdentifier(getProductsCategory());
|
||||||
|
const wasShowingRecommended = isShowingRecommended.value;
|
||||||
|
|
||||||
hideRecommendedProducts();
|
hideRecommendedProducts();
|
||||||
// Handle 'null' from mobile <select>
|
|
||||||
const targetId = rawId === 'null' || rawId === null ? null : Number(rawId);
|
if (currentCategory !== targetId) {
|
||||||
if (getProductsCategory() !== targetId) {
|
|
||||||
setProductsCategory(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>
|
</script>
|
||||||
|
|
||||||
@@ -873,8 +939,18 @@ const onSelectCategory = (rawId) => {
|
|||||||
<!-- Categories - Desktop -->
|
<!-- Categories - Desktop -->
|
||||||
<WhiteBox style="padding-bottom: 0; padding-top: 0.6rem;" class="px-0 is-hidden-touch">
|
<WhiteBox style="padding-bottom: 0; padding-top: 0.6rem;" class="px-0 is-hidden-touch">
|
||||||
<template #default>
|
<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 -->
|
<!-- Tabs -->
|
||||||
<div class="tabs" style="overflow-x: auto;">
|
<div v-else class="tabs" style="overflow-x: auto;">
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="category in orderByOrderPriorityCategories(categories)"
|
v-for="category in orderByOrderPriorityCategories(categories)"
|
||||||
@@ -884,6 +960,7 @@ const onSelectCategory = (rawId) => {
|
|||||||
}">
|
}">
|
||||||
<a
|
<a
|
||||||
@click="onSelectCategory(category.identifier)"
|
@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-color': category.identifier === getProductsCategory() ? '#3273dc' : '#fff' }"
|
||||||
style="
|
style="
|
||||||
border-bottom-width: 0.6rem;
|
border-bottom-width: 0.6rem;
|
||||||
@@ -902,19 +979,29 @@ const onSelectCategory = (rawId) => {
|
|||||||
<template #default>
|
<template #default>
|
||||||
<div class="select is-fullwidth mb-2">
|
<div class="select is-fullwidth mb-2">
|
||||||
<select
|
<select
|
||||||
|
data-testid="pos-product-category-select"
|
||||||
@change="onSelectCategory($event.target.value)"
|
@change="onSelectCategory($event.target.value)"
|
||||||
|
:disabled="categoriesLoading"
|
||||||
:value="getProductsCategory() ?? 'null' "
|
: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'">
|
<option v-for="category in categories" :key="category.identifier" :value="category.identifier ?? 'null'">
|
||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
</option>
|
</option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</WhiteBox>
|
</WhiteBox>
|
||||||
</div>
|
</div>
|
||||||
</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 }">
|
<div class="columns is-mobile" :class="{ 'is-multiline': isGridLayout || !isCustomerBooking }">
|
||||||
<!-- Recommended products -->
|
<!-- Recommended products -->
|
||||||
<div class="column is-12" v-if="isShowingRecommended">
|
<div class="column is-12" v-if="isShowingRecommended">
|
||||||
@@ -1017,7 +1104,27 @@ const onSelectCategory = (rawId) => {
|
|||||||
</configurationCategory>
|
</configurationCategory>
|
||||||
</div>
|
</div>
|
||||||
<!-- New product display -->
|
<!-- 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 }">
|
<div class="column" :class="{ 'is-3-desktop': useCompactCard, 'is-6-desktop': !useCompactCard, 'is-12-mobile': !useCompactCard, 'is-10-mobile': useCompactCard }">
|
||||||
<WhiteBox
|
<WhiteBox
|
||||||
class="p-0 pos-product-box"
|
class="p-0 pos-product-box"
|
||||||
@@ -1051,6 +1158,7 @@ const onSelectCategory = (rawId) => {
|
|||||||
</WhiteBox>
|
</WhiteBox>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Products
|
<!-- Products
|
||||||
<div class="column is-4-desktop" v-for="product in products" :key="product.id" v-if="!isShowingRecommended">
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 */
|
/* is-compact and is-selected */
|
||||||
.pos-product-box.is-compact {
|
.pos-product-box.is-compact {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1133,9 +1275,52 @@ const onSelectCategory = (rawId) => {
|
|||||||
border: 0.5rem solid transparent;
|
border: 0.5rem solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.pos-product-box--loading {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
.pos-product-box.is-selected {
|
.pos-product-box.is-selected {
|
||||||
border: 0.5rem solid #3273dc;
|
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 {
|
.tw-scroll-vertical {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -1167,5 +1352,14 @@ const onSelectCategory = (rawId) => {
|
|||||||
.tw-scroll-horizontal .pos-product-box {
|
.tw-scroll-horizontal .pos-product-box {
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pos-loading-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineEmits, defineProps, nextTick, ref, watch } from "vue";
|
import { computed, nextTick, ref, watch } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import {
|
import {
|
||||||
customer_id,
|
customer_id,
|
||||||
@@ -13,6 +13,7 @@ import CustomerSearchField from "@/components/search/economic/customerSearchFiel
|
|||||||
import { isSearching, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
import { isSearching, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
import { useDraftTransactionCustomer } from "@/composables/useDraftTransactionCustomer.js";
|
||||||
|
import { usePosAddCustomerForm } from "@/composables/usePosAddCustomerForm.js";
|
||||||
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
|
import { useStripeReaderAvailability } from "@/composables/useStripeReaderAvailability.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -44,6 +45,7 @@ const selectedDropdownItem = ref(-1);
|
|||||||
const showSelector = ref(false);
|
const showSelector = ref(false);
|
||||||
|
|
||||||
const CUSTOMER_PICKER_MODE_INVOICE = "invoice";
|
const CUSTOMER_PICKER_MODE_INVOICE = "invoice";
|
||||||
|
const CUSTOMER_PICKER_MODE_NEW_CUSTOMER = "new_customer";
|
||||||
const CUSTOMER_PICKER_MODE_DRAFT = "draft";
|
const CUSTOMER_PICKER_MODE_DRAFT = "draft";
|
||||||
const CUSTOMER_PICKER_MODE_CARD = "card";
|
const CUSTOMER_PICKER_MODE_CARD = "card";
|
||||||
|
|
||||||
@@ -76,18 +78,44 @@ const getResolvedQuickAction = () => {
|
|||||||
watch(
|
watch(
|
||||||
[() => customer_id.value, draftTransactionCustomerNumber],
|
[() => customer_id.value, draftTransactionCustomerNumber],
|
||||||
() => {
|
() => {
|
||||||
|
if (activeQuickAction.value === CUSTOMER_PICKER_MODE_NEW_CUSTOMER && !isCustomerSelected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
activeQuickAction.value = getResolvedQuickAction();
|
activeQuickAction.value = getResolvedQuickAction();
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInvoiceMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_INVOICE);
|
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 isDraftMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_DRAFT);
|
||||||
const isCardMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_CARD);
|
const isCardMode = computed(() => activeQuickAction.value === CUSTOMER_PICKER_MODE_CARD);
|
||||||
const isCardPaymentDisabled = computed(
|
const isCardPaymentDisabled = computed(() => props.showCardPaymentButton && !isCardPaymentAvailable.value);
|
||||||
() => props.showCardPaymentButton && !isCardPaymentAvailable.value
|
|
||||||
);
|
|
||||||
const shouldShowSearchInput = computed(() => isInvoiceMode.value && !isCustomerSelected());
|
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 () => {
|
const focusCustomerSearchInput = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@@ -124,6 +152,10 @@ const arrowKeyHandler = (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emitChange = (customer) => {
|
const emitChange = (customer) => {
|
||||||
|
if (!customer || !customer.customerNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
emit("update:customer_id", customer.customerNumber);
|
emit("update:customer_id", customer.customerNumber);
|
||||||
emit("onCustomerSelected", customer);
|
emit("onCustomerSelected", customer);
|
||||||
props.onCustomerSelected(customer);
|
props.onCustomerSelected(customer);
|
||||||
@@ -150,6 +182,16 @@ const onSelectCustomerInvoice = async () => {
|
|||||||
await focusCustomerSearchInput();
|
await focusCustomerSearchInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSelectNewCustomer = () => {
|
||||||
|
activeQuickAction.value = CUSTOMER_PICKER_MODE_NEW_CUSTOMER;
|
||||||
|
selectedDropdownItem.value = -1;
|
||||||
|
showSelector.value = false;
|
||||||
|
|
||||||
|
if (isCustomerSelected()) {
|
||||||
|
selectCustomer(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSelectDraftCustomer = async () => {
|
const onSelectDraftCustomer = async () => {
|
||||||
if (!hasDraftTransactionCustomer.value) {
|
if (!hasDraftTransactionCustomer.value) {
|
||||||
return;
|
return;
|
||||||
@@ -186,6 +228,17 @@ const onClearSelectedCustomer = async () => {
|
|||||||
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
|
||||||
await focusCustomerSearchInput();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -204,6 +257,19 @@ const onClearSelectedCustomer = async () => {
|
|||||||
</span>
|
</span>
|
||||||
<span>{{ t("pos.customer_picker.select_customer_invoice") }}</span>
|
<span>{{ t("pos.customer_picker.select_customer_invoice") }}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
v-if="hasDraftTransactionCustomer"
|
v-if="hasDraftTransactionCustomer"
|
||||||
class="button is-light customer-quick-action customer-quick-action--draft"
|
class="button is-light customer-quick-action customer-quick-action--draft"
|
||||||
@@ -246,7 +312,10 @@ const onClearSelectedCustomer = async () => {
|
|||||||
type="text"
|
type="text"
|
||||||
:disabled="isCustomerSelected()"
|
:disabled="isCustomerSelected()"
|
||||||
@keydown="arrowKeyHandler"
|
@keydown="arrowKeyHandler"
|
||||||
@focusout="selectedDropdownItem = -1; lostfocus()"
|
@focusout="
|
||||||
|
selectedDropdownItem = -1;
|
||||||
|
lostfocus();
|
||||||
|
"
|
||||||
@focusin="showSelector = true"
|
@focusin="showSelector = true"
|
||||||
:tabindex="isCustomerSelected() ? -1 : 0"
|
:tabindex="isCustomerSelected() ? -1 : 0"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@@ -266,7 +335,10 @@ const onClearSelectedCustomer = async () => {
|
|||||||
v-for="result in searchCustomerResults"
|
v-for="result in searchCustomerResults"
|
||||||
:key="result.id"
|
:key="result.id"
|
||||||
class="dropdown-item customer-drop-down-select customer-picker__result"
|
class="dropdown-item customer-drop-down-select customer-picker__result"
|
||||||
@click="selectCustomer(result); emitChange(result)"
|
@click="
|
||||||
|
selectCustomer(result);
|
||||||
|
emitChange(result);
|
||||||
|
"
|
||||||
:class="{
|
:class="{
|
||||||
'is-active': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
|
'is-active': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
|
||||||
'is-drop-down-selected': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
|
'is-drop-down-selected': getSearchIndexByCustomerNumber(result.customerNumber) === selectedDropdownItem,
|
||||||
@@ -279,6 +351,138 @@ const onClearSelectedCustomer = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div v-if="isCustomerSelected()" class="field has-addons-right has-addons mt-1">
|
<div v-if="isCustomerSelected()" class="field has-addons-right has-addons mt-1">
|
||||||
<p class="control is-expanded">
|
<p class="control is-expanded">
|
||||||
@@ -388,6 +592,11 @@ const onClearSelectedCustomer = async () => {
|
|||||||
color: #1b4f92;
|
color: #1b4f92;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customer-quick-action--new .customer-quick-action__icon {
|
||||||
|
background: #edf7f0;
|
||||||
|
color: #1a6a3d;
|
||||||
|
}
|
||||||
|
|
||||||
.customer-quick-action--card .customer-quick-action__icon {
|
.customer-quick-action--card .customer-quick-action__icon {
|
||||||
background: #e4f7fa;
|
background: #e4f7fa;
|
||||||
color: #0f6a7b;
|
color: #0f6a7b;
|
||||||
@@ -437,6 +646,24 @@ const onClearSelectedCustomer = async () => {
|
|||||||
max-height: min(13rem, 34vh);
|
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 {
|
.customer-picker__result {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: normal;
|
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');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
@@ -23,7 +23,8 @@ export const createScanner = (department_id, name, notes) => {
|
|||||||
return axios.post(API_URL + '/numberplatescanners', {
|
return axios.post(API_URL + '/numberplatescanners', {
|
||||||
department_id,
|
department_id,
|
||||||
name,
|
name,
|
||||||
notes
|
notes,
|
||||||
|
...(lane_id !== undefined ? { lane_id } : {})
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
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');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
@@ -40,7 +41,8 @@ export const editScanner = (id, department_id, name, notes) => {
|
|||||||
id,
|
id,
|
||||||
department_id,
|
department_id,
|
||||||
name,
|
name,
|
||||||
notes
|
notes,
|
||||||
|
...(lane_id !== undefined ? { lane_id } : {})
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
|
|||||||
@@ -27,9 +27,29 @@ const validateDepartmentGateConfig = (config) => {
|
|||||||
const normalizedType = String(normalizedConfig?.type ?? '').trim().toUpperCase();
|
const normalizedType = String(normalizedConfig?.type ?? '').trim().toUpperCase();
|
||||||
|
|
||||||
if (normalizedType !== 'PHONE_CALL') {
|
if (normalizedType !== 'PHONE_CALL') {
|
||||||
|
if (normalizedType !== 'RELAY') {
|
||||||
return normalizedConfig;
|
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();
|
const phoneNumber = String(normalizedConfig?.phone_number ?? '').trim();
|
||||||
if (phoneNumber === '') {
|
if (phoneNumber === '') {
|
||||||
throw new Error("PHONE_CALL gates require a 'phone_number' value.");
|
throw new Error("PHONE_CALL gates require a 'phone_number' value.");
|
||||||
|
|||||||
@@ -31,6 +31,62 @@ export const orderProducts = (products) => {
|
|||||||
return 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) => {
|
export const getProductCategory = (category, departmentId = null, final_price = true, customer_number = null) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!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 : ''), {
|
return axios.get(API_URL + '/products?category=' + category + (departmentId ? '&department_id=' + departmentId : '') + (final_price ? '&final_price=true' : '') + (customer_number ? '&customer_id=' + customer_number : ''), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -996,6 +996,7 @@
|
|||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"previous": "Forrige",
|
"previous": "Forrige",
|
||||||
"previous_step": "Forrige trin",
|
"previous_step": "Forrige trin",
|
||||||
|
"preview": "Forhåndsvisning",
|
||||||
"price": "Pris",
|
"price": "Pris",
|
||||||
"print": "Udskriv",
|
"print": "Udskriv",
|
||||||
"privacy": "Privatliv",
|
"privacy": "Privatliv",
|
||||||
@@ -2802,6 +2803,7 @@
|
|||||||
"customer_picker": {
|
"customer_picker": {
|
||||||
"selected_customer": "Valgt kunde",
|
"selected_customer": "Valgt kunde",
|
||||||
"select_customer_invoice": "Eksisterende kunde",
|
"select_customer_invoice": "Eksisterende kunde",
|
||||||
|
"select_new_customer": "Ny kunde",
|
||||||
"select_draft_customer": "Uds\u00e6t",
|
"select_draft_customer": "Uds\u00e6t",
|
||||||
"select_card_payment": "Betalingskort",
|
"select_card_payment": "Betalingskort",
|
||||||
"select_payment_form": "V\u00e6lg betalingsform"
|
"select_payment_form": "V\u00e6lg betalingsform"
|
||||||
|
|||||||
@@ -962,6 +962,7 @@
|
|||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"previous": "Vorherige",
|
"previous": "Vorherige",
|
||||||
"previous_step": "Vorheriger Schritt",
|
"previous_step": "Vorheriger Schritt",
|
||||||
|
"preview": "Vorschau",
|
||||||
"price": "Preis",
|
"price": "Preis",
|
||||||
"print": "Drucken",
|
"print": "Drucken",
|
||||||
"privacy": "Datenschutz",
|
"privacy": "Datenschutz",
|
||||||
@@ -2768,6 +2769,7 @@
|
|||||||
"customer_picker": {
|
"customer_picker": {
|
||||||
"selected_customer": "Ausgew\u00e4hlter Kunde",
|
"selected_customer": "Ausgew\u00e4hlter Kunde",
|
||||||
"select_customer_invoice": "Kundenrechnung",
|
"select_customer_invoice": "Kundenrechnung",
|
||||||
|
"select_new_customer": "Neuer Kunde",
|
||||||
"select_draft_customer": "Später",
|
"select_draft_customer": "Später",
|
||||||
"select_card_payment": "Zahlungskarte",
|
"select_card_payment": "Zahlungskarte",
|
||||||
"select_payment_form": "Zahlungsart wählen"
|
"select_payment_form": "Zahlungsart wählen"
|
||||||
|
|||||||
@@ -996,6 +996,7 @@
|
|||||||
"phone": "Phone",
|
"phone": "Phone",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"previous_step": "Previous step",
|
"previous_step": "Previous step",
|
||||||
|
"preview": "Preview",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"print": "Print",
|
"print": "Print",
|
||||||
"privacy": "Privacy",
|
"privacy": "Privacy",
|
||||||
@@ -2802,6 +2803,7 @@
|
|||||||
"customer_picker": {
|
"customer_picker": {
|
||||||
"selected_customer": "Selected customer",
|
"selected_customer": "Selected customer",
|
||||||
"select_customer_invoice": "Customer invoice",
|
"select_customer_invoice": "Customer invoice",
|
||||||
|
"select_new_customer": "New customer",
|
||||||
"select_draft_customer": "Defer",
|
"select_draft_customer": "Defer",
|
||||||
"select_card_payment": "Payment card",
|
"select_card_payment": "Payment card",
|
||||||
"select_payment_form": "Select payment form"
|
"select_payment_form": "Select payment form"
|
||||||
|
|||||||
@@ -962,6 +962,7 @@
|
|||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"previous": "Forrige",
|
"previous": "Forrige",
|
||||||
"previous_step": "Forrige trinn",
|
"previous_step": "Forrige trinn",
|
||||||
|
"preview": "Forhåndsvisning",
|
||||||
"price": "Pris",
|
"price": "Pris",
|
||||||
"print": "Skriv ut",
|
"print": "Skriv ut",
|
||||||
"privacy": "Integritet",
|
"privacy": "Integritet",
|
||||||
@@ -2768,6 +2769,7 @@
|
|||||||
"customer_picker": {
|
"customer_picker": {
|
||||||
"selected_customer": "Valgt kunde",
|
"selected_customer": "Valgt kunde",
|
||||||
"select_customer_invoice": "Kundefaktura",
|
"select_customer_invoice": "Kundefaktura",
|
||||||
|
"select_new_customer": "Ny kunde",
|
||||||
"select_draft_customer": "Utsett",
|
"select_draft_customer": "Utsett",
|
||||||
"select_card_payment": "Betalingskort",
|
"select_card_payment": "Betalingskort",
|
||||||
"select_payment_form": "Velg betalingsform"
|
"select_payment_form": "Velg betalingsform"
|
||||||
|
|||||||
@@ -962,6 +962,7 @@
|
|||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"previous": "Föregående",
|
"previous": "Föregående",
|
||||||
"previous_step": "Föregående steg",
|
"previous_step": "Föregående steg",
|
||||||
|
"preview": "Förhandsvisning",
|
||||||
"price": "Pris",
|
"price": "Pris",
|
||||||
"print": "Skriv ut",
|
"print": "Skriv ut",
|
||||||
"privacy": "Integritet",
|
"privacy": "Integritet",
|
||||||
@@ -2768,6 +2769,7 @@
|
|||||||
"customer_picker": {
|
"customer_picker": {
|
||||||
"selected_customer": "Vald kund",
|
"selected_customer": "Vald kund",
|
||||||
"select_customer_invoice": "Kundfaktura",
|
"select_customer_invoice": "Kundfaktura",
|
||||||
|
"select_new_customer": "Ny kund",
|
||||||
"select_draft_customer": "Skjut upp",
|
"select_draft_customer": "Skjut upp",
|
||||||
"select_card_payment": "Betalkort",
|
"select_card_payment": "Betalkort",
|
||||||
"select_payment_form": "Välj betalningsform"
|
"select_payment_form": "Välj betalningsform"
|
||||||
|
|||||||
@@ -357,6 +357,12 @@ export const listEdgeGatewayDepartments = async ({ forceRefresh = false } = {})
|
|||||||
return departmentsRequestInFlight;
|
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 } = {}) => {
|
export const listEdgeGateways = async ({ departmentId = null, view = "summary", forceRefresh = false } = {}) => {
|
||||||
const requestKey = listCacheKey({ departmentId, view });
|
const requestKey = listCacheKey({ departmentId, view });
|
||||||
if (!forceRefresh && listRequestsInFlight.has(requestKey)) {
|
if (!forceRefresh && listRequestsInFlight.has(requestKey)) {
|
||||||
@@ -503,6 +509,15 @@ export const setDepartmentGatewayCutover = async (departmentId, transport_mode)
|
|||||||
return response;
|
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 () =>
|
export const getEdgeGatewayModuleConfig = async () =>
|
||||||
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "GET", {}).then((response) => {
|
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "GET", {}).then((response) => {
|
||||||
const entries = normalizeEntries(unwrapEdgeGatewayResponse(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 SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||||
<SuperUserDashboardNavigation />
|
<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 />
|
<DepartmentGates />
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</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 SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||||
<SuperUserDashboardNavigation />
|
<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"/>
|
<DepartmentLanes :advancedView="true"/>
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
@@ -2,13 +2,52 @@
|
|||||||
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||||
<SuperUserDashboardNavigation />
|
<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 />
|
<DepartmentSelfServePagination />
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</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 { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||||
|
import EdgeGatewayFleetLanding from "@/features/edgeGateways/EdgeGatewayFleetLanding.vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
import PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
@@ -179,6 +180,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<EdgeGatewayFleetLanding v-if="!moduleState.unavailable && !selectedGatewayId" />
|
||||||
|
|
||||||
<EdgeGatewayManager
|
<EdgeGatewayManager
|
||||||
v-if="!moduleState.unavailable"
|
v-if="!moduleState.unavailable"
|
||||||
:selected-gateway-id="selectedGatewayId"
|
:selected-gateway-id="selectedGatewayId"
|
||||||
|
|||||||
@@ -1,20 +1,53 @@
|
|||||||
<script setup>
|
<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 SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
|
||||||
import NumberPlateScanners from "@/components/displays/NumberPlateScanners.vue";
|
import NumberPlateScanners from "@/components/displays/NumberPlateScanners.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||||
<SuperUserDashboardNavigation />
|
<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 />
|
<NumberPlateScanners />
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
+5
-18
@@ -1,23 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
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 PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const departmentId = computed(() => Number(route.params.departmentId));
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,16 +17,11 @@ const openGatewayPage = async (gatewayId) => {
|
|||||||
<DepartmentSubPageWrapper>
|
<DepartmentSubPageWrapper>
|
||||||
<template #title>
|
<template #title>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
title="Gateway for afdelingen"
|
title="Department Hardware Workspace"
|
||||||
subtitle="Overview, tasks, logs, statistics, and inventory without destructive controls"
|
subtitle="Overview, self-serve readiness, gates, scanners, and gateway coverage for this department"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<EdgeGatewayManager
|
<EdgeGatewayDepartmentWorkspace :department-id="departmentId" />
|
||||||
:department-id="departmentId"
|
|
||||||
:route-driven="false"
|
|
||||||
:allow-destructive="false"
|
|
||||||
@open-gateway-page="openGatewayPage"
|
|
||||||
/>
|
|
||||||
</DepartmentSubPageWrapper>
|
</DepartmentSubPageWrapper>
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2288,7 +2288,9 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
|||||||
expect((lastActionBox?.y ?? 0) + (lastActionBox?.height ?? 0)).toBeLessThanOrEqual(viewportHeight - 8);
|
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.setViewportSize({ width: 1900, height: 900 });
|
||||||
await page.goto("/admin/12/modules/pos/orders");
|
await page.goto("/admin/12/modules/pos/orders");
|
||||||
await expect(page.locator("table")).toBeVisible();
|
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 sectionsRail = dropdownContent.getByTestId("action-settings-wheel-sections");
|
||||||
const customerSection = dropdownContent.getByTestId("action-settings-wheel-section-customer");
|
const customerSection = dropdownContent.getByTestId("action-settings-wheel-section-customer");
|
||||||
const vehicleSection = dropdownContent.getByTestId("action-settings-wheel-section-vehicle");
|
const vehicleSection = dropdownContent.getByTestId("action-settings-wheel-section-vehicle");
|
||||||
|
const attachmentsSection = dropdownContent.getByTestId("action-settings-wheel-section-attachments");
|
||||||
|
|
||||||
await expect(flyout).toBeVisible();
|
await expect(flyout).toBeVisible();
|
||||||
await expect(customerSection.locator(".fa-chevron-left")).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(flyoutBoxBefore).not.toBeNull();
|
||||||
expect(flyoutBoxAfter).not.toBeNull();
|
expect(flyoutBoxAfter).not.toBeNull();
|
||||||
expect(Math.abs((flyoutBoxAfter?.height ?? 0) - (flyoutBoxBefore?.height ?? 0))).toBeLessThanOrEqual(1);
|
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 }) => {
|
test("keeps the first action reachable while async menu sections load", async ({ page }) => {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
|||||||
test("filters the fleet roster with search and summary chips", async ({ page }) => {
|
test("filters the fleet roster with search and summary chips", async ({ page }) => {
|
||||||
await page.goto("/superuser/configuration/edgegateway");
|
await page.goto("/superuser/configuration/edgegateway");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
|
||||||
await page.getByTestId("gateway-fleet-search").fill("ode");
|
await page.getByTestId("gateway-fleet-search").fill("ode");
|
||||||
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
|
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
|
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);
|
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 }) => {
|
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 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("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-terminal")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("gateway-tab-settings")).toHaveCount(0);
|
await expect(page.getByTestId("gateway-tab-settings")).toHaveCount(0);
|
||||||
await page.getByTestId("gateway-tab-tasks").click();
|
await page.getByTestId("gateway-tab-tasks").click();
|
||||||
|
|||||||
@@ -305,6 +305,46 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
await expect(page.locator("body")).toContainText("Gateway deleted.");
|
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 }) => {
|
test("@smoke cancels an in-progress gateway task so a replacement task can be queued", async ({ page }) => {
|
||||||
await mockApi(page, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test.describe("Edge gateway visuals", () => {
|
|||||||
await page.goto("/superuser/configuration/edgegateway");
|
await page.goto("/superuser/configuration/edgegateway");
|
||||||
|
|
||||||
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible();
|
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("edge-gateway-workspace")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
|
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
|
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
|
||||||
|
|||||||
+268
-4
@@ -120,6 +120,9 @@ function createPosFixture(overrides = {}) {
|
|||||||
|
|
||||||
const baseFixture = {
|
const baseFixture = {
|
||||||
customer,
|
customer,
|
||||||
|
customersByNumber: {
|
||||||
|
[customer.customerNumber]: customer,
|
||||||
|
},
|
||||||
products,
|
products,
|
||||||
departmentCategories: [
|
departmentCategories: [
|
||||||
{
|
{
|
||||||
@@ -158,6 +161,10 @@ function createPosFixture(overrides = {}) {
|
|||||||
orderBookings: [],
|
orderBookings: [],
|
||||||
orderBookingsDelayMs: 0,
|
orderBookingsDelayMs: 0,
|
||||||
duplicateOrders: [],
|
duplicateOrders: [],
|
||||||
|
cvrSearchResponses: {},
|
||||||
|
cvrSearches: [],
|
||||||
|
customerRegistrationResponse: null,
|
||||||
|
customerRegistrations: [],
|
||||||
ordersById,
|
ordersById,
|
||||||
orderItemsByOrderId,
|
orderItemsByOrderId,
|
||||||
markCompletedOrderIds: [],
|
markCompletedOrderIds: [],
|
||||||
@@ -180,6 +187,10 @@ function createPosFixture(overrides = {}) {
|
|||||||
...baseFixture,
|
...baseFixture,
|
||||||
...overrides,
|
...overrides,
|
||||||
customer: overrides.customer || baseFixture.customer,
|
customer: overrides.customer || baseFixture.customer,
|
||||||
|
customersByNumber: {
|
||||||
|
[(overrides.customer || baseFixture.customer).customerNumber]: overrides.customer || baseFixture.customer,
|
||||||
|
...(overrides.customersByNumber || {}),
|
||||||
|
},
|
||||||
products: overrides.products || baseFixture.products,
|
products: overrides.products || baseFixture.products,
|
||||||
departmentCategories: overrides.departmentCategories || baseFixture.departmentCategories,
|
departmentCategories: overrides.departmentCategories || baseFixture.departmentCategories,
|
||||||
vehicles: overrides.vehicles || baseFixture.vehicles,
|
vehicles: overrides.vehicles || baseFixture.vehicles,
|
||||||
@@ -187,6 +198,10 @@ function createPosFixture(overrides = {}) {
|
|||||||
...baseFixture.customerSuggestionsByReg,
|
...baseFixture.customerSuggestionsByReg,
|
||||||
...(overrides.customerSuggestionsByReg || {}),
|
...(overrides.customerSuggestionsByReg || {}),
|
||||||
},
|
},
|
||||||
|
cvrSearchResponses: {
|
||||||
|
...baseFixture.cvrSearchResponses,
|
||||||
|
...(overrides.cvrSearchResponses || {}),
|
||||||
|
},
|
||||||
ordersById: {
|
ordersById: {
|
||||||
...baseFixture.ordersById,
|
...baseFixture.ordersById,
|
||||||
...(overrides.ordersById || {}),
|
...(overrides.ordersById || {}),
|
||||||
@@ -463,13 +478,100 @@ async function mockPosApi(page, fixture) {
|
|||||||
return;
|
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") {
|
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(
|
await route.fulfill(
|
||||||
json({
|
json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
customer_name: fixture.customer.name,
|
customer_name: resolvedCustomer?.name || fixture.customer.name,
|
||||||
economic_customer: fixture.customer,
|
economic_customer: resolvedCustomer || fixture.customer,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -477,15 +579,29 @@ async function mockPosApi(page, fixture) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.endsWith("/customers") && method === "GET") {
|
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(
|
await route.fulfill(
|
||||||
json({
|
json({
|
||||||
success: true,
|
success: true,
|
||||||
data: [fixture.customer],
|
data: customers,
|
||||||
meta: {
|
meta: {
|
||||||
pagination: {
|
pagination: {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10,
|
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);
|
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 ({
|
test("desktop disables the card quick action while all stripe readers are offline and re-enables it after polling", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
@@ -2982,6 +2982,51 @@ test.describe("POS mobile order flow", () => {
|
|||||||
.toBe("CD1234");
|
.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 }) => {
|
test("manual step 2 selection supports addons and additional items", async ({ page }) => {
|
||||||
const orderId = 9402;
|
const orderId = 9402;
|
||||||
const fixture = createMobilePosFixture({
|
const fixture = createMobilePosFixture({
|
||||||
|
|||||||
@@ -152,8 +152,19 @@ async function fillBookingField(page: Page, toggleTestId: string, inputTestId: s
|
|||||||
await input.press("Enter");
|
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 { departmentId, registrationNumber, reference, poNumber, notes } = data;
|
||||||
|
const { selectInitialProduct = true } = options;
|
||||||
|
|
||||||
const bookWashButton = page.locator("#book-wash-button");
|
const bookWashButton = page.locator("#book-wash-button");
|
||||||
if (!(await bookWashButton.isVisible().catch(() => false))) {
|
if (!(await bookWashButton.isVisible().catch(() => false))) {
|
||||||
@@ -190,6 +201,7 @@ export async function completeBookingCreationFlow(page: Page, data: BookingFlowD
|
|||||||
await clickDesktopNext(page);
|
await clickDesktopNext(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectInitialProduct) {
|
||||||
const productCard = page.locator('[data-testid^="pos-product-card-"]:visible').first();
|
const productCard = page.locator('[data-testid^="pos-product-card-"]:visible').first();
|
||||||
await expect(productCard).toBeVisible();
|
await expect(productCard).toBeVisible();
|
||||||
await productCard.click();
|
await productCard.click();
|
||||||
@@ -198,6 +210,21 @@ export async function completeBookingCreationFlow(page: Page, data: BookingFlowD
|
|||||||
await expect(page.getByTestId("booking-mobile-next")).toBeEnabled();
|
await expect(page.getByTestId("booking-mobile-next")).toBeEnabled();
|
||||||
await page.getByTestId("booking-mobile-next").click();
|
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);
|
const selectedDateTime = await selectBookingDateTime(page);
|
||||||
|
|
||||||
|
|||||||
@@ -1325,6 +1325,10 @@ export async function mockMobilePosApi(page, fixture) {
|
|||||||
const categories = departmentId
|
const categories = departmentId
|
||||||
? fixture.departmentCategories.filter((entry) => Number(entry.department_id) === Number(departmentId))
|
? fixture.departmentCategories.filter((entry) => Number(entry.department_id) === Number(departmentId))
|
||||||
: fixture.departmentCategories;
|
: 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) }));
|
await route.fulfill(json({ success: true, data: clone(categories) }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1684,8 +1688,14 @@ export async function mockMobilePosApi(page, fixture) {
|
|||||||
const category = toPositiveInteger(parsedUrl.searchParams.get("category"));
|
const category = toPositiveInteger(parsedUrl.searchParams.get("category"));
|
||||||
const isWash = parsedUrl.searchParams.get("is_wash");
|
const isWash = parsedUrl.searchParams.get("is_wash");
|
||||||
const limit = Number(parsedUrl.searchParams.get("limit") || 0);
|
const limit = Number(parsedUrl.searchParams.get("limit") || 0);
|
||||||
|
const delayMs = Number(
|
||||||
|
(category ? fixture.productsDelayMsByCategory?.[category] : null) ?? fixture.productsDelayMs ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
if (productId) {
|
if (productId) {
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
await route.fulfill(
|
await route.fulfill(
|
||||||
json({
|
json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1706,6 +1716,9 @@ export async function mockMobilePosApi(page, fixture) {
|
|||||||
if (Number.isFinite(limit) && limit > 0) {
|
if (Number.isFinite(limit) && limit > 0) {
|
||||||
products = products.slice(0, limit);
|
products = products.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
await route.fulfill(json({ success: true, data: clone(products) }));
|
await route.fulfill(json({ success: true, data: clone(products) }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2114,6 +2114,9 @@ export function createPosFixture(overrides = {}) {
|
|||||||
paymentIntentsByOrderId: {},
|
paymentIntentsByOrderId: {},
|
||||||
stripeReadersError: null,
|
stripeReadersError: null,
|
||||||
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
|
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
|
||||||
|
departmentCategoriesDelayMs: 0,
|
||||||
|
productsDelayMs: 0,
|
||||||
|
productsDelayMsByCategory: {},
|
||||||
nextOrderId: 54519,
|
nextOrderId: 54519,
|
||||||
nextOrderItemId: 9200,
|
nextOrderItemId: 9200,
|
||||||
nextAttachmentId: 400,
|
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 }) {
|
async function handlePosRoute({ route, request, parsedUrl, pathname, method, posFixture }) {
|
||||||
if (!posFixture) {
|
if (!posFixture) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2259,6 +2276,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.endsWith("/departments/categories") && method === "GET") {
|
if (pathname.endsWith("/departments/categories") && method === "GET") {
|
||||||
|
await maybeDelayFixtureResponse(posFixture.departmentCategoriesDelayMs);
|
||||||
await route.fulfill(json({ success: true, data: posFixture.departmentCategories || [] }));
|
await route.fulfill(json({ success: true, data: posFixture.departmentCategories || [] }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2573,6 +2591,14 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
if (pathname.endsWith("/products") && method === "GET") {
|
if (pathname.endsWith("/products") && method === "GET") {
|
||||||
const productId = Number(parsedUrl.searchParams.get("id") || 0);
|
const productId = Number(parsedUrl.searchParams.get("id") || 0);
|
||||||
const category = Number(parsedUrl.searchParams.get("category") || 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) {
|
if (productId > 0) {
|
||||||
await route.fulfill(
|
await route.fulfill(
|
||||||
json({ success: true, data: (posFixture.products || []).find((product) => product.id === productId) || null })
|
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") {
|
if (pathname.endsWith("/orders/attachments") && method === "DELETE") {
|
||||||
const orderId = Number(parsedUrl.searchParams.get("order_id") || 0);
|
const body = request.postDataJSON?.() || {};
|
||||||
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || 0);
|
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(
|
posFixture.attachmentsByOrderId[orderId] = (posFixture.attachmentsByOrderId[orderId] || []).filter(
|
||||||
(attachment) => attachment.id !== attachmentId
|
(attachment) => attachment.id !== attachmentId
|
||||||
);
|
);
|
||||||
@@ -3142,6 +3169,562 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
return false;
|
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 }) {
|
async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, method, edgeGatewayFixture }) {
|
||||||
if (!edgeGatewayFixture) {
|
if (!edgeGatewayFixture) {
|
||||||
return false;
|
return false;
|
||||||
@@ -3167,6 +3750,8 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
|||||||
return operation;
|
return operation;
|
||||||
};
|
};
|
||||||
const edgeGatewayCollectionPattern = /\/(?:modules\/)?edge-gateways$/;
|
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 edgeGatewayDetailPattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
||||||
const edgeGatewayTasksPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/tasks$/;
|
const edgeGatewayTasksPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/tasks$/;
|
||||||
const edgeGatewayLogsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/logs$/;
|
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 edgeGatewayInstallTokenPattern = /\/(?:modules\/)?edge-gateways\/install-token$/;
|
||||||
const edgeGatewayInstallTokenStatusPattern = /\/(?:modules\/)?edge-gateways\/install-token\/(\d+)\/status$/;
|
const edgeGatewayInstallTokenStatusPattern = /\/(?:modules\/)?edge-gateways\/install-token\/(\d+)\/status$/;
|
||||||
const edgeGatewayBindingsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/bindings$/;
|
const edgeGatewayBindingsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/bindings$/;
|
||||||
|
const numberPlateScannersPattern = /\/numberplatescanners$/;
|
||||||
|
const numberPlateScannerRotatePattern = /\/numberplatescanners\/(\d+)\/rotate-key$/;
|
||||||
const edgeGatewayDeletePattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
const edgeGatewayDeletePattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
||||||
const edgeGatewayCutoverPattern =
|
const edgeGatewayCutoverPattern =
|
||||||
/\/(?:modules\/edge-gateways\/departments\/(\d+)\/cutover|departments\/(\d+)\/gateway-cutover)$/;
|
/\/(?:modules\/edge-gateways\/departments\/(\d+)\/cutover|departments\/(\d+)\/gateway-cutover)$/;
|
||||||
@@ -3230,6 +3817,102 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
|||||||
return true;
|
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") {
|
if (edgeGatewayCollectionPattern.test(pathname) && method === "GET") {
|
||||||
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
||||||
const departmentId = Number(parsedUrl.searchParams.get("department_id") || 0);
|
const departmentId = Number(parsedUrl.searchParams.get("department_id") || 0);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { bookingTestData } from "./fixtures";
|
import { bookingTestData } from "./fixtures";
|
||||||
import { completeBookingCreationFlow } from "./support/bookingFlow";
|
import {
|
||||||
|
completeBookingCreationFlow,
|
||||||
|
goToBookingProductSelectionStep,
|
||||||
|
goToBookingProductSelectionStepWithOptions,
|
||||||
|
} from "./support/bookingFlow";
|
||||||
import { mockApi, primeMockSession } from "./support/network.js";
|
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, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
sessionData: {
|
sessionData: {
|
||||||
@@ -11,12 +15,53 @@ test.beforeEach(async ({ page }) => {
|
|||||||
customer_number: 12345679,
|
customer_number: 12345679,
|
||||||
permissions: ["user"],
|
permissions: ["user"],
|
||||||
},
|
},
|
||||||
pos: true,
|
pos,
|
||||||
});
|
});
|
||||||
|
|
||||||
await primeMockSession(page, { bootPath: "/user" });
|
await primeMockSession(page, { bootPath: "/user" });
|
||||||
});
|
}
|
||||||
|
|
||||||
test("[BOOKINGS][User][Creation] should create a new booking", async ({ page }) => {
|
test("[BOOKINGS][User][Creation] should create a new booking", async ({ page }) => {
|
||||||
|
await prepareBookingPage(page);
|
||||||
await completeBookingCreationFlow(page, bookingTestData);
|
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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { mount } from "@vue/test-utils";
|
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 ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { createTestI18n } from "./helpers/mountWithApp.js";
|
import { createTestI18n } from "./helpers/mountWithApp.js";
|
||||||
@@ -73,6 +73,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
|||||||
functions: {
|
functions: {
|
||||||
fetchAttachments: vi.fn(() => Promise.resolve([])),
|
fetchAttachments: vi.fn(() => Promise.resolve([])),
|
||||||
get_department_id: vi.fn(() => Promise.resolve(1)),
|
get_department_id: vi.fn(() => Promise.resolve(1)),
|
||||||
|
removeAttachment: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
showAttachWashCertificateForm: vi.fn(() => Promise.resolve()),
|
showAttachWashCertificateForm: vi.fn(() => Promise.resolve()),
|
||||||
showChangeCustomerForm: vi.fn(() => Promise.resolve()),
|
showChangeCustomerForm: vi.fn(() => Promise.resolve()),
|
||||||
showChangeInvoiceCollectionForm: 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_lane_setup_new_tab",
|
||||||
"admin.pos.settings_wheel.view_order_new_tab",
|
"admin.pos.settings_wheel.view_order_new_tab",
|
||||||
"admin.pos.settings_wheel.view_vehicle_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_disable_machine",
|
||||||
"superuser.department_lane.force_enable_machine",
|
"superuser.department_lane.force_enable_machine",
|
||||||
];
|
];
|
||||||
@@ -186,10 +194,114 @@ const flushMicrotasks = async () => {
|
|||||||
await Promise.resolve();
|
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", () => {
|
describe("ActionSettingsWheelButton", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getUserIdMock.mockClear();
|
getUserIdMock.mockClear();
|
||||||
SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm.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 () => {
|
it("resolves customer user id once on mount and not on unrelated rerenders", async () => {
|
||||||
@@ -322,4 +434,123 @@ describe("ActionSettingsWheelButton", () => {
|
|||||||
|
|
||||||
windowOpenSpy.mockRestore();
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,9 +10,13 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
|
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
|
||||||
|
getEdgeGatewayDepartmentWorkspace,
|
||||||
getEdgeGatewayInstallTokenStatus,
|
getEdgeGatewayInstallTokenStatus,
|
||||||
getEdgeGatewayModuleConfig,
|
getEdgeGatewayModuleConfig,
|
||||||
|
listEdgeGatewayDepartmentWorkspaces,
|
||||||
|
rotateNumberPlateScannerKey,
|
||||||
setEdgeGatewayModuleConfig,
|
setEdgeGatewayModuleConfig,
|
||||||
|
updateNumberPlateScanner,
|
||||||
} from "@/services/edgeGateways.js";
|
} from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
describe("edge gateway service", () => {
|
describe("edge gateway service", () => {
|
||||||
@@ -76,6 +80,69 @@ describe("edge gateway service", () => {
|
|||||||
expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways/install-token/9001/status", "GET", {});
|
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", () => {
|
it("keeps a stable browser cache namespace for workspace snapshots", () => {
|
||||||
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
|
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ const edgeGatewaysPageSource = readFileSync(
|
|||||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
|
join(process.cwd(), "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
|
||||||
"utf8"
|
"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(
|
const departmentGatewaysPageSource = readFileSync(
|
||||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
|
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
|
||||||
"utf8"
|
"utf8"
|
||||||
@@ -69,11 +77,25 @@ describe("edge gateway workspace contract", () => {
|
|||||||
it("mounts a module workspace and a safe-subset department workspace", () => {
|
it("mounts a module workspace and a safe-subset department workspace", () => {
|
||||||
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
|
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
|
||||||
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
|
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
|
||||||
|
expect(edgeGatewaysPageSource).toContain("import EdgeGatewayFleetLanding");
|
||||||
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
|
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
|
||||||
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
|
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
|
||||||
expect(departmentGatewaysPageSource).toContain(':route-driven="false"');
|
expect(departmentGatewaysPageSource).toContain("import EdgeGatewayDepartmentWorkspace");
|
||||||
expect(departmentGatewaysPageSource).toContain(':allow-destructive="false"');
|
expect(departmentGatewaysPageSource).toContain("Department Hardware Workspace");
|
||||||
expect(departmentGatewaysPageSource).toContain('@open-gateway-page="openGatewayPage"');
|
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", () => {
|
it("registers canonical module routes and keeps legacy redirects", () => {
|
||||||
|
|||||||
@@ -219,6 +219,12 @@ const CustomerSearchFieldPosStub = {
|
|||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div data-testid="customer-search-field-pos-stub">
|
<div data-testid="customer-search-field-pos-stub">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="customer-search-new-customer-stub"
|
||||||
|
>
|
||||||
|
Ny kunde
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="showCardPaymentButton"
|
v-if="showCardPaymentButton"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -288,6 +294,7 @@ describe("SelectVehicleFormPOS", () => {
|
|||||||
const wrapper = mountForm();
|
const wrapper = mountForm();
|
||||||
|
|
||||||
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
|
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.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
|
||||||
expect(wrapper.find('[data-testid="expandable-content-box-stub"]').exists()).toBe(false);
|
expect(wrapper.find('[data-testid="expandable-content-box-stub"]').exists()).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -299,12 +306,14 @@ describe("SelectVehicleFormPOS", () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
|
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);
|
expect(wrapper.find('[data-testid="expandable-content-box-stub"]').exists()).toBe(true);
|
||||||
|
|
||||||
await wrapper.get('[data-testid="expandable-toggle"]').trigger("click");
|
await wrapper.get('[data-testid="expandable-toggle"]').trigger("click");
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
|
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.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,6 +329,7 @@ describe("SelectVehicleFormPOS", () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(wrapper.findAll('[data-testid="customer-search-field-pos-stub"]')).toHaveLength(1);
|
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.findAll('[data-testid="customer-search-card-payment-stub"]')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
50420
|
||||||
+11
-5
@@ -1,4 +1,4 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import path from 'node:path'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
@@ -63,6 +63,7 @@ function patchBuefyCssMediaQuery() {
|
|||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const isProd = mode === 'production'
|
const isProd = mode === 'production'
|
||||||
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
|
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
|
||||||
|
const workspaceRoot = process.cwd()
|
||||||
|
|
||||||
// Set COMMIT_HASH env var for use in the app
|
// Set COMMIT_HASH env var for use in the app
|
||||||
const version = process.env.npm_package_version || '0.0.0'
|
const version = process.env.npm_package_version || '0.0.0'
|
||||||
@@ -195,7 +196,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
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: {
|
define: {
|
||||||
@@ -219,11 +222,14 @@ export default defineConfig(({ mode }) => {
|
|||||||
ignored: ['**/output/playwright/**']
|
ignored: ['**/output/playwright/**']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
optimizeDeps: isPlaywrightRuntime
|
optimizeDeps: {
|
||||||
|
disabled: 'dev',
|
||||||
|
...(isPlaywrightRuntime
|
||||||
? {
|
? {
|
||||||
noDiscovery: true
|
entries: ['index.html']
|
||||||
|
}
|
||||||
|
: {})
|
||||||
}
|
}
|
||||||
: undefined
|
|
||||||
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user