Add customer suggestions and selectable invoices features
Introduced customer suggestion functionality for unregistered vehicles based on `reg_1`. Enabled multiple invoice selection with toggle option in order tables. Updated visual styles and handling of errors with automatic clearing.
This commit is contained in:
Generated
+6
@@ -13,6 +13,7 @@
|
||||
"@sweetalert2/theme-bulma": "^5.0.18",
|
||||
"@sweetalert2/theme-dark": "^5.0.18",
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.7.8",
|
||||
"bulma": "^1.0.2",
|
||||
"bulma-calendar": "^7.1.1",
|
||||
@@ -2718,6 +2719,11 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/animate.css": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
|
||||
"integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ=="
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@sweetalert2/theme-bulma": "^5.0.18",
|
||||
"@sweetalert2/theme-dark": "^5.0.18",
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.7.8",
|
||||
"bulma": "^1.0.2",
|
||||
"bulma-calendar": "^7.1.1",
|
||||
|
||||
@@ -40,12 +40,15 @@ const highestPage = ref(0); // The highest page that has been loaded (This is us
|
||||
const limitReached = ref(false);
|
||||
|
||||
/** Fetch the scans when the component is mounted */
|
||||
onMounted(() => {
|
||||
const onSetup = () => {
|
||||
limitReached.value = false;
|
||||
setEndpoint("/numberplatescans", false);
|
||||
setFilter('department_id', parseInt(getDepartment()), false);
|
||||
setOrder('created_at', 'desc', false);
|
||||
setMetaItemsPerPage(10, false);
|
||||
}
|
||||
onMounted(() => {
|
||||
onSetup();
|
||||
});
|
||||
|
||||
/** Whenever the search changes, reset the infinite scroll */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {popperBox, popper, removePopperIfOpen, showPopperWithContent, showPopper
|
||||
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
|
||||
import CustomerDiscountsDepartmentDisplay
|
||||
from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
|
||||
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
|
||||
|
||||
const panel_tabs = ref([
|
||||
{
|
||||
@@ -160,6 +161,12 @@ const hasAttribute = (prop) => {
|
||||
};
|
||||
/** Fetch the customer attributes when the component is mounted */
|
||||
loadCustomerAttributes();
|
||||
|
||||
const isCustomerDetailsExpanded = ref(false);
|
||||
|
||||
const customer_data_has_empty_details = () => {
|
||||
return details.value.some(detail => customer_data.value[detail.prop] === null || customer_data.value[detail.prop] === '');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -189,12 +196,21 @@ loadCustomerAttributes();
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a class="panel-block" v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
|
||||
<span class="panel-icon">
|
||||
<i :class="detail.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span style="width: 50%;">{{ detail.name }}</span><span class="has-text-right" style="width: 50%;">{{ customer_data[detail.prop] }}</span>
|
||||
</a>
|
||||
<template v-if="panel_tabs[0].active" v-for="detail in details" :key="detail.name">
|
||||
<!-- Details that's not empty (unless isCustomerDetailsExpanded is true) -->
|
||||
<a class="panel-block" v-if="customer_data[detail.prop] || isCustomerDetailsExpanded">
|
||||
<span class="panel-icon">
|
||||
<i :class="detail.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span style="width: 50%;">{{ detail.name }}</span><span class="has-text-right" style="width: 50%;">{{ customer_data[detail.prop] }}</span>
|
||||
</a>
|
||||
</template>
|
||||
<!-- Details that's empty, (If there are any) -->
|
||||
<ExpandableContentBox
|
||||
v-if="panel_tabs[0].active && customer_data_has_empty_details"
|
||||
@update:expanded="isCustomerDetailsExpanded = !isCustomerDetailsExpanded"
|
||||
v-bind:expanded="isCustomerDetailsExpanded"
|
||||
/>
|
||||
<!-- Attributes -->
|
||||
<RequiresPermission permission="list_customer_attributes">
|
||||
<div class="panel-block" v-if="panel_tabs[1].active" v-for="attribute in attributes" :key="attribute.name">
|
||||
|
||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
||||
invoiceView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowSelectMultiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
import { ref } from 'vue';
|
||||
@@ -290,12 +294,68 @@ const isDropdownActive = (object) => {
|
||||
// Check if the object is in the active dropdown list
|
||||
return active_dropdown_object_ids.value.includes(object.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invoice collection selection
|
||||
*/
|
||||
const selectedInvoiceCollections = ref([]);
|
||||
const toggleInvoiceCollectionSelection = (invoiceCollectionId) => {
|
||||
// Check if the invoice collection is already selected
|
||||
if (selectedInvoiceCollections.value.includes(invoiceCollectionId)) {
|
||||
// If it is, remove it from the list
|
||||
selectedInvoiceCollections.value = selectedInvoiceCollections.value.filter(id => id !== invoiceCollectionId);
|
||||
} else {
|
||||
// If it is not, add it to the list
|
||||
selectedInvoiceCollections.value.push(invoiceCollectionId);
|
||||
}
|
||||
};
|
||||
|
||||
const isInvoiceCollectionSelected = (invoiceCollectionId) => {
|
||||
// Check if the invoice collection is in the selected list
|
||||
return selectedInvoiceCollections.value.includes(invoiceCollectionId);
|
||||
};
|
||||
const isInvoiceCollectionSelectedAll = () => {
|
||||
// Check if all invoice collections are selected
|
||||
return selectedInvoiceCollections.value.length === props.orders.length;
|
||||
};
|
||||
const selectAllInvoiceCollections = () => {
|
||||
// Check if all invoice collections are selected
|
||||
if (isInvoiceCollectionSelectedAll()) {
|
||||
// If they are, deselect all
|
||||
selectedInvoiceCollections.value = [];
|
||||
} else {
|
||||
// If they are not, select all
|
||||
selectedInvoiceCollections.value = props.orders.map(order => order.invoice_collection_id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<p>
|
||||
{{selectedInvoiceCollections.length}}
|
||||
</p>
|
||||
</td>
|
||||
<td colspan="90%">
|
||||
<code>{{selectedInvoiceCollections}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="is-narrow" v-if="props.invoiceView && props.allowSelectMultiple">
|
||||
<button
|
||||
class="button is-small"
|
||||
@click="selectAllInvoiceCollections()"
|
||||
:class="{
|
||||
'is-light': !isInvoiceCollectionSelectedAll(),
|
||||
'is-dark': isInvoiceCollectionSelectedAll()
|
||||
}"
|
||||
>
|
||||
{{ isInvoiceCollectionSelectedAll() ? SessionUser.objects.global.language.unselect : SessionUser.objects.global.language.select }} {{ SessionUser.objects.global.language.all.toLowerCase() }}
|
||||
</button>
|
||||
</th>
|
||||
<th class="is-narrow" v-if="props.invoiceView"></th>
|
||||
<th class="status-bar-table-header"></th>
|
||||
<template v-for="tableHeaders in tableHeaders" :key="tableHeaders">
|
||||
@@ -335,6 +395,20 @@ const isDropdownActive = (object) => {
|
||||
<tbody>
|
||||
<template v-for="order in orders" :key="order.id">
|
||||
<tr>
|
||||
<template v-if="props.invoiceView && props.allowSelectMultiple">
|
||||
<td>
|
||||
<button
|
||||
class="button is-small"
|
||||
@click="toggleInvoiceCollectionSelection(order.invoice_collection_id)"
|
||||
:class="{
|
||||
'is-light': !isInvoiceCollectionSelected(parseInt(order.invoice_collection_id)),
|
||||
'is-dark': isInvoiceCollectionSelected(parseInt(order.invoice_collection_id))
|
||||
}"
|
||||
>
|
||||
{{ isInvoiceCollectionSelected(parseInt(order.invoice_collection_id)) ? SessionUser.objects.global.language.unselect : SessionUser.objects.global.language.select }}
|
||||
</button>
|
||||
</td>
|
||||
</template>
|
||||
<template v-if="props.invoiceView">
|
||||
<td>
|
||||
<button
|
||||
|
||||
@@ -60,6 +60,18 @@ watch(() => isCustomerSelected(), (newValue) => {
|
||||
}
|
||||
});
|
||||
|
||||
// attempt to focus on the reg_1 input field
|
||||
const focusOnReg1 = () => {
|
||||
setTimeout(() => {
|
||||
const reg1Input = document.getElementById("reg_1");
|
||||
if (reg1Input) {
|
||||
reg1Input.focus();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
focusOnReg1();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -151,7 +163,7 @@ watch(() => isCustomerSelected(), (newValue) => {
|
||||
</ElementTabsBox>
|
||||
</template>
|
||||
</WhiteBox>
|
||||
<NextStepError class="is-fullwidth" />
|
||||
<NextStepError class="is-fullwidth" :clear-automatically="true" :clear-delay="8000"/>
|
||||
<ButtonsBox>
|
||||
<NextStep class="is-fullwidth" tabindex="6"/>
|
||||
</ButtonsBox>
|
||||
|
||||
+1
-1
@@ -411,7 +411,7 @@ const getTimeShortcutIcon = (boolean) => {
|
||||
</div>
|
||||
</template>
|
||||
</PaginationDisplay>
|
||||
<OrdersTable :orders="list" :invoiceView="props.invoiceView"/>
|
||||
<OrdersTable :orders="list" :invoiceView="props.invoiceView" :allowSelectMultiple="true"/>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { customer_id, notes, isCustomerBarred, customer_name, department_id, reference, reg_1, reg_2, reg_3, selectCustomer, isCustomerSelected, customer_attributes } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { customer_id, notes, isCustomerBarred, customer_name, department_id, reference, reg_1, reg_2, reg_3, searchAndSelectCustomer, selectCustomer, isCustomerSelected, customer_attributes } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import CustomerSearchField from "@/components/search/economic/customerSearchField.vue";
|
||||
import { searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
|
||||
import PosNotes from "@/components/displays/department/pos/PosNotes.vue";
|
||||
@@ -13,6 +13,7 @@ import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentB
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
|
||||
import VehicleCustomerSuggestionsPos from "@/components/forms/department/pos/input/vehicleCustomerSuggestionsPos.vue";
|
||||
|
||||
const isRegistrationNumbersExpanded = ref(false);
|
||||
const isOtherExpanded = ref(false);
|
||||
@@ -132,23 +133,38 @@ const customerHasAttribute = (attribute) => {
|
||||
</div>
|
||||
<!-- Notes -->
|
||||
<PosNotes :notes="notes" :is-loading="false" :isAddFormVisible="false" :isOldNotesVisible="true" class="mb-3"/>
|
||||
<!-- If the vehicle is registered to a customer -->
|
||||
<template v-if="vehicleObject">
|
||||
<!-- Divider -->
|
||||
<div class="divider">{{ SessionUser.objects.vehicles.meta.labels.single.toUpperCase() }}</div>
|
||||
<!-- Vehicle information -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<!-- Icon -->
|
||||
<div class="card-header-icon">
|
||||
<!-- If the vehicle is not registered to a customer -->
|
||||
<template v-if="(!vehicleObject || !vehicleObject.customer_id) && reg_1.length > 0">
|
||||
<div class="divider">{{SessionUser.objects.global.language.select}} {{ SessionUser.objects.global.language.customer }}</div>
|
||||
<customerSearchFieldPos />
|
||||
<vehicleCustomerSuggestionsPos
|
||||
v-bind:reg_1="reg_1"
|
||||
@customerSelected="searchAndSelectCustomer"
|
||||
/>
|
||||
</template>
|
||||
<!-- Expandable content box for rarely used inputs -->
|
||||
<ExpandableContentBox
|
||||
v-bind:expanded="isOtherExpanded"
|
||||
@update:expanded="isOtherExpanded = $event"
|
||||
>
|
||||
<template #expandedContent>
|
||||
<!-- If the vehicle is registered to a customer -->
|
||||
<template v-if="vehicleObject && vehicleObject.customer_id">
|
||||
<!-- Divider -->
|
||||
<div class="divider">{{ SessionUser.objects.vehicles.meta.labels.single.toUpperCase() }}</div>
|
||||
<!-- Vehicle information -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<!-- Icon -->
|
||||
<div class="card-header-icon">
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-car" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Vehicle registration number -->
|
||||
<div class="card-header-title">{{ SessionUser.objects.products.functions.getProductName(parseInt(vehicleObject.type)) }}</div>
|
||||
<!-- Subscription -->
|
||||
<div class="card-header-icon">
|
||||
</div>
|
||||
<!-- Vehicle registration number -->
|
||||
<div class="card-header-title">{{ SessionUser.objects.products.functions.getProductName(parseInt(vehicleObject.type)) }}</div>
|
||||
<!-- Subscription -->
|
||||
<div class="card-header-icon">
|
||||
<span
|
||||
class="tag is-light"
|
||||
:class="{
|
||||
@@ -157,90 +173,79 @@ const customerHasAttribute = (attribute) => {
|
||||
}"
|
||||
>
|
||||
{{vehicleObject.wash_subscription
|
||||
? SessionUser.objects.global.language.have
|
||||
: SessionUser.objects.global.language.have_not
|
||||
? SessionUser.objects.global.language.have
|
||||
: SessionUser.objects.global.language.have_not
|
||||
}} {{ SessionUser.objects.vehicles.columns.wash_subscription.label.toLowerCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Vehicle information -->
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<!-- Suggest order items -->
|
||||
<div class="columns">
|
||||
<!-- Last time, the vehicle was washed -->
|
||||
<div class="column is-6">
|
||||
<div class="divider">{{ SessionUser.objects.global.language.last_wash }}</div>
|
||||
<!-- Content of the last wash -->
|
||||
<order-content-table v-bind:order-id="vehicleObject.last_order_id" :displayPrice="false" :displayReference="false" :display-notes="false"/>
|
||||
<!-- Copy last wash content -->
|
||||
<div class="control mt-3">
|
||||
<button
|
||||
class="button is-small is-light is-fullwidth"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ SessionUser.objects.global.language.copy }} {{ SessionUser.objects.global.language.last_wash.toLowerCase() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wash subscription -->
|
||||
<div class="column is-6">
|
||||
<div class="divider is-success">{{ SessionUser.objects.vehicles.columns.wash_subscription.label }}</div>
|
||||
<!-- Content of the wash subscription -->
|
||||
<table class="table is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ucFirst(SessionUser.objects.products.meta.labels.single)}}</th>
|
||||
<th>{{ucFirst(SessionUser.objects.global.language.quantity)}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ SessionUser.objects.products.functions.getProductName(parseInt(vehicleObject.type)) }}</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<template v-for="item in vehicleObject.addons.list" :key="item.id">
|
||||
<!-- If the item is a primary item, show it -->
|
||||
<tr>
|
||||
<!-- If the item is included in the invoice, show it normally -->
|
||||
<td>+ {{ item.product.name }}</td>
|
||||
<td>{{ item.amount }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Copy last wash subscription content -->
|
||||
<div class="control mt-3">
|
||||
<button
|
||||
class="button is-small is-light is-fullwidth is-success"
|
||||
</div>
|
||||
<!-- Vehicle information -->
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<!-- Suggest order items -->
|
||||
<div class="columns">
|
||||
<!-- Last time, the vehicle was washed -->
|
||||
<div class="column is-auto-fill" v-if="vehicleObject.last_order_id">
|
||||
<div class="divider">{{ SessionUser.objects.global.language.last_wash }}</div>
|
||||
<!-- Content of the last wash -->
|
||||
<order-content-table v-bind:order-id="vehicleObject.last_order_id" :displayPrice="false" :displayReference="false" :display-notes="false"/>
|
||||
<!-- Copy last wash content -->
|
||||
<div class="control mt-3">
|
||||
<button
|
||||
class="button is-small is-light is-fullwidth"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ SessionUser.objects.global.language.copy }} {{ SessionUser.objects.vehicles.columns.wash_subscription.label.toLowerCase() }}</span>
|
||||
</button>
|
||||
<span>{{ SessionUser.objects.global.language.copy }} {{ SessionUser.objects.global.language.last_wash.toLowerCase() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wash subscription -->
|
||||
<div class="column is-6" v-if="vehicleObject.wash_subscription">
|
||||
<div class="divider is-success">{{ SessionUser.objects.vehicles.columns.wash_subscription.label }}</div>
|
||||
<!-- Content of the wash subscription -->
|
||||
<table class="table is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ucFirst(SessionUser.objects.products.meta.labels.single)}}</th>
|
||||
<th>{{ucFirst(SessionUser.objects.global.language.quantity)}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ SessionUser.objects.products.functions.getProductName(parseInt(vehicleObject.type)) }}</td>
|
||||
<td>1</td>
|
||||
</tr>
|
||||
<template v-for="item in vehicleObject.addons.list" :key="item.id">
|
||||
<!-- If the item is a primary item, show it -->
|
||||
<tr>
|
||||
<!-- If the item is included in the invoice, show it normally -->
|
||||
<td>+ {{ item.product.name }}</td>
|
||||
<td>{{ item.amount }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Copy last wash subscription content -->
|
||||
<div class="control mt-3">
|
||||
<button
|
||||
class="button is-small is-light is-fullwidth is-success"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ SessionUser.objects.global.language.copy }} {{ SessionUser.objects.vehicles.columns.wash_subscription.label.toLowerCase() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Vehicle object -->
|
||||
<!--<pre>{{vehicleObject}}</pre>-->
|
||||
</template>
|
||||
<!-- If the vehicle is not registered to a customer -->
|
||||
<template v-else-if="!vehicleObject && reg_1.length > 0 && !isUserCurrentlyInFocusReg1">
|
||||
<div class="divider">{{ SessionUser.objects.global.language.customer }}</div>
|
||||
<customerSearchFieldPos />
|
||||
</template>
|
||||
<!-- Expandable content box for rarely used inputs -->
|
||||
<ExpandableContentBox
|
||||
v-bind:expanded="isOtherExpanded"
|
||||
@update:expanded="isOtherExpanded = $event"
|
||||
>
|
||||
<template #expandedContent>
|
||||
<!-- Vehicle object -->
|
||||
<!--<pre>{{vehicleObject}}</pre>-->
|
||||
</template>
|
||||
<!-- Divider -->
|
||||
<div class="divider">{{ SessionUser.objects.global.language.other }}</div>
|
||||
<!-- Customer search field (Located discreetly here, if the vehicle is registered to a customer) -->
|
||||
|
||||
@@ -5,14 +5,14 @@ const props = defineProps(['label']);
|
||||
|
||||
const label = props.label || 'Næste';
|
||||
import { Colors } from "@/ThemeConfig.vue";
|
||||
import 'animate.css/animate.min.css';
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="button"
|
||||
class="button slide-green-on-hover-left-to-right has-text-white"
|
||||
@click="nextStep" v-if="nextStepDelay === 0"
|
||||
:style="{ 'background-color': Colors.buttons.warning.backgroundColor, 'color': Colors.buttons.warning.textColor }"
|
||||
>
|
||||
{{ label }}</button>
|
||||
<button class="button is-loading" v-else>Please wait... {{ nextStepDelay }}</button>
|
||||
@@ -20,4 +20,36 @@ import { Colors } from "@/ThemeConfig.vue";
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
.button.slide-green-on-hover-left-to-right {
|
||||
transition: all .3s;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f0ad4e; /* yellow */
|
||||
z-index: -2;
|
||||
}
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background-color: #45a049; /* green */
|
||||
transition: all .3s;
|
||||
z-index: -1;
|
||||
}
|
||||
&:hover {
|
||||
&:before {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,92 @@
|
||||
<script setup>
|
||||
import { getError } from "@/components/request/HandleGlobalError.vue";
|
||||
import {clearErrors, getError} from "@/components/request/HandleGlobalError.vue";
|
||||
import {defineProps, ref, watch} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
clearAutomatically: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowClear: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
clearDelay: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
const timer_start_ms = ref(null);
|
||||
|
||||
const onClick = () => {
|
||||
if (props.allowClear) {
|
||||
clearErrors();
|
||||
}
|
||||
};
|
||||
|
||||
if (props.clearAutomatically) {
|
||||
watch(() => getError('stepError'), (newValue) => {
|
||||
if (newValue && props.allowClear) {
|
||||
console.log("Clearing error after delay", props.clearDelay);
|
||||
timer_start_ms.value = Date.now();
|
||||
setTimeout(() => {
|
||||
console.log("Clearing error");
|
||||
clearErrors();
|
||||
}, props.clearDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const progress_value_percent = ref(0);
|
||||
|
||||
const getProgressValue = () => {
|
||||
if (!timer_start_ms.value) {
|
||||
return 100;
|
||||
}
|
||||
if (!props.allowClear) {
|
||||
return 100;
|
||||
}
|
||||
if (props.clearAutomatically && timer_start_ms.value) {
|
||||
// Check if the duration has passed, if so, return 100
|
||||
if (Date.now() - timer_start_ms.value >= props.clearDelay) {
|
||||
// Clear the timer
|
||||
timer_start_ms.value = null;
|
||||
return 100;
|
||||
}
|
||||
// Calculate the progress percentage
|
||||
const elapsed = Date.now() - timer_start_ms.value;
|
||||
return Math.min((elapsed / props.clearDelay) * 100, 100);
|
||||
}
|
||||
// If not clearing automatically, return 100
|
||||
return 100;
|
||||
};
|
||||
|
||||
const updateProgress = () => {
|
||||
progress_value_percent.value = getProgressValue();
|
||||
};
|
||||
|
||||
// Update the progress value every 100ms
|
||||
setInterval(() => {
|
||||
progress_value_percent.value = getProgressValue();
|
||||
}, 10);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification is-danger" v-if="getError('stepError')">
|
||||
<p><strong>{{ getError('stepError') }}</strong></p>
|
||||
<div class="mb-3" v-if="getError('stepError')">
|
||||
<div class="notification is-danger mb-0" style="border-bottom-left-radius: 0; border-bottom-right-radius: 0;">
|
||||
<button class="delete" @click="onClick" v-if="props.allowClear"></button>
|
||||
<p><strong>{{ getError('stepError') }}</strong></p>
|
||||
</div>
|
||||
<!-- Progress bar, before the error message deletes automatically -->
|
||||
<div class="is-fixed-bottom has-text-centered mt-0" style="border-bottom-left-radius: 6px; border-bottom-right-radius: 6px;">
|
||||
<progress
|
||||
class="progress is-danger"
|
||||
style="border-radius: 0 0 6px 6px;"
|
||||
:value="progress_value_percent"
|
||||
:max="100"
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -43,6 +43,26 @@ const vehicle_response_object = {
|
||||
}
|
||||
}
|
||||
|
||||
const get_unregistered_vehicle_object = (reg) => {
|
||||
// Generate an id for the unregistered vehicle, this is only used for the dropdown
|
||||
let id_prefix = 999999999999;
|
||||
let id = id_prefix + Math.floor(Math.random() * 1000000);
|
||||
return {
|
||||
id: id,
|
||||
user_id: null,
|
||||
customer_id: null,
|
||||
customer_name: "",
|
||||
type: null,
|
||||
reg: reg,
|
||||
wash_subscription: false,
|
||||
addons: {
|
||||
enabled: 0,
|
||||
available: 0,
|
||||
list: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// To prevent the searches from overriding more recent searches
|
||||
const current_search_id = ref(null);
|
||||
const register_new_search = () => {
|
||||
@@ -95,6 +115,7 @@ const searchVehicle = async ( inputValue, search_id ) => {
|
||||
// If no results, clear the vehicles_matching array
|
||||
vehicles_matching.value = [];
|
||||
}
|
||||
getUnregisteredVehicleObjects(inputValue, search_id);
|
||||
}).catch(error => {
|
||||
console.error("Error:", error, search_id);
|
||||
}).finally(() => {
|
||||
@@ -106,6 +127,54 @@ const searchVehicle = async ( inputValue, search_id ) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getUnregisteredVehicleObjects = (inputValue, search_id) => {
|
||||
// Check if the search ID is the latest
|
||||
if (!is_latest_search(search_id)) {
|
||||
console.log("Search ID is not the latest. Ignoring this search.");
|
||||
return;
|
||||
}
|
||||
// If the input value is empty, clear the vehicles_matching array
|
||||
if (!inputValue) {
|
||||
vehicles_matching.value = [];
|
||||
return;
|
||||
}
|
||||
SessionUser.request(
|
||||
'/department/vehicles/unknown-customer',
|
||||
'GET',
|
||||
{
|
||||
search: inputValue,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
}
|
||||
).then(response => {
|
||||
console.log("Unregistered vehicles:", response.data.data);
|
||||
if (!is_latest_search(search_id)) {
|
||||
return
|
||||
}
|
||||
let result = response?.data?.data;
|
||||
let unregistered_vehicles = [];
|
||||
if (result && result.length > 0) {
|
||||
// If there are results, parse them into unregistered_vehicles
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
unregistered_vehicles.push(get_unregistered_vehicle_object(result[i].reg_1));
|
||||
}
|
||||
// Set the vehicles_matching array to include both registered and unregistered vehicles
|
||||
vehicles_matching.value = [...vehicles_matching.value, ...unregistered_vehicles];
|
||||
} else {
|
||||
// If no results, clear the vehicles_matching array
|
||||
vehicles_matching.value = [];
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error("Error:", error, search_id);
|
||||
}).finally(() => {
|
||||
console.log("Finish search", search_id);
|
||||
if (is_latest_search(search_id)) {
|
||||
// Set the isSearching flag to false
|
||||
isSearching.value = false;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const getVehicleObjectFromId = (id) => {
|
||||
// Find the vehicle object by ID
|
||||
return vehicles_matching.value.find(vehicle => vehicle.id === id);
|
||||
@@ -149,7 +218,11 @@ const selectVehicle = (vehicle_id) => {
|
||||
customer_id.value = vehicle.customer_id;
|
||||
department_id.value = vehicle.type;
|
||||
getCustomerName(vehicle.customer_id);
|
||||
searchAndSelectCustomer(vehicle.customer_id);
|
||||
if (vehicle.customer_id) {
|
||||
// If the vehicle has a customer ID, search and select the customer
|
||||
// This is to prevent the unregistered vehicle from trying to select an unknown customer
|
||||
searchAndSelectCustomer(vehicle.customer_id);
|
||||
}
|
||||
// Emit the vehicle object to the parent component
|
||||
emitVehicleObject(vehicle);
|
||||
}
|
||||
@@ -227,7 +300,11 @@ const lostfocus = () => {
|
||||
customer_id.value = vehicle.customer_id;
|
||||
department_id.value = vehicle.type;
|
||||
getCustomerName(vehicle.customer_id);
|
||||
searchAndSelectCustomer(vehicle.customer_id);
|
||||
if (vehicle.customer_id) {
|
||||
// If the vehicle has a customer ID, search and select the customer
|
||||
// This is to prevent the unregistered vehicle from trying to select an unknown customer
|
||||
searchAndSelectCustomer(vehicle.customer_id);
|
||||
}
|
||||
// Emit the vehicle object to the parent component
|
||||
emitVehicleObject(vehicle);
|
||||
} else {
|
||||
@@ -249,7 +326,11 @@ watch(vehicles_matching, (newValue) => {
|
||||
customer_id.value = vehicle.customer_id;
|
||||
department_id.value = vehicle.type;
|
||||
getCustomerName(vehicle.customer_id);
|
||||
searchAndSelectCustomer(vehicle.customer_id);
|
||||
if (vehicle.customer_id) {
|
||||
// If the vehicle has a customer ID, search and select the customer
|
||||
// This is to prevent the unregistered vehicle from trying to select an unknown customer
|
||||
searchAndSelectCustomer(vehicle.customer_id);
|
||||
}
|
||||
// Emit the vehicle object to the parent component
|
||||
emitVehicleObject(vehicle);
|
||||
}
|
||||
@@ -315,7 +396,7 @@ const emitFocus = (isFocused) => {
|
||||
'is-active': getSearchIndexByVehicleId(result.id) === selectedDropdownItem,
|
||||
'is-drop-down-selected': getSearchIndexByVehicleId(result.customerNumber) === selectedDropdownItem,
|
||||
}">
|
||||
{{ result.reg }} - {{ result.customer_name }} {{ (result.wash_subscription ? ' ( VA )' : '') }}
|
||||
{{ result.reg }}{{ result.customer_name ? (' - ' + result.customer_name ) : '' }}{{ (result.wash_subscription ? ' ( VA ) ' : '') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ const keyDownNextTabIndexButton = (event, tabIndex) => {
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-danger" @click="selectCustomer(null)" :tabindex="isCustomerSelected() ? 1 : -1">
|
||||
Clear
|
||||
{{SessionUser.objects.global.language.clear}}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import {ref, defineProps, defineEmits, watch} from 'vue';
|
||||
import { selectCustomer, isCustomerSelected, customer_id } from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const props = defineProps({
|
||||
reg_1: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['customerSelected']);
|
||||
|
||||
const getCustomerSuggestionObject = (id, customer_number, customer_name) => {
|
||||
return {
|
||||
id: parseInt(id),
|
||||
customer_number: parseInt(customer_number),
|
||||
customer_name: customer_name,
|
||||
};
|
||||
}
|
||||
const customer_suggestions = ref([
|
||||
/* Dummy data for testing purposes
|
||||
getCustomerSuggestionObject(1, '1234567890', 'John Doe'),
|
||||
getCustomerSuggestionObject(2, '0987654321', 'Jane Smith'),
|
||||
getCustomerSuggestionObject(3, '1122334455', 'Alice Johnson'),
|
||||
getCustomerSuggestionObject(4, '5566778899', 'Bob Brown'),
|
||||
getCustomerSuggestionObject(5, '2233445566', 'Charlie Davis'),
|
||||
getCustomerSuggestionObject(6, '6677889900', 'Diana Prince'),
|
||||
getCustomerSuggestionObject(7, '3344556677', 'Ethan Hunt'),
|
||||
getCustomerSuggestionObject(8, '7788990011', 'Felicity Smoak'),
|
||||
getCustomerSuggestionObject(9, '4455667788', 'George Clooney'),
|
||||
getCustomerSuggestionObject(10, '8899001122', 'Hannah Montana'),
|
||||
getCustomerSuggestionObject(11, '5566778899', 'Ian Malcolm'),
|
||||
getCustomerSuggestionObject(12, '9988776655', 'Jack Sparrow'),
|
||||
getCustomerSuggestionObject(13, '2233445566', 'Katherine Johnson'),
|
||||
getCustomerSuggestionObject(14, '6677889900', 'Leonardo DiCaprio'),
|
||||
getCustomerSuggestionObject(15, '3344556677', 'Mia Thermopolis'),
|
||||
*/
|
||||
]);
|
||||
|
||||
const getCustomerSuggestions = () => {
|
||||
// Check if the reg_1 prop is provided (and valid)
|
||||
if (!props.reg_1) {
|
||||
return;
|
||||
}
|
||||
SessionUser.request(
|
||||
'/department/vehicle/customer-suggestions',
|
||||
'GET',
|
||||
{
|
||||
reg_1: props.reg_1,
|
||||
},
|
||||
).then(response => {
|
||||
// Assuming the response contains an array of customer suggestions
|
||||
console.log('Customer suggestions:', response.data.data);
|
||||
let suggestions = [];
|
||||
for (let i = 0; i < response.data.data.length; i++) {
|
||||
suggestions.push(getCustomerSuggestionObject(
|
||||
response.data.data[i].id,
|
||||
response.data.data[i].customer_number,
|
||||
response.data.data[i].customer_name,
|
||||
));
|
||||
}
|
||||
customer_suggestions.value = suggestions;
|
||||
}).catch(error => {
|
||||
console.error('Error fetching customer suggestions:', error);
|
||||
});
|
||||
}
|
||||
const isSettingCustomer = ref(false);
|
||||
const isSettingCustomerToInteger = ref(0);
|
||||
const isSettingCustomerStartTime = ref(null);
|
||||
const isSettingCustomerTo = (customer_number) => {
|
||||
return isSettingCustomerToInteger.value === parseInt(customer_number);
|
||||
}
|
||||
|
||||
const onClick = (customer) => {
|
||||
isSettingCustomer.value = true;
|
||||
isSettingCustomerToInteger.value = parseInt(customer.customer_number);
|
||||
isSettingCustomerStartTime.value = new Date();
|
||||
emit('customerSelected', parseInt(customer.customer_number));
|
||||
}
|
||||
|
||||
watch(() => props.reg_1, (newValue) => {
|
||||
resetIsSettingCustomer();
|
||||
if (newValue) {
|
||||
getCustomerSuggestions();
|
||||
} else {
|
||||
customer_suggestions.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => customer_id.value, (newValue) => {
|
||||
resetIsSettingCustomer();
|
||||
});
|
||||
|
||||
const resetIsSettingCustomer = () => {
|
||||
isSettingCustomer.value = false;
|
||||
isSettingCustomerToInteger.value = 0;
|
||||
isSettingCustomerStartTime.value = null;
|
||||
}
|
||||
|
||||
// Watch for the isSettingCustomer to reset after 5 seconds
|
||||
watch(() => isSettingCustomer.value, (newValue) => {
|
||||
if (newValue) {
|
||||
setTimeout(() => {
|
||||
isSettingCustomer.value = false;
|
||||
isSettingCustomerToInteger.value = 0;
|
||||
isSettingCustomerStartTime.value = null;
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// If the reg_1 is set when the component is mounted, fetch the customer suggestions
|
||||
if (props.reg_1) {
|
||||
getCustomerSuggestions();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field" :class="{'is-hidden': isCustomerSelected()}">
|
||||
<div class="control">
|
||||
<template v-if="customer_suggestions.length > 0">
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<!-- Divider -->
|
||||
<div class="column is-12 pb-0">
|
||||
<div class="divider">{{SessionUser.objects.global.language.previous}} {{SessionUser.objects.global.language.customers}}</div>
|
||||
</div>
|
||||
<!-- Loop through the customer suggestions and display them -->
|
||||
<template v-for="(customer, index) in customer_suggestions" :key="index">
|
||||
<div class="column is-6">
|
||||
<div class="card">
|
||||
<!-- Name of the customer -->
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<p class="title is-5">{{ customer.customer_name }}</p>
|
||||
<p class="subtitle is-6">{{ customer.customer_id }}</p>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button class="button is-light is-fullwidth" @click="onClick(customer)" :class="{'is-loading': isSettingCustomerTo(customer.customer_number)}" :disabled="isSettingCustomer">
|
||||
{{SessionUser.objects.global.language.select}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -18,14 +18,19 @@ export const ObjectsGlobal = {
|
||||
},
|
||||
split: "Opdel",
|
||||
save: "Gem",
|
||||
customers: "Kunder",
|
||||
invalid: "Ugyldig",
|
||||
last_wash: "Sidste vask",
|
||||
have_not: "Har ikke",
|
||||
clear: "Ryd",
|
||||
have: "Har",
|
||||
hide: "Skjul",
|
||||
cancel: "Annuller",
|
||||
previous: "Tidligere",
|
||||
unselect: "Fravælg",
|
||||
select: "Vælg",
|
||||
month: "Måned",
|
||||
all: "Alle",
|
||||
copy: "Kopier",
|
||||
yes: "Ja",
|
||||
no: "Nej",
|
||||
|
||||
@@ -452,6 +452,9 @@ export const selectScan = (scan) => {
|
||||
|
||||
/** Get the customer's name */
|
||||
export const getCustomerName = async (customerNumber) => {
|
||||
if (!customerNumber) {
|
||||
return '';
|
||||
}
|
||||
// Get the customer data
|
||||
await authenticatedRequest(
|
||||
`/users/customer?customer_number=${customerNumber}`,
|
||||
|
||||
+5
-4
@@ -2,16 +2,17 @@ import '@/themes/Dark.sass';
|
||||
import '@popperjs/core';
|
||||
//export const API_URL = 'https://truckwashdev.maintenancemode.cloud';
|
||||
//export const API_URL = 'https://nnks.truckwash.dk';
|
||||
export const IS_DEV = false;
|
||||
//export const API_URL = 'https://api.truckwash.dk:4433';
|
||||
export const POS_STEP_1_VERSION = 1;
|
||||
export const API_URL = 'https://api.truckwash.dk';
|
||||
export const IS_DEV = true;
|
||||
export const API_URL = 'https://api.truckwash.dk:4433';
|
||||
export const POS_STEP_1_VERSION = 2;
|
||||
//export const API_URL = 'https://api.truckwash.dk';
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import store from './store/user.vue'
|
||||
import {router} from "@/router.js";
|
||||
import {Colors} from "./ThemeConfig.vue";
|
||||
import '@/assets/main.css';
|
||||
import 'animate.css';
|
||||
import applyMiddleware from '@/middleware/index.js';
|
||||
|
||||
applyMiddleware(router);
|
||||
|
||||
Reference in New Issue
Block a user