Initial admin edits
This commit is contained in:
@@ -39,3 +39,29 @@ export const SHIPMENT_STATUS_BADGE: Record<ShipmentStatus, string> = {
|
||||
shipped: 'admin-badge--purple',
|
||||
delivered: 'admin-badge--success',
|
||||
}
|
||||
|
||||
export type CatalogRequestStatus =
|
||||
| 'new'
|
||||
| 'approved'
|
||||
| 'ready_to_ship'
|
||||
| 'delivered'
|
||||
| 'returned'
|
||||
| 'cancelled'
|
||||
|
||||
export const CATALOG_REQUEST_STATUS_LABELS: Record<CatalogRequestStatus, string> = {
|
||||
new: 'درخواست جدید',
|
||||
approved: 'تأیید شده',
|
||||
ready_to_ship: 'آماده ارسال',
|
||||
delivered: 'تحویل به مشتری',
|
||||
returned: 'بازگردانده شده',
|
||||
cancelled: 'لغو شده',
|
||||
}
|
||||
|
||||
export const CATALOG_REQUEST_STATUS_BADGE: Record<CatalogRequestStatus, string> = {
|
||||
new: 'admin-badge--info',
|
||||
approved: 'admin-badge--purple',
|
||||
ready_to_ship: 'admin-badge--warning',
|
||||
delivered: 'admin-badge--success',
|
||||
returned: 'admin-badge--neutral',
|
||||
cancelled: 'admin-badge--danger',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function isValidBannerLink(value: string): boolean {
|
||||
const link = value.trim()
|
||||
if (!link) return true
|
||||
if (link.startsWith('/')) return /^\/[^\s]*$/.test(link)
|
||||
try {
|
||||
const url = new URL(link)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CatalogRequestRow } from '@/services/api'
|
||||
|
||||
const REGISTERED_ON_RE = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||
|
||||
/** Gregorian YYYY-MM-DD for an instant in Asia/Tehran (matches date picker output). */
|
||||
export function tehranCalendarDay(iso: string): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
export function filterCatalogRequestsByRegisteredOn(
|
||||
rows: CatalogRequestRow[],
|
||||
registeredOn?: string,
|
||||
): CatalogRequestRow[] {
|
||||
const day = registeredOn?.trim()
|
||||
if (!day || !REGISTERED_ON_RE.test(day)) return rows
|
||||
return rows.filter((row) => tehranCalendarDay(row.createdAt) === day)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { CatalogRequestRow } from '@/services/api'
|
||||
|
||||
export type CatalogRequestLine = {
|
||||
lineKey: string
|
||||
requestId: string
|
||||
createdAt: string
|
||||
status: string
|
||||
productName: string
|
||||
productCode: string
|
||||
}
|
||||
|
||||
export type CatalogRequestCustomerGroup = {
|
||||
customerId: string
|
||||
customerName: string
|
||||
customerPhone: string
|
||||
address: string
|
||||
lines: CatalogRequestLine[]
|
||||
}
|
||||
|
||||
export function parseAddressFromNote(note?: string): string {
|
||||
if (!note?.trim()) return '—'
|
||||
const match = note.match(/آدرس:\s*(.+)/)
|
||||
const value = (match?.[1] ?? note).trim()
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
export function groupCatalogRequestsByCustomer(
|
||||
rows: CatalogRequestRow[],
|
||||
): CatalogRequestCustomerGroup[] {
|
||||
const map = new Map<string, CatalogRequestCustomerGroup>()
|
||||
|
||||
for (const row of rows) {
|
||||
const address = parseAddressFromNote(row.note)
|
||||
let group = map.get(row.customerId)
|
||||
if (!group) {
|
||||
group = {
|
||||
customerId: row.customerId,
|
||||
customerName: row.customerName,
|
||||
customerPhone: row.customerPhone,
|
||||
address,
|
||||
lines: [],
|
||||
}
|
||||
map.set(row.customerId, group)
|
||||
} else if (group.address === '—' && address !== '—') {
|
||||
group.address = address
|
||||
}
|
||||
|
||||
for (const item of row.items) {
|
||||
group.lines.push({
|
||||
lineKey: `${row.id}:${item.id}`,
|
||||
requestId: row.id,
|
||||
createdAt: row.createdAt,
|
||||
status: row.status,
|
||||
productName: item.productName,
|
||||
productCode: item.productCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => {
|
||||
const aTime = a.lines[0]?.createdAt ?? ''
|
||||
const bTime = b.lines[0]?.createdAt ?? ''
|
||||
return bTime.localeCompare(aTime)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Company } from '@/types'
|
||||
|
||||
/** همتراز با slug شرکت دریافتکننده پرداخت در سرور */
|
||||
export const PAYMENT_RECIPIENT_COMPANY_SLUG = 'nademitex'
|
||||
|
||||
export function productCompanyIdForForm(
|
||||
companyId: string | undefined,
|
||||
companyList: Company[],
|
||||
): string {
|
||||
const row = companyList.find((c) => c.id === companyId)
|
||||
if (!row || row.slug === PAYMENT_RECIPIENT_COMPANY_SLUG) return ''
|
||||
return companyId ?? ''
|
||||
}
|
||||
|
||||
export function resolveProductCompanyPayload(formCompanyId: string): string | undefined {
|
||||
const trimmed = formCompanyId.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export const ADMIN_NAV_ITEMS: NavItem[] = [
|
||||
path: '/catalog-requests',
|
||||
icon: 'file',
|
||||
},
|
||||
{ name: 'brands', label: 'برندها', path: '/brands', icon: 'building' },
|
||||
{ name: 'suggestions', label: 'پیشنهاد محصول', path: '/suggestions', icon: 'cart' },
|
||||
{ name: 'levels', label: 'سطوح مشتری', path: '/levels', icon: 'users' },
|
||||
{ name: 'companies', label: 'شرکتها', path: '/companies', icon: 'companies' },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { normalizeDigits } from '@/utils/format'
|
||||
|
||||
/** Normalize label/query for case-insensitive Persian-friendly select search. */
|
||||
export function normalizeForSelectSearch(text: string): string {
|
||||
return normalizeDigits(text)
|
||||
.toLowerCase()
|
||||
.replace(/ي/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function optionMatchesSelectSearch(label: string, query: string): boolean {
|
||||
const q = normalizeForSelectSearch(query)
|
||||
if (!q) return true
|
||||
return normalizeForSelectSearch(label).includes(q)
|
||||
}
|
||||
|
||||
export function filterSelectOptionsBySearch<T extends { label: string }>(
|
||||
options: T[],
|
||||
query: string,
|
||||
): T[] {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return options
|
||||
return options.filter((option) => optionMatchesSelectSearch(option.label, trimmed))
|
||||
}
|
||||
|
||||
/** Move focus to first/last visible option (keyboard from search field). */
|
||||
export function focusSelectMenuOption(
|
||||
menuRoot: HTMLElement | null,
|
||||
which: 'first' | 'last',
|
||||
): void {
|
||||
if (!menuRoot) return
|
||||
const buttons = menuRoot.querySelectorAll<HTMLElement>('[data-select-option]')
|
||||
const target = which === 'first' ? buttons[0] : buttons[buttons.length - 1]
|
||||
target?.focus()
|
||||
}
|
||||
|
||||
export function focusAdjacentSelectOption(
|
||||
current: HTMLElement,
|
||||
direction: 1 | -1,
|
||||
): void {
|
||||
const menu = current.closest('[data-select-menu]')
|
||||
if (!menu) return
|
||||
const buttons = [...menu.querySelectorAll<HTMLElement>('[data-select-option]')]
|
||||
const index = buttons.indexOf(current)
|
||||
if (index < 0) return
|
||||
const next = buttons[index + direction]
|
||||
next?.focus()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SettlementMethod } from '@/types'
|
||||
|
||||
const CASH_PHASE_RAW_STATUSES = new Set([
|
||||
'awaiting_cash_payment',
|
||||
'cash_invoice_issued',
|
||||
'cash_in_progress',
|
||||
'cash_payment_submitted',
|
||||
'awaiting_cash_confirmation',
|
||||
])
|
||||
|
||||
export function settlementMethodLabel(method?: string | null): string {
|
||||
if (!method) return 'انتخابنشده'
|
||||
if (method === 'check') return 'چکی'
|
||||
if (method === 'cash_check') return 'نقدی-چکی'
|
||||
return 'نقدی'
|
||||
}
|
||||
|
||||
export function orderShowsCashActions(
|
||||
order: { settlementMethod?: SettlementMethod | null; rawStatus?: string },
|
||||
): boolean {
|
||||
const method = order.settlementMethod
|
||||
if (!method) return false
|
||||
if (method === 'cash') {
|
||||
return order.rawStatus !== 'cash_confirmed'
|
||||
}
|
||||
if (method === 'cash_check') {
|
||||
return !!order.rawStatus && CASH_PHASE_RAW_STATUSES.has(order.rawStatus)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function orderShowsCheckActions(
|
||||
order: { settlementMethod?: SettlementMethod | null; rawStatus?: string },
|
||||
): boolean {
|
||||
const method = order.settlementMethod
|
||||
if (method === 'check') return true
|
||||
if (method === 'cash_check') {
|
||||
return !!order.rawStatus && !CASH_PHASE_RAW_STATUSES.has(order.rawStatus)
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user