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","الحي",""