- Introduced layout components for multiple admin dashboard sections including events, audit logs, bank accounts, blog articles, bookings, chat oversight, cities, contact messages, dashboard, discount codes, event categories, guest lists, identity verifications, manage events, manual notifications, notification rules, payments, provinces, push deliveries, reviews, settlements, sms messages, support tickets, user reports, and users. - Updated metadata for the auth layout to include the admin page title. - Modified branch protection documentation to clarify deployment notifications.
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
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/<uuid>.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 = <Args extends unknown[]>(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<string, unknown>): Record<string, unknown> => {
|
||
return Object.entries(obj).reduce<Record<string, unknown>>((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<string, unknown>) : 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 = <T>(operation: Promise<T>): Promise<T> =>
|
||
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<HTMLImageElement> =>
|
||
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<Blob> => {
|
||
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<string, string> = {
|
||
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<T>` (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<T>` (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',
|
||
})
|
||
}
|