ui fixers in 10/16

This commit is contained in:
2026-10-16 14:34:28 +05:30
parent 379ad204bd
commit e289b8430f
47 changed files with 1904 additions and 1046 deletions
+6
View File
@@ -9,6 +9,7 @@ import {
SalesInvoiceStatus,
SalesInvoicePayment,
SalesInvoicePostingCheck,
SalesInvoiceListTotals,
SalesInvoiceSummary,
SalesListFilterParams,
SalesReportDefinition,
@@ -56,6 +57,11 @@ export const salesApi = {
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
},
/** Count/amount totals for the same filters as `listInvoices`, across every match (not one page). */
invoiceTotals(params: Omit<ListSalesInvoicesParams, "page" | "pageSize"> = {}): Promise<SalesInvoiceListTotals> {
return apiRequest<SalesInvoiceListTotals>(`/sales-invoices/totals${buildQuery(params)}`)
},
/** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
+59
View File
@@ -0,0 +1,59 @@
/**
* Collapses serial / warranty numbers into consecutive runs for display:
* SN-001, SN-002 … SN-050, SN-077 → "SN-001 – SN-050" (50) and "SN-077" (1).
*
* Uses the same "prefix + trailing number" reading as the GRN form's range builder, so a run
* entered there as From/adding comes back out as exactly one range here.
*/
export type SerialRange = { from: string; to: string; count: number }
function parseTrailingNumber(s: string): { prefix: string; num: number; width: number } | null {
const m = s.match(/^(.*?)(\d+)$/)
if (!m) return null
return { prefix: m[1], num: parseInt(m[2], 10), width: m[2].length }
}
/** Natural order: by prefix, then by the trailing number (so SN-9 sorts before SN-10). */
function compareSerials(a: string, b: string): number {
const pa = parseTrailingNumber(a)
const pb = parseTrailingNumber(b)
if (pa && pb && pa.prefix === pb.prefix) return pa.num - pb.num
return a.localeCompare(b, undefined, { numeric: true })
}
export function toSerialRanges(values: string[]): SerialRange[] {
const sorted = [...new Set(values.map((v) => v.trim()).filter(Boolean))].sort(compareSerials)
const ranges: SerialRange[] = []
let run: { start: string; prefix: string; width: number; last: number; lastText: string; count: number } | null = null
for (const value of sorted) {
const parsed = parseTrailingNumber(value)
// Consecutive = same prefix, next number, written with the run's zero-padding.
const continues =
run !== null &&
parsed !== null &&
parsed.prefix === run.prefix &&
parsed.num === run.last + 1 &&
value === `${run.prefix}${String(parsed.num).padStart(run.width, "0")}`
if (continues && run && parsed) {
run.last = parsed.num
run.lastText = value
run.count += 1
continue
}
if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count })
run = parsed
? { start: value, prefix: parsed.prefix, width: parsed.width, last: parsed.num, lastText: value, count: 1 }
: { start: value, prefix: `\u0000${value}`, width: 0, last: Number.NaN, lastText: value, count: 1 }
}
if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count })
return ranges
}
/** One-line summary, e.g. "SN-001 – SN-050" or "SN-001 – SN-050, SN-077 +2 more". */
export function formatSerialRanges(ranges: SerialRange[], max = 2): string {
const label = (r: SerialRange) => (r.count === 1 ? r.from : `${r.from} – ${r.to}`)
const shown = ranges.slice(0, max).map(label).join(", ")
return ranges.length > max ? `${shown} +${ranges.length - max} more` : shown
}
@@ -0,0 +1,56 @@
/**
* Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the
* backend's `PhoneRules.Pattern` so the client rejects exactly what the API would.
*
* Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits
* (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits.
*/
export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/
/** Digit count allowed after the prefix's own digits, per format. */
const LOCAL_DIGITS = 10 // 0 + 9
const INTL_DIGITS = 11 // 94 + 9
const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567"
/**
* Input filter for a phone field's onChange: drops disallowed characters and stops accepting
* digits once the number is full, so the limit is enforced while typing rather than on save.
*/
export function limitPhoneInput(raw: string): string {
let out = ""
let digits = 0
for (const ch of raw) {
if (ch === "+") {
if (out.length === 0) out += ch // "+" only as the very first character
continue
}
if (ch === " " || ch === "-") {
out += ch
continue
}
if (!/\d/.test(ch)) continue
const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS
if (digits >= max) continue
out += ch
digits += 1
}
return out
}
/** Error message for an invalid phone, or null when valid. Blank is valid unless `required`. */
export function phoneError(value: string, { required = false }: { required?: boolean } = {}): string | null {
const phone = value.trim()
if (!phone) return required ? "Enter a phone number" : null
if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +"
const digits = phone.replace(/\D/g, "")
if (phone.startsWith("+")) {
if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}`
if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)`
} else {
if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}`
if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)`
}
if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}`
return null
}