Add product note handling and enhance additional item management in POS Mobile components

- Added `PosDepartmentStepMobilePopupAddProductNote.vue` for managing product notes via popups.
- Updated `PosProduct` to include optional `notes` field in product definition.
- Modified additional item logic in `PosDepartmentStepMobileFlow.vue` to handle product notes and quantities.
- Enhanced `PosDepartmentStepMobile2AdditionalItems.vue` with dynamic toggles, improved layout, and localized labels.
- Updated translations and button labels for additional item actions.
- Adjusted `WhiteBoxCard.vue` to support `forceState` prop for toggle control.
This commit is contained in:
Jeppe Bundgaard
2025-09-23 13:23:15 +02:00
parent 88472dd37c
commit 61050d7ec2
10 changed files with 283 additions and 38 deletions
+12 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineProps, defineEmits, onMounted, ref } from 'vue';
import {defineProps, defineEmits, onMounted, ref, watch} from 'vue';
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
@@ -19,6 +19,10 @@ const props = defineProps({
hideHeader: {
type: Boolean,
default: false
},
forceState: {
type: Boolean,
default: null
}
});
/**
@@ -42,6 +46,13 @@ const toggle = () => {
isOpen.value = !isOpen.value;
emit('toggle', isOpen.value);
};
/** Watch for changes in forceState prop to control isOpen */
watch(() => props.forceState, (newVal) => {
if (newVal !== null) {
isOpen.value = newVal;
}
});
</script>
<template>
@@ -182,7 +182,7 @@ const onBeforeComplete = () => {
product_id: primaryItem.value.id, // The product ID of the primary item
quantity: 1,
price: primaryItem.value.price,
notes: '', // Notes can be added here if needed
notes: primaryItem?.notes || '', // Notes can be added here if needed
related_item_id: null,
}, // The order item to create
{authenticated: true}
@@ -199,12 +199,31 @@ const onBeforeComplete = () => {
product_id: addon.product.id, // The product ID of the addon
quantity: addon.quantity,
price: addon.product.price,
notes: '', // Notes can be added here if needed
notes: addon.product?.notes || '', // Notes can be added here if needed
related_item_id: relatedItemId, // Link the addon to the primary item
},
{authenticated: true}
);
});
// Create the additional items (With a quantity of > 0)
const additionalItems = transactionItems.additionalItems.value.filter(item => item.quantity > 0);
const additionalPromises = additionalItems.map(item => {
// For each additional item, create an order item
return SessionUser.objects.global.add.object(
'/order/items',
{
order_id: parseInt(order_id.value), // The order ID to which the additional item will be added
product_id: item.id, // The product ID of the additional item
quantity: item.quantity,
price: item.price,
notes: item?.notes || '', // Notes can be added here if needed
related_item_id: null, // Additional items are not linked to the primary item
},
{authenticated: true}
);
});
// Combine addon and additional item promises
addonPromises.push(...additionalPromises);
// Wait for all addon items to be created
return Promise.all(addonPromises);
}).then(() => {
@@ -316,7 +335,7 @@ watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false"/>
</template>
<template v-else-if="additionalItemSelection">
<PosDepartmentStepMobile2AdditionalItems/>
<PosDepartmentStepMobile2AdditionalItems :label="SessionUser.objects.global.language.additional_items" :subtitle="''" @close="additionalItemSelection = false"/>
</template>
<template v-else>
<div class="is-flex is-flex-direction-column is-justify-content-space-between is-gap-2">
@@ -331,7 +350,7 @@ watch(() => vehicles.vehicle_1.value.reference, (newValue, oldValue) => {
:product="transactionItems.primaryItem.value"
/>
<!-- Additional items -->
<!--<PosDepartmentStepMobile2AdditionalItems/>-->
<PosDepartmentStepMobile2AdditionalItems :label="SessionUser.objects.global.language.additional_items" :subtitle="''"/>
<!-- Last order details -->
<PosDepartmentStepMobile2LastOrder
v-show="lastOrders.get(1)"
@@ -19,6 +19,7 @@ import {
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStep2MobileVehicleSelection
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
const props = defineProps({
label: {
@@ -69,6 +70,10 @@ const displaySubtitle = computed(() => {
// Emit event on toggle
function onToggle(isOpen: boolean) {
checked.value = isOpen;
// Open the additional item selection view if toggled open
if (isOpen) {
pos.views.additionalItemSelection.value = true;
}
}
/** Additional item management logic */
@@ -89,7 +94,7 @@ const exampleProducts = ref<PosProduct[]>([
}
]);
const convertProductToAddon = (product: PosProduct, options: {quantity?: number, min?: number, max?: number} = {}): Addon => {
console.warn("Converting product to addon:", product, options);
//console.warn("Converting product to addon:", product, options);
return {
id: product.id,
name: product.name,
@@ -129,6 +134,7 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
if (!newVal) return;
if (newVal.length === 0) {
availableAdditionalItems.value = [];
//
return;
}
// Prevent recursive loop by checking if availableAdditionalItems already matches newVal
@@ -138,61 +144,89 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
if (isSame) return;
availableAdditionalItems.value = getAvailableAdditionalItems();
}, { deep: true });
const onClickAddProduct = async (product: PosProduct) => {
// If the product requires note, open note input.
pos.transactionItems.addAdditionalItem(product);
// If the view is fullscreen, close it after adding
if (pos.views.additionalItemSelection.value) {
pos.views.additionalItemSelection.value = false;
}
}
</script>
<template>
<div>
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard :toggleable="true" @toggle="onToggle" v-if="!pos.views.additionalItemSelection.value">
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@toggle="onToggle"
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)"
v-if="!pos.views.additionalItemSelection.value">
<!-- Header -->
<template #header>
<div class="card-header-title">{{ displayLabel }}</div>
<div class="card-header-icon">
<!-- Toggle Switch -->
<label class="switch">
<input type="checkbox" v-model="checked" />
<span class="slider round"></span>
</label>
<!-- Right arrow, if there's no items, down arrow if there are items -->
<span class="icon">
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
<i v-else class="fas fa-angle-down"></i>
</span>
</div>
</template>
<!-- Content -->
<template #content>
<!-- Suggested items -->
<div>
<!-- No items added yet -->
<div v-if="!availableAdditionalItems || availableAdditionalItems.length === 0">
<p>{{ SessionUser.objects.global.language.no_additional_items }}</p>
</div>
<PosDepartmentStepMobile2Addons :label="''" :addons="availableAdditionalItems" />
</div>
</template>
<!-- Footer -->
<template #footer>
<!-- Select other product button -->
<a class="card-footer-item" @click="onClickAddOtherProduct">Vælg andre produkter</a>
<a class="card-footer-item" @click="onClickAddOtherProduct">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>{{ SessionUser.objects.global.language.add_other_product }}</span>
</a>
</template>
</WhiteBoxCard>
<!-- Fullscreen view, when selecting other products -->
<template v-else>
<!-- Shortcuts / recommendations -->
<WhiteBoxCard :toggleable="true">
<!-- Header -->
<!--<WhiteBoxCard :toggleable="true" v-show="false">
Header
<template #header>
<div class="card-header-title">{{ SessionUser.objects.global.language.recommended }}</div>
<div class="card-header-icon">
<span class="icon">
<!-- Arrow right icon -->
Arrow right icon
<i class="fas fa-arrow-right"></i>
</span>
</div>
</template>
</WhiteBoxCard>
</WhiteBoxCard> -->
<!-- Categories of products -->
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="pos.transactionItems.addAdditionalItem" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
<!--{{ availableAdditionalItems }}-->
<!-- Products in category -->
<!-- Back button -->
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl>
<!-- Complete button -->
<PosDepartmentStepMobileButtonNextStep :customAction="onClickAddOtherProduct"/>
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep class="has-background-black" :customAction="onClickAddOtherProduct">
<span class="is-float-left has-text-white">{{ SessionUser.objects.global.language.next }}</span>
<span class="is-float-right has-text-white">
<!-- Arrow right icon -->
<i class="fas fa-arrow-right"></i>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</template>
</div>
@@ -184,6 +184,7 @@ const finalizeStep1 = () => {
const step2 = () => {
/** This function can be used to perform any specific actions for step 2 */
// Add all additional items to the order
popups.select('completed_transaction', {
message: `Order #${order_id.value} successfully created.`,
});
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, defineEmits, watch, ref } from "vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
const emit = defineEmits<{
(e: 'close'): void;
}>();
const props = pos.popups.get()?.props;
const product = props.product;
const note = ref(product.notes || "");
watch(note, (newNote) => {
product.notes = newNote;
// If the note is empty, remove it from the product
if (newNote === "") {
delete product.notes;
}
});
</script>
<template>
<div>
<!-- Input field with underline -->
<div class="field underlined-input-field">
<div class="control has-icons-right">
<input
class="input is-searched"
v-model="note"
type="text"
placeholder="Indtast note"
/>
</div>
</div>
</div>
</template>
<style scoped>
input {
/* input-fields */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding-top: 12px;
padding-bottom: 0px;
border-bottom: none;
padding-left: 4px;
padding-right: 4px;
gap: 10px;
width: 100%;
height: 42px;
background: #FFFFFF;
border-color: transparent;
border-radius: 4px;
/* Inside auto layout */
flex: none;
order: 1;
align-self: stretch;
flex-grow: 0;
}
input:focus-visible {
border-color: transparent;
outline: none;
}
.underlined-input-field {
padding-bottom: 0;
border-bottom: #929292 1px solid;
}
.debug-input {
/* input-fields */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0px 8px;
gap: 10px;
width: 100%;
height: 42px;
background: #FFFFFF;
border: 1px solid #A6A5A5;
border-radius: 4px;
/* Inside auto layout */
flex: none;
order: 1;
align-self: stretch;
flex-grow: 0;
}
input.is-searched {
/* input-fields */
box-sizing: border-box;
/* Auto layout */
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 12px 8px;
gap: 10px;
width: 100%;
height: 42px;
background: #FFFFFF;
border: 1px solid #000000;
border-radius: 4px;
border-bottom: none;
/* Inside auto layout */
flex: none;
order: 1;
align-self: stretch;
flex-grow: 0;
}
</style>
@@ -114,16 +114,16 @@ const locations = {
const defaultActionButtons = ref<{ [key: string]: PosActionButton }>({
/** General purpose action buttons (used by PosAddons)*/
cancel: {
label: 'Cancel',
description: 'Close this popup',
label: 'Annuller',
description: 'Luk denne popup uden at gemme ændringer',
onClick: () => {
clearPopup(); // Close the popup
},
color: 'light'
},
close: {
label: 'Close',
description: 'Close this popup',
label: 'Luk',
description: 'Luk denne popup',
onClick: () => {
clearPopup(); // Close the popup
},
@@ -131,8 +131,8 @@ const defaultActionButtons = ref<{ [key: string]: PosActionButton }>({
},
/** Customer selection action buttons */
selectCustomer: {
label: 'Select Customer',
description: 'Select a customer for this transaction',
label: 'Vælg kunde',
description: 'Vælg en kunde til denne transaktion',
onClick: () => {
console.warn('Select Customer button clicked');
// Open the select customer popup
@@ -141,8 +141,8 @@ const defaultActionButtons = ref<{ [key: string]: PosActionButton }>({
color: 'primary'
},
editReference: {
label: 'Edit Reference',
description: 'Edit the reference for this transaction',
label: 'Redigér reference',
description: 'Redigér reference for denne transaktion',
onClick: () => {
console.warn('Edit Reference button clicked');
// Open the edit reference popup
@@ -229,7 +229,16 @@ const addDefaultPopups = () => {
component: 'error',
hideHeader: true,
actionButtons: [{...defaultActionButtons.value.close}],
})
});
// Add product note
addPopup({
id: 'add_product_note',
title: 'Add Product Note',
message: 'Please enter notes for the product.',
component: 'add_product_note', // This component should handle input and return the notes
hideHeader: true,
actionButtons: [{...defaultActionButtons.value.cancel}],
});
}
// Function to check if a popup is defined in the list
const isPopupDefined = (id: string): boolean => {
@@ -377,6 +386,14 @@ const additionalItems = ref<PosProduct[]>([]);
// Function to add another item to the transaction
const addAdditionalItem = (item: PosProduct) => {
// If the additional item requires a note, prompt for it before adding
if (item.requires_note) {
promptForNotesIfRequired(item, (notes: string) => {
item.notes = notes;
additionalItems.value.push(item);
});
return;
}
additionalItems.value.push(item);
};
// Function to remove an additional item from the transaction
@@ -398,7 +415,7 @@ const setAdditionalItems = (items: PosProduct[]) => {
const getAdditionalItemsTotal = () => {
let total = 0;
additionalItems.value.forEach(item => {
total += item.price;
total += item.price * (item.quantity || 1);
// Add the addons prices if they exist
if (item.addons && item.addons.length > 0) {
// Make sure that there is a quantity for each addon, if not, assume 0 quantity
@@ -496,16 +513,45 @@ const updateTransactionProduct = (updatedProduct: PosProduct) => {
const updateTransactionPrices = (updatedProducts: PosProduct[] | null = null) => {
// If no updated products are provided, take the current product list
if (!updatedProducts) {
console.warn('No updated products provided, using current product list.');
//console.warn('No updated products provided, using current product list.');
updatedProducts = productList.list.value;
}
// Update prices for each product in the transaction
updatedProducts.forEach(product => {
console.warn(`Updating transaction prices for product with id: ${product.id}`);
//console.warn(`Updating transaction prices for product with id: ${product.id}`);
updateTransactionProduct(product);
});
}
// Function to prompt for notes if required (input is a PosProduct)
const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string) => void) => {
if (product.requires_note) {
// Open a popup to prompt for notes
popups.select('add_product_note', {
title: 'Tilføj note',
message: `Tilføj venligst en note for produktet: ${product.name}`,
component: 'add_product_note', // This component should handle input and return the notes
style: {maxHeight: '40vh'},
props: { product },
actionButtons: [
{
label: 'Bekræft',
description: 'Bekræft noten og fortsæt',
onClick: () => {
callback(popups.get()?.props?.product?.notes || '');
clearPopup();
},
color: 'primary'
},
{...defaultActionButtons.value.cancel}
],
});
} else {
// If no notes are required, call the callback with an empty string
callback('');
}
}
const transactionItems = {
// Additional items are separate from the primary item
additionalItems,
@@ -1304,9 +1350,6 @@ watch(
},
{deep: true}
);
// Define component with exports
export default defineComponent({
name: 'PosDepartmentStepMobileFlow',
setup() {
@@ -10,14 +10,17 @@ import PosDepartmentStepMobilePopupCompleteBooking
import PosDepartmentStepMobilePopupError
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupError.vue";
import type { PosActionButton } from './PosActionButton.vue';
import PosDepartmentStepMobilePopupAddProductNote
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupAddProductNote.vue";
export type PopupComponentKey = 'select_customer' | 'completed_transaction' | 'complete_booking' | 'error';
export type PopupComponentKey = 'select_customer' | 'completed_transaction' | 'complete_booking' | 'error' | 'add_product_note';
export const PopupComponents = {
select_customer: PosDepartmentStepMobilePopupSelectCustomer,
completed_transaction: PosDepartmentStepMobilePopupCompletedTransaction,
complete_booking: PosDepartmentStepMobilePopupCompleteBooking,
error: PosDepartmentStepMobilePopupError,
add_product_note: PosDepartmentStepMobilePopupAddProductNote,
} as const;
export const popupComponentKeyToComponent = (key: PopupComponentKey) => PopupComponents[key];
@@ -44,6 +47,7 @@ export default defineComponent({
PosDepartmentStepMobilePopupCompletedTransaction,
PosDepartmentStepMobilePopupCompleteBooking,
PosDepartmentStepMobilePopupError,
PosDepartmentStepMobilePopupAddProductNote,
},
props: {
popup: {
@@ -18,5 +18,6 @@ export type PosProduct = {
// Addons
addons?: Addon[]; // Optional array of addons
quantity?: number;
notes?: string; // Optional notes for the product, used as a part of order item generation
};
</script>
@@ -74,10 +74,10 @@ const onSearchClick = () => {
<!--<PosDepartmentStepMobile2ProductRecommendations/>-->
<!-- Products -->
<PosDepartmentStepMobile2Products @addProduct="onAddProduct" :asAddons="props.asAddons" :addons="props.addons"/>
<!-- Next button -->
<!-- Next button
<GenericButton class="p-5" @click="emit('close')">
Next
</GenericButton>
</GenericButton> -->
</div>
</template>
@@ -24,7 +24,11 @@ export const ObjectsGlobal = {
generated: "Genereret",
requires_action: "Kræver handling",
expand: "Udfold",
add_other_product: "Tilføj andet produkt",
no_additional_items: "Ingen yderligere varer",
additional_items: "Yderligere varer",
showing_of_separator: "af",
next: "Næste",
send_as_is: "Send som den er",
ignore_customer_arrangements: "Ignorer kundeaftaler",
recommended: "Anbefalet",