Add subuser support and require notes for specific products
This commit is contained in:
@@ -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"
|
||||
|
||||
+60
-5
@@ -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) -->
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user