diff --git a/POS/src/components/sale/InvoiceCart.vue b/POS/src/components/sale/InvoiceCart.vue index aeefa004d..704e4c6e1 100644 --- a/POS/src/components/sale/InvoiceCart.vue +++ b/POS/src/components/sale/InvoiceCart.vue @@ -265,6 +265,7 @@ + + + @@ -367,98 +382,137 @@
- +
- - {{ __("Frequent Customers") }} + + {{ customerDropdownLabel }} + + + + {{ customerResults.length }} + +
- -
-
+ +
+ + + +

{{ __('No results for "{0}"', [customerSearch]) }} -

+

@@ -1460,6 +1514,11 @@ import { useCustomerSearchStore } from "@/stores/customerSearch"; import { DEFAULT_CURRENCY, formatCurrency as formatCurrencyUtil } from "@/utils/currency"; import { useFormatters } from "@/composables/useFormatters"; import { useCartSort } from "@/composables/useCartSort"; +import { + matchSegments, + normalizeSearchText, + tokenizeQuery, +} from "@/utils/searchText"; import { isOffline } from "@/utils/offline"; import { offlineWorker } from "@/utils/offline/workerClient"; import { logger } from "@/utils/logger"; @@ -1589,6 +1648,8 @@ const { const customerSearch = ref(""); // Current search query const customerSearchContainer = ref(null); // Ref to search container for click-outside detection const customerSearchFocused = ref(false); // Track if search input is focused +const customerSearchInputRef = ref(null); // Ref to the native search input element +const customerResultsListRef = ref(null); // Ref to the scrollable results list (keyboard nav scroll) // Use Pinia store for allCustomers (shared with CustomerDialog, synced on customer creation) const allCustomers = computed(() => customerSearchStore.allCustomers); const customersLoaded = computed(() => customerSearchStore.allCustomers.length > 0); @@ -1697,60 +1758,140 @@ const customerMap = computed(() => { return map; }); +/** + * Normalized query tokens for the customer search (Arabic-insensitive). + * Search activates from the first typed character. + */ +const searchTokens = computed(() => tokenizeQuery(customerSearch.value)) + +/** + * Customers with pre-normalized search fields, rebuilt only when the + * customer list changes — keeps per-keystroke filtering cheap. + */ +const normalizedCustomers = computed(() => + allCustomers.value.map((cust) => ({ + cust, + name: normalizeSearchText(cust.customer_name), + mobile: normalizeSearchText(cust.mobile_no), + id: normalizeSearchText(cust.name), + email: normalizeSearchText(cust.email_id), + })), +) + /** * Instant customer search results with in-memory filtering. * - * Performs zero-latency filtering on the cached customer list. - * Searches across customer_name, mobile_no, and customer ID. - * Returns max 20 results to keep dropdown performant. + * Zero-latency, Arabic-normalization-aware (hamza/teh-marbuta/diacritics + * insensitive, Arabic-Indic digits accepted) multi-word matching across + * customer_name, mobile_no, customer ID and email. Results are ranked: + * name-prefix > word-start > name-substring > other-field match. * - * @returns {Array} Filtered customer objects matching search query + * @returns {Array} Ranked customer objects matching the search query */ const customerResults = computed(() => { - const searchValue = customerSearch.value.trim().toLowerCase(); + const tokens = searchTokens.value + // POS Settings toggle: show the full customer list instead of capping results + const showAll = settingsStore.customerSearchShowAll - // When focused with no/short search term, show frequent customers (top 5) - if (searchValue.length < 2) { + // Focused with no query: browse list — top-5 most frequently selected + // first, then the remaining customers A-Z (locale-aware for Arabic). + if (tokens.length === 0) { if (customerSearchFocused.value) { - // Get frequent customer IDs from the store - // const frequentIds = customerSearchStore.frequentCustomers.slice(0, 5); - // if (frequentIds.length > 0) { - // const frequentCustomers = []; - // for (const id of frequentIds) { - // const cust = customerMap.value.get(id); - // if (cust) frequentCustomers.push(cust); - // } - // return frequentCustomers; - // } - // If no frequent customers, show first 5 from the list - return allCustomers.value.slice(0, 10); + const top = customerSearchStore.topCustomers + const topSet = new Set(top.map((c) => c.name)) + const rest = allCustomers.value + .filter((c) => !topSet.has(c.name)) + .sort((a, b) => + (a.customer_name || "").localeCompare(b.customer_name || ""), + ) + const ordered = [...top, ...rest] + return showAll ? ordered : ordered.slice(0, 10) } - return []; + return [] } - // Instant in-memory filter - return allCustomers.value - .filter((cust) => { - const name = (cust.customer_name || "").toLowerCase(); - const mobile = (cust.mobile_no || "").toLowerCase(); - const id = (cust.name || "").toLowerCase(); - - return ( - name.includes(searchValue) || - mobile.includes(searchValue) || - id.includes(searchValue) - ); - }) - .slice(0, 20); -}); + const scored = [] + for (const entry of normalizedCustomers.value) { + let score = 0 + let allMatch = true + for (const token of tokens) { + let tokenScore = 0 + if (entry.name.startsWith(token)) tokenScore = 4 + else if (entry.name.includes(` ${token}`)) tokenScore = 3 + else if (entry.name.includes(token)) tokenScore = 2 + else if ( + entry.mobile.includes(token) || + entry.id.includes(token) || + entry.email.includes(token) + ) + tokenScore = 1 + if (tokenScore === 0) { + allMatch = false + break + } + score += tokenScore + } + if (allMatch) scored.push({ cust: entry.cust, score }) + } + scored.sort((a, b) => b.score - a.score) + const matches = scored.map((s) => s.cust) + return showAll ? matches : matches.slice(0, 20) +}) + +/** Header label for the dropdown list. */ +const customerDropdownLabel = computed(() => { + if (searchTokens.value.length > 0) return __("Results") + return settingsStore.customerSearchShowAll + ? __("All Customers") + : __("Frequent Customers") +}) /** - * Reset keyboard selection index when search results change. - * Ensures the selection doesn't point to a non-existent result. + * Highlight the matched parts of a result field for rendering. + * @param {String} text - original display text + * @returns {Array<{text: String, hit: Boolean}>} + */ +function highlightMatch(text) { + return matchSegments(text || "", searchTokens.value) +} + +// Stable pastel avatar color per customer (hash of the customer ID). +// Only color families present in the frappe-ui Tailwind preset — emerald, +// rose, indigo etc. are NOT generated and would render invisible circles. +const AVATAR_PALETTE = [ + "bg-blue-100 text-blue-600", + "bg-green-100 text-green-600", + "bg-violet-100 text-violet-600", + "bg-amber-100 text-amber-700", + "bg-pink-100 text-pink-600", + "bg-cyan-100 text-cyan-700", + "bg-orange-100 text-orange-600", + "bg-teal-100 text-teal-700", +] +function customerAvatarClass(cust) { + const key = cust.name || "" + let hash = 0 + for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) >>> 0 + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length] +} + +/** + * When results change: preselect the first result while typing (Enter + * selects it immediately), no selection while browsing without a query. */ watch(customerResults, () => { - selectedIndex.value = -1; -}); + selectedIndex.value = + searchTokens.value.length > 0 && customerResults.value.length > 0 ? 0 : -1 +}) + +/** Keep the keyboard-selected row visible inside the scrollable list. */ +watch(selectedIndex, async () => { + if (selectedIndex.value < 0) return + await nextTick() + customerResultsListRef.value + ?.querySelector(`[data-idx="${selectedIndex.value}"]`) + ?.scrollIntoView({ block: "nearest" }) +}) /** * Total quantity of all items in cart (including free items). @@ -1858,27 +1999,51 @@ function handleSearchBlur() { * @param {KeyboardEvent} event - Keyboard event from search input */ function handleKeydown(event) { - if (customerResults.value.length === 0) return; + if (event.key === "Escape") { + customerSearch.value = "" + customerSearchFocused.value = false + // Restore a customer that was temporarily deselected to reveal the input + if (previousCustomer.value && !props.customer) { + emit("select-customer", previousCustomer.value) + previousCustomer.value = null + } + event.target?.blur?.() + return + } + + if (customerResults.value.length === 0) return if (event.key === "ArrowDown") { - event.preventDefault(); - selectedIndex.value = Math.min(selectedIndex.value + 1, customerResults.value.length - 1); + event.preventDefault() + selectedIndex.value = Math.min( + selectedIndex.value + 1, + customerResults.value.length - 1, + ) } else if (event.key === "ArrowUp") { - event.preventDefault(); - selectedIndex.value = Math.max(selectedIndex.value - 1, -1); + event.preventDefault() + selectedIndex.value = Math.max(selectedIndex.value - 1, -1) } else if (event.key === "Enter") { - event.preventDefault(); - if (selectedIndex.value >= 0 && selectedIndex.value < customerResults.value.length) { - selectCustomer(customerResults.value[selectedIndex.value]); - } else if (customerResults.value.length === 1) { - // Auto-select if only one result - selectCustomer(customerResults.value[0]); + event.preventDefault() + if ( + selectedIndex.value >= 0 && + selectedIndex.value < customerResults.value.length + ) { + selectCustomer(customerResults.value[selectedIndex.value]) + } else if (customerResults.value.length > 0) { + // No explicit selection: take the top-ranked result + selectCustomer(customerResults.value[0]) } - } else if (event.key === "Escape") { - customerSearch.value = ""; } } +/** + * Clear the search query (× button) keeping focus in the input. + */ +function clearCustomerSearch() { + customerSearch.value = "" + customerSearchInputRef.value?.focus() +} + /** * Select a customer from search results. * Emits select-customer event and resets search state. diff --git a/POS/src/components/settings/POSSettings.vue b/POS/src/components/settings/POSSettings.vue index c28c2d258..780de1d38 100644 --- a/POS/src/components/settings/POSSettings.vue +++ b/POS/src/components/settings/POSSettings.vue @@ -716,6 +716,11 @@ __('Enable partial payment for invoices') " /> + ({ + useRealtimeCustomers: () => ({ onCustomerUpdate: () => {} }), +})) +vi.mock("@/utils/offline/workerClient", () => ({ + offlineWorker: { + searchCachedCustomers: vi.fn(), + cacheCustomers: vi.fn(), + deleteCustomers: vi.fn(), + }, +})) +vi.mock("@/utils/offline", () => ({ isOffline: () => false })) +vi.mock("@/utils/apiWrapper", () => ({ call: vi.fn() })) + +import { useCustomerSearchStore } from "@/stores/customerSearch" + +const cust = (name, customer_name) => ({ name, customer_name }) + +describe("customerSearch store — topCustomers", () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it("returns customers ranked by selection count (desc)", () => { + const store = useCustomerSearchStore() + store.allCustomers = [ + cust("C-ZED", "Zed"), + cust("C-BETA", "Beta"), + cust("C-ALPHA", "Alpha"), + ] + store.trackCustomerSelection("C-ZED") + store.trackCustomerSelection("C-ZED") + store.trackCustomerSelection("C-ZED") + store.trackCustomerSelection("C-BETA") + + expect(store.topCustomers.map((c) => c.name)).toEqual(["C-ZED", "C-BETA"]) + }) + + it("breaks count ties alphabetically by customer_name", () => { + const store = useCustomerSearchStore() + store.allCustomers = [cust("C-1", "Beta"), cust("C-2", "Alpha")] + store.trackCustomerSelection("C-1") + store.trackCustomerSelection("C-2") + + expect(store.topCustomers.map((c) => c.name)).toEqual(["C-2", "C-1"]) + }) + + it("caps the list at 5 entries", () => { + const store = useCustomerSearchStore() + store.allCustomers = Array.from({ length: 8 }, (_, i) => + cust(`C-${i}`, `Name ${i}`), + ) + // Give each a descending, distinct count so order is deterministic. + for (let i = 0; i < 8; i++) { + for (let n = 0; n <= 8 - i; n++) store.trackCustomerSelection(`C-${i}`) + } + + expect(store.topCustomers).toHaveLength(5) + expect(store.topCustomers.map((c) => c.name)).toEqual([ + "C-0", + "C-1", + "C-2", + "C-3", + "C-4", + ]) + }) + + it("ignores IDs that are not present in allCustomers", () => { + const store = useCustomerSearchStore() + store.allCustomers = [cust("C-ALPHA", "Alpha")] + store.trackCustomerSelection("C-GHOST") // not in allCustomers + store.trackCustomerSelection("C-ALPHA") + + expect(store.topCustomers.map((c) => c.name)).toEqual(["C-ALPHA"]) + }) + + it("persists counts to localStorage and reloads them", () => { + const store = useCustomerSearchStore() + store.allCustomers = [cust("C-ALPHA", "Alpha")] + store.trackCustomerSelection("C-ALPHA") + store.trackCustomerSelection("C-ALPHA") + + expect(JSON.parse(localStorage.getItem("pos_customer_counts"))).toEqual({ + "C-ALPHA": 2, + }) + + // Fresh store instance loads persisted counts via loadCustomerHistory(). + setActivePinia(createPinia()) + const store2 = useCustomerSearchStore() + store2.allCustomers = [cust("C-ALPHA", "Alpha")] + store2.loadCustomerHistory() + expect(store2.customerCounts).toEqual({ "C-ALPHA": 2 }) + expect(store2.topCustomers.map((c) => c.name)).toEqual(["C-ALPHA"]) + }) +}) diff --git a/POS/src/stores/customerSearch.js b/POS/src/stores/customerSearch.js index ae65880a3..eb93f881b 100644 --- a/POS/src/stores/customerSearch.js +++ b/POS/src/stores/customerSearch.js @@ -16,6 +16,7 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { const selectedIndex = ref(-1); const recentSearches = ref([]); const frequentCustomers = ref([]); + const customerCounts = ref({}); // Performance optimization: Pre-computed search indices const searchIndex = ref(new Map()); @@ -194,6 +195,25 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { return recs; }); + // Top 5 customer objects by selection frequency, resolved against the + // current (group-filtered) allCustomers so we never surface a stale ID. + // Tie-break alphabetically for deterministic ordering. + const topCustomers = computed(() => { + const counts = customerCounts.value + const byId = new Map(allCustomers.value.map((c) => [c.name, c])) + return Object.keys(counts) + .filter((id) => counts[id] > 0 && byId.has(id)) + .sort( + (a, b) => + counts[b] - counts[a] || + (byId.get(a).customer_name || "").localeCompare( + byId.get(b).customer_name || "", + ), + ) + .slice(0, 5) + .map((id) => byId.get(id)) + }) + // Actions async function loadAllCustomers(posProfile, forceReload = false) { if (!posProfile) { @@ -363,6 +383,12 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { } frequentCustomers.value = [customerId, ...frequentCustomers.value].slice(0, 20); + // Track per-customer selection frequency (immutable update for reactivity) + customerCounts.value = { + ...customerCounts.value, + [customerId]: (customerCounts.value[customerId] || 0) + 1, + } + // Persist to localStorage try { localStorage.setItem("pos_recent_customers", JSON.stringify(recentSearches.value)); @@ -370,6 +396,10 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { "pos_frequent_customers", JSON.stringify(frequentCustomers.value) ); + localStorage.setItem( + "pos_customer_counts", + JSON.stringify(customerCounts.value) + ); } catch (e) { log.warn("Failed to persist customer history:", e); } @@ -379,9 +409,11 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { try { const recent = localStorage.getItem("pos_recent_customers"); const frequent = localStorage.getItem("pos_frequent_customers"); + const counts = localStorage.getItem("pos_customer_counts"); if (recent) recentSearches.value = JSON.parse(recent); if (frequent) frequentCustomers.value = JSON.parse(frequent); + if (counts) customerCounts.value = JSON.parse(counts); } catch (e) { log.warn("Failed to load customer history:", e); } @@ -395,10 +427,12 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { selectedIndex, recentSearches, frequentCustomers, + customerCounts, // Getters filteredCustomers, recommendations, + topCustomers, // Actions loadAllCustomers, diff --git a/POS/src/stores/posSettings.js b/POS/src/stores/posSettings.js index 49f4dcb43..b9a4b7fdd 100644 --- a/POS/src/stores/posSettings.js +++ b/POS/src/stores/posSettings.js @@ -57,6 +57,7 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { // Advanced Settings use_limit_search: 0, search_limit: 1000, + customer_search_show_all: 0, allow_submissions_in_background_job: 0, allow_delete_offline_invoice: 0, allow_change_posting_date: 0, @@ -153,6 +154,7 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { // Computed - Advanced Settings const useLimitSearch = computed(() => Boolean(settings.value.use_limit_search)); const searchLimit = computed(() => Number.parseInt(settings.value.search_limit) || 1000); + const customerSearchShowAll = computed(() => Boolean(settings.value.customer_search_show_all)); const allowSubmissionsInBackgroundJob = computed(() => Boolean(settings.value.allow_submissions_in_background_job) ); @@ -274,6 +276,7 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { auto_set_delivery_charges: 0, use_limit_search: 0, search_limit: 1000, + customer_search_show_all: 0, allow_submissions_in_background_job: 0, allow_delete_offline_invoice: 0, allow_change_posting_date: 0, @@ -401,6 +404,7 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { // Computed - Advanced Settings useLimitSearch, searchLimit, + customerSearchShowAll, allowSubmissionsInBackgroundJob, allowDeleteOfflineInvoice, allowChangePostingDate, diff --git a/POS/src/utils/searchText.js b/POS/src/utils/searchText.js new file mode 100644 index 000000000..7b1daaad5 --- /dev/null +++ b/POS/src/utils/searchText.js @@ -0,0 +1,98 @@ +/** + * Arabic-aware text normalization and match segmentation for instant search. + * + * Normalization makes search forgiving for Arabic input: + * - strips diacritics (تشكيل) + * - unifies alef variants (أ إ آ ٱ → ا), ة → ه, ى → ي, ؤ → و, ئ → ي + * - converts Arabic-Indic digits (٠-٩, ۰-۹) to Latin digits + * - lowercases Latin text + */ + +const ARABIC_DIACRITICS = + /[\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7\u06e8\u06ea-\u06ed]/g + +/** + * Normalize a string for matching. Safe on empty/nullish input. + * @param {String} text + * @returns {String} normalized text + */ +export function normalizeSearchText(text) { + if (!text) return "" + return String(text) + .toLowerCase() + .replace(ARABIC_DIACRITICS, "") + .replace(/[أإآٱ]/g, "ا") + .replace(/ة/g, "ه") + .replace(/ى/g, "ي") + .replace(/ؤ/g, "و") + .replace(/ئ/g, "ي") + .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660)) + .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)) +} + +/** + * Split a raw query into normalized tokens. + * @param {String} query + * @returns {String[]} non-empty normalized tokens + */ +export function tokenizeQuery(query) { + return normalizeSearchText(query).split(/\s+/).filter(Boolean) +} + +/** + * Normalize a string while keeping a map from each normalized character + * back to its index in the original string (diacritics are dropped, so the + * mapping is not 1:1). + */ +function normalizeWithMap(text) { + const chars = [] + const map = [] + for (let i = 0; i < text.length; i++) { + const n = normalizeSearchText(text[i]) + for (const c of n) { + chars.push(c) + map.push(i) + } + } + return { norm: chars.join(""), map } +} + +/** + * Split text into segments marking the parts matched by the tokens, for + * rendering highlighted search results. Matching is normalization-aware, + * so typing "أحمد" highlights "احمد" and vice versa. + * + * @param {String} text - original display text + * @param {String[]} tokens - normalized tokens (from tokenizeQuery) + * @returns {Array<{text: String, hit: Boolean}>} ordered segments + */ +export function matchSegments(text, tokens) { + if (!text) return [] + if (!tokens || tokens.length === 0) return [{ text, hit: false }] + + const { norm, map } = normalizeWithMap(text) + const hits = new Array(text.length).fill(false) + for (const token of tokens) { + let from = 0 + // Mark every occurrence so repeated fragments all light up + for (;;) { + const idx = norm.indexOf(token, from) + if (idx === -1) break + const start = map[idx] + const end = map[idx + token.length - 1] + for (let i = start; i <= end; i++) hits[i] = true + from = idx + token.length + } + } + + const segments = [] + for (let i = 0; i < text.length; i++) { + const last = segments[segments.length - 1] + if (last && last.hit === hits[i]) { + last.text += text[i] + } else { + segments.push({ text: text[i], hit: hits[i] }) + } + } + return segments +} diff --git a/POS/vitest.config.js b/POS/vitest.config.js new file mode 100644 index 000000000..e28847c6b --- /dev/null +++ b/POS/vitest.config.js @@ -0,0 +1,16 @@ +import path from "node:path" +import { defineConfig } from "vitest/config" + +// Standalone Vitest config (kept separate from vite.config.js so the +// frappe-ui / PWA build plugins don't run during unit tests). +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + globals: true, + environment: "jsdom", + }, +}) diff --git a/pos_next/api/constants.py b/pos_next/api/constants.py index 67ec4319f..832a394db 100644 --- a/pos_next/api/constants.py +++ b/pos_next/api/constants.py @@ -39,6 +39,7 @@ "enable_session_lock", "session_lock_timeout", "show_variants_as_items", + "customer_search_show_all", ] # Default POS Settings values @@ -69,4 +70,5 @@ "enable_session_lock": 0, "session_lock_timeout": 5, "show_variants_as_items": 0, + "customer_search_show_all": 0, } diff --git a/pos_next/pos_next/doctype/pos_settings/pos_settings.json b/pos_next/pos_next/doctype/pos_settings/pos_settings.json index 5aa983bbe..893270a8f 100644 --- a/pos_next/pos_next/doctype/pos_settings/pos_settings.json +++ b/pos_next/pos_next/doctype/pos_settings/pos_settings.json @@ -62,6 +62,7 @@ "section_break_advanced", "use_limit_search", "search_limit", + "customer_search_show_all", "column_break_advanced", "allow_submissions_in_background_job", "allow_delete_offline_invoice", @@ -470,6 +471,13 @@ "fieldtype": "Int", "label": "Search Limit Number" }, + { + "default": "0", + "description": "Show the complete customer list in the cart search dropdown instead of capping results at 10 (browsing) / 20 (searching)", + "fieldname": "customer_search_show_all", + "fieldtype": "Check", + "label": "Show All Customers in Search" + }, { "fieldname": "column_break_advanced", "fieldtype": "Column Break" diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv index c99f44df1..84372c446 100644 --- a/pos_next/translations/ar.csv +++ b/pos_next/translations/ar.csv @@ -1560,6 +1560,10 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط: "Invoice {0} saved offline and sent to printer — will sync when online","تم حفظ الفاتورة {0} دون اتصال وإرسالها إلى الطابعة — وستتم مزامنتها عند عودة الاتصال","" "Invoice {0} saved offline but print failed — open Print from the success dialog","تم حفظ الفاتورة {0} دون اتصال، لكن تعذّرت الطباعة — افتح خيار الطباعة من نافذة النجاح","" "Popup blocked — check your browser settings.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.","" +"Show All Customers in Search","عرض كل العملاء في البحث","" +"Show the complete customer list in the cart search dropdown instead of capping results at the first 10-20 matches.","عرض قائمة العملاء كاملة في قائمة البحث بدلاً من الاقتصار على أول 10-20 نتيجة.","" +"All Customers","كل العملاء","" +"Results","النتائج","" "Governorate","المحافظة","" "Select Governorate","اختر المحافظة","" "District","الحي",""