Merge pull request #32 from copenhagentruckwash/pwa

Pwa
This commit is contained in:
Jeppe B
2025-10-28 21:41:53 +01:00
committed by GitHub
351 changed files with 44382 additions and 581 deletions
+1
View File
@@ -0,0 +1 @@
SASS_SILENCE_DEPRECATIONS=import
Vendored
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BUILD_DATE: string,
readonly VITE_APP_VERSION: string,
readonly VITE_GIT_COMMIT_HASH: string,
readonly VITE_IS_DEV: string,
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __APP_GIT_COMMIT__: string
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="bulma/css/bulma.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" integrity="sha512-5Hs3dF2AEPkpNAR7UiOHba+lRSJNeM2ECkwxUIxC1Q/FLycGTbNapWXB4tP889k5T5Ju8fs4b1P5z/iB4nMfSQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>Truck Wash</title>
</head>
+21325 -138
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -6,10 +6,14 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:mobile": "wdio run wdio.conf.js --exclude safari",
"test:android": "wdio run wdio.conf.js --spec test/specs/*.spec.js",
"wdio": "wdio run ./wdio.conf.js"
},
"dependencies": {
"@azure/msal-browser": "^4.12.0",
"@bubblewrap/cli": "^1.23.0",
"@creativebulma/bulma-badge": "^1.0.1",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
@@ -23,6 +27,7 @@
"@vue-flow/core": "^1.43.1",
"@vueuse/core": "^12.7.0",
"@vueuse/head": "^2.0.0",
"@vueuse/integrations": "^13.7.0",
"animate.css": "^4.1.1",
"axios": "^1.7.8",
"bulma": "^1.0.2",
@@ -36,14 +41,17 @@
"chart.js": "^4.4.7",
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "^11.14.5",
"tesseract.js": "^6.0.1",
"toastify-js": "^1.12.0",
"vite-plugin-singlefile": "^2.1.0",
"vue": "^3.5.12",
"vue-cal": "^5.0.1-rc.20",
"vue-chartjs": "^5.3.2",
"vue-popperjs": "^2.3.0",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"vue-toast-notification": "^3.1.3",
"vue3-cookies": "^1.0.6",
@@ -55,8 +63,19 @@
"@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"@wdio/appium-service": "^9.20.0",
"@wdio/cli": "^9.20.0",
"@wdio/local-runner": "^9.20.0",
"@wdio/mocha-framework": "^9.20.0",
"@wdio/spec-reporter": "^9.20.0",
"@wdio/visual-service": "^9.0.0",
"appium": "^3.0.2",
"appium-xcuitest-driver": "^10.1.6",
"sass-embedded": "^1.81.0",
"vite": "^5.4.10",
"vite-plugin-vue-devtools": "^7.5.4"
"vite-plugin-pwa": "^1.0.2",
"vite-plugin-vue-devtools": "^7.5.4",
"wdio-vite-service": "^2.1.0",
"webdriverio": "^9.20.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.
+6 -3
View File
@@ -1,8 +1,7 @@
<script setup>
import Footer from "@/components/global/Footer.vue";
import { onMounted } from "vue";
import DefaultPageWrapper from "@/components/page/wrappers/DefaultPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { API_URL, IS_DEV } from "@/main.js";
// Set the favicon to the /favicon.ico file
import { useHead } from "@vueuse/head";
import favicon from '@/assets/favicon.ico';
@@ -12,7 +11,11 @@ useHead({
{ rel: 'icon', type: 'image/x-icon', href: favicon }
]
});
SessionUser.initiateOnAppStart();
onMounted(() => {
// Set the document title
//document.title = "My Vue App";
SessionUser.initiateOnAppStart();
});
</script>
<template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+592
View File
@@ -0,0 +1,592 @@
<script setup lang="ts">
// Import necessary modules
import { onMounted, ref } from 'vue';
// Define Image range for canvas elements
const imageRange = ref({
start: 24,
end: 24, // 54 images in total
prefix: '/src/assets/test/TRUCK_WASH_EXAMPLE_',
suffix: '.jpg'
});
import axios from "axios";
// Function to load images dynamically based on the range
function loadImages() {
const images = [];
for (let i = imageRange.value.start; i <= imageRange.value.end; i++) {
const imageName = `${imageRange.value.prefix}${i}${imageRange.value.suffix}`;
images.push(imageName);
}
return images;
}
// Ensure the image is loaded and displayed on the canvas
onMounted(() => {
const image = document.getElementById('image') as HTMLImageElement;
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context');
return;
}
image.onload = () => {
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
console.log('Image loaded and drawn on the canvas');
};
image.onerror = (error) => {
console.error('Error loading image:', error);
};
});
// Function to convert hex color to RGB
function hexToRgb(hex: string) {
const bigint = parseInt(hex.slice(1), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return { r, g, b };
}
// Function to convert RGB to hex color
function rgbToHex(r: number, g: number, b: number) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`;
}
// Function to check if two colors match within a tolerance
function isColorMatch(r: number, g: number, b: number, targetColor: { r: number; g: number; b: number }, tolerance: number) {
return (
Math.abs(r - targetColor.r) <= tolerance &&
Math.abs(g - targetColor.g) <= tolerance &&
Math.abs(b - targetColor.b) <= tolerance
);
}
type colorMatch = {
hex: string;
rgb: { r: number; g: number; b: number };
tolerance: number;
location: { x: number; y: number };
};
const interestingBoxes = ref<colorMatch[]>([]);
function findColor(hex: string) {
console.log(`Finding color for hex: ${hex}`);
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context');
return;
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const targetColor = hexToRgb(hex);
const tolerance = 50; // Adjust tolerance as needed
const foundColors = [];
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (isColorMatch(r, g, b, targetColor, tolerance)) {
const colorHex = rgbToHex(r, g, b);
foundColors.push({
hex: colorHex,
rgb: { r, g, b },
tolerance,
location: { x: (i / 4) % canvas.width, y: Math.floor((i / 4) / canvas.width) }
});
console.log(`Found color: ${colorHex} at (${(i / 4) % canvas.width}, ${Math.floor((i / 4) / canvas.width)})`);
}
}
interestingBoxes.value = foundColors;
console.log(`Found colors: ${foundColors}`);
}
const drawSquareAround = (x: number, y: number, size: number, color: string, canvasId: string) => {
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context');
return;
}
ctx.fillStyle = color;
ctx.fillRect(x - size / 2, y - size / 2, size, size);
console.log(`Drew square at (${x}, ${y}) with size ${size} and color ${color}`);
}
const generateSquareAround = (matches: colorMatch[], canvasId: string) => {
// Generate squares containing the found colors
const squares = matches.map(match => {
const size = 25; // Size of the square
const x = match.location.x - size / 2;
const y = match.location.y - size / 2;
return {
x,
y,
width: size,
height: size,
color: match.hex
};
});
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context');
return;
}
// Group squares by proximity and color
console.log(`Generating squares around matches: ${matches}`);
// Combine squares into groups based on proximity and color
const groupedSquares: { [key: string]: any[] } = {};
squares.forEach(square => {
const key = `${square.color}-${Math.floor(square.x / 50)}-${Math.floor(square.y / 50)}`;
if (!groupedSquares[key]) {
groupedSquares[key] = [];
}
groupedSquares[key].push(square);
});
// Merge squares that are overlapping or close together
Object.keys(groupedSquares).forEach(key => {
const group = groupedSquares[key];
if (group.length > 1) {
const mergedSquare = group.reduce((acc, square) => {
acc.x = Math.min(acc.x, square.x);
acc.y = Math.min(acc.y, square.y);
acc.width = Math.max(acc.width, square.x + square.width - acc.x);
acc.height = Math.max(acc.height, square.y + square.height - acc.y);
return acc;
}, { x: Infinity, y: Infinity, width: 0, height: 0, color: group[0].color });
groupedSquares[key] = [mergedSquare];
}
});
// Draw a rectangle around all combined squares
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
Object.values(groupedSquares).forEach(group => {
group.forEach(square => {
minX = Math.min(minX, square.x);
minY = Math.min(minY, square.y);
maxX = Math.max(maxX, square.x + square.width);
maxY = Math.max(maxY, square.y + square.height);
});
});
const combinedWidth = maxX - minX;
const combinedHeight = maxY - minY;
//ctx.fillStyle = '#FF0000'; // Color for the combined square
//ctx.fillRect(minX, minY, combinedWidth, combinedHeight);
console.log(`Drew combined square at (${minX}, ${minY}) with size ${combinedWidth}x${combinedHeight}`);
// Log the generated squares
console.log(`Generated squares: ${Object.values(groupedSquares)}`);
// Get the content in the combined square
const combinedCanvas = document.createElement('canvas');
combinedCanvas.width = combinedWidth;
combinedCanvas.height = combinedHeight;
const combinedCtx = combinedCanvas.getContext('2d');
if (!combinedCtx) {
console.error('Failed to get combined canvas context');
return;
}
combinedCtx.drawImage(canvas, minX, minY, combinedWidth, combinedHeight, 0, 0, combinedWidth, combinedHeight);
// Get the image data of the combined square
const combinedImageData = combinedCtx.getImageData(0, 0, combinedWidth, combinedHeight);
const combinedData = combinedImageData.data;
// Console.log the base64 representation of the combined square
const base64Combined = combinedCanvas.toDataURL();
console.log(`Base64 representation of combined square:`, base64Combined);
// Display the combined square on the canvas
const combinedCanvasElement = document.getElementById('combinedCanvas') as HTMLCanvasElement;
const combinedCanvasCtx = combinedCanvasElement.getContext('2d');
if (!combinedCanvasCtx) {
console.error('Failed to get combined canvas context');
return;
}
combinedCanvasElement.width = combinedWidth;
combinedCanvasElement.height = combinedHeight;
combinedCanvasCtx.clearRect(0, 0, combinedWidth, combinedHeight);
combinedCanvasCtx.drawImage(combinedCanvas, 0, 0, combinedWidth, combinedHeight);
// Make the combined canvas black and white
const imageData = combinedCanvasCtx.getImageData(0, 0, combinedWidth, combinedHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = avg; // Red
data[i + 1] = avg; // Green
data[i + 2] = avg; // Blue
}
combinedCanvasCtx.putImageData(imageData, 0, 0);
console.log('Combined square drawn on the combined canvas');
console.log('Combined square displayed on the canvas');
//attemptReadLicensePlate(combinedCanvasElement);
}
async function attemptReadLicensePlate(canvas: HTMLCanvasElement) {
try {
// Make the canvas black and white
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get canvas context for OCR');
return;
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = avg; // Red
data[i + 1] = avg; // Green
data[i + 2] = avg; // Blue
}
ctx.putImageData(imageData, 0, 0);
// Keep the full data URL format as required by the API
const base64Image = canvas.toDataURL('image/png');
const formData = new FormData();
formData.append('base64Image', base64Image);
formData.append('isOverlayRequired', 'true');
formData.append('scale', 'true');
formData.append('detectOrientation', 'true');
formData.append('language', 'eng');
formData.append('OCREngine', '2'); // Use OCR Engine 2 for better accuracy
const response = await axios.post('https://api.ocr.space/parse/image', formData, {
headers: {
'apikey': '', // TODO: Replace with backend API key
'Content-Type': 'multipart/form-data'
}
});
const res = response.data;
console.log('OCR Response:', res);
if (res.IsErroredOnProcessing) {
console.error('Error processing image:', res.ErrorMessage);
} else {
console.log('OCR Result:', res.ParsedResults[0].ParsedText);
}
} catch (error) {
console.error('Error during OCR request:', error);
if (error.response) {
console.error('Error response:', error.response.data);
}
}
}
type gridObject = {
id: string;
canvas: HTMLCanvasElement;
context: CanvasRenderingContext2D | null;
};
const gridObjects = ref<gridObject[]>([]);
// Load images dynamically
const images = loadImages();
// Log the loaded images
console.log('Loaded images:', images);
function generateGrids() {
gridObjects.value = images.map((image, index) => {
const canvas = document.createElement('canvas');
canvas.id = `grid-${index}`;
canvas.width = 1000;
canvas.height = 1000;
canvas.style.border = '1px solid #000';
const context = canvas.getContext('2d');
if (!context) {
console.error(`Failed to get context for canvas ${canvas.id}`);
return { id: canvas.id, canvas, context: null };
}
const imgElement = document.getElementById(`image-${index}`) as HTMLImageElement;
if (imgElement) {
imgElement.onload = () => {
context.drawImage(imgElement, 0, 0, canvas.width, canvas.height);
};
imgElement.onerror = (error) => {
console.error(`Error loading image ${imgElement.src}:`, error);
};
} else {
console.error(`Image element with id image-${index} not found`);
}
// Append the canvas to the grid container
const gridContainer = document.getElementById('grid-container-canvas');
if (gridContainer) {
gridContainer.appendChild(canvas);
} else {
console.error('Grid container not found');
}
// Add the scaled down canvas to the grid container
const scaledCanvas = document.createElement('canvas');
scaledCanvas.id = `scaled-${canvas.id}`;
scaledCanvas.width = 200;
scaledCanvas.height = 200;
scaledCanvas.className = 'scaled-canvas';
scaledCanvas.style.border = '1px solid #000';
const scaledContext = scaledCanvas.getContext('2d');
if (!scaledContext) {
console.error(`Failed to get context for scaled canvas ${scaledCanvas.id}`);
return { id: canvas.id, canvas, context: null };
}
const gridContainerPreview = document.getElementById('grid-container-canvas-preview');
if (gridContainerPreview) {
gridContainerPreview.appendChild(scaledCanvas);
} else {
console.error('Grid container preview not found');
}
return { id: canvas.id, canvas, context };
});
// Apply the images to the canvases
gridObjects.value.forEach(grid => {
if (grid.context) {
const imgElement = document.getElementById(`image-${grid.id.split('-')[1]}`) as HTMLImageElement;
if (imgElement) {
grid.context.drawImage(imgElement, 0, 0, grid.canvas.width, grid.canvas.height);
} else {
console.error(`Image element with id ${grid.id} not found`);
}
} else {
console.error(`Context for canvas ${grid.id} is null`);
}
// Find license plate in the canvas
const targetArea = findLicensePlate(grid);
addRedSquareAroundLicensePlate(grid, targetArea);
});
// Apply scaling to the canvases
gridObjects.value.forEach(grid => {
const scaledCanvas = document.getElementById(`scaled-${grid.id}`) as HTMLCanvasElement;
if (scaledCanvas) {
const scaledContext = scaledCanvas.getContext('2d');
if (scaledContext) {
scaledContext.clearRect(0, 0, scaledCanvas.width, scaledCanvas.height);
scaledContext.drawImage(grid.canvas, 0, 0, grid.canvas.width, grid.canvas.height, 0, 0, scaledCanvas.width, scaledCanvas.height);
} else {
console.error(`Failed to get context for scaled canvas ${scaledCanvas.id}`);
}
} else {
console.error(`Scaled canvas with id scaled-${grid.id} not found`);
}
});
}
const addRedSquareAroundLicensePlate = (grid: gridObject, targetArea: colorMatch[]) => {
const groupedSquares: { [key: string]: any[] } = {};
targetArea.forEach(square => {
const key = `${square.hex}-${Math.floor(square.location.x / 50)}-${Math.floor(square.location.y / 50)}`;
if (!groupedSquares[key]) {
groupedSquares[key] = [];
}
groupedSquares[key].push({
width: 75,
height: 75,
x: square.location.x - (75 / 2), // Center the square
y: square.location.y - (75 / 2), // Center the square
color: square.hex
});
});
// Merge squares that are overlapping or close together
Object.keys(groupedSquares).forEach(key => {
const group = groupedSquares[key];
if (group.length > 1) {
const mergedSquare = group.reduce((acc, square) => {
acc.x = Math.min(acc.x, square.x);
acc.y = Math.min(acc.y, square.y);
acc.width = Math.max(acc.width, square.x + square.width - acc.x);
acc.height = Math.max(acc.height, square.y + square.height - acc.y);
return acc;
}, { x: Infinity, y: Infinity, width: 0, height: 0, color: group[0].color });
groupedSquares[key] = [mergedSquare];
}
});
// Draw a rectangle around all combined squares
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
Object.values(groupedSquares).forEach(group => {
group.forEach(square => {
minX = Math.min(minX, square.x);
minY = Math.min(minY, square.y);
maxX = Math.max(maxX, square.x + square.width);
maxY = Math.max(maxY, square.y + square.height);
});
});
// If any value is still Infinity, it means no squares were found
if (minX === Infinity || minY === Infinity || maxX === -Infinity || maxY === -Infinity) {
console.warn(`No squares found in grid ${grid.id}`);
return;
}
const combinedWidth = maxX - minX;
const combinedHeight = maxY - minY;
// Draw the result, and return it as base64
const ctx = grid.context;
if (!ctx) {
console.error(`Failed to get context for canvas ${grid.id}`);
return;
}
//ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // Semi-transparent red
//ctx.fillRect(minX, minY, combinedWidth, combinedHeight);
// Cut the content in the combined square
const combinedCanvas = document.createElement('canvas');
combinedCanvas.width = combinedWidth;
combinedCanvas.height = combinedHeight;
const combinedCtx = combinedCanvas.getContext('2d');
if (!combinedCtx) {
console.error(`Failed to get context for combined canvas`);
return;
}
combinedCtx.drawImage(grid.canvas, minX, minY, combinedWidth, combinedHeight, 0, 0, combinedWidth, combinedHeight);
ctx.drawImage(combinedCanvas, 0, 0, combinedWidth, combinedHeight, minX, minY, combinedWidth, combinedHeight);
attemptReadLicensePlate(combinedCanvas);
};
const findLicensePlate = (object: gridObject) => {
const canvas = object.canvas;
const ctx = object.context;
if (!ctx) {
console.error('Failed to get canvas context for', object.id);
return;
}
// Get the image data from the canvas (Pixel data)
const ignoreMargin = {
top: 200,
bottom: 25,
left: 75,
right: 75
};
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Color the margin area white
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, ignoreMargin.top);
ctx.fillRect(0, canvas.height - ignoreMargin.bottom, canvas.width, ignoreMargin.bottom);
ctx.fillRect(0, ignoreMargin.top, ignoreMargin.left, canvas.height - ignoreMargin.top - ignoreMargin.bottom);
ctx.fillRect(canvas.width - ignoreMargin.right, ignoreMargin.top, ignoreMargin.right, canvas.height - ignoreMargin.top - ignoreMargin.bottom);
// Define the colors to search for
const targetColors = [
// Orange: #f2bc01
{ r: 242, g: 188, b: 1, tolerance: 50, include: true }, // Orange (1)
// Red (blacklist): #FF0001
{ r: 255, g: 0, b: 1, tolerance: 50, include: false }, // Red (blacklist)
// Blue (blacklist): #008AFF
{ r: 0, g: 128, b: 255, tolerance: 50, include: false }, // Blue (blacklist)
];
const foundColors: colorMatch[] = [];
// Loop through the image data to find matching colors
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
if (a === 0) continue; // Skip transparent pixels
for (const targetColor of targetColors) {
if (isColorMatch(r, g, b, targetColor, targetColor.tolerance)) {
// make sure to skip blacklisted colors
if (!targetColor.include) {
continue; // Skip blacklisted colors
}
// Check if the found color is within the ignore margin
const mx = (i / 4) % canvas.width;
const my = Math.floor((i / 4) / canvas.width);
if (mx < ignoreMargin.left || mx > canvas.width - ignoreMargin.right ||
my < ignoreMargin.top || my > canvas.height - ignoreMargin.bottom) {
continue; // Skip colors within the ignore margin
}
// Convert RGB to hex
const colorHex = rgbToHex(r, g, b);
// Add the found color to the list
foundColors.push({
hex: colorHex,
rgb: { r, g, b },
tolerance: targetColor.tolerance,
location: { x: (i / 4) % canvas.width, y: Math.floor((i / 4) / canvas.width) }
});
// Check if there's black text around the found color
const x = (i / 4) % canvas.width;
const y = Math.floor((i / 4) / canvas.width);
const textFound = data.slice(i - 100, i + 100).some((value, index) => {
if (index % 4 === 0) { // Check only the red channel
return value < 50; // Assuming black text is below a certain threshold
}
return false;
});
if (textFound) {
}
//drawSquareAround((i / 4) % canvas.width, Math.floor((i / 4) / canvas.width), 10, colorHex, object.id);
}
}
}
return foundColors;
};
</script>
<template>
<!-- Image display -->
<button @click="findColor('#f2bc01')">Find Colors</button>
<button @click="generateSquareAround(interestingBoxes)">Generate Squares</button>
<div class="boxes">
<div class="color-box" v-for="(color, index) in interestingBoxes" :key="index" :style="{ backgroundColor: color.hex }">
</div>
</div>
<canvas id="canvas" style="border:1px solid #000000;" width="500" height="500"></canvas>
<img id="image" style="display:none;" alt="Truck Image" src="./TRUCK_WASH_EXAMPLE_1.jpg" />
<canvas id="combinedCanvas" style=""></canvas>
<p>Image loaded and displayed on the canvas.</p>
<p>Check the console for any errors.</p>
<!-- Img hidden display -->
<div class="image-grid">
<template v-for="(image, index) in images" :key="index">
<img :src="image" alt="Truck Image" style="display:none;" :id="`image-${index}`" />
</template>
</div>
<!-- Canvas grid display (fullscale - hidden) -->
<div class="grid-container-canvas" id="grid-container-canvas"></div>
<button @click="generateGrids">Generate Grids</button>
<!-- Show the canvases scaled down to 200x200 -->
<div id="grid-container-canvas-preview" class="grid-container-canvas-preview"></div>
</template>
<style scoped>
.boxes {
display: flex;
flex-wrap: wrap;
gap: 1px;
}
.color-box {
width: 10px;
height: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
border: 1px solid #000;
border-radius: 5px;
}
#grid-container-canvas {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.grid-container-canvas canvas {
display: none; /* Hide the canvas elements initially */
}
.image-grid img {
display: none;
}
.grid-container-canvas-preview {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.grid-container-canvas-preview canvas {
width: 200px;
height: 200px;
border: 1px solid #000;
margin: 5px;
}
.grid-container-canvas-preview canvas:hover {
cursor: pointer;
transform: scale(1.05);
transition: transform 0.2s;
}
.grid-container-canvas-preview canvas:active {
transform: scale(0.95);
}
.grid-container-canvas-preview canvas:focus {
outline: none;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 942 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,31 @@
<script setup>
import { ref } from 'vue'
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import DepartmentLanesPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentLanesPagination.vue";
const redirect = (path) => {
window.location = path;
}
</script>
<template>
<div>
<PageTitle title="Afdelingsbaner" subtitle="Her kan du se en liste over alle afdelingsbaner i systemet">
<template #buttons>
<button class="button is-dark" @click="SessionUser.objects.department_lanes.showCreateObjectForm(() => loadList())">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>Opret ny afdelingsbane</span>
</button>
</template>
</PageTitle>
<DepartmentLanesPagination auto-load="true" />
</div>
</template>
<style scoped>
</style>
+20 -3
View File
@@ -56,7 +56,21 @@ const props = defineProps({
console.log(form);
}
}
}
},
/**
* Show a success alert when the form is successfully submitted
*/
showSuccessSubmitAlert: {
type: Boolean,
default: true
},
/**
* Show the reset button
*/
showResetButton: {
type: Boolean,
default: true
},
})
const default_validator_options = {
@@ -282,10 +296,13 @@ const onSubmit = async () => {
}
// Call the onSuccessfulSubmission function
props.onSuccessfulSubmission(response.data.data);
if (!props.showSuccessSubmitAlert) {
return;
}
Swal.fire(
'Success',
'The form has been submitted',
'success'
'success',
);
}).catch((error) => {
// Check if the error has a response (If it does not, it is an internal error)
@@ -717,7 +734,7 @@ const debugGetForm = () => {
<div class="field">
<p class="control buttons">
<button class="button is-link" type="submit">{{ meta.submit_button_text }}</button>
<button class="button is-light" type="reset">{{ SessionUser.objects.global.language.reset }}</button>
<button v-show="props.showResetButton" class="button is-light" type="reset">{{ SessionUser.objects.global.language.reset }}</button>
<button class="button is-warning is-inverted" @click="debugGetForm" v-if="IS_DEV">Debug</button>
</p>
</div>
+9 -3
View File
@@ -177,6 +177,10 @@ const isProductRestricted = () => {
}
};
const orderByOrderPriority = (addons) => {
return addons.sort((a, b) => a.product.order_priority - b.product.order_priority);
};
</script>
<template>
@@ -209,8 +213,9 @@ const isProductRestricted = () => {
<!-- Expandable product details -->
<div v-if="isSelected" class="column is-12-desktop">
<!-- Addons -->
<div class="buttons has-addons is-centered mt-3 mb-0 pl-6" v-if="addons.length > 0">
<div class="is-centered mt-3 mb-0 pl-6" v-if="addons.length > 0">
<template v-if="hasRestrictAdditionalServices()">
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap">
<button
class="button is-small is-fullwidth is-danger"
disabled
@@ -220,8 +225,9 @@ const isProductRestricted = () => {
</span>
<span>Ekstra ydelser er ikke tilladt</span>
</button>
</div>
</template>
<template v-for="addon in addons" :key="addon.id" v-else>
<template v-for="addon in orderByOrderPriority(addons)" :key="addon.id" v-else>
<template v-if="!isAddonRestricted(addon)">
<div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
@@ -241,7 +247,7 @@ const isProductRestricted = () => {
</button>
<!-- quantity (if any), Addon name, price -->
<button
class="button is-small"
class="button is-small is-fullwidth"
:class="{ 'is-link': isAddonSelected(props.id, addon.option_id), 'is-light': !isAddonSelected(props.id, addon.option_id) }"
@click="onBeforeToggleProductAddon(addon, isAddonSelected(props.id, addon.option_id), () => toggleProductAddon(props.id, addon.option_id))"
@mouseenter="showAddonPopper(addon, $event.target)"
+77 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { defineProps } from 'vue';
import {defineProps, defineEmits, onMounted, ref, watch} from 'vue';
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
const props = defineProps({
@@ -14,9 +15,43 @@ const props = defineProps({
defaultOpen: {
type: Boolean,
default: false
},
hideHeader: {
type: Boolean,
default: false
},
forceState: {
type: Boolean,
default: null
},
forceStateFooter: {
type: Boolean,
default: false
},
hasSelectedStyle: {
type: Boolean,
default: false
},
hasSelectionStyle: {
type: Boolean,
default: false
},
hasHoverEffect: {
type: Boolean,
default: false
},
isPlaceholder: {
type: Boolean,
default: false
}
});
import { onMounted, ref } from 'vue';
/**
* Emits:
* - toggle (boolean): Emitted when the card is toggled open or closed.
*/
const emit = defineEmits<{
(e: 'toggle', isOpen: boolean): void;
}>();
const isOpen = ref(props.defaultOpen);
onMounted(() => {
@@ -24,10 +59,24 @@ onMounted(() => {
isOpen.value = props.defaultOpen;
}
});
/** Function to emit toggle event when isOpen changes */
const toggle = () => {
if (!props.toggleable) return;
isOpen.value = !isOpen.value;
emit('toggle', isOpen.value);
};
/** Watch for changes in forceState prop to control isOpen */
watch(() => props.forceState, (newVal) => {
if (newVal !== null) {
isOpen.value = newVal;
}
});
</script>
<template>
<WhiteBox class="p-0" :class="{ 'is-white-box-loading': props.loading }">
<WhiteBox class="p-0" :class="{ 'is-white-box-loading': props.loading, 'is-selected': props.hasSelectedStyle, 'is-selection-style': props.hasSelectionStyle, 'has-custom-hover-effect': props.hasHoverEffect, 'is-placeholder': props.isPlaceholder }">
<div class="card is-radiusless">
<div class="is-overlay" v-if="props.loading">
<!-- Loading spinner -->
@@ -38,7 +87,7 @@ onMounted(() => {
</div>
</div>
<!-- Default slot for header -->
<div class="card-header" @click="props.toggleable ? isOpen = !isOpen : null" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header">
<div class="card-header is-radiusless" @click="toggle" :class="{ 'is-clickable': props.toggleable }" v-if="$slots.header && !props.hideHeader">
<slot name="header"></slot>
</div>
<!-- Default slot for any content -->
@@ -50,7 +99,7 @@ onMounted(() => {
</div>
</template>
<!-- Slot for footer -->
<template v-if="isOpen">
<template v-if="isOpen || props.forceStateFooter">
<div class="card-footer">
<slot name="footer"></slot>
</div>
@@ -80,4 +129,27 @@ onMounted(() => {
left: 50%;
transform: translate(-50%, -50%);
}
.is-selected {
border: 2px solid #00d1b2;
box-shadow: 0 0 10px rgba(0, 209, 178, 0.5);
transition: border 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
z-index: 1;
}
.is-selection-style {
/* Semi transparrent border */
border: 2px solid rgba(255, 221, 87, 0);
box-shadow: 0 0 10px rgba(255, 221, 87, 0);
transition: border 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
z-index: 1;
}
.has-custom-hover-effect:hover {
border: 2px solid #3273dc;
}
/* Transparent / Placeholder */
.is-placeholder {
opacity: 0.0;
pointer-events: none;
user-select: none;
cursor: not-allowed;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { defineProps } from 'vue';
import { defineProps, defineEmits } from 'vue';
import { useSlots } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
@@ -7,7 +7,10 @@ import Swal from "sweetalert2";
import { ref } from "vue";
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
const emit = defineEmits(['deleted']);
const emitDeleted = () => {
emit('deleted');
};
const props = defineProps({
icon: {
type: String,
@@ -170,6 +173,16 @@ const redirectDepartmentOrderPage = async (orderId) => {
:click-action="() => SessionUser.functions.redirectTo.user('/orders/' + props.order_id, true)"
:disabled="false"
/>
<!-- Delete the order -->
<action-settings-wheel-item
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
label="Slet transaktion"
icon="fas fa-trash-alt"
:click-action="() => SessionUser.objects.orders.functions.showDeleteConfirmationModal(props.order_id, () => {
emitDeleted();
})"
:disabled="false"
/>
<!-- Change the customer (of the order) -->
<action-settings-wheel-item
v-if="SessionUser.canAccessSuperUser()"
@@ -34,7 +34,10 @@ const props = defineProps({
showToLabel?: boolean,
showSelectionValidity?: boolean,
showUpdateButton?: boolean,
showMonthSelector?: boolean, // Whether to show the month selector
showDailySelector?: boolean, // Whether to show the daily selector
showWeeklySelector?: boolean, // Whether to show the weekly selector
showMonthSelector?: boolean, // Whether to show the month select
showMultipleMonthWarning?: boolean, // Whether to show a warning if the selection spans multiple months
showYearSelector?: boolean, // Whether to show the year selector
},
required: false,
@@ -46,6 +49,10 @@ const props = defineProps({
showUpdateButton: true, // Whether to show the update button
}),
},
reverseLevelOrder: {
type: Boolean,
default: false, // Whether to reverse the order of left and right levels
},
isDisabled: {
type: Boolean,
default: false,
@@ -76,7 +83,10 @@ const visibility = computed(() => {
showSelectionValidity: true,
showUpdateButton: true,
showMonthSelector: true,
showMultipleMonthWarning: true,
showYearSelector: true,
showDailySelector: false,
showWeeklySelector: false,
// Override with props visibility settings
...props.visibility,
};
@@ -186,11 +196,135 @@ const isMultiMonthSelection = computed(() => {
return startDate.value.getMonth() !== endDate.value.getMonth() || startDate.value.getFullYear() !== endDate.value.getFullYear();
});
type shortcut = {
label: string,
getRange: () => { startDate: Date, endDate: Date },
};
const dailyShortcuts: shortcut[] = [
// Today
{
label: SessionUser.objects.global.language.text.today,
getRange: () => {
const today = new Date();
return {
startDate: new Date(today.setHours(23, 59, 59, 999)),
endDate: new Date(today.setHours(23, 59, 59, 999)),
};
},
},
// Yesterday
{
label: SessionUser.objects.global.language.text.yesterday,
getRange: () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return {
startDate: new Date(yesterday.setHours(23, 59, 59, 999)),
endDate: new Date(yesterday.setHours(23, 59, 59, 999)),
};
},
},
];
const weeklyShortcuts: shortcut[] = [
// This week
{
label: SessionUser.objects.global.language.text.this_week,
getRange: () => {
const today = new Date();
const firstDayOfWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1)); // Assuming week starts on Monday
const lastDayOfWeek = new Date(firstDayOfWeek);
lastDayOfWeek.setDate(firstDayOfWeek.getDate() + 6);
return {
startDate: new Date(firstDayOfWeek.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfWeek.setHours(23, 59, 59, 999)),
};
},
},
// Last week
{
label: SessionUser.objects.global.language.text.last_week,
getRange: () => {
const today = new Date();
const firstDayOfLastWeek = new Date(today.setDate(today.getDate() - today.getDay() - 6)); // Assuming week starts on Monday
const lastDayOfLastWeek = new Date(firstDayOfLastWeek);
lastDayOfLastWeek.setDate(firstDayOfLastWeek.getDate() + 6);
return {
startDate: new Date(firstDayOfLastWeek.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastWeek.setHours(23, 59, 59, 999)),
};
},
},
// This month
{
label: SessionUser.objects.global.language.text.this_month,
getRange: () => {
const today = new Date();
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return {
startDate: new Date(firstDayOfThisMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfThisMonth.setHours(23, 59, 59, 999)),
};
},
},
// Last month
{
label: SessionUser.objects.global.language.text.last_month,
getRange: () => {
const today = new Date();
const firstDayOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const lastDayOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
return {
startDate: new Date(firstDayOfLastMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastMonth.setHours(23, 59, 59, 999)),
};
},
},
// Same week last year
{
label: SessionUser.objects.global.language.text.same_week_last_year,
getRange: () => {
const today = new Date();
const firstDayOfThisWeek = new Date(today.setDate(today.getDate() - today.getDay() + 1)); // Assuming week starts on Monday
const firstDayOfLastYearSameWeek = new Date(firstDayOfThisWeek);
firstDayOfLastYearSameWeek.setFullYear(firstDayOfThisWeek.getFullYear() - 1);
const lastDayOfLastYearSameWeek = new Date(firstDayOfLastYearSameWeek);
lastDayOfLastYearSameWeek.setDate(firstDayOfLastYearSameWeek.getDate() + 6);
return {
startDate: new Date(firstDayOfLastYearSameWeek.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastYearSameWeek.setHours(23, 59, 59, 999)),
};
},
},
// Same month last year
{
label: SessionUser.objects.global.language.text.same_month_last_year,
getRange: () => {
const today = new Date();
const firstDayOfThisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const lastDayOfThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
const firstDayOfLastYearSameMonth = new Date(firstDayOfThisMonth);
firstDayOfLastYearSameMonth.setFullYear(firstDayOfThisMonth.getFullYear() - 1);
const lastDayOfLastYearSameMonth = new Date(lastDayOfThisMonth);
lastDayOfLastYearSameMonth.setFullYear(lastDayOfThisMonth.getFullYear() - 1);
return {
startDate: new Date(firstDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
endDate: new Date(lastDayOfLastYearSameMonth.setHours(23, 59, 59, 999)),
};
},
},
];
const shortcuts = ref<shortcut[]>([
...(visibility.value.showDailySelector ? dailyShortcuts : []),
...(visibility.value.showWeeklySelector ? weeklyShortcuts : []),
]);
</script>
<template>
<div class="level is-mobile is-align-items-center">
<div class="level-left">
<!-- Start Date Input -->
<div class="level-item" v-if="visibility.showStartDate">
<input
type="date"
@@ -221,18 +355,53 @@ const isMultiMonthSelection = computed(() => {
</div>
<!-- Month and Year selectors -->
<div class="level-item" v-if="visibility.showMonthSelector">
<select
class="input"
v-model="currentMonthSelection"
@change="setMonth(currentMonthSelection)"
v-bind:disabled="props.isDisabled || props.isReadonly"
>
<!-- Default option: Custom -->
<option value="" disabled selected>Select Month</option>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ SessionUser.functions.ucFirst(month.label) }}
</option>
</select>
<div class="field has-addons">
<p class="control">
<span class="select">
<select
class="input"
v-model="currentMonthSelection"
@change="setMonth(currentMonthSelection)"
v-bind:disabled="props.isDisabled || props.isReadonly"
>
<!-- Default option: Custom -->
<option value="" disabled selected>Select Month</option>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ SessionUser.functions.ucFirst(month.label) }}
</option>
</select>
</span>
</p>
<p class="control">
<a class="button is-static">
{{ SessionUser.objects.global.language.text.month }}
</a>
</p>
</div>
</div>
<div class="level-item" v-if="visibility.showYearSelector">
<div class="field has-addons">
<p class="control">
<span class="select">
<select
class="input"
:value="startDate.getFullYear()"
@change="(e) => { const year = parseInt(e.target.value); if (!isNaN(year)) { const newStartDate = new Date(startDate); newStartDate.setFullYear(year); startDate = newStartDate; const newEndDate = new Date(endDate); newEndDate.setFullYear(year); endDate = newEndDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }"
v-bind:disabled="props.isDisabled || props.isReadonly"
>
<!-- Generate a range of years from current year - 10 to current year + 10 -->
<option v-for="year in Array.from({ length: 21 }, (_, i) => new Date().getFullYear() - 10 + i)" :key="year" :value="year">
{{ year }}
</option>
</select>
</span>
</p>
<p class="control">
<a class="button is-static">
{{ SessionUser.objects.global.language.text.year }}
</a>
</p>
</div>
</div>
<!-- Warnings and messages -->
<div class="level-item" v-if="visibility.showMonthSelector">
@@ -242,12 +411,12 @@ const isMultiMonthSelection = computed(() => {
Invalid selection! Start date must be before end date.
</span>
<!-- Warning if the selection stretches across multiple months -->
<span class="label has-text-danger" v-else-if="isMultiMonthSelection">
<span class="label has-text-danger" v-else-if="isMultiMonthSelection && visibility.showMultipleMonthWarning">
<i class="fas fa-exclamation-triangle"></i>
Selection spans multiple months! This may lead to unexpected results.
</span>
<!-- Warning if it's not an entire month -->
<span class="label has-text-warning" v-else-if="!isCurrentSelectionEntireMonth">
<span class="label has-text-warning" v-else-if="!isCurrentSelectionEntireMonth && visibility.showMultipleMonthWarning">
<i class="fas fa-exclamation-triangle"></i>
Selection is not an entire month <span class="has-text-weight-bold is-clickable" @click="setMonth(currentMonthSelection)">Click to set entire month</span>
</span>
@@ -255,7 +424,8 @@ const isMultiMonthSelection = computed(() => {
<!-- Slot for left side content -->
<slot name="left"></slot>
</div>
<div class="level-right" v-if="visibility.showSelectionValidity">
<div class="level-right"
v-if="visibility.showSelectionValidity">
<slot name="right"></slot>
<div class="level-item" v-if="visibility.showUpdateButton">
<button
@@ -268,6 +438,25 @@ const isMultiMonthSelection = computed(() => {
</div>
</div>
</div>
<div class="navbar is-white">
<div class="navbar-menu">
<!-- Date shortcuts -->
<div class="navbar-start">
<div class="navbar-item">
<div class="buttons">
<button v-for="(shortcut, index) in shortcuts"
:key="index"
@click="() => { const range = shortcut.getRange(); startDate = range.startDate; endDate = range.endDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } }"
:disabled="props.isDisabled || props.isReadonly"
class="button is-light" :class="{ 'is-info': (startDate.toISOString().split('T')[0] === shortcut.getRange().startDate.toISOString().split('T')[0]) && (endDate.toISOString().split('T')[0] === shortcut.getRange().endDate.toISOString().split('T')[0]) }"
>
{{ shortcut.label }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@@ -13,6 +13,13 @@ const props = defineProps({
default: () => {
return SessionUser.canAccessSuperUser();
}
},
// The component wrapper is used to wrap the table cell in a component. By default, it is null, which means it will use the <td> element.
componentWrapper: {
type: String,
default: () => {
return null; // Example: <td>
}
}
});
@@ -45,7 +52,7 @@ const isLoading = ref(false);
</script>
<template>
<td>
<component :is="props.componentWrapper ? props.componentWrapper : 'td'">
<span v-if="!props.virtualColumn">{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column] }}</span>
<span v-else>{{ parseFunction ? parseFunction(props.object) : props.object }}</span>
<span class="ml-2" v-if="props.permissionCheckFunction()">
@@ -55,7 +62,7 @@ const isLoading = ref(false);
</span>
</button>
</span>
</td>
</component>
</template>
<style scoped>
@@ -0,0 +1,300 @@
<script setup lang="ts">
import { defineProps, ref, computed, defineEmits } from 'vue';
import ControlField from "@/components/viewport/elements/controls/fields/ControlField.vue";
import ControlFieldInput from "@/components/viewport/elements/controls/fields/ControlFieldInput.vue";
/** Define the props for the component */
const props = defineProps({
// The default selected date
defaultDate: {
type: Date,
required: false,
default: () => new Date()
},
// The shown date (Used to determine what month to show)
shownDate: {
type: Date,
required: false,
default: () => new Date()
},
// The minimum date that can be selected
// This is used to prevent showing dates prior to this date
// In cases like making a booking.
minDate: {
type: Date,
required: false,
default: null
},
// The maximum date that can be selected
// This is used to prevent showing dates after this date
// In cases like making a booking.
maxDate: {
type: Date,
required: false,
default: null
},
// The locale to use for formatting the dates
// This default to the user's browser locale
locale: {
type: String,
required: false,
default: navigator.language || 'da-DK'
},
// The first day of the week (0 = Sunday, 1 = Monday, etc.)
// Currently this does not change the order of the days in the week
// But is added to prepare for multi-locale support
firstDayOfWeek: {
type: Number,
required: false,
default: 1,
},
// This is an optional function, used to determine if the selection is valid
// Prior to emitting it.
validationFunction: {
type: Function,
required: false,
default: () => true,
},
// Determines if the date picker should include time selection
timeSelection: {
type: Boolean,
required: false,
default: true
},
// Determines the available time slots.
availableTimeSlots: {
type: Object,
required: false,
default: () => [],
}
});
/** Define the reactive state */
// The currently selected date
const selectedDate = ref<Date>(props.defaultDate);
// The date that is currently shown in the calendar
const shownDate = ref<Date>(props.shownDate);
// The current locale
const locale = ref<string>(props.locale);
// The first day of the week
const firstDayOfWeek = ref<number>(props.firstDayOfWeek);
// The minimum date that can be selected
const minDate = ref<Date | null>(props.minDate);
// The maximum date that can be selected
const maxDate = ref<Date | null>(props.maxDate);
// The validation function
const validationFunction = ref<Function>(props.validationFunction);
/** Define the exposes */
const emits = defineEmits(['onSelection', 'onBeforeSelection']);
/** Functions */
// Function to check if a date is selectable
const isDateSelectable = (date: Date): boolean => {
// Check if the date is before the minimum date
if (minDate.value && date < minDate.value) {
return false;
}
// Check if the date is after the maximum date
return !(maxDate.value && date > maxDate.value);
}
// Navigation functions
const navigateToMonthRelative = (modifier: Number): void => {
const newDate = new Date(shownDate.value);
newDate.setMonth(newDate.getMonth() + Number(modifier));
shownDate.value = newDate;
};
const navigateToMonthAbsolute = (month: Number): void => {
const newDate = new Date(shownDate.value);
newDate.setMonth(Number(month));
shownDate.value = newDate;
};
// Date selection function
const selectDate = (date: Date): void => {
// Check if the date is valid
if (isDateSelectable(date)) {
// Emit the before selection event
emits('onBeforeSelection', date);
// Check if the selection is valid
if (validationFunction.value(date)) {
// Set the selected date
selectedDate.value = date;
// Emit the selection event
emits('onSelection', date);
}
}
};
/** Computed properties */
// The month name of the shown date
const monthName = computed<string>(() => {
return shownDate.value.toLocaleString(locale.value, { month: 'long' });
});
// The year of the shown date
const year = computed<number>(() => {
return shownDate.value.getFullYear();
});
// The days in the shown month
const daysInMonth = computed<number>(() => {
return new Date(shownDate.value.getFullYear(), shownDate.value.getMonth() + 1, 0).getDate();
});
// The first day of the shown month (0 = Sunday, 1 = Monday, etc.)
const firstDayOfMonth = computed<number>(() => {
return new Date(shownDate.value.getFullYear(), shownDate.value.getMonth(), 1).getDay();
});
// The last day of the shown month (0 = Sunday, 1 = Monday, etc.)
const lastDayOfMonth = computed<number>(() => {
return new Date(shownDate.value.getFullYear(), shownDate.value.getMonth() + daysInMonth.value - 1).getDay();
});
// Function to get the first character of a string
const getFirstCharacter = (str: string): string => {
return str.charAt(0);
};
// Function to get the "filler" days from the previous month
const getFillerDays = (): number[] => {
const fillerDays: number[] = [];
const prevMonth = new Date(shownDate.value.getFullYear(), shownDate.value.getMonth() - 1, 1);
const daysInPrevMonth = new Date(prevMonth.getFullYear(), prevMonth.getMonth() + 1, 0).getDate();
for (let i = firstDayOfMonth.value - 1; i >= 0; i--) {
fillerDays.push(daysInPrevMonth - i);
}
// Reverse the array to get the correct order
fillerDays.reverse();
return fillerDays;
};
// Function to get the "filler" days from the next month
// This checks how many days are needed to fill the last row
// and returns that many days from the next month
const getFillerDaysNextMonth = (): number[] => {
const fillerDays: number[] = [];
const nextMonth = new Date(shownDate.value.getFullYear(), shownDate.value.getMonth() + 1, 1);
const daysInNextMonth = new Date(nextMonth.getFullYear(), nextMonth.getMonth() + 1, 0).getDate();
const totalDaysShown = daysInMonth.value + firstDayOfMonth.value;
const daysNeeded = ((7 - (totalDaysShown % 7)) % 7);
// If there's 7 days needed, it means the month ends perfectly
// and we don't need to add any filler days
if (daysNeeded === 7 || daysNeeded === 0) {
return fillerDays;
}
for (let i = 1; i <= daysNeeded + 1; i++) {
fillerDays.push(i);
}
return fillerDays;
};
// Function to check if filler days are needed from the next month
const needsFillerDaysNextMonth = computed<boolean>(() => {
const totalDaysShown = daysInMonth.value + firstDayOfMonth.value;
return (totalDaysShown % 7) !== 0;
});
</script>
<template>
<!-- Month & navigation -->
<nav class="level is-mobile is-vcentered">
<div class="level-left">
<div class="level-item">
<!-- Currently shown month -->
<span class="is-size-3 has-text-weight-semibold mr-2">
{{ monthName }}
</span>
</div>
</div>
<div class="level-right">
<div class="level-item">
<!-- Buttons -->
<div class="buttons is-gapless">
<!-- Previous month button -->
<button class="button is-medium is-text no-underline" @click="navigateToMonthRelative(-1)">
<span class="icon is-large">
<i class="fas fa-chevron-left fa-x2l"></i>
</span>
</button>
<!-- Next month button -->
<button class="button is-text is-medium no-underline" @click="navigateToMonthRelative(1)">
<span class="icon is-large">
<i class="fas fa-chevron-right fa-x2l"></i>
</span>
</button>
</div>
</div>
</div>
</nav>
<!-- Table -->
<table class="table is-fullwidth is-hoverable row-borderless">
<thead>
<tr>
<!-- Weekday names -->
<th v-for="(day, index) in 7" :key="index" class="has-text-centered is-narrow px-0">
{{ getFirstCharacter(new Date(new Date().getFullYear(), 0, index + 1).toLocaleDateString(locale, { weekday: 'short' })) }}
</th>
</tr>
</thead>
<!-- Days -->
<tbody>
<template v-for="week in Math.ceil((daysInMonth + firstDayOfMonth) / 7)" :key="week">
<tr class="row-borderless" v-if="week <= Math.ceil((daysInMonth + firstDayOfMonth) / 7)">
<td v-for="day in 7" :key="day" class="has-text-centered is-narrow px-0" :class="{ 'has-background-light': (week - 1) * 7 + (day + 1) === new Date().getDate() && shownDate.getMonth() === new Date().getMonth() && shownDate.getFullYear() === new Date().getFullYear() }">
<!-- Only show days that are in the current month -->
<div v-if="(week - 1) * 7 + (day + 1) > firstDayOfMonth && (week - 1) * 7 + (day + 1) <= daysInMonth + firstDayOfMonth">
<a href="#" class="button is-text no-underline" :class="{
'is-disabled': !isDateSelectable(new Date(shownDate.getFullYear(), shownDate.getMonth(), (week - 1) * 7 + day - firstDayOfMonth)),
'is-primary': selectedDate.getDate() === (week - 1) * 7 + day - firstDayOfMonth &&
selectedDate.getMonth() === shownDate.getMonth() &&
selectedDate.getFullYear() === shownDate.getFullYear()
}" @click.prevent="selectDate(new Date(shownDate.getFullYear(), shownDate.getMonth(), (week - 1) * 7 + day - firstDayOfMonth))">
{{ (week - 1) * 7 + (day + 1) - firstDayOfMonth }}
</a>
</div>
<!-- Filler days from previous month -->
<div v-else-if="(week - 1) * 7 + (day + 1) <= firstDayOfMonth">
<a href="#" class="button is-text has-text-grey-light no-underline" @click.prevent="selectDate(new Date(shownDate.getFullYear(), shownDate.getMonth() - 1, getFillerDays()[firstDayOfMonth - ((week - 1) * 7 + day + 1)]))">
{{ getFillerDays()[firstDayOfMonth - ((week - 1) * 7 + day + 1)] }}
</a>
</div>
<!-- Filler days from next month -->
<div v-else-if="(week - 1) * 7 + (day + 1) > daysInMonth + firstDayOfMonth && needsFillerDaysNextMonth">
<a href="#" class="button is-text has-text-grey-light no-underline" @click.prevent="selectDate(new Date(shownDate.getFullYear(), shownDate.getMonth() + 1, getFillerDaysNextMonth()[(week - 1) * 7 + (day + 1) - (daysInMonth + firstDayOfMonth) - 1]))">
{{ getFillerDaysNextMonth()[(week - 1) * 7 + (day + 1) - (daysInMonth + firstDayOfMonth) - 1] }}
</a>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Time selection (if enabled) -->
<ControlField class="select">
<ControlFieldInput :select="true">
<!-- If there's no time selection, show a disabled option -->
<option v-if="!availableTimeSlots || availableTimeSlots.length === 0" disabled selected>
No time slots available
</option>
<option v-else disabled selected>
Select a time
</option>
<!-- Time slots -->
<option v-for="(time, index) in availableTimeSlots" :key="index" :value="time">
{{ time }}
</option>
</ControlFieldInput>
</ControlField>
</template>
<style scoped>
.no-underline {
text-decoration: none;
}
.is-primary {
background-color: #000000;
border-radius: 0;
color: white;
}
.is-primary:hover {
background-color: #4a4a4a;
border-radius: 0;
color: white;
}
.row-borderless td, .row-borderless th {
border: none;
}
</style>
@@ -5,7 +5,38 @@ import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDe
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
import PosDepartmentStep4 from "@/components/displays/department/pos/steps/PosDepartmentStep4.vue";
import ViewportResponsiveWrapper from "@/components/viewport/conditions/elements/ViewportResponsiveWrapper.vue";
import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue";
import PosDepartmentStepMobile2 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue";
import { pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import PosDepartmentStepMobilePopupRenderer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue";
const debugVehicles = () => {
pos.vehicles.select(
1,
{
reg: "AB123CD",
status: VerifiedCustomer
}
);
pos.vehicles.select(
2,
{
reg: "EF456GH",
status: UnknownCustomer
}
);
pos.vehicles.select(
3,
{
reg: "IJ789KL",
status: BookedCustomer
}
);
};
setDepartment();
// Debug:
@@ -42,12 +73,21 @@ if (window.location.search.includes("customer_id=")) {
<template>
<div>
<div class="mt-5">
<PosDepartmentStep1 v-if="getCurrentStep() === 1" />
<PosDepartmentStep2 v-if="getCurrentStep() === 2 && (getOrderId() !== null)" />
<PosDepartmentStep3 v-if="getCurrentStep() === 3 && (getOrderId() !== null)" />
<PosDepartmentStep4 v-if="getCurrentStep() === 4 && (getOrderId() !== null)" />
</div>
<ViewportResponsiveWrapper>
<template #desktop>
<div class="mt-5">
<PosDepartmentStep1 v-show="getCurrentStep() === 1" />
<PosDepartmentStep2 v-show="getCurrentStep() === 2 && (getOrderId() !== null)" />
<PosDepartmentStep3 v-show="getCurrentStep() === 3 && (getOrderId() !== null)" />
<PosDepartmentStep4 v-show="getCurrentStep() === 4 && (getOrderId() !== null)" />
</div>
</template>
<template #mobile>
<PosDepartmentStepMobilePopupRenderer/>
<PosDepartmentStepMobile1 v-if="getCurrentStep() === 1" />
<PosDepartmentStepMobile2 v-else-if="getCurrentStep() === 2 && (getOrderId() !== null)" />
</template>
</ViewportResponsiveWrapper>
</div>
</template>
@@ -2,9 +2,9 @@
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { showEditOrderItemForm } from "@/components/forms/superUser/editOrderItemForm.vue";
import { removeOrderItem } from "@/components/shop/OrdersItems.vue";
import {ref, watch, defineProps, defineSlots, useSlots, renderSlot } from 'vue';
const props = defineProps(['orderItems', 'orderId', 'loadOrderItems', 'editPossible', 'isLarge']);
import {ref, watch, defineProps, defineSlots, useSlots, renderSlot, defineEmits } from 'vue';
const props = defineProps(['orderItems', 'orderId', 'loadOrderItems', 'editPossible', 'isLarge', 'reg_1', 'reg_2', 'reg_3', 'customer_name']);
const emits = defineEmits(['update:orderItems']);
import { createPopper } from '@popperjs/core';
import { showPopper, popper, showPopperWithContent, removePopperIfOpen, popperBox} from "@/components/displays/PopperDefault.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -23,6 +23,11 @@ const panel_tabs = ref([
icon: 'fas fa-shopping-cart',
name: 'Kurv',
active: true,
},
{
icon: 'fas fa-paperclip',
name: 'Bilag',
active: false,
}
]);
// Check if the size is large
@@ -99,7 +104,32 @@ const toggleIsSelected = () => {
};
const expandIcon = ref(null);
loadOrder();
if (props.orderId === -1) {
registration_numbers.value = {
1: props.reg_1,
2: props.reg_2,
3: props.reg_3,
};
customer_name.value = props.customer_name;
watch(() => props.orderId, (newOrderId) => {
if (newOrderId !== -1) {
loadOrder();
}
});
} else {
loadOrder();
}
const removeItem = async (id) => {
// If the order id is -1, emit the updated order items
if (props.orderId === -1) {
const updatedOrderItems = props.orderItems.filter((orderItem) => orderItem.id !== id);
emits('update:orderItems', updatedOrderItems);
return;
}
// Otherwise remove the order item
await removeOrderItem(id);
};
</script>
<template>
@@ -116,7 +146,7 @@ loadOrder();
{{ customer_name }}
</SpanSkeleton>
<p class="is-7 subtitle mb-2">Transaktion #{{ orderId }}</p><slot name="headingcenter"></slot><span class="is-float-right"><slot></slot></span>
<p class="panel-tabs" v-if="panel_tabs.length > 1">
<p class="panel-tabs is-hidden-touch" v-if="panel_tabs.length > 1">
<a v-for="(tab, index) in panel_tabs" :key="index" :class="{'is-active': tab.active}" @click="panel_tabs.forEach(tab => tab.active = false); tab.active = true" >
<!-- If the tab has an icon, show it -->
<span v-if="tab.icon" class="icon is-small">
@@ -125,6 +155,23 @@ loadOrder();
{{ tab.name }}
</a>
</p>
<!-- Mobile nav -->
<div class="is-hidden-desktop mt-2">
<div class="buttons is-centered is-mobile">
<button
v-for="(tab, index) in panel_tabs"
:key="index"
:class="['button', 'is-fullwidth', tab.active ? 'is-link' : 'is-light']"
@click="panel_tabs.forEach(tab => tab.active = false); tab.active = true"
>
<!-- If the tab has an icon, show it -->
<span v-if="tab.icon" class="icon is-small">
<i :class="tab.icon"></i>
</span>
<span>{{ tab.name }}</span>
</button>
</div>
</div>
<!-- Cart -->
<div v-if="panel_tabs[0].active">
<div class="panel-block">
@@ -229,7 +276,7 @@ loadOrder();
label="Slet"
icon="fas fa-trash"
template="danger"
@click="hideOrderItemWhileRemoving(orderItem.id);removeOrderItem(orderItem.id).then(() => loadOrderItems())"
@click="hideOrderItemWhileRemoving(orderItem.id);removeItem(orderItem.id).then(() => loadOrderItems())"
/>
<ActionSettingsWheelItem
label="Rediger"
@@ -366,15 +413,22 @@ loadOrder();
<slot name="bottom"></slot>
</div>
</div>
<!-- Advanced -->
<!-- Attachments -->
<div v-if="panel_tabs[1] && panel_tabs[1].active">
<div class="panel-block is-fullwidth">
<!-- Attachments slot -->
<slot name="attachments"></slot>
</div>
</div>
<!-- Advanced -->
<div v-if="panel_tabs[2] && panel_tabs[2].active">
<div class="panel-block is-fullwidth">
<!-- Other slot -->
<slot name="other"></slot>
</div>
</div>
<!-- Details -->
<div v-if="panel_tabs[2] && panel_tabs[2].active">
<div v-if="panel_tabs[3] && panel_tabs[3].active">
<div class="panel-block is-fullwidth">
<!-- Details slot -->
<slot name="details"></slot>
@@ -130,7 +130,19 @@ const attributes = ref([
prop: 'onlyTankCleaning',
description: 'Når denne er sat, bliver kunden kategoriseret som "Tank cleaning" kunde.',
icon: 'fas fa-cogs'
}
},
{
name: 'Vises priser på kunde og bookingside',
prop: 'showPricesOnBookingPage',
description: 'Når denne er sat, bliver kunden vist priser på kunde og bookingsiden.',
icon: 'fas fa-cogs'
},
{
name: 'Bruger PO nummer',
prop: 'usePONumbers',
description: 'Når denne er sat, kan kunden angive et PO nummer på ordrer.',
icon: 'fas fa-cogs'
},
]);
const shortcuts = ref([
@@ -2,7 +2,8 @@
import placeholder from "/src/assets/piktogrammer/placeholder.png";
import productImage01 from "/src/assets/piktogrammer/01.png";
import productImage02 from "/src/assets/piktogrammer/02.png";
import productImage03 from "/src/assets/piktogrammer/03.png";
import productImage03 from "/src/assets/piktogrammer/03.png"; // Replaced by 3
import productImage3 from "/src/assets/piktogrammer/3.png"; // Sættevognstræk
import productImage04 from "/src/assets/piktogrammer/04.png";
import productImage05 from "/src/assets/piktogrammer/05.png";
import productImage06 from "/src/assets/piktogrammer/06.png";
@@ -13,6 +14,15 @@ import productImage10 from "/src/assets/piktogrammer/10.png";
import productImage12 from "/src/assets/piktogrammer/12.png";
import productImage13 from "/src/assets/piktogrammer/13.png";
import productImage14 from "/src/assets/piktogrammer/14.png";
import productImage16 from "/src/assets/piktogrammer/16.png"; // Autocamper / Campingvogn
import productImage18 from "/src/assets/piktogrammer/18.png";
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
import productImage44 from "/src/assets/piktogrammer/44.png"; // "Christmas Tree"
import productImage45 from "/src/assets/piktogrammer/45.png"; // "Christmas Tree" - Sticker roll
import productImage46 from "/src/assets/piktogrammer/46.png"; // Kødkroge, vask
import productImage47 from "/src/assets/piktogrammer/47.png"; // Kødkroge med mærker, vask
import productImage48 from "/src/assets/piktogrammer/48.png"; // E2
import productImage56 from "/src/assets/piktogrammer/56.png"; // Safety Seal
// Get the picture of the product
export const getPicture = (piktogram) => {
// If the image is not defined, return a placeholder image
@@ -29,8 +39,10 @@ export const getPicture = (piktogram) => {
return productImage01;
case "02":
return productImage02;
case "03":
case "03":// Replaced by 3
return productImage03;
case "3": // Sættevognstræk
return productImage3;
case "04":
return productImage04;
case "05":
@@ -51,6 +63,24 @@ export const getPicture = (piktogram) => {
return productImage13;
case "14":
return productImage14;
case "16":
return productImage16;
case "18":
return productImage18;
case "43":
return productImage43;
case "44":
return productImage44;
case "45":
return productImage45;
case "46":
return productImage46;
case "47":
return productImage47;
case "48":
return productImage48;
case "56":
return productImage56;
default:
return placeholder;
}
@@ -99,7 +99,7 @@ const calculateTotalPrice = (price, quantity) => {
<tfoot>
<!-- Tax -->
<tr v-if="props.taxPercentage > 0">
<td colspan="4">Tax ({{ props.taxPercentage }}%)</td>
<td colspan="4">Moms ({{ props.taxPercentage }}%)</td>
<td style="text-align: right;" v-if="props.isLoading">
<!-- Skeleton-lines 1 row -->
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
@@ -107,7 +107,7 @@ const calculateTotalPrice = (price, quantity) => {
<td style="text-align: right;" v-else>{{ SessionUser.functions.currency.toLocal(calculateTax(props.orderItems.reduce((acc, orderItem) => acc + orderItem.price * orderItem.quantity, 0))) }}</td>
</tr>
<tr v-if="props.taxPercentage > 0">
<td colspan="4">Subtotal</td>
<td colspan="4">Total ekskl. moms</td>
<td style="text-align: right;" v-if="props.isLoading">
<!-- Skeleton-lines 1 row -->
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
@@ -115,7 +115,7 @@ const calculateTotalPrice = (price, quantity) => {
<td style="text-align: right;" v-else>{{ SessionUser.functions.currency.toLocal(props.orderItems.reduce((acc, orderItem) => acc + orderItem.price * orderItem.quantity, 0)) }}</td>
</tr>
<tr>
<td colspan="4">Total</td>
<td colspan="4">Total {{ props.taxPercentage > 0 ? 'inkl. moms' : '' }}</td>
<td style="text-align: right;" v-if="props.isLoading">
<!-- Skeleton-lines 1 row -->
<div class="skeleton-lines"><div style="width: 100%;"></div></div>
@@ -1,6 +1,7 @@
<script setup>
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { downloadAttachment } from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
orders: {
@@ -44,6 +45,9 @@ import Swal from "sweetalert2";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import InvoicingBillingPeriodInvoiceProgressBar
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/InvoicingBillingPeriodInvoiceProgressBar.vue";
import PosDepartmentStepMobileAttachment
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachment.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
const redirectDepartmentOrderPage = (orderId, departmentId) => {
@@ -116,6 +120,9 @@ const getOrderInvoiceStatusBarColor = (order) => {
: color_class = 'has-text-warning';
}
if (doesOrderHaveInvoiceCollection(order)) {
if (isPendingHandheld(order) && !order.completed_at) {
return 'has-text-warning'; // If the order is pending handheld, show a warning color
}
color_class = 'has-text-grey';
// Check if the invoice collection is closed
if (isOrderInvoiceCollectionClosed(order)) {
@@ -278,6 +285,11 @@ const tableHeaders = ref([
title: 'Noter',
sortable: true,
},
{
name: 'po',
title: 'PO',
sortable: true,
},
{
name: 'created_at',
title: 'Dato',
@@ -564,7 +576,7 @@ const isFirstOrderInCollection = (invoiceCollectionId, order) => {
// Check if the current order is the first in the collection
return firstOrderInCollection.id === order.id;
};
const invoiceCollectionDetails = ref([]); // Used to provide the invoice collection booked id in the list
const invoiceCollectionOrderCounts = ref([]);
const loadInvoiceCollectionOrderCounts = () => {
// Get all unique invoice collection ids
@@ -577,6 +589,7 @@ const loadInvoiceCollectionOrderCounts = () => {
const orders = response.orders || [];
// Set the count for the invoice collection id
invoiceCollectionOrderCounts.value[invoiceCollectionId] = orders.length;
invoiceCollectionDetails.value[invoiceCollectionId] = response;
}).catch((error) => {
console.error('Error loading invoice collection order counts for: ' + invoiceCollectionId, error);
// Set the count for the invoice collection id to 0 if there was an error
@@ -588,6 +601,12 @@ const getInvoiceCollectionOrderCount = (invoiceCollectionId) => {
// Get the count for the invoice collection id
return invoiceCollectionOrderCounts.value[invoiceCollectionId] || 0;
};
const getInvoiceCollectionDetails = (invoiceCollectionId) => {
if (invoiceCollectionDetails.value[invoiceCollectionId]) {
return invoiceCollectionDetails.value[invoiceCollectionId];
}
return null;
};
loadInvoiceCollectionOrderCounts();
@@ -619,6 +638,15 @@ const toggleAutoExpandAll = () => {
active_dropdown_object_ids.value = [];
}
const isSystemOrder = (order) => {
const systemUserIds = SessionUser.objects.global.system_user_ids || [];
return systemUserIds.includes(order?.cashier_id || -1);
};
const isPendingHandheld = (order) => {
return order.pending_handheld === true && order.completed_at === null;
};
</script>
<template>
@@ -705,7 +733,7 @@ const toggleAutoExpandAll = () => {
<tr>
<td colspan="12">
<div class="has-text-weight-bold">
Faktura samling ID: {{ order.invoice_collection_id }}
Faktura samling ID: {{ order.invoice_collection_id }}, E-conomic booked ID: {{ getInvoiceCollectionDetails(order.invoice_collection_id)?.economic_invoice_booked_id }}, Ekstern ID: <code>{{ getInvoiceCollectionDetails(order.invoice_collection_id)?.external_id }}</code>
<span class="has-text-grey">( {{ SessionUser.objects.global.language.showing }} {{ getOrdersByInvoiceCollection(order.invoice_collection_id).length }} {{ SessionUser.objects.global.language.showing_of_separator }} {{ getInvoiceCollectionOrderCount(order.invoice_collection_id) }} {{ SessionUser.objects.orders.meta.labels.multiple.toLowerCase() }} )</span>
</div>
</td>
@@ -739,7 +767,7 @@ const toggleAutoExpandAll = () => {
</td>
</tr>
</template>
<tr v-if="isObjectVisible(order)">
<tr v-if="isObjectVisible(order)" :class="{ 'has-background-info-light' : isSystemOrder(order) }">
<template v-if="props.invoiceView && props.allowSelectMultiple">
<td class="is-narrow">
<button
@@ -774,6 +802,7 @@ const toggleAutoExpandAll = () => {
<td>
<ColorIndicator
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
v-bind:icon_class="isPendingHandheld(order) ? 'fas fa-mobile-alt' : 'fas fa-circle'"
v-bind:is_narrow="true"
v-bind:label="{
text: order.id,
@@ -824,6 +853,15 @@ const toggleAutoExpandAll = () => {
button_text: (order.completed_at !== null ? SessionUser.objects.global.language.yes : SessionUser.objects.global.language.no),
v_centered: true,
},
// Handheld status
...(isPendingHandheld(order) ? [{
text: SessionUser.objects.global.language.handheld_pending_order,
action: () => {},
button_classes: ['no-underline-text', 'is-text', 'has-text-warning'],
button: true,
button_text: SessionUser.objects.global.language.confirmation_needed,
v_centered: true,
}] : []),
],
buttons: [],
}"
@@ -875,7 +913,10 @@ const toggleAutoExpandAll = () => {
{{ order.customer_name }}
</span>
</td> -->
<td v-if="!props.isCustomerView">{{ order.cashier_id }}</td>
<td v-if="!props.isCustomerView">
<template v-if="!isSystemOrder(order)">{{ order.cashier_id }}</template>
<template v-else class="has-text-grey-light">System</template>
</td>
<td>{{ getDepartmentName(order.department_id) }}</td>
<td>
<ColorIndicator
@@ -1001,16 +1042,29 @@ const toggleAutoExpandAll = () => {
}"
/>
</td>
<EditableTableColumn
v-show="(SessionUser.hasAttribute('usePONumbers') || SessionUser.hasPermission('department'))"
:object="order"
:loadList="loadList"
column="po"
:parse-function="(value) => order.po"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
/>
<td v-show="!(SessionUser.hasAttribute('usePONumbers') || SessionUser.hasPermission('department'))">
<span>{{ order.po }}</span>
</td>
<td>{{ order.created_at }}</td>
<td>{{ SessionUser.functions.currency.toLocal(order.total_net_amount) }}</td>
<td>{{ order.completed_at }}</td>
<td>
<td class="is-narrow">
<!-- Dropdown -->
<ActionSettingsWheelButton
v-bind:user_id="order.user_id"
v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:reg_1="order.reg_1"
@deleted="loadList()"
>
<template v-slot:actions>
<!--
@@ -1031,6 +1085,20 @@ const toggleAutoExpandAll = () => {
-->
</template>
</ActionSettingsWheelButton>
<!-- Attachments -->
<ActionSettingsWheelButton v-show="(order.attachments.length > 0 && (SessionUser.hasPermission('download_order_attachments_own') || SessionUser.hasPermission('download_order_attachments')))" :icon="'fas fa-paperclip'" class="ml-1">
<template v-slot:actions>
<template v-for="attachment in order.attachments" :key="attachment.id">
<action-settings-wheel-item
:label="attachment.content.document"
icon="fas fa-paperclip"
:click-action="() => downloadAttachment(attachment.id, order.id)"
:disabled="false"
/>
</template>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<!-- Dropdown content (if the invoice view is enabled,and the dropdown is active) -->
@@ -1049,7 +1117,7 @@ const toggleAutoExpandAll = () => {
</tbody>
<tfoot>
<tr>
<td colspan="100%">Viser {{ getVisibleOrders.length }} transaktioner</td>
<td colspan="100%">Viser {{ getVisibleOrders.length }} {{SessionUser.objects.orders.meta.labels.entries.toLowerCase()}}</td>
</tr>
</tfoot>
</table>
@@ -1,7 +1,5 @@
<script setup>
import { Colors } from "@/ThemeConfig.vue";
import SelectCustomerFormPOS from "@/components/forms/department/pos/SelectCustomerFormPOS.vue";
import PosLastScannedLicensePlates from "@/components/displays/department/pos/PosLastScannedLicensePlates.vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import NextStepError from "@/components/forms/department/pos/error/NextStepError.vue";
import PosSelectedCustomer from "@/components/displays/department/pos/PosSelectedCustomer.vue";
@@ -81,17 +79,24 @@ const getDepartmentVersion = () => {
}
const department_version = ref(getDepartmentVersion());
const duplicateCheckId = ref(0);
// Select the "pos_select_customer_input" input field, and focus on it on page load
onMounted(() => {
if (getDepartmentVersion() === 1) {
document.getElementById("pos_select_customer_input").focus();
}
console.warn("Mounted POS Step 1, department version: " + department_version.value);
});
// Watch for changes in the reg_1 value
watch(() => reg_1.value, (newValue) => {
checkDuplicateOrders(newValue);
watch(() => reg_1.value, (newValue, oldValue) => {
// If the new value is different from the old value, check for duplicate orders
if (newValue === oldValue) {
} else {
console.warn("reg_1 changed from " + oldValue + " to " + newValue);
checkDuplicateOrders(newValue);
}
});
const checkDuplicateOrders = (reg) => {
@@ -99,6 +104,15 @@ const checkDuplicateOrders = (reg) => {
if (!reg || reg.trim() === "") {
return;
}
// Create a token to ignore stale responses
const thisCheckId = Date.now();
duplicateCheckId.value = thisCheckId;
// Reset previous potential duplicates to avoid duplicate keys in the UI
potentialDuplicateOrderObjects.value = {};
potentialDuplicateOrders.value.objects = [];
// Check if the reg_1 value has recently had any orders
SessionUser.request(
SessionUser.objects.orders.meta.endpoint,
@@ -108,10 +122,20 @@ const checkDuplicateOrders = (reg) => {
limit: 5, // Limit to 5 orders
}
).then((response) => {
// Ignore stale responses
if (thisCheckId !== duplicateCheckId.value) {
return;
}
// Check if there are any potential duplicate orders
if (response.data.data.length > 0) {
const orders = response?.data?.data || [];
if (orders.length > 0) {
// Store the potential duplicate orders in the potentialDuplicateOrderObjects
response.data.data.forEach(order => {
const idsInList = new Set();
orders.forEach(order => {
// Prevent pushing the same order multiple times defensively
if (idsInList.has(order.id)) return;
idsInList.add(order.id);
potentialDuplicateOrderObjects.value[order.id] = {
loaded: false,
loading: false,
@@ -288,55 +312,7 @@ const getPotentialDuplicateOrders = computed(() => {
{{department_id}}
{{getDepartment()}}
-->
<!-- Version 1 -->
<template v-if="department_version.valueOf() === 1">
<div class="columns">
<!-- Choose customer -->
<div class="column is-8-desktop pl-0 pr-3">
<WhiteBox>
<template #default>
<SelectCustomerFormPOS/>
</template>
</WhiteBox>
<ButtonsBox>
<div class="buttons">
<Cancel
style="width: 25%;"
tabindex="2"
class="is-fullwidth"
/>
</div>
</ButtonsBox>
</div>
<!-- Last seen License Plates in department -->
<div class="column is-4-desktop pl-3 pr-0">
<WhiteBox>
<template #default>
<ElementTabsBox
:tabs="right_tabs"
default-active-tab="license_plates"
class="is-boxed"
:allowCompactWhenOneTab="true"
>
<template #customer>
<PosSelectedCustomer />
</template>
<template #license_plates>
<PosLastScannedLicensePlates class="my-3"/>
</template>
</ElementTabsBox>
</template>
</WhiteBox>
<NextStepError class="is-fullwidth" />
<ButtonsBox>
<NextStep class="is-fullwidth" tabindex="6"/>
</ButtonsBox>
</div>
</div>
</template>
<!-- Version 2 -->
<template v-else-if="department_version.valueOf() === 2">
<div class="columns">
<!-- Choose vehicle -->
<div class="column is-8-desktop pl-0 pr-3">
@@ -380,8 +356,6 @@ const getPotentialDuplicateOrders = computed(() => {
</ButtonsBox>
</div>
</div>
</template>
</div>
<DefaultObjectSelector
v-bind:isActive="potentialDuplicateOrders.isActive"
:title="SessionUser.objects.global.language.possible_duplicates + ' - ' + reg_1"
@@ -392,6 +366,7 @@ const getPotentialDuplicateOrders = computed(() => {
@selectedObject="(object) => { potentialDuplicateOrders.selectedObject = object; }"
:show-radio="false"
/>
</div>
</template>
<style scoped>
@@ -0,0 +1,336 @@
<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 { 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 { PosSearchResult } from "./objects/PosSearchResult.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
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";
// Debug mode flag
const debug_mode = ref(false);
// Debug array to store request results
const debug_request_results = ref([]);
const lastParsedImage = ref(null);
const setLastCapturedImage = (image: string) => {
camera.latestImage.value = image;
}
type LPRResponse = {
success: boolean;
license_plate_number: string;
};
const latestLPRResponse = ref<LPRResponse | null>(null);
const activeVehicleIndexNext = () => {
// Increment the active vehicle index, wrapping around if necessary
if (vehicles.activeVehicleIndex.value < 3) {
vehicles.setActiveVehicleIndex(vehicles.activeVehicleIndex.value + 1)
}
};
const handleLPRResult = () => {
// Set the active registration number to the one provided.
const searchResultIndex = getSearchResultIndex({
registrationNumber: latestLPRResponse.value?.license_plate_number || "",
customerName: "",
customerId: 0,
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)
}
vehicles.select(searchResultIndex, {
reg: latestLPRResponse.value?.license_plate_number || "",
status: 'unknown'
});
}
}
const parseImage = (image: string) => {
// Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) {
console.log("Skipping parsing due to delay after success.");
return;
}
if (lastParsedImage.value === image) {
// If the image is the same as the last parsed one, skip parsing
console.log("Skipping parsing for the same image.");
return;
}
lastParsedImage.value = image; // Update the last parsed image
camera.setLatestImage(image); // Update the latest image in the camera object
// Function to parse the image data
SessionUser.request(
"/modules/scanner/lpr",
"POST",
{
base64_image: image
},
).then((response) => {
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, log an error and return
if (!response.data.success) {
console.error("LPR request was not successful:", response);
return;
}
latestLPRResponse.value = response.data.data as LPRResponse;
console.log("LPR Response:", latestLPRResponse.value);
// 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;
const registrationNumbers = computed(() => {
// Return the registration numbers of all vehicles
return [
vehicles.vehicle_1.value?.reg || "",
vehicles.vehicle_2.value?.reg || "",
vehicles.vehicle_3.value?.reg || "",
];
});
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)
for (let i = 1; i <= 3; i++) {
const vehicle = vehicles[`vehicle_${i}`].value;
if (vehicle && vehicle.reg === object.registrationNumber) {
targetIndex = i; // Set the target index to the existing vehicle index
break;
}
}
return targetIndex;
}
const onAutomaticSelection = (object: PosSearchResult | null) => {
if (!object) {
console.warn("No object selected for automatic selection.");
return;
}
console.warn("Automatic selection for object:", object);
// Check if the registration number already exists in the vehicles
let targetIndex = getSearchResultIndex(object);
// Set the vehicle registration number and customer details
const vehicleObject = {
reg: object.registrationNumber,
customer_id: object.customerId,
status: object.customerStatus,
type: object?.type || null,
reference: object?.reference || null,
last_order_id: object?.lastOrderId || null,
wash_subscription: object?.washSubscription,
} as PosVehicle;
switch (vehicles.activeVehicleIndex.value) {
case 1:
case 2:
case 3:
vehicles.select(targetIndex, vehicleObject); // Use the select method to set the vehicle
if (targetIndex === vehicles.activeVehicleIndex.value && object.registrationNumber.length > 0) {
// If the registration number is empty, we're not changing the active vehicle index
if (vehicles.getActiveVehicle()?.reg === object.registrationNumber) {
activeVehicleIndexNext(); // If the target index is the same as the current active index, we can safely move to the next vehicle index
}
}
break;
default:
console.warn("Invalid active vehicle index:", targetIndex);
}
}
const getQuery = computed(() => {
// Return the registration number of the active vehicle
const activeVehicle = vehicles.getActiveVehicle();
return activeVehicle ? activeVehicle.reg : "";
});
const activeSelectionHasRegistrationNumber = computed(() => {
return vehicles.getActiveVehicle()?.reg && vehicles.getActiveVehicle()?.reg.length > 0;
});
const shouldCustomerBeModified = computed(() => {
// Check if the customer should be modified based on the active vehicle index
return vehicles.activeVehicleIndex.value === 1;
});
onMounted(() => {
// Set the header to be transparent
setTransparency(!views.isAnyActive.value);
setBackgroundColor(backgroundColors.default); // Set the default background color
setOverflow(false); // Prevent scrolling
});
onUnmounted(() => {
console.warn("Unmounting PosDepartmentStepMobile1 component.");
// Reset the header settings when the component is unmounted
setTransparency(false); // Reset transparency
setBackgroundColor(backgroundColors.default); // Reset to default background color
setOverflow(true); // Allow scrolling again
});
</script>
<template>
<!-- Manual input view -->
<template v-if="manualInput">
<PosDepartmentStep1MobileManualInput @close="manualInput = false"/>
</template>
<!-- Transaction history view -->
<template v-else-if="transactionHistoryView">
<PosDepartmentStep1MobileTransactionHistory @close="transactionHistoryView = false"/>
</template>
<!-- Default: Scanner view -->
<template v-else>
<div class="background-fixed">
<ScannerCamera
@update:frame="parseImage"
/>
</div>
<div class="custom-content">
<!-- Meta objects, registration number auto-lookup -->
<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"
@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"/>
</div>
<!-- Reg. 1, Reg. 2, Reg. 3 -->
<div class="is-flex is-align-content-center is-justify-content-center is-flex-direction-column">
<PosDepartmentStepMobile1RegistrationNumbers v-if="!views.attachmentView.value"/>
</div>
<!-- Debug -->
<PosDepartmentStepMobile1Debug v-if="debug_mode" :results="debug_request_results"/>
<!-- Location -->
<PosDepartmentStepMobile1Location/>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<!-- Attachments -->
<div class="is-flex is-justify-content-center">
<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"/>
</div>
<!-- Take picture button (When attachment view is active) -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileButtonNextStep :isDark="true" v-if="views.attachmentView.value" style="width: 100%;" class="mb-2" :customDisabled="false" :customAction="() => { attachments.takePicture(); }">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.take_picture }}</span>
<span class="is-float-right has-text-white">
<!-- Picture icon -->
<span class="icon is-small">
<i class="fas fa-camera"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</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"/>
<!-- Next step button (If in attachment view) -->
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="views.attachmentView.value" :customDisabled="false" :customAction="() => { views.attachmentView.value = false; }">
<span class="is-float-left has-text-dark">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right has-text-dark">
<!-- x icon -->
<span class="icon is-small">
<i class="fas fa-times"></i>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</div>
</PosDepartmentStepMobileFixedBottomControl>
<!-- Additional content or components can be added here -->
</div>
</template>
</template>
<style scoped>
.background-fixed {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background */
z-index: -2; /* Ensure it is behind other content */
}
/** Spread the custom content over the entire available area **/
.custom-content {
position: fixed;
left: 0;
width: 100%;
height: -webkit-fill-available;
z-index: 1; /* Ensure it is above the background */
display: flex;
flex: 1 1 auto; /* Allow to grow and shrink */
flex-direction: column;
justify-content: space-between; /* Space out elements vertically */
max-height: 70vh; /* Limit the maximum height to 80% of the viewport height */
align-items: center; /* Center elements horizontally */
color: white; /* Default text color */
padding: 1rem; /* Optional padding for content */
}
/** On smaller screens, set the top position lower for custom-content **/
@media (max-height: 768px) {
.custom-content {
top: 80px; /* Adjust the top position for smaller screens */
}
}
</style>
@@ -0,0 +1,475 @@
<script setup lang="ts">
import { setTransparency, setBackgroundColor, backgroundColors } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { onMounted, ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobile2Product
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Product.vue";
import PosDepartmentStepMobile2LastOrder
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2LastOrder.vue";
import PosDepartmentStepMobile2Addons
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import ControlFieldInput from "@/components/viewport/elements/controls/fields/ControlFieldInput.vue";
import ControlFieldInputLabel from "@/components/viewport/elements/controls/fields/ControlFieldInputLabel.vue";
import ControlField from "@/components/viewport/elements/controls/fields/ControlField.vue";
import PosDepartmentStepMobile2RegistrationNumbers
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2RegistrationNumbers.vue";
import { vehicleSelection, transactionItems, notes, reference, vehicles, lastOrders, metadata, additionalItemSelection, attachments } from "./objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStep2MobileVehicleSelection
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue";
import { order_id, order_notes, department_id, customer_id, getCustomerEmail, customer_name } from "@/components/shop/POSDepartmentProcess.vue";
import { getOrderItems } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import {PosOrderItem} from "@/components/displays/department/pos/steps/mobile/objects/PosOrderItem.vue";
import PosDepartmentStepMobile2AdditionalItems
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue";
import PosDepartmentStepMobile2Customer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Customer.vue";
onMounted(() => {
// Set the header to be transparent
setTransparency(false);
setBackgroundColor(backgroundColors.default); // Set the default background color
fetchPrimaryItemProduct(); // Fetch the primary item product based on the vehicle type
// Set the reference to the vehicle 1 reference if it's not already set
if (vehicles?.vehicle_1?.value?.reference && !reference.value) {
reference.value = vehicles.vehicle_1.value.reference;
console.warn('Setting reference to vehicle 1 reference:', reference.value);
}
// Set the notes to the order notes if it's not already set
if (order_notes.value && !notes.value) {
notes.value = order_notes.value;
console.warn('Setting notes to order notes:', notes.value);
}
// Check for potential customer number changes
if (metadata.customerId.value !== parseInt(customer_id.value)) {
applyPotentialCustomerNumberChange(metadata.customerId.value, parseInt(customer_id.value));
}
/** Last order */
// This is used to fetch the last order details for the vehicles.
fetchLastOrder(1);
fetchLastOrder(2);
fetchLastOrder(3);
});
const applyPotentialCustomerNumberChange = (oldId, newId) => {
metadata.customerId.value = parseInt(newId);
console.warn('Customer ID changed from', oldId, 'to', newId)
}
// Watch the customer id, and if it changes, update it in the metadata
watch(customer_id, (newId, oldId) => {
if (newId !== oldId) {
applyPotentialCustomerNumberChange(oldId, newId);
}
});
const lastFetchedPrimaryItemProduct = ref<PosProduct | null>(null); // This is used to make sure all the addons are fetched when the last order is selected.
const fetchPrimaryItemProduct = () => {
if (!vehicles.vehicle_1.value.type && !transactionItems.primaryItem.value) {
vehicleSelection.value = true; // Force the user to select a product.
} else {
// Load the product from the type id.
if (!vehicles.vehicle_1.value.type) {
return; // If no vehicle type is set, do not proceed.
}
// If the primary item is already set and matches the vehicle type, do not fetch again.
//if (transactionItems.primaryItem.value && transactionItems.primaryItem.value.id === vehicles.vehicle_1.value.type) {
// console.warn('Primary item already set and matches vehicle type, skipping fetch.');
// return;
//}
SessionUser.objects.products.get.single(vehicles.vehicle_1.value.type, {department_id: department_id.value, customer_id: metadata.getCustomerId(), category_id: null, final_price: true})
.then(product => {
// If the product is found, set it as the primary item
lastFetchedPrimaryItemProduct.value = product; // Store the last fetched primary item product
console.warn('Setting primary item to vehicle type product:', product);
// If the primary item is already set, simply update the product
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value = {
...transactionItems.primaryItem.value,
...product,
addons: transactionItems.primaryItem.value.addons.map(addon => ({
...addon,
product: {
...addon.product,
price: product.price // Update the addon price to match the new primary item price
}
}))
};
return;
}
transactionItems.setPrimaryItem(product);
})
.catch(error => vehicleSelection.value = true); // If there's an error, force the user to select a product.
}
}
const fetchLastOrder = (vehicleIndex: number) => {
// This function fetches the last order for a given vehicle ID (Provided it has a last_order_id
const tmp_order_id = vehicles[`vehicle_${vehicleIndex}`]?.value?.last_order_id || 0;
if (!tmp_order_id) {
console.warn(`No last order ID found for vehicle ${vehicleIndex}`);
return; // If no last order ID is found, do not proceed.
}
console.log('Fetching last order details for vehicle', vehicleIndex, tmp_order_id);
SessionUser.objects.orders.get.single(tmp_order_id)
.then(order => lastOrders.set(vehicleIndex, {...order}))
.catch(error => console.error('Error fetching last order:', error));
}
const fetchLastOrderItems = (vehicleIndex: number) => {
// If the lastOrder is set, fetch the order items for the last order
if (lastOrders.get(vehicleIndex)) {
getOrderItems(lastOrders.get(vehicleIndex).id)
.then(response => lastOrders.set(vehicleIndex, {...lastOrders.get(vehicleIndex), items: updateLastOrderItemPrices(response.data.data)}))
.catch(error => console.error('Error fetching last order items:', error));
}
}
const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
// Update the prices of the last order items to reflect any changes in the product prices
return items.map(item => {
SessionUser.objects.products.get.single(item.product_id, {category_id: null, department_id: department_id.value, customer_id: customer_id.value, final_price: true}).then(product => {
console.warn('Fetched product for last order item:', product);
transactionItems.updateTransactionPrices([product]); // Update the transaction prices with the fetched product
}).catch(error => {
console.error('Error fetching product for last order item:', error);
});
return {
...item, // Keep existing item properties, they will be overridden if the product fetch is successful
};
});
}
const layout = {
classes: <string[]>[]
}
// When the primary product is long pressed, log the primary item details
const primaryProduct = ref(null);
const pointerDownTime = ref<Date>(null);
const pointerUpTime = ref<Date>(null);
const pointerDown = () => {
// Record the time when the pointer is pressed down
const instanceTime = new Date();
pointerDownTime.value = instanceTime;
pointerUpTime.value = null;
// Wait 500 milliseconds to check if it's a long press
setTimeout(() => {
if (instanceTime !== pointerDownTime.value) {
return; // If the time has changed, it's not a long press
}
if (pointerDownTime.value && !pointerUpTime.value) {
// If the pointer is still down after 500ms, it's a long press
/** Open the vehicle selection view */
vehicleSelection.value = true;
}
// Reset the pointer down time after the check
pointerDownTime.value = null;
pointerUpTime.value = null;
}, 500);
};
const pointerUp = () => {
// Record the time when the pointer is released
pointerUpTime.value = new Date();
};
const onBeforeComplete = () => {
// This function is called before the step is completed
console.log('On before complete called');
return new Promise((resolve, reject) => {
// Check if the primary item is set
if (!transactionItems.primaryItem.value) {
reject(new Error('No primary item selected'));
return;
}
// If the transaction items contain a wash certificate, create a booking for it
const hasWashCertificate = transactionItems.containsWashCertificate();
let bookingPromise = Promise.resolve(true);
if (hasWashCertificate && !metadata.getBookingId()) {
// Create a booking for the wash
//SessionUser.objects.forms.functions.submit(
bookingPromise = <Promise<any>>SessionUser.objects.forms.functions.submit(
{
/**
* {
* "id": "BOOK_WASH",
* "BOOK_WASH_field_department_id": 2,
* "BOOK_WASH_field_wash_type": [
* 3
* ],
* "BOOK_WASH_field_customer_number": 12345678,
* "BOOK_WASH_field_contact_email": "example@truckwash.dk",
* "BOOK_WASH_field_reference": "example",
* "BOOK_WASH_field_registration_number_tractor": "example123",
* "BOOK_WASH_field_registration_number_trailer": "example456",
* "BOOK_WASH_field_wants_wash_certificate": true,
* "BOOK_WASH_field_wants_wash_certificate_email": "example@truckwash.dk",
* "BOOK_WASH_field_date": "2025-09-23",
* "BOOK_WASH_field_wants_pickup": false,
* "BOOK_WASH_field_notes": "Dette er et exempel."
* }
*/
id: 'BOOK_WASH',
BOOK_WASH_field_department_id: department_id.value,
BOOK_WASH_field_wash_type: [3],
BOOK_WASH_field_customer_number: customer_id.value,
BOOK_WASH_field_contact_email: getCustomerEmail() || '',
BOOK_WASH_field_reference: reference.value || '',
BOOK_WASH_field_registration_number_tractor: vehicles.vehicle_1.value?.reg || '',
BOOK_WASH_field_registration_number_trailer: vehicles.vehicle_2.value?.reg || 'INGEN',
BOOK_WASH_field_wants_wash_certificate: true,
BOOK_WASH_field_wants_wash_certificate_email: getCustomerEmail() || '',
BOOK_WASH_field_date: new Date().toISOString().split('T')[0], // Set the date to today
BOOK_WASH_field_wants_pickup: false,
BOOK_WASH_field_notes: notes.value || '',
}
).then(response => {
console.warn('Booking created for wash certificate:', response.data.data);
// Attach the booking ID to the order metadata
metadata.setBookingId(response.data.data.id);
SessionUser.objects.orders.set.booking_id(order_id, response.data.data.id);
resolve(true);
}).catch(error => {
console.error('Error creating booking for wash certificate:', error);
reject(new Error('Error creating booking for wash certificate'));
});
}
// Add the order items to the transaction
SessionUser.objects.global.add.object(
'/order/items',
{
order_id: parseInt(order_id.value), // The order ID to which the item will be added
product_id: primaryItem.value.id, // The product ID of the primary item
quantity: 1,
price: primaryItem.value.price,
notes: primaryItem.value?.notes || '', // Notes can be added here if needed
related_item_id: null,
}, // The order item to create
{authenticated: true}
).then((response) => {
const relatedItemId = response.data.data.id; // Get the ID of the created order item
// Create the addon items (With a quantity of > 0)
const addonItems = transactionItems.primaryItem.value.addons.filter(addon => addon.quantity > 0);
const addonPromises = addonItems.map(addon => {
// For each addon, create an order item
return SessionUser.objects.global.add.object(
'/order/items',
{
order_id: parseInt(order_id.value), // The order ID to which the addon will be added
product_id: addon.product.id, // The product ID of the addon
quantity: addon.quantity,
price: addon.product.price,
notes: addon.product?.notes || '', // Notes can be added here if needed
related_item_id: relatedItemId, // Link the addon to the primary item
},
{authenticated: true}
);
});
// Create the additional items (With a quantity of > 0)
const additionalItems = transactionItems.additionalItems.value.filter(item => item.quantity > 0);
const additionalPromises = additionalItems.map(item => {
// For each additional item, create an order item
return SessionUser.objects.global.add.object(
'/order/items',
{
order_id: parseInt(order_id.value), // The order ID to which the additional item will be added
product_id: item.id, // The product ID of the additional item
quantity: item.quantity,
price: item.price,
notes: item?.notes || '', // Notes can be added here if needed
related_item_id: null, // Additional items are not linked to the primary item
},
{authenticated: true}
);
});
// Combine addon and additional item promises
addonPromises.push(...additionalPromises);
// IF the booking is being created, wait for it to finish first
return bookingPromise.then(() => Promise.all(addonPromises));
}).then(() => {
// After successfully adding the order item, resolve the promise
resolve(true);
}).catch(error => {
// If there's an error, reject the promise
reject(error);
});
});
}
// Watch for changes in the primary item and update the vehicle selection if necessary
// Utility: map source addons and carry over quantities from previous addons by product.id
const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
sourceAddons.map(addon => {
const carriedQty = previousAddons.find(a => a.product.id === addon.product.id)?.quantity ?? 0;
return { ...addon, quantity: carriedQty };
});
watch(
() => transactionItems.primaryItem.value,
(nextPrimary, prevPrimary) => {
if (!nextPrimary) {
console.warn('Primary item is not set, cannot proceed with the transaction.');
fetchPrimaryItemProduct();
return;
}
const lastFetched = lastFetchedPrimaryItemProduct.value;
const isNewPrimary = nextPrimary.id !== lastFetched?.id;
const previousAddons = prevPrimary?.addons ?? transactionItems.primaryItem.value?.addons ?? [];
if (isNewPrimary) {
lastFetchedPrimaryItemProduct.value = nextPrimary;
console.log('Primary item changed from:', prevPrimary ?? null, 'to:', nextPrimary);
fetchPrimaryItemProduct();
if (Array.isArray(nextPrimary.addons) && nextPrimary.addons.length > 0) {
transactionItems.primaryItem.value = {
...transactionItems.primaryItem.value,
addons: mapAddonsWithQuantity(nextPrimary.addons, previousAddons)
};
}
return;
}
// Same product as last fetched: merge/normalize addons from last fetched into the current primary item
const sourceAddons = lastFetched?.addons ?? [];
if (sourceAddons.length > 0) {
console.log(
'Merging addons from last fetched primary item product:',
sourceAddons,
'into primary item:',
transactionItems.primaryItem.value
);
transactionItems.primaryItem.value.addons = mapAddonsWithQuantity(
sourceAddons,
transactionItems.primaryItem.value.addons ?? []
);
}
},
{ immediate: true }
);
// Watch for changes in the last orders and fetch the order items when they change
watch(() => lastOrders.get(1), (newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
console.log('Last order 1 changed:', newValue);
fetchLastOrderItems(1); // Fetch the order items for the last order 1
}
}, { immediate: true });
watch(() => lastOrders.get(2), (newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
console.log('Last order 2 changed:', newValue);
fetchLastOrderItems(2); // Fetch the order items for the last order 2
}
}, { immediate: true });
watch(() => lastOrders.get(3), (newValue, oldValue) => {
if (newValue && newValue.id !== oldValue?.id) {
console.log('Last order 3 changed:', newValue);
fetchLastOrderItems(3); // Fetch the order items for the last order 3
}
}, { immediate: true });
// Watch for changes in the vehicle 1 last_order_id and fetch the last order when it changes
watch(() => vehicles.vehicle_1.value.last_order_id, (newValue, oldValue) => {
if (newValue && newValue !== oldValue) {
console.log('Vehicle 1 last_order_id changed:', newValue);
fetchLastOrder(1); // Fetch the last order for vehicle 1
} else {
lastOrders.set(1, null); // Clear the last order if the last_order_id is removed
}
});
// Watch for changes in the vehicle 1 reference and update the reference field when it changes
watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
if (newValue && newValue !== oldValue) {
console.log('Vehicle 1 reference changed:', newValue);
reference.value = newValue; // Update the reference field
}
});
</script>
<template>
<template v-if="vehicleSelection">
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false"/>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="() => { vehicleSelection = false; fetchPrimaryItemProduct(); }">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.next }}</span>
<span class="is-float-right has-text-white">
<!-- Arrow right icon -->
<i class="fas fa-arrow-right"></i>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</template>
<template v-else-if="additionalItemSelection">
<PosDepartmentStepMobile2AdditionalItems :label="SessionUser.objects.global.language.additional_items" :subtitle="''" @close="additionalItemSelection = false"/>
</template>
<template v-else>
<div class="is-flex is-flex-direction-column is-justify-content-space-between is-gap-2">
<!-- Customer selection -->
<PosDepartmentStepMobile2Customer subtitle="" :label="customer_name"/>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="pointerDown"
v-on:pointerup="pointerUp"
ref="primaryProduct"
:classes="layout.classes"
:product="transactionItems.primaryItem.value"
/>
<!-- Additional items -->
<PosDepartmentStepMobile2AdditionalItems :label="SessionUser.objects.global.language.additional_items" :subtitle="''"/>
<!-- Last order details -->
<PosDepartmentStepMobile2LastOrder
v-show="lastOrders.get(1)"
@click="lastOrders.select(1)"
:classes="layout.classes" :label="'ordered on...'" :subtitle="'Subtitle'" :lastOrder="lastOrders.get(1)"/>
<!-- Addons -->
<PosDepartmentStepMobile2Addons :addons="transactionItems.primaryItem.value?.addons || []" :label="'Add-ons'"/>
<!-- Notes -->
<ControlField>
<ControlFieldInputLabel label="Notes" :classes="layout.classes" :optional="true"/>
<ControlFieldInput type="text" placeholder="" :classes="layout.classes" :vmodel="order_notes" @change="console.warn('Setting notes to', $event); metadata.setNotes($event); SessionUser.objects.orders.set.notes(order_id, $event);"/>
</ControlField>
<!-- Reference -->
<ControlField>
<ControlFieldInputLabel label="Reference" :classes="layout.classes" :optional="true"/> <!-- TODO: Make this required, if the customer requires it -->
<ControlFieldInput type="text" placeholder="" :classes="layout.classes" :vmodel="reference" @change="console.warn('Setting ref. to', $event); metadata.setReference($event); SessionUser.objects.orders.set.reference(order_id, $event);"/>
</ControlField>
<!-- Spacer to push the buttons to the bottom -->
<ControlField>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<!-- Complete button -->
<PosDepartmentStepMobileButtonNextStep :isDark="true" :onBeforeStep="() => onBeforeComplete()">
<span class="is-float-left has-text-white">Gennemfør</span>
<span class="is-float-right has-text-white">{{ SessionUser.functions.currency.toLocal(transactionItems.getTransactionTotal())}}</span>
</PosDepartmentStepMobileButtonNextStep>
<div>
<PosDepartmentStepMobileButtonClearAll/>
</div>
</PosDepartmentStepMobileFixedBottomControl>
</ControlField>
</div>
</template>
</template>
<style scoped>
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref, defineProps } from 'vue';
import Swal from 'sweetalert2';
const props = defineProps({
results: {
type: Array as () => Array<any>,
required: true
}
})
const onClickShowDebug = () => {
Swal.fire({
title: 'Debug Info',
html: `<div class="has-text-left"><pre>${JSON.stringify(props.results, null, 2)}</pre></div>`,
width: '600px',
confirmButtonText: 'Close'
});
}
</script>
<template>
<div v-show="true">
<button @click="onClickShowDebug" class="btn btn-sm btn-outline-secondary">
Show Debug Info ({{ results.length }})
</button>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useGeolocation } from "@vueuse/core";
import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 27000,
});
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
console.log("Geolocation updated:", newCoords);
if (newCoords.latitude && newCoords.longitude) {
locations.set({
coords: {
latitude: newCoords.latitude,
longitude: newCoords.longitude,
},
timestamp: new Date(),
errorMessage: error.value ? error.value.message : null,
})
}
};
watch(coords, (newCoords) => {
onUpdate(newCoords);
});
</script>
<template>
<div v-show="false">
<p>Latitude: {{ coords.latitude }}</p>
<p>Longitude: {{ coords.longitude }}</p>
<p>Located At: {{ new Date(locatedAt).toLocaleString() }}</p>
<p v-if="error">Error: {{ error.message }}</p>
<template v-if="false">
<button @click="resume">Resume</button>
<button @click="pause">Pause</button>
<button @click="onUpdate({
latitude: 55.613448,
longitude: 12.495521
})">Update Now</button>
</template>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { defineProps, ref } from 'vue';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const props = defineProps({
active: {
type: Boolean,
default: false
},
hideLabel: {
type: Boolean,
default: false
},
isWider: {
// Used to widen reg. 1 when the vehicle list is empty.
type: Boolean,
default: false
},
});
</script>
<template>
<div class="buttons is-gap-0.5 is-centered mb-2 size-based-on-screen">
<button class="button custom-button" :class="{ active: props.active }" v-if="!props.hideLabel">
<span class="custom-label"><slot/></span>
</button>
<button class="button custom-button-secondary p-0" :class="{borderless: props.hideLabel, wider: props.isWider}">
<span class="custom-label" v-if="!$slots.selection">
<!-- Loader -->
<template v-if="props.active && !popups.isSet.value">
<div class="columns is-mobile is-centered is-vcentered is-gapless p-0 m-0" style="width: 100%;">
<div class="column is-narrow p-0 m-0">
<span class="loader is-loading" style="border: 2px solid #FFFFFF; border-top: 2px solid transparent; width: 12px; height: 12px; border-radius: 50%; display: inline-block;"></span>
</div>
<div class="column is-narrow p-0 m-0" style="font-size: 12px; font-weight: bold; color: #FFFFFF;">
<span class="pl-2 m-0">{{SessionUser.objects.global.language.scanning}}...</span>
</div>
</div>
</template>
</span>
<slot name="selection"/>
</button>
</div>
</template>
<style scoped>
.wider {
min-width: 174px;
}
.size-based-on-screen {
max-height: 15vh;
}
@media (max-height: 768px) {
.size-based-on-screen {
max-height: 10vh;
}
}
@media (max-height: 600px) {
.size-based-on-screen {
max-height: 15vh;
}
}
@media (max-height: 480px) {
.size-based-on-screen {
max-height: 20vh;
}
}
/* Label text */
.active .custom-label {
/* Category Tag */
width: 100%;
height: 12px;
font-family: 'Arial';
font-style: normal;
font-weight: 700;
font-size: 12px;
line-height: 100%;
/* identical to box height, or 12px */
color: #000000;
/* Inside auto layout */
flex: none;
order: 0;
flex-grow: 0;
}
.button.custom-button-secondary.borderless {
border: transparent;
}
/* Button */
.custom-button.active {
/* Tab */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
align-items: center;
padding: 6px 4px;
gap: 8px;
width: 41px;
height: 28px;
background: #FFFFFF;
border: 1px solid rgba(241, 241, 241, 0.15);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 2px;
/* Inside auto layout */
flex: none;
order: 0;
align-self: stretch;
flex-grow: 0;
}
/* Button: active */
.custom-button {
/* Tab */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
align-items: center;
padding: 6px 4px;
gap: 8px;
width: 41px;
height: 28px;
background: rgba(241, 241, 241, 0.15);
border: 1px solid #545353;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 2px;
/* Inside auto layout */
flex: none;
order: 0;
align-self: stretch;
flex-grow: 0;
}
/* Button label: active */
.custom-label {
/* Category Tag */
width: 100%;
height: 12px;
font-family: 'Arial';
font-style: normal;
font-weight: 700;
font-size: 12px;
line-height: 100%;
/* identical to box height, or 12px */
color: #FFFFFF;
}
/* Button input */
.custom-button-secondary {
/* Rectangle 8 */
box-sizing: border-box;
width: 87px;
height: 28px;
background: rgba(217, 217, 217, 0.2);
border: 1px solid #545353;
border-radius: 4px;
/* Inside auto layout */
flex: none;
order: 2;
flex-grow: 0;
}
.custom-button-secondary.active {
/* Rectangle 8 */
box-sizing: border-box;
width: 87px;
height: 28px;
border: 1px solid #FFFFFF;
border-radius: 4px;
/* Inside auto layout */
flex: none;
order: 2;
flex-grow: 0;
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import PosDepartmentStepMobile1RegistrationNumber from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber.vue";
import PosDepartmentStepMobile1RegistrationNumberStatus
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberStatus.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import { defineProps } from "vue";
const props = defineProps({
active: {
type: Boolean,
default: false
},
showLabel: {
type: Boolean,
default: true
},
isWider: {
type: Boolean,
default: false
},
});
</script>
<template>
<PosDepartmentStepMobile1RegistrationNumber :active="props.active" :hideLabel="!props.showLabel" :isWider="props.isWider">
<template #default>
Reg 1
</template>
<template #selection v-if="pos.vehicles.vehicle_1.value">
<PosDepartmentStepMobile1RegistrationNumberStatus :registration-number="pos.vehicles.vehicle_1.value.reg" :status="pos.vehicles.vehicle_1.value.status" :hasBorder="false"/>
</template>
</PosDepartmentStepMobile1RegistrationNumber>
</template>
<style scoped>
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import PosDepartmentStepMobile1RegistrationNumber from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber.vue";
import PosDepartmentStepMobile1RegistrationNumberStatus
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberStatus.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import { defineProps } from "vue";
const props = defineProps({
active: {
type: Boolean,
default: false
},
showLabel: {
type: Boolean,
default: true
}
});
</script>
<template>
<PosDepartmentStepMobile1RegistrationNumber :active="props.active" :hideLabel="!props.showLabel">
<template #default>
Reg 2
</template>
<template #selection v-if="pos.vehicles.vehicle_2.value">
<PosDepartmentStepMobile1RegistrationNumberStatus :registration-number="pos.vehicles.vehicle_2.value.reg" :status="pos.vehicles.vehicle_2.value.status" />
</template>
</PosDepartmentStepMobile1RegistrationNumber>
</template>
<style scoped>
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import PosDepartmentStepMobile1RegistrationNumber from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumber.vue";
import PosDepartmentStepMobile1RegistrationNumberStatus
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberStatus.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import { defineProps } from "vue";
const props = defineProps({
active: {
type: Boolean,
default: false
},
showLabel: {
type: Boolean,
default: true
}
});
</script>
<template>
<PosDepartmentStepMobile1RegistrationNumber :active="props.active" :hideLabel="!props.showLabel">
<template #default>
Reg 3
</template>
<template #selection v-if="pos.vehicles.vehicle_3.value">
<PosDepartmentStepMobile1RegistrationNumberStatus :registration-number="pos.vehicles.vehicle_3.value.reg" :status="pos.vehicles.vehicle_3.value.status"/>
</template>
</PosDepartmentStepMobile1RegistrationNumber>
</template>
<style scoped>
</style>
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { ref, watch, defineEmits } from 'vue';
import PosDepartmentStepMobile1RegistrationNumberInputFieldLabel
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumberInputFieldLabel.vue";
const props = defineProps({
modelValue: {
type: String,
default: ''
},
label: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'update:hasFocus']);
const modelValue = ref(props.modelValue);
const hasFocus = ref(false);
watch(() => props.modelValue, (newValue) => {
modelValue.value = newValue;
});
watch(modelValue, (newValue) => {
emit('update:modelValue', newValue);
});
watch(hasFocus, (newValue) => {
emit('update:hasFocus', newValue);
});
const delay_lost_focus_to_hide_search_results = 200; // Delay in milliseconds
const isHidden = ref(true);
let timeoutId: ReturnType<typeof setTimeout>;
watch(hasFocus, (newValue) => {
if (!newValue) {
// If focus is lost, set a timeout to hide the search results
timeoutId = setTimeout(() => {
isHidden.value = true;
}, delay_lost_focus_to_hide_search_results);
} else {
// If focus is gained, clear the timeout and show search results
clearTimeout(timeoutId);
isHidden.value = false;
}
});
</script>
<template>
<div>
<PosDepartmentStepMobile1RegistrationNumberInputFieldLabel>{{ label }}</PosDepartmentStepMobile1RegistrationNumberInputFieldLabel>
<div class="columns is-mobile is-vcentered mb-0 is-multiline">
<div class="column m-0 pb-0">
<div class="field">
<div class="control">
<input
class="custom-input"
:class="{ 'is-searched': hasFocus }"
type="text"
v-model="modelValue"
v-on:focusin="hasFocus = true"
v-on:focusout="hasFocus = false"
/>
</div>
</div>
</div>
<!-- If slot is provided, render it -->
<div class="column pl-0 is-narrow pb-0" v-show="$slots.default && !hasFocus">
<slot />
</div>
<!-- If slot is provided, render it -->
<div class="column is-12 py-0" v-if="$slots.searchResults">
<div class="search-results-container" :class="{ 'is-hidden': isHidden }">
<!-- Render search results slot -->
<slot name="searchResults"/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar {
/* Custom scrollbar styles */
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin; /* For Firefox */
scrollbar-color: #A6A5A5 transparent; /* For Firefox */
}
/* input-fields */
.custom-input {
/* input-fields */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0px 8px;
gap: 10px;
width: 100%;
height: 42px;
background: #FFFFFF;
border: 1px solid #A6A5A5;
border-radius: 4px;
/* Inside auto layout */
flex: none;
order: 1;
align-self: stretch;
flex-grow: 0;
}
.custom-input.is-searched {
/* input-fields */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 12px 8px;
gap: 10px;
width: 100%;
height: 42px;
background: #FFFFFF;
border: 1px solid #000000;
border-radius: 4px;
border-bottom: none;
/* Inside auto layout */
flex: none;
order: 1;
align-self: stretch;
flex-grow: 0;
}
.search-results-container {
/* Frame 59 */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: column;
padding: 0px;
width: 100%;
height: max-content;
background: #FFFFFF;
border: 1px solid #A6A5A5;
}
.search-results-container > div .column div {
/* input-fields */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
gap: 10px;
width: 100%;
height: 36px;
border-bottom: 1px solid #EAEAEA;
/* Inside auto layout */
flex: none;
order: 0;
align-self: stretch;
flex-grow: 0;
}
.search-results-container > div p {
/* */
margin: 0 auto;
height: 12px;
width: 100%;
/* sm-tx */
font-family: 'Arial';
font-style: normal;
font-weight: 400;
font-size: 12px;
line-height: 100%;
/* identical to box height, or 12px */
color: #808080;
/* Inside auto layout */
flex: none;
order: 0;
flex-grow: 0;
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
</script>
<template>
<label class="custom-text">
<slot></slot>
</label>
</template>
<style scoped>
.custom-text {
/* Category Tag */
width: 100%;
height: 12px;
/* sm-tx */
font-family: 'Arial';
font-style: normal;
font-weight: 400;
font-size: 12px;
line-height: 100%;
/* identical to box height, or 12px */
color: #000000;
/* Inside auto layout */
flex: none;
order: 0;
align-self: stretch;
flex-grow: 0;
}
</style>

Some files were not shown because too many files have changed in this diff Show More