Add encoding repair utilities and tests:

- Introduced `repairText` and `containsSuspiciousEncoding` utilities for handling corrupted text encodings.
- Added unit tests (`encoding-repair.spec.js`) to validate encoding repair and encoding marker detection.
- Integrated encoding check into unit test workflows (`text:check-encoding` and `text:fix-encoding` scripts).
- Replaced corrupted strings across multiple components and locales with proper UTF-8 encodings.
This commit is contained in:
Jeppe Bundgaard
2026-04-21 10:24:35 +02:00
parent 1a784bbbfd
commit d7ff64bf4e
41 changed files with 2890 additions and 1283 deletions
+3 -1
View File
@@ -14,7 +14,9 @@
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
"preview": "vite preview",
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"test:unit": "node scripts/run-vitest-unit-batches.mjs",
"text:fix-encoding": "node scripts/text-encoding.mjs fix",
"text:check-encoding": "node scripts/text-encoding.mjs check",
"test:unit": "npm run text:check-encoding && node scripts/run-vitest-unit-batches.mjs",
"test:unit:single": "vitest run",
"test:e2e": "playwright test",
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
+314
View File
@@ -0,0 +1,314 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
const SCAN_ROOTS = ["src", "tests"];
const SCAN_EXTENSIONS = new Set([".vue", ".js", ".ts", ".json"]);
const CP1252_EXTRA_BYTE_BY_CHAR = new Map([
["€", 0x80],
["", 0x82],
["ƒ", 0x83],
["„", 0x84],
["…", 0x85],
["†", 0x86],
["‡", 0x87],
["ˆ", 0x88],
["‰", 0x89],
["Š", 0x8a],
["", 0x8b],
["Œ", 0x8c],
["Ž", 0x8e],
["", 0x91],
["", 0x92],
["“", 0x93],
["”", 0x94],
["•", 0x95],
["", 0x96],
["—", 0x97],
["˜", 0x98],
["™", 0x99],
["š", 0x9a],
["", 0x9b],
["œ", 0x9c],
["ž", 0x9e],
["Ÿ", 0x9f],
]);
const SUSPICIOUS_PATTERNS = [
/ÃÂ/g,
/Ã./g,
/Â(?=\S)/g,
/â€(?:[™œžŸ"“”‘’•–—…])/g,
/’/g,
/“/g,
/”/g,
/–/g,
/—/g,
/…/g,
//g,
];
const REGIONAL_CHAR_REGEX = /[æøåÆØÅäöÄÖüÜßéÉèÈáÁàÀóÓúÚíÍñÑçÇ]/g;
const LATIN_EXTENDED_CHAR_REGEX = /[\u00C0-\u024F]/g;
const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
function countMatches(text, regex) {
const matches = text.match(regex);
return matches ? matches.length : 0;
}
export function countSuspiciousMarkers(text) {
return SUSPICIOUS_PATTERNS.reduce((count, pattern) => count + countMatches(text, pattern), 0);
}
export function countRegionalCharacters(text) {
return countMatches(text, REGIONAL_CHAR_REGEX);
}
function countLatinExtendedCharacters(text) {
return countMatches(text, LATIN_EXTENDED_CHAR_REGEX);
}
function countControlCharacters(text) {
return countMatches(text, CONTROL_CHAR_REGEX);
}
function encodeLatin1(text) {
const bytes = [];
for (const character of text) {
const codePoint = character.codePointAt(0);
if (codePoint > 0xff) {
return null;
}
bytes.push(codePoint);
}
return Uint8Array.from(bytes);
}
function encodeWindows1252(text) {
const bytes = [];
for (const character of text) {
if (CP1252_EXTRA_BYTE_BY_CHAR.has(character)) {
bytes.push(CP1252_EXTRA_BYTE_BY_CHAR.get(character));
continue;
}
const codePoint = character.codePointAt(0);
if (codePoint > 0xff) {
return null;
}
bytes.push(codePoint);
}
return Uint8Array.from(bytes);
}
function decodeUtf8(bytes) {
try {
return utf8Decoder.decode(bytes);
} catch {
return null;
}
}
function buildRepairCandidates(text) {
const candidateSet = new Set();
const encoders = [encodeLatin1, encodeWindows1252];
for (const encoder of encoders) {
const encoded = encoder(text);
if (!encoded) {
continue;
}
const decoded = decodeUtf8(encoded);
if (decoded && decoded !== text) {
candidateSet.add(decoded);
}
}
return [...candidateSet];
}
function isRepairImprovement(before, after) {
if (!after || after === before) {
return false;
}
const beforeSuspicious = countSuspiciousMarkers(before);
const afterSuspicious = countSuspiciousMarkers(after);
const beforeRegional = countRegionalCharacters(before);
const afterRegional = countRegionalCharacters(after);
const beforeLatinExtended = countLatinExtendedCharacters(before);
const afterLatinExtended = countLatinExtendedCharacters(after);
const beforeControls = countControlCharacters(before);
const afterControls = countControlCharacters(after);
if (afterControls > beforeControls) {
return false;
}
if (afterSuspicious < beforeSuspicious) {
return afterRegional >= beforeRegional || afterLatinExtended >= beforeLatinExtended || afterSuspicious === 0;
}
return (
afterSuspicious === beforeSuspicious && (afterRegional > beforeRegional || afterLatinExtended > beforeLatinExtended)
);
}
function scoreCandidate(text) {
return (
countSuspiciousMarkers(text) * 20 +
countControlCharacters(text) * 30 -
countLatinExtendedCharacters(text) * 2 -
countRegionalCharacters(text) * 3
);
}
function repairOnce(text) {
const candidates = buildRepairCandidates(text).filter((candidate) => isRepairImprovement(text, candidate));
if (candidates.length === 0) {
return text;
}
return candidates.reduce((bestCandidate, candidate) => {
return scoreCandidate(candidate) < scoreCandidate(bestCandidate) ? candidate : bestCandidate;
});
}
function repairSegment(text) {
let current = text;
for (let pass = 0; pass < 4; pass += 1) {
const repaired = repairOnce(current);
if (repaired === current) {
break;
}
current = repaired;
}
return current;
}
export function repairText(text) {
const wholeTextRepair = repairSegment(text);
const linewiseRepair = wholeTextRepair
.split(/(\r?\n)/)
.map((segment) => (segment.match(/\r?\n/) ? segment : repairSegment(segment)))
.join("");
return repairSegment(linewiseRepair);
}
export function containsSuspiciousEncoding(text) {
return countSuspiciousMarkers(text) > 0;
}
function getPreview(line) {
return line.replace(/\s+/g, " ").trim().slice(0, 180);
}
export function findSuspiciousLines(text) {
return text
.split(/\r?\n/)
.map((line, index) => ({
lineNumber: index + 1,
preview: getPreview(line),
suspiciousCount: countSuspiciousMarkers(line),
}))
.filter((entry) => entry.suspiciousCount > 0);
}
async function collectScanFiles(rootDir) {
const files = [];
async function walk(currentDir) {
let entries = [];
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
continue;
}
if (!entry.isFile() || !SCAN_EXTENSIONS.has(path.extname(entry.name))) {
continue;
}
files.push(entryPath);
}
}
for (const scanRoot of SCAN_ROOTS) {
await walk(path.join(rootDir, scanRoot));
}
return files.sort();
}
async function run(mode) {
const rootDir = process.cwd();
const files = await collectScanFiles(rootDir);
const suspiciousReports = [];
let changedFiles = 0;
for (const filePath of files) {
const originalText = await fs.readFile(filePath, "utf8");
const repairedText = repairText(originalText);
if (mode === "fix" && repairedText !== originalText) {
await fs.writeFile(filePath, repairedText, "utf8");
changedFiles += 1;
}
const inspectedText = mode === "fix" ? repairedText : originalText;
const suspiciousLines = findSuspiciousLines(inspectedText);
if (suspiciousLines.length > 0) {
suspiciousReports.push({
filePath: path.relative(rootDir, filePath),
lines: suspiciousLines.slice(0, 12),
});
}
}
if (mode === "fix") {
console.log(`Updated ${changedFiles} file(s).`);
}
if (suspiciousReports.length > 0) {
console.error(`Detected suspicious encoding markers in ${suspiciousReports.length} file(s):`);
suspiciousReports.forEach((report) => {
console.error(`- ${report.filePath}`);
report.lines.forEach((line) => {
console.error(` ${line.lineNumber}: ${line.preview}`);
});
});
process.exitCode = 1;
return;
}
console.log(`No suspicious encoding markers found across ${files.length} file(s).`);
}
const isDirectRun = typeof process.argv[1] === "string" && pathToFileURL(process.argv[1]).href === import.meta.url;
if (isDirectRun) {
const mode = process.argv[2] || "check";
if (!["check", "fix"].includes(mode)) {
console.error("Usage: node scripts/text-encoding.mjs <check|fix>");
process.exit(1);
}
await run(mode);
}
@@ -33,6 +33,7 @@ import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSetting
import {
customer_name,
customerRequiresReferenceNumber,
fetchAttachments,
customerUsesPONumbers,
getOrderDetails,
setOrderId,
@@ -60,6 +61,14 @@ const panel_tabs = ref([
active: true,
},
]);
const handleCustomerWishesFieldSaved = async (fieldKey) => {
if (fieldKey !== "safety_seal") {
return;
}
await loadOrder();
};
// Check if the size is large
const isLarge = props.isLarge !== undefined ? props.isLarge : false;
const isOrderDetailVariant = computed(() => props.variant === "order-detail");
@@ -237,6 +246,7 @@ const loadOrder = (candidateOrderId = activeOrderId.value ?? props.orderId) => {
.then((orderDetails) => {
syncRegistrationNumbers(orderDetails);
loadedRegistrationNumbersForOrderId.value = normalizedOrderId;
return fetchAttachments(normalizedOrderId);
})
.catch((error) => {
console.error(error);
@@ -902,6 +912,7 @@ const deleteOrderItem = async (orderItemId) => {
v-bind:showSafetySeal="showSafetySealField"
v-bind:referenceRequired="referenceRequired"
v-bind:poRequired="poRequired"
v-bind:onFieldSaved="handleCustomerWishesFieldSaved"
/>
</section>
<section
@@ -34,6 +34,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
onFieldSaved: {
type: Function,
default: null,
},
});
const { t } = useI18n();
@@ -48,6 +52,7 @@ const createCustomerWishField = ({
warningStateWhenEmpty,
warningIconClass,
fillRow = false,
onSaved = null,
}) => {
const inputId = `${testIdBase}-input`;
const isEditing = ref(false);
@@ -57,6 +62,11 @@ const createCustomerWishField = ({
await saveValue(value);
return value;
},
onSaved: async (...args) => {
if (typeof onSaved === "function") {
await onSaved(...args);
}
},
});
const isEmpty = computed(() => isBlankPosMetadataValue(autosave.draft.value));
const hasValue = computed(() => !isEmpty.value);
@@ -141,6 +151,11 @@ const safetySealField = createCustomerWishField({
warningStateWhenEmpty: null,
warningIconClass: "fas fa-shield-alt",
fillRow: true,
onSaved: async () => {
if (typeof props.onFieldSaved === "function") {
await props.onFieldSaved("safety_seal");
}
},
});
const fields = computed(() => {
@@ -1,19 +1,27 @@
<script setup lang="ts">
import ScannerCamera from "@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue";
import ScannerInstructions from "@/components/viewport/page/templates/scanner/graphics/ScannerInstructions.vue";
import ScannerOutline from "@/components/viewport/page/templates/scanner/graphics/ScannerOutline.vue";
import { setTransparency, setBackgroundColor, backgroundColors, setOverflow } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import {PosVehicle} from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
import PosDepartmentStepMobile1RegistrationNumbers
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue";
import {
setTransparency,
setBackgroundColor,
backgroundColors,
setOverflow,
} from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { PosVehicle } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
import PosDepartmentStepMobile1RegistrationNumbers from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import PosDepartmentStep1MobileManualInput
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileManualInput.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { manualInput, vehicles, camera, sounds, transactionHistoryView, views } from "./objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStep1MobileManualInput from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileManualInput.vue";
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import {
manualInput,
vehicles,
camera,
sounds,
transactionHistoryView,
views,
} from "./objects/PosDepartmentStepMobileFlow.vue";
import { PosSearchResult } from "./objects/PosSearchResult.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
@@ -21,18 +29,12 @@ import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCusto
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobile1Location
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import PosDepartmentStepMobile1Debug
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
import PosDepartmentStepMobileAttachments
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
import PosDepartmentStep1MobileTransactionHistory
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
import {
attachments} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
// Debug mode flag
const debug_mode = ref(false);
// Debug array to store request results
@@ -41,7 +43,7 @@ const debug_request_results = ref([]);
const lastParsedImage = ref(null);
const setLastCapturedImage = (image: string) => {
camera.latestImage.value = image;
}
};
type LPRResponse = {
success: boolean;
@@ -64,11 +66,7 @@ const compressImageForLPR = (image: string): Promise<string> => {
return;
}
const scale = Math.min(
1,
LPR_IMAGE_MAX_WIDTH / sourceWidth,
LPR_IMAGE_MAX_HEIGHT / sourceHeight
);
const scale = Math.min(1, LPR_IMAGE_MAX_WIDTH / sourceWidth, LPR_IMAGE_MAX_HEIGHT / sourceHeight);
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
@@ -93,7 +91,7 @@ const compressImageForLPR = (image: string): Promise<string> => {
const activeVehicleIndexNext = () => {
// Increment the active vehicle index, wrapping around if necessary
if (vehicles.activeVehicleIndex.value < 3) {
vehicles.setActiveVehicleIndex(vehicles.activeVehicleIndex.value + 1)
vehicles.setActiveVehicleIndex(vehicles.activeVehicleIndex.value + 1);
}
};
@@ -103,20 +101,19 @@ const handleLPRResult = () => {
registrationNumber: latestLPRResponse.value?.license_plate_number || "",
customerName: "",
customerId: 0,
customerStatus: 'unknown'
customerStatus: "unknown",
});
if (searchResultIndex === vehicles.activeVehicleIndex.value) {
// Only play the sound if the vehicle registration number is being set / changed.
if (vehicles.getActiveVehicle()?.reg !== latestLPRResponse.value?.license_plate_number) {
sounds.play(sounds.list.value.onAfterSuccessfulScan)
sounds.play(sounds.list.value.onAfterSuccessfulScan);
}
vehicles.select(searchResultIndex, {
reg: latestLPRResponse.value?.license_plate_number || "",
status: 'unknown'
status: "unknown",
});
}
}
};
const parseImage = async (image: string) => {
// Check if the time since the last successful parse is enough
@@ -131,40 +128,41 @@ const parseImage = async (image: string) => {
camera.setLatestImage(image); // Update the latest image in the camera object
// Function to parse the image data
const compressedImage = await compressImageForLPR(image);
SessionUser.request(
"/modules/scanner/lpr",
"POST",
{
base64_image: compressedImage
},
).then((response) => {
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, stop here.
if (!response.data.success) {
return;
}
latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time
camera.setLastSuccess();
// Handle parsed result.
handleLPRResult()
}).catch((error) => {
if (debug_mode.value) {
debug_request_results.value.push(error);
}
//console.error("Error parsing image:", error);
});
SessionUser.request("/modules/scanner/lpr", "POST", {
base64_image: compressedImage,
})
.then((response) => {
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, stop here.
if (!response.data.success) {
return;
}
latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time
camera.setLastSuccess();
// Handle parsed result.
handleLPRResult();
})
.catch((error) => {
if (debug_mode.value) {
debug_request_results.value.push(error);
}
//console.error("Error parsing image:", error);
});
};
watch(manualInput, (newValue) => {
// Update the header transparency when manualInput changes
setTransparency(!newValue);
});
type statusIcon = typeof VerifiedCustomer | typeof KnownCustomer | typeof UnknownCustomer | typeof CardPaymentCustomer | typeof BookedCustomer;
type statusIcon =
| typeof VerifiedCustomer
| typeof KnownCustomer
| typeof UnknownCustomer
| typeof CardPaymentCustomer
| typeof BookedCustomer;
const registrationNumbers = computed(() => {
// Return the registration numbers of all vehicles
@@ -175,7 +173,6 @@ const registrationNumbers = computed(() => {
];
});
function getSearchResultIndex(object: PosSearchResult) {
let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index
// Check if the registration number already exists in the vehicles (And update the vehicle if it does)
@@ -223,7 +220,7 @@ const onAutomaticSelection = (object: PosSearchResult | null) => {
default:
return;
}
}
};
const getQuery = computed(() => {
// Return the registration number of the active vehicle
@@ -257,63 +254,97 @@ onUnmounted(() => {
<template>
<!-- Manual input view -->
<template v-if="manualInput">
<PosDepartmentStep1MobileManualInput @close="manualInput = false"/>
<PosDepartmentStep1MobileManualInput @close="manualInput = false" />
</template>
<!-- Transaction history view -->
<template v-else-if="transactionHistoryView">
<PosDepartmentStep1MobileTransactionHistory @close="transactionHistoryView = false"/>
<PosDepartmentStep1MobileTransactionHistory @close="transactionHistoryView = false" />
</template>
<!-- Default: Scanner view -->
<template v-else>
<div class="background-fixed">
<ScannerCamera
@update:frame="parseImage"
/>
<ScannerCamera @update:frame="parseImage" />
</div>
<div class="custom-content" data-testid="pos-mobile-step-1">
<!-- Meta objects, registration number auto-lookup -->
<RegistrationNumberSearchResult :searchQuery="getQuery" @select="onAutomaticSelection" :isHidden="true" :automaticallySelect="true" :modifyCustomerOnChange="shouldCustomerBeModified"/>
<RegistrationNumberSearchResult
:searchQuery="getQuery"
@select="onAutomaticSelection"
:isHidden="true"
:automaticallySelect="true"
:modifyCustomerOnChange="shouldCustomerBeModified"
/>
<!-- Instructions for the user -->
<ScannerInstructions
v-if="!views.attachmentView.value"
title="Scan nummerplader"
subtitle="Hold kameraet op mod nummerpladerne."
buttonText="Skriv registreringsnummer manuelt"
rootTestId="pos-mobile-step-1-shell"
titleTestId="pos-mobile-step-1-title"
subtitleTestId="pos-mobile-step-1-subtitle"
buttonTestId="pos-mobile-manual-input-toggle"
@button-click="manualInput = true"
v-if="!views.attachmentView.value"
title="Scan nummerplader"
subtitle="Hold kameraet op mod nummerpladerne."
buttonText="Skriv registreringsnummer manuelt"
rootTestId="pos-mobile-step-1-shell"
titleTestId="pos-mobile-step-1-title"
subtitleTestId="pos-mobile-step-1-subtitle"
buttonTestId="pos-mobile-manual-input-toggle"
@button-click="manualInput = true"
/>
<!-- Scanner outline object -->
<div class="is-align-content-center is-flex is-justify-content-center">
<ScannerOutline :loading="false" v-if="!views.attachmentView.value"/>
<ScannerOutline :loading="false" v-if="!views.attachmentView.value" />
</div>
<!-- Reg. 1, Reg. 2, Reg. 3 -->
<div class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column" data-testid="pos-mobile-step-1-registration-list">
<PosDepartmentStepMobile1RegistrationNumbers v-if="!views.attachmentView.value"/>
<div
class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column"
data-testid="pos-mobile-step-1-registration-list"
>
<PosDepartmentStepMobile1RegistrationNumbers v-if="!views.attachmentView.value" />
</div>
<!-- Debug -->
<PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results"/>
<PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results" />
<!-- Location -->
<PosDepartmentStepMobile1Location/>
<PosDepartmentStepMobile1Location />
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Attachments -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value"/>
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
</div>
<!-- Attachment view button -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showAttachments="false" :showTakePictureButton="false" :showReferenceButton="false" :showBackButton="false" class="mb-3"/>
<PosDepartmentStepMobileAttachments
:showAttachments="false"
:showTakePictureButton="false"
:showReferenceButton="false"
:showBackButton="false"
class="mb-3"
/>
</div>
<!-- Take picture button (When attachment view is active) -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileButtonNextStep :isWhite="false" v-if="views.attachmentView.value" style="width: 100%;" class="mb-2" :customDisabled="false" :customAction="() => { attachments.takePicture(); }" :buttonClasses="['has-background-primary-dark', 'has-text-black']">
<PosDepartmentStepMobileButtonNextStep
:isWhite="false"
v-if="views.attachmentView.value"
style="width: 100%"
class="mb-2"
:customDisabled="false"
:customAction="
() => {
attachments.takePicture();
}
"
:buttonClasses="['has-background-primary-dark', 'has-text-black']"
action-key="pos-mobile-attachment-view-take-picture"
copy-key="take_picture"
>
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-take-picture-action">
<span class="pos-mobile-action-label has-text-white" data-testid="pos-mobile-attachment-view-take-picture-label">{{ SessionUser.objects.global.language.take_picture }}</span>
<span class="pos-mobile-action-icon has-text-white" data-testid="pos-mobile-attachment-view-take-picture-icon">
<span
class="pos-mobile-action-label has-text-white"
data-testid="pos-mobile-attachment-view-take-picture-label"
>{{ SessionUser.objects.global.language.take_picture }}</span
>
<span
class="pos-mobile-action-icon has-text-white"
data-testid="pos-mobile-attachment-view-take-picture-icon"
>
<span class="icon is-small">
<i class="fas fa-camera"></i>
</span>
@@ -323,11 +354,34 @@ onUnmounted(() => {
</div>
<div class="is-flex is-justify-content-center">
<!-- Next step button (If not in attachment view) -->
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="!views.attachmentView.value" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false"/>
<PosDepartmentStepMobileButtonNextStep
style="width: 100%"
v-if="!views.attachmentView.value"
:buttonClasses="['has-background-primary', 'has-text-black']"
:isWhite="false"
action-key="pos-mobile-next-step-primary"
/>
<!-- Next step button (If in attachment view) -->
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="views.attachmentView.value" :customDisabled="false" :customAction="() => { views.attachmentView.value = false; }" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false">
<PosDepartmentStepMobileButtonNextStep
style="width: 100%"
v-if="views.attachmentView.value"
:customDisabled="false"
:customAction="
() => {
views.attachmentView.value = false;
}
"
:buttonClasses="['has-background-primary', 'has-text-black']"
:isWhite="false"
action-key="pos-mobile-attachment-view-close"
copy-key="close"
>
<span class="pos-mobile-action-content" data-testid="pos-mobile-attachment-view-close-action">
<span class="pos-mobile-action-label has-text-dark" data-testid="pos-mobile-attachment-view-close-label">{{ SessionUser.objects.global.language.close }}</span>
<span
class="pos-mobile-action-label has-text-dark"
data-testid="pos-mobile-attachment-view-close-label"
>{{ SessionUser.objects.global.language.close }}</span
>
<span class="pos-mobile-action-icon has-text-dark" data-testid="pos-mobile-attachment-view-close-icon">
<span class="icon is-small">
<i class="fas fa-times"></i>
@@ -375,5 +429,4 @@ onUnmounted(() => {
.custom-content > * {
width: min(100%, 48rem);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -35,7 +35,7 @@ const primaryItem = computed(() => {
);
return standaloneItems[0] ?? orderItems.value[0] ?? null;
});
const secondaryItems = computed(() => {
const addonItems = computed(() => {
const primaryItemId = Number(primaryItem.value?.id ?? 0);
return orderItems.value.filter((item) => {
const itemId = Number(item?.id ?? 0);
@@ -43,11 +43,18 @@ const secondaryItems = computed(() => {
return false;
}
return primaryItemId
? Number(item?.related_item_id ?? 0) === primaryItemId ||
item?.related_item_id === null ||
item?.related_item_id === undefined
: true;
return primaryItemId ? Number(item?.related_item_id ?? 0) === primaryItemId : false;
});
});
const additionalItems = computed(() => {
const primaryItemId = Number(primaryItem.value?.id ?? 0);
return orderItems.value.filter((item) => {
const itemId = Number(item?.id ?? 0);
if (itemId && primaryItemId && itemId === primaryItemId) {
return false;
}
return item?.related_item_id === null || item?.related_item_id === undefined;
});
});
const createdAtLabel = computed(() => {
@@ -59,6 +66,7 @@ const createdAtLabel = computed(() => {
});
const referenceLabel = computed(() => String(props.lastOrder?.reference ?? "").trim());
const copyButtonLabel = computed(() => `${t("common.copy")} ${t("common.last_wash").toLowerCase()}`);
const copyHint = computed(() => t("pos.copy_last_wash_hint"));
const totalPrice = computed(() => {
return SessionUser.functions.currency.toLocal(props.lastOrder?.total_net_amount || 0);
});
@@ -86,11 +94,25 @@ const onCopy = () => {
<p class="last-order-card__value">{{ primaryItem?.product?.name || primaryItem?.product_id }}</p>
</div>
<div v-if="secondaryItems.length > 0" class="last-order-card__section">
<div v-if="addonItems.length > 0" class="last-order-card__section">
<span class="last-order-card__label">{{ t("common.addons") }}</span>
<div class="last-order-card__tags">
<span
v-for="item in addonItems"
:key="`${item?.id}-${item?.product_id}-${item?.related_item_id}`"
class="last-order-card__tag"
>
{{ item?.product?.name || item?.product_id }}
<template v-if="Number(item?.quantity ?? 1) > 1"> x{{ Number(item?.quantity ?? 1) }}</template>
</span>
</div>
</div>
<div v-if="additionalItems.length > 0" class="last-order-card__section">
<span class="last-order-card__label">{{ SessionUser.objects.global.language.additional_items }}</span>
<div class="last-order-card__tags">
<span
v-for="item in secondaryItems"
v-for="item in additionalItems"
:key="`${item?.id}-${item?.product_id}-${item?.related_item_id}`"
class="last-order-card__tag"
>
@@ -106,6 +128,13 @@ const onCopy = () => {
</div>
</div>
<div class="last-order-card__copy-hint">
<span class="icon is-small last-order-card__copy-hint-icon">
<i class="fas fa-layer-group"></i>
</span>
<span>{{ copyHint }}</span>
</div>
<button
class="button last-order-card__button"
type="button"
@@ -212,6 +241,23 @@ const onCopy = () => {
padding: 0.4rem 0.7rem;
}
.last-order-card__copy-hint {
align-items: center;
background: rgba(143, 93, 25, 0.08);
border-radius: 0.9rem;
color: #6f4919;
display: flex;
font-size: 0.8rem;
gap: 0.5rem;
line-height: 1.35;
padding: 0.7rem 0.8rem;
}
.last-order-card__copy-hint-icon {
color: #8f5d19;
flex-shrink: 0;
}
.last-order-card__button {
align-items: center;
background: linear-gradient(180deg, #17325c 0%, #0f2748 100%);
@@ -19,6 +19,7 @@ const productName = computed(() => props?.product?.name || "");
const productPrice = computed(() => SessionUser.functions.currency.toLocal(props?.product?.price || 0));
const productImage = computed(() => getPicture(props?.product?.piktogram || props?.product?.id || ""));
const productBadge = computed(() => `${t("common.selected")} ${t("common.vehicle").toLowerCase()}`);
const productChangeHint = computed(() => t("pos.hold_to_change_vehicle"));
</script>
<template>
@@ -51,10 +52,18 @@ const productBadge = computed(() => `${t("common.selected")} ${t("common.vehicle
</div>
<div class="vehicle-card__footer">
<span class="icon vehicle-card__footer-icon">
<i class="fas fa-hand-pointer"></i>
<div class="vehicle-card__footer-copy">
<span class="icon vehicle-card__footer-icon">
<i class="fas fa-hand-pointer"></i>
</span>
<div class="vehicle-card__footer-text-group">
<span class="vehicle-card__footer-text">{{ productBadge }}</span>
<span class="vehicle-card__footer-hint">{{ productChangeHint }}</span>
</div>
</div>
<span class="icon vehicle-card__footer-chevron">
<i class="fas fa-angle-right"></i>
</span>
<span class="vehicle-card__footer-text">{{ productBadge }}</span>
</div>
</div>
</template>
@@ -172,17 +181,42 @@ const productBadge = computed(() => `${t("common.selected")} ${t("common.vehicle
border-radius: 0.9rem;
color: #17325c;
display: flex;
gap: 0.75rem;
justify-content: space-between;
padding: 0.75rem 0.9rem;
}
.vehicle-card__footer-copy {
align-items: center;
display: flex;
gap: 0.55rem;
justify-content: flex-start;
padding: 0.7rem 0.85rem;
min-width: 0;
}
.vehicle-card__footer-icon {
color: #1f5bb7;
}
.vehicle-card__footer-text-group {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.vehicle-card__footer-text {
font-size: 0.82rem;
font-weight: 600;
}
.vehicle-card__footer-hint {
color: #59708f;
font-size: 0.74rem;
line-height: 1.2;
}
.vehicle-card__footer-chevron {
color: #1f5bb7;
flex-shrink: 0;
}
</style>
@@ -1,82 +1,93 @@
<script setup lang="ts">
import {ref, watch, defineProps, defineEmits} from "vue";
import { ref, watch, defineProps, defineEmits } from "vue";
import { useI18n } from "vue-i18n";
import { reference } from "@/components/shop/POSDepartmentProcess.vue";
import { metadata, camera, attachments, transactionHistory, views, popups, resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobileAttachmentsDisplay
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachmentsDisplay.vue";
import {
metadata,
camera,
attachments,
transactionHistory,
views,
popups,
resetPos,
} from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobileAttachmentsDisplay from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachmentsDisplay.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import { reset_all_values, order_id } from "@/components/shop/POSDepartmentProcess.vue";
import {sounds} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { sounds } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const { t } = useI18n();
/** Props */
const props = defineProps({
showDefaultControls: {
// This determines if the attach, reference, and history buttons should be shown
type: Boolean,
default: true
default: true,
},
showClearAllButton: {
// Determines if the clear all button should be shown
type: Boolean,
default: true
default: true,
},
showAttachments: {
// This determines if the attachments should be shown
// This determines if the attachments should be shown
type: Boolean,
default: true
default: true,
},
showAttachButton: {
// This determines if the attach button should be shown
type: Boolean,
default: true
default: true,
},
showZoomButton: {
// This determines if the zoom button should be shown
type: Boolean,
default: true
default: true,
},
showBackButton: {
// This determines if the back button in the attachment selector should be shown
type: Boolean,
default: true
default: true,
},
showReferenceButton: {
// This determines if the reference button should be shown
type: Boolean,
default: true
default: true,
},
showHistoryButton: {
// This determines if the history button should be shown
type: Boolean,
default: true
default: true,
},
showTakePictureButton: {
// This determines if the take picture button should be shown
type: Boolean,
default: true
default: true,
},
showUploadFileButton: {
// This determines if the upload file button should be shown
type: Boolean,
default: true
}
})
default: true,
},
});
/** Emits */
const emits = defineEmits([]); // No emits for now
/** State */
const isAttachmentSelectorVisible = ref(false); // When enabled, the attach and reference buttons are hidden, and the attachment selector is shown
/** Reference */
const onClickReferenceButton = () => {
console.warn('End-user clicked reference button');
popups.select('change_reference');
}
console.warn("End-user clicked reference button");
popups.select("change_reference");
};
const isReferenceEmpty = () => {
return !reference.value || reference.value.trim() === '';
}
return !reference.value || reference.value.trim() === "";
};
/** Attachments */
const onClickAttachmentButton = () => {
isAttachmentSelectorVisible.value = !isAttachmentSelectorVisible.value;
}
};
const previousZoomLevel = ref(camera.getZoom());
// Watch the isAttachmentSelectorVisible to zoom out the camera when the selector is shown
watch(isAttachmentSelectorVisible, (newValue) => {
@@ -97,25 +108,25 @@ watch(views.attachmentView, (newValue) => {
// Restore previous zoom level
camera.setZoom(previousZoomLevel.value);
}
})
});
// Camera attachment
const onClickAttachPicture = async () => {
console.warn('End-user clicked attach picture button');
console.warn("End-user clicked attach picture button");
try {
const image = await camera.get();
console.warn('Camera image obtained:', image);
console.warn("Camera image obtained:", image);
if (image) {
attachments.addBase64({filename: `camera_${Date.now()}.jpg`, base64String: image});
sounds.play(sounds.list.value.onAfterSuccessfulScan)
console.warn('Image added to attachments');
attachments.addBase64({ filename: `camera_${Date.now()}.jpg`, base64String: image });
sounds.play(sounds.list.value.onAfterSuccessfulScan);
console.warn("Image added to attachments");
} else {
// User cancelled or no image obtained
console.warn('No image obtained from camera');
console.warn("No image obtained from camera");
}
} catch (error) {
console.error('Error accessing camera image:', error);
console.error("Error accessing camera image:", error);
}
}
};
// File attachment (stored as base64)
const uploadAttachment = (file: File) => {
// Implement the upload logic here
@@ -125,21 +136,21 @@ const uploadAttachment = (file: File) => {
reader.onload = (e) => {
const base64String = e.target?.result;
const filename = file.name;
if (typeof base64String === 'string') {
if (typeof base64String === "string") {
attachments.addBase64({ filename, base64String });
console.warn('File added to attachments:', filename);
console.warn("File added to attachments:", filename);
} else {
console.error('Failed to read file as base64 string');
console.error("Failed to read file as base64 string");
}
}
};
reader.readAsDataURL(file);
};
const onClickAttachFile = () => {
// Trigger file input click
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx'; // Accept images and common document types
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"; // Accept images and common document types
fileInput.onchange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
@@ -150,13 +161,13 @@ const onClickAttachFile = () => {
};
const onClickAttachmentWashCertificate = () => {
console.warn('End-user clicked attach wash certificate button');
console.warn("End-user clicked attach wash certificate button");
// Add the wash certificate to the additional items
attachments.setWashCertificate(!attachments.hasWashCertificate());
};
const onClickTransactionHistoryButton = () => {
console.warn('End-user clicked transaction history button');
console.warn("End-user clicked transaction history button");
// Show the transaction history
views.transactionHistoryView.value = true;
};
@@ -166,40 +177,52 @@ const resetAll = () => {
resetPos();
// 3. Reset all values
reset_all_values();
}
};
const onClickClearAllButton = () => {
// Check if the session storage contains the pos_order_id
if (localStorage.getItem('pos_order_id')) {
order_id.value = parseInt(localStorage.getItem('pos_order_id') || '0');
if (localStorage.getItem("pos_order_id")) {
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
}
// 1. Delete the order (If any)
if (order_id.value) {
SessionUser.objects.orders.delete.single(order_id.value).then(() => {
console.warn("Order deleted successfully");
}).catch((error) => {
console.error("Error deleting order:", error);
}).finally(() => {
resetAll();
})
SessionUser.objects.orders.delete
.single(order_id.value)
.then(() => {
console.warn("Order deleted successfully");
})
.catch((error) => {
console.error("Error deleting order:", error);
})
.finally(() => {
resetAll();
});
} else {
resetAll();
}
}
};
const isDefaultControlsVisible = () => {
return props.showDefaultControls && !isAttachmentSelectorVisible.value;
}
};
const isAttachmentsVisible = () => {
return props.showAttachments;
}
};
</script>
<template>
<div>
<div class="columns is-mobile is-vcentered">
<!-- Clear All Button -->
<div class="column is-narrow" data-testid="pos-mobile-clear-all" v-if="isDefaultControlsVisible() && props.showClearAllButton" @click="onClickClearAllButton" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-clear-all"
data-action-key="pos-mobile-clear-all-inline"
data-copy-key="clear_all"
v-if="isDefaultControlsVisible() && props.showClearAllButton"
@click="onClickClearAllButton"
style="cursor: pointer"
>
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -214,11 +237,19 @@ const isAttachmentsVisible = () => {
</div>
</div>
<!-- Attachment Icon and Label -->
<div class="column is-narrow" data-testid="pos-mobile-attachments-toggle" v-if="isDefaultControlsVisible() && showAttachButton" @click="onClickAttachmentButton" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-attachments-toggle"
data-action-key="pos-mobile-attachments-toggle"
data-copy-key="admin.pos.attachments"
v-if="isDefaultControlsVisible() && showAttachButton"
@click="onClickAttachmentButton"
style="cursor: pointer"
>
<div>
<!-- Count Badge -->
<div class="is-pulled-right" v-if="attachments.count() > 0">
<span class="tag is-info is-rounded is-size-7 pl-2" style="position: absolute;">
<span class="tag is-info is-rounded is-size-7 pl-2" style="position: absolute">
{{ attachments.count() }}
</span>
</div>
@@ -231,21 +262,26 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
<!-- Danish for Attach -->
Vedhæft
{{ t("admin.pos.attachments") }}
</p>
</div>
</div>
</div>
</div>
<!-- Reference Display -->
<div class="column is-narrow" data-testid="pos-mobile-change-reference" v-if="isDefaultControlsVisible() && props.showReferenceButton" @click="onClickReferenceButton" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-change-reference"
data-action-key="pos-mobile-change-reference"
data-copy-key="admin.pos.reference"
v-if="isDefaultControlsVisible() && props.showReferenceButton"
@click="onClickReferenceButton"
style="cursor: pointer"
>
<div>
<!-- Invisible badge to keep alignment -->
<div class="is-pulled-right" v-if="true" style="visibility: hidden;">
<span class="tag is-rounded is-size-7 pl-2" style="position: absolute;">
&nbsp;
</span>
<div class="is-pulled-right" v-if="true" style="visibility: hidden">
<span class="tag is-rounded is-size-7 pl-2" style="position: absolute"> &nbsp; </span>
</div>
<!-- Reference Display -->
<div class="columns is-mobile is-vcentered">
@@ -258,18 +294,26 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
Reference
{{ t("admin.pos.reference") }}
</p>
</div>
</div>
</div>
</div>
<!-- History Icon and Label -->
<div class="column is-narrow" data-testid="pos-mobile-transaction-history" v-if="isDefaultControlsVisible() && props.showHistoryButton" @click="onClickTransactionHistoryButton" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-transaction-history"
data-action-key="pos-mobile-transaction-history"
data-copy-key="history"
v-if="isDefaultControlsVisible() && props.showHistoryButton"
@click="onClickTransactionHistoryButton"
style="cursor: pointer"
>
<div>
<!-- Count Badge -->
<div class="is-pulled-right" v-if="transactionHistory.getPending().length > 0">
<span class="tag is-warning is-rounded is-size-7 pl-2" style="position: absolute;">
<span class="tag is-warning is-rounded is-size-7 pl-2" style="position: absolute">
{{ transactionHistory.getPending().length }}
</span>
</div>
@@ -282,15 +326,20 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
<!-- Danish for History -->
{{ SessionUser.objects.orders.meta.labels.multiple}}
{{ SessionUser.objects.global.language.history }}
</p>
</div>
</div>
</div>
</div>
<!-- Zoom -->
<div class="column is-narrow" data-testid="pos-mobile-camera-zoom" v-if="isDefaultControlsVisible() && props.showZoomButton" @click="camera.setZoom(camera.getZoom() === 1 ? 2 : 1)" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-camera-zoom"
v-if="isDefaultControlsVisible() && props.showZoomButton"
@click="camera.setZoom(camera.getZoom() === 1 ? 2 : 1)"
style="cursor: pointer"
>
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -299,15 +348,21 @@ const isAttachmentsVisible = () => {
</span>
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
Zoom
</p>
<p class="is-size-7 has-text-weight-semibold">Zoom</p>
</div>
</div>
</div>
<!-- Attachment Selector Options -->
<!-- Back button -->
<div class="column is-narrow" data-testid="pos-mobile-attachments-back" v-if="isAttachmentSelectorVisible && props.showBackButton" @click="onClickAttachmentButton" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-attachments-back"
data-action-key="pos-mobile-attachments-back"
data-copy-key="back"
v-if="isAttachmentSelectorVisible && props.showBackButton"
@click="onClickAttachmentButton"
style="cursor: pointer"
>
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -316,13 +371,21 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
Tilbage
{{ SessionUser.objects.global.language.back }}
</p>
</div>
</div>
</div>
<!-- Take picture -->
<div class="column is-narrow" data-testid="pos-mobile-attachments-take-picture" v-if="isAttachmentSelectorVisible && props.showTakePictureButton" @click="onClickAttachPicture" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-attachments-take-picture"
data-action-key="pos-mobile-attachments-take-picture"
data-copy-key="take_picture"
v-if="isAttachmentSelectorVisible && props.showTakePictureButton"
@click="onClickAttachPicture"
style="cursor: pointer"
>
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -331,13 +394,21 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
Tag billede
{{ SessionUser.objects.global.language.take_picture }}
</p>
</div>
</div>
</div>
<!-- Upload from device -->
<div class="column is-narrow" data-testid="pos-mobile-attachments-upload-file" v-if="isAttachmentSelectorVisible && props.showUploadFileButton" @click="onClickAttachFile" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-attachments-upload-file"
data-action-key="pos-mobile-attachments-upload-file"
data-copy-key="admin.pos.attachments_upload_action"
v-if="isAttachmentSelectorVisible && props.showUploadFileButton"
@click="onClickAttachFile"
style="cursor: pointer"
>
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
@@ -346,29 +417,40 @@ const isAttachmentsVisible = () => {
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
Upload fil
{{ t("admin.pos.attachments_upload_action") }}
</p>
</div>
</div>
</div>
<!-- Add wash certificate -->
<div class="column is-narrow" data-testid="pos-mobile-attachments-wash-certificate" v-if="isAttachmentSelectorVisible" @click="onClickAttachmentWashCertificate" style="cursor: pointer;">
<div
class="column is-narrow"
data-testid="pos-mobile-attachments-wash-certificate"
data-action-key="pos-mobile-attachments-wash-certificate"
data-copy-key="admin.pos.wash_certificate"
v-if="isAttachmentSelectorVisible"
@click="onClickAttachmentWashCertificate"
style="cursor: pointer"
>
<div class="columns is-mobile is-vcentered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
<i class="fas fa-file-alt" :class="{'has-text-success': attachments.hasWashCertificate()}"></i>
<i class="fas fa-file-alt" :class="{ 'has-text-success': attachments.hasWashCertificate() }"></i>
</span>
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold" :class="{'has-text-success': attachments.hasWashCertificate()}">
Vaskecertifikat
<p
class="is-size-7 has-text-weight-semibold"
:class="{ 'has-text-success': attachments.hasWashCertificate() }"
>
{{ t("admin.pos.wash_certificate") }}
</p>
</div>
</div>
</div>
</div>
<!-- Attachment Thumbnails -->
<PosDepartmentStepMobileAttachmentsDisplay v-if="isAttachmentsVisible()"/>
<PosDepartmentStepMobileAttachmentsDisplay v-if="isAttachmentsVisible()" />
</div>
<!--
@@ -1,5 +1,21 @@
<script setup lang="ts">
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, setDepartment, getDepartment } from "@/components/shop/POSDepartmentProcess.vue";
import {
reset_all_values,
customer_name,
nextStep,
searchAndSelectCustomer,
isCustomerSelected,
order_id,
customer_id,
step,
reg_1,
reg_2,
reg_3,
reference,
order_notes,
setDepartment,
getDepartment,
} from "@/components/shop/POSDepartmentProcess.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
@@ -7,42 +23,46 @@ import SessionUser from "@/components/session/token/SessionUser.vue";
const onClickClearAll = () => {
// 1. Delete the order (If any)
// Check localstorage for pos_order_id
if (localStorage.getItem('pos_order_id')) {
order_id.value = parseInt(localStorage.getItem('pos_order_id') || '0');
if (localStorage.getItem("pos_order_id")) {
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
}
if (order_id.value) {
SessionUser.objects.orders.delete.single(order_id.value).then(() => {
// 2. Reset the POS
resetPos();
// 3. Reset all values
reset_all_values();
}).catch((error) => {
console.error("Error deleting order:", error);
resetPos();
reset_all_values();
});
SessionUser.objects.orders.delete
.single(order_id.value)
.then(() => {
// 2. Reset the POS
resetPos();
// 3. Reset all values
reset_all_values();
})
.catch((error) => {
console.error("Error deleting order:", error);
resetPos();
reset_all_values();
});
}
}
};
</script>
<template>
<div class="button is-fullwidth is-white mt-2"
data-testid="pos-mobile-clear-all-button"
@click="onClickClearAll()">
<div
class="button is-fullwidth is-white mt-2"
data-testid="pos-mobile-clear-all-button"
data-action-key="pos-mobile-clear-all"
data-copy-key="clear_all"
@click="onClickClearAll()"
>
<div class="columns is-mobile is-vcentered has-text-centered">
<!-- Clear all -->
<div class="column is-12 has-text-centered" @click="onClickClearAll()">
<div class="columns is-mobile is-vcentered is-centered">
<div class="column is-narrow pr-1">
<span class="icon is-small">
<i class="fas fa-trash-alt"></i>
</span>
<span class="icon is-small">
<i class="fas fa-trash-alt"></i>
</span>
</div>
<div class="column is-narrow pl-0">
<p class="is-size-7 has-text-weight-semibold">
Slet alt
</p>
<p class="is-size-7 has-text-weight-semibold">Slet alt</p>
</div>
</div>
</div>
@@ -50,6 +70,4 @@ const onClickClearAll = () => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -1,20 +1,54 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import {defineProps, onMounted, computed, ref} from "vue";
import { reset_all_values, customer_name, nextStep, searchAndSelectCustomer, isCustomerSelected, order_id, customer_id, step, reg_1, reg_2, reg_3, reference, order_notes, order_safety_seal, loadCustomerAttributes, hasAttribute, uploadAttachment, restoreStoredPosOrderId } from "@/components/shop/POSDepartmentProcess.vue";
import { useI18n } from "vue-i18n";
import { defineProps, onMounted, computed, ref } from "vue";
import {
reset_all_values,
customer_name,
nextStep,
searchAndSelectCustomer,
isCustomerSelected,
order_id,
customer_id,
step,
reg_1,
reg_2,
reg_3,
reference,
order_notes,
order_safety_seal,
loadCustomerAttributes,
hasAttribute,
uploadAttachment,
restoreStoredPosOrderId,
} from "@/components/shop/POSDepartmentProcess.vue";
import * as POSDepartmentProcess from "@/components/shop/POSDepartmentProcess.vue";
import { errors } from "@/components/request/HandleGlobalError.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import ContinueArrow from "@/components/viewport/elements/icons/ContinueArrow.vue";
import { vehicles, metadata, getCustomerId, popups, resetPos, attachments, transactionHistory, transactionItems } from "../objects/PosDepartmentStepMobileFlow.vue";
import { finalizeCurrentMobileOrder, getResolvedMobileSafetySeal, syncMobileSafetySealState } from "../objects/mobileOrderCompletion.js";
import {
vehicles,
metadata,
getCustomerId,
popups,
resetPos,
attachments,
transactionHistory,
transactionItems,
} from "../objects/PosDepartmentStepMobileFlow.vue";
import {
finalizeCurrentMobileOrder,
getResolvedMobileSafetySeal,
syncMobileSafetySealState,
} from "../objects/mobileOrderCompletion.js";
import Swal from "sweetalert2";
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
import {views} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { views } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const { t } = useI18n();
const selectCustomerString = `${SessionUser.objects.global.language.select} ${SessionUser.objects.global.language.customer.toLowerCase()}`;
const selectCustomerString = `${
SessionUser.objects.global.language.select
} ${SessionUser.objects.global.language.customer.toLowerCase()}`;
const isCreatingOrder = computed(() => POSDepartmentProcess.isCreatingOrder?.value ?? false);
const props = defineProps({
@@ -52,25 +86,31 @@ const props = defineProps({
type: Array as () => string[],
default: () => [],
},
actionKey: {
type: String,
default: null,
},
copyKey: {
type: String,
default: null,
},
});
const updateReference = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.reference(order_id.value, reference.value);
}
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.reference(order_id.value, reference.value);
};
const updateNotes = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
}
if (!!order_id.value && order_id.value > 0) SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
};
const updateSafetySeal = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.safety_seal(order_id.value, order_safety_seal.value);
}
SessionUser.objects.orders.set.safety_seal(order_id.value, order_safety_seal.value);
};
// Helpers: extracted for clarity and reuse
const isNonEmptyString = (val) => typeof val === 'string' && val.trim().length > 0;
const isNonEmptyString = (val) => typeof val === "string" && val.trim().length > 0;
const resolveReferenceFromSources = () => {
// 1) Metadata reference
@@ -87,22 +127,22 @@ const resolveReferenceFromSources = () => {
return null;
};
const promptForReference = async (initialValue = '') => {
const promptForReference = async (initialValue = "") => {
const result = await Swal.fire({
title: t('admin.pos.reference_required_title'),
text: t('admin.pos.reference_required_text'),
input: 'text',
inputLabel: t('admin.pos.reference'),
title: t("admin.pos.reference_required_title"),
text: t("admin.pos.reference_required_text"),
input: "text",
inputLabel: t("admin.pos.reference"),
inputValue: initialValue,
showCancelButton: true,
confirmButtonText: t('admin.pos.confirm'),
cancelButtonText: t('admin.pos.cancel'),
confirmButtonText: t("admin.pos.confirm"),
cancelButtonText: t("admin.pos.cancel"),
preConfirm: (newReference) => {
if (!isNonEmptyString(newReference)) {
Swal.showValidationMessage(t('admin.pos.reference_cannot_be_empty'));
Swal.showValidationMessage(t("admin.pos.reference_cannot_be_empty"));
}
return newReference;
}
},
});
if (result.isConfirmed && isNonEmptyString(result.value)) {
return result.value.trim();
@@ -114,7 +154,7 @@ const promptForReference = async (initialValue = '') => {
const validateReferenceRequirements = async () => {
await loadCustomerAttributes();
const customerRequiresReference = hasAttribute('requiresReferenceNumber');
const customerRequiresReference = hasAttribute("requiresReferenceNumber");
// Try to resolve an existing reference from known sources
const resolvedRef = resolveReferenceFromSources();
@@ -132,23 +172,22 @@ const validateReferenceRequirements = async () => {
// Prompt user for a reference if required and none available
try {
const enteredRef = await promptForReference(reference.value ?? '');
const enteredRef = await promptForReference(reference.value ?? "");
if (isNonEmptyString(enteredRef)) {
reference.value = enteredRef;
metadata.setReference(enteredRef);
console.warn('New reference set:', enteredRef);
console.warn("New reference set:", enteredRef);
onClick(); // Retry the click action
return true;
}
console.warn('Reference number is required but was not provided. Aborting operation.');
console.warn("Reference number is required but was not provided. Aborting operation.");
return false;
} catch (error) {
console.warn('An error occurred while setting the reference number:', error);
console.warn("An error occurred while setting the reference number:", error);
return false;
}
};
const step1 = () => {
/** This function can be used to perform any specific actions for step 1 */
// Set registration numbers
@@ -191,7 +230,7 @@ const mergeReferences = () => {
metadata.setReference(reference.value);
updateReference();
}
}
};
const mergeNotes = () => {
if (metadata.getNotes() && metadata.getNotes().length >= 1) {
order_notes.value = metadata.getNotes();
@@ -200,13 +239,13 @@ const mergeNotes = () => {
metadata.setNotes(order_notes.value);
updateNotes();
}
}
};
const mergeSafetySeal = () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal();
order_safety_seal.value = resolvedSafetySeal;
syncMobileSafetySealState(resolvedSafetySeal);
updateSafetySeal();
}
};
const finalizeStep1 = () => {
mergeReferences();
mergeNotes();
@@ -214,40 +253,45 @@ const finalizeStep1 = () => {
// Upload the attachments if there are any (And the order id has been created)
if (attachments.getBase64().length > 0 && order_id.value > 0) {
for (const attachment of attachments.getBase64()) {
uploadAttachment(attachment).then(() => {
console.warn('Attachment uploaded successfully');
// Remove the attachment from the list
attachments.removeBase64(attachment);
}).catch((error: any) => {
errors.value.push(error);
console.warn('An error occurred while uploading the attachment:', error);
});
uploadAttachment(attachment)
.then(() => {
console.warn("Attachment uploaded successfully");
// Remove the attachment from the list
attachments.removeBase64(attachment);
})
.catch((error: any) => {
errors.value.push(error);
console.warn("An error occurred while uploading the attachment:", error);
});
}
}
};
const addTransactionToHistory = async (orderId: number) => {
SessionUser.objects.orders.get.single(orderId).then((transaction: any) => {
if (transaction) {
transactionHistory.add(transaction);
console.warn('Transaction added to history:', transaction);
} else {
console.warn('Unable to add transaction to history: Order not found.');
}
}).catch((error: any) => {
errors.value.push(error);
console.warn('An error occurred while fetching the transaction for history:', error);
});
SessionUser.objects.orders.get
.single(orderId)
.then((transaction: any) => {
if (transaction) {
transactionHistory.add(transaction);
console.warn("Transaction added to history:", transaction);
} else {
console.warn("Unable to add transaction to history: Order not found.");
}
})
.catch((error: any) => {
errors.value.push(error);
console.warn("An error occurred while fetching the transaction for history:", error);
});
};
const toPositiveInteger = (value: any) => {
const parsedValue = Number.parseInt(String(value ?? ''), 10);
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
}
};
const getSelectedBookingId = () => {
return toPositiveInteger(metadata.getBookingId?.() ?? metadata.bookingId?.value ?? null);
}
};
const completeStep2Order = async ({
bookingSafetySeal = null,
@@ -260,17 +304,17 @@ const completeStep2Order = async ({
bookingSafetySeal,
markOrderCompleted,
});
popups.select('completed_transaction', {
popups.select("completed_transaction", {
message: `Order #${order_id.value} successfully created.`,
});
completeOrder();
}
};
const step2 = async () => {
/** This function can be used to perform any specific actions for step 2 */
// If the customer is paying with a card, go to the payment step
if (isCustomerSelected() && getCustomerId() === 999) {
nextStep({isMobile: true});
nextStep({ isMobile: true });
return;
}
@@ -281,15 +325,15 @@ const step2 = async () => {
try {
if (requiresBookingCompletionPopup) {
const onCompleteBookingWithSafetySeal = async ({ safetySeal = '' } = {}) => {
const onCompleteBookingWithSafetySeal = async ({ safetySeal = "" } = {}) => {
syncMobileSafetySealState(safetySeal);
await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ''),
bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
});
};
popups.select('complete_booking', {
popups.select("complete_booking", {
props: {
onCompleteWithCertificate: onCompleteBookingWithSafetySeal,
onCompleteWithoutCertificate: onCompleteBookingWithSafetySeal,
@@ -303,8 +347,8 @@ const step2 = async () => {
});
} catch (error: any) {
errors.value.push(error);
console.warn('An error occurred while completing the mobile order:', error);
popups.select('error');
console.warn("An error occurred while completing the mobile order:", error);
popups.select("error");
}
};
@@ -318,12 +362,12 @@ const completeOrder = () => {
resetPos(); // Reset the POS state
// Start the process again
}, 3000);
}
};
const step3 = () => {
/** This function can be used to perform any specific actions for step 3 */
// This step is not defined, but you can add your logic here if needed
console.warn('Step 3: Card payment step is not defined.');
console.warn("Step 3: Card payment step is not defined.");
};
const isProcessingClick = ref(false);
@@ -346,7 +390,7 @@ const onClick = async () => {
}
if (!(isCustomerSelected() && getCustomerId() > 0)) {
// Show the popup to select a customer
popups.select('select_customer');
popups.select("select_customer");
return;
}
@@ -364,7 +408,7 @@ const onClick = async () => {
allowCompleted: false,
});
if (restoredOrderId) {
console.warn('Order ID retrieved from local storage:', restoredOrderId);
console.warn("Order ID retrieved from local storage:", restoredOrderId);
nextStep({
isMobile: true,
orderCreation: false,
@@ -390,7 +434,7 @@ const onClick = async () => {
break;
}
} catch (error) {
console.warn('Next-step action was interrupted:', error);
console.warn("Next-step action was interrupted:", error);
} finally {
isProcessingClick.value = false;
}
@@ -411,7 +455,7 @@ const applyCustomerSelection = () => {
if (shouldApplyCustomerSelection) {
searchAndSelectCustomer(getCustomerId());
}
}
};
// Check customer selection states.
onMounted(() => {
@@ -420,7 +464,7 @@ onMounted(() => {
const isRegistrationNumberFilled = () => {
return vehicles?.vehicle_1?.value?.reg && vehicles.vehicle_1.value?.reg.length >= 4;
}
};
// Is the requirements for clicking the button met?
const isRequirementsForClickMet = () => {
// If a custom disabled state is provided, use that.
@@ -448,7 +492,7 @@ const isRequirementsForClickMet = () => {
// The customer is selected, so check if the reg_1 is filled.
return isRegistrationNumberFilled();
}
}
};
// Should the button be visible?
const isVisible = computed(() => {
@@ -481,8 +525,8 @@ const onLongPress = () => {
const defaultLongPressBehavior = () => {
// Select the customer
popups.select('select_customer');
}
popups.select("select_customer");
};
/**
* Colors:
@@ -491,22 +535,32 @@ const defaultLongPressBehavior = () => {
* Secondary:
* :buttonClasses="['has-background-primary-dark', 'has-text-black']"
*/
</script>
<template>
<LongPressListener @long-press="onLongPress">
<GenericButton @click="onClick" :class="{'white': props.isWhite, 'has-background-black': props.isDark, 'is-loading': isBusy, ...(props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}))}"
data-testid="pos-mobile-next-step"
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
:disabled="!isRequirementsForClickMet()" v-show="isVisible">
<GenericButton
@click="onClick"
:class="{
white: props.isWhite,
'has-background-black': props.isDark,
'is-loading': isBusy,
...props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
}"
data-testid="pos-mobile-next-step"
:action-key="props.actionKey"
:copy-key="props.copyKey"
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
:disabled="!isRequirementsForClickMet()"
v-show="isVisible"
>
<!-- Default content if no slot is provided -->
<template v-if="!$slots.default">
<template v-if="isCustomerSelected()">
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label">{{ customer_name }}</span>
<span class="pos-mobile-cta-value">
<ContinueArrow style="width: 100%; height: 100%; padding: 6px 12px; gap: 5px;" />
<ContinueArrow style="width: 100%; height: 100%; padding: 6px 12px; gap: 5px" />
</span>
</span>
</template>
@@ -552,7 +606,7 @@ const defaultLongPressBehavior = () => {
flex-shrink: 0;
}
.has-text-black >:not(.icon) {
.has-text-black > :not(.icon) {
color: black !important;
}
@@ -655,6 +709,8 @@ const defaultLongPressBehavior = () => {
pointer-events: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>
@@ -181,6 +181,14 @@ const onClickDraftCustomer = async () => {
</div>
</section>
<div class="customer-action-divider" data-testid="pos-mobile-customer-action-divider">
<span class="customer-action-divider__line"></span>
<span class="customer-action-divider__label">
{{ t("pos.customer_picker.select_payment_form") }}
</span>
<span class="customer-action-divider__line"></span>
</div>
<div class="customer-quick-actions" data-testid="pos-mobile-customer-action-group">
<button
class="button is-light customer-quick-action customer-quick-action--invoice"
@@ -259,7 +267,7 @@ const onClickDraftCustomer = async () => {
.pos-mobile-customer-popup {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1.25rem;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
@@ -333,10 +341,42 @@ const onClickDraftCustomer = async () => {
text-align: right;
}
.customer-action-divider {
align-items: center;
display: flex;
gap: 0.7rem;
}
.customer-action-divider__line {
background: linear-gradient(
90deg,
rgba(170, 188, 214, 0) 0%,
rgba(170, 188, 214, 0.95) 25%,
rgba(170, 188, 214, 0.95) 75%,
rgba(170, 188, 214, 0) 100%
);
flex: 1 1 auto;
height: 1px;
}
.customer-action-divider__label {
color: #6d7f99;
flex: 0 0 auto;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.customer-quick-actions {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.customer-quick-actions :deep(.button) {
margin: 0;
}
.customer-quick-action {
@@ -346,12 +386,14 @@ const onClickDraftCustomer = async () => {
box-shadow: 0 8px 16px rgba(21, 49, 93, 0.06);
color: #17325c;
display: inline-flex;
flex: 1 1 10rem;
font-weight: 600;
gap: 0.7rem;
justify-content: flex-start;
min-height: 3rem;
padding: 0.85rem 1rem;
text-align: left;
gap: 0.75rem;
justify-content: center;
max-width: 100%;
min-height: 3.25rem;
padding: 0.9rem 1rem;
text-align: center;
text-decoration: none;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
white-space: normal;
@@ -440,6 +482,7 @@ const onClickDraftCustomer = async () => {
border: 1px solid #dbe5f2;
border-radius: 1rem;
box-shadow: 0 10px 18px rgba(21, 49, 93, 0.05);
margin-top: 0.15rem;
padding: 0.8rem 0.9rem 0.95rem;
}
@@ -480,4 +523,20 @@ const onClickDraftCustomer = async () => {
.customer-suggestions {
margin-top: 0.1rem;
}
@media (max-width: 380px) {
.customer-quick-action {
flex-basis: 100%;
}
.customer-selection-summary__content {
align-items: flex-start;
flex-wrap: wrap;
}
.customer-selection-summary__mode {
max-width: none;
text-align: left;
}
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {ref, defineEmits, onBeforeUnmount, onMounted, watch, defineProps} from "vue";
import { ref, defineEmits, onBeforeUnmount, onMounted, watch, defineProps } from "vue";
import { order_id, reg_1, reg_2, reg_3, getOrderDetails } from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile1RegistrationNumberInputField from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputField.vue";
import BarcodeScanner from "@/components/viewport/elements/icons/BarcodeScanner.vue";
@@ -9,10 +9,8 @@ import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCusto
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import type { PosSearchResult } from "../objects/PosSearchResult.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
@@ -21,34 +19,34 @@ const props = defineProps({
// Show Reg 1.
showReg1: {
type: Boolean,
default: true
default: true,
},
showReg2: {
type: Boolean,
default: true
default: true,
},
showReg3: {
type: Boolean,
default: true
default: true,
},
showButtons: {
type: Boolean,
default: true
default: true,
},
forceShowApplicable: {
// Used to show reg 3, when reg 2 is hidden.
type: Boolean,
default: false
}
default: false,
},
});
const inputTestIds = {
reg1: 'pos-mobile-reg-input-1',
reg2: 'pos-mobile-reg-input-2',
reg3: 'pos-mobile-reg-input-3',
reg1: "pos-mobile-reg-input-1",
reg2: "pos-mobile-reg-input-2",
reg3: "pos-mobile-reg-input-3",
};
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? '').toUpperCase();
const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? "").toUpperCase();
const getNormalizedOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value), 10);
@@ -95,24 +93,25 @@ const label1 = ref("Reg 1*");
const label2 = ref("Reg 2*");
const label3 = ref("Reg 3");
const createRegistrationAutosave = (vehicleIndex: number) => useOrderMetadataAutosave({
source: getRegistrationSourceRef(vehicleIndex),
normalizeValue: normalizeRegistrationValue,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
const createRegistrationAutosave = (vehicleIndex: number) =>
useOrderMetadataAutosave({
source: getRegistrationSourceRef(vehicleIndex),
normalizeValue: normalizeRegistrationValue,
saveValue: async (value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
const refreshedOrder = await getOrderDetails(normalizedOrderId);
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
},
onSaved: async (value) => {
setPersistedRegistrationValue(vehicleIndex, value);
syncVehicleRegistration(vehicleIndex, value);
},
});
await SessionUser.objects.orders.set[`reg_${vehicleIndex}`](normalizedOrderId, value);
const refreshedOrder = await getOrderDetails(normalizedOrderId);
return refreshedOrder?.[`reg_${vehicleIndex}`] ?? value;
},
onSaved: async (value) => {
setPersistedRegistrationValue(vehicleIndex, value);
syncVehicleRegistration(vehicleIndex, value);
},
});
const registrationAutosaves = {
1: createRegistrationAutosave(1),
@@ -172,17 +171,20 @@ watch(reg3Draft, (value) => {
setPersistedRegistrationValue(3, value);
});
watch(() => getNormalizedOrderId(), (normalizedOrderId) => {
if (!normalizedOrderId) {
return;
}
[1, 2, 3].forEach((vehicleIndex) => {
if (registrationAutosaves[vehicleIndex].isDirty.value) {
registrationAutosaves[vehicleIndex].scheduleSave();
watch(
() => getNormalizedOrderId(),
(normalizedOrderId) => {
if (!normalizedOrderId) {
return;
}
});
});
[1, 2, 3].forEach((vehicleIndex) => {
if (registrationAutosaves[vehicleIndex].isDirty.value) {
registrationAutosaves[vehicleIndex].scheduleSave();
}
});
}
);
const flushRegistration = async (vehicleIndex: number) => {
await registrationAutosaves[vehicleIndex].flush();
@@ -210,19 +212,19 @@ function setIfNotNull(variable: string, value: PosSearchResult | null) {
const nextVehicleSelection = createVehicleSelectionFromSearchResult(value);
switch (variable) {
case 'reg_1':
case "reg_1":
//console.log("Setting reg_1 with value:", value);
pos.vehicles.select(1, nextVehicleSelection);
setRegistrationDraft(1, value.registrationNumber);
//reg_1_status.value = value.customerStatus;
break;
case 'reg_2':
case "reg_2":
pos.vehicles.select(2, nextVehicleSelection);
setRegistrationDraft(2, value.registrationNumber);
//reg_2_status.value = value.customerStatus;
break;
case 'reg_3':
case "reg_3":
pos.vehicles.select(3, nextVehicleSelection);
setRegistrationDraft(3, value.registrationNumber);
//reg_3_status.value = value.customerStatus;
@@ -245,7 +247,7 @@ function setValueUserInput(vehicleIndex: number, value: string) {
// If the value is empty, clear the registration number
if (normalizedValue === "") {
pos.vehicles.select(vehicleIndex, null);
setRegistrationDraft(vehicleIndex, '');
setRegistrationDraft(vehicleIndex, "");
void flushRegistration(vehicleIndex);
return;
}
@@ -269,9 +271,8 @@ onBeforeUnmount(() => {
const closeManualInput = async () => {
await flushAllRegistrations();
emit('close');
emit("close");
};
</script>
<template>
@@ -279,67 +280,93 @@ const closeManualInput = async () => {
<template v-if="props.showReg1">
<!-- First input field with a required label -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-model:modelValue="reg1Draft"
v-model:label="label1"
:inputTestId="inputTestIds.reg1"
@focusout="setValueUserInput(1, reg1Draft)"
v-model:modelValue="reg1Draft"
v-model:label="label1"
:inputTestId="inputTestIds.reg1"
@focusout="setValueUserInput(1, reg1Draft)"
>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg1Draft" :automaticallySelect="true"/>
<template #searchResults>
<RegistrationNumberSearchResult
@select="setIfNotNull('reg_1', $event)"
:searchQuery="reg1Draft"
:automaticallySelect="true"
/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showReg2">
<!-- Second input field with a custom expander button -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-model:modelValue="reg2Draft"
v-model:label="label2"
:inputTestId="inputTestIds.reg2"
@focusout="setValueUserInput(2, reg2Draft)"
v-model:modelValue="reg2Draft"
v-model:label="label2"
:inputTestId="inputTestIds.reg2"
@focusout="setValueUserInput(2, reg2Draft)"
>
<template #default>
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg3Draft">
<span class="custom-text">+</span>
</div>
<div class="custom-expander" @click="expanded = !expanded" v-if="!reg3Draft">
<span class="custom-text">+</span>
</div>
</template>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_2', $event)" :searchQuery="reg2Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
<RegistrationNumberSearchResult
@select="setIfNotNull('reg_2', $event)"
:searchQuery="reg2Draft"
:modifyCustomerOnChange="false"
:automaticallySelect="true"
/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showReg3">
<!-- Third input field that is conditionally displayed based on the expanded state -->
<PosDepartmentStepMobile1RegistrationNumberInputField
v-show="expanded || reg3Draft !== ''"
v-model:modelValue="reg3Draft"
v-model:label="label3"
:inputTestId="inputTestIds.reg3"
@focusout="setValueUserInput(3, reg3Draft)"
v-show="expanded || reg3Draft !== ''"
v-model:modelValue="reg3Draft"
v-model:label="label3"
:inputTestId="inputTestIds.reg3"
@focusout="setValueUserInput(3, reg3Draft)"
>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_3', $event)" :searchQuery="reg3Draft" :modifyCustomerOnChange="false" :automaticallySelect="true"/>
<RegistrationNumberSearchResult
@select="setIfNotNull('reg_3', $event)"
:searchQuery="reg3Draft"
:modifyCustomerOnChange="false"
:automaticallySelect="true"
/>
</template>
</PosDepartmentStepMobile1RegistrationNumberInputField>
</template>
<template v-if="props.showButtons">
<!-- Back to scan button -->
<button class="button p-5 is-fullwidth mt-3 is-text" data-testid="pos-mobile-manual-input-close" style="text-decoration: none" @click="closeManualInput()">
<span class="m-2"><BarcodeScanner/></span>
<span class="custom-label-button">{{ SessionUser.objects.global.language.or_scan_plates}}</span>
<button
class="button p-5 is-fullwidth mt-3 is-text"
data-testid="pos-mobile-manual-input-close"
style="text-decoration: none"
@click="closeManualInput()"
>
<span class="m-2"><BarcodeScanner /></span>
<span class="custom-label-button">{{ SessionUser.objects.global.language.or_scan_plates }}</span>
</button>
</template>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => closeManualInput()" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<span class="pos-mobile-action-content">
<span class="pos-mobile-action-label">{{ SessionUser.objects.global.language.close }}</span>
<span class="pos-mobile-action-icon">
<i class="fa-solid fa-xmark"></i>
</span>
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep
:isWhite="false"
:customAction="() => closeManualInput()"
:customDisabled="false"
:buttonClasses="['has-background-primary', 'has-text-black']"
action-key="pos-mobile-manual-input-close-action"
copy-key="close"
>
<span class="pos-mobile-action-content">
<span class="pos-mobile-action-label">{{ SessionUser.objects.global.language.close }}</span>
<span class="pos-mobile-action-icon">
<i class="fa-solid fa-xmark"></i>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</div>
</template>
@@ -350,7 +377,7 @@ const closeManualInput = async () => {
width: max-content;
height: 18px;
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 400;
font-size: 16px;
@@ -363,12 +390,10 @@ const closeManualInput = async () => {
color: #000000;
/* Inside auto layout */
flex: none;
order: 1;
flex-grow: 0;
}
.custom-expander {
/* Button */
@@ -397,14 +422,13 @@ const closeManualInput = async () => {
height: 18px;
width: max-content;
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 700;
font-size: 16px;
line-height: 18px;
color: #FFFFFF;
color: #ffffff;
/* Inside auto layout */
flex: none;
@@ -294,6 +294,8 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
:customAction="() => syncListTransactionHistory()"
:customDisabled="false"
:buttonClasses="['has-background-primary-dark', 'has-text-black', ...(isLoading ? ['is-loading'] : [])]"
action-key="pos-mobile-transaction-history-reload"
copy-key="reload"
>
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-reload-action">
<span
@@ -315,6 +317,8 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
:customAction="() => emit('close')"
:customDisabled="false"
:buttonClasses="['has-background-primary', 'has-text-black']"
action-key="pos-mobile-transaction-history-close"
copy-key="close"
>
<span class="pos-mobile-action-content" data-testid="pos-mobile-transaction-history-close-action">
<span class="pos-mobile-action-label" data-testid="pos-mobile-transaction-history-close-label">{{
@@ -1,5 +1,6 @@
<script setup>
import { computed, ref, nextTick, defineEmits, defineExpose, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
customer_id,
notes,
@@ -33,7 +34,9 @@ import CustomerSearchFieldPos from "@/components/forms/department/pos/input/cust
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const { t } = useI18n();
const emit = defineEmits(["update:desktopStep1Context", "commit:desktopStep1"]);
const isRegistrationNumbersExpanded = ref(false);
@@ -63,11 +66,31 @@ const normalizePlateValue = (value) =>
.replace(/\s/g, "")
.toUpperCase();
const normalizeReferenceValue = (value) => String(value ?? "");
const normalizeCustomerNumber = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getNormalizedOrderId = () => {
const parsedOrderId = Number.parseInt(String(order_id.value ?? ""), 10);
return Number.isInteger(parsedOrderId) && parsedOrderId > 0 ? parsedOrderId : null;
};
const getInputElementValue = (inputId, fallbackValue = "") => {
if (typeof document === "undefined") {
return fallbackValue;
}
const inputElement = document.getElementById(inputId);
if (!inputElement || typeof inputElement.value !== "string") {
return fallbackValue;
}
return inputElement.value;
};
const isBlankPosValue = (value) => String(value ?? "").trim() === "";
const getCustomerConflictKey = (conflict) => {
@@ -229,6 +252,35 @@ const setBookingMatches = (emittedBookingMatches) => {
bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
};
const persistDesktopField = async (fieldName, value) => {
const normalizedOrderId = getNormalizedOrderId();
if (!normalizedOrderId) {
return value;
}
await authenticatedRequest("/order", "PUT", {
id: normalizedOrderId,
field: fieldName,
value,
});
return value;
};
const persistDesktopReference = async () => {
const nextValue = normalizeReferenceValue(getInputElementValue("reference", reference.value));
setReferenceValue(nextValue, isBlankPosValue(nextValue) ? "empty" : referenceSource.value);
await persistDesktopField("reference", nextValue);
return nextValue;
};
const persistDesktopRegistration = async (fieldName) => {
const targetField = fieldName === "reg_3" ? reg_3 : reg_2;
const normalizedValue = normalizePlateValue(getInputElementValue(fieldName, targetField.value));
targetField.value = normalizedValue;
await persistDesktopField(fieldName, normalizedValue);
return normalizedValue;
};
const syncBookingRegistrationFields = async (booking) => {
const bookingReg1 = getBookingReg1Value(booking);
const bookingReg2 = getBookingReg2Value(booking);
@@ -560,7 +612,20 @@ watch(isDesktopStep1Active, (isActive) => {
});
const handleReferenceInput = (event) => {
setReferenceValue(event?.target?.value ?? "", "manual");
const nextValue = normalizeReferenceValue(event?.target?.value ?? "");
setReferenceValue(nextValue, "manual");
};
const flushReferenceAutosave = async () => {
await persistDesktopReference();
};
const flushRegistrationAutosave = async (fieldName) => {
await persistDesktopRegistration(fieldName);
};
const flushDesktopStep1Autosaves = async () => {
await Promise.all([persistDesktopReference(), persistDesktopRegistration("reg_2"), persistDesktopRegistration("reg_3")]);
};
const handleManualCustomerSelection = () => {
@@ -714,6 +779,8 @@ const finalizeDesktopStep1Context = async (options = {}) => {
...options,
};
await flushDesktopStep1Autosaves();
if (normalizedOptions.finalizeReg1Input !== false) {
await licensePlateReg1InputRef.value?.finalizeSelection?.({
source: normalizedOptions.source,
@@ -979,6 +1046,7 @@ defineExpose({
:actual-value="reg_2"
:reg_1="reg_1"
:customer_id="parseInt(customer_id) || 0"
@focusout="flushRegistrationAutosave('reg_2')"
@keydown="keyDownNextInput($event, isRegistrationNumbersExpanded ? 'reg_3' : 'reference')"
/>
</div>
@@ -990,6 +1058,7 @@ defineExpose({
v-model:inputModel="reg_3"
:customer_number="parseInt(customer_id) || 0"
:actual-value="reg_3"
@focusout="flushRegistrationAutosave('reg_3')"
@keydown="keyDownNextInput($event, 'reference')"
/>
</div>
@@ -1003,13 +1072,14 @@ defineExpose({
:tabindex="4"
:value="reference"
@input="handleReferenceInput"
@change="flushReferenceAutosave"
@keydown="keyDownNextInput($event, 'customer_id')"
/>
</div>
</div>
<template v-if="shouldShowInlineCustomerSelector">
<div class="divider pos-vehicle-form__divider">
{{ SessionUser.objects.global.language.select }} {{ SessionUser.objects.global.language.customer }}
{{ t("pos.customer_picker.select_payment_form") }}
</div>
<CustomerSearchFieldPos @onCustomerSelected="handleManualCustomerSelection" />
<VehicleCustomerSuggestionsPos
@@ -8,6 +8,7 @@ import {
ensureDraftTransactionCustomerLoaded,
getDraftTransactionCustomerNumber,
} from "@/composables/useDraftTransactionCustomer.js";
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js";
const t = (key: string) => i18n.global.t(key);
@@ -45,6 +46,9 @@ const firstToUpperCase = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
}
const department_booking_count = ref(0); // Placeholder for actual booking count logic
const department_draft_count = ref(0);
const draftTransactionCustomerNumber = computed(() => getDraftTransactionCustomerNumber());
let draftCountRequestId = 0;
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
const getDepartmentById = (id: number) => {
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
@@ -84,20 +88,51 @@ const fetchDepartmentBookingCount = () => {
});
}
const fetchCurrentDepartmentDraftCount = async () => {
if (!hasValidDepartmentId.value || draftTransactionCustomerNumber.value === null) {
department_draft_count.value = 0;
return;
}
const requestId = ++draftCountRequestId;
const currentDepartmentId = getDepartmentIdNumber();
const currentCustomerNumber = draftTransactionCustomerNumber.value;
const nextDraftCount = await fetchDepartmentDraftCount({
departmentId: currentDepartmentId,
customerNumber: currentCustomerNumber,
});
if (
requestId !== draftCountRequestId ||
currentDepartmentId !== getDepartmentIdNumber() ||
currentCustomerNumber !== draftTransactionCustomerNumber.value
) {
return;
}
department_draft_count.value = nextDraftCount;
};
const isDepartmentSet = computed(() => {
return department_id.value !== 'default' && parseInt(department_id.value) > 0;
});
watch(department_id, () => {
fetchDepartmentBookingCount();
void fetchCurrentDepartmentDraftCount();
}, { immediate: true });
watch(draftTransactionCustomerNumber, () => {
void fetchCurrentDepartmentDraftCount();
});
void ensureDraftTransactionCustomerLoaded();
// Fetch the booking count every 5 seconds when department context is valid
setInterval(() => {
if (hasValidDepartmentId.value) {
fetchDepartmentBookingCount();
void fetchCurrentDepartmentDraftCount();
}
}, 5000);
@@ -147,7 +182,12 @@ const items = computed<NavigationItemProps[]>(() => [
to: `/admin/${department_id.value}/modules/pos/drafts`,
type: 'department',
permissions: ['admin', 'list_orders'],
hidden: getDraftTransactionCustomerNumber() === null,
hidden: draftTransactionCustomerNumber.value === null,
badge: {
type: 'info',
text: department_draft_count.value.toString(),
condition: department_draft_count.value > 0,
},
},
],
},
@@ -0,0 +1,33 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
export const fetchDepartmentDraftCount = async ({ departmentId, customerNumber }) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
if (!normalizedDepartmentId || !normalizedCustomerNumber) {
return 0;
}
try {
const response = await authenticatedRequest("/orders", "GET", {
filters: `department_id:${normalizedDepartmentId},customer_id:${normalizedCustomerNumber}`,
page: 1,
limit: 1,
search: "",
order: "id:DESC",
});
const total = Number.parseInt(String(response?.data?.meta?.pagination?.total ?? 0), 10);
return Number.isInteger(total) && total > 0 ? total : 0;
} catch (error) {
console.error("Error fetching department draft count:", error);
return 0;
}
};
export default fetchDepartmentDraftCount;
+40 -14
View File
@@ -1,32 +1,56 @@
<script setup>
import {BButton, BIcon, BLoading} from "buefy";
import { BButton, BIcon, BLoading } from "buefy";
import { computed, ref } from "vue";
let props = defineProps(['loadFunction', 'icon', 'isLoading', 'disabled'])
import { ref, computed } from 'vue'
const props = defineProps({
loadFunction: {
type: Function,
required: true,
},
icon: {
type: String,
default: null,
},
isLoading: {
type: Boolean,
default: undefined,
},
disabled: {
type: Boolean,
default: false,
},
actionKey: {
type: String,
default: null,
},
copyKey: {
type: String,
default: null,
},
});
// Local loading state for when isLoading prop is not provided
const localIsLoading = ref(false)
const localIsLoading = ref(false);
// Use computed to react to prop changes, fallback to local state if prop not provided
const isLoading = computed(() => {
if (props.isLoading !== undefined) {
return props.isLoading
return props.isLoading;
}
return localIsLoading.value
})
return localIsLoading.value;
});
const loadWhileAwait = async (loadFunction) => {
if (props.isLoading === undefined) {
localIsLoading.value = true
localIsLoading.value = true;
}
await loadFunction()
await loadFunction();
if (props.isLoading === undefined) {
localIsLoading.value = false
localIsLoading.value = false;
}
}
const handleClick = () => loadWhileAwait(props.loadFunction)
};
const handleClick = () => loadWhileAwait(props.loadFunction);
</script>
<template>
@@ -36,7 +60,9 @@ const handleClick = () => loadWhileAwait(props.loadFunction)
:icon-pack="'fas'"
@click="handleClick"
:disabled="isLoading || (props.disabled ?? false)"
:data-action-key="props.actionKey || null"
:data-copy-key="props.copyKey || null"
>
<slot />
</b-button>
</template>
</template>
@@ -2,6 +2,7 @@
import { computed, defineProps, onBeforeUnmount } from "vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { useEconomicQueueJob } from "@/composables/useEconomicQueueJob.js";
import SessionUser from "@/components/session/token/SessionUser.vue";
const props = defineProps(["order_id"]);
@@ -75,11 +76,13 @@ const queueFailureMessage = computed(() => draftQueue.queueFailureMessage.value)
@click="submitExport"
:disabled="isSubmitDisabled"
data-testid="economic-draft-export-submit"
data-action-key="economic-draft-export-submit"
data-copy-key="complete"
>
<span class="icon">
<i class="fas fa-paper-plane"></i>
</span>
<span>Gennemfør</span>
<span>{{ SessionUser.objects.global.language.complete }}</span>
</button>
<div
@@ -97,7 +100,11 @@ const queueFailureMessage = computed(() => draftQueue.queueFailureMessage.value)
<progress class="progress is-link is-small" max="100" :value="progressPercent"></progress>
</div>
<div v-else-if="isCompleted" class="notification is-success is-light mt-2" data-testid="economic-draft-export-completed">
<div
v-else-if="isCompleted"
class="notification is-success is-light mt-2"
data-testid="economic-draft-export-completed"
>
<p>Draft export completed.</p>
<p v-if="resultMessage">{{ resultMessage }}</p>
</div>
@@ -116,5 +123,4 @@ const queueFailureMessage = computed(() => draftQueue.queueFailureMessage.value)
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -3,6 +3,7 @@ import { computed, defineProps, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { useEconomicQueueJob } from "@/composables/useEconomicQueueJob.js";
import SessionUser from "@/components/session/token/SessionUser.vue";
const props = defineProps(["order_id"]);
const router = useRouter();
@@ -84,11 +85,13 @@ const goToDepartmentOverview = () => {
@click="submitExport"
:disabled="isSubmitDisabled"
data-testid="economic-invoice-export-submit"
data-action-key="economic-invoice-export-submit"
data-copy-key="complete"
>
<span class="icon">
<i class="fas fa-file-invoice"></i>
</span>
<span>Gennemfør</span>
<span>{{ SessionUser.objects.global.language.complete }}</span>
</button>
<div
@@ -106,10 +109,19 @@ const goToDepartmentOverview = () => {
<progress class="progress is-link is-small" max="100" :value="progressPercent"></progress>
</div>
<div v-else-if="isCompleted" class="notification is-success is-light mt-2" data-testid="economic-invoice-export-completed">
<div
v-else-if="isCompleted"
class="notification is-success is-light mt-2"
data-testid="economic-invoice-export-completed"
>
<p>Export completed.</p>
<p v-if="resultMessage">{{ resultMessage }}</p>
<button class="button is-small is-success is-light mt-2" @click="goToDepartmentOverview">
<button
class="button is-small is-success is-light mt-2"
@click="goToDepartmentOverview"
data-action-key="economic-invoice-export-back"
data-copy-key="back"
>
Tilbage til afdeling
</button>
</div>
@@ -128,5 +140,4 @@ const goToDepartmentOverview = () => {
</div>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -1,53 +1,55 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps(['order_id'])
import { defineProps } from "vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { handleEconomicError } from "@/components/request/HandleEconomicError.vue";
import { ref } from "vue";
import { useRouter } from "vue-router";
import SessionUser from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
const router = useRouter();
const props = defineProps(["order_id"]);
const exportOrderToInvoiceStripe = async () => {
// Get the email to send the invoice to
await Swal.fire({
title: 'Indtast email til faktura',
input: 'email',
title: "Indtast email til faktura",
input: "email",
inputAttributes: {
autocapitalize: 'off'
autocapitalize: "off",
},
showCancelButton: true,
confirmButtonText: 'Send faktura',
confirmButtonText: "Send faktura",
showLoaderOnConfirm: true,
preConfirm: (email) => {
return authenticatedRequest(
"/modules/stripe/invoice",
"POST",
{
email: email,
order_id: parseInt(props.order_id)
}
).then((response) => {
console.log(response);
Swal.fire({
title: 'Faktura sendt',
icon: 'success'
return authenticatedRequest("/modules/stripe/invoice", "POST", {
email,
order_id: Number.parseInt(String(props.order_id), 10),
})
.then((response) => {
console.log(response);
Swal.fire({
title: "Faktura sendt",
icon: "success",
});
})
.catch((error) => {
handleEconomicError(error);
});
}).catch((error) => {
handleEconomicError(error);
});
},
allowOutsideClick: () => !Swal.isLoading()
allowOutsideClick: () => !Swal.isLoading(),
});
};
</script>
<template>
<LoadButtonWhileAwait class="is-success is-inverted" :loadFunction="exportOrderToInvoiceStripe" icon="fas fa-file-invoice"> Gennemfør </LoadButtonWhileAwait>
<LoadButtonWhileAwait
class="is-success is-inverted"
:loadFunction="exportOrderToInvoiceStripe"
icon="fas fa-file-invoice"
actionKey="economic-invoice-stripe-export-submit"
copyKey="complete"
>
{{ SessionUser.objects.global.language.complete }}
</LoadButtonWhileAwait>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -92,6 +92,26 @@ const visibleChildren = (item: any) => {
if (!item.children || item.children.length === 0) return 0;
return item.children.filter((child: any) => !child.hidden && hasPermission(child)).length;
}
const hasBadge = (item: any) => {
return item?.badge?.condition === true;
}
const getBadgeText = (item: any) => {
return String(item?.badge?.text ?? '');
}
const isDraftsNavigationChild = (item: any) => {
return String(item?.to ?? '').includes('/modules/pos/drafts');
}
const getChildLabelTestId = (item: any) => {
return isDraftsNavigationChild(item) ? 'desktop-buefy-nav-drafts-label' : null;
}
const getChildBadgeTestId = (item: any) => {
return isDraftsNavigationChild(item) ? 'desktop-buefy-nav-drafts-badge' : null;
}
</script>
<template>
@@ -125,15 +145,31 @@ const visibleChildren = (item: any) => {
<template v-slot:default v-if="item.children && item.children.length > 0 && visibleChildren(item) > 0">
<template v-for="(child, cIndex) in item.children" :key="cIndex">
<b-menu-item
v-show="isItemVisible(child)"
:label="child.label"
v-if="isItemVisible(child)"
:to="child.to"
:tag="child.to ? 'router-link' : 'router-link'"
:modelValue="isMatchingRoute(child)"
icon-pack="fas"
:icon="child.icon || ''"
:class="[child.classes ? child.classes.join(' ') : '', 'is-size-5']"
></b-menu-item>
>
<template #label>
<span
class="desktop-buefy-child-label"
:class="{ 'desktop-buefy-child-label-with-badge': hasBadge(child) }"
:data-testid="getChildLabelTestId(child)"
>
<span>{{ child.label }}</span>
<span
v-if="hasBadge(child)"
class="tag is-info is-rounded is-small desktop-buefy-child-badge"
:data-testid="getChildBadgeTestId(child)"
>
{{ getBadgeText(child) }}
</span>
</span>
</template>
</b-menu-item>
</template>
</template>
</b-menu-item>
@@ -179,4 +215,15 @@ export default defineComponent({
.transform-black {
filter: invert(1) grayscale(1) brightness(0);
}
.desktop-buefy-child-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.desktop-buefy-child-badge {
flex-shrink: 0;
}
</style>
@@ -1,8 +1,25 @@
<script setup lang="ts">
import { useAttrs } from "vue";
defineOptions({
inheritAttrs: false,
});
const props = defineProps<{
actionKey?: string;
copyKey?: string;
}>();
const attrs = useAttrs();
</script>
<template>
<button class="generic-button">
<button
class="generic-button"
v-bind="attrs"
:data-action-key="props.actionKey || null"
:data-copy-key="props.copyKey || null"
>
<span class="generic-button__content">
<slot />
</span>
@@ -41,12 +58,12 @@
width: 100%;
min-width: 0;
min-height: 100%;
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 700;
font-size: 16px;
line-height: 18px;
color: #FFFFFF;
color: #ffffff;
/* Inside auto layout */
}
@@ -63,7 +80,7 @@
gap: 10px;
width: 100%;
height: 68px;
background: #A6A5A5;
background: #a6a5a5;
border-radius: 4px;
/* Inside auto layout */
@@ -75,12 +92,12 @@
.generic-button:disabled .generic-button__content {
min-height: 100%;
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 700;
font-size: 16px;
line-height: 18px;
color: #FFFFFF;
color: #ffffff;
/* Inside auto layout */
}
@@ -97,7 +114,7 @@
gap: 10px;
width: 100%;
height: 68px;
background: #FFFFFF;
background: #ffffff;
border-radius: 4px;
/* Inside auto layout */
@@ -109,7 +126,7 @@
.generic-button.white .generic-button__content {
min-height: 100%;
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 700;
font-size: 16px;
@@ -134,7 +151,7 @@
width: 100%;
height: 42px;
border: 1px solid #D9D9D9;
border: 1px solid #d9d9d9;
border-radius: 4px;
/* Inside auto layout */
@@ -153,20 +170,18 @@
min-height: 100%;
/* sm-tx */
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 400;
font-size: 12px;
line-height: 100%;
/* identical to box height, or 12px */
color: #D9D9D9;
color: #d9d9d9;
/* Inside auto layout */
flex: none;
order: 0;
flex-grow: 0;
}
</style>
+5 -2
View File
@@ -2761,9 +2761,12 @@
"customer_picker": {
"selected_customer": "Valgt kunde",
"select_customer_invoice": "Kundefaktura",
"select_draft_customer": "V\u00e6lg transaktionskladde-kunde",
"select_card_payment": "V\u00e6lg direkte betaling med betalingskort"
"select_draft_customer": "Uds\u00e6t",
"select_card_payment": "Betalingskort",
"select_payment_form": "V\u00e6lg betalingsform"
},
"hold_to_change_vehicle": "Hold inde for at \u00e6ndre k\u00f8ret\u00f8j",
"copy_last_wash_hint": "Kopierer ydelse, tilvalg og ekstra varer fra sidste vask",
"license_plate": "Registreringsnummer",
"manual_entry": "Manuell registrering",
"new_order": "Ny order",
+134 -131
View File
@@ -1,4 +1,4 @@
{
{
"about_us": {
"solutions": {
"customer": {
@@ -146,7 +146,7 @@
"completed_washes": "Abgeschlossene W?schen",
"possible_washes": "M?gliche W?schen",
"subtitle": "W?schen",
"title": "Wäschen"
"title": "Wäschen"
},
"water_usage": {
"subtitle": "Wasserverbrauch",
@@ -293,9 +293,9 @@
"add_note_desc": "Neue Notiz hinzuf?gen",
"cancel": "Abbrechen",
"cancel_desc": "Dieses Popup schlie?en, ohne ?nderungen zu speichern",
"close": "Schließen",
"close": "Schließen",
"close_desc": "Dieses Popup schlie?en",
"confirm": "Bestätigen",
"confirm": "Bestätigen",
"confirm_desc": "Best?tigen und ?nderungen speichern",
"edit_reference": "Referenz bearbeiten",
"edit_reference_desc": "Referenz f?r diese Transaktion bearbeiten",
@@ -364,7 +364,7 @@
"order_booking_selector": {
"title": "Buchung auswählen",
"help_text": "Mehrere offene Buchungen passen zu diesem Fahrzeug. Wählen Sie die richtige Buchung oder fahren Sie ohne Buchung fort.",
"option_title": "Buchung #{id} {datetime}",
"option_title": "Buchung #{id} • {datetime}",
"use_booking": "Auswählen",
"continue_without_booking": "Ohne Buchung fortfahren",
"customer_label": "Kunde",
@@ -521,7 +521,7 @@
"change_invoice_collection": "Rechnungssammlung ?ndern",
"change_order": "{order} ?ndern",
"change_password": "Passwort ?ndern",
"close": "Schließen",
"close": "Schließen",
"copy_link": "Link kopieren",
"delete_booking": "Buchung l?schen",
"delete_order": "Auftrag l?schen",
@@ -651,7 +651,7 @@
"password": "Passwort",
"remember_me": "Angemeldet bleiben",
"terms_acceptance": "Mit Klick auf \"Anmelden\" akzeptieren Sie die Datenschutzrichtlinie und die Nutzungsbedingungen von truckwash.io",
"country_code": "Ländervorwahl",
"country_code": "Ländervorwahl",
"login_as_customer": "Als Kunde anmelden",
"login_with": "Anmelden mit",
"phone": "Telefon",
@@ -668,7 +668,7 @@
"at_time": "kl.",
"booking_number": "Buchungsnummer",
"cancel_booking": "Buchung stornieren",
"complete_booking": "Fullfør bestilling",
"complete_booking": "Fullfør bestilling",
"confirm_booking": "Buchung best?tigen",
"customer": "Kunde",
"date": "Datum",
@@ -687,14 +687,14 @@
"cancelled": "@:bookings.statuses.cancelled",
"statuses": {
"cancelled": "Storniert",
"completed": "Fullført",
"completed": "Fullført",
"confirmed": "Best?tigt",
"pending": "Ausstehend"
},
"subtitle": "Buchungen verwalten",
"time": "Zeit",
"title": "Buchungen",
"vehicle": "Kjøretøy",
"vehicle": "Kjøretøy",
"view": "Anzeigen",
"view_booking": "Buchung anzeigen"
},
@@ -711,7 +711,7 @@
"services": "Dienstleistungen",
"unknown_customer": "Unbekannter Kunde",
"unknown_service": "Unbekannter Service",
"wash": "Wäsche"
"wash": "Wäsche"
},
"calendar": {
"april": "April",
@@ -732,7 +732,7 @@
"day": "Tag",
"dayHeaderFormat": "ddd D/M",
"dayNames": ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
"dayNamesShort": ["Søn", "Man", "Tir", "Mi", "Do", "Fre", "Lør"],
"dayNamesShort": ["Søn", "Man", "Tir", "Mi", "Do", "Fre", "Lør"],
"eventTimeFormat": "HH:mm",
"list": "Liste",
"month": "Monat",
@@ -765,13 +765,13 @@
"june": "Juni",
"march": "M?rz",
"may": "Mai",
"monday": "MÃ¥ndag",
"monday": "Måndag",
"month": "Monat",
"november": "November",
"october": "Oktober",
"saturday": "Lördag",
"saturday": "Lördag",
"september": "September",
"sunday": "Söndag",
"sunday": "Söndag",
"thursday": "Donnerstag",
"tuesday": "Tisdag",
"wednesday": "Mittwoch",
@@ -842,7 +842,7 @@
"approved": "Genehmigt",
"approx": "ca.",
"are": "sind",
"back": "Zurück",
"back": "Zurück",
"booking": "Buchung",
"booking_created": "Ihre Buchung wurde erstellt.",
"bookings": "Buchungen",
@@ -852,13 +852,13 @@
"city": "Stadt",
"clear": "Leeren",
"click_to_copy": "Klicken zum Kopieren",
"close": "Schließen",
"close": "Schließen",
"collapse": "Ausblenden",
"comment": "Kommentar",
"comments": "Kommentare",
"company": "Unternehmen",
"completed": "Abgeschlossen",
"confirm": "Bestätigen",
"confirm": "Bestätigen",
"contact": "Kontakt",
"continue": "Weiter",
"copied": "Kopiert",
@@ -981,7 +981,7 @@
"search": "Suchen",
"second": "Sekunde",
"see_more": "Mehr anzeigen",
"select": "Auswählen",
"select": "Auswählen",
"selected": "Ausgew?hlt",
"service": "Service",
"services": "Leistungen",
@@ -999,7 +999,7 @@
"support": "Support",
"tax": "MwSt.",
"terms": "Bedingungen",
"thank_you": "Danke für Ihre Buchung!",
"thank_you": "Danke für Ihre Buchung!",
"time": "Zeit",
"to": "bis",
"today": "Heute",
@@ -1651,7 +1651,7 @@
"avg_revenue_per_wash": "Durchschn. Umsatz pro W?sche",
"revenue": "Umsatz",
"today": "heute",
"washes": "Wäschen"
"washes": "Wäschen"
},
"departments": {
"actions": "Aktionen",
@@ -1757,7 +1757,7 @@
"abnormal": "Abweichend",
"actions": "Aktionen",
"active": "Aktiv",
"add": "Hinzufügen",
"add": "Hinzufügen",
"add_other_product": "Weiteres Produkt hinzuf?gen",
"additional_items": "Zus?tzliche Positionen",
"all": "Alle",
@@ -1772,16 +1772,16 @@
"change_customer": "Kunde ?ndern",
"clear": "Leeren",
"clear_all": "Alles leeren",
"close": "Schließen",
"close": "Schließen",
"closed": "Geschlossen",
"closing_hour": "Schlie?zeit",
"collapse": "Einklappen",
"collection": "Sammlung",
"compare": "Vergleichen",
"complete": "Abschließen",
"complete": "Abschließen",
"completed": "Abgeschlossen",
"configuration": "Konfiguration",
"confirm_delete": "Ja, löschen",
"confirm_delete": "Ja, löschen",
"confirm_delete_message": "Sind Sie sicher, dass Sie dieses Objekt l?schen m?chten?",
"confirmation_needed": "Best?tigung erforderlich",
"copy": "Kopieren",
@@ -1794,7 +1794,7 @@
"date_to": "Datum bis",
"default": "Standard",
"defaults": "Standardwerte",
"delete": "Löschen",
"delete": "Löschen",
"done": "Fertig",
"download_wash_certificate": "Waschzertifikat herunterladen",
"edit": "Bearbeiten",
@@ -1839,7 +1839,7 @@
"lane": "Waschspur",
"last_wash": "Letzte W?sche",
"link": "Link",
"loading": "Lädt",
"loading": "Lädt",
"login": "Anmelden",
"login_with_qr_code": "Mit QR-Code anmelden",
"logout": "Abmelden",
@@ -1864,8 +1864,8 @@
"nothing_left_to_show": "Nichts mehr anzuzeigen",
"nothing_to_do_all_set": "Alles erledigt, hier gibt es nichts mehr zu tun!",
"only_tank_cleaning": "Nur Tankreinigung",
"opening_hour": "Öffnungszeit",
"opening_hours": "Öffnungszeiten",
"opening_hour": "Öffnungszeit",
"opening_hours": "Öffnungszeiten",
"optional": "Optional",
"or_scan_plates": "Oder Kennzeichen scannen",
"other": "Sonstige",
@@ -1921,7 +1921,7 @@
"remove": "Entfernen",
"required": "Erforderlich",
"requires_action": "Aktion erforderlich",
"reset": "Zurücksetzen",
"reset": "Zurücksetzen",
"rows": "Zeilen",
"save": "Speichern",
"scanning": "Scannen",
@@ -1944,7 +1944,7 @@
"search_subuser": "Nach Unterbenutzer suchen",
"search_transactions": "In Transaktionen suchen",
"search_user": "Nach Benutzer suchen",
"select": "Auswählen",
"select": "Auswählen",
"selected": "Ausgew?hlt",
"selected_multiple": "Ausgew?hlt",
"send_as_is": "Unver?ndert senden",
@@ -1962,7 +1962,7 @@
"subscriptions": "Abonnements",
"succeeded": "Erfolgreich",
"successful_syncs": "Erfolgreiche Synchronisierungen",
"summary": "Übersicht",
"summary": "Übersicht",
"synchronized": "Synchronisiert",
"take_picture": "Bild aufnehmen",
"tank_cleaning": "Tankreinigung",
@@ -2005,7 +2005,7 @@
"waiting": "Warten",
"warning": "Warnung",
"warnings": "Warnungen",
"wash_multiple": "Wäschen",
"wash_multiple": "Wäschen",
"weekday": "Wochentag",
"yes": "Ja"
},
@@ -2209,7 +2209,7 @@
"rules": "Regeln",
"rules_for": "Regeln f?r",
"run_again": "Erneut ausf?hren",
"save_changes": "Änderungen speichern",
"save_changes": "Änderungen speichern",
"select_an_object": "Objekt ausw?hlen",
"start_over": "Neu starten",
"status": "Status",
@@ -2220,7 +2220,7 @@
"unknown_customer": "Unbekannter Kunde",
"upload_error": "Beim Hochladen der Datei ist ein Fehler aufgetreten.",
"yes": "JA",
"yes_delete": "Ja, löschen!"
"yes_delete": "Ja, löschen!"
},
"nav": {
"bookings": "Buchungen",
@@ -2239,7 +2239,7 @@
"settings": "Einstellungen",
"vehicles": "Fahrzeuge",
"wash_log": "Waschprotokoll",
"washes": "Wäschen"
"washes": "Wäschen"
},
"objects": {
"bookings": {
@@ -2299,7 +2299,7 @@
"customer_number": "Kundennummer",
"data": "Daten",
"date": "Datum",
"deleted": "Gelöscht",
"deleted": "Gelöscht",
"department": "Abteilung",
"department_id": "Abteilungs-ID",
"description": "Beschreibung",
@@ -2332,11 +2332,11 @@
"product_id": "Produkt-ID",
"question": "Frage",
"reference": "Referenz",
"relay_in_id": "Indgangs relæ ID",
"relay_machine_id": "Maskine relæ ID",
"relay_in_id": "Indgangs relæ ID",
"relay_machine_id": "Maskine relæ ID",
"relay_machine_program_picker_id": "Machine program picker relay ID",
"relay_machine_cleaner_id": "Machine cleaner relay ID",
"relay_out_id": "Udgangs relæ ID",
"relay_out_id": "Udgangs relæ ID",
"role_id": "Rollen-ID",
"rule": "Regel",
"sort_order": "Sortierreihenfolge",
@@ -2407,7 +2407,7 @@
"description": "?bersicht ?ber ?ffnungszeiten",
"multiple": "?ffnungszeiten",
"single": "?ffnungszeit",
"title": "Öffnungszeiten"
"title": "Öffnungszeiten"
},
"department_time_bookings_types": {
"description": "?bersicht ?ber Buchungstypen",
@@ -2473,7 +2473,7 @@
"columns": {
"amount": "Betrag",
"download": "Herunterladen",
"due_date": "Fälligkeitsdatum",
"due_date": "Fälligkeitsdatum",
"invoice_number": "Rechnungsnummer",
"paid": "Bezahlt"
},
@@ -2511,7 +2511,7 @@
"wash_id": "W?sche-ID"
},
"description": "?bersicht des Waschprotokolls",
"entries": "Wäschen",
"entries": "Wäschen",
"multiple": "Waschprotokoll",
"single": "Auftrag",
"title": "Waschprotokoll"
@@ -2654,7 +2654,7 @@
"loading": "Daten werden geladen...",
"new": "Neu",
"new_booking": "Neue Buchung",
"next": "Nächste Seite",
"next": "Nächste Seite",
"no": "Nein",
"no_special_agreement": "Keine Sondervereinbarung",
"no_wash_subscription": "Kein Waschabonnement",
@@ -2694,8 +2694,8 @@
"customer_number": "Kundennummer",
"new_password": "Neues Passwort",
"restart": "Passwort-Reset neu starten",
"submit": "Passwort zurücksetzen",
"title": "Passwort zurücksetzen"
"submit": "Passwort zurücksetzen",
"title": "Passwort zurücksetzen"
},
"pos": {
"add_note": "Notiz hinzuf?gen",
@@ -2706,7 +2706,7 @@
"title": "Zusatz"
},
"addons": "Tillval",
"apply_discount": "Tillämpa rabatt",
"apply_discount": "Tillämpa rabatt",
"bookings": {
"created_at": "Erstellenet",
"customer_number": "Kundenr.",
@@ -2722,9 +2722,9 @@
"card": "Kort",
"cash": "Kontant",
"clear_order": "Auftrag leeren",
"complete": "Fullfør",
"complete": "Fullfør",
"complete_order": "Auftrag abschlie?en",
"completed": "Fullført",
"completed": "Fullført",
"confirm": {
"confirm_booking": "Sind Sie sicher, dass Sie diese Buchung best?tigen und verarbeiten m?chten?",
"title": "Best?tigen",
@@ -2755,15 +2755,18 @@
"invoice": "Rechnung",
"keyboard": {
"backspace": "L?schen",
"clear": "Tøm",
"search": "Søk"
"clear": "Tøm",
"search": "Søk"
},
"customer_picker": {
"selected_customer": "Ausgew\u00e4hlter Kunde",
"select_customer_invoice": "Kundenrechnung",
"select_draft_customer": "Transaktionsentwurfskunde auswählen",
"select_card_payment": "Direkte Kartenzahlung wählen"
"select_draft_customer": "Später",
"select_card_payment": "Zahlungskarte",
"select_payment_form": "Zahlungsart wählen"
},
"hold_to_change_vehicle": "Gedr\u00fcckt halten, um das Fahrzeug zu \u00e4ndern",
"copy_last_wash_hint": "\u00dcbernimmt Leistung, Zusatzoptionen und Zusatzartikel aus der letzten W\u00e4sche",
"license_plate": "Kennzeichen",
"manual_entry": "Manuell registrering",
"new_order": "Neuer Auftrag",
@@ -2824,7 +2827,7 @@
"tax": "MwSt."
},
"status": {
"deleted": "Gelöscht",
"deleted": "Gelöscht",
"not_paid": "Nicht bezahlt",
"paid": "Bezahlt",
"partially_paid": "Teilweise bezahlt",
@@ -2860,7 +2863,7 @@
"remove_from_order": "Aus Auftrag entfernen",
"remove_image": "Bild entfernen",
"scan_plate": "Kennzeichen scannen",
"search_customer": "Sök kund",
"search_customer": "Sök kund",
"search_customer_by_number": "Nach Kundennummer suchen",
"search_customer_none": "Keine Kunden gefunden",
"search_license_plate": "Nach Kennzeichen suchen",
@@ -2869,14 +2872,14 @@
"payment": "Zahlung"
},
"select_product": "Produkt ausw?hlen",
"select_products": "Välj produkter",
"select_products": "Välj produkter",
"subtitle": "Fahrzeuge registrieren und Zahlungen verarbeiten",
"subtotal": "Delsumma",
"take_photo": "Foto aufnehmen",
"tax": "MwSt.",
"title": "Kassasystem",
"total": "Totalt",
"total_amount": "Totalt beløp",
"total_amount": "Totalt beløp",
"upload_image": "Bild hochladen",
"vehicle": {
"previously_washed": "Fr?her gewaschen"
@@ -2891,13 +2894,13 @@
"mandskabsvogn": "Mannschaftswagen",
"motorcykel": "Motorrad",
"personbil": "PKW",
"sættevogn": "Sattelauflieger",
"sættevogn": "Sattelauflieger",
"trailer": "Tilhenger",
"traktor": "Zugmaschine",
"varevogn": "Lieferwagen"
},
"wash_certificate": "Waschzertifikat",
"wash_type": "Tvättyp"
"wash_type": "Tvättyp"
},
"products": {
"actions": "Aktionen",
@@ -2963,7 +2966,7 @@
"answer_questions": "Fragen beantworten",
"assistance": "Hilfe",
"attached_file": "Angeh?ngte Datei",
"available": "Verfügbar",
"available": "Verfügbar",
"configure_wash": "W?sche konfigurieren",
"confirm_and_start": "Best?tigen und W?sche starten",
"customer_number": "Kundennummer",
@@ -2991,7 +2994,7 @@
"start_machine": "Maschine starten",
"start_wash": "W?sche starten",
"total_time": "Gesamtzeit: {minutes} Minuten und {seconds} Sekunden",
"unavailable": "Nicht verfügbar",
"unavailable": "Nicht verfügbar",
"vehicle": "Fahrzeug",
"vehicle_type": "Fahrzeugtyp",
"wash_completed": "W?sche abgeschlossen",
@@ -3008,7 +3011,7 @@
"change_password": "Passwort ?ndern",
"confirm_password": "Passwort best?tigen",
"currency": {
"label": "Währung"
"label": "Währung"
},
"current_password": "Aktuelles Passwort",
"date_format": "Datumsformat",
@@ -3087,9 +3090,9 @@
"week": "Uke",
"year": "Jahr"
},
"popular_products": "Populære produkter",
"popular_times": "Populære tider",
"revenue": "Intäkter",
"popular_products": "Populære produkter",
"popular_times": "Populære tider",
"revenue": "Intäkter",
"subtitle": "Se statistikk",
"sum": "Summe",
"table": "Tabelle",
@@ -3103,7 +3106,7 @@
"total": "Totalt",
"vehicle_types": "Fahrzeugtypen",
"vehicles": "Fordon",
"washes": "Tvättar"
"washes": "Tvättar"
},
"super_user_dashboard": {
"create_department": "Abteilung erstellen",
@@ -3134,7 +3137,7 @@
"payment_overview": "Zahlungs?bersicht"
},
"department_lane": {
"back": "Zurück",
"back": "Zurück",
"force_enable_machine": "Maschine zwangsweise aktivieren",
"force_disable_machine": "Maschine zwangsweise deaktivieren",
"force_enable_success": "Maschine wurde zwangsweise aktiviert",
@@ -3314,7 +3317,7 @@
"title": "Kalender"
},
"configuration": {
"backups": "Säkerhetskopior",
"backups": "Säkerhetskopior",
"economic": "E-conomic",
"email": "E-post",
"fxrates": "Valutakurser",
@@ -3372,7 +3375,7 @@
"products": "Produkte",
"settings": "Einstellungen",
"statistics": "Statistikk",
"vehicles": "Kjøretøy",
"vehicles": "Kjøretøy",
"wash_log": "Waschprotokoll"
},
"nav": {
@@ -3398,7 +3401,7 @@
"xlvask": "XLVask"
},
"products": {
"actions": "Åtgärder",
"actions": "Åtgärder",
"add": "Produkt hinzuf?gen",
"addons": {
"add": "Zusatz hinzuf?gen",
@@ -3422,11 +3425,11 @@
"title": "Produkte"
},
"roles": {
"subtitle": "Verwalten användarroller",
"subtitle": "Verwalten användarroller",
"title": "Rollen"
},
"settings": {
"subtitle": "Verwalten systeminställningar",
"subtitle": "Verwalten systeminställningar",
"title": "Einstellungen"
},
"sidebar": {
@@ -3439,7 +3442,7 @@
"roles": "Rollen",
"settings": "Einstellungen",
"statistics": "Statistik",
"users": "Användare",
"users": "Användare",
"vehicles": "Fordon",
"xlvask": "XL Vask"
},
@@ -3448,19 +3451,19 @@
"title": "Statistik"
},
"users": {
"actions": "Åtgärder",
"actions": "Åtgärder",
"active": "Aktiv",
"add": "Benutzer hinzuf?gen",
"delete": "Radera användare",
"edit": "Bearbeitena användare",
"delete": "Radera användare",
"edit": "Bearbeitena användare",
"email": "E-post",
"inactive": "Inaktiv",
"name": "Name",
"role": "Rolle",
"search": "Benutzer suchen...",
"status": "Status",
"subtitle": "Verwalten användare",
"title": "Användare"
"subtitle": "Verwalten användare",
"title": "Användare"
},
"vehicles": {
"subtitle": "Verwalten fordon",
@@ -3687,19 +3690,19 @@
"end_date": "Sluttdato",
"from_now": "ab jetzt",
"just_now": "just nu",
"last_month": "Förra månaden",
"last_month": "Förra månaden",
"last_week": "Letzte Woche",
"last_year": "Förra året",
"next_month": "Nästa månad",
"next_week": "Nästa vecka",
"next_year": "Nästa år",
"last_year": "Förra året",
"next_month": "Nästa månad",
"next_week": "Nästa vecka",
"next_year": "Nästa år",
"start_date": "Startdato",
"this_month": "Dieser Monat",
"this_week": "Diese Woche",
"this_year": "Dieses Jahr",
"today": "Idag",
"tomorrow": "Imorgon",
"yesterday": "Igår"
"yesterday": "Igår"
},
"time_booking_flow": {
"all_info_looks_good": "Alle Informationen sehen gut aus! Sie k?nnen Ihre Buchung jetzt best?tigen.",
@@ -3766,13 +3769,13 @@
"city": "By",
"country": "Land",
"created_at": "Erstellt",
"currency": "Währung",
"currency": "Währung",
"customer_number": "Kundenummer",
"cvr": "CVR",
"economic_data": "E-conomic-Daten",
"email": "E-Mail",
"group_id": "Gruppe ID",
"mass_data_inserter": "Masse data indsætter",
"mass_data_inserter": "Masse data indsætter",
"mobile_phone": "Mobiltelefon",
"name": "Name",
"no_data": "Keine Daten",
@@ -3804,7 +3807,7 @@
"user_dashboard": {
"bookings": {
"book": {
"back": "Zurück",
"back": "Zurück",
"basket": "Warenkorb",
"booking_confirmed": "Buchung best?tigt",
"booking_confirmed_message": "Ihre Buchung wurde best?tigt. Sie erhalten eine Best?tigung per E-Mail.",
@@ -3817,19 +3820,19 @@
"enter_vehicle": "Fahrzeug eingeben",
"error_copied": "Fehlermeldung in die Zwischenablage kopiert",
"expected_date_time": "Erwartetes Datum und Uhrzeit",
"exterior_wash": "Außenwäsche",
"exterior_wash": "Außenwäsche",
"go_to_confirmation": "Zur Best?tigung",
"guest_info_html": "Als Gast k?nnen Sie Abteilung, Kennzeichen und Zeit ausw?hlen. Um die Buchung zu erstellen, m?ssen Sie sich anmelden.",
"guest_mode_login_prompt": "Um Ihre Buchung abzuschlie?en, m?ssen Sie sich in Ihrem Konto anmelden.",
"guest_mode_warning": "Sie sind im Gastmodus.",
"incl_pickup": "Inkl. Abholung",
"interior_wash": "Innenwäsche",
"interior_wash": "Innenwäsche",
"is_this_your_vehicle": "Ist dies eines Ihrer Fahrzeuge?",
"login": "Anmelden",
"login_for_products": "Melden Sie sich an, um Produkte f?r Ihre Buchung auszuw?hlen.",
"missing_customer_number": "Kundennummer",
"missing_date": "Datum",
"missing_fields": "Följande fält saknas:",
"missing_fields": "Följande fält saknas:",
"missing_products": "Produkte",
"missing_valid_customer_number": "G?ltige Kundennummer",
"missing_vehicle": "Fahrzeug",
@@ -3845,7 +3848,7 @@
"required": "Erforderlich",
"select_a_wash_hall": "Waschhalle ausw?hlen",
"select_date_time_customer": "Datum/Uhrzeit und Kundennummer ausw?hlen",
"select_department": "Välj avdelning",
"select_department": "Välj avdelning",
"select_products": "Produkte ausw?hlen",
"select_vehicle": "Bitte w?hlen Sie ein Fahrzeug aus, um verf?gbare Zusatzoptionen zu sehen",
"select_wash_hall": "Bitte w?hlen Sie eine Waschhalle aus, um fortzufahren",
@@ -3877,8 +3880,8 @@
"profile": "Profil",
"settings": "Einstellungen",
"statistics": "Statistikk",
"vehicles": "Kjøretøy",
"wash": "Wäsche",
"vehicles": "Kjøretøy",
"wash": "Wäsche",
"wash_log": "Waschprotokoll"
},
"navigation": {
@@ -3964,7 +3967,7 @@
"title": "Benutzerprofil"
},
"settings": {
"subtitle": "Verwalten dina inställningar",
"subtitle": "Verwalten dina inställningar",
"title": "Einstellungen"
},
"sidebar": {
@@ -3972,13 +3975,13 @@
"bookings": "Buchungen",
"home": "Hem",
"invoices": "Rechnungen",
"language": "Språk",
"language": "Språk",
"logout": "Abmelden",
"orders": "Auftr?ge",
"profile": "Profil",
"settings": "Einstellungen",
"vehicles": "Fordon",
"wash": "Tvätt"
"wash": "Tvätt"
},
"vehicles": {
"new_subtitle": "Neues Fahrzeug erstellen",
@@ -4116,9 +4119,9 @@
"car": "Bil",
"other": "Andere",
"semi": "Sattelzugmaschine",
"trailer": "Släpvagn",
"trailer": "Släpvagn",
"truck": "LKW",
"van": "Skåpbil"
"van": "Skåpbil"
},
"vehicle_types": {
"box": "Kassebil",
@@ -4321,7 +4324,7 @@
"subtitle": "Wird alle paar Sekunden automatisch versucht, die Verbindung wiederherzustellen.",
"no_internet": "Keine Internetverbindung.",
"server_down": "Keine Verbindung zum Server.",
"description": "Dies klärt sich in der Regel von selbst. Bitte warten Sie, während wir den Server überprüfen.",
"description": "Dies klärt sich in der Regel von selbst. Bitte warten Sie, während wir den Server überprüfen.",
"retry_in": "Wird erneut versucht in {seconds} Sekunden...",
"retry_now": "Versuche Verbindung wiederherzustellen...",
"retry": "Wiederholen",
@@ -4331,16 +4334,16 @@
"installation": {
"title": "Veraltete Installation",
"subtitle": "Diese installierte App ist veraltet und muss migriert werden.",
"description": "Truck Wash wurde auf eine neue Installation unter truckwash.io umgestellt. Öffnen Sie die neue Seite und installieren Sie die App erneut auf Ihrem Gerät.",
"description": "Truck Wash wurde auf eine neue Installation unter truckwash.io umgestellt. Öffnen Sie die neue Seite und installieren Sie die App erneut auf Ihrem Gerät.",
"android_title": "Android-Migration",
"android_step_1": "Öffnen Sie truckwash.io in Chrome.",
"android_step_2": "Öffnen Sie das Browsermenü und wählen Sie \"App installieren\".",
"android_step_1": "Öffnen Sie truckwash.io in Chrome.",
"android_step_2": "Öffnen Sie das Browsermenü und wählen Sie \"App installieren\".",
"android_step_3": "Verwenden Sie die neu installierte App und entfernen Sie diese veraltete App.",
"ios_title": "iPhone-Migration",
"ios_step_1": "Öffnen Sie truckwash.io in Safari.",
"ios_step_1": "Öffnen Sie truckwash.io in Safari.",
"ios_step_2": "Tippen Sie auf \"Teilen\" und dann auf \"Zum Home-Bildschirm\".",
"ios_step_3": "Verwenden Sie die neue Home-Bildschirm-App und entfernen Sie diese veraltete App.",
"open_now": "truckwash.io jetzt öffnen"
"open_now": "truckwash.io jetzt öffnen"
},
"gateway": {
"title": "Veraltetes Gateway",
@@ -4352,64 +4355,64 @@
},
"passkeys": {
"title": "Passkeys",
"description": "Verwenden Sie Passkeys für eine sichere Anmeldung mit Biometrie wie Face ID, Touch ID oder der PIN Ihres Geräts.",
"add_passkey": "Passkey hinzufügen",
"description": "Verwenden Sie Passkeys für eine sichere Anmeldung mit Biometrie wie Face ID, Touch ID oder der PIN Ihres Geräts.",
"add_passkey": "Passkey hinzufügen",
"rename_passkey": "Passkey umbenennen",
"delete_passkey": "Passkey löschen",
"no_passkeys": "Sie haben noch keine Passkeys hinzugefügt. Fügen Sie einen hinzu, um sich ohne Passwort anzumelden.",
"delete_passkey": "Passkey löschen",
"no_passkeys": "Sie haben noch keine Passkeys hinzugefügt. Fügen Sie einen hinzu, um sich ohne Passwort anzumelden.",
"name_label": "Passkey-Name",
"name_placeholder": "z. B. Mein iPhone, Arbeitslaptop",
"name_required": "Bitte geben Sie einen Namen für Ihren Passkey ein.",
"name_required": "Bitte geben Sie einen Namen für Ihren Passkey ein.",
"unnamed": "Unbenannter Passkey",
"created": "Erstellt",
"rename": "Umbenennen",
"delete": "Löschen",
"delete": "Löschen",
"cancel": "Abbrechen",
"success": "Erfolg",
"error": "Fehler",
"added_successfully": "Passkey wurde erfolgreich hinzugefügt.",
"added_successfully": "Passkey wurde erfolgreich hinzugefügt.",
"renamed_successfully": "Passkey wurde erfolgreich umbenannt.",
"deleted_successfully": "Passkey wurde erfolgreich gelöscht.",
"delete_confirm": "Sind Sie sicher, dass Sie den Passkey „{name}“ löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"deleted_successfully": "Passkey wurde erfolgreich gelöscht.",
"delete_confirm": "Sind Sie sicher, dass Sie den Passkey {name}“ löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"registration_failed": "Passkey konnte nicht registriert werden. Bitte versuchen Sie es erneut.",
"registration_cancelled": "Die Passkey-Registrierung wurde abgebrochen.",
"rename_failed": "Passkey konnte nicht umbenannt werden. Bitte versuchen Sie es erneut.",
"delete_failed": "Passkey konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"not_supported": "Passkeys werden auf diesem Gerät nicht unterstützt.",
"not_supported_browser": "Ihr Browser unterstützt keine Passkeys. Bitte verwenden Sie einen modernen Browser wie Chrome, Safari, Firefox oder Edge.",
"delete_failed": "Passkey konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"not_supported": "Passkeys werden auf diesem Gerät nicht unterstützt.",
"not_supported_browser": "Ihr Browser unterstützt keine Passkeys. Bitte verwenden Sie einen modernen Browser wie Chrome, Safari, Firefox oder Edge.",
"login_with_passkey": "Mit Passkey anmelden",
"login_failed": "Anmeldung mit Passkey fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"2fa": {
"title": "Zwei-Faktor-Authentifizierung",
"description": "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu, indem beim Anmelden ein Bestätigungscode erforderlich ist.",
"description": "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu, indem beim Anmelden ein Bestätigungscode erforderlich ist.",
"status_enabled": "Die Zwei-Faktor-Authentifizierung ist aktiviert",
"status_disabled": "Die Zwei-Faktor-Authentifizierung ist deaktiviert",
"status_enabled_desc": "Ihr Konto ist mit einem zusätzlichen Bestätigungsschritt geschützt.",
"status_disabled_desc": "Aktivieren Sie die Zwei-Faktor-Authentifizierung für mehr Sicherheit.",
"status_enabled_desc": "Ihr Konto ist mit einem zusätzlichen Bestätigungsschritt geschützt.",
"status_disabled_desc": "Aktivieren Sie die Zwei-Faktor-Authentifizierung für mehr Sicherheit.",
"enable_button": "Zwei-Faktor-Authentifizierung aktivieren",
"disable_button": "Zwei-Faktor-Authentifizierung deaktivieren",
"setup_title": "Zwei-Faktor-Authentifizierung einrichten",
"setup_step1": "Scannen Sie diesen QR-Code mit Ihrer Authentifizierungs-App (Google Authenticator, Authy usw.):",
"setup_step2": "Geben Sie zur Bestätigung den 6-stelligen Code aus Ihrer Authentifizierungs-App ein:",
"setup_manual": "Scannen nicht möglich? Geben Sie diesen Code manuell ein:",
"setup_step2": "Geben Sie zur Bestätigung den 6-stelligen Code aus Ihrer Authentifizierungs-App ein:",
"setup_manual": "Scannen nicht möglich? Geben Sie diesen Code manuell ein:",
"setup_error_title": "Einrichtung fehlgeschlagen",
"code_placeholder": "123456",
"code_label": "Bestätigungscode",
"verify_button": "Bestätigen",
"verify_title": "Zwei-Faktor-Bestätigung",
"code_label": "Bestätigungscode",
"verify_button": "Bestätigen",
"verify_title": "Zwei-Faktor-Bestätigung",
"verify_description": "Geben Sie den 6-stelligen Code aus Ihrer Authentifizierungs-App ein.",
"verification_success": "Bestätigung erfolgreich!",
"invalid_code": "Bitte geben Sie einen gültigen 6-stelligen Code ein.",
"verification_success": "Bestätigung erfolgreich!",
"invalid_code": "Bitte geben Sie einen gültigen 6-stelligen Code ein.",
"enabled_title": "Zwei-Faktor-Authentifizierung aktiviert",
"enabled_message": "Ihr Konto ist jetzt mit Zwei-Faktor-Authentifizierung geschützt.",
"enabled_message": "Ihr Konto ist jetzt mit Zwei-Faktor-Authentifizierung geschützt.",
"disabled_title": "Zwei-Faktor-Authentifizierung deaktiviert",
"disabled_message": "Die Zwei-Faktor-Authentifizierung wurde für Ihr Konto deaktiviert.",
"disabled_message": "Die Zwei-Faktor-Authentifizierung wurde für Ihr Konto deaktiviert.",
"disable_title": "Zwei-Faktor-Authentifizierung deaktivieren",
"disable_confirm": "Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung deaktivieren möchten? Dadurch wird Ihr Konto weniger sicher.",
"disable_confirm": "Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung deaktivieren möchten? Dadurch wird Ihr Konto weniger sicher.",
"disable_error_title": "Deaktivierung fehlgeschlagen",
"enter_code": "Geben Sie Ihren Bestätigungscode ein",
"back_to_login": "Zurück zur Anmeldung"
"enter_code": "Geben Sie Ihren Bestätigungscode ein",
"back_to_login": "Zurück zur Anmeldung"
},
"system_status": {
"title": "Systemstatus",
+6 -3
View File
@@ -2761,9 +2761,12 @@
"customer_picker": {
"selected_customer": "Selected customer",
"select_customer_invoice": "Customer invoice",
"select_draft_customer": "Select transaction draft customer",
"select_card_payment": "Select direct card payment"
"select_draft_customer": "Defer",
"select_card_payment": "Payment card",
"select_payment_form": "Select payment form"
},
"hold_to_change_vehicle": "Hold to change vehicle",
"copy_last_wash_hint": "Copies the service, add-ons, and extra items from the previous wash",
"license_plate": "Registration number",
"manual_entry": "Manual registration",
"new_order": "New order",
@@ -2891,7 +2894,7 @@
"mandskabsvogn": "Crew",
"motorcykel": "Motorcycle",
"personbil": "Passenger car",
"sættevogn": "Semitrailer",
"sættevogn": "Semitrailer",
"trailer": "Trailer",
"traktor": "Trekkbil",
"varevogn": "Van",
+237 -234
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,4 +1,4 @@
{
{
"about_us": {
"solutions": {
"customer": {
@@ -364,7 +364,7 @@
"order_booking_selector": {
"title": "Välj bokning",
"help_text": "Flera väntande bokningar matchar det här fordonet. Välj rätt bokning eller fortsätt utan bokning.",
"option_title": "Bokning #{id} {datetime}",
"option_title": "Bokning #{id} • {datetime}",
"use_booking": "Välj",
"continue_without_booking": "Fortsätt utan bokning",
"customer_label": "Kund",
@@ -2761,9 +2761,12 @@
"customer_picker": {
"selected_customer": "Vald kund",
"select_customer_invoice": "Kundfaktura",
"select_draft_customer": "Välj transaktionsutkast-kund",
"select_card_payment": "Välj direkt betalning med betalkort"
"select_draft_customer": "Skjut upp",
"select_card_payment": "Betalkort",
"select_payment_form": "Välj betalningsform"
},
"hold_to_change_vehicle": "H\u00e5ll inne f\u00f6r att byta fordon",
"copy_last_wash_hint": "Kopierar tj\u00e4nst, tillval och extra artiklar fr\u00e5n senaste tv\u00e4tten",
"license_plate": "Registreringsnummer",
"manual_entry": "Manuell registrering",
"new_order": "Ny order",
@@ -2891,7 +2894,7 @@
"mandskabsvogn": "Mannskap",
"motorcykel": "Motorsykkel",
"personbil": "Personbil",
"sættevogn": "Semitrailer",
"sættevogn": "Semitrailer",
"trailer": "Släp",
"traktor": "Dragbil",
"varevogn": "Skåpbil"
@@ -4527,3 +4530,5 @@
}
}
}
@@ -103,6 +103,7 @@ const loadOrder = async () => {
});
if (!response?.data?.data) {
doesOrderExist.value = false;
attachments.value = [];
isLoading.value = false;
return;
}
@@ -113,8 +114,9 @@ const loadOrder = async () => {
economicModule.value = response.data.includes.economicModuleOrders; // Get the economic module orders
stripeModule.value = response.data.includes.stripeModuleOrders; // Get the stripe module orders
closed_at.value = response.data.data.closed_at;
isLoading.value = false;
setOrderId(orderId.value);
await fetchAttachments(orderId.value);
isLoading.value = false;
selectCustomer(customer.value.economic_customer);
invoiceCollectionId.value = response.data.data.invoice_collection_id || null;
if (response.data.data.id) {
@@ -204,7 +206,6 @@ onMounted(async () => {
await loadOrder();
await loadOrderItems();
await getInvoiceCollection();
await fetchAttachments(orderId.value);
// Check if the query parameter "print_receipt" is set to true
if (props.isPrintReceipt || router.currentRoute.value.query.print_receipt === 'true') {
@@ -1,8 +1,8 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import { BTabs, BTabItem } from 'buefy';
import { ref, onMounted, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useDebounceFn } from "@vueuse/core";
import { BTabs, BTabItem } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { searchCustomer, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
@@ -15,26 +15,26 @@ import {
TARGET_DURATION_MODE_LEGACY,
TARGET_DURATION_MODE_MONTHS,
TARGET_DURATION_MODE_WEEKS,
TARGET_DURATION_MODE_YEARS
TARGET_DURATION_MODE_YEARS,
} from "@/views/dashboards/departmentDashboard/modules/goals/functions/goalCriteriaPayload";
const props = defineProps({
initialDepartments: {
type: Array,
default: () => []
default: () => [],
},
initialGoal: {
type: Object,
default: null
default: null,
},
onSave: {
type: Function,
required: true
required: true,
},
onCancel: {
type: Function,
required: true
}
required: true,
},
});
const isEdit = computed(() => !!props.initialGoal);
@@ -45,15 +45,15 @@ const loading = ref(true);
const departments = ref([]);
const products = ref([]);
const customers = ref([]);
const departmentTargetUnit = ref('days');
const departmentTargetUnit = ref("days");
const departmentSearch = ref('');
const productSearch = ref('');
const customerSearchQuery = ref('');
const departmentSearch = ref("");
const productSearch = ref("");
const customerSearchQuery = ref("");
const filteredDepartments = computed(() => {
if (!departmentSearch.value) return departments.value;
return departments.value.filter(d => d.name?.toLowerCase().includes(departmentSearch.value.toLowerCase()));
return departments.value.filter((d) => d.name?.toLowerCase().includes(departmentSearch.value.toLowerCase()));
});
const searchCustomers = useDebounceFn(async (query) => {
@@ -61,8 +61,8 @@ const searchCustomers = useDebounceFn(async (query) => {
try {
await searchCustomer(query);
const fetched = searchCustomerResults.value || [];
fetched.forEach(item => {
if (!customers.value.find(c => c.customerNumber === item.customerNumber)) {
fetched.forEach((item) => {
if (!customers.value.find((c) => c.customerNumber === item.customerNumber)) {
customers.value.push(item);
}
});
@@ -76,8 +76,8 @@ const searchProducts = useDebounceFn(async (query) => {
try {
const res = await SessionUser.objects.products.get.all({ search: query, limit: 100 });
const fetched = res || [];
fetched.forEach(item => {
if (!products.value.find(p => p.id === item.id)) {
fetched.forEach((item) => {
if (!products.value.find((p) => p.id === item.id)) {
products.value.push(item);
}
});
@@ -97,41 +97,40 @@ watch(productSearch, (val) => {
const filteredProducts = computed(() => {
const query = productSearch.value.toLowerCase();
const selected = form.value.criteria.products || [];
let list = products.value.filter(p => {
let list = products.value.filter((p) => {
const isSelected = selected.includes(p.id);
if (!query) return true;
const matches = p.name?.toLowerCase().includes(query);
return isSelected || matches;
});
return list.sort((a, b) => {
const aSel = selected.includes(a.id);
const bSel = selected.includes(b.id);
if (aSel && !bSel) return -1;
if (!aSel && bSel) return 1;
return (a.name || '').localeCompare(b.name || '');
return (a.name || "").localeCompare(b.name || "");
});
});
const filteredCustomers = computed(() => {
const query = customerSearchQuery.value.toLowerCase();
const selected = form.value.criteria.users || [];
let list = customers.value.filter(u => {
let list = customers.value.filter((u) => {
const isSelected = selected.includes(u.customerNumber);
if (!query) return true;
const matches = (u.name?.toLowerCase().includes(query)) ||
(u.customerNumber?.toString().includes(query));
const matches = u.name?.toLowerCase().includes(query) || u.customerNumber?.toString().includes(query);
return isSelected || matches;
});
return list.sort((a, b) => {
const aSel = selected.includes(a.customerNumber);
const bSel = selected.includes(b.customerNumber);
if (aSel && !bSel) return -1;
if (!aSel && bSel) return 1;
return (a.name || '').localeCompare(b.name || '');
return (a.name || "").localeCompare(b.name || "");
});
});
@@ -146,17 +145,19 @@ const parseInitialTime = (timeString) => {
return match ? match[1] : null;
};
// Helper to get current timezone offset in ±HH:mm or Z format
// Helper to get current timezone offset in ±HH:mm or Z format
const getTimezoneOffset = () => {
const now = new Date();
const offsetMinutes = -now.getTimezoneOffset();
if (offsetMinutes === 0) return 'Z';
const sign = offsetMinutes >= 0 ? '+' : '-';
if (offsetMinutes === 0) return "Z";
const sign = offsetMinutes >= 0 ? "+" : "-";
const absMinutes = Math.abs(offsetMinutes);
const hours = Math.floor(absMinutes / 60).toString().padStart(2, '0');
const minutes = (absMinutes % 60).toString().padStart(2, '0');
const hours = Math.floor(absMinutes / 60)
.toString()
.padStart(2, "0");
const minutes = (absMinutes % 60).toString().padStart(2, "0");
return `${sign}${hours}:${minutes}`;
};
@@ -177,8 +178,8 @@ const form = ref({
departments: [...normalizedCriteria.departments],
criteria: {
...normalizedCriteria,
departments: [...normalizedCriteria.departments]
}
departments: [...normalizedCriteria.departments],
},
});
const targetDurationMode = ref(resolveInitialTargetDurationMode(form.value.criteria));
@@ -196,9 +197,13 @@ if (!form.value.criteria.users) form.value.criteria.users = [];
if (!form.value.criteria.department_daily_targets) form.value.criteria.department_daily_targets = {};
if (!form.value.criteria.departments) form.value.criteria.departments = [...form.value.departments];
watch(() => form.value.departments, (selectedDepartments) => {
form.value.criteria.departments = [...selectedDepartments];
}, { deep: true });
watch(
() => form.value.departments,
(selectedDepartments) => {
form.value.criteria.departments = [...selectedDepartments];
},
{ deep: true }
);
watch(targetDurationMode, (mode) => {
if (!requiresTargetDurationEvery(mode)) {
@@ -212,13 +217,13 @@ const fetchData = async () => {
const [deps, prods, customersRes] = await Promise.all([
SessionUser.objects.departments.get.all({ limit: 1000 }),
SessionUser.objects.products.get.all({ limit: 100 }),
authenticatedRequest('/customers', 'GET', { limit: 100 })
authenticatedRequest("/customers", "GET", { limit: 100 }),
]);
departments.value = deps || [];
products.value = prods || [];
customers.value = customersRes.data?.data || [];
if (isEdit.value) {
await fetchInitialDetails();
}
@@ -232,15 +237,15 @@ const fetchData = async () => {
const fetchInitialDetails = async () => {
// Fetch missing customers
const missingCustomerNumbers = (form.value.criteria.users || []).filter(
num => !customers.value.find(c => c.customerNumber === num)
(num) => !customers.value.find((c) => c.customerNumber === num)
);
if (missingCustomerNumbers.length > 0) {
try {
for (const num of missingCustomerNumbers) {
const res = await authenticatedRequest('/customers', 'GET', { customer_number: num });
const res = await authenticatedRequest("/customers", "GET", { customer_number: num });
const customer = res.data?.data?.economic_customer || res.data?.data;
if (customer && !Array.isArray(customer)) {
if (!customers.value.find(c => c.customerNumber === customer.customerNumber)) {
if (!customers.value.find((c) => c.customerNumber === customer.customerNumber)) {
customers.value.push(customer);
}
}
@@ -249,15 +254,15 @@ const fetchInitialDetails = async () => {
console.error("Failed to fetch missing customers:", e);
}
}
// Fetch missing products
const missingProductIds = (form.value.criteria.products || []).filter(
id => !products.value.find(p => p.id === id)
(id) => !products.value.find((p) => p.id === id)
);
if (missingProductIds.length > 0) {
try {
const res = await SessionUser.objects.products.get.all({
filters: { id: missingProductIds }
filters: { id: missingProductIds },
});
if (res) {
products.value.push(...res);
@@ -282,17 +287,17 @@ const submit = () => {
// Basic validation
if (form.value.departments.length === 0) {
alert(t('admin.goals.form.validation.departments_required'));
alert(t("admin.goals.form.validation.departments_required"));
return;
}
if (!form.value.criteria.target || form.value.criteria.target <= 0) {
alert(t('admin.goals.form.validation.target_required'));
alert(t("admin.goals.form.validation.target_required"));
return;
}
if (isTargetDurationCadenceRequired.value) {
const cadence = Number.parseInt(String(form.value.criteria.target_duration_every ?? ''), 10);
const cadence = Number.parseInt(String(form.value.criteria.target_duration_every ?? ""), 10);
if (!Number.isInteger(cadence) || cadence < 1) {
alert(t('admin.goals.form.validation.target_duration_every_required'));
alert(t("admin.goals.form.validation.target_duration_every_required"));
return;
}
form.value.criteria.target_duration_every = cadence;
@@ -303,17 +308,17 @@ const submit = () => {
criteria: buildGoalCriteriaApiPayload(
{
...form.value.criteria,
departments: [...form.value.departments]
departments: [...form.value.departments],
},
{
departmentTargetUnit: departmentTargetUnit.value,
targetDurationMode: targetDurationMode.value
targetDurationMode: targetDurationMode.value,
}
)
),
};
if (!payload.criteria.start || !payload.criteria.end) {
alert(t('admin.goals.form.validation.date_range_invalid'));
alert(t("admin.goals.form.validation.date_range_invalid"));
return;
}
@@ -332,7 +337,7 @@ const toggleSelection = (list, id) => {
const isType = (type) => form.value.criteria.type === type;
const selectedDepartments = computed(() =>
form.value.departments.map(id => departments.value.find(d => d.id === id)).filter(Boolean)
form.value.departments.map((id) => departments.value.find((d) => d.id === id)).filter(Boolean)
);
</script>
@@ -346,17 +351,12 @@ const selectedDepartments = computed(() =>
</div>
<div v-else>
<BTabs
v-model="activeTab"
type="is-boxed"
size="is-small"
class="goal-form-tabs"
>
<BTabs v-model="activeTab" type="is-boxed" size="is-small" class="goal-form-tabs">
<BTabItem label="Goal Setup" icon="bullseye" icon-pack="fas">
<div class="field">
<label class="label">Label (Navn)</label>
<div class="control has-icons-left">
<input class="input" type="text" v-model="form.criteria.label" placeholder="f.eks. Månedligt salgsmål">
<input class="input" type="text" v-model="form.criteria.label" placeholder="f.eks. Månedligt salgsmål" />
<span class="icon is-small is-left">
<i class="fas fa-tag"></i>
</span>
@@ -386,7 +386,7 @@ const selectedDepartments = computed(() =>
<div class="field">
<label class="label">Mål (Target)</label>
<div class="control has-icons-left">
<input class="input" type="number" v-model.number="form.criteria.target" placeholder="f.eks. 10000">
<input class="input" type="number" v-model.number="form.criteria.target" placeholder="f.eks. 10000" />
<div class="icon is-small is-left">
<i :class="isType('REVENUE') ? 'fas fa-coins' : 'fas fa-boxes'"></i>
</div>
@@ -398,27 +398,37 @@ const selectedDepartments = computed(() =>
<div class="columns">
<div class="column is-6">
<div class="field">
<label class="label">{{ t('admin.goals.form.target_duration.label') }}</label>
<label class="label">{{ t("admin.goals.form.target_duration.label") }}</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select v-model="targetDurationMode">
<option :value="TARGET_DURATION_MODE_LEGACY">{{ t('admin.goals.form.target_duration.modes.legacy') }}</option>
<option :value="TARGET_DURATION_MODE_ENTIRE_DURATION">{{ t('admin.goals.form.target_duration.modes.entire_duration') }}</option>
<option :value="TARGET_DURATION_MODE_WEEKS">{{ t('admin.goals.form.target_duration.modes.weeks') }}</option>
<option :value="TARGET_DURATION_MODE_MONTHS">{{ t('admin.goals.form.target_duration.modes.months') }}</option>
<option :value="TARGET_DURATION_MODE_YEARS">{{ t('admin.goals.form.target_duration.modes.years') }}</option>
<option :value="TARGET_DURATION_MODE_LEGACY">
{{ t("admin.goals.form.target_duration.modes.legacy") }}
</option>
<option :value="TARGET_DURATION_MODE_ENTIRE_DURATION">
{{ t("admin.goals.form.target_duration.modes.entire_duration") }}
</option>
<option :value="TARGET_DURATION_MODE_WEEKS">
{{ t("admin.goals.form.target_duration.modes.weeks") }}
</option>
<option :value="TARGET_DURATION_MODE_MONTHS">
{{ t("admin.goals.form.target_duration.modes.months") }}
</option>
<option :value="TARGET_DURATION_MODE_YEARS">
{{ t("admin.goals.form.target_duration.modes.years") }}
</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hourglass-half"></i>
</div>
</div>
<p class="help is-size-7">{{ t('admin.goals.form.target_duration.help') }}</p>
<p class="help is-size-7">{{ t("admin.goals.form.target_duration.help") }}</p>
</div>
</div>
<div class="column is-6" v-if="isTargetDurationCadenceRequired">
<div class="field">
<label class="label">{{ t('admin.goals.form.target_duration_every.label') }}</label>
<label class="label">{{ t("admin.goals.form.target_duration_every.label") }}</label>
<div class="control has-icons-left">
<input
class="input"
@@ -427,12 +437,12 @@ const selectedDepartments = computed(() =>
step="1"
v-model.number="form.criteria.target_duration_every"
:placeholder="t('admin.goals.form.target_duration_every.placeholder')"
>
/>
<div class="icon is-small is-left">
<i class="fas fa-repeat"></i>
</div>
</div>
<p class="help is-size-7">{{ t('admin.goals.form.target_duration_every.help') }}</p>
<p class="help is-size-7">{{ t("admin.goals.form.target_duration_every.help") }}</p>
</div>
</div>
</div>
@@ -442,7 +452,7 @@ const selectedDepartments = computed(() =>
<div class="field">
<label class="label">Start Dato</label>
<div class="control has-icons-left">
<input class="input" type="date" v-model="form.criteria.start">
<input class="input" type="date" v-model="form.criteria.start" />
<div class="icon is-small is-left">
<i class="fas fa-calendar"></i>
</div>
@@ -453,7 +463,7 @@ const selectedDepartments = computed(() =>
<div class="field">
<label class="label">Slut Dato</label>
<div class="control has-icons-left">
<input class="input" type="date" v-model="form.criteria.end">
<input class="input" type="date" v-model="form.criteria.end" />
<div class="icon is-small is-left">
<i class="fas fa-calendar-check"></i>
</div>
@@ -472,7 +482,12 @@ const selectedDepartments = computed(() =>
</span>
</label>
<div class="control mb-2">
<input class="input is-small" type="text" v-model="customerSearchQuery" :placeholder="t('global.search_customers_ellipsis')">
<input
class="input is-small"
type="text"
v-model="customerSearchQuery"
:placeholder="t('global.search_customers_ellipsis')"
/>
</div>
<div class="control">
<div class="department-grid department-grid--two-columns department-grid--single-scroll">
@@ -482,15 +497,18 @@ const selectedDepartments = computed(() =>
class="department-grid-item"
:class="{ 'is-selected': form.criteria.users.includes(cust.customerNumber) }"
>
<input class="department-grid-checkbox" type="checkbox" :value="cust.customerNumber" v-model="form.criteria.users">
<input
class="department-grid-checkbox"
type="checkbox"
:value="cust.customerNumber"
v-model="form.criteria.users"
/>
<span class="department-grid-checkmark" aria-hidden="true">
<i class="fas fa-check"></i>
</span>
<span class="department-grid-name">{{ cust.name }} ({{ cust.customerNumber }})</span>
</label>
<p v-if="filteredCustomers.length === 0" class="department-grid-empty">
Ingen kunder fundet
</p>
<p v-if="filteredCustomers.length === 0" class="department-grid-empty">Ingen kunder fundet</p>
</div>
</div>
</div>
@@ -505,7 +523,12 @@ const selectedDepartments = computed(() =>
</span>
</label>
<div class="control mb-2">
<input class="input is-small" type="text" v-model="departmentSearch" :placeholder="t('global.search_departments')">
<input
class="input is-small"
type="text"
v-model="departmentSearch"
:placeholder="t('global.search_departments')"
/>
</div>
<div class="control">
<div class="department-grid">
@@ -521,15 +544,13 @@ const selectedDepartments = computed(() =>
v-model="form.departments"
:value="dept.id"
@change="form.criteria.departments = [...form.departments]"
>
/>
<span class="department-grid-checkmark" aria-hidden="true">
<i class="fas fa-check"></i>
</span>
<span class="department-grid-name">{{ dept.name }}</span>
</label>
<p v-if="filteredDepartments.length === 0" class="department-grid-empty">
Ingen afdelinger fundet
</p>
<p v-if="filteredDepartments.length === 0" class="department-grid-empty">Ingen afdelinger fundet</p>
</div>
</div>
<p class="help">Vælg hvilke afdelinger dette mål tilhører.</p>
@@ -545,7 +566,12 @@ const selectedDepartments = computed(() =>
</span>
</label>
<div class="control mb-2">
<input class="input is-small" type="text" v-model="productSearch" :placeholder="t('global.search_products_ellipsis')">
<input
class="input is-small"
type="text"
v-model="productSearch"
:placeholder="t('global.search_products_ellipsis')"
/>
</div>
<div class="control">
<div class="department-grid department-grid--two-columns department-grid--single-scroll">
@@ -555,15 +581,18 @@ const selectedDepartments = computed(() =>
class="department-grid-item"
:class="{ 'is-selected': form.criteria.products.includes(prod.id) }"
>
<input class="department-grid-checkbox" type="checkbox" :value="prod.id" v-model="form.criteria.products">
<input
class="department-grid-checkbox"
type="checkbox"
:value="prod.id"
v-model="form.criteria.products"
/>
<span class="department-grid-checkmark" aria-hidden="true">
<i class="fas fa-check"></i>
</span>
<span class="department-grid-name">{{ prod.name }}</span>
</label>
<p v-if="filteredProducts.length === 0" class="department-grid-empty">
Ingen produkter fundet
</p>
<p v-if="filteredProducts.length === 0" class="department-grid-empty">Ingen produkter fundet</p>
</div>
</div>
</div>
@@ -582,7 +611,9 @@ const selectedDepartments = computed(() =>
</div>
</div>
</div>
<p class="help is-size-7">Custom daily targets per department. Select Weeks to enter weekly values (sent as daily by dividing by 7).</p>
<p class="help is-size-7">
Custom daily targets per department. Select Weeks to enter weekly values (sent as daily by dividing by 7).
</p>
<div class="columns is-multiline">
<div v-for="dept in selectedDepartments" :key="dept.id" class="column is-6">
<label class="label is-size-7">{{ dept.name }}</label>
@@ -594,7 +625,7 @@ const selectedDepartments = computed(() =>
step="1"
v-model.number="form.criteria.department_daily_targets[dept.id]"
placeholder="0"
>
/>
<span class="icon is-small is-left">
<i class="fas fa-bullseye"></i>
</span>
@@ -621,7 +652,10 @@ const selectedDepartments = computed(() =>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas" :class="form.criteria.progress_alert_frequency === 'NONE' ? 'fa-bell-slash' : 'fa-bell'"></i>
<i
class="fas"
:class="form.criteria.progress_alert_frequency === 'NONE' ? 'fa-bell-slash' : 'fa-bell'"
></i>
</div>
</div>
</div>
@@ -640,7 +674,10 @@ const selectedDepartments = computed(() =>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas" :class="form.criteria.progress_alert_destination === 'NONE' ? 'fa-share' : 'fa-paper-plane'"></i>
<i
class="fas"
:class="form.criteria.progress_alert_destination === 'NONE' ? 'fa-share' : 'fa-paper-plane'"
></i>
</div>
</div>
</div>
@@ -689,7 +726,7 @@ const selectedDepartments = computed(() =>
<div class="field">
<label class="label is-size-7">Tidspunkt</label>
<div class="control has-icons-left">
<input class="input is-small" type="time" v-model="localTimeOfDay">
<input class="input is-small" type="time" v-model="localTimeOfDay" />
<div class="icon is-small is-left">
<i class="fas fa-clock"></i>
</div>
@@ -718,9 +755,15 @@ const selectedDepartments = computed(() =>
<div class="field mt-2">
<label class="label is-size-7">Brugerdefineret Format (Valgfrit)</label>
<div class="control">
<textarea class="textarea is-small" v-model="form.criteria.progress_alert_format" placeholder="f.eks. {label}: {percent}% opnaet ({count}/{target})"></textarea>
<textarea
class="textarea is-small"
v-model="form.criteria.progress_alert_format"
placeholder="f.eks. {label}: {percent}% opnaet ({count}/{target})"
></textarea>
</div>
<p class="help is-size-7">Tokens: {label}, {percent}, {count}, {target}, {timeframe}, {departments}, {prefix}, {body}</p>
<p class="help is-size-7">
Tokens: {label}, {percent}, {count}, {target}, {timeframe}, {departments}, {prefix}, {body}
</p>
</div>
</div>
</BTabItem>
@@ -729,7 +772,7 @@ const selectedDepartments = computed(() =>
<div class="buttons is-right mt-5">
<button class="button" @click="onCancel">Annuller</button>
<button class="button is-dark" @click="submit">
{{ isEdit ? 'Gem ÃÆÃ¦ndringer' : 'Opret mÃÆÃÂ¥l' }}
{{ isEdit ? "Gem ændringer" : "Opret mål" }}
</button>
</div>
</div>
@@ -205,6 +205,10 @@ test.describe("Admin department visibility", () => {
"add_order",
"list_orders",
],
departments: [
{ id: 1, name: "Visible North", visible: true },
{ id: 2, name: "Visible South", visible: true },
],
sessionData: {
runtime_config: {
economic: {
@@ -223,4 +227,65 @@ test.describe("Admin department visibility", () => {
await expect(page).toHaveURL(/\/admin\/1\/modules\/pos\/drafts$/);
await expect(page.getByTestId("department-pos-drafts-page")).toBeVisible();
});
test("shows a department-scoped draft count badge in the desktop buefy menu", async ({
page,
}, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"add_order",
"list_orders",
],
sessionData: {
runtime_config: {
economic: {
transaction_draft_customer_number: 6001,
},
},
},
});
await page.route(/\/orders(\?.*)?$/i, async (route) => {
const url = new URL(route.request().url());
const filters = url.searchParams.get("filters") || "";
const isDraftRequest = filters.includes("customer_id:6001");
const isDepartmentOne = filters.includes("department_id:1");
const isDepartmentTwo = filters.includes("department_id:2");
const total = isDraftRequest ? (isDepartmentOne ? 3 : isDepartmentTwo ? 0 : 0) : 0;
const perPage = Number(url.searchParams.get("limit") || "1");
await route.fulfill(
json({
data: total > 0 && perPage > 1 ? [{ id: 501, department_id: 1, customer_id: 6001 }] : [],
meta: {
pagination: {
page: 1,
per_page: perPage,
total,
},
},
})
);
});
await page.goto("/admin/1/modules/daily-report");
const draftsLabel = page.getByTestId("desktop-buefy-nav-drafts-label");
const draftsBadge = page.getByTestId("desktop-buefy-nav-drafts-badge");
await expect(draftsLabel).toContainText("Kladder");
await expect(draftsBadge).toHaveText("3");
await page.goto("/admin/2/modules/daily-report");
await expect(draftsLabel).toContainText("Kladder");
await expect(draftsBadge).toHaveCount(0);
await draftsLabel.click();
await expect(page).toHaveURL(/\/admin\/2\/modules\/pos\/drafts$/);
await expect(page.getByTestId("department-pos-drafts-page")).toBeVisible();
});
});
+46
View File
@@ -1462,6 +1462,52 @@ test.describe("Admin POS wash certificate completion", () => {
await expect(safetySealInput).toHaveValue("SEAL-5566");
});
test("refreshes the attachments tab after regenerating a wash certificate from a safety seal edit", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only wash certificate refresh coverage");
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createPosFixture(),
});
await primeOperatorSession(page, "pos-safety-seal-refresh-token");
await openOrderAttachments(page);
await openAddAttachmentCard(page);
const attachWashCertificateButton = page.getByTestId("pos-order-attachments-attach-wash-certificate");
await attachWashCertificateButton.click();
await expect(page.locator(".swal2-popup")).toBeVisible();
await page.locator(".swal2-input").fill("SEAL-INITIAL");
await page.locator(".swal2-confirm").click();
await expect(page.getByTestId("pos-order-attachment-card-400")).toBeVisible();
await waitForSwalToClose(page);
await clickVisibleTestId(page, "pos-order-tab-cart");
await expect(page.getByTestId("pos-order-panel-cart")).toBeVisible();
const updateRequest = waitForOrderMutation(
page,
"PUT",
"/orders",
(body) => Number(body.id) === 54518 && body.safety_seal === "SEAL-UPDATED"
);
await page.getByTestId("pos-order-customer-wishes-safety-seal").click();
const safetySealInput = page.getByTestId("pos-order-customer-wishes-safety-seal-input");
await expect(safetySealInput).toBeVisible();
await safetySealInput.fill("SEAL-UPDATED");
await updateRequest;
await clickVisibleTestId(page, "pos-order-tab-attachments");
await expect(page.getByTestId("pos-order-panel-attachments")).toBeVisible();
await expect(page.getByTestId("pos-order-attachment-card-401")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-order-attachment-card-400")).toHaveCount(0);
});
test("defers material desktop completion to step 4 and auto-attaches the wash certificate on final completion", async ({
page,
}, testInfo) => {
+22
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { mockApi } from "./support/network.js";
import { containsSuspiciousEncoding } from "../../scripts/text-encoding.mjs";
function json(body, status = 200) {
return {
@@ -71,6 +72,27 @@ test.describe("Economic queue async export workflow", () => {
await mockApi(page);
});
test("economic export actions expose stable metadata with UTF-8 labels", async ({ page }) => {
await openHarness(page);
const invoiceSubmit = page.getByTestId("economic-invoice-export-submit");
const draftSubmit = page.getByTestId("economic-draft-export-submit");
await expect(invoiceSubmit).toBeVisible();
await expect(draftSubmit).toBeVisible();
await expect(invoiceSubmit).toHaveAttribute("data-action-key", "economic-invoice-export-submit");
await expect(draftSubmit).toHaveAttribute("data-action-key", "economic-draft-export-submit");
await expect(invoiceSubmit).toHaveAttribute("data-copy-key", "complete");
await expect(draftSubmit).toHaveAttribute("data-copy-key", "complete");
await expect(invoiceSubmit).toContainText("Fuldfør");
await expect(draftSubmit).toContainText("Fuldfør");
for (const action of [invoiceSubmit, draftSubmit]) {
const text = await action.textContent();
expect(containsSuspiciousEncoding(text ?? "")).toBe(false);
}
});
test("invoice export enqueues, polls, completes, and stops polling in terminal state", async ({ page }) => {
let statusCalls = 0;
+29 -3
View File
@@ -1179,6 +1179,32 @@ test.describe("POS flow", () => {
.toBe(true);
});
test("desktop persists manual step-one reference and trailer fields when creating the order", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Desktop POS step 1 is validated on chromium-desktop.");
const fixture = createPosFixture();
await setupDesktopPosPage(page, fixture, {
token: "pos-desktop-step-one-manual-field-persistence",
});
await page.locator("#reg_1").fill("AB12345");
await expect(page.getByTestId("pos-step-1").getByText("Pleno Logistics").first()).toBeVisible({ timeout: 10_000 });
await page.locator("#reg_2").fill("TRAILER9");
await page.locator("#reference").fill("MANUAL-DESKTOP-SAVE");
await page.locator('[data-testid="pos-next-step"]:visible').click();
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: 10_000 });
await expect.poll(() => fixture.ordersById[9300]?.id ?? null, { timeout: 10_000 }).toBe(9300);
await expect.poll(() => fixture.ordersById[9300]?.reg_2 ?? null, { timeout: 10_000 }).toBe("TRAILER9");
await expect.poll(() => fixture.ordersById[9300]?.reference ?? null, { timeout: 10_000 }).toBe(
"MANUAL-DESKTOP-SAVE"
);
});
test("desktop auto-applies the only previous customer suggestion while selection source is none", async ({
page,
}, testInfo) => {
@@ -2536,12 +2562,12 @@ test.describe("POS flow", () => {
reference: "BOOK-ONLY-A",
reference_number: "BOOK-ONLY-A",
items: [
{ id: 53, name: "Tankvogn med hænger", price: 599, quantity: 1 },
{ id: 53, name: "Tankvogn med hænger", price: 599, quantity: 1 },
{ id: 63, name: "Dolly", price: 275, quantity: 1 },
],
parsed_services: {
string: "Tankvogn med hænger, Dolly",
array: ["Tankvogn med hænger", "Dolly"],
string: "Tankvogn med hænger, Dolly",
array: ["Tankvogn med hænger", "Dolly"],
},
}),
buildOrderBooking(8112, {
+101 -25
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { containsSuspiciousEncoding } from "../../scripts/text-encoding.mjs";
import {
DEFAULT_BOOKING_ID,
DEFAULT_DEPARTMENT_ID,
@@ -7,6 +8,7 @@ import {
buildMobilePosState,
createAttachmentFile,
createMobilePosFixture,
getByActionKey,
gotoMobilePos,
getStoredPosSnapshot,
setupMobilePosPage,
@@ -194,6 +196,11 @@ async function waitForStepReset(page) {
await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 });
}
async function expectCleanActionText(locator) {
const text = await locator.textContent();
expect(containsSuspiciousEncoding(text ?? "")).toBe(false);
}
async function expectHorizontallyCentered(container, target, tolerance = 8) {
const [containerBox, targetBox] = await Promise.all([container.boundingBox(), target.boundingBox()]);
@@ -260,18 +267,15 @@ async function selectPrimaryProduct(page, productId = 53) {
}
async function openVehicleSelectionFromPrimaryProduct(page, productName = "Tank truck wash") {
const namedProduct = page.getByText(productName, { exact: true });
const fallbackCard = page.getByTestId("pos-mobile-primary-product-card");
const hasNamedProduct = await namedProduct
.isVisible()
.then(() => true)
.catch(() => false);
const trigger = hasNamedProduct ? namedProduct : fallbackCard;
const trigger = page.getByTestId("pos-mobile-primary-product-card");
await expect(trigger).toBeVisible({ timeout: 10_000 });
if (productName) {
await expect(trigger).toContainText(productName, { timeout: 10_000 });
}
await trigger.dispatchEvent("pointerdown");
await page.waitForTimeout(650);
await trigger.dispatchEvent("pointerup");
await page.locator("body").dispatchEvent("pointerup");
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 });
}
@@ -493,8 +497,9 @@ test("mobile customer popup defaults to customer invoice mode and keeps customer
seedState: {
customerId: null,
includePrimaryItem: false,
reg: "AB12345",
reg: "FREE123",
reference: "MOBILE-INVOICE-MODE",
lastOrderId: null,
},
route: {
step: 1,
@@ -697,26 +702,22 @@ test.describe("POS mobile order flow", () => {
const footerBeforeScroll = await footer.boundingBox();
await expect
.poll(() => scrollRegion.evaluate((element) => element.scrollHeight > element.clientHeight))
.toBe(true);
await expect.poll(() => scrollRegion.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
await scrollRegion.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
await expect
.poll(() => scrollRegion.evaluate((element) => Math.round(element.scrollTop)))
.toBeGreaterThan(0);
await expect.poll(() => scrollRegion.evaluate((element) => Math.round(element.scrollTop))).toBeGreaterThan(0);
const footerAfterScroll = await footer.boundingBox();
expect(footerBeforeScroll).not.toBeNull();
expect(footerAfterScroll).not.toBeNull();
expect(Math.abs(footerBeforeScroll.y - footerAfterScroll.y)).toBeLessThanOrEqual(2);
expect(Math.abs(
footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height)
)).toBeLessThanOrEqual(2);
expect(
Math.abs(footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height))
).toBeLessThanOrEqual(2);
await expectAboveFixedActions(page, footer);
});
@@ -1369,18 +1370,16 @@ test.describe("POS mobile order flow", () => {
element.scrollTop = element.scrollHeight;
});
await expect
.poll(() => popupContent.evaluate((element) => Math.round(element.scrollTop)))
.toBeGreaterThan(0);
await expect.poll(() => popupContent.evaluate((element) => Math.round(element.scrollTop))).toBeGreaterThan(0);
const footerAfterScroll = await footer.boundingBox();
expect(footerBeforeScroll).not.toBeNull();
expect(footerAfterScroll).not.toBeNull();
expect(Math.abs(footerBeforeScroll.y - footerAfterScroll.y)).toBeLessThanOrEqual(2);
expect(Math.abs(
footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height)
)).toBeLessThanOrEqual(2);
expect(
Math.abs(footerBeforeScroll.y + footerBeforeScroll.height - (footerAfterScroll.y + footerAfterScroll.height))
).toBeLessThanOrEqual(2);
await expectAboveFixedActions(page, footer);
await expect(popup.getByTestId("pos-mobile-order-booking-option-8267")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-order-booking-skip").click();
@@ -2225,6 +2224,83 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("mobile POS actions expose stable metadata and clean localized labels", async ({ page }) => {
const orderId = 9411;
const fixture = createMobilePosFixture({
ordersById: {
[orderId]: buildRegularOrder(orderId),
},
orderItemsByOrderId: {
[orderId]: [
{
id: 9911,
order_id: orderId,
product_id: 53,
product: fixtureProduct(53),
quantity: 1,
notes: "",
reference: "",
related_item_id: null,
price: 599,
},
],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-action-metadata-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "ACTION-METADATA",
includePrimaryItem: true,
primaryItemId: 53,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
const completeButton = page.getByTestId("pos-mobile-next-step");
const clearAllButton = page.getByTestId("pos-mobile-clear-all-button");
await expect(completeButton).toBeVisible({ timeout: 10_000 });
await expect(clearAllButton).toBeVisible({ timeout: 10_000 });
await expect(completeButton).toHaveAttribute("data-action-key", "pos-mobile-complete-order");
await expect(clearAllButton).toHaveAttribute("data-action-key", "pos-mobile-clear-all");
await expect(completeButton).toHaveAttribute("data-copy-key", "complete");
await expect(clearAllButton).toHaveAttribute("data-copy-key", "clear_all");
await expect(completeButton).toContainText(/Afslut|Fuldf/);
await expectCleanActionText(completeButton);
await expectCleanActionText(clearAllButton);
await gotoMobilePos(page, {
departmentId: fixture.departmentId ?? DEFAULT_DEPARTMENT_ID,
step: 1,
});
const attachmentsToggle = page.getByTestId("pos-mobile-attachments-toggle");
await expect(attachmentsToggle).toBeVisible({ timeout: 10_000 });
await expect(attachmentsToggle).toHaveAttribute("data-action-key", "pos-mobile-attachments-toggle");
await expectCleanActionText(attachmentsToggle);
await attachmentsToggle.click();
for (const actionKey of [
"pos-mobile-attachment-view-take-picture",
"pos-mobile-attachment-view-close",
"pos-mobile-attachments-upload-file",
"pos-mobile-attachments-wash-certificate",
]) {
const action = getByActionKey(page, actionKey);
await expect(action).toBeVisible({ timeout: 10_000 });
await expect(action).toHaveAttribute("data-action-key", actionKey);
await expectCleanActionText(action);
}
});
test("step 2 sync is idempotent when the order already matches the local transaction", async ({ page }) => {
const orderId = 9404;
const fixture = createMobilePosFixture({
+4
View File
@@ -14,6 +14,10 @@ export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100;
const ATTACHMENT_DOWNLOAD_URL = "https://cdn.example.test/mobile-pos";
export function getByActionKey(scope, actionKey) {
return scope.locator(`[data-action-key="${actionKey}"]`);
}
export function json(body, status = 200) {
return {
status,
+100
View File
@@ -178,9 +178,91 @@ function ensureWashCertificateAttachment(posFixture, orderId) {
deleted_at: null,
};
posFixture.attachmentsByOrderId[orderId].push(attachment);
syncOrderAttachments(posFixture, orderId);
return attachment;
}
function syncOrderAttachments(posFixture, orderId) {
if (!posFixture.ordersById?.[orderId]) {
return;
}
posFixture.ordersById[orderId].attachments = [...(posFixture.attachmentsByOrderId[orderId] || [])];
}
function replaceWashCertificateAttachment(posFixture, orderId) {
if (!Array.isArray(posFixture.attachmentsByOrderId[orderId])) {
posFixture.attachmentsByOrderId[orderId] = [];
}
const existingAttachmentIndex = (posFixture.attachmentsByOrderId[orderId] || []).findIndex((attachment) => {
return String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE";
});
if (existingAttachmentIndex === -1) {
return null;
}
const existingAttachment = posFixture.attachmentsByOrderId[orderId][existingAttachmentIndex];
const attachmentId = posFixture.nextAttachmentId++;
const replacementAttachment = {
...existingAttachment,
id: attachmentId,
content: {
...existingAttachment.content,
document: `wash_certificate_${orderId}_${attachmentId}.pdf`,
other: "WASH_CERTIFICATE",
src: null,
},
created_at: toSqlDateTime(),
updated_at: toSqlDateTime(),
deleted_at: null,
};
posFixture.attachmentsByOrderId[orderId].splice(existingAttachmentIndex, 1, replacementAttachment);
syncOrderAttachments(posFixture, orderId);
return replacementAttachment;
}
function normalizeRelevantOrderUpdateValue(field, value) {
if (field === "customer_id") {
return normalizePositiveIntegerValue(value);
}
if (field === "reg_1" || field === "reg_2" || field === "reg_3") {
return normalizeRegistrationValue(value);
}
if (field === "safety_seal") {
return normalizeSafetySealValue(value);
}
return value;
}
function shouldRegenerateWashCertificateForOrderUpdate(order, body) {
if (!order || !body || typeof body !== "object") {
return false;
}
const relevantFields = ["customer_id", "reg_1", "reg_2", "reg_3", "safety_seal"];
return relevantFields.some((field) => {
const hasDirectField = Object.prototype.hasOwnProperty.call(body, field);
const hasLegacyField = body.field === field;
if (!hasDirectField && !hasLegacyField) {
return false;
}
const nextValue = normalizeRelevantOrderUpdateValue(field, hasDirectField ? body[field] : body.value);
const currentValue = normalizeRelevantOrderUpdateValue(field, order[field]);
return nextValue !== currentValue;
});
}
function resolveDepartmentIncludeInInvoice(posFixture, departmentId) {
const department = (posFixture.departments || []).find((entry) => Number(entry.id) === Number(departmentId));
if (!department) {
@@ -2001,6 +2083,10 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
if (pathname.endsWith("/orders") && method === "PUT") {
const body = request.postDataJSON?.() || {};
const orderId = Number(body.id || 0);
const shouldRegenerateWashCertificate = shouldRegenerateWashCertificateForOrderUpdate(
posFixture.ordersById[orderId],
body
);
if (posFixture.ordersById[orderId]) {
posFixture.ordersById[orderId] = {
...posFixture.ordersById[orderId],
@@ -2025,6 +2111,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
: {}),
};
}
if (shouldRegenerateWashCertificate) {
replaceWashCertificateAttachment(posFixture, orderId);
}
await route.fulfill(
json({ success: true, data: withEffectiveOrderState(posFixture, posFixture.ordersById[orderId] || null) })
);
@@ -2034,6 +2123,10 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
if (pathname.endsWith("/order") && method === "PUT") {
const body = request.postDataJSON?.() || {};
const orderId = Number(body.id || 0);
const shouldRegenerateWashCertificate = shouldRegenerateWashCertificateForOrderUpdate(
posFixture.ordersById[orderId],
body
);
if (posFixture.ordersById[orderId]) {
if (body.field) {
posFixture.ordersById[orderId][body.field] =
@@ -2071,6 +2164,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
};
}
}
if (shouldRegenerateWashCertificate) {
replaceWashCertificateAttachment(posFixture, orderId);
}
await route.fulfill(
json({ success: true, data: withEffectiveOrderState(posFixture, posFixture.ordersById[orderId] || null) })
);
@@ -2162,6 +2258,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
deleted_at: null,
};
posFixture.attachmentsByOrderId[orderId].push(attachment);
syncOrderAttachments(posFixture, orderId);
await route.fulfill(json({ success: true, data: attachment }));
return true;
}
@@ -2172,6 +2269,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
posFixture.attachmentsByOrderId[orderId] = (posFixture.attachmentsByOrderId[orderId] || []).filter(
(attachment) => attachment.id !== attachmentId
);
syncOrderAttachments(posFixture, orderId);
await route.fulfill(json({ success: true, data: true }));
return true;
}
@@ -2207,6 +2305,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
}) || null;
if (existingAttachment) {
syncOrderAttachments(posFixture, orderId);
await route.fulfill(
json({
success: true,
@@ -2238,6 +2337,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
deleted_at: null,
};
posFixture.attachmentsByOrderId[orderId].push(attachment);
syncOrderAttachments(posFixture, orderId);
await route.fulfill(
json({
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const authenticatedRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: authenticatedRequestMock,
}));
import { fetchDepartmentDraftCount } from "@/components/models/navigation/items/adminDraftCount.js";
describe("fetchDepartmentDraftCount", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
beforeEach(() => {
authenticatedRequestMock.mockReset();
consoleErrorSpy.mockClear();
});
it("returns pagination total when the orders request succeeds", async () => {
authenticatedRequestMock.mockResolvedValue({
data: {
meta: {
pagination: {
total: 7,
},
},
},
});
await expect(
fetchDepartmentDraftCount({
departmentId: 12,
customerNumber: 6001,
})
).resolves.toBe(7);
expect(authenticatedRequestMock).toHaveBeenCalledWith("/orders", "GET", {
filters: "department_id:12,customer_id:6001",
page: 1,
limit: 1,
search: "",
order: "id:DESC",
});
});
it("returns 0 when the department or customer is invalid", async () => {
await expect(
fetchDepartmentDraftCount({
departmentId: null,
customerNumber: 6001,
})
).resolves.toBe(0);
await expect(
fetchDepartmentDraftCount({
departmentId: 12,
customerNumber: 0,
})
).resolves.toBe(0);
expect(authenticatedRequestMock).not.toHaveBeenCalled();
});
it("returns 0 when the orders request fails", async () => {
authenticatedRequestMock.mockRejectedValue(new Error("Request failed"));
await expect(
fetchDepartmentDraftCount({
departmentId: 12,
customerNumber: 6001,
})
).resolves.toBe(0);
expect(consoleErrorSpy).toHaveBeenCalled();
});
});
@@ -74,7 +74,7 @@ const makeOverview = ({
state: overtimeState,
value: overtime,
out_of: null,
message: overtimeState === "ready" ? null : "Overarbejde kræver Workfeed-kobling.",
message: overtimeState === "ready" ? null : "Overarbejde kræver Workfeed-kobling.",
},
},
products: [
@@ -182,7 +182,7 @@ describe("DepartmentDailyReportObject behavior", () => {
bookings: 3,
bookingsOutOf: 4,
complaintsState: "unavailable",
complaintsMessage: "Kundeklager er ikke tilgængelig endnu.",
complaintsMessage: "Kundeklager er ikke tilgængelig endnu.",
nightWashesState: "ready",
nightWashes: 1,
nightWashesBySource: { orders: 0, xlvask: 1, selfserve: 0 },
@@ -203,7 +203,7 @@ describe("DepartmentDailyReportObject behavior", () => {
await flushAll();
expect(module.complaints_metric_state.value.state).toBe("unavailable");
expect(module.complaints_metric_state.value.message).toContain("ikke tilgængelig");
expect(module.complaints_metric_state.value.message).toContain("ikke tilgængelig");
expect(module.count_complaints.value).toBe(0);
expect(module.overtime_metric_state.value.state).toBe("unavailable");
expect(module.count_overtime.value).toBeNull();
@@ -77,7 +77,7 @@ vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentD
});
return {
complaints_metric_state: ref({ state: "unavailable", message: "Kundeklager er ikke tilgængelig endnu." }),
complaints_metric_state: ref({ state: "unavailable", message: "Kundeklager er ikke tilgængelig endnu." }),
count_bookings: ref(0),
count_bookings_out_of: ref(0),
count_complaints: ref(0),
@@ -94,7 +94,7 @@ vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentD
night_washes_has_missing_opening_hours: ref(false),
night_washes_missing_department_ids: ref([]),
night_washes_metric_state: ref({ state: "ready", message: null }),
overtime_metric_state: ref({ state: "unavailable", message: "Overarbejde kræver Workfeed-kobling." }),
overtime_metric_state: ref({ state: "unavailable", message: "Overarbejde kræver Workfeed-kobling." }),
selected_date: sharedState.selected_date,
selected_date_to: sharedState.selected_date_to,
selected_department_id: sharedState.selected_department_id,
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { containsSuspiciousEncoding, repairText } from "../../scripts/text-encoding.mjs";
function fromCharCodes(codes) {
return String.fromCharCode(...codes);
}
function buildSingleEncoded(inputPrefix, inputSuffix) {
return `${inputPrefix}${fromCharCodes([0xc3, 0xb8])}${inputSuffix}`;
}
function buildDoubleEncoded(inputPrefix, inputSuffix) {
return `${inputPrefix}${fromCharCodes([0xc3, 0x83, 0xc2, 0xb8])}${inputSuffix}`;
}
describe("encoding repair utilities", () => {
it("repairs a single-encoded Danish string", () => {
const input = buildSingleEncoded("Gennemf", "r");
expect(repairText(input)).toBe("Gennemfør");
});
it("repairs a second single-encoded Danish string", () => {
const input = buildSingleEncoded("Tilf", "j");
expect(repairText(input)).toBe("Tilføj");
});
it("repairs a double-encoded Danish string", () => {
const input = buildDoubleEncoded("Gennemf", "r");
expect(repairText(input)).toBe("Gennemfør");
});
it("does not mutate already-correct UTF-8 text", () => {
expect(repairText("Gennemfør")).toBe("Gennemfør");
expect(repairText("Tilføj")).toBe("Tilføj");
});
it("detects suspicious encoding markers only in corrupted strings", () => {
expect(containsSuspiciousEncoding(buildSingleEncoded("Tilf", "j"))).toBe(true);
expect(containsSuspiciousEncoding("Tilføj")).toBe(false);
});
});