Initial admin edits

This commit is contained in:
2026-10-01 20:50:23 +03:30
parent 990883ae11
commit 16806a4d3c
124 changed files with 1964 additions and 740 deletions
+26
View File
@@ -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',
}
+11
View File
@@ -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
}
}
+22
View File
@@ -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)
}
+65
View File
@@ -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)
})
}
+18
View File
@@ -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
}
-1
View File
@@ -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' },
+50
View File
@@ -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()
}
+41
View File
@@ -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
}