@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -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>
|
||||
|
||||
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 210 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 942 KiB |
|
After Width: | Height: | Size: 548 KiB |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 976 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 915 KiB |
|
After Width: | Height: | Size: 647 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1011 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 920 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 899 KiB |
|
After Width: | Height: | Size: 851 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 685 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 651 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1018 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 732 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||