From 3ce40a95715d191c90b970afb416944b2e544d10 Mon Sep 17 00:00:00 2001 From: NotAbdelrahmanelsayed Date: Fri, 12 Jun 2026 15:38:17 +0200 Subject: [PATCH] feat(customer-search): enhance cart customer search bar Improve the in-cart customer search with faster, more forgiving lookup and clearer results: - Clear-search (X) button to reset the query in one tap - Arabic-insensitive tokenized matching with highlighted match segments (normalizes alef/ya/ta-marbuta variants, diacritics, and Arabic-Indic digits via new POS/src/utils/searchText.js) - Top-customers-by-frequency suggestions on focus, tracked in localStorage (pos_customer_counts) - "Show All Customers in Search" POS setting to show the full customer list instead of capping at the first 10-20 matches - Keyboard navigation with auto-scroll of the highlighted result into view and preselection of the top-ranked match Adds a unit test for the topCustomers ranking and Arabic translations for the new strings. Co-Authored-By: Claude Opus 4.8 --- POS/src/components/sale/InvoiceCart.vue | 334 +++++++++++++----- POS/src/components/settings/POSSettings.vue | 5 + .../customerSearch.topCustomers.test.js | 100 ++++++ POS/src/stores/customerSearch.js | 34 ++ POS/src/stores/posSettings.js | 6 + POS/src/utils/searchText.js | 98 +++++ POS/vitest.config.js | 16 + pos_next/api/constants.py | 2 + .../doctype/pos_settings/pos_settings.json | 8 + pos_next/translations/ar.csv | 6 +- 10 files changed, 526 insertions(+), 83 deletions(-) create mode 100644 POS/src/stores/__tests__/customerSearch.topCustomers.test.js create mode 100644 POS/src/utils/searchText.js create mode 100644 POS/vitest.config.js diff --git a/POS/src/components/sale/InvoiceCart.vue b/POS/src/components/sale/InvoiceCart.vue index d91835154..eff65d30a 100644 --- a/POS/src/components/sale/InvoiceCart.vue +++ b/POS/src/components/sale/InvoiceCart.vue @@ -192,6 +192,7 @@ + + + @@ -270,87 +285,131 @@
- +
- - {{ __('Frequent Customers') }} + + {{ customerDropdownLabel }} + + + + {{ customerResults.length }} + +
-
+
- -
-
+ +
+ + + +

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

+

@@ -1260,6 +1319,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"; @@ -1383,6 +1447,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); @@ -1491,60 +1557,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") +}) + +/** + * 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] +} /** - * Reset keyboard selection index when search results change. - * Ensures the selection doesn't point to a non-existent result. + * 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). @@ -1652,27 +1798,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 549c522be..751ecf7a4 100644 --- a/POS/src/components/settings/POSSettings.vue +++ b/POS/src/components/settings/POSSettings.vue @@ -363,6 +363,11 @@ :label="__('Allow Partial Payment')" :description="__('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 1a1db1ce7..0000074c6 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()) @@ -196,6 +197,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) { @@ -369,6 +389,12 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { 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( @@ -379,6 +405,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) } @@ -388,9 +418,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) } @@ -404,10 +436,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 9278faf4e..f36a73422 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, @@ -205,6 +206,9 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { 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), ) @@ -336,6 +340,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, @@ -463,6 +468,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 059c04ea0..c213e4b73 100644 --- a/pos_next/api/constants.py +++ b/pos_next/api/constants.py @@ -40,6 +40,7 @@ "enable_session_lock", "session_lock_timeout", "show_variants_as_items", + "customer_search_show_all", ] # Default POS Settings values @@ -70,4 +71,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 f89e6390a..34a645053 100644 --- a/pos_next/translations/ar.csv +++ b/pos_next/translations/ar.csv @@ -1559,4 +1559,8 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط: "This offline receipt is no longer in browser storage. Sync the invoice, then print from history.","هذا الإيصال المحفوظ دون اتصال لم يعد موجودًا في تخزين المتصفح. قم بمزامنة الفاتورة أولًا، ثم اطبعها من السجل.","" "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.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.","" \ No newline at end of file +"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","النتائج",""