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:
+3
-1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+427
-305
File diff suppressed because it is too large
Load Diff
+54
-8
@@ -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%);
|
||||
|
||||
+39
-5
@@ -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>
|
||||
|
||||
+167
-85
@@ -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;">
|
||||
|
||||
</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"> </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>
|
||||
|
||||
<!--
|
||||
|
||||
+45
-27
@@ -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>
|
||||
|
||||
+138
-82
@@ -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>
|
||||
|
||||
+66
-7
@@ -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>
|
||||
|
||||
+111
-87
@@ -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;
|
||||
|
||||
+4
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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') {
|
||||
|
||||
+144
-101
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user