Merge pull request #35 from copenhagentruckwash/buefy-integration

buefy-integration
This commit is contained in:
Jeppe B
2026-01-05 09:20:36 +01:00
committed by GitHub
174 changed files with 11033 additions and 1868 deletions
+48 -5
View File
@@ -26,6 +26,7 @@
"@vueuse/integrations": "^13.7.0",
"animate.css": "^4.1.1",
"axios": "^1.7.8",
"buefy": "^3.0.3",
"bulma": "^1.0.2",
"bulma-block-list": "^1.1.0",
"bulma-calendar": "^7.1.1",
@@ -902,6 +903,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz",
"integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -2896,6 +2898,7 @@
"version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.17.tgz",
"integrity": "sha512-0W7lnIrv18ruJ5zeWBeNZXO8qCWlzxDdp9COFEsZnyNjiEhUVnrW/dPbjRKYpL0edGG0/Lhs0ghp1z/5ekt8ZA==",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
@@ -4012,6 +4015,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz",
"integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==",
"peer": true,
"dependencies": {
"@jimp/core": "^0.22.12"
}
@@ -4540,6 +4544,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz",
"integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -4551,6 +4556,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz",
"integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -4573,6 +4579,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz",
"integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12",
"tinycolor2": "^1.6.0"
@@ -4613,6 +4620,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz",
"integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -4853,6 +4861,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz",
"integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -4864,6 +4873,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz",
"integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -4878,6 +4888,7 @@
"version": "0.22.12",
"resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz",
"integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==",
"peer": true,
"dependencies": {
"@jimp/utils": "^0.22.12"
},
@@ -8249,6 +8260,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"devOptional": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8272,6 +8284,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -14254,6 +14267,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@@ -14267,6 +14281,18 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buefy": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/buefy/-/buefy-3.0.3.tgz",
"integrity": "sha512-//QfIDgbhmpn+mFszD/lEwhlphA+hQavJED2XwjlzqS/bpxYjk/Rnj36X08QVbqiH3lt4a+G357uzsZczc7JTg==",
"license": "MIT",
"dependencies": {
"bulma": "^1.0.4"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -14323,9 +14349,11 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/bulma": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.2.tgz",
"integrity": "sha512-D7GnDuF6seb6HkcnRMM9E739QpEY9chDzzeFrHMyEns/EXyDJuQ0XA0KxbBl/B2NTsKSoDomW61jFGFaAxhK5A=="
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
"integrity": "sha512-Ffb6YGXDiZYX3cqvSbHWqQ8+LkX6tVoTcZuVB3lm93sbAVXlO0D6QlOTMnV6g18gILpAXqkG2z9hf9z4hCjz2g==",
"license": "MIT",
"peer": true
},
"node_modules/bulma-block-list": {
"version": "1.1.0",
@@ -14540,7 +14568,8 @@
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/chardet": {
"version": "0.7.0",
@@ -14551,6 +14580,7 @@
"version": "4.4.7",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz",
"integrity": "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -15427,6 +15457,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -15592,6 +15623,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz",
"integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==",
"deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.",
"peer": true,
"dependencies": {
"@vue/compiler-sfc": "2.7.16",
"csstype": "^3.1.0"
@@ -16561,6 +16593,7 @@
"integrity": "sha512-/XxRRR90gNSuNf++w1jOQjhC5LE9Ixf/iAQctVb/miEI3dwzPZTuG27/omoh5REfSLDoPXofM84vAH/ULtz35g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/snapshot": "^3.2.4",
"deep-eql": "^5.0.2",
@@ -18055,7 +18088,8 @@
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"peer": true
},
"node_modules/ieee754": {
"version": "1.2.1",
@@ -18918,6 +18952,7 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -19927,6 +19962,7 @@
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"peer": true,
"engines": {
"node": "*"
}
@@ -21232,6 +21268,7 @@
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"peer": true,
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
@@ -21916,6 +21953,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -22128,6 +22166,7 @@
"version": "1.81.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.81.1.tgz",
"integrity": "sha512-VNLgf4FC5yFyKwAumAAwwNh8X4SevlVREq3Y8aDZIkm0lI/zO1feycMXQ4hn+eB6FVhRbleSQ1Yb/q8juSldTA==",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -22148,6 +22187,7 @@
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.81.0.tgz",
"integrity": "sha512-uZQ2Faxb1oWBHpeSSzjxnhClbMb3QadN0ql0ZFNuqWOLUxwaVhrMlMhPq6TDPbbfDUjihuwrMCuy695Bgna5RA==",
"devOptional": true,
"peer": true,
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"buffer-builder": "^0.2.0",
@@ -24847,6 +24887,7 @@
"version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
"integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -25034,6 +25075,7 @@
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.5.13",
@@ -26371,6 +26413,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
+2
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"preview": "vite preview",
"test:mobile": "wdio run wdio.conf.js --exclude safari",
"test:android": "wdio run wdio.conf.js --spec test/specs/*.spec.js",
@@ -30,6 +31,7 @@
"@vueuse/integrations": "^13.7.0",
"animate.css": "^4.1.1",
"axios": "^1.7.8",
"buefy": "^3.0.3",
"bulma": "^1.0.2",
"bulma-block-list": "^1.1.0",
"bulma-calendar": "^7.1.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+46
View File
@@ -0,0 +1,46 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-js:2025.3
+2
View File
@@ -5,6 +5,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
// Set the favicon to the /favicon.ico file
import { useHead } from "@vueuse/head";
import favicon from '@/assets/favicon.ico';
import VersionCheck from "@/components/global/VersionCheck.vue";
useHead({
link: [
@@ -23,6 +24,7 @@ onMounted(() => {
</header>
<main>
<DefaultPageWrapper>
<VersionCheck/>
<router-view />
</DefaultPageWrapper>
</main>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+11 -1
View File
@@ -5,6 +5,7 @@ import { showPopper, popperBoxProductAddonHtml, removePopperIfOpen } from "@/com
import Swal from "sweetalert2";
import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
const expandIcon = ref(null);
@@ -29,6 +30,10 @@ const props = defineProps({
customerDiscounts: Array,
customerAttributes: Array,
compact: Boolean,
showPrices: {
type: Boolean,
default: true
}
});
const isAddonSelected = (productId, addonId) => {
@@ -200,7 +205,12 @@ const orderByOrderPriority = (addons) => {
<!-- Title -->
<div class="p-2">
<p class="title is-6 mb-0 text-automatic-shrink" style="font-size: small">{{ name }}</p>
<p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><small>579 kr</small></p>
<template v-if="showPrices">
<p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><small>{{ price }} kr</small></p>
</template>
<template v-else>
<p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><br/></p>
</template>
</div>
</div>
<!-- Price -->
@@ -1,5 +1,5 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import {defineProps, defineEmits, onMounted, computed} from 'vue';
import { useSlots } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
@@ -7,6 +7,7 @@ import Swal from "sweetalert2";
import { ref } from "vue";
import CustomerModal from "@/components/displays/modals/CustomerModal.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
const emit = defineEmits(['deleted']);
const emitDeleted = () => {
emit('deleted');
@@ -36,9 +37,54 @@ const props = defineProps({
type: String,
default: null
},
reg_2: {
type: String,
default: null
},
order_booking_id: {
type: Number,
default: null
},
customer_number: {
type: Number,
default: null
},
refreshFunction: {
type: Function,
default: () => {
console.warn("Refresh function is not defined");
}
},
department_id: {
type: Number,
default: null
},
attachments: {
type: Array,
default: () => []
},
displayActionsDirectly: {
type: Boolean,
default: false
}
});
const customerModalVisible = ref(false);
const userIdFromCustomerNumber = ref(null);
const getUserId = () => {
if (!!props.customer_number) {
SessionUser.adminUser.customers.fromCustomerNumber.getUserId(props.customer_number).then((response) => {
userIdFromCustomerNumber.value = response.data.data.user_id;
console.warn(userIdFromCustomerNumber.value);
}).catch((error) => {
console.error(error);
});
} else if (!!props.user_id) {
userIdFromCustomerNumber.value = props.user_id;
} else {
userIdFromCustomerNumber.value = null;
}
}
const showSetCustomerPassword = (userId) => {
Swal.fire({
@@ -85,163 +131,651 @@ const redirectSuperUserInvoiceCollectionPage = (invoiceCollectionId) => {
window.open(`/superuser/invoices/${invoiceCollectionId}`, '_blank');
};
const redirectDepartmentOrderPage = async (orderId) => {
const redirectDepartmentOrderPage = async (orderId, newTab = false) => {
// Send the user to the order page (In a new tab)
// Get the department id from the order
await SessionUser.objects.orders.functions.get_department_id(props.order_id).then((response) => {
console.warn(response);
// Get the department id from the response
// Send the user to the order page (In a new tab)
window.open(`/admin/${response}/modules/pos/orders/${orderId}`, '_blank');
SessionUser.functions.redirectTo.department(response, 'modules/pos/orders/' + orderId, !!newTab);
}).catch((error) => {
console.error(error);
});
};
const hasUser = () => {
if (userIdFromCustomerNumber.value) {
return true;
}
let hasUser = false;
if (!!props.user_id) {
hasUser = true;
userIdFromCustomerNumber.value = props.user_id;
}
if (!!props.customer_number) {
hasUser = true;
getUserId();
}
return hasUser;
}
const attachmentsFromOrder = ref([]);
const attachmentsFromOrderError = ref(null);
onMounted(() => {
if (props.order_id) {
SessionUser.objects.orders.functions.fetchAttachments(props.order_id).then((response) => {
/**
* [
* {
* "id": 4396,
* "object_type": "`orders`",
* "object_id": 30242,
* "content": {
* "image": null,
* "document": "pdf_6912ea1886f15.pdf",
* "relation": null,
* "other": "wash_certificate"
* },
* "created_at": "2025-11-11 08:47:36",
* "updated_at": null,
* "deleted_at": null
* }
* ]
*/
// Validate the response is an array
if (!Array.isArray(response)) {
response = [];
}
attachmentsFromOrder.value = response;
}).catch((error) => {
const parsedErrorMessage = SessionUser.functions.parseErrorMessage(error);
console.warn('Error fetching attachments from order:', error, parsedErrorMessage);
// Check if there's an error message in the response
attachmentsFromOrderError.value = `ERROR: ${parsedErrorMessage || 'Failed to fetch attachments'}`;
});
}
});
const onShowImpersonationQRCode = (src, directLink) => {
Swal.fire({
title: 'Scan QR-koden for at logge ind som bruger',
html: `<img src="${src}" alt="QR-kode" />`,
showCloseButton: true,
showConfirmButton: !!directLink,
showCancelButton: true,
focusConfirm: false,
confirmButtonText: 'Kopiér link',
cancelButtonText: 'Luk',
preConfirm: () => {
navigator.clipboard.writeText(directLink);
Swal.showValidationMessage('Link kopieret til udklipsholderen');
}
});
}
const onClickShowImpersonationQRCode = async (userId) => {
// Get the session token
const newToken = await SessionUser.superUser.intimidate.getImpersonationToken(userId);
// Create the impersonation link
const link = `${window.location.protocol}//${window.location.host}/login/qr?token=${newToken}`;
// Generate a QR code for the impersonation link
const response = await SessionUser.superUser.intimidate.showImpersonationQRCode(link);
// Show the QR code in a modal
onShowImpersonationQRCode(response.value, link);
}
const defaultActions = computed(() => {
return [
/**
* Example action
{
icon: 'fas fa-eye',
label: 'View Order',
clickAction: () => {
console.warn('View Order clicked');
},
showFunction: () => {
return true; // Logic to determine if action should be shown
},
disabled: false,
template: 'success' // Optional: Set the template to 'success' or 'danger'
isLabel: false // Optional: Set to true to render the label as a button
}
*/
/** Bookings actions */
{
label: 'Booking',
isLabel: true,
showFunction: () => {
return !!props.order_booking_id;
}
},
{
icon: 'fas fa-check-circle',
label: 'Marker som gennemført',
template: 'success',
clickAction: () => {
return SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(props.order_booking_id, () => {
props.refreshFunction();
});
},
showFunction: () => {
return !!props.order_booking_id && SessionUser.canAccessAdmin() && !props.order_id;
},
disabled: false,
},
{
icon: 'fas fa-external-link-alt',
label: 'Se booking i ny fane',
clickAction: () => {
return SessionUser.functions.redirectTo.department(props.department_id, 'modules/bookings/order/' + props.order_booking_id, true);
},
showFunction: () => {
return !!props.order_booking_id;
},
},
{
icon: 'fas fa-edit',
label: 'Ændr tilknytning',
clickAction: () => {
return SessionUser.objects.order_bookings.showEditObjectFieldForm(props.order_booking_id, 'order_id', props.order_id, () => {
props.refreshFunction();
}, {
filters: {
...(props.customer_number ? {customer_id: props.customer_number} : {}),
...(props.department_id ? {department_id: props.department_id} : {})
},
pagination: {
page: 1,
limit: 100
}
});
},
showFunction: () => {
return !!props.order_booking_id && SessionUser.canAccessAdmin();
},
disabled: false,
},
{
icon: 'fas fa-trash-alt',
label: 'Slet booking',
clickAction: () => {
return SessionUser.objects.order_bookings.functions.showDeleteConfirmationModal(props.order_booking_id, () => {
props.refreshFunction();
});
},
showFunction: () => {
return !!props.order_booking_id && SessionUser.canAccessAdmin() && !props.order_id;
},
disabled: false,
template: 'danger'
},
/** Orders actions */
{
label: SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single),
isLabel: true,
showFunction: () => {
return !!props.order_id;
}
},
{
icon: 'fas fa-external-link-alt',
label: 'Se ordre i ny fane',
clickAction: async () => {
return redirectDepartmentOrderPage(props.order_id, true);
},
showFunction: () => {
return !!props.order_id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
},
},
{
icon: 'fas fa-paperclip',
label: 'Vedhæft vaskecertifikat',
clickAction: () => {
return SessionUser.objects.orders.functions.showAttachWashCertificateForm(props.order_id, () => {
props.refreshFunction();
});
},
showFunction: () => {
return !!props.order_id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
},
disabled: false,
},
{
icon: 'fas fa-user-edit',
label: 'Skift kunde',
clickAction: () => {
return SessionUser.objects.orders.functions.showChangeCustomerForm(props.order_id);
},
showFunction: () => {
return !!props.order_id && SessionUser.canAccessSuperUser();
},
disabled: false,
},
{
icon: 'fas fa-file-invoice-dollar',
label: 'Skift faktura samling',
clickAction: () => {
return SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id);
},
showFunction: () => {
return !!props.order_id && SessionUser.canAccessSuperUser();
},
disabled: false,
},
{
icon: 'fas fa-download',
label: 'Download faktura',
clickAction: () => {
return SessionUser.objects.collectedOrderInvoices.functions.download(props.invoice_collection_id);
},
showFunction: () => {
return true;
},
disabled: false,
},
{
icon: 'fas fa-trash-alt',
label: 'Slet ordre',
clickAction: () => {
return SessionUser.objects.orders.functions.showDeleteConfirmationModal(props.order_id, () => {
emitDeleted();
});
},
showFunction: () => {
return !!props.order_id && (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser());
},
disabled: false,
template: 'danger'
},
{
icon: 'fas fa-file-invoice-dollar',
label: 'Vis faktura samling i ny fane',
clickAction: () => {
return redirectSuperUserInvoiceCollectionPage(props.invoice_collection_id);
},
showFunction: () => {
return !!props.invoice_collection_id && SessionUser.canAccessSuperUser();
},
},
/** Vehicle actions */
{
label: props.reg_1 && props.reg_2 ? `${SessionUser.objects.vehicles.meta.labels.multiple}` : `${SessionUser.objects.vehicles.meta.labels.single}`,
isLabel: true,
showFunction: () => {
return (!!props.reg_1 || !!props.reg_2) && SessionUser.canAccessSuperUser();
}
},
{
icon: 'fas fa-car',
label: `Se køretøj ${props.reg_1 ? props.reg_1 : '-'} i ny fane`,
clickAction: () => {
return SessionUser.functions.redirectTo.superUser('/vehicles/' + props.reg_1, true);
},
showFunction: () => {
return !!props.reg_1 && SessionUser.canAccessSuperUser();
},
},
{
icon: 'fas fa-car',
label: `Se køretøj ${props.reg_2 ? props.reg_2 : '-'} i ny fane`,
clickAction: () => {
return SessionUser.functions.redirectTo.superUser('/vehicles/' + props.reg_2, true);
},
showFunction: () => {
return !!props.reg_2 && SessionUser.canAccessSuperUser();
},
},
/** User actions */
{ label: SessionUser.objects.global.language.customer,
isLabel: true,
showFunction: () => {
return hasUser() && SessionUser.canAccessSuperUser();
}
},
{
icon: 'fas fa-user-edit',
label: 'Se kunde i ny fane',
clickAction: () => {
return SessionUser.functions.redirectTo.superUser('/users/' + userIdFromCustomerNumber.value, true);
},
showFunction: () => {
return hasUser() && SessionUser.canAccessSuperUser() && userIdFromCustomerNumber.value;
},
},
{
icon: 'fas fa-user-shield',
label: 'Log ind som kunde',
clickAction: () => {
return SessionUser.superUser.intimidate.intimidateUser(userIdFromCustomerNumber.value);
},
showFunction: () => {
return hasUser() && SessionUser.canAccessSuperUser();
},
},
{
icon: 'fas fa-qrcode',
label: 'Vis QR-kode',
clickAction: () => {
return onClickShowImpersonationQRCode(userIdFromCustomerNumber.value);
},
showFunction: () => {
return hasUser() && SessionUser.canAccessSuperUser();
}
},
{
icon: 'fas fa-key',
label: 'Skift kodeord',
clickAction: () => {
return showSetCustomerPassword(userIdFromCustomerNumber.value);
},
showFunction: () => {
return hasUser() && SessionUser.canAccessSuperUser();
},
},
{
icon: 'fas fa-user',
label: 'Vis kunde',
clickAction: () => {
customerModalVisible.value = true;
},
showFunction: () => {
return hasUser() && SessionUser.canAccessSuperUser();
},
},
];
});
</script>
<template>
<div class="dropdown is-right is-hoverable">
<div class="dropdown-trigger">
<button class="button is-small is-dark" aria-haspopup="true" aria-controls="dropdown-menu">
<span class="icon">
<i :class="props.icon"></i>
</span>
<span v-if="props.label.length > 0" class="ml-2">{{ props.label }}</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<!-- Actions (If defined) -->
<template v-if="useSlots().actions">
<slot name="actions"></slot>
<!-- If there's a user_id, show the user actions -->
<template v-if="props.user_id">
<!-- Label -->
<ActionSettingsWheelItemLabel
:label="SessionUser.objects.global.language.customer"
<div>
<template v-if="displayActionsDirectly">
<!-- Error -->
<template v-if="attachmentsFromOrderError">
<div class="message is-danger">
<div class="message-body">
{{ attachmentsFromOrderError }}
</div>
</div>
</template>
<template v-for="(action, index) in [ ...defaultActions, ...(useSlots().actions ? [] : []) ]" :key="index">
<ActionSettingsWheelItem
v-if="(!action.showFunction || action.showFunction()) && (!action.isLabel || action.isLabel === false)"
:icon="action.icon"
:label="action.label"
:template="action.template"
:click-action="action.clickAction"
:show-function="action.showFunction"
:disabled="action.disabled"
/>
<ActionSettingsWheelItemLabel
v-else-if="action.isLabel && action.isLabel === true && (!action.showFunction || action.showFunction())"
:label="action.label"
/>
</template>
<!-- If there are attachments, show the attachments -->
<template v-if="attachmentsFromOrder.length > 0">
<ActionSettingsWheelItemLabel :label="'Vedhæftede filer'"/>
<ActionSettingsWheelItem
v-for="attachment in attachmentsFromOrder"
:key="attachment.id"
:icon="'fas fa-paperclip'"
:label="`Åbn vedhæftede fil #${attachment.id}`"
:click-action="() => SessionUser.objects.orders.functions.downloadAttachment(props.order_id, attachment.id, true)
.catch((error) => {
Swal.fire({
title: 'Fejl',
text: 'Der opstod en fejl under download af vedhæftede fil.',
icon: 'error',
});
})"
/>
</template>
</template>
<div class="dropdown is-right is-hoverable" v-else>
<div class="dropdown-trigger">
<button class="button is-small is-dark" aria-haspopup="true" aria-controls="dropdown-menu">
<span class="icon">
<i :class="props.icon"></i>
</span>
<span v-if="props.label.length > 0" class="ml-2">{{ props.label }}</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<!-- Actions (If defined) -->
<template v-if="useSlots().actions">
<slot name="actions"></slot>
<!-- If there is an order_booking_id, show the order booking actions -->
<template v-if="props.order_booking_id">
<ActionSettingsWheelItemLabel :label="'Booking'"/>
<ActionSettingsWheelItem
v-if="(SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()) && !props.order_id"
:icon="'fas fa-check-circle'"
:label="'Marker som gennemført'"
template="success"
:click-action="() => SessionUser.objects.order_bookings.functions.showCompleteConfirmationModal(props.order_booking_id, () => {
props.refreshFunction();
})"
/>
<ActionSettingsWheelItem
:icon="'fas fa-external-link-alt'"
:label="'Se booking i ny fane'"
:click-action="() => SessionUser.functions.redirectTo.department(props.department_id, 'modules/bookings/order/' + props.order_booking_id, true)"
/>
<ActionSettingsWheelItem
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
:icon="'fas fa-edit'"
:label="(!props.order_id) ? `Tilknyt ${SessionUser.objects.orders.meta.labels.single.toLowerCase()}` : `Ændr ${SessionUser.objects.orders.meta.labels.single.toLowerCase()}`"
:click-action="() => SessionUser.objects.order_bookings.showEditObjectFieldForm(props.order_booking_id, 'order_id', props.order_id, () => {
props.refreshFunction();
}, {
filters: {
...(customer_number ? {customer_id: props.customer_number} : {}),
...(props.department_id ? {department_id: props.department_id} : {})
},
pagination: {
page: 1,
limit: 100
}
})"
/>
<ActionSettingsWheelItem
:icon="'fas fa-trash-alt'"
:label="'Slet booking'"
template="danger"
v-show="!props.order_id"
:click-action="() => SessionUser.objects.order_bookings.functions.showDeleteConfirmationModal(props.order_booking_id, () => {
props.refreshFunction();
})"
/>
</template>
<!-- If there's an order_id, show the order actions -->
<template v-if="props.order_id">
<!-- Label -->
<action-settings-wheel-item-label
:label="SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single)"
/>
<!-- Go to the order page, in a new tab -->
<action-settings-wheel-item
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
:label="`Se ${SessionUser.objects.orders.meta.labels.single.toLowerCase()} i ny fane`"
icon="fas fa-external-link-alt"
:click-action="async () => redirectDepartmentOrderPage(props.order_id, true)"
:disabled="false"
/>
<!-- Go to the user/order/:order_id page, in a new tab -->
<action-settings-wheel-item
v-if="!SessionUser.canAccessAdmin() && !SessionUser.canAccessSuperUser()"
:label="`Se ${SessionUser.objects.orders.meta.labels.single.toLowerCase()} i ny fane`"
icon="fas fa-external-link-alt"
:click-action="() => SessionUser.functions.redirectTo.user('/orders/' + props.order_id, true)"
:disabled="false"
/>
<!-- Attach wash certificate -->
<action-settings-wheel-item
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
label="Vedhæft vaskecertifikat"
icon="fas fa-paperclip"
:click-action="() => SessionUser.objects.orders.functions.showAttachWashCertificateForm(props.order_id, () => {
props.refreshFunction();
})"
:disabled="false"
/>
<!-- Change the customer (of the order) -->
<action-settings-wheel-item
v-if="SessionUser.canAccessSuperUser()"
label="Skift kunde"
icon="fas fa-user-edit"
:click-action="() => SessionUser.objects.orders.functions.showChangeCustomerForm(props.order_id)"
:disabled="false"
/>
<!-- Change the invoice collection (of the order) -->
<action-settings-wheel-item
v-if="SessionUser.canAccessSuperUser()"
label="Skift faktura samling"
icon="fas fa-file-invoice-dollar"
:click-action="() => SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id)"
:disabled="false"
/>
<!-- Delete the order -->
<action-settings-wheel-item
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
:label="`Slet ${SessionUser.objects.orders.meta.labels.single.toLowerCase()}`"
template="danger"
icon="fas fa-trash-alt"
:click-action="() => SessionUser.objects.orders.functions.showDeleteConfirmationModal(props.order_id, () => {
emitDeleted();
})"
:disabled="false"
/>
</template>
<!-- If there is an invoice collection id, show the invoice actions -->
<template v-if="props.invoice_collection_id">
<!-- Label -->
<action-settings-wheel-item-label
:label="SessionUser.objects.collectedOrderInvoices.meta.title"
v-show="SessionUser.canAccessSuperUser()"
/>
<!-- Se bruger -->
<ActionSettingsWheelItem
label="Se kunde i ny fane"
icon="fas fa-user-edit"
:click-action="() => SessionUser.functions.redirectTo.superUser('/users/' + props.user_id, true)"
v-if="SessionUser.canAccessSuperUser()"
/>
<!-- Log ind som kunde -->
<ActionSettingsWheelItem
label="Log ind som kunde"
icon="fas fa-user-shield"
:click-action="() => SessionUser.superUser.intimidate.intimidateUser(props.user_id)"
v-if="SessionUser.canAccessSuperUser()"
/>
/>
<!-- Show the invoice collection, in a new tab -->
<action-settings-wheel-item
v-if="SessionUser.canAccessSuperUser()"
label="Vis faktura samling i ny fane"
icon="fas fa-file-invoice-dollar"
:click-action="() => redirectSuperUserInvoiceCollectionPage(props.invoice_collection_id)"
/>
</template>
<!-- If there's a user_id, show the user actions -->
<template v-if="hasUser()">
<!-- Label -->
<ActionSettingsWheelItemLabel
:label="SessionUser.objects.global.language.customer"
v-show="SessionUser.canAccessSuperUser()"
/>
<!-- Se bruger -->
<ActionSettingsWheelItem
label="Se kunde i ny fane"
icon="fas fa-user-edit"
:click-action="() => SessionUser.functions.redirectTo.superUser('/users/' + userIdFromCustomerNumber, true)"
v-if="SessionUser.canAccessSuperUser() && userIdFromCustomerNumber"
/>
<!-- Log ind som kunde -->
<ActionSettingsWheelItem
label="Log ind som bruger"
icon="fas fa-user-shield"
:click-action="() => SessionUser.superUser.intimidate.intimidateUser(userIdFromCustomerNumber)"
v-if="SessionUser.canAccessSuperUser() && userIdFromCustomerNumber"
/>
<!-- Log ind som kunde (QR) -->
<ActionSettingsWheelItem
@click="onClickShowImpersonationQRCode(userIdFromCustomerNumber)"
icon="fas fa-user-shield"
label="Log ind som bruger (QR-kode)"
v-if="SessionUser.canAccessSuperUser() && userIdFromCustomerNumber"
/>
<!-- Set password -->
<ActionSettingsWheelItem
label="Skift kodeord"
icon="fas fa-key"
:click-action="() => showSetCustomerPassword(props.user_id)"
v-if="SessionUser.canAccessSuperUser()"
/>
<!-- Show customer modal -->
<ActionSettingsWheelItem
label="Vis kunde"
icon="fas fa-user"
:click-action="() => customerModalVisible = true"
v-if="SessionUser.canAccessSuperUser()"
/>
</template>
<!-- If there's an order_id, show the order actions -->
<template v-if="props.order_id">
<!-- Label, only if the user is a superuser (Otherwise, the label is redundant) -->
<action-settings-wheel-item-label
:label="SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single)"
v-show="SessionUser.canAccessSuperUser()"
/>
<!-- Go to the order page, in a new tab -->
<action-settings-wheel-item
v-if="SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()"
label="Se transaktion i ny fane"
icon="fas fa-external-link-alt"
:click-action="async () => redirectDepartmentOrderPage(props.order_id)"
:disabled="false"
/>
<!-- Go to the user/order/:order_id page, in a new tab -->
<action-settings-wheel-item
v-if="!SessionUser.canAccessAdmin() && !SessionUser.canAccessSuperUser()"
label="Se transaktion i ny fane"
icon="fas fa-external-link-alt"
:click-action="() => SessionUser.functions.redirectTo.user('/orders/' + props.order_id, true)"
:disabled="false"
<!-- Set password -->
<ActionSettingsWheelItem
label="Skift kodeord"
icon="fas fa-key"
:click-action="() => showSetCustomerPassword(userIdFromCustomerNumber)"
v-if="SessionUser.canAccessSuperUser()"
/>
<!-- Show customer modal -->
<ActionSettingsWheelItem
label="Vis kunde"
icon="fas fa-user"
:click-action="() => customerModalVisible = true"
v-if="SessionUser.canAccessSuperUser()"
/>
</template>
<!-- If there is a reg_1, show the reg_1 actions -->
<template v-if="props.reg_1 || props.reg_2">
<!-- Label -->
<action-settings-wheel-item-label
:label="props.reg_1 && props.reg_2 ? `${SessionUser.objects.vehicles.meta.labels.multiple}` : `${SessionUser.objects.vehicles.meta.labels.single}`"
v-show="SessionUser.canAccessSuperUser()"
/>
<!-- Show the vehicle, in a new tab -->
<action-settings-wheel-item
:label="`Se ${SessionUser.objects.vehicles.meta.labels.single.toLowerCase()} ${props.reg_1} i ny fane`"
icon="fas fa-car"
v-if="props.reg_1 && SessionUser.canAccessSuperUser()"
:click-action="() => SessionUser.functions.redirectTo.superUser('/vehicles/' + props.reg_1, true)"
/>
<action-settings-wheel-item
:label="`Se ${SessionUser.objects.vehicles.meta.labels.single.toLowerCase()} ${props.reg_2} i ny fane`"
icon="fas fa-car"
v-if="props.reg_2 && SessionUser.canAccessSuperUser()"
:click-action="() => SessionUser.functions.redirectTo.superUser('/vehicles/' + props.reg_2, true)"
/>
<!-- If there are any attachments, show the attachments actions -->
<template v-if="attachmentsFromOrder.length > 0">
<action-settings-wheel-item-label
:label="'Vedhæftede filer'"
/>
<!-- 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()"
label="Skift kunde"
icon="fas fa-user-edit"
:click-action="() => SessionUser.objects.orders.functions.showChangeCustomerForm(props.order_id)"
:disabled="false"
/>
<!-- Change the invoice collection (of the order) -->
<action-settings-wheel-item
v-if="SessionUser.canAccessSuperUser()"
label="Skift faktura samling"
icon="fas fa-file-invoice-dollar"
:click-action="() => SessionUser.objects.orders.functions.showChangeInvoiceCollectionForm(props.order_id)"
:disabled="false"
/>
<template v-for="(attachment, index) in attachmentsFromOrder" :key="index">
<action-settings-wheel-item
:label="`${attachment.content?.document || attachment.content?.other}`"
icon="fas fa-paperclip"
:click-action="() => SessionUser.objects.orders.functions.downloadAttachment(props.order_id, attachment.id, true)
.catch((error) => {
Swal.fire({
title: 'Fejl',
text: 'Der opstod en fejl under download af vedhæftede fil.',
icon: 'error',
});
})"
/>
</template>
</template>
</template>
<!-- Collected order invoice actions -->
<template v-if="props.invoice_collection_id">
<ActionSettingsWheelItemLabel
:label="SessionUser.objects.collectedOrderInvoices.meta.title"
v-show="props.invoice_collection_id"
/>
<!-- Download invoice -->
<ActionSettingsWheelItem
label="Download faktura"
icon="fas fa-download"
:click-action="() => SessionUser.objects.collectedOrderInvoices.functions.download(props.invoice_collection_id)"
/>
</template>
</template>
<!-- If there is an invoice collection id, show the invoice actions -->
<template v-if="props.invoice_collection_id">
<!-- Label -->
<action-settings-wheel-item-label
:label="SessionUser.objects.collectedOrderInvoices.meta.title"
v-show="SessionUser.canAccessSuperUser()"
/>
<!-- Show the invoice collection, in a new tab -->
<action-settings-wheel-item
v-if="SessionUser.canAccessSuperUser()"
label="Vis faktura samling i ny fane"
icon="fas fa-file-invoice-dollar"
:click-action="() => redirectSuperUserInvoiceCollectionPage(props.invoice_collection_id)"
/>
<!-- If no actions are defined, then show that no actions are defined -->
<template v-else>
<div class="dropdown-item">
<p class="has-text-centered">Ingen handlinger defineret</p>
</div>
</template>
<!-- If there is a reg_1, show the reg_1 actions -->
<template v-if="props.reg_1">
<!-- Label -->
<action-settings-wheel-item-label
:label="SessionUser.objects.vehicles.meta.labels.single"
v-show="SessionUser.canAccessSuperUser()"
/>
<!-- Show the vehicle, in a new tab -->
<action-settings-wheel-item
label="Vis køretøj i ny fane"
icon="fas fa-car"
v-if="SessionUser.canAccessSuperUser()"
:click-action="() => SessionUser.functions.redirectTo.superUser('/vehicles/' + props.reg_1, true)"
/>
</template>
</template>
<!-- If no actions are defined, then show that no actions are defined -->
<template v-else>
<div class="dropdown-item">
<p class="has-text-centered">Ingen handlinger defineret</p>
</div>
</template>
</div>
</div>
</div>
<CustomerModal
v-if="customerModalVisible"
:user_id="props.user_id"
:user_id="userIdFromCustomerNumber"
@closeModal="customerModalVisible = false"
/>
</div>
@@ -2,6 +2,7 @@
import { IS_DEV } from '@/main';
import { ref, computed, defineProps, defineEmits, watch } from 'vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import {BField, BSelect} from "buefy";
const props = defineProps({
selection: {
@@ -39,6 +40,7 @@ const props = defineProps({
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
showShortCuts?: boolean, // Whether to show the shortcuts
},
required: false,
default: () => ({
@@ -87,6 +89,7 @@ const visibility = computed(() => {
showYearSelector: true,
showDailySelector: false,
showWeeklySelector: false,
showShortCuts: true,
// Override with props visibility settings
...props.visibility,
};
@@ -315,14 +318,104 @@ const weeklyShortcuts: shortcut[] = [
},
];
const shortcuts = ref<shortcut[]>([
...(visibility.value.showDailySelector ? dailyShortcuts : []),
...(visibility.value.showWeeklySelector ? weeklyShortcuts : []),
...((visibility.value.showDailySelector || visibility.value.showShortCuts) ? dailyShortcuts : []),
...((visibility.value.showWeeklySelector || visibility.value.showShortCuts) ? weeklyShortcuts : []),
]);
</script>
<template>
<div class="level is-mobile is-align-items-center">
<!-- Mobile date selector -->
<div class="columns is-mobile is-multiline" v-show="SessionUser.functions.device.isMobile()">
<!-- Date from & Date to -->
<div class="column is-half">
<label class="label is-small" v-if="visibility.showStartDate">{{ SessionUser.objects.global.language.text.date_from }}</label>
<input
type="date"
class="input"
v-model="props.selection.startDate.toISOString().split('T')[0]"
@change="handleStartDateChange"
v-bind:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
/>
</div>
<div class="column is-half">
<label class="label is-small" v-if="visibility.showEndDate">{{ SessionUser.objects.global.language.text.date_to }}</label>
<input
type="date"
class="input"
v-model="props.selection.endDate.toISOString().split('T')[0]"
@change="handleEndDateChange"
v-bind:disabled="props.isDisabled || props.isReadonly"
:class="{ 'is-danger': !isSelectionValid }"
/>
</div>
<!-- Month selector -->
<div class="column is-half" v-if="visibility.showMonthSelector">
<div class="field has-addons is-centered">
<p class="control is-expanded">
<span class="select is-fullwidth">
<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>
</div>
</div>
<!-- Year selector -->
<div class="column is-half" v-if="visibility.showYearSelector">
<div class="field has-addons is-centered">
<p class="control is-expanded">
<span class="select is-fullwidth">
<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>
<!-- Shortcuts -->
<div class="column is-full">
<b-field label="Periode">
<b-select placeholder="Vælg periode"
:expandable="true"
:model-value="shortcuts.find(s => (s.getRange().startDate.toISOString().split('T')[0] === startDate.toISOString().split('T')[0]) && (s.getRange().endDate.toISOString().split('T')[0] === endDate.toISOString().split('T')[0]))?.label || null"
:expanded="true"
@update:modelValue="(value) => { const selectedShortcut = shortcuts.find(s => s.label === value); if (selectedShortcut) { const range = selectedShortcut.getRange(); startDate = range.startDate; endDate = range.endDate; isPendingEmit = true; if (props.autoEmitChange) { updateSelection(); } } }" v-bind:disabled="props.isDisabled || props.isReadonly">
<option
v-for="(option, index) in shortcuts"
:key="index"
:value="option.label"
>{{ option.label }}
</option>
</b-select>
</b-field>
</div>
</div>
<!-- Desktop date selector -->
<div class="level is-align-items-center" v-show="!SessionUser.functions.device.isMobile()">
<div class="level-left">
<!-- Start Date Input -->
<div class="level-item" v-if="visibility.showStartDate">
@@ -350,7 +443,7 @@ const shortcuts = ref<shortcut[]>([
/>
</div>
<!-- Display a message if neither start nor end date is shown -->
<div class="level-item" v-if="!visibility.showStartDate && !visibility.showEndDate">
<div class="level-item" v-if="!visibility.showStartDate && !visibility.showEndDate && visibility.showToLabel">
<span class="label">No date selection available</span>
</div>
<!-- Month and Year selectors -->
@@ -438,7 +531,7 @@ const shortcuts = ref<shortcut[]>([
</div>
</div>
</div>
<div class="navbar is-white">
<div class="navbar is-white" v-show="!SessionUser.functions.device.isMobile()">
<div class="navbar-menu">
<!-- Date shortcuts -->
<div class="navbar-start">
@@ -1,6 +1,7 @@
<script setup>
import {defineProps, ref} from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {BTooltip} from "buefy";
const props = defineProps({
object: Object,
loadList: Function,
@@ -8,6 +9,12 @@ const props = defineProps({
column: String,
parseFunction: Function,
virtualColumn: Boolean,
hoverText: {
type: String,
default: () => {
return null;
}
},
permissionCheckFunction: {
type: Function,
default: () => {
@@ -20,6 +27,23 @@ const props = defineProps({
default: () => {
return null; // Example: <td>
}
},
// Theme can be 'divided' or 'simple'
theme: {
type: String,
validator(value, props) {
return ['divided', 'simple'].includes(value);
},
default: () => {
return 'simple';
}
},
// Hover HTML content
hoverHtml: {
type: Boolean,
default: () => {
return false;
}
}
});
@@ -52,19 +76,62 @@ const isLoading = ref(false);
</script>
<template>
<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()">
<button class="button is-small is-transparent is-text" style="text-decoration-line: none;" @click="clickEdit" v-if="props.editFunction" :class="{'is-loading': isLoading}" :disabled="isLoading">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</button>
</span>
<component :is="props.componentWrapper ? props.componentWrapper : 'td'"
@click="clickEdit"
class="hover-illustration"
:class="{'is-clickable': props.permissionCheckFunction() && props.editFunction}">
<div>
<!-- Divided display ( value | edit button ) -->
<template v-if="props.theme === 'divided'">
<nav class="level is-mobile" style="min-width: -webkit-fill-available; width: -webkit-fill-available; overflow: visible; max-width: -webkit-fill-available;">
<div class="level-left" style="overflow: hidden; max-width: calc(100% - 2.5em); text-overflow: ellipsis; white-space: nowrap;">
<div class="level-item" :title="props.hoverText ? props.hoverText : ( !props.virtualColumn ? (parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column]) : (parseFunction ? parseFunction(props.object) : props.object) )">
<span class="" v-if="!props.virtualColumn">{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column] }}</span>
<span class="" v-else>{{ parseFunction ? parseFunction(props.object) : props.object }}</span>
</div>
</div>
<div class="level-right">
<div class="level-item">
<span class="ml-2 is-float-right" v-if="props.permissionCheckFunction()">
<button class="button is-small is-transparent is-text" style="text-decoration-line: none;" @click="clickEdit" v-if="props.editFunction" :class="{'is-loading': isLoading}" :disabled="isLoading">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</button>
</span>
</div>
</div>
</nav>
</template>
<!-- Simple display ( value ) with edit button on hover -->
<template v-else>
<!-- Hover Edit icon -->
<b-tooltip
type="is-light"
position="is-bottom"
:multilined="$slots.hoverHtml"
square>
<!-- Value Display -->
<span class="" v-if="!props.virtualColumn">{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column] }}</span>
<span class="" v-else>{{ parseFunction ? parseFunction(props.object) : props.object }}</span>
<template v-slot:content>
<slot name="hoverHtml">
<div v-html="props.hoverText" v-if="props.hoverText"></div>
<div v-else>
{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column]}}
</div>
</slot>
</template>
</b-tooltip>
</template>
</div>
</component>
</template>
<style scoped>
.hover-illustration:hover {
background-color: #f5f5f5;
border-radius: 4px;
}
</style>
@@ -57,38 +57,42 @@ const modules = [
},
count: ref(0)
},
{
id: 4,
name: "Notifikationer",
description: "Gå til notifikationer",
icon: "fas fa-bell",
onClick: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the notifications
router.push(`/admin/${departmentId}/modules/notifications`);
},
onClickCount: () => {
this.onClick();
},
count: ref(0)
},
{
id: 5,
name: "Tidsbookinger",
description: "Gå til tidsbookinger",
icon: "fas fa-clock",
onClick: () => {
// Get the departmentId from the URL
const departmentId = router.currentRoute.value.params.departmentId;
// Redirect to the time bookings
router.push(`/admin/${departmentId}/modules/time-bookings`);
},
onClickCount: () => {
this.onClick();
},
count: ref(0)
}
/**
* {
* id: 4,
* name: "Notifikationer",
* description: "Gå til notifikationer",
* icon: "fas fa-bell",
* onClick: () => {
* // Get the departmentId from the URL
* const departmentId = router.currentRoute.value.params.departmentId;
* // Redirect to the notifications
* router.push(`/admin/${departmentId}/modules/notifications`);
* },
* onClickCount: () => {
* this.onClick();
* },
* count: ref(0)
* },
*/
/**
* {
* id: 5,
* name: "Tidsbookinger",
* description: "Gå til tidsbookinger",
* icon: "fas fa-clock",
* onClick: () => {
* // Get the departmentId from the URL
* const departmentId = router.currentRoute.value.params.departmentId;
* // Redirect to the time bookings
* router.push(`/admin/${departmentId}/modules/time-bookings`);
* },
* onClickCount: () => {
* this.onClick();
* },
* count: ref(0)
* }
*/
];
// Get the departmentId from the URL
@@ -71,8 +71,8 @@ const hasUpdatePermission = () => {
:permission-check-function="hasUpdatePermission"
/>
<!-- Actions -->
<td class="is-float-right">
<ActionSettingsWheelButton>
<td>
<ActionSettingsWheelButton class="is-float-right">
<template #actions>
<RequiresPermission :permission="'department_notification_sms_delete'">
<template #default>
@@ -99,7 +99,7 @@ const hasUpdatePermission = () => {
</tbody>
<tfoot>
<tr>
<td colspan="12">{{SessionUser.objects.global.language.showing}} {{ objects.length }} {{SessionUser.objects.department_notification_sms.meta.labels.multiple}}</td>
<td colspan="6">{{SessionUser.objects.global.language.showing}} {{ objects.length }} {{SessionUser.objects.department_notification_sms.meta.labels.multiple}}</td>
</tr>
</tfoot>
</table>
@@ -40,10 +40,10 @@ const pendingBookings = ref([]);
// Get the department booking list
const loadPendingBookings = () => {
SessionUser.request(
SessionUser.objects.bookings.meta.endpoint,
SessionUser.objects.order_bookings.meta.endpoint,
'GET',
{
filters: 'department:' + department_id.value + ',status:pending',
filters: 'department:' + department_id.value + ',order_id:is null',
page: 1,
limit: 100,
}
@@ -72,12 +72,12 @@ const getVehiclePlateBooking = (vehiclePlate) => {
return null;
}
// Find the booking for the given vehicle plate
return pendingBookings.value.find(booking => booking.regNrTraekker === vehiclePlate || booking.regNrTrailer === vehiclePlate);
return pendingBookings.value.find(booking => booking.reg_1 === vehiclePlate || booking.reg_2 === vehiclePlate);
};
const doesVehiclePlateHaveBooking = (vehiclePlate) => {
// Check if the vehicle has a booking in the pending bookings list
return pendingBookings.value.some(booking => booking.regNrTraekker === vehiclePlate || booking.regNrTrailer === vehiclePlate);
return pendingBookings.value.some(booking => booking.reg_1 === vehiclePlate || booking.reg_2 === vehiclePlate);
};
@@ -681,7 +681,7 @@ const isDropdownContentLoaded = (scan) => {
<tr v-if="scansInfinityScroll !== null && scansInfinityScroll.length === 0">
<td colspan="3">
<div class="has-text-centered">
Ingen flere nummerplader
Ingen nummerplader blev fundet
</div>
</td>
</tr>
@@ -695,6 +695,7 @@ const isDropdownContentLoaded = (scan) => {
<span class="icon">
<i class="fas fa-check"></i>
</span>
<span>&nbsp;</span>
<span>{{ SessionUser.objects.global.language.nothing_left_to_show }}</span>
</div>
</td>
@@ -702,12 +703,6 @@ const isDropdownContentLoaded = (scan) => {
</tbody>
</table>
</div>
<!-- If there's no scans, and the infinity scroll is also empty or null -->
<div class="panel-block" v-if="list.length === 0 && getDepartment() !== '' && (scansInfinityScroll === null || scansInfinityScroll.length === 0)">
<p>
Ingen nummerplader blev fundet
</p>
</div>
<!-- Reload button -->
<div class="panel-block" v-if="list.length === 0 && getDepartment() !== '' && scansInfinityScroll === null">
<button class="button is-dark is-fullwidth" @click="loadList">
@@ -5,23 +5,38 @@ import productImage02 from "/src/assets/piktogrammer/02.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";
import productImage05 from "/src/assets/piktogrammer/05.png"; // Deprecated - use 5
import productImage5 from "/src/assets/piktogrammer/5.png"; // Forvogn
import productImage06 from "/src/assets/piktogrammer/06.png"; // Deprecated - use 6
import productImage6 from "/src/assets/piktogrammer/6.png"; // Forvogn med hænger
import productImage07 from "/src/assets/piktogrammer/07.png";
import productImage08 from "/src/assets/piktogrammer/08.png";
import productImage09 from "/src/assets/piktogrammer/09.png";
import productImage10 from "/src/assets/piktogrammer/10.png";
import productImage11 from "/src/assets/piktogrammer/11.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 productImage15 from "/src/assets/piktogrammer/15.png";
import productImage16 from "/src/assets/piktogrammer/16.png"; // Autocamper / Campingvogn
import productImage18 from "/src/assets/piktogrammer/18.png";
import productImage17 from "/src/assets/piktogrammer/17.png"; // Bus
import productImage18 from "/src/assets/piktogrammer/18.png"; // Entreprenør forvogn
import productImage19 from "/src/assets/piktogrammer/19.png"; // Entreprenør forvogn med hænger
import productImage20 from "/src/assets/piktogrammer/20.png"; // Entreprenør sættevogn
import productImage21 from "/src/assets/piktogrammer/21.png"; // Undervognsskyld.
import productImage23 from "/src/assets/piktogrammer/23.png"; // Spotfree
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
import productImage25 from "/src/assets/piktogrammer/25.png"; // Fælg flex
import productImage26 from "/src/assets/piktogrammer/26.png"; // Højglans
import productImage27 from "/src/assets/piktogrammer/27.png"; // 10 Minutter
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
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 productImage49 from "/src/assets/piktogrammer/49.png"; // Interior wash forvogn hænger
import productImage56 from "/src/assets/piktogrammer/56.png"; // Safety Seal
// Get the picture of the product
export const getPicture = (piktogram) => {
@@ -46,9 +61,11 @@ export const getPicture = (piktogram) => {
case "04":
return productImage04;
case "05":
return productImage05;
case "5": // Forvogn
return productImage5;
case "06":
return productImage06;
case "6": // Forvogn med hænger
return productImage6;
case "07":
return productImage07;
case "08":
@@ -57,16 +74,40 @@ export const getPicture = (piktogram) => {
return productImage09;
case "10":
return productImage10;
case "11":
return productImage11;
case "12":
return productImage12;
case "13":
return productImage13;
case "14":
return productImage14;
case "15":
return productImage15;
case "16":
return productImage16;
case "17":
return productImage17;
case "18":
return productImage18;
case "19":
return productImage19;
case "20":
return productImage20;
case "21":
return productImage21;
case "23":
return productImage23;
case "24":
return productImage24;
case "25":
return productImage25;
case "26":
return productImage26;
case "27":
return productImage27;
case "39":
return productImage39;
case "43":
return productImage43;
case "44":
@@ -79,6 +120,8 @@ export const getPicture = (piktogram) => {
return productImage47;
case "48":
return productImage48;
case "49":
return productImage49;
case "56":
return productImage56;
default:
File diff suppressed because it is too large Load Diff
@@ -39,7 +39,7 @@ const right_tabs = [
<div class="columns">
<!-- Choose products -->
<div class="column is-8-desktop pl-0 pr-3">
<SelectProductsFormPOS class="mt-3" category="null" />
<SelectProductsFormPOS class="mt-3" category="null" :isCustomerBooking="false"/>
<PosNotes :notes="notes" class="mt-3" :isAddFormVisible="false" :isOldNotesVisible="true"/>
</div>
<!-- Current order items -->
@@ -31,7 +31,10 @@ import PosDepartmentStepMobileAttachments
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
import PosDepartmentStep1MobileTransactionHistory
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
import {attachments} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import {
attachments,
popups
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
// Debug mode flag
const debug_mode = ref(false);
// Debug array to store request results
@@ -169,6 +172,7 @@ const onAutomaticSelection = (object: PosSearchResult | null) => {
reference: object?.reference || null,
last_order_id: object?.lastOrderId || null,
wash_subscription: object?.washSubscription,
booking_id: object?.bookingId || null,
} as PosVehicle;
switch (vehicles.activeVehicleIndex.value) {
case 1:
@@ -269,7 +273,7 @@ onUnmounted(() => {
</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(); }">
<PosDepartmentStepMobileButtonNextStep :isWhite="false" v-if="views.attachmentView.value" style="width: 100%;" class="mb-2" :customDisabled="false" :customAction="() => { attachments.takePicture(); }" :buttonClasses="['has-background-primary-dark', 'has-text-black']">
<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 -->
@@ -281,9 +285,9 @@ onUnmounted(() => {
</div>
<div class="is-flex is-justify-content-center">
<!-- Next step button (If not in attachment view) -->
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="!views.attachmentView.value"/>
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="!views.attachmentView.value" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false"/>
<!-- Next step button (If in attachment view) -->
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="views.attachmentView.value" :customDisabled="false" :customAction="() => { views.attachmentView.value = false; }">
<PosDepartmentStepMobileButtonNextStep style="width: 100%;" v-if="views.attachmentView.value" :customDisabled="false" :customAction="() => { views.attachmentView.value = false; }" :buttonClasses="['has-background-primary', 'has-text-black']" :isWhite="false">
<span class="is-float-left has-text-dark">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right has-text-dark">
<!-- x icon -->
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { setTransparency, setBackgroundColor, backgroundColors } from "@/components/viewport/page/headers/ViewportHeaderSettings.vue";
import { onMounted, ref, watch } from "vue";
import {onMounted, onUnmounted, ref, watch} from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobile2Product
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Product.vue";
@@ -31,6 +31,7 @@ import PosDepartmentStepMobile2AdditionalItems
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue";
import PosDepartmentStepMobile2Customer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Customer.vue";
import { pendingBookings, getVehiclePlateBooking } from "@/components/shop/POSDepartmentProcess.vue";
onMounted(() => {
@@ -58,6 +59,11 @@ onMounted(() => {
fetchLastOrder(1);
fetchLastOrder(2);
fetchLastOrder(3);
// Watch for scroll events to handle long press detection
window.addEventListener('scroll', onScroll, true);
// Try to auto-apply pending booking when Step 2 opens
applyPendingBookingFromSelection();
});
const applyPotentialCustomerNumberChange = (oldId, newId) => {
@@ -74,6 +80,128 @@ watch(customer_id, (newId, oldId) => {
const lastFetchedPrimaryItemProduct = ref<PosProduct | null>(null); // This is used to make sure all the addons are fetched when the last order is selected.
// Keep track so we only apply a booking once (idempotent)
const lastAppliedBookingId = ref<number | null>(null);
const getSelectedPendingBooking = () => {
// Prefer explicit booking id if present
const bookingId = metadata.getBookingId?.() ?? metadata.bookingId?.value ?? null;
let booking = null as any;
if (bookingId) {
booking = pendingBookings.value?.find?.((b: any) => b?.id === bookingId) || null;
if (booking) return booking;
}
// Fallback: try match current primary vehicle registration
const reg = vehicles?.vehicle_1?.value?.reg || null;
if (reg) {
try {
booking = getVehiclePlateBooking(reg);
} catch (e) {
booking = null;
}
}
return booking || null;
}
const fetchProductById = async (id: number): Promise<PosProduct> => {
// Fetch product with final price for current department/customer. Fallback to a minimal shape.
try {
// Note: metadata.getCustomerId exists in flow; fallback to global customer_id if missing
const custId = (metadata.getCustomerId && metadata.getCustomerId()) || (customer_id?.value ? parseInt(customer_id.value as any) : null);
const product = await SessionUser.objects.products.get.single(id, {
department_id: department_id.value,
customer_id: custId,
category_id: null,
final_price: true
});
return product as PosProduct;
} catch (e) {
console.error('Failed to fetch product', id, e);
// Minimal fallback; name/price will be set by caller when available
return { id, name: 'Produkt', price: 0, subscription_allowed: false } as PosProduct;
}
}
const applyPendingBookingFromSelection = async () => {
const booking: any = getSelectedPendingBooking();
if (!booking) return;
if (lastAppliedBookingId.value === booking.id) return; // avoid duplicate application
try {
// Update metadata booking id if not set
if (!metadata.getBookingId || !metadata.getBookingId()) {
metadata.setBookingId && metadata.setBookingId(booking.id);
}
// Set PO number on the order if provided by the booking
if (booking.po && typeof booking.po === 'string' && booking.po.trim() !== '') {
try {
await SessionUser.objects.orders.set.po(order_id.value, booking.po);
console.warn('Applied PO from booking:', booking.po);
} catch (e) {
console.error('Failed to set PO on order from booking', e);
}
}
// Map booking items to primary + addons
const items: any[] = Array.isArray(booking.items) ? booking.items : [];
if (!items.length) {
lastAppliedBookingId.value = booking.id;
return;
}
// Primary product is the first item
const primaryRaw = items[0];
const primaryId = parseInt(primaryRaw?.id);
if (!primaryId || isNaN(primaryId)) {
lastAppliedBookingId.value = booking.id;
return;
}
const primaryProduct = await fetchProductById(primaryId);
// Prefer booking price if provided, otherwise keep fetched price
if (typeof primaryRaw.price === 'number') primaryProduct.price = primaryRaw.price;
primaryProduct.name = primaryRaw?.name || primaryProduct.name;
primaryProduct.quantity = 1; // Primary item quantity is handled as 1 in order creation
// Apply as primary item
transactionItems.setPrimaryItem(primaryProduct as any);
// Remaining items become addons
const addonRawList = items.slice(1);
const addonProducts = await Promise.all(addonRawList.map(async (raw: any) => {
const id = parseInt(raw?.id);
if (!id || isNaN(id)) return null;
const p = await fetchProductById(id);
if (typeof raw.price === 'number') p.price = raw.price;
p.name = raw?.name || p.name;
p.quantity = raw?.quantity || 1;
return p;
}));
const preparedAddons = addonProducts
.filter(p => !!p)
.map(p => transactionItems.convertProductToAddon(p as any, { quantity: (p as any)?.quantity || 1 }));
if (!transactionItems.primaryItem.value) {
// Safety: if primary was cleared/changed while awaiting fetches, set again
transactionItems.setPrimaryItem(primaryProduct as any);
}
// Attach addons to primary
if (transactionItems.primaryItem.value) {
// Keep any existing addons that may have been set previously only if they are different.
transactionItems.primaryItem.value.addons = preparedAddons as any;
primaryProduct.addons = preparedAddons as any;
}
lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = primaryProduct; // Update last fetched primary item
// Hide the vehicle selection as booking has been applied
vehicleSelection.value = false;
} catch (e) {
console.error('Failed to apply pending booking to Step 2', e);
}
}
const fetchPrimaryItemProduct = () => {
if (!vehicles.vehicle_1.value.type && !transactionItems.primaryItem.value) {
vehicleSelection.value = true; // Force the user to select a product.
@@ -158,6 +286,7 @@ const layout = {
const primaryProduct = ref(null);
const pointerDownTime = ref<Date>(null);
const pointerUpTime = ref<Date>(null);
const pointerDownScrollPosition = ref(0); // Track the scroll position when the pointer is pressed down (To prevent triggering when scrolling with touch)
const pointerDown = () => {
// Record the time when the pointer is pressed down
const instanceTime = new Date();
@@ -184,6 +313,19 @@ const pointerUp = () => {
pointerUpTime.value = new Date();
};
const onScroll = (event: Event) => {
// If the user scrolls while the pointer is down, cancel the long press detection
if (pointerDownTime.value) {
const target = event.target as HTMLElement;
const currentScrollPosition = target.scrollTop;
// If the scroll position has changed significantly, cancel the long press
if (Math.abs(currentScrollPosition - pointerDownScrollPosition.value) > 10) {
pointerDownTime.value = null;
pointerUpTime.value = null;
}
}
}
const onBeforeComplete = () => {
// This function is called before the step is completed
console.log('On before complete called');
@@ -195,56 +337,6 @@ const onBeforeComplete = () => {
}
// 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',
@@ -296,7 +388,7 @@ const onBeforeComplete = () => {
// 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));
return Promise.all(addonPromises);
}).then(() => {
// After successfully adding the order item, resolve the promise
resolve(true);
@@ -307,6 +399,14 @@ const onBeforeComplete = () => {
});
}
// Re-try applying booking whenever booking id or vehicle reg changes
watch(() => metadata.bookingId?.value, () => {
applyPendingBookingFromSelection();
});
watch(() => vehicles?.vehicle_1?.value?.reg, () => {
applyPendingBookingFromSelection();
});
// 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 = []) =>
@@ -399,6 +499,8 @@ watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
}
});
</script>
<template>
@@ -406,10 +508,12 @@ watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false"/>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<!-- Buttons -->
<!-- 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">
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => { vehicleSelection = false; fetchPrimaryItemProduct(); }" :buttonClasses="['has-background-primary', 'has-text-black']" v-show="vehicles.vehicle_1.value.type">
<span class="is-float-left " v-if="false">{{ SessionUser.objects.global.language.next }}</span>
<span class="is-float-left">{{ primaryItem ? primaryItem.name : SessionUser.objects.global.language.next }}</span>
<span class="is-float-right ">
<!-- Arrow right icon -->
<i class="fas fa-arrow-right"></i>
</span>
@@ -422,7 +526,8 @@ watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
<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"/>
<h3 class="is-size-5 has-text-weight-bold">{{ customer_name }}</h3>
<PosDepartmentStepMobile2Customer subtitle="" :label="customer_name" v-show="false"/>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<!-- Product -->
@@ -441,7 +546,7 @@ watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
@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'"/>
<PosDepartmentStepMobile2Addons :addons="transactionItems.primaryItem.value?.addons || []" :label="'Add-ons'" :compact="false"/>
<!-- Notes -->
<ControlField>
<ControlFieldInputLabel label="Notes" :classes="layout.classes" :optional="true"/>
@@ -457,9 +562,9 @@ watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
<!-- 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 :isDark="false" :onBeforeStep="() => onBeforeComplete()" :buttonClasses="['has-background-primary', 'has-text-white']">
<span class="is-float-left">Afslut</span>
<span class="is-float-right">{{ SessionUser.functions.currency.toLocal(transactionItems.getTransactionTotal())}}</span>
</PosDepartmentStepMobileButtonNextStep>
<div>
<PosDepartmentStepMobileButtonClearAll/>
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { useGeolocation } from "@vueuse/core";
import { watch } from "vue";
import { watch, defineEmits } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: true,
maximumAge: 30000,
@@ -18,6 +21,7 @@ const onUpdate = (newCoords: { latitude: number | null; longitude: number | null
timestamp: new Date(),
errorMessage: error.value ? error.value.message : null,
})
emits('location-updated', newCoords);
}
};
watch(coords, (newCoords) => {
@@ -12,16 +12,18 @@ const props = defineProps({
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'update:hasFocus']);
const emit = defineEmits(['update:modelValue', 'update:hasFocus', 'onInput']);
const modelValue = ref(props.modelValue);
const hasFocus = ref(false);
watch(() => props.modelValue, (newValue) => {
modelValue.value = newValue;
modelValue.value = newValue.toUpperCase();
});
watch(modelValue, (newValue) => {
modelValue.value = newValue.toUpperCase();
emit('update:modelValue', newValue);
});
watch(hasFocus, (newValue) => {
modelValue.value = modelValue.value.toUpperCase();
emit('update:hasFocus', newValue);
});
@@ -41,6 +43,10 @@ watch(hasFocus, (newValue) => {
}
});
const onInput = (value: string) => {
emit('onInput', value);
}
</script>
<template>
@@ -55,6 +61,8 @@ watch(hasFocus, (newValue) => {
:class="{ 'is-searched': hasFocus }"
type="text"
v-model="modelValue"
@input="onInput($event.target.value.toUpperCase())"
autocomplete="off"
v-on:focusin="hasFocus = true"
v-on:focusout="hasFocus = false"
/>
@@ -20,6 +20,10 @@ const props = defineProps({
compact: {
type: Boolean,
default: false
},
showPrices: {
type: Boolean,
default: true
}
});
const emit = defineEmits(["update:addons"]);
@@ -42,6 +46,63 @@ const getLabel = (addon: Addon) => {
//return `${addon.name} / ${SessionUser.functions.currency.toLocal(addon.price)}`;
}
/**
* When double-clicking on the product, it will add 1 to the quantity
*/
const doubleClickTimeout = 500; // milliseconds
const doubleClickTimers = ref<{ [key: string]: number | null }>({});
/**
* Function to add a product to the add-ons list
* @param addon
*/
const onAddProduct = (addon: Addon) => {
// Check if the product was just added
if (doubleClickTimers.value[addon.id]) {
// Double-click detected, clear the timeout
clearTimeout(doubleClickTimers.value[addon.id]);
doubleClickTimers.value[addon.id] = null;
// Check if the product can have more quantity
if (addon.quantity >= addon.max && addon.max !== -1) {
// If the product max is reached, it will not be added.
return;
}
addon.quantity = addon.quantity + 1;
emit('update:addons', [...props.addons])
// Set the timeout again to allow for the next double-click
doubleClickTimers.value[addon.id] = window.setTimeout(() => {
doubleClickTimers.value[addon.id] = null;
}, doubleClickTimeout);
return;
}
// Single-click detected, add the product to the list
// If the product is already has a quantity, set it to 0
if (addon.quantity > 0) {
addon.quantity = 0;
emit('update:addons', [...props.addons])
return;
}
addon.quantity = (addon.min > 0 ? addon.min : 1);
// Set a timeout to handle double-clicks.
// If the product max is reached, it will not be added.
if (addon.quantity >= addon.max && addon.max !== -1) {
// If the product max is reached, it will not be added.
emit('update:addons', [...props.addons])
return;
}
doubleClickTimers.value[addon.id] = window.setTimeout(() => {
doubleClickTimers.value[addon.id] = null;
}, doubleClickTimeout);
emit('update:addons', [...props.addons])
}
const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
return addons.sort((a, b) => {
const priorityA = a.product.order_priority || 0;
const priorityB = b.product.order_priority || 0;
return priorityA - priorityB;
});
}
</script>
@@ -49,13 +110,14 @@ const getLabel = (addon: Addon) => {
<template v-if="addons.length > 0"> <!-- If there are add-ons available -->
<p class="custom-label">{{ props.label }}</p>
<div>
<template v-for="addon in props.addons" :key="addon.id">
<template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id">
<PosDepartmentStepMobile2CategoryProduct
:compact="props.compact"
:price="addon.price"
:label="getLabel(addon)"
:piktogram="addon.product.piktogram"
@addProduct="addon.quantity = (addon.min > 0 ? addon.min : 1); emit('update:addons', [...props.addons])"
:showPrices="props.showPrices"
@addProduct="onAddProduct(addon)"
:customButton="addon.quantity > 0">
<span class="custom-select-quantity-container">
<ControlSelectAmount
@@ -180,7 +242,7 @@ const getLabel = (addon: Addon) => {
}
.custom-label {
/* Add-ons */
padding-bottom: 24px;
width: 60px;
height: 16px;
@@ -1,9 +1,17 @@
<script setup lang="ts">
import GenericTag from "@/components/viewport/page/templates/generic/graphics/GenericTag.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import {onMounted} from "vue";
import {onMounted, defineProps} from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
// Function to fetch categories based on the department ID
const props = defineProps({
// Optional list of category IDs to restrict the displayed categories to
restrictCategories: {
type: Array as () => number[],
required: false,
default: () => [],
},
});
// This function will be called when the component is mounted
const fetchCategories = async (departmentId: number) => {
await SessionUser.objects.department_categories.get.single(departmentId)
@@ -16,7 +24,11 @@ const fetchCategories = async (departmentId: number) => {
// name: "Suggested",
// });
// Add the categories to the pos.categories object
response.forEach((result: {category: {id: number, name: string}}) => {
SessionUser.objects.department_categories.functions.orderCategories(response).forEach((result: {category: {id: number, name: string}}) => {
// If restrictCategories prop is set, only add categories that are in the list
if (props.restrictCategories.length > 0 && !props.restrictCategories.includes(result.category.id)) {
return;
}
pos.categories.add({
id: result.category.id,
name: result.category.name,
@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
required: false
},
showPrices: {
type: Boolean,
default: true,
}
});
@@ -61,25 +65,28 @@ const imageStyleCompact = {
</script>
<template>
<div style="border-bottom: 1px solid #E5E5E5; padding: 10px 0;">
<div class="columns is-vcentered is-mobile">
<div class="column is-narrow">
<div style="border-bottom: 1px solid #E5E5E5; padding: 10px 0;" class="mb-1">
<div class="columns is-vcentered is-mobile" :class="props.customButton ? 'has-quantity' : ''">
<div class="column is-narrow" @click="onClick" style="cursor: pointer;">
<img :src="getPicture(props.piktogram)" :alt="props.label" class="product-image"
:style="props.compact ? imageStyleCompact : imageSize"
style="border-radius: 8px; object-fit: contain;" />
</div>
<div class="column">
<div class="column" @click="onClick" style="cursor: pointer;">
<template v-if="!props.compact">
<h1 class="title is-6">{{ props.label }}</h1>
<h2 class="subtitle is-6">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
<h2 class="subtitle is-6" v-else>&nbsp;</h2>
</template>
<template v-else>
<h1 class="title is-6" style="font-size: small">{{ props.label }}</h1>
<h2 class="subtitle is-6" style="font-size: smaller">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
<h2 class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
<h2 class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</h2>
</template>
</div>
<div class="column is-narrow">
<button class="custom-button-product" @click="onClick" v-if="!props.customButton" :style="props.compact ? imageStyleCompact : imageSize">
<slot name="right"/>
<button class="custom-button-product" @click="onClick" v-if="!props.customButton" :style="props.compact ? imageStyleCompact : imageSize" v-show="false">
<span class="custom-icon">
<Plus width="100%" height="100%" />
</span>
@@ -135,4 +142,14 @@ const imageStyleCompact = {
padding: 0px;
gap: 0px;
}
.has-quantity {
border-radius: 10px;
background-color: #5173d4;
}
.has-quantity .column .title {
color: #FFFFFF;
}
.has-quantity .column .subtitle {
color: rgba(255, 255, 255, 0.6);
}
</style>
@@ -15,23 +15,23 @@ const props = defineProps({
<template>
<!-- Box -->
<div class="box">
<div class="box" style="border: 3px solid rgb(0,45,255);">
<!-- Product image -->
<div class="has-text-centered">
<img :src="getPicture(props?.product?.piktogram || props?.product?.id || '')" :alt="props?.product?.name || 'N/A'" class="product-image">
</div>
<!-- Product id -->
<div class="tag mb-2">
<div class="tag mb-2" v-show="false">
<span>No. {{ props?.product?.piktogram || props?.product?.id }}</span>
</div>
<!-- Product name and price -->
<div class="columns is-mobile is-vcentered">
<!-- Product name -->
<div class="column is-6">
<div class="column is-narrow">
<p class="title is-5">{{ props?.product?.name || '' }}</p>
</div>
<!-- Product price -->
<div class="column is-6 has-text-right">
<div class="column has-text-right" v-show="false">
<p class="title is-5 is-grey">{{ SessionUser.functions.currency.toLocal(props?.product?.price || 0) }}</p>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {watch, defineEmits, defineProps, ref} from "vue";
import {watch, defineEmits, defineProps, ref, defineSlots} from "vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobile2CategoryProduct
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
@@ -10,6 +10,7 @@ import { department_id, customer_id } from "@/components/shop/POSDepartmentProce
import PosDepartmentStepMobile2Addons
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import { orderProducts } from "@/components/shop/Products.vue";
const emits = defineEmits(["addProduct"]);
const props = defineProps({
@@ -26,6 +27,7 @@ const props = defineProps({
default: false,
},
});
const slots = defineSlots();
// Function to fetch categories based on the department ID
// This function will be called when the component is mounted
@@ -34,7 +36,7 @@ const fetchProducts = async (category: PosCategory, departmentId: number) => {
.then((response) => {
console.log("Products fetched for category:", category.name, response);
pos.productList.clear();
response.forEach((result: PosProduct) => {
orderProducts(response).forEach((result: PosProduct) => {
pos.productList.add(result);
});
// TODO: Update prices of products based on customer pricing rules
@@ -110,7 +112,11 @@ watch(() => props.addons, (newAddons) => {
:price="product.price"
:label="product.name"
:piktogram="product.piktogram"
/>
>
<template #right>
<slot name="right" :product="product"/>
</template>
</PosDepartmentStepMobile2CategoryProduct>
</template>
</template>
<template v-else>
@@ -53,7 +53,7 @@ const parseVehicleStatus = (status: any) => {
<div :class="layout.columnClasses">
<PosDepartmentStepMobile1RegistrationNumber2 :class="layout.classes" :hideLabel="true" @click="onClick('reg_2', reg_2)" />
</div>
<div :class="layout.columnClasses">
<div :class="layout.columnClasses" v-show="!!pos.vehicles.vehicle_3.value">
<PosDepartmentStepMobile1RegistrationNumber3 :class="layout.classes" :hideLabel="true" @click="onClick('reg_3', reg_3)" />
</div>
</div>
@@ -8,6 +8,7 @@ import ContinueArrow from "@/components/viewport/elements/icons/ContinueArrow.vu
import { vehicles, metadata, getCustomerId, popups, resetPos, attachments, transactionHistory } from "../objects/PosDepartmentStepMobileFlow.vue";
import Swal from "sweetalert2";
import LongPressListener from "@/components/viewport/elements/wrappers/LongPressListener.vue";
import {views} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const selectCustomerString = `${SessionUser.objects.global.language.select} ${SessionUser.objects.global.language.customer.toLowerCase()}`;
const props = defineProps({
@@ -40,13 +41,20 @@ const props = defineProps({
type: Function,
default: null,
},
// Button classes
buttonClasses: {
type: Array as () => string[],
default: () => [],
},
});
const updateReference = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.reference(order_id.value, reference.value);
}
const updateNotes = () => {
if (!!order_id.value && order_id.value > 0)
SessionUser.objects.orders.set.notes(order_id.value, order_notes.value);
}
@@ -136,6 +144,8 @@ const step1 = () => {
reg_1.value = vehicles.vehicle_1.value?.reg || "";
reg_2.value = vehicles.vehicle_2.value?.reg || "";
reg_3.value = vehicles.vehicle_3.value?.reg || "";
// Reset the vehicle selection
views.vehicleSelection.value = false;
// Merge references and notes
mergeReferences();
mergeNotes();
@@ -226,9 +236,15 @@ const step2 = () => {
// If there is a booking ID, complete the booking
SessionUser.objects.orders.set.booking_id(order_id.value, metadata.getBookingId());
// Check if the booking has been completed
SessionUser.objects.bookings.functions.isBookingCompleted(metadata.getBookingId()).then((isBookingCompleted: boolean) => {
SessionUser.objects.order_bookings.get.single(metadata.getBookingId()).then((booking: any) => {
const isBookingCompleted = booking?.order_id && booking.order_id > 0;
if (!isBookingCompleted) {
popups.select('complete_booking', {});
// Set the order bookings id to the metadata
if (metadata.getBookingId() && metadata.getBookingId() > 0) {
SessionUser.objects.order_bookings.set.order_id(metadata.getBookingId(), order_id.value).then(() => {
popups.select('complete_booking', {});
});
}
// Listen for the booking to be completed
awaitBookingCompletion(() => {
completeOrder();
@@ -425,11 +441,20 @@ const defaultLongPressBehavior = () => {
popups.select('select_customer');
}
/**
* Colors:
* Primary:
* :buttonClasses="['has-background-primary', 'has-text-black']"
* Secondary:
* :buttonClasses="['has-background-primary-dark', 'has-text-black']"
*/
</script>
<template>
<LongPressListener @long-press="onLongPress">
<GenericButton @click="onClick" :class="{'white': props.isWhite, 'has-background-black': props.isDark, 'is-loading': metadata.getLoadingState()}"
<GenericButton @click="onClick" :class="{'white': props.isWhite, 'has-background-black': props.isDark, 'is-loading': metadata.getLoadingState(), ...(props.buttonClasses.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}))}"
class="is-size-6-mobile is-size-5-tablet is-size-4-desktop"
:disabled="!isRequirementsForClickMet()" v-show="isVisible">
<!-- Default content if no slot is provided -->
<template v-if="!$slots.default">
@@ -457,5 +482,42 @@ const defaultLongPressBehavior = () => {
</template>
<style scoped>
.has-text-black >:not(.icon) {
color: black !important;
}
/* Loading state */
.is-loading {
pointer-events: none;
cursor: not-allowed;
}
.is-loading > *:not(.icon) {
visibility: hidden;
opacity: 0;
transition: opacity 0.6s ease-in-out;
}
/* Loading animation for the button */
.is-loading::after {
transition: opacity 0.6s ease-in-out;
content: "";
position: absolute;
top: auto;
left: 50%;
width: 1em;
height: 1em;
margin-top: -0.5em;
margin-left: -0.5em;
/* Set the color to the original text color */
border: 0.2em solid white;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: inline-block;
vertical-align: top;
border-right-color: transparent;
border-bottom-color: transparent;
pointer-events: none;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, defineEmits, watch, ref } from "vue";
import { PosSearchResult } from "../objects/PosSearchResult.vue";
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
import { searchCustomerResults, isSearching } from "@/components/search/economic/customerSearch.vue";
import ControlFieldInputSearchResults
from "@/components/viewport/elements/controls/fields/search/ControlFieldInputSearchResults.vue";
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
import { metadata, vehicles } from "../objects/PosDepartmentStepMobileFlow.vue";
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
import {
actionButtons,
popups
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
const emit = defineEmits<{
(e: 'close'): void;
}>();
/**
* Variables
*/
const searchQuery = ref("");
const searchResult = ref(/**{
"vat": 41004355,
"status": "Normal",
"name": "Truckwash ApS",
"address": "Letland Alle 2",
"zipcode": 2630,
"city": "Taastrup",
"protected": true,
"phone": "21754690",
"website": null,
"email": "mikkel@truckwash.dk",
"fax": null,
"startdate": "2019-12-11",
"enddate": null,
"employees": 14,
"industrycode": 953190,
"industrydesc": "Reparation og vedligeholdelse af motorkøretøjer i.a.n.",
"companytype": "APS",
"companydesc": "Anpartsselskab",
"owners": [
"DELOITTE STATSAUTORISERET REVISIONSPARTNERSELSKAB",
"MBL Revision I/S",
"WASH GROUP A/S"
]
}*/null);
const lastSearchResult = ref(null); // This is used to change the autofilled fields, when the search result changes
const searching = ref(false);
const error = ref(popups.get().props.error); // Used to display an error message if the customer creation fails
const search = (query: string) => {
return SessionUser.request("/cvr/search", "GET", {
query: query,
}).then((response) => {
console.warn(response, 'response');
searchResult.value = response.data.data;
}).catch((error) => {
console.error(error);
searchResult.value = null;
}).finally(() => {
searching.value = false;
changeAutofilledFields();
});
};
/**
* Form fields
*/
const cvr = ref("");
const companyPhone = ref("");
const invoiceEmail = ref("");
const contactEmail = ref("");
const contactPhone = ref("");
/**
* Autofill fields, when search result changes
* If the operator has changed the values in the fields, we want to keep them, so we can autofill them again when the search result changes
*/
const changeAutofilledFields = () => {
if (lastSearchResult.value !== null) {
cvr.value = "";
if (lastSearchResult.value.phone === companyPhone.value) {
companyPhone.value = "";
}
if (lastSearchResult.value.phone === contactPhone.value) {
contactPhone.value = "";
}
if (lastSearchResult.value.email === contactEmail.value) {
contactEmail.value = "";
}
if (lastSearchResult.value.email === invoiceEmail.value) {
invoiceEmail.value = "";
}
}
// Fill the fields with the new search result
if (searchResult.value !== null) {
if (cvr.value === "") {
cvr.value = searchResult.value.vat.toString();
}
if (companyPhone.value === "") {
companyPhone.value = searchResult.value.phone;
}
if (contactPhone.value === "") {
contactPhone.value = searchResult.value.phone;
}
if (contactEmail.value === "") {
contactEmail.value = searchResult.value.email;
}
if (invoiceEmail.value === "") {
invoiceEmail.value = searchResult.value.email;
}
}
lastSearchResult.value = searchResult.value;
}
/**
* Watch the validity of the fields and emit an event to the parent component
* If the fields are valid, we can enable the "Add Customer" button
*/
watch([cvr, companyPhone, invoiceEmail, contactEmail, contactPhone], (newValues) => {
// Remove the "Add Customer" button if it exists
const index = popups.get().actionButtons.findIndex((button) => button.label === "Tilføj kunde");
if (index !== -1) {
popups.get().actionButtons.splice(index, 1);
}
// Add the "Add Customer" button if all fields are filled
const requiredFields = [cvr, companyPhone, contactPhone];
if (searchResult.value !== null &&
searchResult.value.name !== "Ikke fundet" &&
requiredFields.every((field) => field.value !== "")) {
popups.get().actionButtons.push({
label: "Tilføj kunde",
onClick: () => {
popups.get().actionButtons = []; // Remove all action buttons to prevent multiple clicks
try {
SessionUser.request("/auth/register/cvr", "POST", {
cvr: cvr.value,
companyPhone: parseInt(companyPhone.value),
invoiceEmail: invoiceEmail.value,
contactEmail: contactEmail.value,
contactPhone: parseInt(contactPhone.value),
searchResult: searchResult.value,
}).then((response) => {
console.warn(response, 'response register cvr');
searchAndSelectCustomer(companyPhone.value);
metadata.setCustomerId(parseInt(companyPhone.value));
emit('close');
}).catch((error) => {
console.error(error);
const errorResponse = SessionUser.functions.parseErrorMessage(error);
if (errorResponse !== null) {
popups.get().props.error = `Der opstod en fejl under oprettelsen af kunden: ${errorResponse}`;
// Reset the buttons
popups.get().actionButtons = popups.get().actionButtons = [{...actionButtons.default.value.cancel}];
return;
}
popups.get().props.error = "Der opstod en fejl under oprettelsen af kunden. Prøv igen.";
});
} catch (error) {
console.error(error);
popups.get().props.error = "Der opstod en fejl under oprettelsen af kunden. Prøv igen.";
}
},
color: "primary",
});
}
})
</script>
<template>
<div style="overflow-y: auto; overflow-x: hidden; height: 100%; padding-bottom: 10px;">
<div class="mx-3">
<div class="field">
<label class="label"><small>CVR</small></label>
<div class="control has-icons-right" :class="{'is-loading': searching}">
<input
v-model="searchQuery"
@input="searching = true; search(searchQuery);"
class="input is-searched"
:class="{'is-danger': searchResult === null, 'is-success': searchResult !== null && searchResult.name !== 'Ikke fundet'}"
type="text"
placeholder="Søg CVR nummer..."
/>
<span class="icon is-right" :class="{'has-text-danger': (searchResult === null || searchResult.name === 'Ikke fundet') && !searching, 'has-text-success': (searchResult !== null && searchResult.name !== 'Ikke fundet') && !searching}">
<i class="fas fa-check" v-if="(searchResult !== null && searchResult.name !== 'Ikke fundet') && !searching"></i>
<i class="fas fa-exclamation-triangle" v-else-if="(searchResult === null || searchResult.name === 'Ikke fundet') && !searching"></i>
<i class="fas fa-search" v-else-if="!searching"></i>
</span>
</div>
<p class="help" v-if="searchResult !== null && searchResult.name !== 'Ikke fundet'">{{ searchResult.name }}</p>
</div>
<!-- Company phone -->
<div class="field">
<label class="label"><small>Firma telefon</small></label>
<div class="control">
<input v-model="companyPhone" class="input" type="text" placeholder="Firma telefon" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && companyPhone === ''}"/>
</div>
</div>
<!-- Invoice email -->
<div class="field">
<label class="label"><small>Faktura email</small></label>
<div class="control">
<input v-model="invoiceEmail" class="input" type="text" placeholder="Faktura email" :disabled="searchResult === null"/>
</div>
</div>
<!-- Contact email -->
<div class="field">
<label class="label"><small>Kontakt email</small></label>
<div class="control">
<input v-model="contactEmail" class="input" type="text" placeholder="Kontakt email" :disabled="searchResult === null"/>
</div>
</div>
<!-- Contact phone -->
<div class="field">
<label class="label"><small>Kontakt telefon</small></label>
<div class="control">
<input v-model="contactPhone" class="input" type="text" placeholder="Kontakt telefon" :disabled="searchResult === null" :class="{'is-danger': searchResult !== null && contactPhone === ''}"/>
</div>
</div>
<!-- Error message -->
<div class="notification is-danger" v-if="popups.get().props.error !== null">
{{ popups.get().props.error}}
</div>
</div>
<!-- Search results -->
<!--{{ searchResult }}-->
</div>
</template>
<style scoped>
</style>
@@ -2,6 +2,7 @@
import { computed, defineEmits, watch, ref } from "vue";
import { metadata } from "../objects/PosDepartmentStepMobileFlow.vue";
import FormDisplay from "@/components/displays/FormDisplay.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
const emit = defineEmits<{
(e: 'close'): void;
}>();
@@ -11,18 +12,43 @@ const onSuccessfulCertificateSubmission = (response: any) => {
console.log("Certificate generated successfully:", response);
emit('close');
}
const safetySealNumber = ref("");
</script>
<template>
<div>
<!-- Booking completion form -->
<FormDisplay
<!-- Safety seal certificate field -->
<div class="field">
<label class="label">Safety Seal</label>
<div class="control">
<input
type="text"
class="input is-searched"
placeholder="Indtast safety seal nummer"
v-model="safetySealNumber"
/>
</div>
</div>
<!-- Generate certificate button -->
<div class="buttons is-centered mb-4">
<button class="button is-light" @click="emit('close')">
Intet vaskecertifikat
</button>
<button
class="button is-primary"
@click="SessionUser.objects.order_bookings.functions.complete(metadata.getBookingId(), safetySealNumber, onSuccessfulCertificateSubmission)">
Generer vaskecertifikat
</button>
</div>
<!--<FormDisplay
:form_identifier="'GENERATE_BOOKING_WASH_CERTIFICATE'"
v-bind:validator_options="{validateBookingId: {default: metadata.getBookingId(), locked: true, visible: false}}"
:onSuccessfulSubmission="onSuccessfulCertificateSubmission"
:showSuccessSubmitAlert="false"
:showResetButton="false"
/>
/>-->
</div>
</template>
@@ -13,6 +13,8 @@ export type Addon = {
product?: PosProduct; // The product details for the add-on
};
export type PosAddon = Addon;
export default defineComponent({
name: "PosAddon",
});
@@ -157,6 +157,16 @@ const defaultActionButtons = ref<{ [key: string]: PosActionButton }>({
},
color: 'primary'
},
addCustomer: {
label: 'Tilføj kunde',
description: 'Tilføj en ny kunde til systemet',
onClick: () => {
console.warn('Add Customer button clicked');
// Open the add customer popup
popups.select('add_customer', { props: { error: null, canCreate: true } });
},
color: 'primary',
},
editReference: {
label: 'Redigér reference',
description: 'Redigér reference for denne transaktion',
@@ -229,7 +239,7 @@ const addDefaultPopups = () => {
message: 'Bekræft venligst valget af kunde til denne transaktion',
component: 'select_customer',
style: {height: '50vh'},
actionButtons: [{...defaultActionButtons.value.cancel}],
actionButtons: [{...defaultActionButtons.value.cancel}, {...defaultActionButtons.value.addCustomer, label: 'Ny kunde'}],
});
// Completed
addPopup({
@@ -315,6 +325,18 @@ const addDefaultPopups = () => {
],
props: {base64String: null, filename: null}
});
// Add customer
addPopup({
id: 'add_customer',
title: 'Tilføj kunde',
message: 'Indtast venligst oplysninger for den nye kunde',
component: 'add_customer',
style: {maxHeight: '60vh'},
actionButtons: [
{...defaultActionButtons.value.cancel}
],
props: {cvr: null, contactEmail: null, contactPhone: null, companyPhone: null, invoiceEmail: null, searchResult: null, canCreate: false, error: null}
});
}
// Function to check if a popup is defined in the list
const isPopupDefined = (id: string): boolean => {
@@ -20,8 +20,10 @@ import PosDepartmentStepMobilePopupCustomerNotes
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupCustomerNotes.vue";
import PosDepartmentStepMobilePopupImage
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupImage.vue";
import PosDepartmentStepMobilePopupAddCustomer
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddCustomer.vue";
export type PopupComponentKey = 'select_customer' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer';
export type PopupComponentKey = 'select_customer' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note' | 'select_vehicle' | 'change_reference' | 'customer_notes' | 'image_viewer' | 'add_customer';
export const PopupComponents = {
select_customer: PosDepartmentStepMobilePopupSelectCustomer,
@@ -33,6 +35,7 @@ export const PopupComponents = {
change_reference: PosDepartmentStepMobilePopupSetReference,
customer_notes: PosDepartmentStepMobilePopupCustomerNotes,
image_viewer: PosDepartmentStepMobilePopupImage,
add_customer: PosDepartmentStepMobilePopupAddCustomer
} as const;
export const popupComponentKeyToComponent = (key: PopupComponentKey) => PopupComponents[key];
@@ -64,6 +67,7 @@ export default defineComponent({
PosDepartmentStepMobilePopupSetReference,
PosDepartmentStepMobilePopupCustomerNotes,
PosDepartmentStepMobilePopupImage,
PosDepartmentStepMobilePopupAddCustomer
},
props: {
popup: {
@@ -10,6 +10,10 @@ import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCusto
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import { PosVehicle } from "@/components/displays/department/pos/steps/mobile/objects/PosVehicle.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
// Define the props
@@ -49,7 +53,7 @@ const registrationNumber3 = ref("");
const label3 = ref("Reg 3");
// Function to set a value if it is not null
function setIfNotNull(variable: string, value: { registrationNumber: string, customerStatus: any, customerId?: number } | null) {
function setIfNotNull(variable: string, value: { registrationNumber: string, customerStatus: any, customerId?: number, lastOrderId?: number } | null) {
//console.warn("setIfNotNull", variable, value);
if (value !== null) {
switch (variable) {
@@ -61,6 +65,7 @@ function setIfNotNull(variable: string, value: { registrationNumber: string, cus
reg: value.registrationNumber,
status: value.customerStatus,
customer_id: value.customerId,
last_order_id: value.lastOrderId,
}
)
registrationNumber1.value = value.registrationNumber;
@@ -131,6 +136,7 @@ onMounted(() => {
// If the reg_3 is not empty, set the expanded state to true
expanded.value = reg_3.value !== "" || props.forceShowApplicable;
});
</script>
<template>
@@ -141,6 +147,7 @@ onMounted(() => {
v-model:modelValue="reg_1"
v-model:label="label1"
@focusout="setValueUserInput(1, reg_1)"
@onInput="reg_1 = $event"
>
<template #searchResults>
<RegistrationNumberSearchResult @select="setIfNotNull('reg_1', $event)" :searchQuery="reg_1" :automaticallySelect="true"/>
@@ -183,11 +190,17 @@ onMounted(() => {
<span class="m-2"><BarcodeScanner/></span>
<span class="custom-label-button">{{ SessionUser.objects.global.language.or_scan_plates}}</span>
</button>
<!-- Add vehicle button -->
<GenericButton class="p-5" @click="$emit('close')">
{{ SessionUser.objects.global.language.close}}
</GenericButton>
</template>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl v-if="props.showButtons">
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right">
<i class="fa-solid fa-xmark"></i>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</div>
</template>
@@ -102,9 +102,12 @@ const getTransactionError = (transaction: PosOrder) => {
}
const liveTransactions = ref(null)
const isLoading = ref(true);
const syncListTransactionHistory = async () => {
let dateToday = new Date().toISOString().split('T')[0]; // Get today's date in YYYY-MM-DD format
isLoading.value = true;
// Fetch the list of orders created today for the current department
SessionUser.objects.orders.get.list({filters: {
'created_at-date_from': dateToday,
'created_at-date_to': dateToday,
@@ -118,6 +121,10 @@ const syncListTransactionHistory = async () => {
for (let i = 0; i < liveTransactions.value.length; i++) {
pos.transactionHistory.add(liveTransactions.value[i]);
}
}).catch((error) => {
console.error('Error syncing list of transactions:', error);
}).finally(() => {;
isLoading.value = false;
});
};
@@ -207,24 +214,17 @@ const isVisible = (transaction: PosOrder, showPending: boolean, showCompleted: b
</div>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<!-- Clear button -->
<PosDepartmentStepMobileButtonNextStep class="mb-2" :isDark="true" :customAction="() => pos.transactionHistory.clear()" :customDisabled="false" v-if="pos.transactionHistory.get().length > 0" v-show="false">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.clear }}</span>
<span class="is-float-right has-text-white">
<i class="fa-solid fa-trash"></i>
</span>
</PosDepartmentStepMobileButtonNextStep>
<!-- Refresh button -->
<PosDepartmentStepMobileButtonNextStep class="mb-2" :isDark="true" :customAction="() => syncListTransactionHistory()" :customDisabled="false">
<PosDepartmentStepMobileButtonNextStep class="mb-2" :isWhite="false" :customAction="() => syncListTransactionHistory()" :customDisabled="false" :buttonClasses="['has-background-primary-dark', 'has-text-black', ...isLoading ? ['is-loading'] : []]">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.reload }}</span>
<span class="is-float-right has-text-white">
<span class="is-float-right has-text-white is-loading">
<i class="fa-solid fa-arrows-rotate"></i>
</span>
</PosDepartmentStepMobileButtonNextStep>
<!-- Close button -->
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="() => emit('close')" :customDisabled="false">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right has-text-white">
<PosDepartmentStepMobileButtonNextStep :isWhite="false" :customAction="() => emit('close')" :customDisabled="false" :buttonClasses="['has-background-primary', 'has-text-black']">
<span class="is-float-left">{{ SessionUser.objects.global.language.close }}</span>
<span class="is-float-right">
<i class="fa-solid fa-xmark"></i>
</span>
</PosDepartmentStepMobileButtonNextStep>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {defineEmits, defineProps} from "vue";
import {computed, defineEmits, defineProps, ref} from "vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import GenericLabelSearch from "@/components/viewport/page/templates/generic/graphics/GenericLabelSearch.vue";
@@ -37,10 +37,16 @@ const props = defineProps({
type: Object as () => Addon[],
required: false,
},
minimumCloseDelay: { // Minimum delay before allowing the component to be closed, in milliseconds
type: Number,
required: false,
default: 750
}
});
// Function to handle adding a product
const onAddProduct = (product: any) => {
if (!canClose()) return;
// If a custom onAddProduct function is provided, use it
if (props.onAddProduct) {
product.quantity = product.quantity || 1; // Ensure quantity is set
@@ -60,6 +66,9 @@ const onSearchClick = () => {
}
console.log("Search clicked");
};
const openedAt = ref(Date.now());
const earliestCloseTime = computed(() => openedAt.value + props.minimumCloseDelay);
const canClose = () => Date.now() >= earliestCloseTime.value;
</script>
<template>
@@ -73,7 +82,14 @@ const onSearchClick = () => {
<!-- Product Recommendations -->
<!--<PosDepartmentStepMobile2ProductRecommendations/>-->
<!-- Products -->
<PosDepartmentStepMobile2Products @addProduct="onAddProduct" :asAddons="props.asAddons" :addons="props.addons"/>
<PosDepartmentStepMobile2Products @addProduct="onAddProduct" :asAddons="props.asAddons" :addons="props.addons">
<template v-slot:right>
<!-- Right arrow -->
<span class="icon is-small">
<i class="fas fa-chevron-right"></i>
</span>
</template>
</PosDepartmentStepMobile2Products>
<!-- Next button
<GenericButton class="p-5" @click="emit('close')">
Next
@@ -1,5 +1,5 @@
<script setup>
defineProps(['metaItemsPerPage', 'loadFunction', 'isLoading', 'setMetaItemsPerPage'])
defineProps(['metaItemsPerPage', 'loadFunction', 'isLoading', 'setMetaItemsPerPage', 'allowSmallScreen'])
import { ref } from 'vue'
import ShowErrorField from "@/components/global/ShowErrorField.vue";
@@ -21,10 +21,10 @@ window.addEventListener('resize', () => {
<div class="py-2">
<div class="columns is-vcentered is-multiline is-mobile">
<!-- Change the number of items per page -->
<div class="column is-narrow my-3" :class="{ 'is-hidden': isSmall }">
<label class="label">Antal per side</label>
<div class="column is-narrow" :class="{ 'is-hidden': isSmall && !allowSmallScreen, 'my-3': !isSmall }">
<label class="label is-small">Per side</label>
<div class="control">
<div class="select">
<div class="select" :class="{ 'is-small': isSmall }">
<select v-model="selectedPerPage" @change="setMetaItemsPerPage(selectedPerPage); selectedPerPage.valueOf = selectedPerPage">
<!-- If the selected number of items per page is not in the list, add it to the list -->
<option v-if="!perPageOptions.includes(selectedPerPage)" :value="selectedPerPage" :class="{ 'is-skeleton': isLoading }" selected>{{ selectedPerPage }}</option>
@@ -0,0 +1,283 @@
<script setup lang="ts">
import {setFilter, getFilter} from "@/components/pagination/paginatedList.vue";
import {computed, defineProps, onMounted, ref} from 'vue';
import SessionUser from "@/components/session/token/SessionUser.vue";
import {BAutocomplete} from "buefy";
/**
* Props:
* - departmentColumn: string (default: "department_id")
*/
const props = defineProps({
departmentColumn: {
type: String||undefined,
default: undefined,
required: false
},
productColumn: {
type: String||undefined,
default: undefined,
required: false
},
columnLabels: {
type: Object||undefined,
default: undefined,
required: false
// Example: { department: 'Department', product: 'Product Type' }
}
})
/**
* General functions (Filters)
*/
const getOptionsForSelect = (options: {label: string, value: string | number | undefined | null}[], searchQuery: string | undefined): {label: string, value: string | number | undefined | null}[] => {
if (!searchQuery) return options;
const query = searchQuery.toLowerCase();
let filteredOptions = options;
// Sort the options so that those starting with the query appear first
filteredOptions = filteredOptions.sort((a, b) => {
const aStartsWith = a.label.toLowerCase().startsWith(query) ? -1 : 1;
const bStartsWith = b.label.toLowerCase().startsWith(query) ? -1 : 1;
return aStartsWith - bStartsWith;
});
// Then filter the options based on the query
filteredOptions = filteredOptions.filter(option => option.label.toLowerCase().includes(query));
return filteredOptions;
}
type FilterType = {
label: string;
value: string | number | string[] | number[] | undefined | null;
columnName?: string;
type?: 'select' | 'input' | 'date' | 'checkbox';
options?: {label: string, value: string | number | undefined | null}[];
disabled?: boolean;
placeholder?: string;
icon?: string;
searchable?: boolean;
searchQuery?: string;
multiple?: boolean;
loading?: boolean;
loadFunction?: () => Promise<void>;
onInput?: (value: string | number | string[] | number[]) => void;
onChange?: (value: string | number | string[] | number[]) => void;
};
const setFilterValue = (filterName: string, value: string | number | string[] | number[]) => {
setFilter(filterName, value);
}
const getFilterValue = (filterName: string): string | number | string[] | number[] | undefined | null => {
return getFilter(filterName);
}
const departmentFilter = ref<FilterType>({
label: SessionUser.functions.ucFirst(SessionUser.objects.departments.meta.labels.single),
columnName: props.departmentColumn || 'department_id',
type: 'select',
multiple: false,
loading: false,
searchable: true,
placeholder: SessionUser.objects.global.language.search,
value: props.departmentColumn ? getFilterValue(props.departmentColumn) : undefined,
loadFunction: async () => {
// Load options dynamically if needed
if (departmentFilter.value.loading) return;
departmentFilter.value.loading = true;
try {
// Simulate async loading of options
const departments = await SessionUser.objects.departments.get.all();
departmentFilter.value.options = [
{ label: SessionUser.objects.global.language.all, value: '*' },
...departments.map((dept: any) => ({
label: dept.name,
value: dept.id
}))
];
} catch (error) {
console.error('Error loading departments:', error);
} finally {
departmentFilter.value.loading = false;
}
},
options: [
{ label: SessionUser.objects.global.language.all, value: '*' },
// Additional options can be loaded dynamically
],
onChange: (value: string | number | string[] | number[]) => {
setFilterValue(props.departmentColumn || 'department_id', value);
departmentFilter.value.searchQuery = value.toString();
departmentFilter.value.value = value;
},
onInput: (value: string | number | string[] | number[]) => {
departmentFilter.value.searchQuery = value.toString();
},
});
const productFilter = ref<FilterType>({
label: SessionUser.functions.ucFirst(SessionUser.objects.products.meta.labels.single),
columnName: props.productColumn || 'product',
type: 'select',
multiple: false,
loading: false,
searchable: true,
placeholder: SessionUser.objects.global.language.search,
value: props.productColumn ? getFilterValue(props.productColumn) : undefined,
loadFunction: async () => {
// Load options dynamically if needed
if (productFilter.value.loading) return;
productFilter.value.loading = true;
try {
// Simulate async loading of options
const productTypes = await SessionUser.objects.vehicles.columns.type.options();
productFilter.value.options = [
{ label: SessionUser.objects.global.language.all, value: '*' },
...productTypes.map((type: any) => ({
label: type.name,
value: type.id
}))
];
} catch (error) {
console.error('Error loading product types:', error);
} finally {
productFilter.value.loading = false;
}
},
options: [
{ label: SessionUser.objects.global.language.all, value: '*' },
// Additional options can be loaded dynamically
],
onChange: (value: string | number | string[] | number[]) => {
setFilterValue(props.productColumn || 'product', value);
productFilter.value.searchQuery = value.toString();
productFilter.value.value = value;
},
onInput: (value: string | number | string[] | number[]) => {
productFilter.value.searchQuery = value.toString();
},
});
const filters = computed<FilterType[]>(() => {
const filterList: FilterType[] = [];
if (props.departmentColumn) {
filterList.push(departmentFilter.value);
}
if (props.productColumn) {
filterList.push(productFilter.value);
}
// Additional filters can be added here
return filterList;
});
/**
* Auto load options for filters
*/
const loadFilters = async () => {
for (const filter of filters.value) {
// Load options dynamically if needed
if (filter.loadFunction) {
await filter.loadFunction();
}
}
}
const isFilterSet = (filter: FilterType): boolean => {
if (filter.multiple) {
return Array.isArray(filter.value) && filter.value.length > 0;
} else {
return filter.value !== undefined && filter.value !== null && filter.value !== '*';
}
}
onMounted(() => {
loadFilters();
});
const renderFilterName = (filter: FilterType) => {
return props?.columnLabels?.[filter.columnName] || filter.label
}
</script>
<template>
<template
v-for="filter in filters"
:key="filter.label"
>
<!-- Select -->
<template v-if="filter.type === 'select'">
<div class="column is-narrow">
<div class="field mb-0">
<!-- Label -->
<label class="label is-small">
{{ renderFilterName(filter) }}
<span class="is-float-right">
<!-- Reset "button" -->
<template v-if="isFilterSet(filter)">
<span @click="filter.onChange ? filter.onChange(filter.multiple ? [] : '*') : null" class="is-clickable">
<span class="mr-1">
{{ SessionUser.objects.global.language.reset }}
</span>
<span class="icon is-small">
<i class="fas fa-times-circle"></i>
</span>
</span>
</template>
</span>
</label>
<div class="control">
<!-- Single select -->
<div class="select" v-if="!filter.multiple">
<!-- Searchable (autocomplete) -->
<BAutocomplete
v-if="filter.searchable"
:loading="filter.loading"
:placeholder="filter.placeholder || ''"
:open-on-focus="true"
:icon="filter.icon || ''"
:data="getOptionsForSelect(filter.options || [], filter.searchQuery).map(option => option.label) || []"
:model-value="filter.options?.find(option => option.value === filter.value)?.label || ''"
@input="(value) => {
const selectedOption = filter.options?.find(option => option.label === value);
if (selectedOption) {
filter.onChange ? filter.onChange(selectedOption.value) : null;
}
console.warn('Search input: ', value.target.value);
filter.onInput ? filter.onInput(value.target.value) : null;
}"
@select="(value) => {
const selectedOption = filter.options?.find(option => option.label === value);
if (selectedOption) {
filter.onChange ? filter.onChange(selectedOption.value) : null;
}
}"
/>
<!-- Regular select -->
<select
v-else
:value="filter.value"
@change="(event) => {
const selectedValue = event.target.value;
filter.onChange ? filter.onChange(selectedValue) : null;
}"
>
<option
v-for="option in filter.options"
:key="option.value"
:value="option.value"
>{{ option.label }}</option>
</select>
</div>
<!-- Multiple select goes here when relevant... -->
</div>
</div>
</div>
</template>
<!-- Date Picker -->
<!-- ... Other filter types can be added here ... -->
</template>
</template>
<style scoped>
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import {search, metaSearch, isLoading, loadList} from "@/components/pagination/paginatedList.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
</script>
<template>
<!-- Top row: Reload, Search -->
<div class="columns is-vcentered is-multiline">
<div class="column">
<input
class="input"
type="text"
placeholder="Søg..."
v-model="metaSearch"
@input="search($event.target.value)"
/>
</div>
<div class="column is-narrow">
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{SessionUser.objects.global.language.reload}}</LoadButtonWhileAwait>
</div>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,53 @@
<script setup>
import { defineProps, ref } from 'vue';
import {
isLoading,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setMetaItemsPerPage,
setPage,
} from "@/components/pagination/paginatedList.vue";
import PaginationDisplayGeneralSearchReload
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
const props = defineProps({
label: {
type: String,
required: true,
},
});
const isSmall = ref(window.innerWidth < 1024);
</script>
<template>
<div>
<!-- Label -->
<h2 class="title is-4 mb-4">{{ label }}</h2>
<!-- Search & reload -->
<PaginationDisplayGeneralSearchReload/>
<!-- Filters -->
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
<slot name="paginationDisplayFiltersElement"></slot>
<slot name="leftPaginationColumns"></slot>
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
<div class="columns is-multiline">
<div class="column is-12 p-1 m-0"></div>
<slot name="rightPaginationColumns"></slot>
</div>
</template>
</PaginationDisplay>
<!-- Table -->
<section>
<slot name="default"></slot>
</section>
<!-- Pagination -->
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
</div>
</template>
<style scoped>
</style>
@@ -44,6 +44,9 @@ import PaginationNavigation from "@/components/displays/pagination/PaginationNav
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import PaginationDisplayGeneralSearchReload
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
const router = useRouter();
setEndpoint("/orders", false);
// If the customer filter is set, filter the orders by the customer number
@@ -65,7 +68,7 @@ if (router.currentRoute.value.params.departmentId) {
setOrder("created_at", "desc");
setFilter("department_id", router.currentRoute.value.params.departmentId, false);
} else {
setOrder("created_at", "desc");
setOrder("created_at", "desc", false);
}
// If the onlyFromInvoiceCollection prop is set, filter the orders by the invoice collection id
@@ -81,12 +84,12 @@ if (props.autoLoad) {
</script>
<template>
<input @input="search($event.target.value)" class="input" type="text" :placeholder="'Søg i ' + SessionUser.objects.orders.meta.labels.multiple.toLowerCase()" v-if="!hideSearchField"/>
<PaginationDisplayGeneralSearchReload/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
<!-- Sort by created_at -->
<div class="column is-narrow my-3">
<label class="label">Order direction</label>
<label class="label is-small">Order direction</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value); loadList();">
@@ -96,11 +99,11 @@ if (props.autoLoad) {
</div>
</div>
</div>
<PaginationDisplayFilters />
</template>
</PaginationDisplay>
<OrdersTable :orders="list" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{SessionUser.objects.global.language.reload}}</LoadButtonWhileAwait>
</template>
<style scoped>
@@ -1,5 +1,7 @@
<script setup>
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
const props = defineProps({
onlyFromInvoiceCollection: {
type: Number,
@@ -60,6 +62,10 @@ const props = defineProps({
queryParameters : {
type: Object,
default: () => ({})
},
excludedOrderIds: {
type: Array, // Used to exclude specific order ids from the list, this will also ignore warnings about hidden orders
default: () => []
}
})
import {defineProps, ref} from "vue";
@@ -91,6 +97,9 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import {now} from "@vueuse/core";
const router = useRouter();
setHideSearchField(props.hideSearch);
@@ -260,15 +269,19 @@ const doesEndpointMatch = (matcher) => {
<template>
<template v-if="doesEndpointMatch('/orders')">
<input @input="search($event.target.value)" class="input" type="text" placeholder="Søg i transaktioner" v-if="!hideSearchField"/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage" v-show="props.hideFilter === false">
<template #paginationColumns>
<TableLabeledPagination :label="SessionUser.objects.orders.meta.labels.multiple">
<template #paginationDisplayFiltersElement>
<PaginationDisplayFilters
:departmentColumn="'department_id'"
/>
</template>
<template #leftPaginationColumns>
<!-- Sort by created_at -->
<div class="column is-narrow my-3">
<label class="label">Order direction</label>
<div class="column is-narrow">
<label class="label is-small">Order direction</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value, true);">
<select @change="setOrder('created_at', $event.target.value); loadList();">
<option value="asc">Ascending (Oldest first)</option>
<option value="desc" selected>Descending (Newest first)</option>
</select>
@@ -276,8 +289,8 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Invoice status -->
<div class="column is-narrow my-3">
<label class="label">Bogføringsstatus</label>
<div class="column is-narrow">
<label class="label is-small">Bogføringsstatus</label>
<div class="control">
<div class="select">
<select @change="setFilter('booked_invoice_id', $event.target.value, true);">
@@ -289,8 +302,8 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Error status -->
<div class="column is-narrow my-3">
<label class="label">Fejlstatus</label>
<div class="column is-narrow">
<label class="label is-small">Fejlstatus</label>
<div class="control">
<div class="select">
<select @change="setFilter('error_message', $event.target.value, true);">
@@ -302,8 +315,8 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Payment processor -->
<div class="column is-narrow my-3">
<label class="label">Betalingsprocessor</label>
<div class="column is-narrow">
<label class="label is-small">Betalingsprocessor</label>
<div class="control">
<div class="select">
<select @change="setFilter('processor', $event.target.value, true);">
@@ -317,8 +330,8 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Marked as complete ("Gennemført") -->
<div class="column is-narrow my-3">
<label class="label">Gennemført</label>
<div class="column is-narrow">
<label class="label is-small">Gennemført</label>
<div class="control">
<div class="select">
<select @change="setFilter('completed_at', $event.target.value, true);">
@@ -330,8 +343,8 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Multiple selection, customer has attribute -->
<div class="column is-narrow my-3">
<label class="label">Faktura valg</label>
<div class="column is-narrow">
<label class="label is-small">Faktura valg</label>
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_attribute', $event.target.value, true);">
@@ -343,8 +356,8 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Multiple selection, customer has special arrangement -->
<div class="column is-narrow my-3">
<label class="label">Specialaftale</label>
<div class="column is-narrow">
<label class="label is-small">Specialaftale</label>
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_key-OtherSpecialArrangement', $event.target.value, true);">
@@ -356,8 +369,8 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Multiple selection, customer has washing subscription -->
<div class="column is-narrow my-3">
<label class="label">Vaskeabonnement</label>
<div class="column is-narrow">
<label class="label is-small">Vaskeabonnement</label>
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_key-OtherVaskeabonnement', $event.target.value, true);">
@@ -369,7 +382,7 @@ const doesEndpointMatch = (matcher) => {
</div>
</div>
<!-- Multiple selection, customer has washing subscription -FIX
<div class="column is-narrow my-3">
<div class="column is-narrow">
<label class="label">Only tank cleaning</label>s
<div class="control">
<div class="select">
@@ -382,115 +395,47 @@ const doesEndpointMatch = (matcher) => {
</div>
</div> -->
<!-- Date from -->
<div class="column is-narrow my-3">
<label class="label">Fra dato</label>
<div class="column is-narrow">
<label class="label is-small">Fra dato</label>
<div class="control">
<input type="date" class="input" @change="setFilter('created_at-date_from', $event.target.value, true)" v-model="date_from" />
</div>
</div>
<!-- Date to -->
<div class="column is-narrow my-3">
<label class="label">Til dato</label>
<div class="column is-narrow">
<label class="label is-small">Til dato</label>
<div class="control">
<input type="date" class="input" @change="setFilter('created_at-date_to', $event.target.value, true)" v-model="date_to" />
</div>
</div>
<!-- Time shortcuts -->
<!-- Last month -->
<div class="column is-narrow my-3">
<label class="label">&nbsp</label>
<div class="control">
<button
class="button"
v-bind:class="getTimeShortcutClass(setFilterTime.is_last_month(date_from, date_to))"
@click="setFilterTime.last_month()"
>
<span class="icon">
<i v-bind:class="getTimeShortcutIcon(setFilterTime.is_last_month(date_from, date_to))"></i>
</span>
<span>{{SessionUser.objects.global.language.time.last_month}}</span>
<span class="icon">
</span>
</button>
</div>
</div>
<!-- This month -->
<div class="column is-narrow my-3">
<label class="label">&nbsp</label>
<div class="control">
<button
class="button"
v-bind:class="getTimeShortcutClass(setFilterTime.is_this_month(date_from, date_to))"
@click="setFilterTime.this_month()"
>
<span class="icon">
<i v-bind:class="getTimeShortcutIcon(setFilterTime.is_this_month(date_from, date_to))"></i>
</span>
<span>{{SessionUser.objects.global.language.time.this_month}}</span>
<span class="icon">
</span>
</button>
</div>
</div>
<!-- Today -->
<div class="column is-narrow my-3">
<label class="label">&nbsp</label>
<div class="control">
<button
class="button"
v-bind:class="getTimeShortcutClass(setFilterTime.is_today(date_from, date_to))"
@click="setFilterTime.today()"
>
<span class="icon">
<i v-bind:class="getTimeShortcutIcon(setFilterTime.is_today(date_from, date_to))"></i>
</span>
<span>{{SessionUser.objects.global.language.time.today}}</span>
<span class="icon">
</span>
</button>
</div>
</div>
<!-- Yesterday -->
<div class="column is-narrow my-3">
<label class="label">&nbsp</label>
<div class="control">
<button
class="button"
v-bind:class="getTimeShortcutClass(setFilterTime.is_yesterday(date_from, date_to))"
@click="setFilterTime.yesterday()"
>
<span class="icon">
<i v-bind:class="getTimeShortcutIcon(setFilterTime.is_yesterday(date_from, date_to))"></i>
</span>
<span>{{SessionUser.objects.global.language.time.yesterday}}</span>
<span class="icon">
</span>
</button>
</div>
</div>
<!-- All -->
<div class="column is-narrow my-3">
<label class="label">&nbsp</label>
<div class="control">
<button
class="button"
v-bind:class="getTimeShortcutClass(setFilterTime.is_all(date_from, date_to))"
@click="setFilterTime.all()"
>
<span class="icon">
<i v-bind:class="getTimeShortcutIcon(setFilterTime.is_all(date_from, date_to))"></i>
</span>
<span>{{SessionUser.objects.global.language.time.all}}</span>
<span class="icon">
</span>
</button>
</div>
</template>
<template #rightPaginationColumns>
<div class="column is-12">
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="(newSelectionStartDate, newSelectionToDate) => {
const formattedStartDate = newSelectionStartDate.toISOString().split('T')[0];
const formattedEndDate = newSelectionToDate.toISOString().split('T')[0];
date_from = formattedStartDate;
date_to = formattedEndDate;
setFilter('created_at-date_from', formattedStartDate, true);
setFilter('created_at-date_to', formattedEndDate, true);
loadList();
}"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
</div>
</template>
</PaginationDisplay>
<OrdersTable :orders="list" :invoiceView="props.invoiceView" :allowSelectMultiple="true" v-bind:groupInvoiceCollection="props.groupInvoiceCollection" v-bind:showOnlyWithIds="props.showOnlyWithIds" v-bind:autoExpandAll="props.autoExpandAll"/>
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" v-show="!props.hidePagination" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">Reload</LoadButtonWhileAwait>
<template #default>
<OrdersTable :orders="list"
:invoiceView="props.invoiceView"
:allowSelectMultiple="true"
v-bind:groupInvoiceCollection="props.groupInvoiceCollection"
v-bind:showOnlyWithIds="props.showOnlyWithIds"
v-bind:autoExpandAll="props.autoExpandAll"
v-bind:excludedOrderIds="props.excludedOrderIds"
/>
</template>
</TableLabeledPagination>
</template>
</template>
@@ -48,7 +48,7 @@ if (props.autoLoad) {
<template #paginationColumns>
<!-- Sort by created_at -->
<div class="column is-narrow my-3">
<label class="label">Rækkefølge</label>
<label class="label is-small">Rækkefølge</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value); loadList();">
@@ -90,7 +90,14 @@ window.addEventListener('resize', () => {
isSmall.value = window.innerWidth < 1024;
});
const showNewOrderBookingsPortal = () => {
const departmentId = SessionUser.functions.getDepartmentIdFromUrl();
if (!departmentId) {
SessionUser.functions.redirectTo.user('/bookings', true);
return;
}
SessionUser.functions.redirectTo.department(SessionUser.functions.getDepartmentIdFromUrl(), 'modules/bookings', true);
};
</script>
<template>
@@ -169,6 +176,9 @@ window.addEventListener('resize', () => {
</div>
</template>
</PaginationDisplay>
<div class="notification is-warning is-light mb-3 is-clickable" @click="showNewOrderBookingsPortal()">
<strong>Bemærk:</strong> Du befinder dig den gamle bookingportal, vi er i gang med at flytte historisk data til den nye. Du finder den nye booking portal <u>her</u>.
</div>
<BookingsTable :objects="list" :meta="meta" class="mb-4" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" class="mt-3" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">Opdater</LoadButtonWhileAwait>
@@ -1,6 +1,5 @@
<script setup>
import {
isLoaded,
isLoading,
list,
loadList,
@@ -9,27 +8,17 @@ import {
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setFilter,
getFilter,
setPage,
search,
metaSearch,
setOrder,
meta
} from "@/components/pagination/paginatedList.vue";
import { useRouter } from "vue-router";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {ref, watch} from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import InvoiceCollectionsTable from "@/components/displays/user/invoicecollections/InvoiceCollectionsTable.vue";
import PaginationDisplayGeneralSearchReload
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
const router = useRouter();
setEndpoint("/user/invoices", true);
setOrder("created_at", "desc");
@@ -38,13 +27,11 @@ loadList();
</script>
<template>
<input @input="search($event.target.value)" class="input mt-2" type="text" placeholder="Søg efter faktura" v-model="metaSearch" />
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns></template>
</PaginationDisplay>
<InvoiceCollectionsTable :objects="list" :meta="meta" class="mb-4" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" class="mt-3" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">Opdater</LoadButtonWhileAwait>
<TableLabeledPagination label="Oversigt over fakturaer">
<template #default>
<InvoiceCollectionsTable :objects="list" :meta="meta" />
</template>
</TableLabeledPagination>
</template>
<style scoped>
@@ -0,0 +1,207 @@
<script setup>
import {
list,
loadList,
setEndpoint,
setFilter,
} from "@/components/pagination/paginatedList.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
import { defineProps, onMounted, ref} from "vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displays/tables/OrderBookingsTable.vue";
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { Colors } from "@/ThemeConfig.vue";
/**
* Router
*/
const router = useRouter();
/**
* Props
*/
const props = defineProps({
filters: {
type: Object,
default: () => ({}),
required: false
},
})
const orderIdFilter = ref('*');
const onOrderIdFilterChange = (event) => {
const val = event.target.value;
orderIdFilter.value = val;
// Apply filter: '*' clears, 'is null' shows without order, 'not null' shows with order
setFilter('order_id', val);
};
// Department filter
const departmentFilter = ref('*');
const onDepartmentFilterChange = (event) => {
const val = event.target.value;
departmentFilter.value = val;
setFilter('department', val);
};
// Only today filter
const onlyTodayFilter = ref('*');
const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
const val = event.target.value;
onlyTodayFilter.value = val;
if (val === '*') {
setFilter('datetime', val, false); // Clear filter
setFilter('datetime-date_from', null, false);
setFilter('datetime-date_to', null, false);
} else {
const startOfDay = new Date().setHours(0, 0, 0, 0);
const endOfDay = new Date().setHours(23, 59, 59, 999);
setFilter('datetime', null, false);
setFilter('datetime-date_from', new Date(startOfDay).toISOString(), false);
setFilter('datetime-date_to', new Date(endOfDay).toISOString(), false);
}
if (autoLoadList) {
loadList();
}
};
// Version selector + helpers
const versionSelector = ref('new');
const resetVersionToNew = () => {
versionSelector.value = 'new';
};
const showLegacyOrderBookingsPortal = () => {
const departmentId = SessionUser.functions.getDepartmentIdFromUrl();
if (!departmentId) {
SessionUser.functions.redirectTo.user('/bookings-legacy', true);
return;
}
SessionUser.functions.redirectTo.department(SessionUser.functions.getDepartmentIdFromUrl(), 'modules/bookings-legacy', true);
};
// Filters
onMounted(() => {
setEndpoint(SessionUser.objects.order_bookings.meta.endpoint, false);
for (const [key, value] of Object.entries(props.filters)) {
let setFilterKey = true;
// Also set the filter controls if applicable
if (key === 'order_id') {
orderIdFilter.value = value;
} else if (key === 'department') {
departmentFilter.value = value;
} else if (key === 'only_today' && value === true) {
setFilterKey = false; // Since the only_today filter is handled separately
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split('T')[0] } }, false);
}
if (setFilterKey) {
setFilter(key, value, false);
}
}
loadList();
// Load departments for filter options
getDepartments().catch(() => {});
});
</script>
<template>
<div>
<TableLabeledPagination label="Oversigt over bookinger">
<template #paginationDisplayFiltersElement>
<!-- Status filter -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">Status</label>
<div class="control">
<div class="select">
<select :value="orderIdFilter" @change="onOrderIdFilterChange">
<option value="*">Alle</option>
<option value="is null">Ikke udført</option>
<option value="not null">Udført</option>
</select>
</div>
</div>
</div>
</div>
<!-- Department filter -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">Afdeling</label>
<div class="control">
<div class="select">
<select :value="departmentFilter" @change="onDepartmentFilterChange">
<option value="*">Alle</option>
<option v-for="department in departments" :key="department.id" :value="department.id">{{ department.name }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Version selector -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">Version</label>
<div class="control">
<div class="select">
<select @change="showLegacyOrderBookingsPortal(); resetVersionToNew()" v-model="versionSelector">
<option value="new">Ny</option>
<option value="legacy">Gammel</option>
</select>
</div>
</div>
</div>
</div>
<!-- Only today filter -->
<div class="column is-narrow">
<div class="field mb-0">
<label class="label is-small">Kun i dag</label>
<div class="control">
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">Alle</option>
<option :value="new Date().toISOString().split('T')[0]">Ja</option>
</select>
</div>
</div>
</div>
</div>
</template>
<template #leftPaginationColumns>
</template>
<template #rightPaginationColumns>
<!-- Toggles and actions depending on route -->
<!-- User: Only today switch + New booking button -->
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">Vis kun dagens bookinger</label>
<div class="field">
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }) }" :class="{ 'is-link': onlyTodayFilter !== '*' }" :checked="onlyTodayFilter !== '*'" />
<label for="today"></label>
</div>
</div>
<div class="column is-narrow is-float-right" v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">&nbsp;</label>
<button
class="button is-link"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
>
Ny booking
</button>
</div>
<!-- Admin: pending + only today combined switch -->
<div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/admin')">
<label class="label is-small">Vis kun dagens afventende</label>
<div class="field">
<input id="today-pending" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }, false); onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } }); }" :class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }" :checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'" />
<label for="today-pending"></label>
</div>
</div>
</template>
<template #default>
<OrderBookingsTable :objects="list" />
</template>
</TableLabeledPagination>
</div>
</template>
<style scoped>
</style>
@@ -8,7 +8,9 @@ import {
metaItemsPerPage,
metaTotalItems,
setEndpoint,
metaSearch,
setMetaItemsPerPage,
setOrder,
setPage,
search,
setFilter
@@ -21,6 +23,10 @@ import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import InvoicesTable from "@/components/displays/user/invoices/invoicesTable.vue";
import {defineProps} from "vue";
import PaginationDisplayGeneralSearchReload
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
const props = defineProps({
onlyFromInvoiceCollection: {
type: Number,
@@ -40,6 +46,8 @@ if (props.onlyFromInvoiceCollection > 0) {
setFilter("invoice_collection_id", props.onlyFromInvoiceCollection, false);
}
setOrder("id", "desc", false);
if (props.autoLoad) {
loadList();
}
@@ -47,16 +55,20 @@ if (props.autoLoad) {
</script>
<template>
<input @input="search($event.target.value)" class="input mt-2" type="text" placeholder="Søg efter vask" v-model="metaSearch" />
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage" />
<!-- <invoicesTable :objects="list" /> -->
<orders-table
v-bind:orders="list"
v-bind:is-customer-view="true"
v-bind:invoiceView="props.allowDropdownToShowOrderContent"
/>
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">Reload</LoadButtonWhileAwait>
<TableLabeledPagination label="Historiske vaske">
<template #paginationDisplayFiltersElement>
<PaginationDisplayFilters
:departmentColumn="'department_id'"
/>
</template>
<template #default>
<orders-table
v-bind:orders="list"
v-bind:is-customer-view="true"
v-bind:invoiceView="props.allowDropdownToShowOrderContent"
/>
</template>
</TableLabeledPagination>
</template>
<style scoped>
@@ -1,26 +1,22 @@
<script setup>
import {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
setFilter,
getFilter,
} from "@/components/pagination/paginatedList.vue";
// Create a new pagination component for the vehicles, to allow multiple pagination components on the same page
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import { useRouter } from "vue-router";
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
import {defineProps, ref} from "vue";
import {defineProps} from "vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import {Colors} from "@/ThemeConfig.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
/**
* Router
*/
const router = useRouter();
const props = defineProps({
customer_id: {
@@ -42,11 +38,31 @@ loadList();
</script>
<template>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
</PaginationDisplay>
<VehiclesTable :vehicles="list" :show_add_vehicle="props.customer_id" :add_other_customer_id="props.add_other_customer_id"/>
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="!isLoaded()" :loadFunction="loadList" icon="fas fa-sync-alt">Opdater</LoadButtonWhileAwait>
<div>
<TableLabeledPagination label="Oversigt over registrerede køretøjer">
<template #paginationDisplayFiltersElement>
<PaginationDisplayFilters
:productColumn="'type'"
:columnLabels="{ type: SessionUser.objects.vehicles.columns.type.label }"
/>
</template>
<template #rightPaginationColumns>
<!-- Create a new vehicle, if the route is /user -->
<div class="column is-narrow is-float-right"
v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">&nbsp;</label>
<button
class="button is-link"
@click="SessionUser.objects.vehicles.functions.showCreateObjectForCustomerForm(props.customer_id, () => loadList())" style="text-decoration-line: none;" :style="{ 'color': Colors.buttons.textColor, 'background-color': Colors.buttons.backgroundColor }">
Tilføj køretøj
</button>
</div>
</template>
<template #default>
<VehiclesTable :vehicles="list" :show_add_vehicle="props.customer_id" :add_other_customer_id="props.add_other_customer_id"/>
</template>
</TableLabeledPagination>
</div>
</template>
<style scoped>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref, computed, watch, defineProps } from "vue";
/**
* Props
*/
const props = defineProps({
// The maximum height of the steps container
maxHeight: {
type: String,
default: '300px',
},
});
/**
* Variables
*/
</script>
<template>
<div>
<!-- Header -->
<div>
<slot name="header">
<!-- Default header content can go here if needed -->
</slot>
</div>
<!-- Steps container with scrollable content -->
<div :style="{ maxHeight: maxHeight, overflowY: 'auto' }">
<slot name="steps">
<!-- Default steps content can go here if needed -->
</slot>
</div>
<!-- Footer -->
<div>
<slot name="footer">
<!-- Default footer content can go here if needed -->
</slot>
</div>
</div>
</template>
<style scoped>
</style>
@@ -27,6 +27,10 @@ const props = defineProps({
type: Array,
default: () => [],
},
summaryMode: {
type: Boolean,
default: false,
},
})
const order_item_default = {
@@ -100,7 +104,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template>
<div>
<table class="table is-fullwidth">
<table class="table is-fullwidth" v-if="!props.summaryMode">
<thead>
<tr>
<th>{{ucFirst(SessionUser.objects.products.meta.labels.single)}}</th>
@@ -153,6 +157,34 @@ watch(() => props.orderId, (newValue, oldValue) => {
</template>
</tbody>
</table>
<!-- Summary Mode -->
<div v-else>
<!-- Loader -->
<template v-if="orderItems.length === 0">
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px;">
<span class="loader is-loading is-align-self-center"></span>
</span>
</template>
<!-- Order Items -->
<template v-else>
<div v-for="item in orderItems" :key="item.id" class="mb-2">
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<div>
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div>
<!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
@@ -2,12 +2,9 @@
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
defineProps(['objects']);
import { ref } from 'vue';
import { loadList } from "@/components/pagination/paginatedList.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
@@ -18,7 +15,7 @@ const redirect = (path) => {
</script>
<template>
<table class="table is-fullwidth">
<table class="table is-fullwidth is-bordered">
<thead>
<tr>
<th>{{ SessionUser.objects.department_lanes.columns.id.label }}</th>
@@ -26,6 +23,7 @@ const redirect = (path) => {
<th>{{ SessionUser.objects.department_lanes.columns.name.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_in_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_out_id.label }}</th>
<th>{{ SessionUser.objects.department_lanes.columns.relay_machine_id.label }}</th>
<th class="has-text-right">Handlinger</th>
</tr>
</thead>
@@ -57,6 +55,12 @@ const redirect = (path) => {
column="relay_out_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="object"
:loadList="loadList"
column="relay_machine_id"
:edit-function="SessionUser.objects.department_lanes.showEditObjectFieldForm"
/>
<!-- Actions -->
<td class="has-text-right">
<ActionSettingsWheelButton>
@@ -95,7 +95,7 @@ const getDisplayProductAddons = (product) => {
</script>
<template>
<table class="table is-fullwidth is-hoverable is-striped">
<table class="table is-fullwidth is-hoverable is-striped" style="table-layout: fixed;">
<thead>
<tr>
<th>{{ SessionUser.objects.products.columns.order_priority.label }}</th>
@@ -20,33 +20,7 @@ const redirect = (path) => {
window.location = path;
}
const onShowImpersonationQRCode = (src, directLink) => {
Swal.fire({
title: 'Scan QR-koden for at logge ind som bruger',
html: `<img src="${src}" alt="QR-kode" />`,
showCloseButton: true,
showConfirmButton: !!directLink,
showCancelButton: true,
focusConfirm: false,
confirmButtonText: 'Kopiér link',
cancelButtonText: 'Luk',
preConfirm: () => {
navigator.clipboard.writeText(directLink);
Swal.showValidationMessage('Link kopieret til udklipsholderen');
}
});
}
const onClickShowImpersonationQRCode = async (userId) => {
// Get the session token
const newToken = await SessionUser.superUser.intimidate.getImpersonationToken(userId);
// Create the impersonation link
const link = `${window.location.protocol}//${window.location.host}/login/qr?token=${newToken}`;
// Generate a QR code for the impersonation link
const response = await SessionUser.superUser.intimidate.showImpersonationQRCode(link);
// Show the QR code in a modal
onShowImpersonationQRCode(response.value, link);
}
</script>
@@ -69,6 +43,8 @@ const onClickShowImpersonationQRCode = async (userId) => {
<div class="buttons is-float-right">
<!-- Settings wheel -->
<ActionSettingsWheelButton
:customer_number="user.customer_number"
:user_id="user.id"
>
<template #actions>
<ActionSettingsWheelItem
@@ -76,18 +52,6 @@ const onClickShowImpersonationQRCode = async (userId) => {
icon="fas fa-user-edit"
label="Rediger"
/>
<ActionSettingsWheelItem
@click="SessionUser.superUser.intimidate.intimidateUser(user.id)"
icon="fas fa-user-shield"
label="Log ind som bruger"
:disabled="user.group_id === 1"
/>
<ActionSettingsWheelItem
@click="onClickShowImpersonationQRCode(user.id)"
icon="fas fa-user-shield"
label="Log ind som bruger (QR-kode)"
:disabled="user.group_id === 1 && !SessionUser.superUser.intimidate.canImpersonateAdmin"
/>
</template>
</ActionSettingsWheelButton>
</div>
@@ -7,6 +7,7 @@ import NewBookingStep1 from "@/views/dashboards/userDashboard/bookings/displays/
import NewBookingStep2 from "@/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep2.vue";
import NewBookingStep3 from "@/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep3.vue";
import NewBookingStep4 from "@/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep4.vue";
import NewBookingStep5 from "@/views/dashboards/userDashboard/bookings/displays/steps/NewBookingStep5.vue";
/**
* This is the new time booking flow.
* It contains the functions used to create a new time booking.
@@ -31,7 +32,7 @@ const stepComponents = {
2: NewBookingStep2,
3: NewBookingStep3,
4: NewBookingStep4,
5: null,
5: NewBookingStep5,
}
const steps = ref<stepType[]>([
{
@@ -328,7 +328,7 @@ const canUserEditObject = (object) => {
<template>
<div>
<table class="table is-hoverable is-narrow" :class="isSmall ? 'is-hidden' : ''">
<table class="table is-hoverable is-narrow is-fullwidth" :class="isSmall ? 'is-hidden' : ''" style="table-layout: fixed;">
<thead>
<tr>
<th>ID</th>
@@ -349,7 +349,7 @@ const canUserEditObject = (object) => {
<tbody>
<tr v-for="object in parseAndSortBookings(objects)" :key="object.id">
<template v-if="object.isLabel">
<td colspan="14" class="has-text-left">
<td colspan="13" class="has-text-left">
<span class="has-text-grey">
<i class="fas fa-calendar-day"></i>
</span>
@@ -369,7 +369,6 @@ const canUserEditObject = (object) => {
<td>{{ object.date }}</td>
<td>{{ object.regNrTraekker }}</td>
<td>{{ object.regNrTrailer }}</td>
<td>
<!-- Reference number -->
<EditableTableColumn
:object="object"
@@ -379,7 +378,6 @@ const canUserEditObject = (object) => {
:edit-function="SessionUser.objects.bookings.showEditObjectFieldForm"
:permission-check-function="() => canUserEditObject(object)"
/>
</td>
<td>{{ object.notes }}</td>
<td>{{ object.pickup_bool == 1 ? "Ja" : "Nej" }}</td>
<td>{{ object.created_at }}</td>
@@ -440,7 +438,7 @@ const canUserEditObject = (object) => {
</tbody>
<tfoot>
<tr>
<td colspan="14">Viser {{ objects.length }} bookinger {{ isSmall ? '' : 'i alt' }}</td>
<td colspan="13">Viser {{ objects.length }} bookinger {{ isSmall ? '' : 'i alt' }}</td>
</tr>
</tfoot>
</table>
@@ -13,6 +13,7 @@ import { ref } from 'vue';
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
const su_object = SessionUser.objects.collectedOrderInvoices;
@@ -114,7 +115,8 @@ const dropdown_content = (object) => {
<th>{{su_object.columns.id.label}}</th>
<th>{{su_object.columns.created_at.label}}</th>
<th>{{su_object.columns.po_number.label}}</th>
<th>{{SessionUser.objects.global.language.total}}</th>
<th>{{SessionUser.objects.global.language.total_price_amount}}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
@@ -136,6 +138,13 @@ const dropdown_content = (object) => {
:permission-check-function="() => canUserEditObject(object)"
/>
<td>{{ SessionUser.functions.currency.toLocal(object.total_net_amount) }}</td>
<td>
<ActionSettingsWheelButton
:invoice_collection_id="object.id"
>
<template #actions></template>
</ActionSettingsWheelButton>
</td>
</tr>
</template>
</tbody>
@@ -1,6 +1,11 @@
<script setup>
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import {ref} from 'vue';
import { departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({
vehicles: {
type: Array,
@@ -27,12 +32,6 @@ const props = defineProps({
default: null
}
});
import { ref, defineEmits } from 'vue';
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { loadList } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const reload = () => {
@@ -140,7 +139,7 @@ const isVehicleAddonApplied = (addon) => {
<th>{{ SessionUser.objects.vehicles.columns.wash_subscription.label }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.vehicles.columns.reference.label }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.global.language.actions }}</th>
<th v-if="!props.compact"></th>
</tr>
</thead>
<tbody>
@@ -254,31 +253,10 @@ const isVehicleAddonApplied = (addon) => {
</ActionSettingsWheelButton>
</td>
</tr>
<!-- Add vehicle button -->
<tr>
<td colspan="10">
<div class="buttons">
<!-- Add own vehicle button (If the add_other_customer_id is not set) -->
<button
v-if="props.show_add_vehicle && !props.add_other_customer_id"
class="button is-small is-text"
@click="SessionUser.objects.vehicles.functions.showCreateObjectForCustomerForm(props.show_add_vehicle, () => reload())" style="text-decoration-line: none">
Tilføj køretøj
</button>
<!-- Add other vehicle button -->
<button
v-if="props.add_other_customer_id"
class="button is-small is-text"
@click="SessionUser.objects.vehicles.functions.showCreateObjectForCustomerForm(props.add_other_customer_id, () => reload())" style="text-decoration-line: none">
Tilføj køretøj til kunde
</button>
</div>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="10">Showing {{ vehicles.length }} vehicles</td>
<td colspan="10">{{SessionUser.objects.global.language.showing}} {{ props.vehicles.length }} {{SessionUser.objects.vehicles.meta.labels.multiple}}</td>
</tr>
</tfoot>
</table>
+4 -4
View File
@@ -67,13 +67,13 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
<template>
<form @submit.prevent="doNothing">
<div class="field">
<label class="label">Customer Number</label>
<label class="label">{{ SessionUser.functions.ucFirst(SessionUser.objects.global.language.customer_number) }}</label>
<div class="control">
<input class="input" type="text" v-model="customer_number" />
</div>
</div>
<div class="field">
<label class="label">Password</label>
<label class="label">{{ SessionUser.functions.ucFirst(SessionUser.objects.global.language.password) }}</label>
<div class="control">
<input class="input" type="password" v-model="password" />
</div>
@@ -93,13 +93,13 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
<div class="field">
<div class="control">
<div class="buttons">
<LoadButtonWhileAwait :loadFunction="login" class="button is-dark is-fullwidth" id="login-button">Login</LoadButtonWhileAwait>
<LoadButtonWhileAwait :loadFunction="login" class="button is-dark is-fullwidth" id="login-button">{{ SessionUser.functions.ucFirst(SessionUser.objects.global.language.login) }}</LoadButtonWhileAwait>
<LoadButtonWhileAwait :loadFunction="() => router.push('/login/qr')" class="button is-dark is-outlined is-fullwidth is-hidden-desktop" id="qr-login-button">
<!-- QR code icon -->
<span class="icon">
<i class="fas fa-qrcode"></i>
</span>
<span>Login with QR code</span>
<span>{{ SessionUser.functions.ucFirst(SessionUser.objects.global.language.login_with_qr_code) }}</span>
</LoadButtonWhileAwait>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import {onMounted, ref, watch, defineProps, defineEmits} from 'vue';
import {onMounted, ref, watch, defineProps, defineEmits, nextTick, computed} from 'vue';
import {
getOrderId,
getProductsCategory,
@@ -24,6 +24,7 @@ import ConfigurationCategory from "@/components/displays/superuser/configuration
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ProductBox from "@/components/displays/boxes/ProductBox.vue";
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
import { products_category } from "@/components/shop/POSDepartmentProcess.vue";
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct']);
const props = defineProps({
isCustomerBooking: {
@@ -50,27 +51,103 @@ const props = defineProps({
type: Boolean,
default: false,
required: false
},
attemptAutoSelectId: {
type: Number,
default: null,
required: false
},
// New props for grid-before-select and auto-scroll behavior (desktop)
gridBeforeSelect: {
type: Boolean,
default: false,
required: false
},
selectedId: {
type: [String, Number],
default: null,
required: false
},
autoScrollOnSelect: {
type: Boolean,
default: false,
required: false
},
filterProductsVisibleOnCustomerBooking: {
type: Boolean,
default: true,
required: false
},
customer_number: {
type: Number,
default: null,
required: false
},
showPrices: {
type: Boolean,
default: true,
required: false
}
})
// Define the products
const products = ref([]);
onMounted(() => {
console.warn('Mounted SelectProductsFormPOS');
// If the category is not defined, get all products
if (getProductsCategory() === null) {
getProducts(department_id.value).then((response) => {
getProducts(department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
console.log(products.value);
});
} else {
// Get the products by category
getProductCategory(getProductsCategory(), department_id.value).then((response) => {
getProductCategory(getProductsCategory(), department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
// Wait a bit to ensure the DOM has settled
setTimeout(() => {
// Check if the attempt auto select id is set
if (props.attemptAutoSelectId !== null) {
// Check if the product is in the products
if (products.value.find((product) => product.id === props.attemptAutoSelectId) !== undefined) {
// Set the selected id
selectedProductId.value = props.attemptAutoSelectId;
emits('onSelectProduct', products.value.find((product) => product.id === props.attemptAutoSelectId));
}
}
}, 200);
console.log(products.value);
});
}
});
// React to department or category changes
watch(() => department_id.value, (newDeptId) => {
// Reload categories for the new department
loadCategories();
// Refetch products for the current category scope
if (getProductsCategory() === null) {
getProducts(newDeptId, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
} else {
getProductCategory(getProductsCategory(), newDeptId, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
}
});
watch(() => getProductsCategory(), (newCategory) => {
if (newCategory === null) {
getProducts(department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
} else {
getProductCategory(newCategory, department_id.value, !!props.isCustomerBooking, props.customer_number).then((response) => {
products.value = response.data.data;
});
}
});
@@ -129,9 +206,10 @@ const loadCategories = async () => {
categories.value.push(...categories_static.value);
// Get the department id from the route
const route = useRoute();
const departmentId = route.params.departmentId;
// Prefer route param, otherwise fallback to global POS department_id
const departmentId = route.params.departmentId ?? department_id.value;
// If the department id is not set, return
if (departmentId === undefined) {
if (departmentId === undefined || departmentId === null) {
return;
}
// Get the categories
@@ -139,10 +217,16 @@ const loadCategories = async () => {
departmentId
).then((response) => {
for (let i = 0; i < response.length; i++) {
categories.value.push({
name: response[i].category.name,
identifier: response[i].category.id
});
// Check if the id is already in the categories by id,
// This is needed to prevent duplicate categories for higher privilege users
if (categories.value.find((category) => category.identifier === response[i].category.id) !== undefined) {
// The id is already included in the list. Do nothing.
} else {
categories.value.push({
name: response[i].category.name,
identifier: response[i].category.id
});
}
}
});
};
@@ -358,19 +442,113 @@ const hideRecommendedProducts = () => {
isShowingRecommended.value = false;
};
const selectedProduct = ref(null);
const selectedProductId = ref(null);
const isProductSelected = (productId) => {
return selectedProduct.valueOf() === parseInt(productId);
// Effective selected id prefers external prop when provided
const effectiveSelectedId = computed(() => (props.selectedId !== null && props.selectedId !== undefined) ? props.selectedId : selectedProductId.value);
// Determine layout mode: if gridBeforeSelect is true and nothing is selected, use grid (not compact);
// once a product is selected, switch to compact (horizontal scroll) automatically
const isCompactMode = computed(() => {
if (props.gridBeforeSelect) {
return !!effectiveSelectedId.value; // compact (scroll) after selection
}
return !!props.compact;
});
const isGridLayout = computed(() => props.gridBeforeSelect && !effectiveSelectedId.value);
// Card visual compactness: in gridBeforeSelect we still want the compact look
const useCompactCard = computed(() => {
return props.gridBeforeSelect ? true : isCompactMode.value;
});
// Refs for auto-scroll to selected item
const scrollWrapRef = ref(null);
const itemRefs = new Map();
const setItemRef = (el, id) => {
// Normalize possible component refs to their root DOM elements
const node = el && (el.$el || el);
if (node) {
itemRefs.set(id, node);
} else {
itemRefs.delete(id);
}
};
const selectProduct = (product) => {
if (selectedProduct.valueOf() === parseInt(product.id)) {
selectedProduct.value = null;
} else {
emits('onSelectProduct', product);
selectedProduct.value = parseInt(product.id);
const scrollSelectedIntoCenter = async (isRetry = false) => {
if (!props.autoScrollOnSelect) return;
const sel = effectiveSelectedId.value;
if (!sel) return;
// Ensure DOM/classes/layout have settled after mode switch
await nextTick();
await nextTick();
// Wait one frame to ensure CSS class toggles (horizontal vs vertical) have applied
await new Promise((resolve) => requestAnimationFrame(resolve));
const container = scrollWrapRef.value;
const el = itemRefs.get(sel);
if (container && el) {
try {
// If container has no width yet, retry shortly
if (!container.clientWidth) {
setTimeout(() => scrollSelectedIntoCenter(isRetry), 50);
return;
}
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const currentLeft = container.scrollLeft || 0;
const itemCenterX = currentLeft + (elRect.left - containerRect.left) + (elRect.width / 2);
let targetLeft = Math.round(itemCenterX - (container.clientWidth / 2));
const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth);
if (targetLeft < 0) targetLeft = 0;
if (targetLeft > maxScrollLeft) targetLeft = maxScrollLeft;
container.scrollTo({ left: targetLeft, behavior: 'smooth' });
// One-off delayed re-center to account for late image/font reflows
if (!isRetry) {
setTimeout(() => scrollSelectedIntoCenter(true), 300);
}
return;
} catch (e) {
// fall through to fallback below
}
}
// Fallback to native element-based scroll if container metrics aren't ready
if (el && typeof el.scrollIntoView === 'function') {
try {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
} catch (e2) {
el.scrollIntoView(true);
}
}
};
// Watch for changes to the customer number
watch(() => props.customer_number, (newCustomerNumber) => {
console.warn('Customer number changed in SelectProductsFormPOS:', newCustomerNumber);
// Refetch products for the current category scope
if (getProductsCategory() === null) {
getProducts(department_id.value, !!props.isCustomerBooking, newCustomerNumber).then((response) => {
products.value = response.data.data;
});
} else {
getProductCategory(getProductsCategory(), department_id.value, !!props.isCustomerBooking, newCustomerNumber).then((response) => {
products.value = response.data.data;
});
}
});
// When switching from grid to scroll, or when selection changes in scroll mode, ensure visibility
watch([isGridLayout, effectiveSelectedId], (newVals) => {
const [gridNow] = newVals;
// If we just entered scroll mode or changed selection while in scroll mode
if (!gridNow) {
scrollSelectedIntoCenter();
}
});
const selectProduct = (product) => {
emits('onSelectProduct', product);
selectedProductId.value = product.id;
};
const getAddonPrice = (productId, addonId) => {
@@ -418,7 +596,7 @@ const subtractProductAddon = (productId, addonId) => {
const unselectProduct = () => {
// Wait for the selection from the click to be done
setTimeout(() => {
selectedProduct.value = null;
selectedProductId.value = null;
}, 100);
};
@@ -444,8 +622,10 @@ onMounted(() => {
});
const orderByOrderPriority = (productsList) => {
// If the subkey is set, sort the products by the subkey
return productsList.sort((a, b) => a.order_priority - b.order_priority);
// Normalize to an array to avoid runtime errors when source is null/undefined
const list = Array.isArray(productsList) ? productsList : [];
// If the subkey is set, sort the products by the subkey without mutating the original array
return [...list].sort((a, b) => (a?.order_priority ?? 0) - (b?.order_priority ?? 0));
};
const filterProductsVisibleOnCustomerBooking = (productsList) => {
@@ -467,13 +647,16 @@ const filterProductsIsWash = (productsList) => {
}
const filterProductAddonsVisibleOnCustomerBooking = (productsList) => {
if (!props.isCustomerBooking) {
return productsList;
}
// Return a new array of products with filtered addons to avoid mutating reactive sources during render
return productsList.map((product) => {
if (props.isCustomerBooking) {
product.addons = product.addons.filter((addon) => {
return addon.product.display_in_booking_form;
});
}
return product;
const filteredAddons = (product.addons || []).filter((addon) => addon.product && addon.product.display_in_booking_form);
return {
...product,
addons: filteredAddons
};
});
}
@@ -493,18 +676,37 @@ const orderByOrderPriorityCategories = (productsList) => {
}
const hasAutoSelectedCategory = ref(false);
watch(() => categories.value, (newCategories) => {
// If the automaticallySelectCategory prop is set, select the category
if (props.automaticallySelectCategory !== null) {
if (newCategories.find((category) => parseInt(category.identifier) === props.automaticallySelectCategory) !== undefined) {
setProductsCategory(props.automaticallySelectCategory);
getProductCategory(props.automaticallySelectCategory, department_id.valueOf())
.then((response) => {
products.value = response.data.data;
});
// If the automaticallySelectCategory prop is set, select the category once per mount
if (props.automaticallySelectCategory !== null && !hasAutoSelectedCategory.value) {
const targetId = Number(props.automaticallySelectCategory);
if (newCategories.find((category) => Number(category.identifier) === targetId) !== undefined) {
if (getProductsCategory() !== targetId) {
setProductsCategory(targetId);
}
hasAutoSelectedCategory.value = true;
getProductCategory(targetId, department_id.value, true, props.customer_number)
.then((response) => {
products.value = response.data.data;
});
}
}
}, { immediate: true });
});
// Guarded category selection to avoid redundant reactive churn
const onSelectCategory = (rawId) => {
hideRecommendedProducts();
// Handle 'null' from mobile <select>
const targetId = rawId === 'null' || rawId === null ? null : Number(rawId);
if (getProductsCategory() !== targetId) {
setProductsCategory(targetId);
}
getProductCategory(targetId, department_id.value, !!props.isCustomerBooking, props.customer_number)
.then((response) => {
products.value = response.data.data;
});
};
</script>
<template>
@@ -524,12 +726,7 @@ watch(() => categories.value, (newCategories) => {
'is-active': category.identifier === getProductsCategory() && !isShowingRecommended
}">
<a
@click="hideRecommendedProducts();
setProductsCategory(category.identifier);
getProductCategory(category.identifier, department_id.valueOf())
.then((response) => {
products = response.data.data;
});"
@click="onSelectCategory(category.identifier)"
:style="{ 'border-bottom-color': category.identifier === getProductsCategory() ? '#3273dc' : '#fff' }"
style="
border-bottom-width: 0.6rem;
@@ -548,12 +745,8 @@ watch(() => categories.value, (newCategories) => {
<template #default>
<div class="select is-fullwidth mb-2">
<select
@change="hideRecommendedProducts();
setProductsCategory($event.target.value);
getProductCategory($event.target.value, department_id.valueOf())
.then((response) => {
products = response.data.data;
});"
@change="onSelectCategory($event.target.value)"
:value="getProductsCategory() ?? 'null' "
>
<option v-for="category in categories" :key="category.identifier" :value="category.identifier ?? 'null'">
{{ category.name }}
@@ -564,8 +757,8 @@ watch(() => categories.value, (newCategories) => {
</WhiteBox>
</div>
</div>
<div class="tw-scroll-minimal" :class="{ 'tw-scroll-vertical': !props.compact, 'tw-scroll-horizontal': props.compact }">
<div class="columns is-mobile" :class="{ 'is-multiline': !props.compact }">
<div class="tw-scroll-minimal" :class="{ 'tw-scroll-vertical': !isCompactMode, 'tw-scroll-horizontal': isCompactMode }" ref="scrollWrapRef">
<div class="columns is-mobile" :class="{ 'is-multiline': isGridLayout || !isCustomerBooking }">
<!-- Recommended products -->
<div class="column is-12" v-if="isShowingRecommended">
<!-- Same as last time -->
@@ -624,7 +817,7 @@ watch(() => categories.value, (newCategories) => {
<div class="card-content">
<p class="title is-6">
<span>{{ products.find(product => product.id === product_id).name }}</span>
<span class="is-float-right">{{ products.find(product => product.id === product_id).price - (products.find(product => product.id === product_id).price * (getUserProductDiscount(products.find(product => product.id === product_id)) / 100)).toFixed(0) }} Kr.</span>
<span class="is-float-right" v-if="props.showPrices">{{ products.find(product => product.id === product_id).price - (products.find(product => product.id === product_id).price * (getUserProductDiscount(products.find(product => product.id === product_id)) / 100)).toFixed(0) }} Kr.</span>
</p>
<p class="subtitle is-6 has-text-grey">{{ products.find(product => product.id === product_id).description }}</p>
<div class="content">
@@ -668,13 +861,15 @@ watch(() => categories.value, (newCategories) => {
</div>
<!-- New product display -->
<template v-for="product in filterProductAddonsVisibleOnCustomerBooking(filterProductsIsWash(filterProductsVisibleOnCustomerBooking(orderByOrderPriority(products))))" :key="product.id" v-if="!isShowingRecommended">
<div class="column" :class="{ 'is-3': props.compact, 'is-6': !props.compact }">
<div class="column" :class="{ 'is-3-desktop': useCompactCard, 'is-6-desktop': !useCompactCard, 'is-12-mobile': !useCompactCard, 'is-10-mobile': useCompactCard }">
<WhiteBox
class="p-0 pos-product-box"
:class="{ 'is-selected': selectedProduct === product.id, 'is-compact': props.compact }"
:class="{ 'is-selected': effectiveSelectedId === product.id, 'is-compact': useCompactCard }"
:ref="el => setItemRef(el, product.id)"
>
<ProductBox
:compact="!!props.compact"
:showPrices="props.showPrices"
:compact="useCompactCard"
:product="product"
:id="product.id"
:name="product.name"
@@ -682,13 +877,13 @@ watch(() => categories.value, (newCategories) => {
:price="product.price"
:image="getPicture(product.piktogram)"
:addons="props.isAddonsVisible ? product.addons : []"
:isSelected="selectedProduct === product.id"
:isSelected="effectiveSelectedId === product.id"
:selectedAddons="productAddons"
:toggleProductAddon="toggleProductAddon"
:getProductAddonQuantity="getProductAddonQuantity"
:addProductAddon="addProductAddon"
:subtractProductAddon="subtractProductAddon"
@click="!isProductSelected(product.id) ? selectProduct(product) : void 0"
@click="selectProduct(product);"
@addToCart="addProductWithAddonsToOrder(product.id);"
:unselect="unselectProduct"
:setProductAddonNote="setProductAddonNote"
@@ -799,4 +994,19 @@ watch(() => categories.value, (newCategories) => {
.tw-scroll-vertical::-webkit-scrollbar-track {
background: transparent;
}
/* Enforce single-row horizontal layout and ensure horizontal overflow exists */
.tw-scroll-horizontal > .columns {
flex-wrap: nowrap; /* prevent wrapping so we get a single horizontal row */
}
.tw-scroll-horizontal .column {
flex: 0 0 auto; /* items keep intrinsic/column width and overflow horizontally */
}
/* Optional: nicer feel when manually scrolling */
.tw-scroll-horizontal {
scroll-snap-type: x proximity;
}
.tw-scroll-horizontal .pos-product-box {
scroll-snap-align: center;
}
</style>
+6 -2
View File
@@ -12,14 +12,18 @@ const props = defineProps({
type: [String, Function, Object],
default: null,
required: false
},
template: {
type: String,
default: 'is-warning',
}
});
</script>
<template>
<template v-if="message">
<div class="notification is-warning">
<button class="delete"
<div class="notification" :class="template">
<button class="delete" v-show="false"
@click="clearErrors()"
></button>
{{ typeof message === 'function' ? message() : message }}
-1
View File
@@ -1,5 +1,4 @@
<script setup>
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import logo from "@/assets/branding/truckwash-banner-white-compressed.png";
import { showAuthSignOutForm } from "@/components/forms/auth/authSignOutForm.vue";
import { useRouter } from "vue-router";
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import Swal from "sweetalert2";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || 'No commit hash';
const VITE_IS_DEV = import.meta.env.DEV || false;
/**
* Variables
*/
const localVersion = VITE_COMMIT_HASH;
const localStorageLastCheckKey = "lastVersionCheck";
const versionCheckUrl = "/worker/version";
const versionCheckDelay = 1000 * 60 * 5; // 5 minutes
const latestVersion = ref<string | null>(null);
const lastVersionCheck = ref(localStorage.getItem(localStorageLastCheckKey) ? parseInt(localStorage.getItem(localStorageLastCheckKey) || "0") : 0);
/**
* Computed
*/
const currentVersion = computed(() => localVersion || "unknown");
const isUpdateAvailable = computed(() => {
return latestVersion.value !== null && latestVersion.value !== currentVersion.value;
});
const shouldCheckForUpdate = computed(() => {
// In dev mode, do not check for updates
if (VITE_IS_DEV) {
return false;
}
// If never checked before, should check
if (lastVersionCheck.value === 0) {
return true;
}
// If enough time has passed since last check, should check
const now = Date.now();
return (now - lastVersionCheck.value) > versionCheckDelay;
});
/**
* Methods
*/
const showUpdateAvailableNotification = () => {
Swal.fire({
title: 'Opdatering tilgængelig',
text: `En ny version (${latestVersion.value}) er tilgængelig! Du bruger i øjeblikket version ${currentVersion.value}. Opdater venligst siden for at få den nyeste version.`,
icon: 'info',
confirmButtonText: 'Opdater nu',
}).then((result) => {
if (result.isConfirmed) {
window.location.reload();
}
});
}
const checkForUpdate = async () => {
const response = await SessionUser.request(
versionCheckUrl,
'GET',
);
// Verify response
if (!response || !response.data || !response.data.data || !response.data.data.version) {
console.error("Invalid version check response:", response);
return;
}
// Update last check time
lastVersionCheck.value = Date.now();
localStorage.setItem(localStorageLastCheckKey, lastVersionCheck.value.toString());
// Update latest version
latestVersion.value = response.data.data.version;
// Notify user if update is available
if (isUpdateAvailable.value) {
showUpdateAvailableNotification();
}
return response;
}
/**
* Lifecycle Hooks
*/
onMounted(() => {
if (shouldCheckForUpdate.value) {
checkForUpdate();
}
window.addEventListener('focus', () => {
if (shouldCheckForUpdate.value) {
checkForUpdate();
}
});
window.setInterval(() => {
if (shouldCheckForUpdate.value) {
checkForUpdate();
}
}, versionCheckDelay);
});
</script>
<template>
</template>
<style scoped>
</style>
@@ -21,6 +21,8 @@ export type NavigationItemProps = {
permissions?: string[];
// Children can be used for nested navigation items
children?: NavigationItemProps[] | [];
classes?: string[]; // Optional CSS classes for the navigation item
hidden?: boolean; // Optional property to hide the navigation item
};
type NavigationItemType = typeof props;
@@ -61,7 +63,13 @@ const props = {
type: Array as PropType<NavigationItemProps[]>,
required: false,
default: () => []
}
},
// Optional CSS classes for the navigation item
classes: {
type: Array as PropType<string[]>,
required: false,
default: () => []
},
};
@@ -89,13 +97,13 @@ export default defineComponent({
<template>
<router-link v-if="to" v-bind:to="to">
<div class="navbar-item">
<div class="navbar-item" :class="classes">
<i v-if="icon" :class="icon"></i>
<span>{{ label }}</span>
</div>
</router-link>
<!-- If 'to' is not provided, we can assume it's a category or a non-link item -->
<div v-else class="navbar-item">
<div v-else class="navbar-item" :class="classes">
<i v-if="icon" :class="icon"></i>
<span>{{ label }}</span>
</div>
@@ -43,18 +43,18 @@ export function useNavigationItems() {
return itemsGuest;
});
const parsedItems = computed(() => {
const parsedItems = () => {
if (SessionUser) {
if (SessionUser.canAccessUser() && isUser.value) {
return parsedItemsUser;
return parsedItemsUser();
} else if (SessionUser.canAccessAdmin() && isAdmin.value) {
return parsedItemsAdmin;
return parsedItemsAdmin();
} else if (SessionUser.canAccessSuperUser()) {
return parsedItemsSuperUser;
return parsedItemsSuperUser();
}
}
return parsedItemsGuest;
});
return parsedItemsGuest();
};
// Keep exposing global parsed items as-is
return {
@@ -1,8 +1,9 @@
<script lang="ts">
import { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
import { defineComponent, ref, computed} from 'vue';
import {defineComponent, ref, computed, watch} from 'vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
const department_id = ref(window.location.pathname.split('/')[2] || 'default');
import { getDepartmentName } from "@/components/session/token/SessionUser/Objects/economicDepartments.vue";
const department_id = computed(() => {return SessionUser.functions.getDepartmentIdFromUrl()});
const department_booking_count = ref(0); // Placeholder for actual booking count logic
const fetchDepartmentBookingCount = () => {
// Check if the department_id is a valid number
@@ -22,6 +23,10 @@ const fetchDepartmentBookingCount = () => {
});
}
const isDepartmentSet = computed(() => {
return department_id.value !== 'default' && parseInt(department_id.value) > 0;
});
// Fetch the booking count every 5 seconds
setInterval(fetchDepartmentBookingCount, 5000);
@@ -31,10 +36,25 @@ const items = computed<NavigationItemProps[]>(() => [
to: `/admin`,
permissions: ['admin'],
},
// Select available department options
{
label: 'Vælg afdeling',
to: `/admin/departments`,
type: 'category',
hidden: isDepartmentSet.value,
permissions: ['admin', 'list_departments'],
children: SessionUser.functions.getAccessibleDepartments().map((department: any) => ({
label: getDepartmentName(department),
to: `/admin/${department}`,
type: 'department',
permissions: ['admin', 'view_department'],
})),
},
// Kassesystem
{
label: 'Kassesystem',
type: 'category',
hidden: !isDepartmentSet.value,
permissions: ['admin', 'add_order'],
children: [
{
@@ -52,6 +72,7 @@ const items = computed<NavigationItemProps[]>(() => [
],
},
// Tidsbookinger
/**
{
label: 'Tidsbookinger',
type: 'category',
@@ -82,13 +103,14 @@ const items = computed<NavigationItemProps[]>(() => [
permissions: ['admin', 'department_timebookings_types_get'],
},
],
},
}, */
// Bookinger
{
label: 'Bookinger',
to: `/admin/${department_id.value}/modules/bookings`,
type: 'department',
permissions: ['admin', 'list_bookings'],
hidden: !isDepartmentSet.value,
badge: {
// Example badge, replace with actual logic if needed
type: 'info',
@@ -102,6 +124,7 @@ const items = computed<NavigationItemProps[]>(() => [
to: `/admin/${department_id.value}/modules/daily-report`,
type: 'department',
permissions: ['admin', 'list_department_daily_reports'],
hidden: !isDepartmentSet.value,
},
// Notifikationer
{
@@ -109,14 +132,16 @@ const items = computed<NavigationItemProps[]>(() => [
to: `/admin/${department_id.value}/modules/notifications`,
type: 'department',
permissions: ['admin', 'department_notification_sms_get'],
hidden: !isDepartmentSet.value,
},
// Intranet
/**
{
label: 'Intranet',
to: `/admin/${department_id.value}/modules/intranet`,
type: 'department',
permissions: ['admin', 'list_intranet_posts'],
}
}*/
]);
const isDepartment = computed(() => department_id.value !== 'default' && parseInt(department_id.value) > 0);
@@ -19,45 +19,73 @@ const addChildrenIfApplicable = (item: NavigationItemProps) => {
if (item.type === 'link' && item.permissions) {
// Check if the user has at least one of the required permissions
for (const perm of item.permissions) {
if (hasPermission(perm)) {
return [item];
if (!hasPermission(perm)) {
return [];
}
}
return []; // User lacks required permissions
return [item]; // User lacks required permissions
}
}
const items = computed<NavigationItemProps[]>(() => [
/**
* {
* label: getNameIfSignedIn('Hjem'),
* type: 'category',
* children: [
* ...addChildrenIfApplicable({
* label: 'Kunde side',
* to: `/user`,
* type: 'link',
* permissions: ['user'],
* }) || [],
* ...addChildrenIfApplicable({
* label: 'Afdelings side',
* to: `/admin`,
* type: 'link',
* permissions: ['admin'],
* }) || [],
* ...addChildrenIfApplicable({
* label: 'Backoffice',
* to: `/superuser`,
* type: 'link',
* permissions: ['superuser'],
* }) || [],
* ...addChildrenIfApplicable({
* label: 'Indstillinger',
* to: `/user/profile`,
* type: 'link',
* permissions: ['user'],
* }) || [],
* ...addChildrenIfApplicable({
* label: 'Tilbage til egen konto →',
* to: `/auth/return`,
* type: 'link',
* permissions: ['has_superuser_token'],
* }) || [],
* ...addChildrenIfApplicable({
* label: 'Log ud →',
* to: `/logout`,
* type: 'link',
* permissions: ['user'],
* }) || []
* ]
* }
*/
{
label: getNameIfSignedIn('Hjem'),
type: 'category',
children: [
...addChildrenIfApplicable({
label: 'Kunde side',
to: `/user`,
type: 'link',
permissions: ['user'],
}) || [],
...addChildrenIfApplicable({
label: 'Afdelings side',
to: `/admin`,
type: 'link',
permissions: ['admin'],
}) || [],
...addChildrenIfApplicable({
label: 'Backoffice',
to: `/superuser`,
type: 'link',
permissions: ['superuser'],
}) || [],
...addChildrenIfApplicable({
label: 'Sign out →',
to: `/logout`,
type: 'link',
permissions: ['user'],
}) || []
]
}
label: SessionUser.functions.ucFirst(SessionUser.objects.global.language.settings),
to: `/user/profile`,
type: 'link',
permissions: ['user'],
hidden: !window.location.pathname.startsWith('/user')
},
{
label: SessionUser.functions.ucFirst(SessionUser.objects.global.language.logout),
to: `/logout`,
type: 'link',
permissions: ['user'],
hidden: !SessionUser.authenticated.value
},
]);
const parsedItems = () => {
@@ -1,24 +1,29 @@
<script lang="ts">
import { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
import {computed, defineComponent, ref} from 'vue';
import SessionUser from "@/components/session/token/SessionUser.vue";
const items = computed<NavigationItemProps[]>(() => [
{
label: 'Log ind',
to: '/login',
},
{
label: 'Log ind (med QR-kode)',
to: '/login/qr',
},
{
label: 'Opret bruger',
label: SessionUser.functions.ucFirst(SessionUser.objects.global.language.register),
to: '/register',
},
{
label: 'Book Vask',
label: SessionUser.functions.ucFirst(SessionUser.objects.global.language.login),
to: '/login',
hidden: SessionUser.authenticated.value,
children: [
{
label: SessionUser.functions.ucFirst(SessionUser.objects.global.language.login_with_qr_code),
to: '/login/qr',
hidden: !SessionUser.functions.device.isMobile(),
},
]
},
{
label: 'Book vask',
to: '/guest/book/wash',
}
]);
@@ -1,6 +1,6 @@
<script lang="ts">
import { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
import {computed, defineComponent, ref} from 'vue';
import {computed, defineComponent} from 'vue';
import SessionUser from "@/components/session/token/SessionUser.vue";
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -48,10 +48,11 @@ const items = computed<NavigationItemProps[]>(() => [
{ label: firstToUpperCase(SessionUser.superUser.modules.licenseplaterecognizer.meta.labels.multiple), to: '/superuser/configuration/licenseplaterecognizer'},
{ label: firstToUpperCase(SessionUser.superUser.modules.virkdata.meta.labels.multiple), to: '/superuser/configuration/virkdata'},
{ label: firstToUpperCase(SessionUser.superUser.modules.shelly.meta.labels.multiple), to: '/superuser/configuration/shelly'},
{ label: firstToUpperCase(SessionUser.superUser.modules.selfserve.meta.labels.multiple), to: '/superuser/configuration/selfserve'},
]},
// Other
{ label: 'Other', type: 'category', children: [
{ label: 'Statistics', to: '/superuser/statistics' },
//{ label: 'Statistics', to: '/superuser/statistics' },
{ label: firstToUpperCase(SessionUser.objects.vehicles.meta.labels.multiple), to: '/superuser/vehicles' },
]},
]);
@@ -1,57 +1,87 @@
<script lang="ts">
import { NavigationItemProps } from "@/components/models/navigation/NavigationItem.vue";
import {computed, defineComponent, ref} from 'vue';
import SessionUser from "@/components/session/token/SessionUser.vue";
const items = computed<NavigationItemProps[]>(() => [
/**
* {
* label: 'Kunde side',
* type: 'category',
* permissions: ['user'],
* children: [
* {
* label: 'Mine vaske',
* to: `/user/orders`,
* type: 'link',
* permissions: ['user'],
* },
* {
* label: 'Mine køretøjer',
* to: `/user/vehicles`,
* type: 'link',
* permissions: ['user'],
* },
* {
* label: 'Mine bookinger',
* to: `/user/bookings`,
* type: 'link',
* permissions: ['user'],
* },
* {
* label: 'Mine fakturaer',
* to: `/user/invoices`,
* type: 'link',
* permissions: ['user', 'user_invoices'],
* },
* {
* label: 'Mine materialer',
* to: `/user/materials`,
* type: 'link',
* permissions: ['user', 'user_materials'],
* },
* ],
* },
*/
{
label: 'Hjem',
label: SessionUser.functions.ucFirst(SessionUser.objects.global.language.homepage),
to: `/user`,
type: 'link',
permissions: ['user'],
},
{
label: 'Kunde side',
type: 'category',
label: SessionUser.functions.ucFirst(SessionUser.objects.vehicles.meta.labels.multiple),
to: `/user/vehicles`,
type: 'link',
permissions: ['user'],
children: [
{
label: 'Mine vaske',
to: `/user/orders`,
type: 'link',
permissions: ['user'],
},
{
label: 'Mine køretøjer',
to: `/user/vehicles`,
type: 'link',
permissions: ['user'],
},
{
label: 'Mine bookinger',
to: `/user/bookings`,
type: 'link',
permissions: ['user'],
},
{
label: 'Mine fakturaer',
to: `/user/invoices`,
type: 'link',
permissions: ['user', 'user_invoices'],
},
{
label: 'Mine materialer',
to: `/user/materials`,
type: 'link',
permissions: ['user', 'user_materials'],
},
],
},
{
label: 'Book Vask',
to: '/user/bookings/new',
type: 'button',
permissions: ['user', 'user_bookings'],
color: 'primary',
}
label: SessionUser.functions.ucFirst(SessionUser.objects.bookings.meta.labels.multiple),
to: `/user/bookings`,
type: 'link',
permissions: ['user'],
},
{
label: SessionUser.functions.ucFirst(SessionUser.objects.global.language.wash_multiple),
to: `/user/orders`,
type: 'link',
permissions: ['user'],
},
{
label: SessionUser.functions.ucFirst(SessionUser.objects.collectedOrderInvoices.meta.labels.multiple),
to: `/user/invoices`,
type: 'link',
permissions: ['user', 'user_invoices'],
},
/**
* {
* label: 'Book Vask',
* to: '/user/bookings/new',
* type: 'button',
* permissions: ['user', 'user_bookings'],
* color: 'primary',
* }
*/
]);
const parsedItems = () => {
@@ -11,6 +11,7 @@ import { vehicles_matching, searchVehicle, register_new_search, is_latest_search
import { setCustomerId, pos } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import {PosSearchResult} from "@/components/displays/department/pos/steps/mobile/objects/PosSearchResult.vue";
import Swal from "sweetalert2";
import SessionUser from "@/components/session/token/SessionUser.vue";
defineComponent({
name: "RegistrationNumberSearchResult"
});
@@ -72,6 +73,9 @@ const applyBookingAutomatically = (booking: any) => {
console.warn("Booking already applied, ignoring.");
return;
}
if (!booking?.id) {
console.warn("No booking found to apply.");
}
pos.metadata.setBookingId(booking.id); // Set the booking ID to prevent multiple prompts, this is reset if the user cancels.
// If the input registration numbers manually is displayed, hide it.
pos.views.manualInput.value = false;
@@ -88,25 +92,25 @@ const applyBookingAutomatically = (booking: any) => {
//console.warn("User confirmed to apply booking:", booking);
// Apply the booking
pos.metadata.setBookingId(booking.id);
pos.metadata.setNotes(booking.notes || `Booking ID: ${booking.id}`);
pos.metadata.setReference(booking.reference_number || null);
pos.metadata.setNotes(booking.note || `Booking ID: ${booking.id}`);
pos.metadata.setReference(booking.reference || null);
// Set the registration numbers
reg_1.value = booking.regNrTraekker;
reg_2.value = booking.regNrTrailer || null;
reg_1.value = booking.reg_1;
reg_2.value = booking.reg_2 || null;
// Select the vehicles
pos.vehicles.select(1, {
reg: booking.regNrTraekker,
reg: booking.reg_1,
type: 0, // TODO: Determine type from booking if possible
customer_id: booking.customer_id,
customer_id: booking.customer_number,
status: 'booked',
barred: false,
booking_id: booking.id,
});
if (booking.regNrTrailer) {
if (booking.reg_2) {
pos.vehicles.select(2, {
reg: booking.regNrTrailer,
reg: booking.reg_2,
type: 0, //TODO: Determine type from booking if possible
customer_id: booking.customer_id,
customer_id: booking.customer_number,
status: 'booked',
barred: false,
booking_id: booking.id,
@@ -129,9 +133,14 @@ const applyBookingAutomatically = (booking: any) => {
const onSelect = (result: PosSearchResult) => {
//console.warn("onSelect:", result);
// Check if the vehicle has a booking
if (doesVehiclePlateHaveBooking(result.registrationNumber)) {
if (result?.bookingId && result.bookingId > 0) {
//console.warn("Vehicle has a booking:", getVehiclePlateBooking(result.registrationNumber));
applyBookingAutomatically(getVehiclePlateBooking(result.registrationNumber));
SessionUser.objects.order_bookings.get.single(result.bookingId).then((booking) => {
// Apply the booking automatically
applyBookingAutomatically(booking);
}).catch((error) => {
console.error("Error fetching booking:", error);
});
}
// Search and select the customer based on the registration number
if (props.modifyCustomerOnChange) {
@@ -207,6 +216,9 @@ watch(isSearching, (newValue) => {
watch(() => props.searchQuery, (newValue) => {
if (newValue) {
performSearch(newValue);
if (props.automaticallySelect) {
automaticallySelect();
}
}
});
@@ -232,6 +244,7 @@ watch(() => vehicles_matching.value, (newValue) => {
reference: vehicle?.reference || null,
lastOrderId: vehicle?.last_order_id || null,
washSubscription: vehicle?.wash_subscription || null,
bookingId: vehicle?.booking_id || null,
} as PosSearchResult;
});
//console.warn('Transformed PosSearchResult:', result);
@@ -244,24 +257,26 @@ watch(() => vehicles_matching.value, (newValue) => {
});
const automaticallySelect = () => {
if (props.automaticallySelect && props.searchQuery !== '' && vehicles_matching.value.length > 0) {
const withoutDuplicates = preventDuplicates(searchResults.value);
if (props.automaticallySelect && props.searchQuery !== '' && withoutDuplicates.length > 0) {
//console.warn("Automatically selecting vehicle for reg:", props.searchQuery, "from vehicles_matching:", vehicles_matching.value);
// Check if the vehicle is found in the results.
// If the vehicle is found.
if (vehicles_matching.value.find(v => v.reg === props.searchQuery)) {
const match = vehicles_matching.value.find(v => v.reg === props.searchQuery);
//console.warn("Automatically selecting vehicle:", match);
if (withoutDuplicates.find(v => v.registrationNumber.toUpperCase() === props.searchQuery.toUpperCase())) {
const match = withoutDuplicates.find(v => v.registrationNumber.toUpperCase() === props.searchQuery.toUpperCase());
console.warn("Automatically selecting vehicle:", match);
// Emit the selected vehicle object
onSelect({
registrationNumber: props.searchQuery,
customerName: match?.customer_name || "Unknown Customer",
customerId: match?.customer_id || 0,
customerStatus: determineCustomerStatus(match),
registrationNumber: props.searchQuery.toUpperCase(),
customerName: match?.customerName || "Unknown Customer",
customerId: match?.customerId || 0,
customerStatus: match ? match.customerStatus : determineCustomerStatus({reg: props.searchQuery} as any),
type: match?.type || null,
barred: match?.barred,
reference: match?.reference || null,
lastOrderId: match?.last_order_id || null,
washSubscription: match?.wash_subscription,
lastOrderId: match?.lastOrderId || null,
washSubscription: match?.washSubscription,
bookingId: match?.bookingId || null,
});
} else {
console.warn("No exact match found for automatic selection.");
@@ -272,19 +287,19 @@ const automaticallySelect = () => {
const determineCustomerStatus = (vehicle: any): VehicleStatusKey => {
//console.warn('Determine customer status for vehicle:', vehicle);
// Determine the customer status based on the vehicle object
if (doesVehiclePlateHaveBooking(vehicle.reg)) {
if (vehicle?.booking_id) {
//console.warn('Vehicle has a booking:', getVehiclePlateBooking(vehicle.reg));
return 'booked';
} else if (vehicle?.status === 'known') {
//console.warn('Vehicle is known:', vehicle);
return 'known'
} else if (!vehicle.customer_id && !vehicle.customer_name) {
} else if (vehicle?.status === 'unknown') {
//console.warn('Vehicle is unknown:', vehicle);
return 'unknown';
} else if (vehicle.barred) {
} else if (vehicle?.status === 'card' || vehicle?.barred === true) {
//console.warn('Vehicle is barred:', vehicle);
return 'card';
} else if (vehicle.status === 'verified') {
} else if (vehicle?.status === 'verified') {
//console.warn('Vehicle is verified:', vehicle);
return 'verified';
} else {
@@ -296,7 +311,7 @@ const determineCustomerStatus = (vehicle: any): VehicleStatusKey => {
const importPendingBookings = () => {
// Import pending bookings and update the search results
pendingBookings.value.forEach(booking => {
const vehicle = vehicles_matching.value.find(v => v.reg === booking.vehicle_plate);
const vehicle = vehicles_matching.value.find(v => v.reg.toUpperCase() === booking.reg_1.toUpperCase() || v.reg.toUpperCase() === booking.reg_2?.toUpperCase());
if (vehicle) {
vehicle.customer_name = booking.customer_name;
//vehicle.customer_id = booking.customer_id;
@@ -312,6 +327,25 @@ onMounted(() => {
performSearch(props.searchQuery);
}
});
const preventDuplicates = (results: PosSearchResult[]) => {
// The same registration number should not appear multiple times in the results
const seen = new Set();
const uniqueResults: PosSearchResult[] = [];
for (const result of results) {
const strippedReg = stripSpaces(result.registrationNumber).toUpperCase();
if (!seen.has(strippedReg)) {
//console.warn("Adding unique result:", result);
seen.add(strippedReg);
uniqueResults.push(result);
}
}
//console.warn("Unique results after preventing duplicates:", uniqueResults);
return uniqueResults;
}
const stripSpaces = (str: string) => {
return str.replace(/\s+/g, '');
};
</script>
<template>
<!-- Debugging information
@@ -319,7 +353,7 @@ onMounted(() => {
{{ vehicles_matching.length }} vehicles matching
{{ pendingBookings.length }} pending bookings
-->
<div v-for="result in searchResults" :key="result.customerId" style="border-bottom: 1px solid #EAEAEA" @click="onSelect(result)" class="is-clickable" v-if="!props.isHidden">
<div v-for="result in preventDuplicates(searchResults)" :key="result.registrationNumber" style="border-bottom: 1px solid #EAEAEA" @click="onSelect(result)" class="is-clickable" v-if="!props.isHidden">
<div class="columns is-mobile pb-0">
<div class="column is-align-content-center pl-4">
<p>
@@ -3,7 +3,7 @@
</script>
<template>
<div class="mt-6">
<div>
<slot></slot>
</div>
</template>
+11 -1
View File
@@ -1,5 +1,5 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { authenticatedRequest, unauthenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ref } from "vue";
// Define the departments
@@ -40,4 +40,14 @@ export const getDepartmentName = (id) => {
export const getDepartmentDescription = (id) => {
return departments.value.find(department => department.id === id).description;
};
export const getDepartmentsGuest = async (queryParams = {}) => {
isLoading.value = true;
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest('/guest/departments' + (queryString ? `?${queryString}` : ''),'get');
departments.value = request.data.data;
isLoading.value = false;
console.log(departments.value);
return departments.value;
};
</script>
@@ -243,7 +243,6 @@ export const isLatestSearch = (search) => {
export const loadList = () => {
loadSwitch(true);
// clear the list
list.value = [];
paginatedGetRequest();
};

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