Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 69 additions & 5 deletions POS/src/components/sale/EditItemDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,35 @@
</svg>
</div>
<!-- Item Info -->
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-gray-900 truncate">
{{ localItem.item_name }}
</h3>
<div class="flex flex-col flex-1 min-w-0 gap-2">
<!-- Item Name Editor -->
<div>
<div class="flex gap-2 items-center" v-if="isItemNameEditable">
<input
v-model="localItemName"
type="text"
class="w-full h-10 border border-gray-300 rounded-lg px-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
:placeholder="__('Enter item name')"
@blur="handleItemNameBlur"
/>
<Button
variant="ghost"
:title="__('Reset')"
:aria-label="__('Reset')"
@click="restoreOriginalItemName"
>
<svg fill="none" class="h-4 w-4" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</Button>
</div>
<h3 v-else class="text-base font-semibold text-gray-900 truncate">
{{ localItemName }}
</h3>
</div>
<p class="text-sm text-gray-500 truncate">
{{ localItem.item_code }}
</p>
<p class="text-sm text-gray-500 truncate">
{{ formatCurrency(localItem.price_list_rate || localItem.rate) }} / {{ localItem.stock_uom || __('Nos', null, 'UOM') }}
</p>
Expand Down Expand Up @@ -209,7 +234,7 @@
<Button
variant="solid"
@click="updateItem"
:disabled="!hasStock || isCheckingStock"
:disabled="!hasStock || isItemNameEmpty || isCheckingStock"
>
<span v-if="isCheckingStock">{{ __('Checking Stock...') }}</span>
<span v-else-if="!hasStock">{{ __('No Stock Available') }}</span>
Expand All @@ -224,6 +249,7 @@
import { useToast } from "@/composables/useToast"
import { usePOSSettingsStore } from "@/stores/posSettings"
import { useSerialNumberStore } from "@/stores/serialNumber"
import { useItemSearchStore } from "../../stores/itemSearch"
import { getItemStock } from "@/utils/stockValidator"
import { formatCurrency as formatCurrencyUtil, getCurrencySymbol } from "@/utils/currency"
import { Button, Dialog } from "frappe-ui"
Expand All @@ -233,6 +259,7 @@ import SelectInput from "@/components/common/SelectInput.vue"
const { showSuccess, showError, showWarning } = useToast()
const settingsStore = usePOSSettingsStore()
const serialStore = useSerialNumberStore()
const { getItem } = useItemSearchStore()

