admin/helpers/index.ts
alisaza 82593dc18c refactor(events): update event management components and remove unused files
- 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.
2026-09-07 18:09:55 +03:30

505 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
})
}