Refactor POS components and implement new features:

- Replaced `POSOrderReference.vue` with `OrderAttachmentsActionButton.vue` and added new features like attachment preview, upload, and management.
- Introduced `POSOrderCustomerWishes.vue` for handling customer wishes fields with autosave behavior.
- Added Playwright global setup/teardown scripts for managing dev server lifecycle during tests.
- Enhanced booking flow test utilities and adjusted visibility rules in `bookingFlow.ts`.
This commit is contained in:
Jeppe Bundgaard
2026-04-13 15:27:34 +02:00
parent 8a25dcd97d
commit 653aa63899
29 changed files with 1465 additions and 425 deletions
+8 -7
View File
@@ -1,6 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`;
const isCI = !!process.env.CI;
export default defineConfig({
@@ -10,6 +11,12 @@ export default defineConfig({
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : 4,
...(process.env.PLAYWRIGHT_BASE_URL
? {}
: {
globalSetup: "./playwright.global-setup.mjs",
globalTeardown: "./playwright.global-teardown.mjs",
}),
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/report" }]
@@ -21,12 +28,6 @@ export default defineConfig({
screenshot: "only-on-failure",
video: "retain-on-failure"
},
webServer: {
command: "npm run dev",
port: 5173,
timeout: 120_000,
reuseExistingServer: !isCI
},
projects: [
{
name: "chromium-desktop",
+147
View File
@@ -0,0 +1,147 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = `http://localhost:${devPort}`;
const pidFile = path.resolve(process.cwd(), "output/playwright/dev-server.json");
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
cwd: process.cwd(),
});
const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi"));
if (!match) {
return null;
}
const pid = Number(match[1]);
const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`;
const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], {
cwd: process.cwd(),
}).catch(() => ({ stdout: "" }));
return {
Id: pid,
CommandLine: processResult.stdout.trim(),
};
}
async function getListeningProcessOnUnix(port) {
try {
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], {
cwd: process.cwd(),
});
const pidMatch = stdout.match(/^p(\d+)$/m);
const commandMatch = stdout.match(/^c(.+)$/m);
if (!pidMatch) {
return null;
}
return {
Id: Number(pidMatch[1]),
CommandLine: commandMatch ? commandMatch[1] : "",
};
} catch {
return null;
}
}
async function getListeningProcess(port) {
if (process.platform === "win32") {
return getListeningProcessOnWindows(port);
}
return getListeningProcessOnUnix(port);
}
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: process.cwd() }).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
async function waitForServerReady(url, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) {
return;
}
} catch {
// keep polling until ready
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`);
}
export default async function globalSetup() {
if (process.env.PLAYWRIGHT_BASE_URL) {
return;
}
await fs.mkdir(path.dirname(pidFile), { recursive: true });
const existing = await getListeningProcess(devPort);
if (existing) {
if (!/vite(?:\.js)?/i.test(existing.CommandLine || "")) {
throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existing.CommandLine}`);
}
await killProcessTree(existing.Id);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const serverProcess =
process.platform === "win32"
? spawn(
"cmd.exe",
["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`],
{
cwd: process.cwd(),
detached: true,
stdio: "ignore",
windowsHide: true,
}
)
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
cwd: process.cwd(),
detached: true,
stdio: "ignore",
});
serverProcess.unref();
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
try {
await waitForServerReady(baseURL);
} catch (error) {
await killProcessTree(serverProcess.pid);
throw error;
}
}
+44
View File
@@ -0,0 +1,44 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const pidFile = path.resolve(process.cwd(), "output/playwright/dev-server.json");
async function killProcessTree(pid) {
if (!pid) {
return;
}
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: process.cwd() }).catch(() => {});
return;
}
try {
process.kill(-pid, "SIGTERM");
} catch {
try {
process.kill(pid, "SIGTERM");
} catch {
// ignore
}
}
}
export default async function globalTeardown() {
if (process.env.PLAYWRIGHT_BASE_URL) {
return;
}
try {
const file = await fs.readFile(pidFile, "utf8");
const { pid } = JSON.parse(file);
await killProcessTree(pid);
} catch {
// ignore missing or malformed state
}
await fs.rm(pidFile, { force: true }).catch(() => {});
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
const baseURL = "http://localhost:4173";
const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL;
+14 -10
View File
@@ -364,6 +364,10 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pos-registration-grid--customer-wishes {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pos-registration-slot {
min-width: 0;
}
@@ -453,6 +457,10 @@
text-transform: uppercase;
}
.pos-registration-field__input--customer-wishes {
text-transform: none;
}
.pos-registration-field__input:focus {
outline: none;
box-shadow: none;
@@ -462,7 +470,7 @@
gap: 0.9rem;
grid-template-columns: minmax(20rem, 1.22fr) minmax(16rem, 1fr);
grid-template-areas:
"license-plates reference"
"license-plates customer-wishes"
"note note";
align-items: stretch;
}
@@ -480,8 +488,8 @@
grid-area: license-plates;
}
.pos-order-items--order-detail .pos-order-metadata-card--reference {
grid-area: reference;
.pos-order-items--order-detail .pos-order-metadata-card--customer-wishes {
grid-area: customer-wishes;
}
.pos-order-items--order-detail .pos-order-metadata-card--note {
@@ -512,12 +520,8 @@
padding: 0.7rem 0.85rem;
}
.pos-order-items--order-detail .pos-order-reference-editor .pos-order-field__control--interactive {
align-items: flex-start;
}
.pos-order-items--order-detail .pos-order-reference-editor .pos-order-field__preview {
-webkit-line-clamp: 2;
.pos-order-items--order-detail .pos-order-customer-wishes .pos-registration-field__value {
font-size: 0.95rem;
}
.pos-order-items--order-detail .pos-order-note-editor .pos-order-field__control {
@@ -602,7 +606,7 @@
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"license-plates"
"reference"
"customer-wishes"
"note";
}
@@ -23,6 +23,10 @@ const props = defineProps({
type: String,
default: "",
},
triggerButtonVariant: {
type: String,
default: "dark",
},
user_id: {
type: Number,
default: null,
@@ -97,6 +101,17 @@ const onActionSelected = () => {
closeDropdown();
};
const isTextTriggerButton = computed(() => props.triggerButtonVariant === "text");
const isIconOnlyTriggerButton = computed(() => props.label.length === 0);
const getTriggerButtonClass = computed(() => ({
button: true,
"is-small": true,
"is-dark": !isTextTriggerButton.value,
"action-settings-wheel-trigger": true,
"action-settings-wheel-trigger--text": isTextTriggerButton.value,
"action-settings-wheel-trigger--icon-only": isIconOnlyTriggerButton.value,
}));
const onDocumentClick = (event) => {
if (!isDropdownOpen.value) {
return;
@@ -681,7 +696,7 @@ const defaultActions = computed(() => {
<div class="dropdown-trigger">
<button
type="button"
class="button is-small is-dark"
:class="getTriggerButtonClass"
aria-haspopup="true"
aria-controls="dropdown-menu"
:aria-expanded="isDropdownOpen ? 'true' : 'false'"
@@ -1068,4 +1083,52 @@ const defaultActions = computed(() => {
</div>
</template>
<style scoped></style>
<style scoped>
.action-settings-wheel-trigger {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.action-settings-wheel-trigger--text {
background: transparent;
border: 0;
box-shadow: none;
color: #25344d;
padding-inline: 0.35rem;
text-decoration: none;
}
.action-settings-wheel-trigger--text:hover,
.action-settings-wheel-trigger--text:focus,
.action-settings-wheel-trigger--text[aria-expanded="true"] {
background: rgba(37, 52, 77, 0.1);
color: #132339;
text-decoration: none;
}
.action-settings-wheel-trigger--text .icon,
.action-settings-wheel-trigger--text .icon:hover,
.action-settings-wheel-trigger--text .icon:focus {
text-decoration: none;
}
.action-settings-wheel-trigger--icon-only {
min-width: auto;
}
.dropdown-menu {
min-width: 15rem;
}
.dropdown-content {
background: #ffffff;
border: 1px solid #cfd8e3;
border-radius: 0.8rem;
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
padding: 0.35rem;
}
.dropdown-content :deep(.dropdown-item-action),
.dropdown-content :deep(.dropdown-item-label) {
border-radius: 0.55rem;
}
</style>
@@ -30,10 +30,10 @@ import { showPopper, removePopperIfOpen, popperBox } from "@/components/displays
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import { customer_name, getOrderDetails, setOrderId, order_id, reference, order_notes } from "@/components/shop/POSDepartmentProcess.vue";
import { customer_name, getOrderDetails, setOrderId, order_id, reference, order_notes, order_po } from "@/components/shop/POSDepartmentProcess.vue";
import SpanSkeleton from "@/components/displays/skeletons/SpanSkeleton.vue";
import PosOrderLicensePlates from "@/components/displays/department/pos/order/PosOrderLicensePlates.vue";
import POSOrderReference from "@/components/displays/department/pos/order/POSOrderReference.vue";
import POSOrderCustomerWishes from "@/components/displays/department/pos/order/POSOrderCustomerWishes.vue";
import POSOrderNote from "@/components/displays/department/pos/order/POSOrderNote.vue";
const { t } = useI18n();
@@ -817,14 +817,14 @@ const deleteOrderItem = async (orderItemId) => {
/>
</section>
<section
class="pos-order-metadata-card pos-order-metadata-card--reference"
data-testid="pos-order-metadata-reference"
class="pos-order-metadata-card pos-order-metadata-card--customer-wishes"
data-testid="pos-order-metadata-customer-wishes"
>
<p class="pos-order-metadata-label">{{ $t('admin.pos.reference') }}</p>
<POSOrderReference
<p class="pos-order-metadata-label">{{ $t('admin.pos.customer_wishes') }}</p>
<POSOrderCustomerWishes
v-bind:order_id="activeOrderId"
v-bind:loadOrder="loadOrder"
v-bind:reference="reference"
v-bind:po="order_po"
/>
</section>
<section
@@ -1098,7 +1098,7 @@ const deleteOrderItem = async (orderItemId) => {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"license-plates"
"reference"
"customer-wishes"
"note";
}
@@ -0,0 +1,140 @@
<script setup>
import { computed, defineProps, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const props = defineProps({
order_id: {
type: Number,
required: true,
},
reference: {
type: String,
required: true,
},
po: {
type: String,
required: true,
},
});
const { t } = useI18n();
const createCustomerWishField = ({ key, label, source, saveValue, testIdBase }) => {
const inputId = `${testIdBase}-input`;
const isEditing = ref(false);
const autosave = useOrderMetadataAutosave({
source,
saveValue: async (value) => {
await saveValue(value);
return value;
},
});
const hasValue = computed(() => autosave.draft.value.length > 0);
watch(() => autosave.draft.value, () => {
if (isEditing.value) {
autosave.scheduleSave();
}
});
const focusInput = () => {
document.getElementById(inputId)?.focus();
};
const openEditor = () => {
if (isEditing.value) {
return;
}
isEditing.value = true;
nextTick(() => {
focusInput();
});
};
const closeEditor = async () => {
isEditing.value = false;
await autosave.flush();
};
return {
key,
label,
inputId,
isEditing,
autosave,
hasValue,
openEditor,
closeEditor,
testIdBase,
};
};
const referenceField = createCustomerWishField({
key: "reference",
label: computed(() => t("pos.order.reference")),
source: () => props.reference,
saveValue: (value) => SessionUser.objects.orders.set.reference(props.order_id, value),
testIdBase: "pos-order-customer-wishes-reference",
});
const poField = createCustomerWishField({
key: "po",
label: computed(() => t("admin.pos.po_number")),
source: () => props.po,
saveValue: (value) => SessionUser.objects.orders.set.po(props.order_id, value),
testIdBase: "pos-order-customer-wishes-po",
});
const fields = [referenceField, poField];
</script>
<template>
<div class="pos-order-customer-wishes">
<div class="pos-registration-grid pos-registration-grid--customer-wishes">
<div v-for="field in fields" :key="field.key" class="pos-registration-slot">
<div class="control pos-registration-control" :class="{ 'is-loading': field.autosave.isSaving.value }">
<div
v-if="field.isEditing.value"
class="pos-registration-field pos-registration-field--editing"
>
<label class="pos-registration-field__label" :for="field.inputId">{{ field.label.value }}</label>
<input
:id="field.inputId"
v-model="field.autosave.draft.value"
:data-testid="`${field.testIdBase}-input`"
class="pos-registration-field__input pos-registration-field__input--customer-wishes"
type="text"
autocomplete="off"
@blur="field.closeEditor()"
@keydown.enter.prevent="field.closeEditor()"
/>
</div>
<button
v-else
class="pos-registration-field"
:class="{ 'pos-registration-field--add': !field.hasValue.value }"
type="button"
:data-testid="field.testIdBase"
:aria-label="!field.hasValue.value ? `${t('common.add')} ${field.label.value}` : undefined"
@click="field.openEditor()"
>
<span class="pos-registration-field__label">{{ field.label.value }}</span>
<span
class="pos-registration-field__value"
:class="{ 'pos-registration-field__value--muted': !field.hasValue.value }"
>
{{ field.hasValue.value ? field.autosave.draft.value : `+ ${t('common.add')}` }}
</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
@@ -1,96 +0,0 @@
<script setup>
import { computed, defineProps, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
const props = defineProps({
order_id: {
type: Number,
required: true
},
reference: {
type: String,
required: true
},
loadOrder: {
type: Function,
required: true
}
});
const { t } = useI18n();
const uniqueId = Math.random().toString(36).slice(2);
const isEditing = ref(false);
const autosave = useOrderMetadataAutosave({
source: () => props.reference,
saveValue: async (value) => {
await SessionUser.objects.orders.set.reference(props.order_id, value);
return value;
},
});
const previewValue = computed(() => autosave.draft.value);
const rowCount = computed(() => Math.max((autosave.draft.value || '').split('\n').length, 1));
watch(() => autosave.draft.value, () => {
if (isEditing.value) {
autosave.scheduleSave();
}
});
const focusOnTextarea = () => {
document.getElementById(`${uniqueId}-textarea`)?.focus();
};
const onFieldClicked = () => {
if (isEditing.value) {
return;
}
isEditing.value = true;
nextTick(() => {
focusOnTextarea();
});
};
const lostFocus = async () => {
isEditing.value = false;
await autosave.flush();
};
</script>
<template>
<div class="pos-order-field pos-order-reference-editor">
<div class="control" :class="{ 'is-loading': autosave.isSaving.value }">
<div v-if="isEditing" class="pos-order-field__control pos-order-field__control--editing">
<textarea
:id="`${uniqueId}-textarea`"
v-model="autosave.draft.value"
data-testid="pos-order-reference-textarea"
class="textarea pos-order-field__input"
:rows="rowCount"
:placeholder="t('pos.order.reference')"
@blur="lostFocus()"
></textarea>
</div>
<button
v-else
class="pos-order-field__control pos-order-field__control--interactive"
:class="{ 'pos-order-field__control--empty': !autosave.draft.value }"
type="button"
@click="onFieldClicked()"
>
<span v-if="previewValue" class="pos-order-field__preview">{{ previewValue }}</span>
<span v-else class="pos-order-field__empty-state">
<span class="pos-order-field__empty-pill">+ {{ t('pos.order.reference') }}</span>
</span>
</button>
</div>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,659 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const props = defineProps({
order: {
type: Object,
required: true,
},
refreshFunction: {
type: Function,
default: () => {},
},
dataTestId: {
type: String,
default: "",
},
});
const { t } = useI18n();
const dropdownRoot = ref(null);
const isDropdownOpen = ref(false);
const attachments = ref([]);
const attachmentsLoaded = ref(false);
const isLoadingAttachments = ref(false);
const activePreviewAttachmentId = ref(null);
const previewLoadingAttachmentId = ref(null);
const previewSourcesById = ref({});
const previewRequestsInFlight = new Set();
const generatedObjectUrls = new Set();
const normalizeAttachments = (order) => (Array.isArray(order?.attachments) ? order.attachments : []);
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
const releaseObjectUrl = (value) => {
if (!isObjectUrl(value)) {
return;
}
URL.revokeObjectURL(value);
generatedObjectUrls.delete(value);
};
const clearPreviewState = () => {
Object.values(previewSourcesById.value).forEach((value) => {
releaseObjectUrl(value);
});
previewSourcesById.value = {};
activePreviewAttachmentId.value = null;
previewLoadingAttachmentId.value = null;
};
const resetAttachmentsFromOrder = () => {
const normalizedAttachments = normalizeAttachments(props.order);
attachments.value = normalizedAttachments;
attachmentsLoaded.value = normalizedAttachments.length > 0;
};
resetAttachmentsFromOrder();
watch(
() => [props.order?.id, props.order?.attachments],
() => {
resetAttachmentsFromOrder();
clearPreviewState();
isDropdownOpen.value = false;
}
);
const canAddOrderAttachments = computed(() => {
return !!props.order?.id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
});
const canAccessOrderAttachments = computed(() => {
return (
SessionUser.canAccessAdmin() ||
SessionUser.canAccessSuperUser() ||
SessionUser.hasPermission("download_order_attachments_own") ||
SessionUser.hasPermission("download_order_attachments")
);
});
const hasAttachmentIndicator = computed(() => {
if (attachments.value.length > 0 || props.order?.has_attachments === true) {
return true;
}
return [props.order?.attachments_count, props.order?.attachment_count, props.order?.attachmentsCount].some(
(value) => {
const normalizedValue = Number.parseInt(value, 10);
return Number.isInteger(normalizedValue) && normalizedValue > 0;
}
);
});
const shouldShowButton = computed(() => {
if (hasAttachmentIndicator.value) {
return canAccessOrderAttachments.value || canAddOrderAttachments.value;
}
return canAddOrderAttachments.value;
});
const triggerIcon = computed(() => (hasAttachmentIndicator.value ? "fas fa-paperclip" : "fas fa-plus"));
const isTextTriggerButton = computed(() => !hasAttachmentIndicator.value);
const triggerButtonClass = computed(() => ({
button: true,
"is-small": true,
"is-dark": hasAttachmentIndicator.value,
"action-settings-wheel-trigger": true,
"action-settings-wheel-trigger--text": isTextTriggerButton.value,
"action-settings-wheel-trigger--icon-only": true,
}));
const activePreviewAttachment = computed(() => {
return attachments.value.find((attachment) => attachment.id === activePreviewAttachmentId.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 activePreviewKind = computed(() => {
if (!activePreviewAttachment.value) {
return "none";
}
return getAttachmentPreviewKind(activePreviewAttachment.value);
});
const activePreviewSource = computed(() => {
if (!activePreviewAttachment.value) {
return null;
}
return previewSourcesById.value[activePreviewAttachment.value.id] ?? null;
});
const closeDropdown = () => {
isDropdownOpen.value = false;
activePreviewAttachmentId.value = null;
previewLoadingAttachmentId.value = null;
};
const onActionSelected = () => {
closeDropdown();
};
const onDocumentClick = (event) => {
if (!isDropdownOpen.value) {
return;
}
if (dropdownRoot.value && !dropdownRoot.value.contains(event.target)) {
closeDropdown();
}
};
const onDocumentKeydown = (event) => {
if (event.key === "Escape") {
closeDropdown();
}
};
onMounted(() => {
document.addEventListener("click", onDocumentClick);
document.addEventListener("keydown", onDocumentKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
document.removeEventListener("keydown", onDocumentKeydown);
clearPreviewState();
});
const ensureAttachmentsLoaded = async (force = false) => {
const shouldRefetchAttachmentList = attachments.value.length === 0;
if (
!props.order?.id ||
(attachmentsLoaded.value && !force && !shouldRefetchAttachmentList) ||
isLoadingAttachments.value
) {
return attachments.value;
}
isLoadingAttachments.value = true;
try {
const response = await SessionUser.objects.orders.functions.fetchAttachments(props.order.id);
attachments.value = Array.isArray(response) ? response : [];
attachmentsLoaded.value = true;
} catch (error) {
console.error("Error loading order attachments:", error);
} finally {
isLoadingAttachments.value = false;
}
return attachments.value;
};
watch(
() => [props.order?.id, props.order?.attachments, canAccessOrderAttachments.value, canAddOrderAttachments.value],
() => {
if (!props.order?.id || (!canAccessOrderAttachments.value && !canAddOrderAttachments.value)) {
return;
}
if (attachments.value.length > 0) {
return;
}
void ensureAttachmentsLoaded();
},
{ immediate: true }
);
const readAttachmentFileAsBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result ?? null);
};
reader.onerror = () => {
reject(new Error("Failed to read attachment file"));
};
reader.readAsDataURL(file);
});
const refreshAfterMutation = async () => {
await ensureAttachmentsLoaded(true);
await props.refreshFunction();
};
const uploadOrderAttachmentFile = async (file) => {
try {
const base64File = await readAttachmentFileAsBase64(file);
const response = await SessionUser.objects.orders.functions.uploadAttachment(props.order.id, file.name, base64File);
if (!response) {
throw new Error("Attachment upload failed");
}
await refreshAfterMutation();
} catch (error) {
console.error("Error uploading order attachment:", error);
Swal.fire({
title: t("admin.pos.settings_wheel.error"),
text: t("admin.pos.attachments_upload_error"),
icon: "error",
});
}
};
const openOrderAttachmentFilePicker = () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = acceptedOrderAttachmentFileTypes;
fileInput.onchange = async (event) => {
const target = event.target;
if (target instanceof HTMLInputElement && target.files && target.files[0]) {
await uploadOrderAttachmentFile(target.files[0]);
}
};
fileInput.click();
};
const showAddOrderAttachmentsForm = () => {
return SessionUser.objects.orders.functions.showAttachWashCertificateForm(props.order.id, async () => {
await refreshAfterMutation();
});
};
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 preview blob", error);
return null;
}
};
const ensurePreviewSource = async (attachment) => {
if (!props.order?.id || !attachment) {
return null;
}
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 SessionUser.objects.orders.functions.downloadAttachment(
props.order.id,
attachment.id,
false
);
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: previewSource,
};
return previewSource;
} catch (error) {
console.warn("Unable to load attachment preview", attachment.id, error);
previewSourcesById.value = {
...previewSourcesById.value,
[attachment.id]: null,
};
return null;
} finally {
previewRequestsInFlight.delete(attachment.id);
if (previewLoadingAttachmentId.value === attachment.id) {
previewLoadingAttachmentId.value = null;
}
}
};
const onAttachmentHover = async (attachment) => {
activePreviewAttachmentId.value = attachment.id;
await ensurePreviewSource(attachment);
};
const downloadOrderAttachment = async (attachment) => {
const downloadLink = await SessionUser.objects.orders.functions.downloadAttachment(
props.order.id,
attachment.id,
false
);
if (!downloadLink) {
return;
}
const link = document.createElement("a");
link.href = downloadLink;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.download = getAttachmentLabel(attachment);
document.body.appendChild(link);
link.click();
link.remove();
};
const toggleDropdown = async () => {
if (isDropdownOpen.value) {
closeDropdown();
return;
}
isDropdownOpen.value = true;
if (hasAttachmentIndicator.value) {
await ensureAttachmentsLoaded();
}
};
</script>
<template>
<div
v-if="shouldShowButton"
ref="dropdownRoot"
class="dropdown is-right"
:class="{ 'is-active': isDropdownOpen }"
:data-testid="props.dataTestId"
>
<div class="dropdown-trigger">
<button
type="button"
:class="triggerButtonClass"
aria-haspopup="true"
aria-controls="dropdown-menu"
:aria-expanded="isDropdownOpen ? 'true' : 'false'"
@click.stop="toggleDropdown"
>
<span class="icon">
<i :class="triggerIcon"></i>
</span>
</button>
</div>
<div class="dropdown-menu order-attachments-dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content" @dropdown-action-selected="onActionSelected">
<ActionSettingsWheelItem
v-if="canAddOrderAttachments"
:label="t('admin.pos.settings_wheel.attach_wash_certificate')"
icon="fas fa-plus"
:click-action="showAddOrderAttachmentsForm"
:disabled="false"
/>
<ActionSettingsWheelItem
v-if="canAddOrderAttachments"
:label="t('admin.pos.attachments_upload_action')"
icon="fas fa-upload"
:click-action="openOrderAttachmentFilePicker"
:disabled="false"
/>
<template v-if="attachments.length > 0">
<ActionSettingsWheelItemLabel :label="t('admin.pos.settings_wheel.attached_files')" />
<div
v-for="attachment in attachments"
:key="attachment.id"
class="order-attachments-previewable-item"
:data-testid="`pos-order-list-attachment-item-${props.order.id}-${attachment.id}`"
@mouseenter="onAttachmentHover(attachment)"
@focusin="onAttachmentHover(attachment)"
>
<ActionSettingsWheelItem
:label="getAttachmentLabel(attachment)"
icon="fas fa-paperclip"
:click-action="() => downloadOrderAttachment(attachment)"
:disabled="false"
/>
</div>
</template>
</div>
<div
v-if="activePreviewAttachment"
class="order-attachments-preview-panel"
:data-testid="`pos-order-list-attachment-preview-${props.order.id}`"
>
<div class="order-attachments-preview-panel__header">
{{ getAttachmentLabel(activePreviewAttachment) }}
</div>
<div class="order-attachments-preview-panel__content">
<span
v-if="previewLoadingAttachmentId === activePreviewAttachment.id"
class="order-attachments-preview-panel__text"
>
{{ t("global.loading") }}
</span>
<img
v-else-if="activePreviewKind === 'image' && activePreviewSource"
:src="activePreviewSource"
alt=""
class="order-attachments-preview-panel__image"
/>
<iframe
v-else-if="activePreviewKind === 'document' && activePreviewSource"
:src="activePreviewSource"
class="order-attachments-preview-panel__document"
title="Attachment preview"
></iframe>
<a
v-else-if="activePreviewKind === 'link'"
:href="activePreviewAttachment.content?.other"
target="_blank"
rel="noopener noreferrer"
class="order-attachments-preview-panel__link"
>
{{ activePreviewAttachment.content?.other }}
</a>
<span v-else-if="activePreviewKind === 'office'" class="order-attachments-preview-panel__text">
{{ t("admin.pos.attachments_office_preview_unavailable") }}
</span>
<span v-else-if="activePreviewKind === 'text'" class="order-attachments-preview-panel__text">
{{ activePreviewAttachment.content?.other }}
</span>
<span v-else class="order-attachments-preview-panel__text">
{{ t("admin.pos.attachments_no_preview") }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.action-settings-wheel-trigger {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.action-settings-wheel-trigger--text {
background: transparent;
border: 0;
box-shadow: none;
color: #25344d;
padding-inline: 0.35rem;
text-decoration: none;
}
.action-settings-wheel-trigger--text:hover,
.action-settings-wheel-trigger--text:focus,
.action-settings-wheel-trigger--text[aria-expanded="true"] {
background: rgba(37, 52, 77, 0.1);
color: #132339;
text-decoration: none;
}
.action-settings-wheel-trigger--text .icon,
.action-settings-wheel-trigger--text .icon:hover,
.action-settings-wheel-trigger--text .icon:focus {
text-decoration: none;
}
.action-settings-wheel-trigger--icon-only {
min-width: auto;
}
.order-attachments-dropdown-menu {
min-width: 15rem;
overflow: visible;
}
.dropdown-content {
background: #ffffff;
border: 1px solid #cfd8e3;
border-radius: 0.8rem;
box-shadow: 0 18px 36px rgba(19, 35, 57, 0.14);
padding: 0.35rem;
}
.dropdown-content :deep(.dropdown-item-action),
.dropdown-content :deep(.dropdown-item-label) {
border-radius: 0.55rem;
}
.order-attachments-previewable-item {
width: 100%;
}
.order-attachments-preview-panel {
position: absolute;
top: 0;
right: calc(100% + 0.75rem);
width: min(22rem, 55vw);
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;
}
.order-attachments-preview-panel__header {
font-size: 0.95rem;
font-weight: 600;
color: #132339;
overflow-wrap: anywhere;
}
.order-attachments-preview-panel__content {
min-height: 17rem;
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;
}
.order-attachments-preview-panel__image,
.order-attachments-preview-panel__document {
width: 100%;
height: 100%;
border: 0;
background: #ffffff;
}
.order-attachments-preview-panel__image {
object-fit: contain;
}
.order-attachments-preview-panel__text,
.order-attachments-preview-panel__link {
padding: 1rem;
text-align: center;
color: #4a5568;
overflow-wrap: anywhere;
}
@media (hover: none) {
.order-attachments-preview-panel {
display: none;
}
}
</style>
@@ -4,7 +4,7 @@ import { useI18n } from "vue-i18n";
const { t } = useI18n();
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { downloadAttachment } from "@/components/shop/POSDepartmentProcess.vue";
import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue";
const props = defineProps({
orders: {
@@ -81,47 +81,6 @@ if (departments.value.length === 0) {
const getEconomicInvoiceModule = (order) => order?.economic_invoice_module ?? null;
const getStripeInvoiceModule = (order) => order?.stripe_invoice_module ?? null;
const getInvoiceCollection = (order) => order?.invoice_collection ?? null;
const getOrderAttachments = (order) => (Array.isArray(order?.attachments) ? order.attachments : []);
const canAddOrderAttachments = (order) => {
return !!order?.id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
};
const canAccessOrderAttachments = () => {
return (
SessionUser.canAccessAdmin() ||
SessionUser.canAccessSuperUser() ||
SessionUser.hasPermission("download_order_attachments_own") ||
SessionUser.hasPermission("download_order_attachments")
);
};
const hasOrderAttachments = (order) => {
if (getOrderAttachments(order).length > 0 || order?.has_attachments === true) {
return true;
}
const attachmentCountCandidates = [order?.attachments_count, order?.attachment_count, order?.attachmentsCount];
return attachmentCountCandidates.some((value) => {
const normalizedValue = Number.parseInt(value, 10);
return Number.isInteger(normalizedValue) && normalizedValue > 0;
});
};
const shouldShowOrderAttachmentsButton = (order) => {
if (hasOrderAttachments(order)) {
return canAccessOrderAttachments() || canAddOrderAttachments(order);
}
return canAddOrderAttachments(order);
};
const getOrderAttachmentsButtonIcon = (order) => (hasOrderAttachments(order) ? "fas fa-paperclip" : "fas fa-plus");
const getOrderAttachmentsButtonLabel = (order) =>
hasOrderAttachments(order) ? "" : `${t("add")} ${t("admin.pos.settings_wheel.attached_files").toLowerCase()}`;
const showAddOrderAttachmentsForm = (order) => {
return SessionUser.objects.orders.functions.showAttachWashCertificateForm(order.id, () => {
loadList();
});
};
const getAttachmentLabel = (attachment) =>
attachment?.content?.document || attachment?.content?.other || `Attachment ${attachment?.id ?? ""}`.trim();
const isOrderInvoicedWithEconomic = (order) => {
const economicInvoiceModule = getEconomicInvoiceModule(order);
@@ -1118,30 +1077,11 @@ const formatCashierName = (order) => {
<td class="is-narrow">
<div class="buttons pos-order-list-actions">
<!-- Attachments -->
<ActionSettingsWheelButton
v-show="shouldShowOrderAttachmentsButton(order)"
:icon="getOrderAttachmentsButtonIcon(order)"
:label="getOrderAttachmentsButtonLabel(order)"
<OrderAttachmentsActionButton
:order="order"
:refresh-function="loadList"
:data-testid="`pos-order-list-attachments-${order.id}`"
>
<template v-slot:actions>
<action-settings-wheel-item
v-if="canAddOrderAttachments(order)"
:label="t('admin.pos.settings_wheel.attach_wash_certificate')"
icon="fas fa-plus"
:click-action="() => showAddOrderAttachmentsForm(order)"
:disabled="false"
/>
<template v-for="attachment in getOrderAttachments(order)" :key="attachment.id">
<action-settings-wheel-item
:label="getAttachmentLabel(attachment)"
icon="fas fa-paperclip"
:click-action="() => downloadAttachment(attachment.id, order.id)"
:disabled="false"
/>
</template>
</template>
</ActionSettingsWheelButton>
<ActionSettingsWheelButton
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
@@ -1361,23 +1301,11 @@ const formatCashierName = (order) => {
>
<template #actions></template>
</ActionSettingsWheelButton>
<ActionSettingsWheelButton
v-show="shouldShowOrderAttachmentsButton(order)"
:icon="'fas fa-paperclip'"
:displayActionsDirectly="true"
<OrderAttachmentsActionButton
:order="order"
:refresh-function="loadList"
:data-testid="`pos-order-list-attachments-${order.id}`"
>
<template #actions>
<template v-for="attachment in getOrderAttachments(order)" :key="attachment.id">
<action-settings-wheel-item
:label="getAttachmentLabel(attachment)"
icon="fas fa-paperclip"
:click-action="() => downloadAttachment(attachment.id, order.id)"
:disabled="false"
/>
</template>
</template>
</ActionSettingsWheelButton>
</div>
<!-- Order line items -->
<div class="content" v-if="props.invoiceView && isAutoExpandAll()">
@@ -141,6 +141,7 @@ export const department_id = ref('');
export const reference = ref('');
export const notes = ref([]); // The customer notes
export const order_notes = ref(''); // The order notes
export const order_po = ref(''); // The PO number
export const reg_1 = ref(''); // Only uppercase letters and numbers without spaces
export const reg_2 = ref(''); // Only uppercase letters and numbers without spaces
export const reg_3 = ref(''); // Only uppercase letters and numbers without spaces
@@ -388,6 +389,7 @@ export const reset_all_values = () => {
completed_at.value = null;
reference.value = '';
order_notes.value = '';
order_po.value = '';
isCreatingOrder.value = false;
createOrderRequest = null;
// Clear the order id from the local storage (if present)
@@ -423,6 +425,7 @@ export const getOrderDetails = async (id = null) => {
department_id.value = order_data.department_id;
reference.value = order_data.reference || '';
order_notes.value = order_data.notes || '';
order_po.value = order_data.po || '';
reg_1.value = order_data.reg_1 || '';
reg_2.value = order_data.reg_2 || '';
reg_3.value = order_data.reg_3 || '';
@@ -1035,6 +1038,7 @@ export const clearCache = () => {
reg_1.value = '';
reg_2.value = '';
reg_3.value = '';
order_po.value = '';
// Clear the scans
scans.value = [];
};
+2
View File
@@ -10,7 +10,9 @@ export const ALLOWED_ORIGINS = [
'https://www.truckwash.io',
'http://localhost:5173',
'http://localhost:4173',
'http://localhost:4174',
'http://127.0.0.1:4173',
'http://127.0.0.1:4174',
];
export const MIGRATION_ORIGIN = 'https://truckwash.io';
+6 -32
View File
@@ -314,6 +314,7 @@
"attachments_office_preview_unavailable": "Forhåndsvisning er ikke tilgjengelig for Office-dokumenter. Last ned for å se.",
"attachments_upload_description": "Upload en ny fil som vedhæftning til denne transaktion.",
"attachments_upload_action": "Upload",
"attachments_upload_error": "Fejl ved upload af vedhaeftet fil.",
"attachments_download_action": "Download",
"buttons": {
"delete_all": "Slet alle",
@@ -357,6 +358,7 @@
"no_wash_certificate": "Intet vaskecertifikat",
"not_found": "Ikke fundet",
"order": "Ordre",
"customer_wishes": "Kundeønsker",
"order_note": "Ordrenote",
"orders": {
"subtitle": "Administrer ordrer",
@@ -374,6 +376,7 @@
"recent_scan_details_empty": "Der er ingen køretøjsdetaljer til denne skanning.",
"recent_scan_details_error": "Køretøjsdetaljer kunne ikke hentes. Prøv en anden skanning.",
"recent_scan_details_loading": "Henter køretøjsdetaljer...",
"po_number": "PO Nummer",
"reference": "Reference",
"reference_cannot_be_empty": "Reference må ikke være tom",
"reference_required_text": "Den valgte kunde kræver et referencenummer for at fortsætte. Indtast et referencenummer nedenfor:",
@@ -608,24 +611,8 @@
},
"day": "Dag",
"dayHeaderFormat": "ddd D/M",
"dayNames": [
"Søndag",
"Mandag",
"Tirsdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lørdag"
],
"dayNamesShort": [
"Søn",
"Man",
"Tir",
"Ons",
"Tor",
"Fre",
"Lør"
],
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
"dayNamesShort": ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Måned",
@@ -643,20 +630,7 @@
"November",
"Desember"
],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
"next": "I dette",
"prev": "Forrige",
"slotLabelFormat": "HH:mm",
+6 -32
View File
@@ -314,6 +314,7 @@
"attachments_office_preview_unavailable": "F?r Office-Dokumente ist keine Vorschau verf?gbar. Bitte zum Anzeigen herunterladen.",
"attachments_upload_description": "Laden Sie eine neue Datei als Anhang zu dieser Transaktion hoch.",
"attachments_upload_action": "Upload",
"attachments_upload_error": "Beim Hochladen der angehaengten Datei ist ein Fehler aufgetreten.",
"attachments_download_action": "Download",
"buttons": {
"delete_all": "Alle l?schen",
@@ -357,6 +358,7 @@
"no_wash_certificate": "Kein Waschzertifikat",
"not_found": "Nicht gefunden",
"order": "Auftrag",
"customer_wishes": "Kundenwünsche",
"order_note": "Auftragsnotiz",
"orders": {
"subtitle": "?bersicht des Waschprotokolls",
@@ -374,6 +376,7 @@
"recent_scan_details_empty": "Für diesen Scan sind keine Fahrzeugdetails verfügbar.",
"recent_scan_details_error": "Fahrzeugdetails konnten nicht geladen werden. Versuchen Sie einen anderen Scan.",
"recent_scan_details_loading": "Fahrzeugdetails werden geladen...",
"po_number": "PO-Nummer",
"reference": "Referenz",
"reference_cannot_be_empty": "Referenz darf nicht leer sein",
"reference_required_text": "Der ausgew?hlte Kunde ben?tigt eine Referenznummer, um fortzufahren. Bitte geben Sie unten eine Referenznummer ein:",
@@ -608,24 +611,8 @@
},
"day": "Tag",
"dayHeaderFormat": "ddd D/M",
"dayNames": [
"Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag"
],
"dayNamesShort": [
"Søn",
"Man",
"Tir",
"Mi",
"Do",
"Fre",
"Lør"
],
"dayNames": ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
"dayNamesShort": ["Søn", "Man", "Tir", "Mi", "Do", "Fre", "Lør"],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Monat",
@@ -643,20 +630,7 @@
"November",
"Desember"
],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
"next": "Neste",
"prev": "Vorherige",
"slotLabelFormat": "HH:mm",
+6 -32
View File
@@ -314,6 +314,7 @@
"attachments_office_preview_unavailable": "Preview not available for Office documents. Please download to view.",
"attachments_upload_description": "Upload a new file as an attachment to this transaction.",
"attachments_upload_action": "Upload",
"attachments_upload_error": "An error occurred while uploading the attached file.",
"attachments_download_action": "Download",
"buttons": {
"delete_all": "Delete all",
@@ -357,6 +358,7 @@
"no_wash_certificate": "No wash certificate",
"not_found": "Not found",
"order": "Order",
"customer_wishes": "Customer wishes",
"order_note": "Order note",
"orders": {
"subtitle": "Overview of wash log",
@@ -374,6 +376,7 @@
"recent_scan_details_empty": "No vehicle details are available for this scan.",
"recent_scan_details_error": "Vehicle details could not be loaded. Please try another scan.",
"recent_scan_details_loading": "Loading vehicle details...",
"po_number": "PO Number",
"reference": "Reference",
"reference_cannot_be_empty": "Reference cannot be empty",
"reference_required_text": "The selected customer requires a reference number to continue. Please enter a reference number below:",
@@ -608,24 +611,8 @@
},
"day": "Day",
"dayHeaderFormat": "ddd D/M",
"dayNames": [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
"dayNamesShort": [
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat"
],
"dayNames": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
"dayNamesShort": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
"eventTimeFormat": "HH:mm",
"list": "List",
"month": "Month",
@@ -643,20 +630,7 @@
"November",
"December"
],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
"next": "Next",
"prev": "Previous",
"slotLabelFormat": "HH:mm",
+6 -32
View File
@@ -309,6 +309,7 @@
"attachments_office_preview_unavailable": "Forhåndsvisning er ikke tilgjengelig for Office-dokumenter. Last ned for å se.",
"attachments_upload_description": "Last opp en ny fil som et vedlegg til denne transaksjonen.",
"attachments_upload_action": "Upload",
"attachments_upload_error": "Det oppsto en feil under opplasting av den vedlagte filen.",
"attachments_download_action": "Download",
"buttons": {
"delete_all": "Delete all",
@@ -352,6 +353,7 @@
"no_wash_certificate": "Ingen vaskesertifikat",
"not_found": "Ikke funnet",
"order": "Bestille",
"customer_wishes": "Kundeønsker",
"order_note": "Bestillingsnotat",
"orders": {
"subtitle": "Oversikt over vaskelogg",
@@ -369,6 +371,7 @@
"recent_scan_details_empty": "Det finnes ingen kjøretøydetaljer for denne skanningen.",
"recent_scan_details_error": "Kjøretøydetaljer kunne ikke lastes inn. Prøv en annen skanning.",
"recent_scan_details_loading": "Laster inn kjøretøydetaljer...",
"po_number": "PO-nummer",
"reference": "Referanse",
"reference_cannot_be_empty": "Referansen kan ikke være tom",
"reference_required_text": "Den valgte kunden krever et referansenummer for å fortsette. Vennligst skriv inn et referansenummer nedenfor:",
@@ -603,24 +606,8 @@
},
"day": "Dag",
"dayHeaderFormat": "ddd D/M",
"dayNames": [
"Søndag",
"Mandag",
"Tirsdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lørdag"
],
"dayNamesShort": [
"Søn",
"Mann",
"Tir",
"Ons",
"Tor",
"Fre",
"Lør"
],
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
"dayNamesShort": ["Søn", "Mann", "Tir", "Ons", "Tor", "Fre", "Lør"],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "MÃ¥ned",
@@ -638,20 +625,7 @@
"november",
"desember"
],
"monthNamesShort": [
"Jan",
"feb",
"Mar",
"apr",
"mai",
"jun",
"jul",
"august",
"sep",
"Okt",
"nov",
"Av"
],
"monthNamesShort": ["Jan", "feb", "Mar", "apr", "mai", "jun", "jul", "august", "sep", "Okt", "nov", "Av"],
"next": "I dette",
"prev": "Forrige",
"slotLabelFormat": "HH:mm",
+6 -33
View File
@@ -309,6 +309,7 @@
"attachments_office_preview_unavailable": "Förhandsvisning är inte tillgänglig för Office-dokument. Ladda ner för att visa.",
"attachments_upload_description": "Ladda upp en ny fil som bilaga till denna transaktion.",
"attachments_upload_action": "Upload",
"attachments_upload_error": "Ett fel uppstod nar den bifogade filen laddades upp.",
"attachments_download_action": "Download",
"buttons": {
"delete_all": "Delete all",
@@ -352,6 +353,7 @@
"no_wash_certificate": "Inget tvättcertifikat",
"not_found": "Not found",
"order": "Order",
"customer_wishes": "Kundönskemål",
"order_note": "Order note",
"orders": {
"subtitle": "översikt över tvättlogg",
@@ -369,6 +371,7 @@
"recent_scan_details_empty": "Det finns inga fordonsdetaljer för den här skanningen.",
"recent_scan_details_error": "Fordonsdetaljer kunde inte laddas. Prova en annan skanning.",
"recent_scan_details_loading": "Laddar fordonsdetaljer...",
"po_number": "PO-nummer",
"reference": "Referens",
"reference_cannot_be_empty": "Referens för inte vara tom",
"reference_required_text": "Den valda kunden kräver ett referensnummer för att fortsätta. Ange ett referensnummer nedan:",
@@ -603,24 +606,8 @@
},
"day": "Dag",
"dayHeaderFormat": "ddd D/M",
"dayNames": [
"Söndag",
"Måndag",
"Tisdag",
"Onsdag",
"Torsdag",
"Fredag",
"Lördag"
],
"dayNamesShort": [
"Sön",
"Man",
"Tir",
"Ons",
"Tor",
"Fre",
"Lör"
],
"dayNames": ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"],
"dayNamesShort": ["Sön", "Man", "Tir", "Ons", "Tor", "Fre", "Lör"],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Månad",
@@ -638,20 +625,7 @@
"November",
"Desember"
],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Des"
],
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
"next": "Neste",
"prev": "Föregående",
"slotLabelFormat": "HH:mm",
@@ -4383,4 +4357,3 @@
}
}
}
+191 -37
View File
@@ -274,7 +274,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await openOrderDetail(page);
await expect(page.getByTestId("pos-order-registration-1")).toBeVisible();
await expect(page.getByTestId("pos-order-metadata-reference")).toBeVisible();
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toBeVisible();
await expect(page.getByTestId("pos-order-metadata-note")).toBeVisible();
const combinedMessages = [...consoleMessages, ...pageErrors].join("\n");
@@ -339,9 +339,9 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(page.getByTestId("pos-order-item-price-9101")).toHaveCSS("text-align", "right");
const licensePlatesSection = page.getByTestId("pos-order-metadata-license-plates");
const referenceSection = page.getByTestId("pos-order-metadata-reference");
const customerWishesSection = page.getByTestId("pos-order-metadata-customer-wishes");
const noteSection = page.getByTestId("pos-order-metadata-note");
const metadataSections = [licensePlatesSection, referenceSection, noteSection];
const metadataSections = [licensePlatesSection, customerWishesSection, noteSection];
for (const section of metadataSections) {
await expect(section).toBeVisible();
@@ -355,9 +355,15 @@ test.describe("Admin POS Orders - desktop settings", () => {
const reg1 = page.getByTestId("pos-order-registration-1");
const reg2Add = page.getByTestId("pos-order-registration-add-2");
const reg3Add = page.getByTestId("pos-order-registration-add-3");
const customerWishesReference = page.getByTestId("pos-order-customer-wishes-reference");
const customerWishesPo = page.getByTestId("pos-order-customer-wishes-po");
await expect(reg1).toBeVisible();
await expect(reg2Add).toBeVisible();
await expect(reg3Add).toBeVisible();
await expect(customerWishesReference).toBeVisible();
await expect(customerWishesPo).toBeVisible();
await expect(customerWishesReference).toContainText("EC21233 - Test Ref. / Intern nummer");
await expect(customerWishesPo).toContainText("+ Tilføj");
const noteEmptyState = page.getByTestId("pos-order-note-empty-state");
await expect(noteEmptyState).toBeVisible();
await expect(noteEmptyState.locator(".pos-order-field__empty-pill")).toHaveText(/^\+\s+\S+/);
@@ -367,25 +373,29 @@ test.describe("Admin POS Orders - desktop settings", () => {
const reg1Box = await reg1.boundingBox();
const reg2AddBox = await reg2Add.boundingBox();
const licensePlatesBox = await licensePlatesSection.boundingBox();
const referenceSectionBox = await referenceSection.boundingBox();
const customerWishesSectionBox = await customerWishesSection.boundingBox();
const noteSectionBox = await noteSection.boundingBox();
const customerWishesReferenceBox = await customerWishesReference.boundingBox();
const customerWishesPoBox = await customerWishesPo.boundingBox();
expect(reg1Box).not.toBeNull();
expect(reg2AddBox).not.toBeNull();
expect(licensePlatesBox).not.toBeNull();
expect(referenceSectionBox).not.toBeNull();
expect(customerWishesSectionBox).not.toBeNull();
expect(noteSectionBox).not.toBeNull();
expect(customerWishesReferenceBox).not.toBeNull();
expect(customerWishesPoBox).not.toBeNull();
expect(Math.abs((reg1Box?.height ?? 0) - (reg2AddBox?.height ?? 0))).toBeLessThanOrEqual(2);
expect(Math.abs((licensePlatesBox?.height ?? 0) - (referenceSectionBox?.height ?? 0))).toBeLessThanOrEqual(2);
expect(referenceSectionBox?.x ?? 0).toBeGreaterThan(licensePlatesBox?.x ?? 0);
expect(noteSectionBox?.y ?? 0).toBeGreaterThan(referenceSectionBox?.y ?? 0);
expect(Math.abs((licensePlatesBox?.height ?? 0) - (customerWishesSectionBox?.height ?? 0))).toBeLessThanOrEqual(2);
expect(customerWishesSectionBox?.x ?? 0).toBeGreaterThan(licensePlatesBox?.x ?? 0);
expect(noteSectionBox?.y ?? 0).toBeGreaterThan(customerWishesSectionBox?.y ?? 0);
expect(Math.abs((licensePlatesBox?.x ?? 0) - (noteSectionBox?.x ?? 0))).toBeLessThanOrEqual(2);
expect(noteSectionBox?.width ?? 0).toBeGreaterThan(referenceSectionBox?.width ?? 0);
expect(noteSectionBox?.width ?? 0).toBeGreaterThan(customerWishesSectionBox?.width ?? 0);
const referenceControlBox = await referenceSection.locator(".pos-order-field__control").boundingBox();
const noteControlBox = await noteSection.locator(".pos-order-field__control").boundingBox();
expect(referenceControlBox).not.toBeNull();
expect(noteControlBox).not.toBeNull();
expect(referenceControlBox?.height ?? 0).toBeLessThan(65);
expect(
Math.abs((customerWishesReferenceBox?.height ?? 0) - (customerWishesPoBox?.height ?? 0))
).toBeLessThanOrEqual(2);
expect(noteControlBox?.height ?? 0).toBeLessThan(100);
await expect(noteEmptyState).not.toContainText("pos.add_note_placeholder");
@@ -444,7 +454,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(getVisibleTestId(page, "pos-order-total")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-item-edit-9101")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-registration-1")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-metadata-reference")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-metadata-customer-wishes")).toBeVisible();
const categoryTabs = productPanel.locator(".tabs li");
await expect(categoryTabs).toHaveCount(3);
@@ -493,46 +503,49 @@ test.describe("Admin POS Orders - desktop settings", () => {
const actions = stepTwo.getByTestId("pos-step-2-actions");
const customerName = () => page.locator('[data-testid="pos-order-customer-name"]:visible').first();
const licensePlatesCard = stepTwo.getByTestId("pos-order-metadata-license-plates");
const referenceCard = stepTwo.getByTestId("pos-order-metadata-reference");
const customerWishesCard = stepTwo.getByTestId("pos-order-metadata-customer-wishes");
const noteCard = stepTwo.getByTestId("pos-order-metadata-note");
await expect(stepTwo).toBeVisible();
await expect(customerName()).toHaveText(/\(TEST\) Pleno Vognmandsforretning/, { timeout: 10000 });
await expect(actions.getByTestId("pos-next-step")).toBeVisible();
await expect(licensePlatesCard).toBeVisible();
await expect(referenceCard).toBeVisible();
await expect(customerWishesCard).toBeVisible();
await expect(noteCard).toBeVisible();
const orderCardBox = await orderCard.boundingBox();
const actionPanelBox = await actions.boundingBox();
const licensePlatesCardBox = await licensePlatesCard.boundingBox();
const referenceCardBox = await referenceCard.boundingBox();
const customerWishesCardBox = await customerWishesCard.boundingBox();
expect(orderCardBox).not.toBeNull();
expect(actionPanelBox).not.toBeNull();
expect(licensePlatesCardBox).not.toBeNull();
expect(referenceCardBox).not.toBeNull();
expect(customerWishesCardBox).not.toBeNull();
const orderCardBottom = (orderCardBox?.y ?? 0) + (orderCardBox?.height ?? 0);
expect(actionPanelBox?.y ?? 0).toBeGreaterThan(orderCardBottom - 2);
expect((actionPanelBox?.y ?? 0) - orderCardBottom).toBeLessThanOrEqual(40);
expect(Math.abs((actionPanelBox?.x ?? 0) - (orderCardBox?.x ?? 0))).toBeLessThanOrEqual(2);
expect(Math.abs((actionPanelBox?.width ?? 0) - (orderCardBox?.width ?? 0))).toBeLessThanOrEqual(2);
expect(referenceCardBox?.y ?? 0).toBeGreaterThan(
expect(customerWishesCardBox?.y ?? 0).toBeGreaterThan(
(licensePlatesCardBox?.y ?? 0) + (licensePlatesCardBox?.height ?? 0) - 2
);
expect(Math.abs((referenceCardBox?.x ?? 0) - (licensePlatesCardBox?.x ?? 0))).toBeLessThanOrEqual(2);
expect(Math.abs((customerWishesCardBox?.x ?? 0) - (licensePlatesCardBox?.x ?? 0))).toBeLessThanOrEqual(2);
await page.reload();
await expect(stepTwo).toBeVisible();
await expect(page).toHaveURL(/\/admin\/12\/modules\/pos\?id=\d+&customer_id=12345679&step=2/);
await expect(getVisibleTestId(page, "pos-order-registration-1")).toContainText("EC21235");
await expect(getVisibleTestId(page, "pos-order-metadata-reference")).toContainText(
await expect(getVisibleTestId(page, "pos-order-metadata-customer-wishes")).toContainText(
"EC21233 - Test Ref. / Intern nummer"
);
await expect(stepTwo.getByTestId("pos-next-step")).toBeVisible();
});
test("autosaves reference and note metadata after a typing pause without blur", async ({ page }) => {
test("autosaves customer wishes and note metadata after a typing pause without blur", async ({ page }) => {
await openOrderDetail(page);
const referenceValue = "AUTOSAVE-REF-2026";
const poValue = "AUTOSAVE-PO-2026";
const noteValue = "Autosaved desktop note";
const referenceRequest = waitForOrderMutation(
@@ -541,8 +554,8 @@ test.describe("Admin POS Orders - desktop settings", () => {
"/orders",
(body) => Number(body.id) === 54518 && body.reference === referenceValue
);
await page.getByTestId("pos-order-metadata-reference").locator("button").click();
const referenceInput = page.getByTestId("pos-order-reference-textarea");
await page.getByTestId("pos-order-customer-wishes-reference").click();
const referenceInput = page.getByTestId("pos-order-customer-wishes-reference-input");
await referenceInput.fill(referenceValue);
const capturedReferenceRequest = await referenceRequest;
@@ -552,6 +565,23 @@ test.describe("Admin POS Orders - desktop settings", () => {
});
await expect(referenceInput).toBeFocused();
const poRequest = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.po === poValue
);
await page.getByTestId("pos-order-customer-wishes-po").click();
const poInput = page.getByTestId("pos-order-customer-wishes-po-input");
await poInput.fill(poValue);
const capturedPoRequest = await poRequest;
expect(capturedPoRequest.postDataJSON()).toMatchObject({
id: 54518,
po: poValue,
});
await expect(poInput).toBeFocused();
const noteRequest = waitForOrderMutation(
page,
"PUT",
@@ -571,10 +601,63 @@ test.describe("Admin POS Orders - desktop settings", () => {
await page.reload();
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-metadata-reference")).toContainText(referenceValue);
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(referenceValue);
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(poValue);
await expect(page.getByTestId("pos-order-metadata-note")).toContainText(noteValue);
});
test("shows empty-state pills for cleared customer wishes fields", async ({ page }) => {
await openOrderDetail(page);
const clearReferenceRequest = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.reference === ""
);
await page.getByTestId("pos-order-customer-wishes-reference").click();
const referenceInput = page.getByTestId("pos-order-customer-wishes-reference-input");
await referenceInput.fill("");
const capturedReferenceRequest = await clearReferenceRequest;
expect(capturedReferenceRequest.postDataJSON()).toMatchObject({
id: 54518,
reference: "",
});
await page.getByTestId("pos-order-customer-wishes-po").click();
const poInput = page.getByTestId("pos-order-customer-wishes-po-input");
const setPoRequest = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.po === "temp-po"
);
await poInput.fill("temp-po");
const capturedSetPoRequest = await setPoRequest;
expect(capturedSetPoRequest.postDataJSON()).toMatchObject({
id: 54518,
po: "temp-po",
});
const clearPoRequest = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.po === ""
);
await poInput.fill("");
const capturedPoRequest = await clearPoRequest;
expect(capturedPoRequest.postDataJSON()).toMatchObject({
id: 54518,
po: "",
});
await page.reload();
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-customer-wishes-reference")).toContainText("+ Tilføj");
await expect(page.getByTestId("pos-order-customer-wishes-po")).toContainText("+ Tilføj");
});
test("autosaves inline registration metadata and reloads normalized values", async ({ page }) => {
await openOrderDetail(page);
@@ -965,12 +1048,13 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(getOrderDetailInput(page, "invoice_collection_id")).toHaveValue("301");
});
test("keeps the attachment control to the left of settings when the order has attachments", async ({ page }) => {
test("shows a paperclip attachment control, all attachment actions, and a hover preview when the order has attachments", async ({
page,
}) => {
await page.goto("/admin/12/modules/pos/orders");
const attachmentAction = getVisibleTestId(page, "pos-order-list-attachments-54518").locator(
".dropdown-trigger button"
);
const attachmentDropdown = getVisibleTestId(page, "pos-order-list-attachments-54518");
const attachmentAction = attachmentDropdown.locator(".dropdown-trigger button");
const settingsAction = getVisibleTestId(page, "pos-order-list-settings-54518").locator(".dropdown-trigger button");
await expect(attachmentAction).toBeVisible();
@@ -982,29 +1066,51 @@ test.describe("Admin POS Orders - desktop settings", () => {
expect(attachmentBox).not.toBeNull();
expect(settingsBox).not.toBeNull();
expect(attachmentBox?.x ?? 0).toBeLessThan(settingsBox?.x ?? 0);
await expect(attachmentAction).toHaveClass(/is-dark/);
await expect(attachmentAction.locator(".fa-paperclip")).toBeVisible();
await attachmentAction.click();
await expect(page.getByRole("button", { name: /safety-seal\.pdf/i })).toBeVisible();
const attachmentItems = attachmentDropdown.locator(".dropdown-content button.dropdown-item-action");
await expect(attachmentItems.nth(0)).toContainText(/vaskecertifikat|wash certificate/i);
await expect(attachmentItems.nth(1)).toContainText(/upload/i);
await expect(attachmentItems.nth(2)).toContainText(/safety-seal\.pdf/i);
const hoveredAttachmentItem = page.getByTestId("pos-order-list-attachment-item-54518-301");
await hoveredAttachmentItem.hover();
const dropdownContent = attachmentDropdown.locator(".dropdown-content");
const previewPanel = page.getByTestId("pos-order-list-attachment-preview-54518");
await expect(previewPanel).toBeVisible();
await expect(previewPanel.locator("iframe")).toBeVisible();
const dropdownContentBox = await dropdownContent.boundingBox();
const previewPanelBox = await previewPanel.boundingBox();
expect(dropdownContentBox).not.toBeNull();
expect(previewPanelBox).not.toBeNull();
expect(previewPanelBox?.x ?? 0).toBeLessThan(dropdownContentBox?.x ?? 0);
});
test("shows a plus-labeled attachment control to the left of settings when the order has no attachments", async ({
test("shows an icon-only plus attachment control and outlined dropdown when the order has no attachments", async ({
page,
}) => {
const { orderId } = await createDisposableOrder(page);
await page.goto("/admin/12/modules/pos/orders");
const attachmentAction = getVisibleTestId(page, `pos-order-list-attachments-${orderId}`).locator(
".dropdown-trigger button"
);
const attachmentDropdown = getVisibleTestId(page, `pos-order-list-attachments-${orderId}`);
const attachmentAction = attachmentDropdown.locator(".dropdown-trigger button");
const settingsAction = getVisibleTestId(page, `pos-order-list-settings-${orderId}`).locator(
".dropdown-trigger button"
);
await expect(attachmentAction).toBeVisible();
await expect(settingsAction).toBeVisible();
await expect(attachmentAction).toHaveClass(/action-settings-wheel-trigger--text/);
await expect(attachmentAction).not.toHaveClass(/is-text/);
await expect(attachmentAction).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(attachmentAction).toHaveCSS("text-decoration-line", "none");
await expect(attachmentAction.locator(".fa-plus")).toBeVisible();
await expect(attachmentAction).toHaveText(/\S+/);
await expect(attachmentAction.locator(".ml-2")).toHaveCount(0);
const attachmentBox = await attachmentAction.boundingBox();
const settingsBox = await settingsAction.boundingBox();
@@ -1014,9 +1120,57 @@ test.describe("Admin POS Orders - desktop settings", () => {
expect(attachmentBox?.x ?? 0).toBeLessThan(settingsBox?.x ?? 0);
await attachmentAction.click();
await expect(
getVisibleTestId(page, `pos-order-list-attachments-${orderId}`).locator("button.dropdown-item-action").first()
).toBeVisible();
const dropdownContent = attachmentDropdown.locator(".dropdown-content");
const attachmentItems = dropdownContent.locator("button.dropdown-item-action");
await expect(dropdownContent).toBeVisible();
await expect(dropdownContent).toHaveCSS("border-top-width", "1px");
await expect(dropdownContent).toHaveCSS("border-right-width", "1px");
await expect(dropdownContent).toHaveCSS("border-bottom-width", "1px");
await expect(dropdownContent).toHaveCSS("border-left-width", "1px");
await expect(dropdownContent).toHaveCSS("border-top-style", "solid");
await expect(attachmentItems.nth(0)).toContainText(/vaskecertifikat|wash certificate/i);
await expect(attachmentItems.nth(1)).toContainText(/upload/i);
});
});
test.describe("Admin POS Orders - desktop attachment discovery", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only attachment discovery coverage");
const posFixture = createPosFixture();
posFixture.ordersById[54518] = {
...posFixture.ordersById[54518],
attachments: [],
has_attachments: false,
attachments_count: 0,
attachment_count: 0,
attachmentsCount: 0,
};
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: posFixture,
});
await primeOperatorSession(page, "pos-orders-attachment-discovery-token");
});
test("discovers fetched attachments and switches the list trigger from plus to paperclip", async ({ page }) => {
await page.goto("/admin/12/modules/pos/orders");
const attachmentDropdown = getVisibleTestId(page, "pos-order-list-attachments-54518");
const attachmentAction = attachmentDropdown.locator(".dropdown-trigger button");
await expect.poll(async () => attachmentAction.locator(".fa-paperclip").count(), { timeout: 10000 }).toBe(1);
await expect(attachmentAction).toHaveClass(/is-dark/);
await expect(attachmentAction.locator(".fa-plus")).toHaveCount(0);
await attachmentAction.click();
const attachmentItems = attachmentDropdown.locator(".dropdown-content button.dropdown-item-action");
await expect(attachmentItems.nth(0)).toContainText(/vaskecertifikat|wash certificate/i);
await expect(attachmentItems.nth(1)).toContainText(/upload/i);
await expect(attachmentItems.nth(2)).toContainText(/safety-seal\.pdf/i);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -3,7 +3,7 @@ import { loginAsUser } from "../fixtures/authHelpers";
import { createPosFixture, mockApi, seedAuthenticatedState } from "../support/network.js";
import { attachPageHealthGuards, expectBodyHasContent, expectOneVisible, settlePage } from "./helpers";
const LOCAL_PROD_BASE_URL = "http://127.0.0.1:4173";
const LOCAL_PROD_BASE_URL = "http://localhost:4173";
const LOGIN_URL = /\/login(?:\?.*)?$/;
const POS_PERMISSIONS = [
"admin",
+5 -4
View File
@@ -35,22 +35,23 @@ async function clickDesktopNext(page: Page) {
}
async function ensureVehicleInput(page: Page) {
const reg1Input = page.locator("#reg1-input");
const reg1Input = page.locator('input[id="reg1-input"]:visible').first();
if (await reg1Input.isVisible().catch(() => false)) {
return reg1Input;
}
await page.click("#reg1-button");
await page.locator('button[id="reg1-button"]:visible').first().click();
await expect(reg1Input).toBeVisible();
return reg1Input;
}
async function fillBookingField(page: Page, toggleTestId: string, inputTestId: string, value: string) {
const input = page.getByTestId(inputTestId);
const input = page.locator(`[data-testid="${inputTestId}"]:visible`).first();
const toggle = page.locator(`[data-testid="${toggleTestId}"]:visible`).first();
if (!(await input.isVisible().catch(() => false))) {
await page.getByTestId(toggleTestId).click();
await toggle.click();
await expect(input).toBeVisible();
}
+95 -1
View File
@@ -4,6 +4,10 @@ const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAukB9pY9ZxQAAAAASUVORK5CYII=",
"base64"
);
const TINY_PDF = Buffer.from(
"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF",
"utf8"
);
function json(body, status = 200) {
return {
@@ -21,6 +25,24 @@ function binary(body, contentType = "image/png", status = 200) {
};
}
function getAttachmentPreviewContentType(attachment = null) {
const filename = String(
attachment?.content?.image || attachment?.content?.document || attachment?.content?.other || ""
).toLowerCase();
if (filename.endsWith(".pdf")) {
return {
body: TINY_PDF,
contentType: "application/pdf",
};
}
return {
body: TINY_PNG,
contentType: "image/png",
};
}
function mergeFixture(base, overrides = {}) {
return {
...base,
@@ -890,6 +912,7 @@ export function createPosFixture(overrides = {}) {
],
unknownVehicles: [],
orderBookings: [],
nextOrderBookingId: 9001,
numberPlateScanners: [
{ id: 1, name: "North scanner" },
{ id: 2, name: "South scanner" },
@@ -940,6 +963,7 @@ export function createPosFixture(overrides = {}) {
customer_id: defaultCustomer.customerNumber,
department_id: 12,
reference: "EC21233 - Test Ref. / Intern nummer",
po: "",
notes: "",
reg_1: "EC21235",
reg_2: "",
@@ -1134,7 +1158,58 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
}
if (pathname.endsWith("/bookings") && method === "GET") {
await route.fulfill(json({ success: true, data: [] }));
await route.fulfill(json({ success: true, data: Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [] }));
return true;
}
if (pathname.endsWith("/order-bookings") && method === "POST") {
const body = request.postDataJSON?.() || {};
const bookingId = Number(posFixture.nextOrderBookingId || 9001);
const customerNumber = Number(body.customer_number || 0);
const departmentId = Number(body.department || 0);
const createdAt = toSqlDateTime();
const bookingDate = String(body.datetime || createdAt).slice(0, 10);
const customer = posFixture.customersByNumber?.[customerNumber] || null;
const serviceNames = Array.isArray(body.items)
? body.items
.map((item) => String(item?.name || "").trim())
.filter(Boolean)
: [];
const createdBooking = {
id: bookingId,
customer_number: customerNumber,
customer_name: customer?.name || "E2E User",
department: departmentId,
date: bookingDate,
datetime: body.datetime || createdAt,
regNrTraekker: body.reg_1 || "",
regNrTrailer: body.reg_2 || "",
reg_1: body.reg_1 || "",
reg_2: body.reg_2 || "",
reg_3: body.reg_3 || "",
reference_number: body.reference || "",
reference: body.reference || "",
notes: body.note || "",
note: body.note || "",
po: body.po || "",
pickup_bool: body.pickup ? 1 : 0,
pickup: Boolean(body.pickup),
created_at: createdAt,
status: "pending",
wash_type: serviceNames.join(", "),
parsed_services: {
string: serviceNames.join(", "),
array: serviceNames,
},
wash_certificate_pdf: null,
washCertificateStatus: null,
};
posFixture.nextOrderBookingId = bookingId + 1;
posFixture.orderBookings = [createdBooking, ...(posFixture.orderBookings || [])];
await route.fulfill(json({ success: true, data: createdBooking }));
return true;
}
@@ -1342,6 +1417,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
customer_id: Number(body.customer_id),
department_id: Number(body.department_id || body.department || 12),
reference: body.reference || "",
po: body.po || "",
notes: body.notes || "",
reg_1: normalizeRegistrationValue(body.reg_1),
reg_2: normalizeRegistrationValue(body.reg_2),
@@ -2015,6 +2091,24 @@ export async function mockApi(page, options = {}) {
? buildEdgeGatewayShellSocketConfig(edgeGatewayOptions.shellSocket || {})
: null;
await page.route(/https:\/\/cdn\.example\.test\/orders\/\d+\/attachments\/\d+$/i, async (route) => {
const request = route.request();
if (request.method() !== "GET" || !posFixture) {
await route.continue();
return;
}
const url = new URL(request.url());
const pathSegments = url.pathname.split("/").filter(Boolean);
const orderId = Number(pathSegments[1] || 0);
const attachmentId = Number(pathSegments[3] || 0);
const attachment =
(posFixture.attachmentsByOrderId?.[orderId] || []).find((entry) => Number(entry.id) === attachmentId) || null;
const previewResponse = getAttachmentPreviewContentType(attachment);
await route.fulfill(binary(previewResponse.body, previewResponse.contentType));
});
await page.route(API_HOST, async (route) => {
const request = route.request();
const url = request.url();
+16 -2
View File
@@ -1,8 +1,22 @@
import { test } from "@playwright/test";
import { loginAsUser, bookingTestData } from "./fixtures";
import { bookingTestData } from "./fixtures";
import { completeBookingCreationFlow } from "./support/bookingFlow";
import { mockApi, primeMockSession } from "./support/network.js";
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
sessionData: {
display_name: "Booking User",
customer_number: 12345679,
permissions: ["user"],
},
pos: true,
});
await primeMockSession(page, { bootPath: "/user" });
});
test("[BOOKINGS][User][Creation] should create a new booking", async ({ page }) => {
await loginAsUser(page);
await completeBookingCreationFlow(page, bookingTestData);
});
+16 -4
View File
@@ -1,21 +1,33 @@
import { expect, test } from "@playwright/test";
import { loginAsUser, bookingTestData } from "./fixtures";
import { bookingTestData } from "./fixtures";
import { completeBookingCreationFlow } from "./support/bookingFlow";
import { mockApi, primeMockSession } from "./support/network.js";
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
sessionData: {
display_name: "Booking User",
customer_number: 12345679,
permissions: ["user"],
},
pos: true,
});
await primeMockSession(page, { bootPath: "/user" });
});
test("[PAGES][User][/user/bookings] should load the page", async ({ page }) => {
await loginAsUser(page);
await page.goto("/user/bookings");
await expect(page).toHaveURL(/user\/bookings/);
});
test("[PAGES][User][/user/bookings] should display the title", async ({ page }) => {
await loginAsUser(page);
await page.goto("/user/bookings");
await expect(page.locator(".title")).toContainText("Oversigt over bookinger");
});
test("[BOOKINGS][User][List] should show created booking after book wash flow", async ({ page }) => {
await loginAsUser(page);
await completeBookingCreationFlow(page, bookingTestData);
await page.waitForLoadState("networkidle");