Add subuser support and require notes for specific products

This commit is contained in:
Jeppe Bundgaard
2026-05-28 16:36:42 +02:00
parent c5a536fbea
commit cc241e5e19
11 changed files with 185 additions and 70 deletions
@@ -2651,7 +2651,7 @@ const syncDesktopFlyoutPosition = () => {
{{ t("admin.pos.attachments_office_preview_unavailable") }}
</span>
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
{{ activeAttachment.content?.other }}
{{ formatAttachmentText(activeAttachment) }}
</span>
<span v-else class="action-settings-wheel-attachment-panel__text">
{{ t("admin.pos.attachments_no_preview") }}
@@ -3041,6 +3041,7 @@ const syncDesktopFlyoutPosition = () => {
text-align: center;
color: #4a5568;
overflow-wrap: anywhere;
white-space: pre-line;
}
.action-settings-wheel-attachment-panel__link {
@@ -1260,6 +1260,8 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
:refreshFunction="loadList"
@deleted="loadList()"
@@ -1501,6 +1503,8 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1"
:refreshFunction="loadList"
@deleted="loadList()"
@@ -1992,6 +1996,7 @@ const formatCashierName = (order) => {
v-bind:user_id="selectedOrderForActionsMenu.user_id"
v-bind:order_id="selectedOrderForActionsMenu.id"
v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id"
v-bind:customer_number="selectedOrderForActionsMenu.customer_id"
v-bind:reg_1="selectedOrderForActionsMenu.reg_1"
v-bind:reg_2="selectedOrderForActionsMenu.reg_2"
v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
@@ -14,7 +14,7 @@ type attachment = {
image: string | null;
document: string | null;
relation: string | null;
other: string | null;
other: unknown;
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
};
created_at: string;
@@ -22,6 +22,8 @@ type attachment = {
deleted_at: string | null;
}
const SELF_SERVE_WASH_ATTACHMENT_TYPE = 'SELF_SERVE_WASH';
const getAttachmentContent = (attachmentEntry: attachment) => {
if (!attachmentEntry.content) {
return {
@@ -43,7 +45,19 @@ const getAttachmentContent = (attachmentEntry: attachment) => {
};
const getAttachmentOtherText = (attachmentEntry: attachment) => {
return getAttachmentContent(attachmentEntry).other || '';
const other = getAttachmentContent(attachmentEntry).other;
if (typeof other === 'string') {
return other;
}
if (isSelfServeWashAttachment(attachmentEntry)) {
const customerNumber = getSelfServeWashPayload(attachmentEntry)?.customer_number;
return customerNumber
? `${t('admin.pos.settings_wheel.self_serve_wash_attachment')} #${customerNumber}`
: t('admin.pos.settings_wheel.self_serve_wash_attachment');
}
return other && typeof other === 'object' ? JSON.stringify(other) : '';
};
const props = defineProps({
attachments: {
@@ -79,6 +93,26 @@ const determineAttachmentType = (attachment: attachment): 'image' | 'document' |
return 'unknown';
};
const getSelfServeWashPayload = (attachmentEntry: attachment): Record<string, any> | null => {
const other = getAttachmentContent(attachmentEntry).other;
return other && typeof other === 'object' && (other as Record<string, any>).type === SELF_SERVE_WASH_ATTACHMENT_TYPE
? other as Record<string, any>
: null;
};
const isSelfServeWashAttachment = (attachmentEntry: attachment): boolean => {
return getSelfServeWashPayload(attachmentEntry) !== null;
};
const formatSelfServeDriver = (payload: Record<string, any>): string => {
return payload.subuser?.name || payload.subuser?.username || (payload.subuser_id ? `#${payload.subuser_id}` : '-');
};
const formatElapsedMinutes = (seconds: unknown): string => {
const parsed = Number(seconds);
return Number.isFinite(parsed) && parsed > 0 ? `${Math.ceil(parsed / 60)} min` : '-';
};
const getAttachmentTypeIcon = (attachment: attachment): string => {
const type = determineAttachmentType(attachment);
switch (type) {
@@ -309,14 +343,35 @@ const onClickAttachWashCertificate = () => {
</template>
<!-- OTHER PREVIEW -->
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
<template v-if="isSelfServeWashAttachment(attachment)">
<div class="content is-size-7">
<p class="has-text-weight-semibold">{{ t('admin.pos.settings_wheel.self_serve_wash_attachment') }}</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_customer') }}:</strong>
#{{ getSelfServeWashPayload(attachment)?.customer_number || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_driver') }}:</strong>
{{ formatSelfServeDriver(getSelfServeWashPayload(attachment) || {}) }}
</p>
<p>
<strong>{{ t('pos.license_plate') }}:</strong>
{{ getSelfServeWashPayload(attachment)?.license_plate || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_elapsed') }}:</strong>
{{ formatElapsedMinutes(getSelfServeWashPayload(attachment)?.elapsed_wash_time_seconds) }}
</p>
</div>
</template>
<!-- If the other type is a URL, you can create a link -->
<template v-if="getAttachmentContent(attachment).other.startsWith('http')">
<a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
<template v-else-if="typeof getAttachmentContent(attachment).other === 'string' && getAttachmentContent(attachment).other.startsWith('http')">
<a :href="String(getAttachmentContent(attachment).other)" target="_blank" rel="noopener noreferrer">
{{ getAttachmentContent(attachment).other }}
</a>
</template>
<template v-else>
<span>{{ getAttachmentContent(attachment).other }}</span>
<span>{{ getAttachmentOtherText(attachment) }}</span>
</template>
</template>
<!-- NO PREVIEW -->
@@ -235,12 +235,12 @@ const getProductOptionsLabel = (vehicle) => {
/>
<!-- Actions -->
<td>
<!-- Actions -->
<!-- Actions Should not be shown directly! -->
<ActionSettingsWheelButton
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="true"
:displayActionsDirectly="false"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
+8
View File
@@ -583,6 +583,9 @@
"settings_wheel": {
"associate_order": "Tilknyt ordre",
"attach_wash_certificate": "Vedhæft vaskecertifikat",
"accept_self_serve_wash": "Godkend selvbetjent vask",
"accept_self_serve_wash_confirm": "Flyt denne kladde til kunde #{customerNumber} med selvbetjeningsdetaljerne?",
"accept_self_serve_wash_success": "Den selvbetjente vask blev godkendt.",
"attached_files": "Vedhæftede filer",
"booking": "Booking",
"change_association": "Skift tilknytning",
@@ -616,6 +619,11 @@
"shortcut_other": "Andet",
"shortcut_vehicles": "Køretøjer",
"self_serve_studio_section": "Selvvask Studio",
"self_serve_wash_attachment": "Selvbetjent vask",
"self_serve_wash_attachment_for_customer": "Selvbetjent vask for kunde #{customerNumber}",
"self_serve_customer": "Kunde",
"self_serve_driver": "Chauffør",
"self_serve_elapsed": "Tid",
"gates_section": "Porte",
"relays_section": "Relæer",
"gateways_section": "Gateways",
+8
View File
@@ -583,6 +583,9 @@
"settings_wheel": {
"associate_order": "{order} zuordnen",
"attach_wash_certificate": "Waschzertifikat anh?ngen",
"accept_self_serve_wash": "Self-Service-Wäsche akzeptieren",
"accept_self_serve_wash_confirm": "Diesen Entwurf mit den Self-Service-Details zu Kunde #{customerNumber} verschieben?",
"accept_self_serve_wash_success": "Die Self-Service-Wäsche wurde akzeptiert.",
"attached_files": "Angeh?ngte Dateien",
"booking": "Buchung",
"change_association": "Zuordnung ?ndern",
@@ -616,6 +619,11 @@
"shortcut_other": "Sonstiges",
"shortcut_vehicles": "Fahrzeuge",
"self_serve_studio_section": "Self-serve Studio",
"self_serve_wash_attachment": "Self-Service-Wäsche",
"self_serve_wash_attachment_for_customer": "Self-Service-Wäsche für Kunde #{customerNumber}",
"self_serve_customer": "Kunde",
"self_serve_driver": "Fahrer",
"self_serve_elapsed": "Zeit",
"gates_section": "Tore",
"relays_section": "Relais",
"gateways_section": "Gateways",
+8
View File
@@ -583,6 +583,9 @@
"settings_wheel": {
"associate_order": "Associate {order}",
"attach_wash_certificate": "Attach wash certificate",
"accept_self_serve_wash": "Accept self-serve wash",
"accept_self_serve_wash_confirm": "Move this draft to customer #{customerNumber} with the self-serve details?",
"accept_self_serve_wash_success": "The self-serve wash was accepted.",
"attached_files": "Attached files",
"booking": "Booking",
"change_association": "Change association",
@@ -616,6 +619,11 @@
"shortcut_other": "Other",
"shortcut_vehicles": "Vehicles",
"self_serve_studio_section": "Self-serve Studio",
"self_serve_wash_attachment": "Self-serve wash",
"self_serve_wash_attachment_for_customer": "Self-serve wash for customer #{customerNumber}",
"self_serve_customer": "Customer",
"self_serve_driver": "Driver",
"self_serve_elapsed": "Elapsed",
"gates_section": "Gates",
"relays_section": "Relays",
"gateways_section": "Gateways",
+8
View File
@@ -583,6 +583,9 @@
"settings_wheel": {
"associate_order": "Associate {order}",
"attach_wash_certificate": "Legg ved vaskesertifikat",
"accept_self_serve_wash": "Godta selvbetjent vask",
"accept_self_serve_wash_confirm": "Flytt denne kladden til kunde #{customerNumber} med selvbetjeningsdetaljene?",
"accept_self_serve_wash_success": "Den selvbetjente vasken ble godtatt.",
"attached_files": "Vedlagte filer",
"booking": "Bestilling",
"change_association": "Bytt forening",
@@ -616,6 +619,11 @@
"shortcut_other": "Annet",
"shortcut_vehicles": "Kjøretøy",
"self_serve_studio_section": "Self-serve Studio",
"self_serve_wash_attachment": "Selvbetjent vask",
"self_serve_wash_attachment_for_customer": "Selvbetjent vask for kunde #{customerNumber}",
"self_serve_customer": "Kunde",
"self_serve_driver": "Sjåfør",
"self_serve_elapsed": "Tid",
"gates_section": "Porter",
"relays_section": "Reléer",
"gateways_section": "Gateways",
+8
View File
@@ -583,6 +583,9 @@
"settings_wheel": {
"associate_order": "Associera {order}",
"attach_wash_certificate": "Bifoga tvättcertifikat",
"accept_self_serve_wash": "Godkänn självbetjäningstvätt",
"accept_self_serve_wash_confirm": "Flytta detta utkast till kund #{customerNumber} med självbetjäningsdetaljerna?",
"accept_self_serve_wash_success": "Självbetjäningstvätten godkändes.",
"attached_files": "Bifogade filer",
"booking": "Bokning",
"change_association": "ändra koppling",
@@ -616,6 +619,11 @@
"shortcut_other": "Annat",
"shortcut_vehicles": "Fordon",
"self_serve_studio_section": "Self-serve Studio",
"self_serve_wash_attachment": "Självbetjäningstvätt",
"self_serve_wash_attachment_for_customer": "Självbetjäningstvätt för kund #{customerNumber}",
"self_serve_customer": "Kund",
"self_serve_driver": "Förare",
"self_serve_elapsed": "Tid",
"gates_section": "Grindar",
"relays_section": "Reläer",
"gateways_section": "Gateways",
+66 -39
View File
@@ -1,39 +1,19 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onBeforeUnmount, onMounted } from 'vue';
import {useI18n} from "vue-i18n";
import {BButton, BIcon} from "buefy";
import { pingApiServer } from "@/services/apiHealth.js";
const { t } = useI18n();
import { unauthenticatedRequest } from "@/components/session/unauthenticatedRequest.vue";
// font awesome icon name
const iconName = 'wifi';
const connectionStatus = ref<'loading' | 'ok' | 'error'>('loading');
const check_connection = () => {
reset_timer();
try {
unauthenticatedRequest('/ping', 'GET')
.then(() => {
connectionStatus.value = 'ok';
})
.catch((error: any) => {
// Keep the user on the error page if the connection check fails
connectionStatus.value = 'error';
console.error('Connection failed, staying on the error page', error);
});
} catch (error) {
connectionStatus.value = 'error';
console.error('Unexpected error during connection check', error);
}
};
onMounted(() => {
check_connection();
// Check every 5 seconds
setInterval(() => {
check_connection();
}, 5000);
})
const RETRY_INTERVAL_MS = 5000;
const RETRY_INTERVAL_SECONDS = RETRY_INTERVAL_MS / 1000;
const isCheckingConnection = ref(false);
const retryTimeout = ref<ReturnType<typeof setTimeout> | null>(null);
const timer = ref<NodeJS.Timeout | null>(null);
const time_until_retry = ref(RETRY_INTERVAL_SECONDS);
const clear_timer_interval = () => {
if (timer.value) {
clearInterval(timer.value);
@@ -41,21 +21,68 @@ const clear_timer_interval = () => {
}
}
const reset_timer = () => {
const clear_retry_timeout = () => {
if (retryTimeout.value) {
clearTimeout(retryTimeout.value);
retryTimeout.value = null;
}
};
const clear_retry_state = () => {
clear_retry_timeout();
clear_timer_interval();
start_timer_interval();
};
const schedule_retry = () => {
clear_retry_state();
time_until_retry.value = RETRY_INTERVAL_SECONDS;
timer.value = setInterval(() => {
time_until_retry.value = Math.max(0, time_until_retry.value - 1);
if (time_until_retry.value === 0) {
clear_timer_interval();
}
}, 1000);
retryTimeout.value = setTimeout(() => {
retryTimeout.value = null;
void check_connection();
}, RETRY_INTERVAL_MS);
}
const start_timer_interval = () => {
time_until_retry.value = 5;
timer.value = setInterval(() => {
time_until_retry.value--;
if (time_until_retry.value === 0) {
clearInterval(timer.value);
const check_connection = async () => {
if (isCheckingConnection.value) {
return;
}
clear_retry_state();
time_until_retry.value = 0;
isCheckingConnection.value = true;
try {
const result = await pingApiServer();
if (result.ok) {
connectionStatus.value = 'ok';
return;
}
}, 1000)
}
const time_until_retry = ref(5);
connectionStatus.value = 'error';
schedule_retry();
console.error('Connection failed, staying on the error page', result.error || result.status);
} catch (error) {
connectionStatus.value = 'error';
schedule_retry();
console.error('Unexpected error during connection check', error);
} finally {
isCheckingConnection.value = false;
}
};
onMounted(() => {
void check_connection();
})
onBeforeUnmount(() => {
clear_retry_state();
})
</script>
+10 -23
View File
@@ -1,11 +1,10 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
const i18nRoots = ["src/i18n/source", "src/i18n/generated", "src/i18n/locales"];
const suspiciousKeyPattern = /[?\uFFFD]/u;
const suspiciousJsonKeyPattern = /"(?:(?:\\.)|[^"\\])*(?:\?|\uFFFD|\\u[fF]{2}[fF][dD])(?:(?:\\.)|[^"\\])*"\s*:/u;
function listJsonFiles(directory) {
return readdirSync(directory, { withFileTypes: true })
@@ -21,32 +20,20 @@ function listJsonFiles(directory) {
.sort();
}
function collectSuspiciousKeyPaths(value, keyPath = []) {
if (Array.isArray(value)) {
return value.flatMap((entry, index) => collectSuspiciousKeyPaths(entry, [...keyPath, String(index)]));
}
if (!value || typeof value !== "object") {
return [];
}
return Object.entries(value).flatMap(([key, entry]) => {
const nextKeyPath = [...keyPath, key];
const ownKeyMatches = suspiciousKeyPattern.test(key) ? [nextKeyPath.join(".")] : [];
return [...ownKeyMatches, ...collectSuspiciousKeyPaths(entry, nextKeyPath)];
});
}
describe("i18n key integrity", () => {
it("does not contain replacement placeholders in translation key names", () => {
const failures = i18nRoots.flatMap((i18nRoot) => {
const files = listJsonFiles(join(root, i18nRoot));
return files.flatMap((filePath) => {
const keyPaths = collectSuspiciousKeyPaths(readJsonFile(filePath));
const relativePath = relative(root, filePath);
const keyPaths = readFileSync(filePath, "utf8")
.split(/\r?\n/)
.flatMap((line, index) =>
suspiciousJsonKeyPattern.test(line) ? [`${relativePath}: line ${index + 1}: ${line.trim()}`] : []
);
return keyPaths.map((keyPath) => `${filePath.replace(`${root}/`, "")}: ${keyPath}`);
return keyPaths;
});
});