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
345 changes: 255 additions & 90 deletions POS/src/components/sale/InvoiceCart.vue

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions POS/src/components/settings/POSSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,11 @@
__('Enable partial payment for invoices')
"
/>
<CheckboxField
v-model="settings.customer_search_show_all"
:label="__('Show All Customers in Search')"
:description="__('Show the complete customer list in the cart search dropdown instead of capping results at the first 10-20 matches.')"
/>
<CheckboxField
v-model="settings.silent_print"
:label="__('Silent Print')"
Expand Down
100 changes: 100 additions & 0 deletions POS/src/stores/__tests__/customerSearch.topCustomers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { createPinia, setActivePinia } from "pinia"

// The store wires up a realtime socket listener and an offline worker at
// setup time; neither is relevant to the ordering logic under test.
vi.mock("@/composables/useRealtimeCustomers", () => ({
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"])
})
})
34 changes: 34 additions & 0 deletions POS/src/stores/customerSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -363,13 +383,23 @@ 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));
localStorage.setItem(
"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);
}
Expand All @@ -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);
}
Expand All @@ -395,10 +427,12 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => {
selectedIndex,
recentSearches,
frequentCustomers,
customerCounts,

// Getters
filteredCustomers,
recommendations,
topCustomers,

// Actions
loadAllCustomers,
Expand Down
4 changes: 4 additions & 0 deletions POS/src/stores/posSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -401,6 +404,7 @@ export const usePOSSettingsStore = defineStore("posSettings", () => {
// Computed - Advanced Settings
useLimitSearch,
searchLimit,
customerSearchShowAll,
allowSubmissionsInBackgroundJob,
allowDeleteOfflineInvoice,
allowChangePostingDate,
Expand Down
98 changes: 98 additions & 0 deletions POS/src/utils/searchText.js
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions POS/vitest.config.js
Original file line number Diff line number Diff line change
@@ -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",
},
})
2 changes: 2 additions & 0 deletions pos_next/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"enable_session_lock",
"session_lock_timeout",
"show_variants_as_items",
"customer_search_show_all",
]

# Default POS Settings values
Expand Down Expand Up @@ -69,4 +70,5 @@
"enable_session_lock": 0,
"session_lock_timeout": 5,
"show_variants_as_items": 0,
"customer_search_show_all": 0,
}
8 changes: 8 additions & 0 deletions pos_next/pos_next/doctype/pos_settings/pos_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions pos_next/translations/ar.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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","الحي",""
Expand Down
Loading