/opt/canhelp/apps/web/src/app/admin
NameSizeModeActions
page.tsx4035600644editdlrm
Edit: /opt/canhelp/apps/web/src/app/admin/page.tsx (403560B)
'use client' import { useState, useEffect, useRef, type ReactNode } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { useSession } from '@/lib/auth' import { useLocale } from '@/context/locale' import * as api from '@/lib/api' import { request } from '@/lib/api' import { formatShortName } from '@/lib/formatName' import { formatPrice } from '@/lib/taskLocale' import { CategoryIcon } from '@/components/CategoryIcon' type Locale = 'el' | 'en' | 'ru' | 'uk' type Tab = 'stats' | 'users' | 'tasks' | 'reports' | 'moderation' | 'categories' | 'locations' | 'balances' | 'logs' | 'plans' | 'settings' | 'support' | 'push' | 'backup' | 'translations' | 'dbcheck' | 'referrals' const TEMPLATE_VARS: Record = { registration_success: ['userName', 'link', 'siteName', 'siteUrl'], email_verification: ['userName', 'link', 'siteName', 'siteUrl'], password_reset: ['userName', 'link', 'siteName', 'siteUrl'], new_offer: ['userName', 'taskTitle', 'taskId', 'specialistName', 'price', 'link'], new_task: ['userName', 'taskTitle', 'taskId', 'customerName', 'link'], offer_accepted: ['userName', 'taskTitle', 'price', 'link'], new_message: ['userName', 'senderName', 'preview', 'link'], offer_declined: ['userName', 'taskTitle', 'taskId', 'link'], offer_other_accepted: ['userName', 'taskTitle', 'taskId', 'link'], task_updated: ['userName', 'taskTitle', 'taskId', 'link'], deadline_reminder: ['userName', 'taskTitle', 'taskId', 'link'], task_archived: ['userName', 'taskTitle', 'taskId', 'link'], referral_reward: ['userName', 'days', 'planName', 'link'], plan_activated: ['userName', 'planName', 'price', 'expiresAt', 'link'], plan_upgraded: ['userName', 'oldPlanName', 'newPlanName', 'charged', 'expiresAt', 'link'], plan_downgrade_scheduled: ['userName', 'currentPlanName', 'newPlanName', 'scheduledDate', 'link'], plan_downgrade_applied: ['userName', 'planName', 'link'], plan_renewal_reminder: ['userName', 'planName', 'amount', 'expiresAt', 'link'], plan_insufficient_funds: ['userName', 'currentPlanName', 'requiredAmount', 'currentBalance', 'expiresAt', 'topUpLink'], } const TEMPLATE_HINT_VARS = Array.from(new Set(Object.values(TEMPLATE_VARS).flat())) type CategoryIconPreset = { value: string label: string } const DEFAULT_CATEGORY_ICON_PRESETS: CategoryIconPreset[] = [ { value: '/category-icons/repairs.svg', label: 'Repairs & Construction' }, { value: '/category-icons/repairs-alt-wrench.svg', label: 'Repairs Alt: Wrench' }, { value: '/category-icons/repairs-alt-hard-hat.svg', label: 'Repairs Alt: Hard Hat' }, { value: '/category-icons/cleaning.svg', label: 'Cleaning' }, { value: '/category-icons/cleaning-alt-broom.svg', label: 'Cleaning Alt: Broom' }, { value: '/category-icons/cleaning-alt-sparkles.svg', label: 'Cleaning Alt: Sparkles' }, { value: '/category-icons/moving.svg', label: 'Moving & Transport' }, { value: '/category-icons/moving-alt-bus.svg', label: 'Moving Alt: Bus' }, { value: '/category-icons/moving-alt-train.svg', label: 'Moving Alt: Train' }, { value: '/category-icons/tutoring.svg', label: 'Tutoring & Education' }, { value: '/category-icons/tutoring-alt-book-open.svg', label: 'Tutoring Alt: Book Open' }, { value: '/category-icons/tutoring-alt-notebook.svg', label: 'Tutoring Alt: Notebook' }, { value: '/category-icons/it.svg', label: 'IT & Technology' }, { value: '/category-icons/it-alt-monitor.svg', label: 'IT Alt: Monitor' }, { value: '/category-icons/it-alt-settings.svg', label: 'IT Alt: Settings' }, { value: '/category-icons/design.svg', label: 'Design' }, { value: '/category-icons/design-alt-brush.svg', label: 'Design Alt: Brush' }, { value: '/category-icons/design-alt-paintbrush.svg', label: 'Design Alt: Paintbrush' }, { value: '/category-icons/beauty.svg', label: 'Beauty & Health' }, { value: '/category-icons/beauty-alt-heart.svg', label: 'Beauty Alt: Heart' }, { value: '/category-icons/beauty-alt-stethoscope.svg', label: 'Beauty Alt: Stethoscope' }, { value: '/category-icons/tech-repair.svg', label: 'Device Repair' }, { value: '/category-icons/tech-repair-alt-smartphone.svg', label: 'Device Repair Alt: Smartphone' }, { value: '/category-icons/tech-repair-alt-wrench.svg', label: 'Device Repair Alt: Wrench' }, { value: '/category-icons/events.svg', label: 'Events' }, { value: '/category-icons/events-alt-calendar.svg', label: 'Events Alt: Calendar' }, { value: '/category-icons/events-alt-clock.svg', label: 'Events Alt: Clock' }, { value: '/category-icons/photo-video.svg', label: 'Photo & Video' }, { value: '/category-icons/photo-video-alt-image.svg', label: 'Photo & Video Alt: Image' }, { value: '/category-icons/photo-video-alt-video.svg', label: 'Photo & Video Alt: Video' }, { value: '/category-icons/pets.svg', label: 'Pets' }, { value: '/category-icons/pets-alt-dog.svg', label: 'Pets Alt: Dog' }, { value: '/category-icons/pets-alt-cat.svg', label: 'Pets Alt: Cat' }, { value: '/category-icons/auto.svg', label: 'Automotive' }, { value: '/category-icons/auto-alt-carfront.svg', label: 'Automotive Alt: Car Front' }, { value: '/category-icons/auto-alt-car.svg', label: 'Automotive Alt: Car' }, { value: '/category-icons/legal.svg', label: 'Legal Services' }, { value: '/category-icons/legal-alt-gavel.svg', label: 'Legal Alt: Gavel' }, { value: '/category-icons/legal-alt-landmark.svg', label: 'Legal Alt: Landmark' }, { value: '/category-icons/accounting.svg', label: 'Accounting' }, { value: '/category-icons/accounting-alt-receipt.svg', label: 'Accounting Alt: Receipt' }, { value: '/category-icons/accounting-alt-wallet.svg', label: 'Accounting Alt: Wallet' }, { value: '/category-icons/other.svg', label: 'Other' }, { value: '/category-icons/other-alt-briefcase.svg', label: 'Other Alt: Briefcase' }, { value: '/category-icons/other-alt-search.svg', label: 'Other Alt: Search' }, ] function getCatLabel(row: any, locale: string): string { if (!row) return '' if (locale === 'en') return row.namesEn || row.namesEl || row.slug || '' if (locale === 'ru') return row.namesRu || row.namesEl || row.slug || '' if (locale === 'uk') return row.namesUk || row.namesEl || row.slug || '' return row.namesEl || row.namesEn || row.slug || '' } function getLocLabel(row: any, locale: string): string { if (!row) return '' if (locale === 'en') return row.nameEn || row.nameEl || row.slug || '' if (locale === 'ru') return row.nameRu || row.nameEl || row.slug || '' if (locale === 'uk') return row.nameUk || row.nameEl || row.slug || '' return row.nameEl || row.nameEn || row.slug || '' } function CategoryIconPicker({ value, onChange, onUpload, uploading, error, presets, }: { value: string onChange: (icon: string) => void onUpload: (file: File) => Promise uploading: boolean error: string | null presets: CategoryIconPreset[] }) { return (
onChange(e.target.value)} className="flex-1 border-0 bg-transparent p-0 text-sm outline-none ring-0" placeholder="/category-icons/package.svg" />
or choose from presets

Preset source: /public/category-icons/presets.json

{error ?

{error}

: null}
{presets.map((preset) => { const active = value === preset.value return ( ) })}
) } interface AdminCatPickerProps { catRows: any[] locale: string selected: string[] onAdd: (id: string) => void addLabel: string } function AdminCatPicker({ catRows, locale, selected, onAdd, addLabel }: AdminCatPickerProps) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [expandedParent, setExpandedParent] = useState(null) const ref = useRef(null) useEffect(() => { function h(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); setSearch('') } } document.addEventListener('mousedown', h) return () => document.removeEventListener('mousedown', h) }, []) const parents = catRows.filter((r) => !r.parentId) const lowerSearch = search.toLowerCase() const matchesSearch = (r: any) => !search || getCatLabel(r, locale).toLowerCase().includes(lowerSearch) || (r.slug ?? '').toLowerCase().includes(lowerSearch) return (
{open && (
setSearch(e.target.value)} placeholder="Поиск..." className="w-full text-xs border border-gray-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-300" />
{parents.map((parent) => { const children = catRows.filter((r) => r.parentId === parent.id) const parentLabel = getCatLabel(parent, locale) const isExpanded = expandedParent === parent.id const matchingChildren = children.filter((c) => !selected.includes(c.slug) && matchesSearch(c)) const parentAvail = !selected.includes(parent.slug) && matchesSearch(parent) const hasChildren = children.length > 0 if (!parentAvail && matchingChildren.length === 0 && search) return null return (
{(isExpanded || !!search) && matchingChildren.map((child) => ( ))}
) })}
)}
) } interface AdminLocPickerProps { locRows: any[] locale: string selected: string[] onAdd: (id: string) => void addLabel: string } function AdminLocPicker({ locRows, locale, selected, onAdd, addLabel }: AdminLocPickerProps) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [expandedCity, setExpandedCity] = useState(null) const ref = useRef(null) useEffect(() => { function h(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); setSearch('') } } document.addEventListener('mousedown', h) return () => document.removeEventListener('mousedown', h) }, []) const cities = locRows.filter((r) => !r.parentId) const lowerSearch = search.toLowerCase() const matchesSearch = (r: any) => !search || getLocLabel(r, locale).toLowerCase().includes(lowerSearch) || (r.slug ?? '').toLowerCase().includes(lowerSearch) return (
{open && (
setSearch(e.target.value)} placeholder="Поиск..." className="w-full text-xs border border-gray-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-300" />
{cities.map((city) => { const districts = locRows.filter((r) => r.parentId === city.id) const cityLabel = getLocLabel(city, locale) const isExpanded = expandedCity === city.id const hasDistricts = districts.length > 0 const matchingDistricts = districts.filter((d) => !selected.includes(d.slug) && matchesSearch(d)) const cityAvail = !selected.includes(city.slug) && matchesSearch(city) if (!cityAvail && matchingDistricts.length === 0 && search) return null return (
{(isExpanded || !!search) && matchingDistricts.map((d) => ( ))}
) })}
)}
) } export default function AdminPage() { const router = useRouter() const { data: session, isPending } = useSession() const { t, locale } = useLocale() const [tab, setTab] = useState('stats') const [sidebarCollapsed, setSidebarCollapsed] = useState(false) useEffect(() => { setSidebarCollapsed(window.innerWidth < 1024) }, []) // Stats const [stats, setStats] = useState(null) // Users const [users, setUsers] = useState([]) const [usersPage, setUsersPage] = useState(1) const [usersTotal, setUsersTotal] = useState(0) const [usersQ, setUsersQ] = useState('') const [usersQInput, setUsersQInput] = useState('') const [usersRole, setUsersRole] = useState('') // Tasks const [tasks, setTasks] = useState([]) const [tasksPage, setTasksPage] = useState(1) const [tasksTotal, setTasksTotal] = useState(0) const [tasksQ, setTasksQ] = useState('') const [tasksQInput, setTasksQInput] = useState('') const [tasksStatus, setTasksStatus] = useState('') // Reports const [reports, setReports] = useState([]) const [reportsPage, setReportsPage] = useState(1) const [reportsTotal, setReportsTotal] = useState(0) const [pendingReportsCount, setPendingReportsCount] = useState(0) // Specialist card moderation const [moderationCards, setModerationCards] = useState([]) const [moderationCardsPage, setModerationCardsPage] = useState(1) const [moderationCardsTotal, setModerationCardsTotal] = useState(0) const [moderationCardsLoading, setModerationCardsLoading] = useState(false) const [pendingCardsCount, setPendingCardsCount] = useState(0) const [categorySuggestionTargets, setCategorySuggestionTargets] = useState>({}) const [categorySuggestionSlugs, setCategorySuggestionSlugs] = useState>({}) const [resolvingCategorySuggestion, setResolvingCategorySuggestion] = useState(null) // Support tickets const [supportTickets, setSupportTickets] = useState([]) const [supportPage, setSupportPage] = useState(1) const [supportTotal, setSupportTotal] = useState(0) const [supportStatus, setSupportStatus] = useState('') const [supportType, setSupportType] = useState('') const [supportQ, setSupportQ] = useState('') const [supportQInput, setSupportQInput] = useState('') const [supportReplyId, setSupportReplyId] = useState(null) const [supportReplyText, setSupportReplyText] = useState('') const [supportReplySaving, setSupportReplySaving] = useState(false) // AI Logs const [logs, setLogs] = useState([]) const [logsPage, setLogsPage] = useState(1) const [logsTotal, setLogsTotal] = useState(0) // Activity Logs const [activityLog, setActivityLog] = useState([]) const [activityTotal, setActivityTotal] = useState(0) const [activityPage, setActivityPage] = useState(1) const [activityEvent, setActivityEvent] = useState('') const [activityUserId, setActivityUserId] = useState('') const [activityDateFrom, setActivityDateFrom] = useState('') const [activityDateTo, setActivityDateTo] = useState('') const [activityEventTypes, setActivityEventTypes] = useState([]) // Mail Logs const [mailLog, setMailLog] = useState([]) const [mailTotal, setMailTotal] = useState(0) const [mailPage, setMailPage] = useState(1) const [mailEvent, setMailEvent] = useState('') const [mailDateFrom, setMailDateFrom] = useState('') const [mailDateTo, setMailDateTo] = useState('') const [mailEventTypes, setMailEventTypes] = useState([]) // Logs sub-tab const [logsSubTab, setLogsSubTab] = useState<'ai' | 'activity' | 'mail' | 'notifications'>('ai') // Notification Logs const [notifLogs, setNotifLogs] = useState([]) const [notifLogsPage, setNotifLogsPage] = useState(1) const [notifLogsTotal, setNotifLogsTotal] = useState(0) const [notifLogsType, setNotifLogsType] = useState('') const [notifLogsTypes, setNotifLogsTypes] = useState([]) const [notifLogsDateFrom, setNotifLogsDateFrom] = useState('') const [notifLogsDateTo, setNotifLogsDateTo] = useState('') // Notification stats chart const [notifsByDay, setNotifsByDay] = useState<{ day: string; count: number }[]>([]) const [mailByDay, setMailByDay] = useState<{ day: string; count: number }[]>([]) // Balance archive const [balanceRows, setBalanceRows] = useState([]) const [balanceTotal, setBalanceTotal] = useState(0) const [balancePage, setBalancePage] = useState(1) const [balanceUserId, setBalanceUserId] = useState('') const [balanceKind, setBalanceKind] = useState('') const [topUpSaving, setTopUpSaving] = useState(false) const [withdrawSaving, setWithdrawSaving] = useState(false) // User edit modal const [editUser, setEditUser] = useState(null) const [editUserSaving, setEditUserSaving] = useState(false) const [verifyingEmail, setVerifyingEmail] = useState(false) const [editUserTab, setEditUserTab] = useState<'profile' | 'account' | 'balance' | 'settings' | 'site' | 'specialist'>('profile') const [balanceModal, setBalanceModal] = useState<'topup' | 'withdraw' | null>(null) const [skillInput, setSkillInput] = useState('') const [editUserCards, setEditUserCards] = useState([]) const [editUserCardsLoading, setEditUserCardsLoading] = useState(false) const [cardSkillInputs, setCardSkillInputs] = useState>({}) const [cardSaving, setCardSaving] = useState>({}) const [cardCatSelects, setCardCatSelects] = useState>({}) const [cardLocSelects, setCardLocSelects] = useState>({}) // Plans const [planRows, setPlanRows] = useState([]) const [planForm, setPlanForm] = useState<{ id?: string; name: string; description: string; role: string; tier: string price: string; oldPrice: string; currency: string maxTasks: string; maxOffers: string; maxCards: string maxMessagesPerDay: string; maxOrdersPerDay: string; maxSkills: string durationDays: string; searchBoost: number; offersMultiplier: string features: string; isDefault: boolean; isActive: boolean; order: number notifyNewTasks: boolean canContactFreePlan: boolean; canContactProPlan: boolean; canContactAll: boolean canShowContactInfo: boolean; canViewPhone: boolean; canUploadVideo: boolean hasFavorites: boolean; hasGoogleCalendar: boolean; hasVerifiedBadge: boolean highlightedReviews: boolean; hasPersonalSite: boolean; hasCanHelpNowStatus: boolean hasNeedHelpStatus: boolean; hasAutoResponse: boolean; hasAutoMatch: boolean; hasPriceList: boolean receiveInstantDispatchFree: boolean; receiveInstantDispatchPro: boolean } | null>(null) const [planSaving, setPlanSaving] = useState(false) // Locations const [locRows, setLocRows] = useState([]) const [locForm, setLocForm] = useState<{ id?: string; parentId?: string | null; slug: string nameEl: string; nameEn: string; nameRu: string; nameUk: string; order: number; isActive: boolean } | null>(null) const [locSaving, setLocSaving] = useState(false) const [locExpanded, setLocExpanded] = useState>(new Set()) // Categories const [catRows, setCatRows] = useState([]) const [catForm, setCatForm] = useState<{ id?: string; parentId?: string | null; slug: string; icon: string namesEl: string; namesEn: string; namesRu: string; namesUk: string; order: number; isActive: boolean } | null>(null) const [catSaving, setCatSaving] = useState(false) const [catExpanded, setCatExpanded] = useState>(new Set()) const [catIconUploading, setCatIconUploading] = useState(false) const [catIconError, setCatIconError] = useState(null) const [categoryIconPresets, setCategoryIconPresets] = useState(DEFAULT_CATEGORY_ICON_PRESETS) // Category Skills const [catSkillsExpanded, setCatSkillsExpanded] = useState>(new Set()) const [catSkillsCache, setCatSkillsCache] = useState>({}) const [catSkillsLoading, setCatSkillsLoading] = useState>(new Set()) const [skillForm, setSkillForm] = useState<{ id?: string; catSlug: string nameEl: string; nameEn: string; nameRu: string; nameUk: string; order: number } | null>(null) const [skillSaving, setSkillSaving] = useState(false) // Settings const [settingsTab, setSettingsTab] = useState<'general' | 'smtp' | 'notifications' | 'payment' | 'referral' | 'seo' | 'socials' | 'telegram'>('general') const [generalForm, setGeneralForm] = useState({ siteName: 'CanHelp', siteUrl: '', contactEmail: '', supportPhone: '', showPricing: true }) const [generalSaving, setGeneralSaving] = useState(false) const [generalSaved, setGeneralSaved] = useState(false) const [smtpForm, setSmtpForm] = useState({ host: '', port: '587', username: '', password: '', fromName: 'CanHelp', fromEmail: '', encryption: 'tls' as 'none' | 'tls' | 'ssl' }) const [smtpSaving, setSmtpSaving] = useState(false) const [smtpSaved, setSmtpSaved] = useState(false) const [testEmailSending, setTestEmailSending] = useState(false) const [testEmailResult, setTestEmailResult] = useState(null) const [notifTemplates, setNotifTemplates] = useState>({ email_verification: { subject: 'Подтвердите ваш email — {{siteName}}', body: '' }, registration_success: { subject: 'Добро пожаловать в {{siteName}}!', body: '' }, password_reset: { subject: 'Восстановление пароля — {{siteName}}', body: '' }, new_offer: { subject: 'Новое предложение по вашей задаче «{{taskTitle}}»', body: '' }, new_task: { subject: 'Новая задача: {{taskTitle}}', body: '' }, offer_accepted: { subject: 'Ваше предложение принято: {{taskTitle}}', body: '

Ваше предложение принято! 🎉

Привет, {{userName}}!

Заказчик принял ваше предложение на выполнение задачи:

{{taskTitle}}

Сумма: {{price}}€

Открыть чат →' }, new_message: { subject: 'Новое сообщение от {{senderName}}', body: '

Новое сообщение

Привет, {{userName}}!

Вам написал(а) {{senderName}}:

"{{preview}}"

Ответить →' }, offer_declined: { subject: 'Ваше предложение отклонено: {{taskTitle}}', body: '

Ваше предложение отклонено

Привет, {{userName}}!

К сожалению, заказчик отклонил ваше предложение на:

{{taskTitle}}

Найти другие заказы →' }, offer_other_accepted: { subject: 'Выбран другой исполнитель: {{taskTitle}}', body: '

Выбран другой исполнитель

Привет, {{userName}}!

Заказчик выбрал другого исполнителя для задания:

{{taskTitle}}

Найти другие заказы →' }, task_updated: { subject: 'Задание обновлено: {{taskTitle}}', body: '

Задание обновлено

Привет, {{userName}}!

Заказчик обновил детали задания:

{{taskTitle}}

Смотреть заказ →' }, deadline_reminder: { subject: 'Задание истекает завтра: {{taskTitle}}', body: '

Напоминание о дедлайне

Привет, {{userName}}!

Ваше задание истекает через 24 часа. Обновите дедлайн, иначе задание будет автоматически снято с публикации.

{{taskTitle}}

Редактировать задание →' }, task_archived: { subject: 'Задание снято с публикации: {{taskTitle}}', body: '

Задание переведено в черновик

Привет, {{userName}}!

Ваше задание истекло и было автоматически переведено в черновик. Обновите дедлайн, чтобы опубликовать его снова.

{{taskTitle}}

Редактировать задание →' }, referral_reward: { subject: 'Вы получили реферальную награду — {{siteName}}', body: '

Вы получили реферальную награду! 🎁

Привет, {{userName}}!

Друг зарегистрировался по вашей реферальной ссылке. На ваш аккаунт добавлено {{days}} дней доступа к плану {{planName}}!

+{{days}} дней — {{planName}}

Перейти к аккаунту →' }, referral_reward_el: { subject: 'Λάβατε ανταμοιβή παραπομπής — {{siteName}}', body: '

Λάβατε ανταμοιβή παραπομπής! 🎁

Γεια {{userName}},

Ένας φίλος εγγράφηκε χρησιμοποιώντας τον σύνδεσμο παραπομπής σας. Στον λογαριασμό σας προστέθηκαν {{days}} ημέρες πρόσβασης στο πλάνο {{planName}}!

+{{days}} ημέρες — {{planName}}

Δες τον λογαριασμό σου →' }, referral_reward_en: { subject: 'You received a referral reward — {{siteName}}', body: '

You received a referral reward! 🎁

Hi {{userName}},

A friend registered using your referral link. {{days}} days of {{planName}} access have been added to your account!

+{{days}} days — {{planName}}

View your account →' }, referral_reward_ru: { subject: 'Вы получили реферальную награду — {{siteName}}', body: '

Вы получили реферальную награду! 🎁

Привет, {{userName}}!

Друг зарегистрировался по вашей реферальной ссылке. На ваш аккаунт добавлено {{days}} дней доступа к плану {{planName}}!

+{{days}} дней — {{planName}}

Перейти к аккаунту →' } }) const [editingTemplate, setEditingTemplate] = useState(null) const [templateLocale, setTemplateLocale] = useState<'el' | 'en' | 'uk' | 'ru'>('el') const [templateSaving, setTemplateSaving] = useState(false) const [templateTranslating, setTemplateTranslating] = useState(false) const [ePayForm, setEPayForm] = useState({ merchantId: '', apiUser: '', apiPassword: '', environment: 'test' as 'test' | 'production', confirmationUrl: '', cancelUrl: '', enabled: false }) const [ePaySaving, setEPaySaving] = useState(false) const [ePaySaved, setEPaySaved] = useState(false) const [referralForm, setReferralForm] = useState({ enabled: false, planId: '', days: '30' }) const [referralSaving, setReferralSaving] = useState(false) const [referralSaved, setReferralSaved] = useState(false) const [seoForm, setSeoForm] = useState({ maintenanceMode: false, indexingEnabled: true, headScripts: '', siteTitleEl: '', siteTitleEn: '', siteTitleRu: '', siteTitleUk: '', siteDescriptionEl: '', siteDescriptionEn: '', siteDescriptionRu: '', siteDescriptionUk: '' }) const [seoSaving, setSeoSaving] = useState(false) const [seoSaved, setSeoSaved] = useState(false) const [seoLangTab, setSeoLangTab] = useState<'el' | 'en' | 'ru' | 'uk'>('el') const [socialForm, setSocialForm] = useState({ telegram: '', instagram: '', facebook: '', twitter: '', youtube: '', tiktok: '', whatsapp: '', viber: '' }) const [socialSaving, setSocialSaving] = useState(false) const [socialSaved, setSocialSaved] = useState(false) const [telegramForm, setTelegramForm] = useState({ botToken: '', adminChatId: '', channelIdEl: '', channelIdEn: '', channelIdRu: '', channelIdUk: '', notifyRegistrations: false, postNewTasks: false }) const [telegramSaving, setTelegramSaving] = useState(false) const [telegramSaved, setTelegramSaved] = useState(false) const [testingTelegramAdmin, setTestingTelegramAdmin] = useState(false) const [testingTelegramChannel, setTestingTelegramChannel] = useState(false) const [testResultTelegramAdmin, setTestResultTelegramAdmin] = useState<{ ok: boolean; msg: string } | null>(null) const [testResultTelegramChannel, setTestResultTelegramChannel] = useState<{ ok: boolean; msg: string } | null>(null) const [testingLocale, setTestingLocale] = useState(null) const [testResultChannels, setTestResultChannels] = useState>>({}) const [verifyingTelegramToken, setVerifyingTelegramToken] = useState(false) const [verifyResultTelegramToken, setVerifyResultTelegramToken] = useState<{ ok: boolean; msg: string } | null>(null) // Backup const [backupAutoEnabled, setBackupAutoEnabled] = useState(false) const [backupAutoSaving, setBackupAutoSaving] = useState(false) const [backupTables, setBackupTables] = useState([]) const [backupTablesLoading, setBackupTablesLoading] = useState(false) const [backupStoredFiles, setBackupStoredFiles] = useState<{ name: string; size: number; date: string }[]>([]) const [backupExporting, setBackupExporting] = useState(false) const [backupImporting, setBackupImporting] = useState(null) const [backupRestoring, setBackupRestoring] = useState(null) const [backupMsg, setBackupMsg] = useState(null) const [backupMsgError, setBackupMsgError] = useState(false) const setBackupSuccess = (msg: string) => { setBackupMsg(msg); setBackupMsgError(false) } const setBackupError = (msg: string) => { setBackupMsg(msg); setBackupMsgError(true) } // ─── Translations state ─────────────────────────────────────────────────── const [transStats, setTransStats] = useState(null) const [transStatsLoading, setTransStatsLoading] = useState(false) const [transLang, setTransLang] = useState('uk') const [transFromLang, setTransFromLang] = useState('el') const [transTableTranslating, setTransTableTranslating] = useState>({}) const [transMsg, setTransMsg] = useState<{ text: string; error?: boolean } | null>(null) const [transPreview, setTransPreview] = useState<{ lang: string loading: boolean rows: Array<{ id: string; title: string; originalLocale: string; titleEl: string | null; titleEn: string | null; titleRu: string | null; titleUk: string | null }> } | null>(null) // ─── Referrals state ───────────────────────────────────────────────────── const [referralsData, setReferralsData] = useState(null) const [referralsLoading, setReferralsLoading] = useState(false) const [referralsExpanded, setReferralsExpanded] = useState>(new Set()) // ─── Push state ────────────────────────────────────────────────────────── const [pushMode, setPushMode] = useState<'broadcast' | 'user'>('broadcast') const [pushTargetUserId, setPushTargetUserId] = useState('') const [pushTitle, setPushTitle] = useState('') const [pushBody, setPushBody] = useState('') const [pushType, setPushType] = useState('general') const [pushReferenceId, setPushReferenceId] = useState('') const [pushOnlyActive, setPushOnlyActive] = useState(true) const [pushSending, setPushSending] = useState(false) const [pushResult, setPushResult] = useState(null) const [pushError, setPushError] = useState(null) const [loading, setLoading] = useState(false) const isAdmin = !isPending && session && (session.user as any)?.role === 'admin' useEffect(() => { if (!isPending && !session) router.push('/login') if (!isPending && session && (session.user as any)?.role !== 'admin') router.push('/') }, [session, isPending]) useEffect(() => { if (!isAdmin) return api.getAdminStats().then(setStats).catch(() => {}) api.getAdminNotifStats().then(setNotifsByDay).catch(() => {}) api.getAdminMailStats().then(setMailByDay).catch(() => {}) api.getAdminReports(1, 'pending').then((d) => setPendingReportsCount(d.total)).catch(() => {}) api.getAdminSpecialistCards(1, 'pending').then((d) => setPendingCardsCount(d.total)).catch(() => {}) }, [isAdmin]) useEffect(() => { if (!isAdmin || tab !== 'users') return setLoading(true) api.getAdminUsers(usersPage, usersQ || undefined, usersRole || undefined) .then((d) => { setUsers(d.data); setUsersTotal(d.total) }) .catch(() => {}) .finally(() => setLoading(false)) }, [isAdmin, tab, usersPage, usersQ, usersRole]) useEffect(() => { if (!isAdmin || tab !== 'tasks') return setLoading(true) api.getAdminTasks(tasksPage, tasksQ || undefined, tasksStatus || undefined) .then((d) => { setTasks(d.data); setTasksTotal(d.total) }) .catch(() => {}) .finally(() => setLoading(false)) }, [isAdmin, tab, tasksPage, tasksQ, tasksStatus]) useEffect(() => { if (!isAdmin || tab !== 'reports') return setLoading(true) api.getAdminReports(reportsPage) .then((d) => { setReports(d.data); setReportsTotal(d.total) }) .catch(() => {}) .finally(() => setLoading(false)) }, [isAdmin, tab, reportsPage]) useEffect(() => { if (!isAdmin || tab !== 'moderation') return setModerationCardsLoading(true) Promise.all([ api.getAdminSpecialistCards(moderationCardsPage, 'review'), catRows.length === 0 ? api.getAdminCategories() : Promise.resolve(catRows), ]) .then(([data, categoryRows]) => { setModerationCards(data.data) setModerationCardsTotal(data.total) if (catRows.length === 0) setCatRows(categoryRows) }) .catch(() => {}) .finally(() => setModerationCardsLoading(false)) }, [isAdmin, tab, moderationCardsPage]) useEffect(() => { if (!isAdmin || tab !== 'support') return setLoading(true) const params: Record = { page: String(supportPage), limit: '30' } if (supportStatus) params.status = supportStatus if (supportType) params.type = supportType if (supportQ) params.q = supportQ api.getAdminSupportTickets(params) .then((d) => { setSupportTickets(d.data); setSupportTotal(d.total) }) .catch(() => {}) .finally(() => setLoading(false)) }, [isAdmin, tab, supportPage, supportStatus, supportType, supportQ]) useEffect(() => { if (!isAdmin || tab !== 'balances') return setLoading(true) api.getAdminBalanceHistory({ page: balancePage, userId: balanceUserId || undefined, kind: balanceKind || undefined }) .then((d) => { setBalanceRows(d.data) setBalanceTotal(d.total) }) .catch(() => {}) .finally(() => setLoading(false)) }, [isAdmin, tab, balancePage, balanceUserId, balanceKind]) useEffect(() => { if (!isAdmin || tab !== 'logs') return if (logsSubTab === 'ai') { setLoading(true) api.getAdminAiLogs(logsPage) .then((d) => { setLogs(d.data); setLogsTotal(d.total) }) .catch(() => {}) .finally(() => setLoading(false)) } else if (logsSubTab === 'activity') { api.getAdminActivityLogs({ page: activityPage, event: activityEvent || undefined, userId: activityUserId || undefined, dateFrom: activityDateFrom || undefined, dateTo: activityDateTo || undefined }) .then((d) => { setActivityLog(d.data) setActivityTotal(d.total) if (d.eventTypes?.length) setActivityEventTypes(d.eventTypes) }) .catch(() => {}) } else if (logsSubTab === 'mail') { api.getAdminMailLogs({ page: mailPage, event: mailEvent || undefined, dateFrom: mailDateFrom || undefined, dateTo: mailDateTo || undefined }) .then((d) => { setMailLog(d.data) setMailTotal(d.total) if (d.eventTypes?.length) setMailEventTypes(d.eventTypes) }) .catch(() => {}) } else if (logsSubTab === 'notifications') { api.getAdminNotifLogs({ page: notifLogsPage, type: notifLogsType || undefined, dateFrom: notifLogsDateFrom || undefined, dateTo: notifLogsDateTo || undefined }) .then((d) => { setNotifLogs(d.data) setNotifLogsTotal(d.total) if (d.notifTypes?.length) setNotifLogsTypes(d.notifTypes) }) .catch(() => {}) } }, [isAdmin, tab, logsSubTab, logsPage, activityPage, activityEvent, activityUserId, activityDateFrom, activityDateTo, mailPage, mailEvent, mailDateFrom, mailDateTo, notifLogsPage, notifLogsType, notifLogsDateFrom, notifLogsDateTo]) useEffect(() => { setActivityPage(1) }, [activityEvent, activityUserId, activityDateFrom, activityDateTo]) useEffect(() => { setMailPage(1) }, [mailEvent, mailDateFrom, mailDateTo]) useEffect(() => { setNotifLogsPage(1) }, [notifLogsType, notifLogsDateFrom, notifLogsDateTo]) useEffect(() => { if (!isAdmin || tab !== 'plans') return api.getAdminPlans().then(setPlanRows).catch(() => {}) }, [isAdmin, tab]) useEffect(() => { if (!isAdmin || tab !== 'translations') return setTransStatsLoading(true) api.getTranslationsStats() .then((d) => setTransStats(d.tables)) .catch(() => {}) .finally(() => setTransStatsLoading(false)) }, [isAdmin, tab]) useEffect(() => { if (!isAdmin || tab !== 'backup') return request('/admin/backup/settings').then((d) => setBackupAutoEnabled(!!d.autoEnabled)).catch(() => {}) setBackupTablesLoading(true) request('/admin/backup/tables').then((d) => setBackupTables(d.tables || [])).catch(() => {}).finally(() => setBackupTablesLoading(false)) request('/admin/backup/list').then((d) => setBackupStoredFiles(d.files || [])).catch(() => {}) }, [isAdmin, tab]) useEffect(() => { if (!isAdmin || tab !== 'referrals') return setReferralsLoading(true) api.getAdminReferrals() .then((d) => setReferralsData(d)) .catch(() => {}) .finally(() => setReferralsLoading(false)) }, [isAdmin, tab]) // Load plans when users tab opens (for plan selector in edit modal) useEffect(() => { if (!isAdmin || tab !== 'users') return if (planRows.length === 0) api.getAdminPlans().then(setPlanRows).catch(() => {}) }, [isAdmin, tab]) // Reset page when filters change useEffect(() => { setUsersPage(1) }, [usersQ, usersRole]) useEffect(() => { setTasksPage(1) }, [tasksQ, tasksStatus]) useEffect(() => { setBalancePage(1) }, [balanceUserId, balanceKind]) async function sendAdminPush() { if (!pushTitle.trim()) return if (pushMode === 'user' && !pushTargetUserId.trim()) { setPushError('Укажите ID пользователя') return } setPushSending(true) setPushError(null) setPushResult(null) try { const payload: api.AdminPushPayload = { title: pushTitle.trim(), body: pushBody.trim() || undefined, type: pushType.trim() || undefined, referenceId: pushReferenceId.trim() || undefined, } if (pushMode === 'broadcast') { const res = await api.adminBroadcastPush({ ...payload, onlyActive: pushOnlyActive, }) setPushResult(res) } else { const res = await api.adminSendPushToUser(pushTargetUserId.trim(), payload) setPushResult(res) } } catch (err: any) { setPushError(err?.message || 'Не удалось отправить push') } finally { setPushSending(false) } } async function toggleUser(id: string, isActive: boolean) { const fn = isActive ? api.deactivateUser : api.activateUser const updated = await fn(id).catch(() => null) if (updated) setUsers((prev) => prev.map((u) => u.id === id ? updated : u)) } async function changeRole(id: string, role: string) { const updated = await api.changeUserRole(id, role).catch(() => null) if (updated) setUsers((prev) => prev.map((u) => u.id === id ? updated : u)) } function openEditUser(u: any) { setEditUserTab('profile') setSkillInput('') setBalanceModal(null) setEditUserCards([]) setCardSkillInputs({}) setCardSaving({}) setCardCatSelects({}) setCardLocSelects({}) setEditUser({ ...u, topUpAmount: '', topUpDescription: '', withdrawAmount: '', withdrawDescription: '' }) api.getAdminUser(u.id) .then((fresh) => setEditUser((prev: any) => prev && prev.id === fresh.id ? { ...fresh, topUpAmount: '', topUpDescription: '', withdrawAmount: '', withdrawDescription: '' } : prev )) .catch(() => {}) } function describeFcmFailure(key?: string | null, message?: string | null) { if (!key) return message || 'FCM принял 0 сообщений' if (key === 'THIRD_PARTY_AUTH_ERROR') { return 'Ошибка APNs/Firebase iOS: проверьте Apple APNs Auth Key (.p8) и связку Firebase проекта для iOS-приложения' } if (key === 'UNREGISTERED') { return 'Токен устройства больше не валиден' } if (key === 'INVALID_ARGUMENT') { return 'Некорректный токен или payload' } if (key === 'REQUEST_FAILED') { return message || 'FCM request failed before response' } return message ? `${key}: ${message}` : key } async function saveEditUser() { if (!editUser) return setEditUserSaving(true) try { const updated = await api.updateAdminUser(editUser.id, { firstName: editUser.firstName, lastName: editUser.lastName, phone: editUser.phone || null, bio: editUser.bio || null, role: editUser.role, planId: editUser.planId || null, isActive: editUser.isActive, locale: editUser.locale, languages: editUser.languages ?? [], personalSiteSlug: editUser.personalSiteSlug || null, showContactInfo: editUser.showContactInfo ?? false, notifyNewTasks: editUser.notifyNewTasks ?? false, notifMessages: editUser.notifMessages ?? true, skills: editUser.skills ?? [], siteSettings: editUser.siteSettings ?? {}, }) await Promise.all( editUserCards.map((card: any) => api.adminUpdateSpecialistCard(card.id, { title: card.title, description: card.description || null, skills: card.skills ?? [], categories: card.categories ?? [], locations: card.locations ?? [], publicationStatus: card.publicationStatus, isActive: card.isActive, }).catch(() => null)), ) setUsers((prev) => prev.map((u) => u.id === editUser.id ? updated : u)) setEditUser(null) } catch {} finally { setEditUserSaving(false) } } async function verifyEmail() { if (!editUser) return setVerifyingEmail(true) try { const updated = await api.verifyAdminUserEmail(editUser.id) setEditUser((u: any) => u ? { ...u, emailVerified: updated.emailVerified } : u) setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, emailVerified: updated.emailVerified } : u)) } catch {} finally { setVerifyingEmail(false) } } async function handleAdminTopUp() { if (!editUser) return const amount = Number(editUser.topUpAmount) if (!amount || amount <= 0) return setTopUpSaving(true) try { const result = await api.adminTopUpUserBalance(editUser.id, { amount, description: editUser.topUpDescription || undefined, }) setUsers((prev) => prev.map((u) => u.id === editUser.id ? result.user : u)) setEditUser((prev: any) => prev ? ({ ...prev, ...result.user, topUpAmount: '', topUpDescription: '', }) : prev) if (tab === 'balances') { api.getAdminBalanceHistory({ page: balancePage, userId: balanceUserId || undefined, kind: balanceKind || undefined }) .then((d) => { setBalanceRows(d.data) setBalanceTotal(d.total) }) .catch(() => {}) } } catch { // ignore } finally { setTopUpSaving(false) } } async function handleAdminWithdraw() { if (!editUser) return const amount = Number(editUser.withdrawAmount) if (!amount || amount <= 0) return setWithdrawSaving(true) try { const result = await api.adminWithdrawUserBalance(editUser.id, { amount, description: editUser.withdrawDescription || undefined, }) setUsers((prev) => prev.map((u) => u.id === editUser.id ? result.user : u)) setEditUser((prev: any) => prev ? ({ ...prev, ...result.user, withdrawAmount: '', withdrawDescription: '', }) : prev) if (tab === 'balances') { api.getAdminBalanceHistory({ page: balancePage, userId: balanceUserId || undefined, kind: balanceKind || undefined }) .then((d) => { setBalanceRows(d.data) setBalanceTotal(d.total) }) .catch(() => {}) } } catch { // ignore } finally { setWithdrawSaving(false) } } async function saveCard(cardId: string) { const card = editUserCards.find((c: any) => c.id === cardId) if (!card) return setCardSaving((prev) => ({ ...prev, [cardId]: true })) try { const updated = await api.adminUpdateSpecialistCard(cardId, { title: card.title, description: card.description || null, skills: card.skills ?? [], categories: card.categories ?? [], locations: card.locations ?? [], publicationStatus: card.publicationStatus, isActive: card.isActive, }) setEditUserCards((prev) => prev.map((c: any) => c.id === cardId ? updated : c)) } catch {} finally { setCardSaving((prev) => ({ ...prev, [cardId]: false })) } } async function updateModerationCard(cardId: string, publicationStatus: 'pending' | 'active' | 'inactive') { const updated = await api.adminUpdateSpecialistCard(cardId, { publicationStatus, isActive: publicationStatus === 'active', }).catch(() => null) if (!updated) return setModerationCards((prev) => prev.map((card) => card.id === cardId ? updated : card)) if (editUser?.id === updated.specialistId) { setEditUserCards((prev) => prev.map((card) => card.id === cardId ? updated : card)) } api.getAdminReports(1, 'pending').then((d) => setPendingReportsCount(d.total)).catch(() => {}) api.getAdminSpecialistCards(1, 'pending').then((d) => setPendingCardsCount(d.total)).catch(() => {}) } async function resolveCategorySuggestion( cardId: string, suggestion: any, action: 'map' | 'create' | 'reject', ) { const categoryId = categorySuggestionTargets[suggestion.id] const slug = categorySuggestionSlugs[suggestion.id] if (action === 'map' && !categoryId) return if (action === 'create' && !slug?.trim()) return setResolvingCategorySuggestion(suggestion.id) const result = await api.resolveAdminCategorySuggestion(cardId, suggestion.id, { action, ...(action === 'map' ? { categoryId } : {}), ...(action === 'create' ? { slug: slug.trim() } : {}), }).catch(() => null) if (result) { setModerationCards((prev) => prev.map((card) => card.id === cardId ? { ...card, categorySuggestions: (card.categorySuggestions ?? []).filter((item: any) => item.id !== suggestion.id), ...(action !== 'reject' ? { categories: [...new Set([...(card.categories ?? []), action === 'map' ? catRows.find((row) => row.id === categoryId)?.slug : slug.trim()])] } : {}), } : card)) } setResolvingCategorySuggestion(null) } async function cancelTask(id: string) { const updated = await api.cancelTaskAdmin(id).catch(() => null) if (updated) { const task = updated.task || updated setTasks((prev) => prev.map((row) => { const t = row.task || row return t.id === id ? { ...row, task: task } : row })) } } async function deleteTask(id: string, title: string) { if (!window.confirm(`${t('admin.tasks.delete.confirm')}\n"${title}"`)) return const ok = await api.deleteTaskAdmin(id).catch(() => null) if (ok) setTasks((prev) => prev.filter((row) => (row.task?.id || row.id) !== id)) } async function handleReport(id: string, status: 'reviewed' | 'dismissed') { const updated = await api.updateReportStatus(id, status).catch(() => null) if (updated) setReports((prev) => prev.map((r) => r.id === id ? { ...r, ...updated } : r)) } // Locations useEffect(() => { if (!isAdmin || tab !== 'locations') return api.getAdminLocations().then(setLocRows).catch(() => {}) }, [isAdmin, tab]) function openAddLoc(parentId?: string | null) { setLocForm({ parentId: parentId ?? null, slug: '', nameEl: '', nameEn: '', nameRu: '', nameUk: '', order: 0, isActive: true }) } function openEditLoc(loc: any) { setLocForm({ id: loc.id, parentId: loc.parentId ?? null, slug: loc.slug, nameEl: loc.nameEl ?? loc.names?.el ?? '', nameEn: loc.nameEn ?? loc.names?.en ?? '', nameRu: loc.nameRu ?? loc.names?.ru ?? '', nameUk: loc.nameUk ?? loc.names?.uk ?? '', order: loc.order ?? 0, isActive: loc.isActive ?? true }) } async function saveLoc() { if (!locForm) return setLocSaving(true) try { if (locForm.id) { const updated = await api.updateLocation(locForm.id, locForm) setLocRows((prev) => prev.map((r) => r.id === locForm.id ? updated : r)) } else { const created = await api.createLocation({ slug: locForm.slug, nameEl: locForm.nameEl, nameEn: locForm.nameEn, nameRu: locForm.nameRu, nameUk: locForm.nameUk, parentId: locForm.parentId, order: locForm.order }) setLocRows((prev) => [...prev, created]) } setLocForm(null) } catch { // ignore } finally { setLocSaving(false) } } async function deleteLoc(id: string, name: string) { if (!window.confirm(`${t('admin.loc.delete.confirm')}\n"${name}"`)) return const ok = await api.deleteLocation(id).catch(() => null) if (ok) setLocRows((prev) => prev.filter((r) => r.id !== id)) } function toggleLocExpand(id: string) { setLocExpanded((prev) => { const next = new Set(prev) next.has(id) ? next.delete(id) : next.add(id) return next }) } // Categories useEffect(() => { if (!isAdmin || tab !== 'categories') return api.getAdminCategories().then(setCatRows).catch(() => {}) }, [isAdmin, tab]) useEffect(() => { if (!isAdmin) return fetch('/category-icons/presets.json') .then((res) => res.ok ? res.json() : Promise.reject(new Error('Failed to load presets'))) .then((data: unknown) => { if (!Array.isArray(data)) return const cleaned = data .filter((x): x is CategoryIconPreset => !!x && typeof x === 'object' && typeof (x as any).value === 'string' && typeof (x as any).label === 'string') .filter((x) => x.value.startsWith('/category-icons/')) if (cleaned.length > 0) setCategoryIconPresets(cleaned) }) .catch(() => {}) }, [isAdmin]) function openAddCat(parentId?: string | null) { setCatIconError(null) setCatForm({ parentId: parentId ?? null, slug: '', icon: '/category-icons/package.svg', namesEl: '', namesEn: '', namesRu: '', namesUk: '', order: 0, isActive: true }) } function openEditCat(cat: any) { const selectedIcon = typeof cat.icon === 'string' ? cat.icon : '' const knownPreset = new Set(categoryIconPresets.map((p) => p.value)) const normalizedIcon = selectedIcon.startsWith('/category-icons/') && !knownPreset.has(selectedIcon) ? '/category-icons/package.svg' : (selectedIcon || '/category-icons/package.svg') setCatIconError(null) setCatForm({ id: cat.id, parentId: cat.parentId ?? null, slug: cat.slug, icon: normalizedIcon, namesEl: cat.namesEl ?? cat.names?.el ?? '', namesEn: cat.namesEn ?? cat.names?.en ?? '', namesRu: cat.namesRu ?? cat.names?.ru ?? '', namesUk: cat.namesUk ?? cat.names?.uk ?? '', order: cat.order ?? 0, isActive: cat.isActive ?? true }) } async function uploadCategoryIcon(file: File) { if (file.type !== 'image/svg+xml') { setCatIconError('Only SVG files are allowed') return } setCatIconUploading(true) setCatIconError(null) try { const fd = new FormData() fd.append('file', file) const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' const res = await fetch(`${base}/api/uploads`, { method: 'POST', credentials: 'include', body: fd, }) if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Upload failed' })) throw new Error(err.error || 'Upload failed') } const data = await res.json() as { url: string } setCatForm((f) => (f ? { ...f, icon: data.url } : f)) } catch (err: any) { setCatIconError(err?.message || 'Upload failed') } finally { setCatIconUploading(false) } } async function saveCat() { if (!catForm) return setCatSaving(true) try { if (catForm.id) { const updated = await api.updateCategory(catForm.id, catForm) setCatRows((prev) => prev.map((r) => r.id === catForm.id ? updated : r)) } else { const created = await api.createCategory({ slug: catForm.slug, icon: catForm.icon, namesEl: catForm.namesEl, namesEn: catForm.namesEn, namesRu: catForm.namesRu, namesUk: catForm.namesUk, parentId: catForm.parentId, order: catForm.order }) setCatRows((prev) => [...prev, created]) } setCatForm(null) } catch { // ignore } finally { setCatSaving(false) } } async function deleteCat(id: string, name: string) { if (!window.confirm(`${t('admin.cat.delete.confirm')}\n"${name}"`)) return const ok = await api.deleteCategory(id).catch(() => null) if (ok) setCatRows((prev) => prev.filter((r) => r.id !== id)) } function toggleCatExpand(id: string) { setCatExpanded((prev) => { const next = new Set(prev) next.has(id) ? next.delete(id) : next.add(id) return next }) } async function toggleCatSkills(cat: any) { const id = cat.id const slug = cat.slug setCatSkillsExpanded((prev) => { const next = new Set(prev) if (next.has(id)) { next.delete(id); return next } next.add(id) return next }) if (!catSkillsCache[slug]) { setCatSkillsLoading((prev) => new Set(prev).add(id)) try { const skills = await api.getAdminSkills(slug) setCatSkillsCache((prev) => ({ ...prev, [slug]: skills })) } catch { /* ignore */ } finally { setCatSkillsLoading((prev) => { const n = new Set(prev); n.delete(id); return n }) } } } function openAddSkill(catSlug: string) { setSkillForm({ catSlug, nameEl: '', nameEn: '', nameRu: '', nameUk: '', order: 0 }) } function openEditSkill(skill: any, catSlug: string) { setSkillForm({ id: skill.id, catSlug, nameEl: skill.nameEl ?? '', nameEn: skill.nameEn ?? '', nameRu: skill.nameRu ?? '', nameUk: skill.nameUk ?? '', order: skill.order ?? 0 }) } async function saveSkill() { if (!skillForm) return setSkillSaving(true) try { if (skillForm.id) { const updated = await api.updateSkill(skillForm.id, { nameEl: skillForm.nameEl, nameEn: skillForm.nameEn, nameRu: skillForm.nameRu, nameUk: skillForm.nameUk, order: skillForm.order }) setCatSkillsCache((prev) => ({ ...prev, [skillForm.catSlug]: (prev[skillForm.catSlug] ?? []).map((s) => s.id === skillForm.id ? updated : s), })) } else { const created = await api.createSkill({ categorySlug: skillForm.catSlug, nameEl: skillForm.nameEl, nameEn: skillForm.nameEn, nameRu: skillForm.nameRu, nameUk: skillForm.nameUk, order: skillForm.order }) setCatSkillsCache((prev) => ({ ...prev, [skillForm.catSlug]: [...(prev[skillForm.catSlug] ?? []), created] })) } setSkillForm(null) } catch { /* ignore */ } finally { setSkillSaving(false) } } async function deleteSkill(skill: any, catSlug: string) { if (!skill._skipConfirm && !window.confirm(`Удалить навык "${skill.nameEl}"?`)) return const ok = skill._skipConfirm ? true : await api.deleteSkill(skill.id).catch(() => null) if (ok) { setCatSkillsCache((prev) => ({ ...prev, [catSlug]: (prev[catSlug] ?? []).filter((s) => s.id !== skill.id) })) } } function openAddPlan() { setPlanForm({ name: '', description: '', role: 'customer', tier: 'free', price: '0', oldPrice: '', currency: 'EUR', maxTasks: '', maxOffers: '', maxCards: '', maxMessagesPerDay: '', maxOrdersPerDay: '', maxSkills: '', durationDays: '', searchBoost: 0, offersMultiplier: '1.00', features: '', isDefault: false, isActive: true, order: 0, notifyNewTasks: false, canContactFreePlan: false, canContactProPlan: true, canContactAll: false, canShowContactInfo: false, canViewPhone: false, canUploadVideo: false, hasFavorites: false, hasGoogleCalendar: false, hasVerifiedBadge: false, highlightedReviews: false, hasPersonalSite: false, hasCanHelpNowStatus: false, hasNeedHelpStatus: false, hasAutoResponse: false, hasAutoMatch: false, hasPriceList: false, receiveInstantDispatchFree: false, receiveInstantDispatchPro: false, }) } function openEditPlan(plan: any) { setPlanForm({ id: plan.id, name: plan.name, description: plan.description ?? '', role: plan.role ?? 'customer', tier: plan.tier ?? 'free', price: plan.price ?? '0', oldPrice: plan.oldPrice ?? '', currency: plan.currency ?? 'EUR', maxTasks: plan.maxTasks != null ? String(plan.maxTasks) : '', maxOffers: plan.maxOffers != null ? String(plan.maxOffers) : '', maxCards: plan.maxCards != null ? String(plan.maxCards) : '', maxMessagesPerDay: plan.maxMessagesPerDay != null ? String(plan.maxMessagesPerDay) : '', maxOrdersPerDay: plan.maxOrdersPerDay != null ? String(plan.maxOrdersPerDay) : '', maxSkills: plan.maxSkills != null ? String(plan.maxSkills) : '', durationDays: plan.durationDays != null ? String(plan.durationDays) : '', searchBoost: plan.searchBoost ?? 0, offersMultiplier: plan.offersMultiplier ?? '1.00', features: (plan.features ?? []).join('\n'), isDefault: plan.isDefault ?? false, isActive: plan.isActive ?? true, order: plan.order ?? 0, notifyNewTasks: plan.notifyNewTasks ?? false, canContactFreePlan: plan.canContactFreePlan ?? false, canContactProPlan: plan.canContactProPlan ?? true, canContactAll: plan.canContactAll ?? false, canShowContactInfo: plan.canShowContactInfo ?? false, canViewPhone: plan.canViewPhone ?? false, canUploadVideo: plan.canUploadVideo ?? false, hasFavorites: plan.hasFavorites ?? false, hasGoogleCalendar: plan.hasGoogleCalendar ?? false, hasVerifiedBadge: plan.hasVerifiedBadge ?? false, highlightedReviews: plan.highlightedReviews ?? false, hasPersonalSite: plan.hasPersonalSite ?? false, hasCanHelpNowStatus: plan.hasCanHelpNowStatus ?? false, hasNeedHelpStatus: plan.hasNeedHelpStatus ?? false, hasAutoResponse: plan.hasAutoResponse ?? false, hasAutoMatch: plan.hasAutoMatch ?? false, hasPriceList: plan.hasPriceList ?? false, receiveInstantDispatchFree: plan.receiveInstantDispatchFree ?? false, receiveInstantDispatchPro: plan.receiveInstantDispatchPro ?? false, }) } async function savePlan() { if (!planForm) return setPlanSaving(true) try { const payload = { name: planForm.name, description: planForm.description || undefined, role: planForm.role, tier: planForm.tier, price: planForm.price, oldPrice: planForm.oldPrice || null, currency: planForm.currency, maxTasks: planForm.maxTasks ? Number(planForm.maxTasks) : null, maxOffers: planForm.maxOffers ? Number(planForm.maxOffers) : null, maxCards: planForm.maxCards ? Number(planForm.maxCards) : null, maxMessagesPerDay: planForm.maxMessagesPerDay ? Number(planForm.maxMessagesPerDay) : null, maxOrdersPerDay: planForm.maxOrdersPerDay ? Number(planForm.maxOrdersPerDay) : null, maxSkills: planForm.maxSkills ? Number(planForm.maxSkills) : null, durationDays: planForm.durationDays ? Number(planForm.durationDays) : null, notifyNewTasks: planForm.notifyNewTasks, searchBoost: planForm.searchBoost, offersMultiplier: planForm.offersMultiplier, canContactFreePlan: planForm.canContactFreePlan, canContactProPlan: planForm.canContactProPlan, canContactAll: planForm.canContactAll, canShowContactInfo: planForm.canShowContactInfo, canViewPhone: planForm.canViewPhone, canUploadVideo: planForm.canUploadVideo, hasFavorites: planForm.hasFavorites, hasGoogleCalendar: planForm.hasGoogleCalendar, hasVerifiedBadge: planForm.hasVerifiedBadge, highlightedReviews: planForm.highlightedReviews, hasPersonalSite: planForm.hasPersonalSite, hasCanHelpNowStatus: planForm.hasCanHelpNowStatus, hasNeedHelpStatus: planForm.hasNeedHelpStatus, hasAutoResponse: planForm.hasAutoResponse, hasAutoMatch: planForm.hasAutoMatch, hasPriceList: planForm.hasPriceList, receiveInstantDispatchFree: planForm.receiveInstantDispatchFree, receiveInstantDispatchPro: planForm.receiveInstantDispatchPro, features: planForm.features ? planForm.features.split('\n').map((s) => s.trim()).filter(Boolean) : [], isDefault: planForm.isDefault, isActive: planForm.isActive, order: planForm.order, } if (planForm.id) { const updated = await api.updatePlan(planForm.id, payload) setPlanRows((prev) => prev.map((r) => r.id === planForm.id ? updated : r)) } else { const created = await api.createPlan(payload) setPlanRows((prev) => [...prev, created]) } // If set as default — refresh all to sync isDefault flags if (planForm.isDefault) { api.getAdminPlans().then(setPlanRows).catch(() => {}) } setPlanForm(null) } catch { // ignore } finally { setPlanSaving(false) } } async function deletePlan(id: string, name: string) { if (!window.confirm(`Delete plan "${name}"?`)) return const ok = await api.deletePlan(id).catch(() => null) if (ok) setPlanRows((prev) => prev.filter((r) => r.id !== id)) } // Settings useEffect(() => { if (!isAdmin || tab !== 'settings') return if (planRows.length === 0) api.getAdminPlans().then(setPlanRows).catch(() => {}) api.getAdminSettings().then((kv) => { setGeneralForm({ siteName: kv['general.siteName'] ?? 'CanHelp', siteUrl: kv['general.siteUrl'] ?? '', contactEmail: kv['general.contactEmail'] ?? '', supportPhone: kv['general.supportPhone'] ?? '', showPricing: kv['general.showPricing'] !== 'false', }) setSmtpForm({ host: kv['smtp.host'] ?? '', port: kv['smtp.port'] ?? '587', username: kv['smtp.username'] ?? '', password: kv['smtp.password'] ?? '', fromName: kv['smtp.fromName'] ?? 'CanHelp', fromEmail: kv['smtp.fromEmail'] ?? '', encryption: (kv['smtp.encryption'] as any) ?? 'tls', }) setEPayForm({ merchantId: kv['payment.epay.merchantId'] ?? '', apiUser: kv['payment.epay.apiUser'] ?? '', apiPassword: kv['payment.epay.apiPassword'] ?? '', environment: (kv['payment.epay.environment'] as any) ?? 'test', confirmationUrl: kv['payment.epay.confirmationUrl'] ?? '', cancelUrl: kv['payment.epay.cancelUrl'] ?? '', enabled: kv['payment.epay.enabled'] === 'true', }) setReferralForm({ enabled: kv['referral_enabled'] === 'true', planId: kv['referral_plan_id'] ?? kv['referral_customer_plan_id'] ?? kv['referral_specialist_plan_id'] ?? '', days: kv['referral_days'] ?? kv['referral_customer_days'] ?? kv['referral_specialist_days'] ?? '30', }) setSeoForm({ maintenanceMode: kv['seo.maintenanceMode'] === 'true', indexingEnabled: kv['seo.indexingEnabled'] !== 'false', headScripts: kv['seo.headScripts'] ?? '', siteTitleEl: kv['seo.siteTitle.el'] ?? kv['seo.siteTitle'] ?? '', siteTitleEn: kv['seo.siteTitle.en'] ?? '', siteTitleRu: kv['seo.siteTitle.ru'] ?? '', siteTitleUk: kv['seo.siteTitle.uk'] ?? '', siteDescriptionEl: kv['seo.siteDescription.el'] ?? kv['seo.siteDescription'] ?? '', siteDescriptionEn: kv['seo.siteDescription.en'] ?? '', siteDescriptionRu: kv['seo.siteDescription.ru'] ?? '', siteDescriptionUk: kv['seo.siteDescription.uk'] ?? '', }) setSocialForm({ telegram: kv['social.telegram'] ?? '', instagram: kv['social.instagram'] ?? '', facebook: kv['social.facebook'] ?? '', twitter: kv['social.twitter'] ?? '', youtube: kv['social.youtube'] ?? '', tiktok: kv['social.tiktok'] ?? '', whatsapp: kv['social.whatsapp'] ?? '', viber: kv['social.viber'] ?? '', }) setTelegramForm({ botToken: kv['telegram.botToken'] ?? '', adminChatId: kv['telegram.adminChatId'] ?? '', channelIdEl: kv['telegram.channelId.el'] ?? '', channelIdEn: kv['telegram.channelId.en'] ?? '', channelIdRu: kv['telegram.channelId.ru'] ?? '', channelIdUk: kv['telegram.channelId.uk'] ?? '', notifyRegistrations: kv['telegram.notifyRegistrations'] === 'true', postNewTasks: kv['telegram.postNewTasks'] === 'true', }) }).catch(() => {}) api.getAdminEmailTemplates().then((rows) => { const map: Record = {} for (const r of rows) map[r.key] = { subject: r.subject, body: r.body } setNotifTemplates((prev) => ({ ...prev, ...map })) }).catch(() => {}) }, [isAdmin, tab]) async function saveGeneralSettings() { setGeneralSaving(true) try { await api.updateAdminSettings({ 'general.siteName': generalForm.siteName, 'general.siteUrl': generalForm.siteUrl, 'general.contactEmail': generalForm.contactEmail, 'general.supportPhone': generalForm.supportPhone, 'general.showPricing': String(generalForm.showPricing), }) setGeneralSaved(true) setTimeout(() => setGeneralSaved(false), 2500) } catch { /* ignore */ } finally { setGeneralSaving(false) } } async function saveSmtpSettings() { setSmtpSaving(true) try { await api.updateAdminSettings({ 'smtp.host': smtpForm.host, 'smtp.port': smtpForm.port, 'smtp.username': smtpForm.username, 'smtp.password': smtpForm.password, 'smtp.fromName': smtpForm.fromName, 'smtp.fromEmail': smtpForm.fromEmail, 'smtp.encryption': smtpForm.encryption, }) setSmtpSaved(true) setTimeout(() => setSmtpSaved(false), 2500) } catch { /* ignore */ } finally { setSmtpSaving(false) } } async function autoTranslateTemplate(key: string) { const localeKey = `${key}_${templateLocale}` const current = notifTemplates[localeKey] ?? notifTemplates[key] ?? { subject: '', body: '' } if (!current.subject && !current.body) return setTemplateTranslating(true) try { // Сохраняем исходный язык в БД (если ещё не сохранён) await api.updateAdminEmailTemplate(localeKey, current) // Переводим на остальные языки const results = await api.translateAdminEmailTemplate({ subject: current.subject, body: current.body, fromLocale: templateLocale }) // Обновляем state setNotifTemplates((prev) => { const next = { ...prev, [localeKey]: current } for (const [loc, tpl] of Object.entries(results)) { next[`${key}_${loc}`] = tpl as { subject: string; body: string } } return next }) // Сохраняем каждый перевод в БД await Promise.all( Object.entries(results).map(([loc, tpl]) => api.updateAdminEmailTemplate(`${key}_${loc}`, tpl as { subject: string; body: string }) ) ) } catch { /* ignore */ } finally { setTemplateTranslating(false) } } async function saveTemplate(key: string) { const localeKey = `${key}_${templateLocale}` setTemplateSaving(true) try { await api.updateAdminEmailTemplate(localeKey, notifTemplates[localeKey] ?? { subject: '', body: '' }) setEditingTemplate(null) } catch { /* ignore */ } finally { setTemplateSaving(false) } } async function saveEPaySettings() { setEPaySaving(true) try { await api.updateAdminSettings({ 'payment.epay.merchantId': ePayForm.merchantId, 'payment.epay.apiUser': ePayForm.apiUser, 'payment.epay.apiPassword': ePayForm.apiPassword, 'payment.epay.environment': ePayForm.environment, 'payment.epay.confirmationUrl': ePayForm.confirmationUrl, 'payment.epay.cancelUrl': ePayForm.cancelUrl, 'payment.epay.enabled': String(ePayForm.enabled), }) setEPaySaved(true) setTimeout(() => setEPaySaved(false), 2500) } catch { /* ignore */ } finally { setEPaySaving(false) } } async function saveReferralSettings() { setReferralSaving(true) try { await api.updateAdminSettings({ 'referral_enabled': String(referralForm.enabled), 'referral_plan_id': referralForm.planId, 'referral_days': referralForm.days, }) setReferralSaved(true) setTimeout(() => setReferralSaved(false), 2500) } catch { /* ignore */ } finally { setReferralSaving(false) } } async function saveSeoSettings() { setSeoSaving(true) try { await api.updateAdminSettings({ 'seo.maintenanceMode': String(seoForm.maintenanceMode), 'seo.indexingEnabled': String(seoForm.indexingEnabled), 'seo.headScripts': seoForm.headScripts, 'seo.siteTitle.el': seoForm.siteTitleEl, 'seo.siteTitle.en': seoForm.siteTitleEn, 'seo.siteTitle.ru': seoForm.siteTitleRu, 'seo.siteTitle.uk': seoForm.siteTitleUk, 'seo.siteDescription.el': seoForm.siteDescriptionEl, 'seo.siteDescription.en': seoForm.siteDescriptionEn, 'seo.siteDescription.ru': seoForm.siteDescriptionRu, 'seo.siteDescription.uk': seoForm.siteDescriptionUk, }) setSeoSaved(true) setTimeout(() => setSeoSaved(false), 2500) } catch { /* ignore */ } finally { setSeoSaving(false) } } async function saveSocialSettings() { setSocialSaving(true) try { await api.updateAdminSettings({ 'social.telegram': socialForm.telegram, 'social.instagram': socialForm.instagram, 'social.facebook': socialForm.facebook, 'social.twitter': socialForm.twitter, 'social.youtube': socialForm.youtube, 'social.tiktok': socialForm.tiktok, 'social.whatsapp': socialForm.whatsapp, 'social.viber': socialForm.viber, }) setSocialSaved(true) setTimeout(() => setSocialSaved(false), 2500) } catch { /* ignore */ } finally { setSocialSaving(false) } } async function saveTelegramSettings() { setTelegramSaving(true) try { await api.updateAdminSettings({ 'telegram.botToken': telegramForm.botToken, 'telegram.adminChatId': telegramForm.adminChatId, 'telegram.channelId.el': telegramForm.channelIdEl, 'telegram.channelId.en': telegramForm.channelIdEn, 'telegram.channelId.ru': telegramForm.channelIdRu, 'telegram.channelId.uk': telegramForm.channelIdUk, 'telegram.notifyRegistrations': String(telegramForm.notifyRegistrations), 'telegram.postNewTasks': String(telegramForm.postNewTasks), }) setTelegramSaved(true) setTimeout(() => setTelegramSaved(false), 2500) } catch { /* ignore */ } finally { setTelegramSaving(false) } } async function testTelegram(type: 'admin' | Locale) { const chatIdMap: Record = { admin: telegramForm.adminChatId, el: telegramForm.channelIdEl, en: telegramForm.channelIdEn, ru: telegramForm.channelIdRu, uk: telegramForm.channelIdUk, } const chatId = chatIdMap[type] ?? '' if (type === 'admin') { setTestingTelegramAdmin(true); setTestResultTelegramAdmin(null) } else { setTestingLocale(type as Locale); setTestResultChannels((p) => { const n = { ...p }; delete n[type as Locale]; return n }) } try { const res = await api.testTelegramConnection(telegramForm.botToken, chatId) const result = { ok: res.ok, msg: res.ok ? (res.botName ? `@${res.botName}` : t('admin.settings.telegram.test_ok')) : (res.error ?? t('admin.settings.telegram.test_fail')) } if (type === 'admin') setTestResultTelegramAdmin(result) else setTestResultChannels((p) => ({ ...p, [type]: result })) } catch { const result = { ok: false, msg: t('admin.settings.telegram.test_fail') } if (type === 'admin') setTestResultTelegramAdmin(result) else setTestResultChannels((p) => ({ ...p, [type]: result })) } finally { if (type === 'admin') setTestingTelegramAdmin(false) else setTestingLocale(null) } } async function verifyTelegramToken() { setVerifyingTelegramToken(true) setVerifyResultTelegramToken(null) try { const res = await api.verifyTelegramToken(telegramForm.botToken) setVerifyResultTelegramToken({ ok: res.ok, msg: res.ok ? `@${res.botName}` : (res.error ?? t('admin.settings.telegram.test_fail')) }) } catch { setVerifyResultTelegramToken({ ok: false, msg: t('admin.settings.telegram.test_fail') }) } finally { setVerifyingTelegramToken(false) } } if (isPending) return
{t('common.loading')}
const dateLocale = locale === 'ru' ? 'ru-RU' : locale === 'en' ? 'en-US' : 'el-GR' const catName = (id: string) => { const cat = catRows.find((c: any) => c.id === id) if (!cat) return id if (locale === 'en') return cat.namesEn || cat.namesEl if (locale === 'ru') return cat.namesRu || cat.namesEl if (locale === 'uk') return cat.namesUk || cat.namesEl return cat.namesEl || cat.namesEn || id } const locName = (id: string) => { const loc = locRows.find((l: any) => l.id === id) if (!loc) return id if (locale === 'en') return loc.nameEn || loc.nameEl if (locale === 'ru') return loc.nameRu || loc.nameEl if (locale === 'uk') return loc.nameUk || loc.nameEl return loc.nameEl || loc.nameEn || id } const TABS: { key: Tab; label: string; icon: ReactNode; badge?: number }[] = [ { key: 'stats', label: t('admin.tab.stats'), icon: }, { key: 'users', label: t('admin.tab.users'), icon: }, { key: 'tasks', label: t('admin.tab.tasks'), icon: }, { key: 'reports', label: t('admin.tab.reports'), badge: pendingReportsCount || undefined, icon: }, { key: 'moderation', label: t('admin.tab.moderation'), badge: pendingCardsCount || undefined, icon: }, { key: 'categories', label: t('admin.tab.categories'), icon: }, { key: 'locations', label: t('admin.tab.locations'), icon: }, { key: 'balances', label: t('admin.tab.balances', 'Balance'), icon: }, { key: 'plans', label: t('admin.tab.plans'), icon: }, { key: 'logs', label: t('admin.tab.logs'), icon: }, { key: 'settings', label: t('admin.tab.settings'), icon: }, { key: 'support', label: t('admin.tab.support', 'Support'), icon: }, { key: 'push', label: t('admin.tab.push', 'Push'), icon: }, { key: 'backup', label: t('admin.tab.backup', 'Backup'), icon: }, { key: 'translations', label: t('admin.tab.translations', 'Translations'), icon: }, { key: 'dbcheck', label: t('admin.tab.dbcheck', 'DB check'), icon: }, { key: 'referrals', label: t('admin.tab.referrals', 'Referrals'), icon: }, ] const statusColors: Record = { open: 'bg-green-100 text-green-700', in_progress: 'bg-green-100 text-green-700', completed: 'bg-gray-100 text-gray-600', cancelled: 'bg-red-100 text-red-600', draft: 'bg-yellow-100 text-yellow-700', } const reportStatusColors: Record = { pending: 'bg-yellow-100 text-yellow-700', reviewed: 'bg-green-100 text-green-700', dismissed: 'bg-gray-100 text-gray-500', } const reasonColors: Record = { spam: 'bg-green-100 text-green-700', inappropriate: 'bg-red-100 text-red-700', fraud: 'bg-purple-100 text-purple-700', duplicate: 'bg-green-100 text-green-700', other: 'bg-gray-100 text-gray-600', } const cardStatus = (card: any) => card.publicationStatus ?? (card.isActive ? 'active' : 'inactive') const pendingModerationCards = moderationCards.filter((card) => cardStatus(card) === 'pending') const activeModerationCards = moderationCards.filter((card) => cardStatus(card) === 'active') return (
{/* Header */}

{t('admin.title')}

{t('admin.back_home')}
{/* Sidebar */} {/* Content */}
{/* ── Stats ── */} {tab === 'stats' && (
{/* ── Stat cards ── */}
{[ { label: t('admin.stats.users'), value: stats?.userCount, icon: , color: 'text-green-600' }, { label: t('admin.stats.specialists'), value: stats?.specialistCount, icon: , color: 'text-purple-600' }, { label: t('admin.stats.tasks'), value: stats?.taskCount, icon: , color: 'text-green-600' }, { label: t('admin.stats.offers'), value: stats?.offerCount, icon: , color: 'text-green-600' }, { label: t('admin.stats.reviews'), value: stats?.reviewCount, icon: , color: 'text-yellow-500' }, ].map(({ label, value, icon, color }) => (
{icon}
{value ?? '—'}
{label}
))}
{/* ── Area chart: tasks & users per day ── */} {stats?.tasksByDay && (

{t('admin.stats.activity_30d')}

{t('admin.stats.users_label')} {t('admin.stats.specialists_label')} {t('admin.stats.tasks_label')}
)} {/* ── Tasks by status — horizontal bars ── */} {stats?.tasksByStatus && (

{t('admin.stats.tasks_by_status')}

{(() => { const total = stats.tasksByStatus.reduce((s: number, r: any) => s + Number(r.count), 0) || 1 const statusMeta: Record = { open: { label: t('admin.task_status.open'), bg: 'bg-green-50', bar: 'bg-green-500' }, in_progress: { label: t('admin.task_status.in_progress'), bg: 'bg-green-50', bar: 'bg-green-500' }, completed: { label: t('admin.task_status.completed'), bg: 'bg-gray-50', bar: 'bg-gray-400' }, cancelled: { label: t('admin.task_status.cancelled'), bg: 'bg-red-50', bar: 'bg-red-400' }, draft: { label: t('admin.task_status.draft'), bg: 'bg-yellow-50', bar: 'bg-yellow-400' }, } const order = ['open', 'in_progress', 'completed', 'draft', 'cancelled'] const sorted = [...stats.tasksByStatus].sort((a: any, b: any) => order.indexOf(a.status) - order.indexOf(b.status) ) return sorted.map(({ status, count: c }: any) => { const meta = statusMeta[status] ?? { label: status, bg: 'bg-gray-50', bar: 'bg-gray-300' } const pct = Math.round((Number(c) / total) * 100) return (
{meta.label}
{c} {pct}%
) }) })()}
)} {/* ── Notification sends per day ── */} {notifsByDay.length > 0 && (

{t('admin.stats.notif_chart_title')}

{t('admin.stats.notif_label')}
)} {/* ── Emails per day ── */} {mailByDay.length > 0 && (

{t('admin.stats.mail_chart_title')}

{t('admin.stats.mail_label')}
)} {/* ── Token usage ── */} {stats && (

{t('admin.stats.tokens_title')}

{t('admin.stats.tokens_out')} {t('admin.stats.tokens_in')}
{t('admin.stats.tokens_out_label')}
{Number(stats.totalOutputTokens).toLocaleString()}
{t('admin.stats.tokens_in_label')}
{Number(stats.totalInputTokens).toLocaleString()}
{t('admin.stats.cost_label')}
${Number(stats.totalCostUsd ?? 0).toFixed(4)}
{stats.tokensByMonth?.length > 0 && ( ({ month: r.month, count: Number(r.output) })), color: '#8b5cf6', fill: '#8b5cf610', label: t('admin.stats.tokens_out_label') }, { data: stats.tokensByMonth.map((r: any) => ({ month: r.month, count: Number(r.input) })), color: '#0ea5e9', fill: '#0ea5e915', label: t('admin.stats.tokens_in_label') }, ]} /> )}
)}
)} {/* ── Users ── */} {tab === 'users' && (
{/* Filters */}
{ e.preventDefault(); setUsersQ(usersQInput) }} > setUsersQInput(e.target.value)} placeholder={t('admin.users.search.placeholder')} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
{loading ? ( ) : users.length === 0 ? ( ) : users.map((u) => ( ))}
{t('admin.col.user')} {t('admin.col.email')} {t('admin.col.role')} {t('admin.col.plan', 'Plan')} {t('admin.col.registered')} {t('admin.col.status')}
{t('common.loading')}
{formatShortName(u.firstName, u.lastName, u.name)} {u.email} {t(`profile.role.${u.role}`) || u.role} {u.planId ? (planRows.find((p: any) => p.id === u.planId)?.name ?? {u.planId.slice(0, 8)}…) : } {new Date(u.createdAt).toLocaleDateString(dateLocale)} {u.isActive !== false ? t('admin.users.active') : t('admin.users.inactive')}
{u.role !== 'admin' && ( )}
)} {tab === 'balances' && (
setBalanceUserId(e.target.value)} placeholder={t('admin.balance.filter.user_id', 'Filter by userId')} className="flex-1 min-w-72 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
{loading ? ( ) : balanceRows.length === 0 ? ( ) : balanceRows.map((row) => { const isCredit = row.direction === 'credit' const kindLabel = row.kind === 'topup' ? t('admin.balance.kind.topup', 'Top-up') : row.kind === 'plan_purchase' ? t('admin.balance.kind.plan_purchase', 'Plan charge') : row.kind === 'admin_topup' ? t('admin.balance.kind.admin_topup', 'Admin top-up') : t('admin.balance.kind.admin_adjustment', 'Adjustment') return ( ) })}
{t('admin.col.user')} {t('admin.balance.col.operation', 'Operation')} {t('admin.balance.col.amount', 'Amount')} {t('admin.balance.col.result', 'Balance')} {t('admin.balance.col.description', 'Description')} {t('admin.balance.col.date', 'Date')}
{t('common.loading')}
{t('admin.balance.empty', 'No records yet')}
{row.userName || '—'}
{row.userEmail || row.userId}
{kindLabel} {isCredit ? '+' : '-'}{Number(row.amount).toFixed(2)} {row.currency}
{Number(row.balanceBefore).toFixed(2)} → {Number(row.balanceAfter).toFixed(2)} {row.currency}
{row.description || '—'} {new Date(row.createdAt).toLocaleString(dateLocale)}
)} {/* ── Tasks ── */} {tab === 'tasks' && (
{/* Filters */}
{ e.preventDefault(); setTasksQ(tasksQInput) }} > setTasksQInput(e.target.value)} placeholder={t('admin.tasks.search.placeholder')} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
{loading ? ( ) : tasks.length === 0 ? ( ) : tasks.map((row) => { const task = row.task || row const customer = row.customer return ( ) })}
{t('admin.col.title')} {t('admin.col.customer')} {t('admin.col.status')} {t('admin.col.budget')} {t('admin.col.created')}
{t('common.loading')}
{(locale === 'en' && task.titleEn) || (locale === 'ru' && task.titleRu) || (locale === 'uk' && task.titleUk) || task.titleEl || task.title} {customer?.name ?? '—'} {t(`task.status.${task.status}`, task.status)} {task.budget ? `${formatPrice(task.budget)}€` : '—'} {new Date(task.createdAt).toLocaleDateString(dateLocale)}
{task.status !== 'cancelled' && task.status !== 'completed' && ( )}
)} {/* ── Categories ── */} {tab === 'categories' && (

{t('admin.cat.hint')}

{/* Form */} {catForm && (

{catForm.id ? t('admin.cat.edit') : t('admin.cat.new')} {catForm.parentId && ({t('admin.cat.subcategory')})}

setCatForm((f) => f && ({ ...f, slug: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" placeholder="e.g. repairs" />
setCatForm((f) => f && ({ ...f, icon }))} onUpload={uploadCategoryIcon} uploading={catIconUploading} error={catIconError} presets={categoryIconPresets} />
setCatForm((f) => f && ({ ...f, order: Number(e.target.value) }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setCatForm((f) => f && ({ ...f, namesEl: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setCatForm((f) => f && ({ ...f, namesEn: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setCatForm((f) => f && ({ ...f, namesRu: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setCatForm((f) => f && ({ ...f, namesUk: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
{catForm.id && ( )}
)} {/* Tree */}
{catRows.length === 0 ? (
{t('admin.cat.empty')}
) : ( setSkillForm(null)} onSkillFormChange={setSkillForm} t={t} /> )}
)} {/* ── Locations ── */} {tab === 'locations' && (

{t('admin.loc.hint')}

{/* Form */} {locForm && (

{locForm.id ? t('admin.loc.edit') : t('admin.loc.new')} {locForm.parentId && ({t('admin.loc.district')})}

setLocForm((f) => f && ({ ...f, slug: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" placeholder="e.g. athens-center" />
setLocForm((f) => f && ({ ...f, nameEl: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setLocForm((f) => f && ({ ...f, nameEn: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setLocForm((f) => f && ({ ...f, nameRu: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setLocForm((f) => f && ({ ...f, nameUk: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setLocForm((f) => f && ({ ...f, order: Number(e.target.value) }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
{locForm.id && ( )}
)} {/* Tree */}
{locRows.length === 0 ? (
{t('admin.loc.empty')}
) : ( )}
)} {/* ── Reports ── */} {tab === 'reports' && (
{loading ? (
{t('common.loading')}
) : reports.length === 0 ? (
{t('admin.reports.none')}
) : ( reports.map((r) => (
{/* Status + reason badges */}
{r.status} {r.reason} {/* Target link */} {r.targetType === 'task' ? t('admin.reports.target.task') : t('admin.reports.target.user')}:{' '} {r.targetTitle ? ( {r.targetTitle} ) : ( #{r.targetId?.slice(0, 8)} )}
{/* Reporter */} {r.reporterName && (

{t('admin.col.reporter')}: {r.reporterName}

)} {/* Description */} {r.description && (

{r.description}

)}

{new Date(r.createdAt).toLocaleString(dateLocale)}

{r.status === 'pending' && (
)}
)) )}
)} {/* ── Moderation ── */} {tab === 'moderation' && (
{t('admin.moderation.pending_count', 'Pending')}
{pendingModerationCards.length}
{t('admin.moderation.pending_hint', 'New proposals waiting for approval')}
{t('admin.moderation.active_count', 'Active')}
{activeModerationCards.length}
{t('admin.moderation.active_hint', 'Visible cards that can be blocked')}
{moderationCardsLoading ? (
{t('common.loading')}
) : moderationCards.length === 0 ? (
{t('admin.moderation.none', 'No specialist cards found')}
) : (

{t('admin.moderation.pending_title', 'Pending approval')}

{pendingModerationCards.length === 0 ? (
{t('admin.moderation.pending_empty', 'No pending proposals right now')}
) : pendingModerationCards.map((card) => (
{t('admin.moderation.pending', 'Pending')} {card.title}
{card.specialistName ?? card.specialistEmail ?? card.specialistId}
{card.description &&

{card.description}

} {(card.categorySuggestions ?? []).length > 0 && (

{t('admin.moderation.category_suggestions', 'Requested categories')}

{card.categorySuggestions.map((suggestion: any) => (

{suggestion.name}

setCategorySuggestionSlugs((prev) => ({ ...prev, [suggestion.id]: event.target.value }))} placeholder={t('admin.moderation.new_category_slug', 'New category slug')} className="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-700" />
))}
)}
))}

{t('admin.moderation.active_title', 'Active cards')}

{activeModerationCards.length === 0 ? (
{t('admin.moderation.active_empty', 'No active cards found')}
) : activeModerationCards.map((card) => (
{t('admin.moderation.active', 'Active')} {card.title}
{card.specialistName ?? card.specialistEmail ?? card.specialistId}
{card.description &&

{card.description}

}
))}
)}
)} {/* ── Logs ── */} {tab === 'logs' && (
{/* Sub-tabs */}
{([ { key: 'ai', label: t('admin.logs.subtab.ai') }, { key: 'activity', label: t('admin.logs.subtab.activity') }, { key: 'mail', label: t('admin.logs.subtab.mail') }, { key: 'notifications', label: t('admin.logs.subtab.notifications') }, ] as const).map(({ key, label }) => ( ))}
{/* AI Logs */} {logsSubTab === 'ai' && (

{t('admin.pagination.total')}: {logsTotal}

{loading ? (
{t('common.loading')}
) : logs.length === 0 ? (
{t('admin.logs.empty')}
) : (
{logs.map((log) => ( ))}
{t('admin.logs.date')} {t('admin.logs.provider')} {t('admin.logs.model')} {t('admin.logs.action')} {t('admin.logs.langs')} {t('admin.logs.in')} {t('admin.logs.out')} {t('admin.logs.total')} {t('admin.logs.ms')} {t('admin.logs.status')}
{new Date(log.createdAt).toLocaleString(dateLocale)} {log.provider} {log.model} {log.action} {log.fromLocale && log.toLocale ? `${log.fromLocale} → ${log.toLocale}` : '—'} {log.inputTokens ?? '—'} {log.outputTokens ?? '—'} {log.totalTokens ?? '—'} {log.durationMs ?? '—'} {log.success ? ( ) : ( )}
)}
)} {/* Activity Logs */} {logsSubTab === 'activity' && (
setActivityDateFrom(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setActivityDateTo(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
{(activityEvent || activityDateFrom || activityDateTo || activityUserId) && ( )}

{t('admin.activity.total')}: {activityTotal}

{activityLog.length === 0 ? (
{t('admin.activity.empty')}
) : (
{activityLog.map((entry) => ( { setActivityUserId(id); setActivityEvent('') }} t={t} /> ))}
)}
)} {/* Mail Logs */} {logsSubTab === 'mail' && (
setMailDateFrom(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setMailDateTo(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
{(mailEvent || mailDateFrom || mailDateTo) && ( )}

{t('admin.mail.total')}: {mailTotal}

{mailLog.length === 0 ? (
{t('admin.mail.empty')}
) : (
{mailLog.map((entry) => { const details = parseLogDetails(entry.details) const provider = typeof details?.provider === 'string' ? details.provider : '—' const recipient = typeof details?.to === 'string' ? details.to : entry.userEmail || '—' const subject = typeof details?.subject === 'string' ? details.subject : '' const error = typeof details?.error === 'string' ? details.error : '' const success = entry.event === 'email.sent' || entry.event === 'admin.email.test.sent' return ( ) })}
{t('admin.logs.date')} {t('admin.mail.recipient')} {t('admin.mail.event')} {t('admin.mail.provider')} {t('admin.mail.details')} {t('admin.mail.status')}
{new Date(entry.createdAt).toLocaleString(dateLocale)} {recipient} {t(`admin.event.${entry.event.replace(/\./g, '_')}`, entry.event)} {provider}
{subject || error || '—'}
{success ? ( ) : ( )}
)}
)} {/* Notification Logs */} {logsSubTab === 'notifications' && (
setNotifLogsDateFrom(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
setNotifLogsDateTo(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" />
{(notifLogsType || notifLogsDateFrom || notifLogsDateTo) && ( )}

{t('admin.pagination.total')}: {notifLogsTotal}

{notifLogs.length === 0 ? (
{t('admin.notif_logs.empty')}
) : (
{notifLogs.map((n) => ( ))}
{t('admin.logs.date')} {t('admin.col.user')} {t('admin.notif_logs.type')} {t('admin.notif_logs.title')} {t('admin.notif_logs.read')}
{new Date(n.createdAt).toLocaleString(dateLocale)} {n.recipientName ?? n.recipientEmail ?? n.recipientId} {n.type} {n.title} {n.isRead ? ( ) : ( )}
)}
)}
)} {/* ── Plans ── */} {tab === 'plans' && (

{t('admin.plans.title')}

{planForm && (

{planForm.id ? t('admin.plans.form.edit') : t('admin.plans.form.new')}

{/* Basic */}
setPlanForm({ ...planForm, name: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, price: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, oldPrice: e.target.value })} placeholder="—" className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, description: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
{/* Limits */}

{t('admin.plans.form.section.limits')}

setPlanForm({ ...planForm, maxTasks: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, maxOffers: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, maxCards: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, maxMessagesPerDay: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, maxOrdersPerDay: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, maxSkills: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, durationDays: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
{/* Multipliers */}

{t('admin.plans.form.section.boost')}

setPlanForm({ ...planForm, searchBoost: Number(e.target.value) })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
setPlanForm({ ...planForm, offersMultiplier: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" />
{/* Features */}