Initialize the admin app with banner management for app sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:46 +03:30
co-authored by Cursor
commit d670921f74
104 changed files with 26137 additions and 0 deletions
+41
View File
@@ -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',
}
+46
View File
@@ -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
})
}
+100
View File
@@ -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}`
}
+112
View File
@@ -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
}
+38
View File
@@ -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}/`)
}
+88
View File
@@ -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