- Replaced `LIST_PUBLIC_CATEGORIES` with `LIST_ADMIN_CATEGORIES_FLAT` in `ArticleFormModal.tsx`. - Removed the "Add Event" button from the dashboard page. - Simplified the `EventsPage` by removing the button and adjusting the layout. - Cleaned up the `AdminEventEditPage` by removing unnecessary props. - Deleted unused layout and page files related to event creation. - Updated `AdminAuthContent` to use `useAdminCitiesQuery` instead of `useDiscoveryCitiesQuery`. - Refactored `EventGuestListAccessPanel` to remove the `accessMode` prop and adjust API calls accordingly. - Removed several unused components and tests related to event creation, enhancing project maintainability.
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`, 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',
|
||
})
|
||
}
|