Initialize the admin app with banner management for app sections.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import type { CashPaymentStatus, CustomerType, ShipmentStatus, UserRole } from '@/types'
|
||||
|
||||
export const CUSTOMER_TYPE_LABELS: Record<CustomerType, string> = {
|
||||
wholesale: 'عمدهفروش',
|
||||
retail: 'خردهفروش',
|
||||
}
|
||||
|
||||
export const USER_ROLE_LABELS: Record<UserRole, string> = {
|
||||
admin: 'مدیر سیستم',
|
||||
sales: 'واحد فروش',
|
||||
finance: 'واحد مالی',
|
||||
warehouse: 'انبار',
|
||||
}
|
||||
|
||||
export const CASH_PAYMENT_STATUS_LABELS: Record<CashPaymentStatus, string> = {
|
||||
pending_review: 'در انتظار تأیید',
|
||||
approved: 'تأیید شده',
|
||||
rejected: 'رد شده',
|
||||
expired: 'منقضیشده',
|
||||
}
|
||||
|
||||
export const CASH_PAYMENT_STATUS_BADGE: Record<CashPaymentStatus, string> = {
|
||||
pending_review: 'admin-badge--warning',
|
||||
approved: 'admin-badge--success',
|
||||
rejected: 'admin-badge--danger',
|
||||
expired: 'admin-badge--neutral',
|
||||
}
|
||||
|
||||
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
|
||||
preparing: 'در حال آمادهسازی',
|
||||
ready: 'آماده ارسال',
|
||||
shipped: 'ارسال شده',
|
||||
delivered: 'تحویل شده',
|
||||
}
|
||||
|
||||
export const SHIPMENT_STATUS_BADGE: Record<ShipmentStatus, string> = {
|
||||
preparing: 'admin-badge--warning',
|
||||
ready: 'admin-badge--info',
|
||||
shipped: 'admin-badge--purple',
|
||||
delivered: 'admin-badge--success',
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const MAX_EDGE = 960
|
||||
const JPEG_QUALITY = 0.82
|
||||
|
||||
export async function fileToCatalogImage(file: File): Promise<string> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('فقط فایل تصویری مجاز است.')
|
||||
}
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
throw new Error('حجم تصویر نباید بیشتر از ۸ مگابایت باشد.')
|
||||
}
|
||||
|
||||
const dataUrl = await readFileAsDataUrl(file)
|
||||
return resizeDataUrl(dataUrl)
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(new Error('خواندن تصویر ناموفق بود.'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function resizeDataUrl(src: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(image.width, image.height))
|
||||
const width = Math.max(1, Math.round(image.width * scale))
|
||||
const height = Math.max(1, Math.round(image.height * scale))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
reject(new Error('پردازش تصویر ناموفق بود.'))
|
||||
return
|
||||
}
|
||||
ctx.drawImage(image, 0, 0, width, height)
|
||||
resolve(canvas.toDataURL('image/jpeg', JPEG_QUALITY))
|
||||
}
|
||||
image.onerror = () => reject(new Error('تصویر نامعتبر است.'))
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||||
const numberFormatter = new Intl.NumberFormat('fa-IR', { useGrouping: true })
|
||||
|
||||
export function normalizeDigits(input: string): string {
|
||||
return input.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)))
|
||||
}
|
||||
|
||||
export function extractNumericString(input: string): string {
|
||||
return normalizeDigits(input).replace(/[^\d]/g, '')
|
||||
}
|
||||
|
||||
export function parseFormattedNumber(value: string): number {
|
||||
const numeric = extractNumericString(value)
|
||||
return numeric ? Number(numeric) : 0
|
||||
}
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return '—'
|
||||
return numberFormatter.format(value)
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number): string {
|
||||
return `${formatNumber(value)} تومان`
|
||||
}
|
||||
|
||||
export function formatMeterage(value: number): string {
|
||||
return `${formatNumber(value)} متر`
|
||||
}
|
||||
|
||||
export function toPersianDigits(input: string | number): string {
|
||||
return String(input).replace(/\d/g, (d) => persianDigits[Number(d)]!)
|
||||
}
|
||||
|
||||
const persianDateFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
const persianDateTimeFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
/** Detect already-Jalali date strings like 1403/07/30 or 1403-07-30 */
|
||||
function isJalaliDateString(value: string): boolean {
|
||||
const normalized = normalizeDigits(value.trim()).replace(/-/g, '/')
|
||||
const match = normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/)
|
||||
if (!match) return false
|
||||
const year = Number(match[1])
|
||||
return year >= 1300 && year <= 1500
|
||||
}
|
||||
|
||||
function toDateValue(date: string | Date): Date {
|
||||
if (typeof date !== 'string') return date
|
||||
const trimmed = date.trim()
|
||||
// فقط تاریخِ بدون ساعت (YYYY-MM-DD) را بهصورت تقویم محلی بخوان تا شیفت UTC روز را جابهجا نکند.
|
||||
// رشتههای دارای ساعت (…T10:30:00 یا … 10:30) باید کامل پارس شوند.
|
||||
const dateOnly = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (dateOnly && Number(dateOnly[1]) >= 1700) {
|
||||
return new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]))
|
||||
}
|
||||
const parsed = new Date(trimmed)
|
||||
if (Number.isNaN(parsed.getTime())) return new Date(NaN)
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date | null | undefined): string {
|
||||
if (!date) return '—'
|
||||
if (typeof date === 'string' && isJalaliDateString(date)) {
|
||||
const normalized = normalizeDigits(date.trim()).replace(/-/g, '/')
|
||||
const match = normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/)
|
||||
if (!match) return toPersianDigits(normalized)
|
||||
const [, y, m, d] = match
|
||||
return toPersianDigits(`${y}/${m!.padStart(2, '0')}/${d!.padStart(2, '0')}`)
|
||||
}
|
||||
const value = toDateValue(date)
|
||||
if (Number.isNaN(value.getTime())) return '—'
|
||||
return persianDateFormatter.format(value)
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return '—'
|
||||
if (typeof date === 'string' && isJalaliDateString(date) && !/[T\s]\d/.test(date)) {
|
||||
return formatDate(date)
|
||||
}
|
||||
const value = toDateValue(date)
|
||||
if (Number.isNaN(value.getTime())) return '—'
|
||||
return persianDateTimeFormatter.format(value)
|
||||
}
|
||||
|
||||
export function generateOrderNumber(): string {
|
||||
const year = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric' }).format(new Date())
|
||||
const persianYear = year.replace(/[^\d]/g, '') || '1403'
|
||||
const seq = Math.floor(Math.random() * 9000) + 1000
|
||||
return `ORD-${persianYear}-${seq}`
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/** Resolve admin header message/notification click targets. */
|
||||
|
||||
const ORDER_NUMBER_RE = /ORD-?\d{2,4}-?\d{2,6}/i
|
||||
const BARE_ORDER_PATH_RE = /^\/?(?:admin\/)?orders\/([^/?#]+)\/?$/i
|
||||
const ORDER_SUBPATH_RE = /^\/?(?:admin\/)?orders\/([^/?#]+)\/(pricing|checks)\/?$/i
|
||||
|
||||
export function extractOrderNumber(text: string): string | null {
|
||||
const match = text.match(ORDER_NUMBER_RE)
|
||||
if (!match?.[0]) return null
|
||||
const digits = match[0].toUpperCase().replace(/^ORD-?/, '').replace(/-/g, '')
|
||||
if (digits.length < 6) return match[0].toUpperCase()
|
||||
const year = digits.slice(0, 4)
|
||||
const seq = digits.slice(4)
|
||||
return `ORD-${year}-${seq}`
|
||||
}
|
||||
|
||||
function hasKeyword(text: string, patterns: RegExp[]): boolean {
|
||||
return patterns.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
/** Rewrite API links to routes that exist in the admin app. */
|
||||
export function normalizeAdminInboxLink(link: string): string {
|
||||
let path = link.trim()
|
||||
if (!path) return path
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
const url = new URL(path)
|
||||
path = `${url.pathname}${url.search}`
|
||||
}
|
||||
} catch {
|
||||
/* keep raw path */
|
||||
}
|
||||
|
||||
path = path.replace(/^\/admin(?=\/)/, '')
|
||||
if (!path.startsWith('/')) path = `/${path}`
|
||||
|
||||
const sub = path.match(ORDER_SUBPATH_RE)
|
||||
if (sub?.[1] && sub[2]) {
|
||||
return `/orders/${sub[1]}/${sub[2]}`
|
||||
}
|
||||
|
||||
const bare = path.match(BARE_ORDER_PATH_RE)
|
||||
if (bare?.[1]) {
|
||||
return `/orders?order=${encodeURIComponent(bare[1])}`
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
export type InboxLinkInput = {
|
||||
link?: string | null
|
||||
orderId?: string | null
|
||||
title?: string | null
|
||||
body?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer explicit `link`, then `orderId` + keywords, then order number / keyword heuristics.
|
||||
*/
|
||||
export function resolveAdminInboxLink(input: InboxLinkInput): string | null {
|
||||
const explicit = input.link?.trim()
|
||||
if (explicit) return normalizeAdminInboxLink(explicit)
|
||||
|
||||
const title = input.title?.trim() ?? ''
|
||||
const body = input.body?.trim() ?? ''
|
||||
const text = `${title} ${body}`
|
||||
const orderId = input.orderId?.trim() || null
|
||||
const orderNumber = extractOrderNumber(text)
|
||||
|
||||
const isSms = hasKeyword(text, [/پیامک/])
|
||||
const isCheck = hasKeyword(text, [/چک/])
|
||||
const isPricing = hasKeyword(text, [/قیمت/, /تخفیف/, /پیش[\s]?فاکتور/, /قیمتگذاری/, /اصلاح قیمت/])
|
||||
const isShipping = !isSms && hasKeyword(text, [/ارسال/, /باربری/, /حمل/, /بارنامه/])
|
||||
const isCash = hasKeyword(text, [/نقدی/, /پرداخت نقد/, /رسید پرداخت/])
|
||||
const isReport = hasKeyword(text, [/گزارش/])
|
||||
const isCustomer = hasKeyword(text, [/مشتری جدید/, /ثبت مشتری/])
|
||||
const isCatalog = hasKeyword(text, [/کالیته/])
|
||||
const isProduct = hasKeyword(text, [/محصول/])
|
||||
const isCompany = hasKeyword(text, [/شرکت/, /حساب بانکی/])
|
||||
const isOrderish = hasKeyword(text, [/سفارش/])
|
||||
const isSettings = isSms || hasKeyword(text, [/پشتیبان/, /تنظیمات/])
|
||||
|
||||
if (orderId) {
|
||||
if (isCheck) return `/orders/${orderId}/checks`
|
||||
if (isPricing) return `/orders/${orderId}/pricing`
|
||||
if (isShipping) return `/shipping?order=${encodeURIComponent(orderId)}`
|
||||
if (isCash) return `/cash-payments?order=${encodeURIComponent(orderId)}`
|
||||
return `/orders?order=${encodeURIComponent(orderId)}`
|
||||
}
|
||||
|
||||
if (orderNumber) {
|
||||
if (isCheck) return `/orders?order=${encodeURIComponent(orderNumber)}&focus=checks`
|
||||
if (isPricing) return `/orders?order=${encodeURIComponent(orderNumber)}&focus=pricing`
|
||||
if (isShipping) return `/shipping?order=${encodeURIComponent(orderNumber)}`
|
||||
if (isCash) return `/cash-payments?order=${encodeURIComponent(orderNumber)}`
|
||||
return `/orders?order=${encodeURIComponent(orderNumber)}`
|
||||
}
|
||||
|
||||
if (isCheck) return '/checks'
|
||||
if (isShipping) return '/shipping'
|
||||
if (isCash) return '/cash-payments'
|
||||
if (isReport) return '/reports'
|
||||
if (isCustomer) return '/customers'
|
||||
if (isCatalog) return '/catalogs'
|
||||
if (isProduct) return '/products'
|
||||
if (isCompany) return '/companies'
|
||||
if (isSettings) return '/settings'
|
||||
if (isPricing || isOrderish) return '/orders'
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NavItem } from '@/types'
|
||||
|
||||
export const ADMIN_NAV_ITEMS: NavItem[] = [
|
||||
{ name: 'dashboard', label: 'داشبورد', path: '/', icon: 'home' },
|
||||
{ name: 'orders', label: 'سفارشها', path: '/orders', icon: 'orders' },
|
||||
{ name: 'customers', label: 'مشتریان', path: '/customers', icon: 'customers' },
|
||||
{ name: 'products', label: 'محصولات', path: '/products', icon: 'products' },
|
||||
{ name: 'categories', label: 'دستهبندیها', path: '/categories', icon: 'catalogs' },
|
||||
{ name: 'banners', label: 'بنرها', path: '/banners', icon: 'image' },
|
||||
{ name: 'catalogs', label: 'کالیتهها', path: '/catalogs', icon: 'catalogs' },
|
||||
{
|
||||
name: 'catalog-requests',
|
||||
label: 'درخواست کالیته فیزیکی',
|
||||
path: '/catalog-requests',
|
||||
icon: 'file',
|
||||
},
|
||||
{ name: 'brands', label: 'برندها', path: '/brands', icon: 'building' },
|
||||
{ name: 'suggestions', label: 'پیشنهاد محصول', path: '/suggestions', icon: 'cart' },
|
||||
{ name: 'stock-alerts', label: 'خبر موجودی', path: '/stock-alerts', icon: 'bell' },
|
||||
{ name: 'levels', label: 'سطوح مشتری', path: '/levels', icon: 'users' },
|
||||
{ name: 'companies', label: 'شرکتها', path: '/companies', icon: 'companies' },
|
||||
{ name: 'checks', label: 'چکها', path: '/checks', icon: 'checks' },
|
||||
{ name: 'cash-payments', label: 'پرداختهای نقدی', path: '/cash-payments', icon: 'cash' },
|
||||
{ name: 'shipping', label: 'ارسال و باربری', path: '/shipping', icon: 'shipping' },
|
||||
{ name: 'reports', label: 'گزارشها', path: '/reports', icon: 'reports' },
|
||||
{ name: 'settings', label: 'تنظیمات', path: '/settings', icon: 'settings' },
|
||||
{ name: 'users', label: 'کاربران و دسترسیها', path: '/users', icon: 'users' },
|
||||
]
|
||||
|
||||
export function isNavActive(path: string, currentPath: string): boolean {
|
||||
if (path === '/') return currentPath === '/'
|
||||
// چکهای یک سفارش زیر منوی «چکها» فعال میشود، نه «سفارشها»
|
||||
if (/^\/orders\/[^/]+\/checks/.test(currentPath)) {
|
||||
if (path === '/checks') return true
|
||||
if (path === '/orders') return false
|
||||
}
|
||||
return currentPath === path || currentPath.startsWith(`${path}/`)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { OrderStatus, RawOrderStatus } from '@/types'
|
||||
|
||||
export interface StatusMeta {
|
||||
label: string
|
||||
tone: 'warning' | 'purple' | 'success' | 'danger' | 'info' | 'neutral' | 'delivered'
|
||||
}
|
||||
|
||||
export const ORDER_STATUS_META: Record<OrderStatus, StatusMeta> = {
|
||||
pending_review: { label: 'در انتظار بررسی', tone: 'warning' },
|
||||
awaiting_customer: { label: 'در انتظار مشتری', tone: 'info' },
|
||||
pending_check: { label: 'بررسی چک', tone: 'purple' },
|
||||
pending_payment: { label: 'بررسی رسید پرداخت', tone: 'purple' },
|
||||
needs_correction: { label: 'نیاز به اصلاح', tone: 'danger' },
|
||||
check_correction: { label: 'نیاز به اصلاح چک', tone: 'warning' },
|
||||
final_approval: { label: 'تأیید نهایی', tone: 'success' },
|
||||
shipped: { label: 'ارسال شده', tone: 'info' },
|
||||
delivered: { label: 'تحویل شده', tone: 'delivered' },
|
||||
cancelled: { label: 'لغو شده', tone: 'neutral' },
|
||||
rejected: { label: 'رد شده', tone: 'danger' },
|
||||
expired: { label: 'منقضی شده', tone: 'neutral' },
|
||||
}
|
||||
|
||||
/** برچسب وضعیت واقعی سفارش؛ باید با ORDER_STATUS_LABELS سرور یکسان بماند. */
|
||||
export const RAW_ORDER_STATUS_LABELS: Record<RawOrderStatus, string> = {
|
||||
cart: 'سبد خرید',
|
||||
submitted: 'ثبتشده',
|
||||
awaiting_review: 'در انتظار بررسی',
|
||||
pricing: 'قیمتگذاری',
|
||||
awaiting_settlement: 'در انتظار تسویه',
|
||||
awaiting_settlement_fix: 'اصلاح تسویه',
|
||||
cash_in_progress: 'در حال ثبت نقدی',
|
||||
cash_invoice_issued: 'صدور فاکتور نقدی',
|
||||
awaiting_cash_payment: 'در انتظار پرداخت نقدی',
|
||||
cash_payment_submitted: 'رسید نقدی ارسالشده',
|
||||
awaiting_cash_confirmation: 'تأیید پرداخت نقدی',
|
||||
cash_confirmed: 'پرداخت نقدی تأیید شد',
|
||||
check_in_progress: 'در حال ثبت چک',
|
||||
awaiting_check_review: 'بررسی چک',
|
||||
check_needs_fix: 'اصلاح چک',
|
||||
check_rejected: 'رد چک',
|
||||
checks_approved: 'تأیید چکها',
|
||||
final_approved: 'تأیید نهایی',
|
||||
preparing: 'آمادهسازی',
|
||||
ready_to_ship: 'آماده ارسال',
|
||||
shipped: 'ارسالشده',
|
||||
delivered: 'تحویلشده',
|
||||
needs_correction: 'نیاز به اصلاح',
|
||||
cancelled: 'لغو شده',
|
||||
rejected: 'رد شده',
|
||||
expired: 'منقضی',
|
||||
}
|
||||
|
||||
export function rawStatusLabel(status?: string): string {
|
||||
if (!status) return '—'
|
||||
return RAW_ORDER_STATUS_LABELS[status as RawOrderStatus] ?? status
|
||||
}
|
||||
|
||||
/** وضعیتهایی که هنوز فاکتور صادر نشده و قیمتگذاری اقدام اصلی است. */
|
||||
export const PRE_INVOICE_RAW_STATUSES: RawOrderStatus[] = [
|
||||
'cart',
|
||||
'submitted',
|
||||
'awaiting_review',
|
||||
'pricing',
|
||||
'needs_correction',
|
||||
]
|
||||
|
||||
const PRE_INVOICE_ADMIN_STATUSES: OrderStatus[] = ['pending_review', 'needs_correction']
|
||||
|
||||
export function canPriceOrder(order: {
|
||||
status: OrderStatus
|
||||
rawStatus?: RawOrderStatus
|
||||
}): boolean {
|
||||
if (order.rawStatus) return PRE_INVOICE_RAW_STATUSES.includes(order.rawStatus)
|
||||
return PRE_INVOICE_ADMIN_STATUSES.includes(order.status)
|
||||
}
|
||||
|
||||
export const ORDER_TABS = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'pending_review', label: 'در انتظار بررسی' },
|
||||
{ key: 'awaiting_customer', label: 'در انتظار مشتری' },
|
||||
{ key: 'pending_check', label: 'بررسی چک' },
|
||||
{ key: 'pending_payment', label: 'بررسی رسید' },
|
||||
{ key: 'check_correction', label: 'اصلاح چک' },
|
||||
{ key: 'needs_correction', label: 'نیاز به اصلاح' },
|
||||
{ key: 'final_approval', label: 'تأیید نهایی' },
|
||||
{ key: 'shipped', label: 'ارسال شده' },
|
||||
{ key: 'delivered', label: 'تحویل شده' },
|
||||
] as const
|
||||
Reference in New Issue
Block a user