const props = defineProps({
modelValue: Boolean,
Expand All @@ -251,6 +278,11 @@ const emit = defineEmits(["update:modelValue", "update-item"])

// Local state
const localItem = ref(null)

// Store item name states for item name customization
const localItemName = ref("")
const originalItemName = ref("")

const localQuantity = ref(1)
const localUom = ref("")
const localRate = ref(0)
Expand Down Expand Up @@ -313,6 +345,14 @@ watch(
(newItem) => {
if (newItem) {
localItem.value = { ...newItem }

localItemName.value = newItem.item_name || ""

// Track original name from cache to restore when appropriate
originalItemName.value = newItem.item_name || "" // Initial value as fallback
getItem(localItem.value.item_code)
.then((item) => originalItemName.value = item.item_name)

localQuantity.value = newItem.quantity || 1
localUom.value = newItem.uom || newItem.stock_uom || __("Nos")
localRate.value = newItem.rate || 0
Expand Down Expand Up @@ -355,6 +395,26 @@ watch(
{ immediate: true },
)

const isItemNameEditable = computed(() => {
const allowedForPOS = settingsStore.allowCustomItemNameInCart
const allowedForItem = localItem.value.custom_allow_custom_item_name_in_cart
return allowedForPOS && allowedForItem
})

const isItemNameEmpty = computed(() => !(localItemName.value.trim()))

function restoreOriginalItemName() {
localItemName.value = originalItemName.value
}

function handleItemNameBlur() {
if (isItemNameEmpty.value) {
restoreOriginalItemName()
} else {
localItemName.value = localItemName.value.trim()
}
}

/**
* Intelligently determine the step size based on current quantity
* - Whole numbers (1, 2, 3): step by 1
Expand Down Expand Up @@ -533,6 +593,10 @@ function updateItem() {
discountType.value === "amount" ? discountValue.value : 0,
}

if (isItemNameEditable.value) {
updatedItem.item_name = localItemName.value || originalItemName.value
}

// Update serial numbers if item has serials
if (localItem.value.has_serial_no) {
updatedItem.serial_no = localSerials.value.join('\n')
Expand Down
1 change: 1 addition & 0 deletions POS/src/composables/useInvoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export function useInvoice() {
// Add item_group and brand for offer eligibility checking
item_group: item.item_group,
brand: item.brand,
custom_allow_custom_item_name_in_cart: item.custom_allow_custom_item_name_in_cart
}
invoiceItems.value.push(newItem)
// Recalculate the newly added item to apply taxes
Expand Down
1 change: 1 addition & 0 deletions POS/src/stores/posCart.js
Original file line number Diff line number Diff line change
Expand Up @@ -1337,6 +1337,7 @@ export const usePOSCartStore = defineStore("posCart", () => {
}

// Apply other updates
if (settingsStore.allowCustomItemNameInCart && updates.item_name !== undefined) cartItem.item_name = updates.item_name
if (updates.quantity !== undefined) cartItem.quantity = updates.quantity
if (updates.warehouse !== undefined) cartItem.warehouse = updates.warehouse
if (updates.discount_percentage !== undefined) cartItem.discount_percentage = updates.discount_percentage
Expand Down
11 changes: 11 additions & 0 deletions POS/src/stores/posSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const usePOSSettingsStore = defineStore("posSettings", () => {
allow_negative_stock: 0,
// Sales Persons
enable_sales_persons: "Disabled",
// Item Name Customization
allow_custom_item_name_in_cart: 0
})

const isLoading = ref(false)
Expand Down Expand Up @@ -216,6 +218,11 @@ export const usePOSSettingsStore = defineStore("posSettings", () => {
settings.value.enable_sales_persons === "Multiple"
)

// Computed - Item Name Customization
const allowCustomItemNameInCart = computed(() =>
Boolean(settings.value.allow_custom_item_name_in_cart)
)

// Resource
const settingsResource = createResource({
url: "pos_next.pos_next.doctype.pos_settings.pos_settings.get_pos_settings",
Expand Down Expand Up @@ -319,6 +326,7 @@ export const usePOSSettingsStore = defineStore("posSettings", () => {
input_qty: 0,
allow_negative_stock: 0,
enable_sales_persons: "Disabled",
allow_custom_item_name_in_cart: 0
}
isLoaded.value = false
}
Expand Down Expand Up @@ -448,6 +456,9 @@ export const usePOSSettingsStore = defineStore("posSettings", () => {
isSingleSalesPerson,
isMultipleSalesPersons,

// Computed - Item Name Customization
allowCustomItemNameInCart,

// Actions
loadSettings,
reloadSettings,
Expand Down
10 changes: 8 additions & 2 deletions pos_next/api/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import frappe
from frappe import _

from pos_next.pos_next.doctype.pos_settings.pos_settings import is_item_name_customization_enabled_for_user


@frappe.whitelist()
def get_initial_data():
Expand Down Expand Up @@ -154,13 +156,16 @@ def get_pos_settings(pos_profile):
"silent_print",
"allow_sales_order",
"allow_select_sales_order",
"create_only_sales_order"
"create_only_sales_order",
"allow_custom_item_name_in_cart"
],
as_dict=True
)

if not pos_settings:
return get_default_pos_settings()

pos_settings["allow_custom_item_name_in_cart"] = is_item_name_customization_enabled_for_user(pos_settings)

return pos_settings
except Exception:
Expand Down Expand Up @@ -188,7 +193,8 @@ def get_default_pos_settings():
"silent_print": 0,
"allow_sales_order": 0,
"allow_select_sales_order": 0,
"create_only_sales_order": 0
"create_only_sales_order": 0,
"allow_custom_item_name_in_cart": 0
}


Expand Down
57 changes: 57 additions & 0 deletions pos_next/api/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ def update_invoice(data):
# Ensure the document type is set
data.setdefault("doctype", doctype)

# Validate item name customizations in invoice data
_validate_invoice_item_names(data)

# Create or update invoice
if data.get("name"):
invoice_doc = frappe.get_doc(doctype, data.get("name"))
Expand Down Expand Up @@ -528,6 +531,60 @@ def update_invoice(data):
raise


def _validate_invoice_item_names(data):
"""
Validates that custom item names are provided only if the feature is enabled for both the POS and all relevant items.
Raises an error if any item contains a custom name where it is not allowed.

:param data: The request data
:rtype: None
"""

item_data = data.get("items")
pos_profile = data.get("pos_profile")

if not pos_profile: # Assume feature is disabled
is_enabled = False
else:
is_enabled = frappe.db.get_value(
"POS Settings",
{ "pos_profile": pos_profile },
"allow_custom_item_name_in_cart"
)

item_codes = [x["item_code"] for x in item_data]

# Fetch items from database to compare names
db_items = {
item["item_code"]: item
for item in frappe.db.get_list(
"Item",
fields=["item_code", "item_name", "custom_allow_custom_item_name_in_cart"],
filters={"item_code": ["in", item_codes]}
)
}

for item in item_data:
db_item = db_items[item["item_code"]]

# If item name is not present, set the one from database
if not item["item_name"]:
item["item_name"] = db_item["item_name"]
continue

# Invoice item that matches database item name is always valid
if item["item_name"] == db_item["item_name"]:
continue

# Check if feature is enabled for both POS and item
if not is_enabled or not db_item["custom_allow_custom_item_name_in_cart"]:
frappe.throw(_("Not allowed to set custom name {0} for item {1} (Item Code: {2}).").format(
item["item_name"],
db_item["item_name"],
item["item_code"]
))


PENDING_TIMEOUT_MINUTES = 5 # Pending records older than this are considered stale


Expand Down
3 changes: 3 additions & 0 deletions pos_next/api/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"has_variants",
"custom_company",
"disabled",
"custom_allow_custom_item_name_in_cart"
]

ITEM_RESULT_COLUMNS = ",\n\t".join(ITEM_RESULT_FIELDS)
Expand Down Expand Up @@ -493,6 +494,7 @@ def get_item_variants(template_item, pos_profile):
"item_group",
"brand",
"custom_company",
"custom_allow_custom_item_name_in_cart"
],
)

Expand Down Expand Up @@ -1033,6 +1035,7 @@ def get_items(pos_profile, search_term=None, item_group=None, start=0, limit=20)
"has_variants",
"custom_company",
"disabled",
"custom_allow_custom_item_name_in_cart"
],
start=start,
page_length=limit,
Expand Down
9 changes: 7 additions & 2 deletions pos_next/api/pos_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pos_next.api.utilities import check_user_company
from pos_next.api.utilities import _parse_list_parameter

from pos_next.pos_next.doctype.pos_settings.pos_settings import is_item_name_customization_enabled_for_user

@frappe.whitelist()
def get_pos_profiles():
Expand Down Expand Up @@ -88,7 +89,8 @@ def get_pos_settings(pos_profile):
"enable_sales_persons",
"allow_sales_order",
"allow_select_sales_order",
"create_only_sales_order"
"create_only_sales_order",
"allow_custom_item_name_in_cart"
],
as_dict=True
)
Expand All @@ -111,8 +113,11 @@ def get_pos_settings(pos_profile):
"enable_sales_persons": "Disabled",
"allow_sales_order": 0,
"allow_select_sales_order": 0,
"create_only_sales_order": 0
"create_only_sales_order": 0,
"allow_custom_item_name_in_cart": 0
}

pos_settings["allow_custom_item_name_in_cart"] = is_item_name_customization_enabled_for_user(pos_settings)

return pos_settings
except Exception as e:
Expand Down
Loading