import { AxiosError } from 'axios' import { type Area } from 'react-easy-crop' import { type FieldError, type FieldErrors } from 'react-hook-form' import type { WBSItem } from '@/types' import { addToast } from '@/lib/toast' import { ensurePersianApiErrorMessage } from '@/services/apiErrorLocalization' import { extractServerErrorDetail, NETWORK_ERROR_MESSAGE_KEY } from '@/services/errorHandler' import { toPersianDigits } from '@/lib/formatters' export { extractExportFileData, getItemsKeyFromUrl, parseRemittanceList, unwrapApiPayload, unwrapApiDataUnknown, } from '@/helpers/listResponse' export { parseUploadedFile, type UploadedFilePayload } from '@/helpers/upload' export { convertToDateString, convertToISOFormat, getDate } from '@/helpers/dates' /** Safely stringify unknown Input/table cell values without `[object Object]`. */ export const coerceToString = (value: unknown, fallback = ''): string => { if (value == null) return fallback if (typeof value === 'string') return value if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { return String(value) } return fallback } export const getBodyFont = (): string => (typeof window !== 'undefined' ? window.getComputedStyle(document.body).fontFamily : 'sans-serif') export const formatPersonName = (firstName: string | null | undefined, lastName: string | null | undefined, fallback = '—') => { const parts = [firstName, lastName].filter((part): part is string => Boolean(part?.trim())) return parts.length > 0 ? parts.join(' ') : fallback } export const convertPersianToEnglish = (str: string): string => { const persianNumbers = [/۰/g, /۱/g, /۲/g, /۳/g, /۴/g, /۵/g, /۶/g, /۷/g, /۸/g, /۹/g] const arabicNumbers = [/٠/g, /١/g, /٢/g, /٣/g, /٤/g, /٥/g, /٦/g, /٧/g, /٨/g, /٩/g] const englishNumbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] const normalizedPersian = persianNumbers.reduce((acc, persianNum, index) => acc.replace(persianNum, englishNumbers[index]), str) return arabicNumbers.reduce((acc, arabicNum, index) => acc.replace(arabicNum, englishNumbers[index]), normalizedPersian) } export const fileAddress = (fileId: string, fileName = 'image') => { if (!fileId || fileId === '000000000000000000000000') { const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '') return `${basePath}/images/placeholders/image.png` } if (/^https?:\/\//i.test(fileId)) { return fileId } const base = (process.env.NEXT_PUBLIC_FILE_SERVER_URL ?? '').replace(/\/$/, '') const normalizedId = fileId.replace(/^\//, '') // Nest local storage returns relative paths like `files/.jpg` if (normalizedId.includes('/') || /\.[a-z0-9]+$/i.test(normalizedId)) { return `${base}/${normalizedId}` } // Legacy opaque id format: {base}/{id}/{fileName} return `${base}/${normalizedId}/${fileName}` } export const debounce = (func: (...args: Args) => void, delay: number) => { let timer: NodeJS.Timeout return (...args: Args) => { clearTimeout(timer) timer = setTimeout(() => { func(...args) }, delay) } } export const isEmptyObject = (obj: object) => Object.keys(obj).length === 0 export const removeEmptyFields = (obj: Record): Record => { return Object.entries(obj).reduce>((acc, [key, value]) => { if ( value !== '' && value !== null && value !== undefined && (!(typeof value === 'object' && !Array.isArray(value)) || Object.keys(value).length) ) { acc[key] = typeof value === 'object' && !Array.isArray(value) ? removeEmptyFields(value as Record) : value } return acc }, {}) } export const downloadExcel = (base64Data: string, fileName: string) => { const byteCharacters = atob(base64Data) const byteNumbers = Array.from(byteCharacters, (char) => char.charCodeAt(0)) const byteArray = new Uint8Array(byteNumbers) const blob = new Blob([byteArray], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `${fileName}.xlsx` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) } export const handleDownload = async (fileId: string, fileName: string, isUrl = false) => { try { // Get file URL const fileUrl = isUrl ? fileId : fileAddress(fileId, fileName) // Fetch file const response = await fetch(fileUrl) // Check server response if (!response.ok) { throw new Error('فایل قابل دانلود نیست.') } // Get file blob const blob = await response.blob() // Get file name from header (if present) let fileNameFromHeader = response.headers.get('X-File-Name') if (fileNameFromHeader) { fileNameFromHeader = decodeURIComponent(fileNameFromHeader) } const finalFileName = fileNameFromHeader ?? fileName // Create download link const link = document.createElement('a') const url = URL.createObjectURL(blob) link.href = url link.setAttribute('download', finalFileName) // Set downloaded file name document.body.appendChild(link) link.click() document.body.removeChild(link) // Revoke temporary URL URL.revokeObjectURL(url) } catch { addToast({ title: 'خطا در دانلود فایل', color: 'danger', }) } } const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)) export const handleUnhandledErrors = async (errors: FieldErrors) => { for (const key of Object.keys(errors)) { const fieldError = errors[key] as FieldError | undefined if (fieldError?.message) { addToast({ title: fieldError.message, color: 'danger', }) await delay(50) // 50ms delay between toasts } if (Array.isArray(fieldError)) { for (const error of fieldError) { await handleUnhandledErrors(error as FieldErrors) } } } } const CROP_PROCESSING_TIMEOUT_MS = 30_000 const withCropProcessingTimeout = (operation: Promise): Promise => new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { reject(new Error('IMAGE_PROCESSING_TIMEOUT')) }, CROP_PROCESSING_TIMEOUT_MS) operation.then( (value) => { window.clearTimeout(timeout) resolve(value) }, (error: unknown) => { window.clearTimeout(timeout) reject(error instanceof Error ? error : new Error('Image processing failed')) } ) }) const createImage = (url: string): Promise => withCropProcessingTimeout( new Promise((resolve, reject) => { const image = new Image() image.onload = () => { resolve(image) } image.onerror = () => { reject(new Error('Failed to load image')) } // Data URLs can resolve from a mobile browser's image cache immediately. // Assigning `src` after its handlers guarantees that load is not missed. image.src = url }) ) export const getCroppedImg = async (imageSrc: string, crop: Area): Promise => { const image = await createImage(imageSrc) const canvas = document.createElement('canvas') canvas.width = crop.width canvas.height = crop.height const ctx = canvas.getContext('2d') if (!ctx) { throw new Error('Canvas 2D context is unavailable') } ctx.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height) return withCropProcessingTimeout( new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error('Failed to create cropped image blob')) return } resolve(blob) }, 'image/jpeg') }) ) } export const convertTimeToMinutes = (timeString: string) => { const [hours, minutes] = timeString.split(':').map(Number) return hours * 60 + minutes } export const convertMinutesToTime = (minutes: number) => { const hours = Math.floor(minutes / 60) // Calculate the number of hours const remainingMinutes = minutes % 60 // Calculate the remaining minutes const formattedHours = String(hours).padStart(2, '0') const formattedMinutes = String(remainingMinutes).padStart(2, '0') return { hours: formattedHours, minutes: formattedMinutes, result: `${formattedHours}:${formattedMinutes}`, } } export const isActiveRoute = (pathname: string, route: string) => { const path = pathname.split('/').filter(Boolean).join('/') return path === route.replace(/^\//, '') } // WBS Helper Functions /** * Find WBS item and parent branches in a single traverse (optimized) * @param items - WBS items array * @param targetId - Target item id * @param type - Item type ('branch' | 'activity') * @param filterParentsByType - If true, returns only parent branches (for activity) * @returns Object with item and parentBranches */ export const findWBSItemWithParents = ( items: WBSItem[], targetId: string, type: 'branch' | 'activity', filterParentsByType = false ): { item: WBSItem | null; parentBranches: WBSItem[] } => { const findRecursive = (currentItems: WBSItem[], parents: WBSItem[] = []): { item: WBSItem | null; parentBranches: WBSItem[] } | null => { for (const item of currentItems) { if (item.id === targetId && item.type === type) { const filteredParents = filterParentsByType ? parents.filter((p) => p.type === 'branch') : parents return { item, parentBranches: filteredParents } } if (item.children) { const found = findRecursive(item.children, [...parents, item]) if (found) return found } } return null } const result = findRecursive(items) return result ?? { item: null, parentBranches: [] } } /** * Build breadcrumb items for WBS pages */ interface WBSBreadcrumbItem { link: string title: string } export const buildWBSBreadcrumb = ( projectId: string, parentBranches: WBSItem[], currentItem: WBSItem | null, currentItemId: string, currentItemType: 'branch' | 'activity', wbsTitle: string ): WBSBreadcrumbItem[] => { const items: WBSBreadcrumbItem[] = [ { link: `/project/${projectId}/wbs`, title: wbsTitle, }, ] // Add parent branches const parentBranchPathIds: string[] = [] parentBranches.forEach((parentBranch) => { parentBranchPathIds.push(parentBranch.id) items.push({ link: `/project/${projectId}/wbs/branch/${parentBranchPathIds.join('/')}`, title: parentBranch.name, }) }) // Add current item if (currentItem) { const itemPath = currentItemType === 'branch' ? `branch/${[...parentBranchPathIds, currentItemId].join('/')}` : parentBranchPathIds.length ? `branch/${parentBranchPathIds.join('/')}/activity/${currentItemId}` : `activity/${currentItemId}` items.push({ link: `/project/${projectId}/wbs/${itemPath}`, title: currentItem.name, }) } return items } /** * Format a plain number with Persian digit grouping (fa-IR locale) — the * shared base for currency/count/stat display everywhere in the app. * * `fa-IR` emits U+066C (ARABIC THOUSANDS SEPARATOR). Pinar draws that glyph * high like an apostrophe (`۷۸۰ʼ۰۰۰`); swap to a baseline comma so amounts * read as `۷۸۰,۰۰۰`. * @param value - Number to format */ export const formatNumber = (value: number): string => { const sign = value < 0 ? '-' : '' const grouped = Math.abs(value) .toString() .replace(/\B(?=(\d{3})+(?!\d))/g, ',') return toPersianDigits(sign + grouped) } /** * Format amount in Toman — the platform's single monetary unit everywhere * except the payment gateway boundary (see TOMAN_TO_RIAL on the backend). * @param amount - Amount to format * @param showUnit - Show "Toman" unit (default: true) */ export const formatCurrency = (amount: number, showUnit = true): string => { const formatted = formatNumber(amount) return showUnit ? formatted + ' تومان' : formatted } const SERVICE_ERROR_MESSAGES: Record = { networkError: 'اتصال به سرور برقرار نشد یا قطع شد. لطفاً اتصال اینترنت و دسترسی به سرور را بررسی کنید.', apiError: 'خطایی در انجام درخواست پیش آمد.', mockServerError: 'خطای شبیه‌سازی‌شده سرور رخ داد.', projectNotFound: 'پروژه موردنظر یافت نشد.', mediaNotFound: 'رسانه موردنظر یافت نشد.', ticketNotFound: 'تیکت موردنظر یافت نشد.', providerNotFound: 'تأمین‌کننده موردنظر یافت نشد.', contractNotFound: 'قرارداد موردنظر یافت نشد.', inquiryNotFound: 'استعلام موردنظر یافت نشد.', progressBillNotFound: 'صورت وضعیت موردنظر یافت نشد.', inquiryLoadError: 'خطا در بارگذاری استعلام', personnelNotFound: 'نیروی انسانی / قرارداد موردنظر یافت نشد.', userNotFound: 'کاربر موردنظر یافت نشد.', projectMemberNotFound: 'عضو پروژه موردنظر یافت نشد.', wbsNotFound: 'شاخه یا فعالیت موردنظر یافت نشد.', activityOwnerNotFound: 'صاحب فعالیت موردنظر یافت نشد.', activityProviderNotFound: 'تأمین‌کننده فعالیت موردنظر یافت نشد.', departmentNotFound: 'دپارتمان موردنظر یافت نشد.', postNotFound: 'سمت موردنظر یافت نشد.', mapIrReverseFailed: 'دریافت آدرس از سرویس نقشه انجام نشد.', mapIrReverseEmpty: 'برای این نقطه آدرسی برنگشت؛ در صورت نیاز آدرس را دستی وارد کنید.', } /** * Turn a thrown/rejected `unknown` error into a single Persian toast-ready message. * * Resolution order: `error.detailMessage` → server `response.data` (via * `extractServerErrorDetail`) → network-error copy → `error.message` (if not a generic * "Request failed with status code NNN" string) → `SERVICE_ERROR_MESSAGES[messageKey]` → * `SERVICE_ERROR_MESSAGES[fallbackKey]`. * * **When to use it:** in a `catch` block for services that throw raw axios errors instead * of returning a `ServiceResult` (e.g. the camelCase event-domain services — * `events.ts`, `eventManagement.ts`, `eventDetail.ts`, `discovery.ts`, * `geography.ts`, `mapirReverseGeocode.ts`). It accepts the same shape those catch blocks * already narrow to — `(err as { response?: { data?: unknown } })?.response?.data` — so it * is a drop-in replacement for the hand-copied * `extractServerErrorDetail((err as {...})?.response?.data)` block duplicated in * `EventEditForm.tsx`, `AdminEventDetail.tsx`, `EventCreateWizard.tsx`, and * `CreateSettlementModal.tsx`. Those call sites currently split the toast into a * context-specific `title` plus a `description` (server detail, falling back to custom copy * per call site); `getServiceErrorMessage`/`showServiceErrorToast` instead produce one * title-only message, so adopting it there means collapsing that title/description split. * * **When not to use it:** services already returning `ServiceResult` (the * `SCREAMING_SNAKE_CASE` services) normalize and toast their own errors via * `services/errorHandler.ts`'s `handleServiceError`/`resolveServiceErrorDisplayMessage` — * don't double-toast by also calling this helper on their results. * * @param error - The caught value, typically an `AxiosError` or `{ response: { data } }`-shaped object * @param fallbackKey - Key into `SERVICE_ERROR_MESSAGES` used when no server/network message can be resolved */ export const getServiceErrorMessage = (error: unknown, fallbackKey = 'mockServerError'): string => { const err = error as { detailMessage?: string message?: string response?: { data?: unknown } messageKey?: string code?: string } | null if (err?.detailMessage && typeof err.detailMessage === 'string' && err.detailMessage.trim()) { return ensurePersianApiErrorMessage(err.detailMessage) } const responseData: unknown = error instanceof AxiosError ? error.response?.data : err?.response?.data const responseStatus = error instanceof AxiosError ? error.response?.status : undefined const fromResponse = extractServerErrorDetail(responseData, responseStatus) if (fromResponse) return fromResponse const messageKey = responseData && typeof responseData === 'object' && 'messageKey' in responseData && typeof responseData.messageKey === 'string' ? responseData.messageKey : err?.messageKey const isNetworkError = (error instanceof AxiosError && !error.response && error.code !== 'ERR_CANCELED') || messageKey === NETWORK_ERROR_MESSAGE_KEY if (isNetworkError) { return SERVICE_ERROR_MESSAGES.networkError } const normalizedMessage = typeof err?.message === 'string' ? err.message.trim() : '' if (normalizedMessage && !/^Request failed with status code \d+$/i.test(normalizedMessage)) { return ensurePersianApiErrorMessage(normalizedMessage) } if (messageKey && typeof messageKey === 'string') { const shortKey = messageKey.split('.').pop() ?? fallbackKey return SERVICE_ERROR_MESSAGES[shortKey] ?? SERVICE_ERROR_MESSAGES[fallbackKey] } return SERVICE_ERROR_MESSAGES[fallbackKey] } /** * Fire a danger toast with the message from {@link getServiceErrorMessage}. * Convenience wrapper for `catch` blocks that just need to show *something* useful — * see {@link getServiceErrorMessage} for when this fits and when it doesn't. * * @param error - The caught value, typically an `AxiosError` or `{ response: { data } }`-shaped object * @param fallbackKey - Key into `SERVICE_ERROR_MESSAGES` used when no server/network message can be resolved */ export const showServiceErrorToast = (error: unknown, fallbackKey = 'mockServerError') => { addToast({ title: getServiceErrorMessage(error, fallbackKey), color: 'danger', }) }