/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
admin
/
/opt/canhelp/apps/web/src/app/admin
mkdir
upload
Name
Size
Mode
Actions
page.tsx
403560
0644
edit
dl
rm
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<string, string[]> = { 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<void> uploading: boolean error: string | null presets: CategoryIconPreset[] }) { return ( <div className="space-y-2"> <div className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-3 py-2"> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50"> <CategoryIcon icon={value} alt="category icon" className="h-9 w-9 object-contain text-3xl leading-none" fallback="📦" /> </div> <input value={value} onChange={(e) => onChange(e.target.value)} className="flex-1 border-0 bg-transparent p-0 text-sm outline-none ring-0" placeholder="/category-icons/package.svg" /> </div> <div className="flex items-center gap-2"> <label className={`inline-flex cursor-pointer items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition ${uploading ? 'border-gray-200 bg-gray-100 text-gray-400' : 'border-green-300 bg-green-50 text-green-700 hover:bg-green-100'}`}> {uploading ? 'Uploading...' : 'Upload SVG'} <input type="file" accept=".svg,image/svg+xml" className="hidden" disabled={uploading} onChange={async (e) => { const file = e.target.files?.[0] if (!file) return await onUpload(file) e.target.value = '' }} /> </label> <span className="text-xs text-gray-500">or choose from presets</span> </div> <p className="text-[11px] text-gray-500">Preset source: /public/category-icons/presets.json</p> {error ? <p className="text-xs text-red-600">{error}</p> : null} <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4"> {presets.map((preset) => { const active = value === preset.value return ( <button key={preset.value} type="button" onClick={() => onChange(preset.value)} className={`flex h-20 flex-col items-center justify-center gap-1 rounded-lg border px-2 transition ${ active ? 'border-green-500 bg-green-50 shadow-sm' : 'border-gray-200 bg-white hover:border-green-300 hover:bg-green-50' }`} aria-label={preset.label} title={preset.label} > <CategoryIcon icon={preset.value} alt={preset.label} className="h-8 w-8 object-contain text-3xl leading-none" fallback="📦" /> <span className="max-w-full truncate text-[11px] font-medium text-gray-600 leading-tight">{preset.label}</span> </button> ) })} </div> </div> ) } 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<string | null>(null) const ref = useRef<HTMLDivElement>(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 ( <div className="relative" ref={ref}> <button type="button" onClick={() => setOpen((v) => !v)} className={`w-full flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors ${ open ? 'rounded-t-lg border border-green-400 bg-white text-gray-900' : 'rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-gray-500 hover:text-gray-700' }`} > <svg className="w-3.5 h-3.5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" /> </svg> <span className="flex-1 text-left truncate">{addLabel}</span> <svg className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> </svg> </button> {open && ( <div className="absolute left-0 top-full w-full min-w-[240px] bg-white border border-t-0 border-green-400 rounded-b-xl shadow-lg z-50 max-h-72 overflow-y-auto"> <div className="sticky top-0 bg-white px-3 py-2 border-b border-gray-100"> <input autoFocus value={search} onChange={(e) => 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" /> </div> {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 ( <div key={parent.id}> <button type="button" className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-gray-50 text-gray-800" onClick={() => { if (hasChildren && !search) { setExpandedParent(isExpanded ? null : parent.id) } else if (parentAvail) { onAdd(parent.slug); setOpen(false); setSearch('') } }} > {parent.icon ? <CategoryIcon icon={parent.icon} alt={parentLabel} className="h-5 w-5 shrink-0 object-contain text-sm leading-none" fallback="📦" /> : null} <span className="flex-1 font-medium">{parentLabel}</span> {hasChildren && !search ? ( <svg className={`w-3.5 h-3.5 text-gray-400 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> </svg> ) : null} </button> {(isExpanded || !!search) && matchingChildren.map((child) => ( <button key={child.id} type="button" className="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs text-left bg-gray-50 hover:bg-gray-100 text-gray-600" onClick={() => { onAdd(child.slug); setOpen(false); setSearch('') }} > <span className="w-1.5 h-1.5 rounded-full bg-gray-300 shrink-0" /> <span className="flex-1">{getCatLabel(child, locale)}</span> </button> ))} </div> ) })} </div> )} </div> ) } 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<string | null>(null) const ref = useRef<HTMLDivElement>(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 ( <div className="relative" ref={ref}> <button type="button" onClick={() => setOpen((v) => !v)} className={`w-full flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors ${ open ? 'rounded-t-lg border border-green-400 bg-white text-gray-900' : 'rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-gray-500 hover:text-gray-700' }`} > <svg className="w-3.5 h-3.5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> </svg> <span className="flex-1 text-left truncate">{addLabel}</span> <svg className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> </svg> </button> {open && ( <div className="absolute left-0 top-full w-full min-w-[220px] bg-white border border-t-0 border-green-400 rounded-b-xl shadow-lg z-50 max-h-72 overflow-y-auto"> <div className="sticky top-0 bg-white px-3 py-2 border-b border-gray-100"> <input autoFocus value={search} onChange={(e) => 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" /> </div> {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 ( <div key={city.id}> <button type="button" className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-gray-50 text-gray-800" onClick={() => { if (hasDistricts && !search) { setExpandedCity(isExpanded ? null : city.id) } else if (cityAvail) { onAdd(city.slug); setOpen(false); setSearch('') } }} > <svg className="w-3 h-3 text-gray-300 shrink-0" fill="currentColor" viewBox="0 0 24 24"> <path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" /> </svg> <span className="flex-1 font-medium">{cityLabel}</span> {hasDistricts && !search ? ( <svg className={`w-3.5 h-3.5 text-gray-400 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> </svg> ) : null} </button> {(isExpanded || !!search) && matchingDistricts.map((d) => ( <button key={d.id} type="button" className="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs text-left bg-gray-50 hover:bg-gray-100 text-gray-600" onClick={() => { onAdd(d.slug); setOpen(false); setSearch('') }} > <span className="w-1.5 h-1.5 rounded-full bg-gray-300 shrink-0" /> <span className="flex-1">{getLocLabel(d, locale)}</span> </button> ))} </div> ) })} </div> )} </div> ) } export default function AdminPage() { const router = useRouter() const { data: session, isPending } = useSession() const { t, locale } = useLocale() const [tab, setTab] = useState<Tab>('stats') const [sidebarCollapsed, setSidebarCollapsed] = useState(false) useEffect(() => { setSidebarCollapsed(window.innerWidth < 1024) }, []) // Stats const [stats, setStats] = useState<any>(null) // Users const [users, setUsers] = useState<any[]>([]) 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<any[]>([]) 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<any[]>([]) const [reportsPage, setReportsPage] = useState(1) const [reportsTotal, setReportsTotal] = useState(0) const [pendingReportsCount, setPendingReportsCount] = useState(0) // Specialist card moderation const [moderationCards, setModerationCards] = useState<any[]>([]) const [moderationCardsPage, setModerationCardsPage] = useState(1) const [moderationCardsTotal, setModerationCardsTotal] = useState(0) const [moderationCardsLoading, setModerationCardsLoading] = useState(false) const [pendingCardsCount, setPendingCardsCount] = useState(0) const [categorySuggestionTargets, setCategorySuggestionTargets] = useState<Record<string, string>>({}) const [categorySuggestionSlugs, setCategorySuggestionSlugs] = useState<Record<string, string>>({}) const [resolvingCategorySuggestion, setResolvingCategorySuggestion] = useState<string | null>(null) // Support tickets const [supportTickets, setSupportTickets] = useState<any[]>([]) 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<string | null>(null) const [supportReplyText, setSupportReplyText] = useState('') const [supportReplySaving, setSupportReplySaving] = useState(false) // AI Logs const [logs, setLogs] = useState<any[]>([]) const [logsPage, setLogsPage] = useState(1) const [logsTotal, setLogsTotal] = useState(0) // Activity Logs const [activityLog, setActivityLog] = useState<any[]>([]) 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<string[]>([]) // Mail Logs const [mailLog, setMailLog] = useState<any[]>([]) 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<string[]>([]) // Logs sub-tab const [logsSubTab, setLogsSubTab] = useState<'ai' | 'activity' | 'mail' | 'notifications'>('ai') // Notification Logs const [notifLogs, setNotifLogs] = useState<any[]>([]) const [notifLogsPage, setNotifLogsPage] = useState(1) const [notifLogsTotal, setNotifLogsTotal] = useState(0) const [notifLogsType, setNotifLogsType] = useState('') const [notifLogsTypes, setNotifLogsTypes] = useState<string[]>([]) 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<any[]>([]) 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<any | null>(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<any[]>([]) const [editUserCardsLoading, setEditUserCardsLoading] = useState(false) const [cardSkillInputs, setCardSkillInputs] = useState<Record<string, string>>({}) const [cardSaving, setCardSaving] = useState<Record<string, boolean>>({}) const [cardCatSelects, setCardCatSelects] = useState<Record<string, string>>({}) const [cardLocSelects, setCardLocSelects] = useState<Record<string, string>>({}) // Plans const [planRows, setPlanRows] = useState<any[]>([]) 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<any[]>([]) 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<Set<string>>(new Set()) // Categories const [catRows, setCatRows] = useState<any[]>([]) 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<Set<string>>(new Set()) const [catIconUploading, setCatIconUploading] = useState(false) const [catIconError, setCatIconError] = useState<string | null>(null) const [categoryIconPresets, setCategoryIconPresets] = useState<CategoryIconPreset[]>(DEFAULT_CATEGORY_ICON_PRESETS) // Category Skills const [catSkillsExpanded, setCatSkillsExpanded] = useState<Set<string>>(new Set()) const [catSkillsCache, setCatSkillsCache] = useState<Record<string, any[]>>({}) const [catSkillsLoading, setCatSkillsLoading] = useState<Set<string>>(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<string | null>(null) const [notifTemplates, setNotifTemplates] = useState<Record<string, { subject: string; body: string }>>({ 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: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Ваше предложение принято! 🎉</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Заказчик принял ваше предложение на выполнение задачи:</p><div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#1e40af;font-weight:600">{{taskTitle}}</p><p style="margin:4px 0 0;color:#374151">Сумма: <strong>{{price}}€</strong></p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Открыть чат →</a>' }, new_message: { subject: 'Новое сообщение от {{senderName}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Новое сообщение</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Вам написал(а) <strong>{{senderName}}</strong>:</p><div style="background:#f9fafb;border-left:4px solid #2563eb;padding:16px;margin:16px 0"><p style="margin:0;color:#374151;font-style:italic">"{{preview}}"</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Ответить →</a>' }, offer_declined: { subject: 'Ваше предложение отклонено: {{taskTitle}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Ваше предложение отклонено</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">К сожалению, заказчик отклонил ваше предложение на:</p><div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#b91c1c;font-weight:600">{{taskTitle}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Найти другие заказы →</a>' }, offer_other_accepted: { subject: 'Выбран другой исполнитель: {{taskTitle}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Выбран другой исполнитель</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Заказчик выбрал другого исполнителя для задания:</p><div style="background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#c2410c;font-weight:600">{{taskTitle}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Найти другие заказы →</a>' }, task_updated: { subject: 'Задание обновлено: {{taskTitle}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Задание обновлено</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Заказчик обновил детали задания:</p><div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#1d4ed8;font-weight:600">{{taskTitle}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Смотреть заказ →</a>' }, deadline_reminder: { subject: 'Задание истекает завтра: {{taskTitle}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Напоминание о дедлайне</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Ваше задание истекает через 24 часа. Обновите дедлайн, иначе задание будет автоматически снято с публикации.</p><div style="background:#fefce8;border:1px solid #fde68a;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#92400e;font-weight:600">{{taskTitle}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Редактировать задание →</a>' }, task_archived: { subject: 'Задание снято с публикации: {{taskTitle}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Задание переведено в черновик</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Ваше задание истекло и было автоматически переведено в черновик. Обновите дедлайн, чтобы опубликовать его снова.</p><div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#b91c1c;font-weight:600">{{taskTitle}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Редактировать задание →</a>' }, referral_reward: { subject: 'Вы получили реферальную награду — {{siteName}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Вы получили реферальную награду! 🎁</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Друг зарегистрировался по вашей реферальной ссылке. На ваш аккаунт добавлено <strong>{{days}} дней</strong> доступа к плану <strong>{{planName}}</strong>!</p><div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#15803d;font-weight:600">+{{days}} дней — {{planName}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Перейти к аккаунту →</a>' }, referral_reward_el: { subject: 'Λάβατε ανταμοιβή παραπομπής — {{siteName}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Λάβατε ανταμοιβή παραπομπής! 🎁</h2><p style="color:#6b7280;margin:0 0 20px">Γεια {{userName}},</p><p style="color:#374151">Ένας φίλος εγγράφηκε χρησιμοποιώντας τον σύνδεσμο παραπομπής σας. Στον λογαριασμό σας προστέθηκαν <strong>{{days}} ημέρες</strong> πρόσβασης στο πλάνο <strong>{{planName}}</strong>!</p><div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#15803d;font-weight:600">+{{days}} ημέρες — {{planName}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Δες τον λογαριασμό σου →</a>' }, referral_reward_en: { subject: 'You received a referral reward — {{siteName}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">You received a referral reward! 🎁</h2><p style="color:#6b7280;margin:0 0 20px">Hi {{userName}},</p><p style="color:#374151">A friend registered using your referral link. <strong>{{days}} days</strong> of <strong>{{planName}}</strong> access have been added to your account!</p><div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#15803d;font-weight:600">+{{days}} days — {{planName}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">View your account →</a>' }, referral_reward_ru: { subject: 'Вы получили реферальную награду — {{siteName}}', body: '<h2 style="margin:0 0 8px;color:#111827;font-size:20px">Вы получили реферальную награду! 🎁</h2><p style="color:#6b7280;margin:0 0 20px">Привет, {{userName}}!</p><p style="color:#374151">Друг зарегистрировался по вашей реферальной ссылке. На ваш аккаунт добавлено <strong>{{days}} дней</strong> доступа к плану <strong>{{planName}}</strong>!</p><div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:16px;margin:16px 0"><p style="margin:0;color:#15803d;font-weight:600">+{{days}} дней — {{planName}}</p></div><a href="{{link}}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;margin-top:8px">Перейти к аккаунту →</a>' } }) const [editingTemplate, setEditingTemplate] = useState<string | null>(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<Locale | null>(null) const [testResultChannels, setTestResultChannels] = useState<Partial<Record<Locale, { ok: boolean; msg: string }>>>({}) 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<string[]>([]) const [backupTablesLoading, setBackupTablesLoading] = useState(false) const [backupStoredFiles, setBackupStoredFiles] = useState<{ name: string; size: number; date: string }[]>([]) const [backupExporting, setBackupExporting] = useState(false) const [backupImporting, setBackupImporting] = useState<string | null>(null) const [backupRestoring, setBackupRestoring] = useState<string | null>(null) const [backupMsg, setBackupMsg] = useState<string | null>(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<api.TranslationTableStats[] | null>(null) const [transStatsLoading, setTransStatsLoading] = useState(false) const [transLang, setTransLang] = useState('uk') const [transFromLang, setTransFromLang] = useState('el') const [transTableTranslating, setTransTableTranslating] = useState<Record<string, boolean>>({}) 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<api.AdminReferralsData | null>(null) const [referralsLoading, setReferralsLoading] = useState(false) const [referralsExpanded, setReferralsExpanded] = useState<Set<string>>(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<api.AdminPushBroadcastResult | api.AdminPushUserResult | null>(null) const [pushError, setPushError] = useState<string | null>(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<string, string> = { 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<any>('/admin/backup/settings').then((d) => setBackupAutoEnabled(!!d.autoEnabled)).catch(() => {}) setBackupTablesLoading(true) request<any>('/admin/backup/tables').then((d) => setBackupTables(d.tables || [])).catch(() => {}).finally(() => setBackupTablesLoading(false)) request<any>('/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<string, { subject: string; body: string }> = {} 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<string, string> = { 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 <div className="text-center py-20 text-gray-500">{t('common.loading')}</div> 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: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg> }, { key: 'users', label: t('admin.tab.users'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path strokeLinecap="round" strokeLinejoin="round" d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg> }, { key: 'tasks', label: t('admin.tab.tasks'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg> }, { key: 'reports', label: t('admin.tab.reports'), badge: pendingReportsCount || undefined, icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> }, { key: 'moderation', label: t('admin.tab.moderation'), badge: pendingCardsCount || undefined, icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8c-1.1 0-2 .9-2 2v1H9a1 1 0 00-1 1v5a1 1 0 001 1h6a1 1 0 001-1v-5a1 1 0 00-1-1h-1v-1c0-1.1-.9-2-2-2zm0 0V6m0 12v-2"/><path strokeLinecap="round" strokeLinejoin="round" d="M6 20h12"/></svg> }, { key: 'categories', label: t('admin.tab.categories'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg> }, { key: 'locations', label: t('admin.tab.locations'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg> }, { key: 'balances', label: t('admin.tab.balances', 'Balance'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-2"/><path strokeLinecap="round" strokeLinejoin="round" d="M20 12h-6m0 0a2 2 0 100 4h6v-8h-6a2 2 0 100 4z"/></svg> }, { key: 'plans', label: t('admin.tab.plans'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg> }, { key: 'logs', label: t('admin.tab.logs'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> }, { key: 'settings', label: t('admin.tab.settings'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="3"/><path strokeLinecap="round" strokeLinejoin="round" d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> }, { key: 'support', label: t('admin.tab.support', 'Support'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" /></svg> }, { key: 'push', label: t('admin.tab.push', 'Push'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.4-1.4A2 2 0 0118 14.17V11a6 6 0 10-12 0v3.17a2 2 0 01-.6 1.43L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg> }, { key: 'backup', label: t('admin.tab.backup', 'Backup'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg> }, { key: 'translations', label: t('admin.tab.translations', 'Translations'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"/></svg> }, { key: 'dbcheck', label: t('admin.tab.dbcheck', 'DB check'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><ellipse cx="12" cy="5" rx="9" ry="3"/><path strokeLinecap="round" strokeLinejoin="round" d="M21 12c0 1.66-4.03 3-9 3S3 13.66 3 12"/><path strokeLinecap="round" strokeLinejoin="round" d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4"/></svg> }, { key: 'referrals', label: t('admin.tab.referrals', 'Referrals'), icon: <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg> }, ] const statusColors: Record<string, string> = { 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<string, string> = { pending: 'bg-yellow-100 text-yellow-700', reviewed: 'bg-green-100 text-green-700', dismissed: 'bg-gray-100 text-gray-500', } const reasonColors: Record<string, string> = { 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 ( <div className="min-h-screen bg-gray-50"> <div className="max-w-7xl mx-auto px-4 py-8"> {/* Header */} <div className="flex items-center justify-between mb-6"> <div className="flex items-center gap-3"> <svg className="w-7 h-7 text-yellow-500" fill="currentColor" viewBox="0 0 24 24"> <path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 3a1 1 0 000 2h14a1 1 0 000-2H5z"/> </svg> <h1 className="text-2xl font-bold text-gray-900">{t('admin.title')}</h1> </div> <Link href="/" className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-green-600 transition" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> </svg> {t('admin.back_home')} </Link> </div> <div className="flex gap-6"> {/* Sidebar */} <aside className={`shrink-0 transition-all duration-200 ${sidebarCollapsed ? 'w-12' : 'w-52'}`}> <nav className="bg-white rounded-xl border border-gray-200 overflow-hidden"> {/* Toggle button */} <button type="button" onClick={() => setSidebarCollapsed((v) => !v)} title={sidebarCollapsed ? 'Развернуть меню' : 'Свернуть меню'} className="w-full flex items-center justify-center py-2.5 text-gray-400 hover:text-gray-600 hover:bg-gray-50 border-b border-gray-100 transition" > <svg className={`w-4 h-4 transition-transform duration-200 ${sidebarCollapsed ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /> </svg> </button> {TABS.map(({ key, label, icon, badge }) => ( <button key={key} onClick={() => setTab(key)} title={sidebarCollapsed ? label : undefined} className={`w-full flex items-center py-3 text-sm font-medium text-left transition ${ tab === key ? 'bg-green-50 text-green-700 border-r-2 border-green-600' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' } ${sidebarCollapsed ? 'justify-center px-3' : 'gap-3 px-4'}`} > <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{icon}</span> {!sidebarCollapsed && ( <span className="flex items-center gap-2 min-w-0"> <span>{label}</span> {badge ? <span className="ml-auto inline-flex min-w-5 h-5 items-center justify-center rounded-full bg-red-100 px-1.5 text-[11px] font-semibold text-red-700">{badge}</span> : null} </span> )} </button> ))} </nav> </aside> {/* Content */} <div className="flex-1 min-w-0"> {/* ── Stats ── */} {tab === 'stats' && ( <div className="space-y-6"> {/* ── Stat cards ── */} <div className="grid grid-cols-2 md:grid-cols-5 gap-4"> {[ { label: t('admin.stats.users'), value: stats?.userCount, icon: <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>, color: 'text-green-600' }, { label: t('admin.stats.specialists'), value: stats?.specialistCount, icon: <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>, color: 'text-purple-600' }, { label: t('admin.stats.tasks'), value: stats?.taskCount, icon: <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>, color: 'text-green-600' }, { label: t('admin.stats.offers'), value: stats?.offerCount, icon: <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>, color: 'text-green-600' }, { label: t('admin.stats.reviews'), value: stats?.reviewCount, icon: <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/></svg>, color: 'text-yellow-500' }, ].map(({ label, value, icon, color }) => ( <div key={label} className="bg-white rounded-xl border border-gray-200 p-5"> <div className={`mb-2 ${color}`}>{icon}</div> <div className={`text-3xl font-bold ${color}`}>{value ?? '—'}</div> <div className="text-sm text-gray-500 mt-1">{label}</div> </div> ))} </div> {/* ── Area chart: tasks & users per day ── */} {stats?.tasksByDay && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-4"> <h2 className="font-semibold text-gray-800">{t('admin.stats.activity_30d')}</h2> <div className="flex gap-4 text-xs text-gray-500"> <span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-green-500 inline-block rounded" />{t('admin.stats.users_label')}</span> <span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-purple-400 inline-block rounded" />{t('admin.stats.specialists_label')}</span> <span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-green-500 inline-block rounded" />{t('admin.stats.tasks_label')}</span> </div> </div> <AdminAreaChart series={[ { data: stats.usersByDay, color: '#3b82f6', fill: '#3b82f615', label: t('admin.stats.users_label') }, { data: stats.specialistsByDay, color: '#a855f7', fill: '#a855f710', label: t('admin.stats.specialists_label') }, { data: stats.tasksByDay, color: '#22c55e', fill: '#22c55e10', label: t('admin.stats.tasks_label') }, ]} /> </div> )} {/* ── Tasks by status — horizontal bars ── */} {stats?.tasksByStatus && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <h2 className="font-semibold text-gray-800 mb-5">{t('admin.stats.tasks_by_status')}</h2> <div className="space-y-3"> {(() => { const total = stats.tasksByStatus.reduce((s: number, r: any) => s + Number(r.count), 0) || 1 const statusMeta: Record<string, { label: string; bg: string; bar: string }> = { 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 ( <div key={status} className={`flex items-center gap-3 px-3 py-2 rounded-lg ${meta.bg}`}> <span className="text-sm text-gray-600 w-28 shrink-0">{meta.label}</span> <div className="flex-1 bg-gray-200 rounded-full h-2 overflow-hidden"> <div className={`h-full rounded-full ${meta.bar} transition-all`} style={{ width: `${pct}%` }} /> </div> <span className="text-sm font-semibold text-gray-700 w-8 text-right">{c}</span> <span className="text-xs text-gray-400 w-8">{pct}%</span> </div> ) }) })()} </div> </div> )} {/* ── Notification sends per day ── */} {notifsByDay.length > 0 && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-4"> <h2 className="font-semibold text-gray-800">{t('admin.stats.notif_chart_title')}</h2> <span className="flex items-center gap-1.5 text-xs text-gray-500"> <span className="w-3 h-0.5 bg-orange-500 inline-block rounded" /> {t('admin.stats.notif_label')} </span> </div> <AdminAreaChart series={[ { data: notifsByDay, color: '#f97316', fill: '#f9731615', label: t('admin.stats.notif_label') }, ]} /> </div> )} {/* ── Emails per day ── */} {mailByDay.length > 0 && ( <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-4"> <h2 className="font-semibold text-gray-800">{t('admin.stats.mail_chart_title')}</h2> <span className="flex items-center gap-1.5 text-xs text-gray-500"> <span className="w-3 h-0.5 bg-sky-500 inline-block rounded" /> {t('admin.stats.mail_label')} </span> </div> <AdminAreaChart series={[ { data: mailByDay, color: '#0ea5e9', fill: '#0ea5e915', label: t('admin.stats.mail_label') }, ]} /> </div> )} {/* ── Token usage ── */} {stats && ( <div className="bg-white rounded-xl border border-gray-200 p-6 space-y-5"> <div className="flex items-center justify-between"> <h2 className="font-semibold text-gray-800">{t('admin.stats.tokens_title')}</h2> <div className="flex gap-4 text-xs text-gray-500"> <span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-violet-500 inline-block rounded" />{t('admin.stats.tokens_out')}</span> <span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-sky-500 inline-block rounded" />{t('admin.stats.tokens_in')}</span> </div> </div> <div className="grid grid-cols-3 gap-4"> <div className="rounded-lg bg-violet-50 border border-violet-100 px-4 py-3"> <div className="text-xs text-violet-600 mb-1">{t('admin.stats.tokens_out_label')}</div> <div className="text-2xl font-bold text-violet-700">{Number(stats.totalOutputTokens).toLocaleString()}</div> </div> <div className="rounded-lg bg-sky-50 border border-sky-100 px-4 py-3"> <div className="text-xs text-sky-600 mb-1">{t('admin.stats.tokens_in_label')}</div> <div className="text-2xl font-bold text-sky-700">{Number(stats.totalInputTokens).toLocaleString()}</div> </div> <div className="rounded-lg bg-emerald-50 border border-emerald-100 px-4 py-3"> <div className="text-xs text-emerald-600 mb-1">{t('admin.stats.cost_label')}</div> <div className="text-2xl font-bold text-emerald-700">${Number(stats.totalCostUsd ?? 0).toFixed(4)}</div> </div> </div> {stats.tokensByMonth?.length > 0 && ( <AdminMonthChart dateLocale={dateLocale} series={[ { data: stats.tokensByMonth.map((r: any) => ({ 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') }, ]} /> )} </div> )} </div> )} {/* ── Users ── */} {tab === 'users' && ( <div> {/* Filters */} <div className="flex gap-3 mb-4"> <form className="flex gap-2 flex-1" onSubmit={(e) => { e.preventDefault(); setUsersQ(usersQInput) }} > <input value={usersQInput} onChange={(e) => 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" /> <button type="submit" className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700"> {t('tasks.search.btn')} </button> </form> <select value={usersRole} onChange={(e) => setUsersRole(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="">{t('admin.filter.all_roles')}</option> <option value="user">{t('admin.user_edit.role.user')}</option> <option value="admin">{t('profile.role.admin')}</option> </select> </div> <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> <table className="w-full text-sm"> <thead className="bg-gray-50 text-left"> <tr> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.user')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.email')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.role')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.plan', 'Plan')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.registered')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.status')}</th> <th className="px-4 py-3" /> </tr> </thead> <tbody className="divide-y divide-gray-100"> {loading ? ( <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">{t('common.loading')}</td></tr> ) : users.length === 0 ? ( <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">—</td></tr> ) : users.map((u) => ( <tr key={u.id} className="hover:bg-gray-50"> <td className="px-4 py-3 font-medium text-gray-800"> {formatShortName(u.firstName, u.lastName, u.name)} </td> <td className="px-4 py-3 text-gray-500">{u.email}</td> <td className="px-4 py-3"> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${ u.role === 'admin' ? 'bg-red-100 text-red-700' : u.role === 'specialist' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600' }`}> {t(`profile.role.${u.role}`) || u.role} </span> </td> <td className="px-4 py-3 text-gray-400 text-xs"> {u.planId ? (planRows.find((p: any) => p.id === u.planId)?.name ?? <span className="font-mono">{u.planId.slice(0, 8)}…</span>) : <span className="text-gray-300">—</span>} </td> <td className="px-4 py-3 text-gray-400"> {new Date(u.createdAt).toLocaleDateString(dateLocale)} </td> <td className="px-4 py-3"> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${ u.isActive !== false ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600' }`}> {u.isActive !== false ? t('admin.users.active') : t('admin.users.inactive')} </span> </td> <td className="px-4 py-3 text-right"> <div className="flex items-center justify-end gap-1.5"> <button onClick={() => openEditUser(u)} className="text-xs px-2.5 py-1 rounded-lg font-medium bg-gray-50 text-gray-600 hover:bg-gray-100 border border-gray-200" > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> </button> {u.role !== 'admin' && ( <button onClick={() => toggleUser(u.id, u.isActive !== false)} className={`text-xs px-3 py-1 rounded-lg font-medium ${ u.isActive !== false ? 'bg-red-50 text-red-600 hover:bg-red-100' : 'bg-green-50 text-green-600 hover:bg-green-100' }`} > {u.isActive !== false ? t('admin.users.deactivate') : t('admin.users.activate')} </button> )} </div> </td> </tr> ))} </tbody> </table> <Pagination page={usersPage} total={usersTotal} limit={20} onChange={setUsersPage} totalLabel={t('admin.pagination.total')} /> </div> </div> )} {tab === 'balances' && ( <div> <div className="flex flex-wrap gap-3 mb-4"> <input value={balanceUserId} onChange={(e) => 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" /> <select value={balanceKind} onChange={(e) => setBalanceKind(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="">{t('admin.balance.filter.all', 'All operations')}</option> <option value="topup">{t('admin.balance.kind.topup', 'Top-up')}</option> <option value="plan_purchase">{t('admin.balance.kind.plan_purchase', 'Plan charge')}</option> <option value="admin_topup">{t('admin.balance.kind.admin_topup', 'Admin top-up')}</option> <option value="admin_adjustment">{t('admin.balance.kind.admin_adjustment', 'Adjustment')}</option> </select> </div> <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> <table className="w-full text-sm"> <thead className="bg-gray-50 text-left"> <tr> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.user')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.balance.col.operation', 'Operation')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.balance.col.amount', 'Amount')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.balance.col.result', 'Balance')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.balance.col.description', 'Description')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.balance.col.date', 'Date')}</th> </tr> </thead> <tbody className="divide-y divide-gray-100"> {loading ? ( <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">{t('common.loading')}</td></tr> ) : balanceRows.length === 0 ? ( <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">{t('admin.balance.empty', 'No records yet')}</td></tr> ) : 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 ( <tr key={row.id} className="hover:bg-gray-50 align-top"> <td className="px-4 py-3"> <div className="font-medium text-gray-800">{row.userName || '—'}</div> <div className="text-xs text-gray-500 mt-0.5">{row.userEmail || row.userId}</div> </td> <td className="px-4 py-3"> <span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${isCredit ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'}`}> {kindLabel} </span> </td> <td className={`px-4 py-3 font-semibold ${isCredit ? 'text-emerald-700' : 'text-rose-700'}`}> {isCredit ? '+' : '-'}{Number(row.amount).toFixed(2)} {row.currency} </td> <td className="px-4 py-3 text-gray-600"> <div>{Number(row.balanceBefore).toFixed(2)} → {Number(row.balanceAfter).toFixed(2)} {row.currency}</div> </td> <td className="px-4 py-3 text-gray-500 max-w-sm"> {row.description || '—'} </td> <td className="px-4 py-3 text-gray-500 whitespace-nowrap"> {new Date(row.createdAt).toLocaleString(dateLocale)} </td> </tr> ) })} </tbody> </table> <Pagination page={balancePage} total={balanceTotal} limit={50} onChange={setBalancePage} totalLabel={t('admin.pagination.total')} /> </div> </div> )} {/* ── Tasks ── */} {tab === 'tasks' && ( <div> {/* Filters */} <div className="flex gap-3 mb-4"> <form className="flex gap-2 flex-1" onSubmit={(e) => { e.preventDefault(); setTasksQ(tasksQInput) }} > <input value={tasksQInput} onChange={(e) => 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" /> <button type="submit" className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700"> {t('tasks.search.btn')} </button> </form> <select value={tasksStatus} onChange={(e) => setTasksStatus(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="">{t('admin.filter.all_statuses')}</option> {['open', 'in_progress', 'completed', 'cancelled', 'draft'].map((s) => ( <option key={s} value={s}>{t(`task.status.${s}`, s)}</option> ))} </select> </div> <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> <table className="w-full text-sm"> <thead className="bg-gray-50 text-left"> <tr> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.title')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.customer')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.status')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.budget')}</th> <th className="px-4 py-3 font-medium text-gray-600">{t('admin.col.created')}</th> <th className="px-4 py-3" /> </tr> </thead> <tbody className="divide-y divide-gray-100"> {loading ? ( <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">{t('common.loading')}</td></tr> ) : tasks.length === 0 ? ( <tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">—</td></tr> ) : tasks.map((row) => { const task = row.task || row const customer = row.customer return ( <tr key={task.id} className="hover:bg-gray-50"> <td className="px-4 py-3 font-medium text-gray-800 max-w-xs"> <Link href={`/tasks/${task.id}`} className="hover:text-green-600 truncate block" target="_blank"> {(locale === 'en' && task.titleEn) || (locale === 'ru' && task.titleRu) || (locale === 'uk' && task.titleUk) || task.titleEl || task.title} </Link> </td> <td className="px-4 py-3 text-gray-500 text-xs">{customer?.name ?? '—'}</td> <td className="px-4 py-3"> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[task.status] ?? 'bg-gray-100 text-gray-600'}`}> {t(`task.status.${task.status}`, task.status)} </span> </td> <td className="px-4 py-3 text-gray-500">{task.budget ? `${formatPrice(task.budget)}€` : '—'}</td> <td className="px-4 py-3 text-gray-400"> {new Date(task.createdAt).toLocaleDateString(dateLocale)} </td> <td className="px-4 py-3 text-right"> <div className="flex gap-1.5 justify-end"> {task.status !== 'cancelled' && task.status !== 'completed' && ( <button onClick={() => cancelTask(task.id)} className="text-xs px-2.5 py-1 rounded-lg font-medium bg-yellow-50 text-yellow-700 hover:bg-yellow-100" > {t('admin.tasks.cancel')} </button> )} <button onClick={() => deleteTask(task.id, (locale === 'en' && task.titleEn) || (locale === 'ru' && task.titleRu) || (locale === 'uk' && task.titleUk) || task.titleEl || task.title)} className="text-xs px-2.5 py-1 rounded-lg font-medium bg-red-50 text-red-600 hover:bg-red-100" > {t('admin.tasks.delete')} </button> </div> </td> </tr> ) })} </tbody> </table> <Pagination page={tasksPage} total={tasksTotal} limit={20} onChange={setTasksPage} totalLabel={t('admin.pagination.total')} /> </div> </div> )} {/* ── Categories ── */} {tab === 'categories' && ( <div> <div className="flex justify-between items-center mb-4"> <p className="text-sm text-gray-500">{t('admin.cat.hint')}</p> <button onClick={() => openAddCat(null)} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700" > + {t('admin.cat.add_root')} </button> </div> {/* Form */} {catForm && ( <div className="bg-green-50 border border-green-200 rounded-xl p-5 mb-4"> <h3 className="font-semibold text-gray-800 mb-4"> {catForm.id ? t('admin.cat.edit') : t('admin.cat.new')} {catForm.parentId && <span className="text-xs text-gray-400 ml-2">({t('admin.cat.subcategory')})</span>} </h3> <div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-3"> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.slug')}</label> <input value={catForm.slug} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.icon')}</label> <CategoryIconPicker value={catForm.icon} onChange={(icon) => setCatForm((f) => f && ({ ...f, icon }))} onUpload={uploadCategoryIcon} uploading={catIconUploading} error={catIconError} presets={categoryIconPresets} /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.order')}</label> <input type="number" value={catForm.order} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_el')} 🇬🇷</label> <input value={catForm.namesEl} onChange={(e) => setCatForm((f) => f && ({ ...f, namesEl: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_en')} 🇬🇧</label> <input value={catForm.namesEn} onChange={(e) => setCatForm((f) => f && ({ ...f, namesEn: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_ru')} 🇷🇺</label> <input value={catForm.namesRu} onChange={(e) => setCatForm((f) => f && ({ ...f, namesRu: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_uk')} 🇺🇦</label> <input value={catForm.namesUk} onChange={(e) => setCatForm((f) => f && ({ ...f, namesUk: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> </div> {catForm.id && ( <label className="flex items-center gap-2 text-sm text-gray-700 mb-3"> <input type="checkbox" checked={catForm.isActive} onChange={(e) => setCatForm((f) => f && ({ ...f, isActive: e.target.checked }))} /> {t('admin.cat.active')} </label> )} <div className="flex gap-2"> <button onClick={saveCat} disabled={catSaving} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {catSaving ? t('common.saving') : t('common.save')} </button> <button onClick={() => setCatForm(null)} className="px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50" > {t('common.cancel')} </button> </div> </div> )} {/* Tree */} <div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100"> {catRows.length === 0 ? ( <div className="px-4 py-8 text-center text-gray-400">{t('admin.cat.empty')}</div> ) : ( <CatTree rows={catRows} parentId={null} depth={0} locale={locale} expanded={catExpanded} onToggle={toggleCatExpand} onEdit={openEditCat} onDelete={deleteCat} onAddChild={openAddCat} skillsExpanded={catSkillsExpanded} skillsCache={catSkillsCache} skillsLoading={catSkillsLoading} onToggleSkills={toggleCatSkills} skillForm={skillForm} skillSaving={skillSaving} onOpenAddSkill={openAddSkill} onOpenEditSkill={openEditSkill} onSaveSkill={saveSkill} onDeleteSkill={deleteSkill} onCancelSkillForm={() => setSkillForm(null)} onSkillFormChange={setSkillForm} t={t} /> )} </div> </div> )} {/* ── Locations ── */} {tab === 'locations' && ( <div> <div className="flex justify-between items-center mb-4"> <p className="text-sm text-gray-500">{t('admin.loc.hint')}</p> <button onClick={() => openAddLoc(null)} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700" > + {t('admin.loc.add_city')} </button> </div> {/* Form */} {locForm && ( <div className="bg-green-50 border border-green-200 rounded-xl p-5 mb-4"> <h3 className="font-semibold text-gray-800 mb-4"> {locForm.id ? t('admin.loc.edit') : t('admin.loc.new')} {locForm.parentId && <span className="text-xs text-gray-400 ml-2">({t('admin.loc.district')})</span>} </h3> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3"> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.slug')}</label> <input value={locForm.slug} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_el')} 🇬🇷</label> <input value={locForm.nameEl} onChange={(e) => setLocForm((f) => f && ({ ...f, nameEl: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_en')} 🇬🇧</label> <input value={locForm.nameEn} onChange={(e) => setLocForm((f) => f && ({ ...f, nameEn: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_ru')} 🇷🇺</label> <input value={locForm.nameRu} onChange={(e) => setLocForm((f) => f && ({ ...f, nameRu: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.name_uk')} 🇺🇦</label> <input value={locForm.nameUk} onChange={(e) => setLocForm((f) => f && ({ ...f, nameUk: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> </div> <div className="flex items-center gap-4 mb-3"> <div className="w-24"> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.cat.order')}</label> <input type="number" value={locForm.order} onChange={(e) => 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" /> </div> {locForm.id && ( <label className="flex items-center gap-2 text-sm text-gray-700 mt-4"> <input type="checkbox" checked={locForm.isActive} onChange={(e) => setLocForm((f) => f && ({ ...f, isActive: e.target.checked }))} /> {t('admin.cat.active')} </label> )} </div> <div className="flex gap-2"> <button onClick={saveLoc} disabled={locSaving} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {locSaving ? t('common.saving') : t('common.save')} </button> <button onClick={() => setLocForm(null)} className="px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50" > {t('common.cancel')} </button> </div> </div> )} {/* Tree */} <div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100"> {locRows.length === 0 ? ( <div className="px-4 py-8 text-center text-gray-400">{t('admin.loc.empty')}</div> ) : ( <LocTree rows={locRows} parentId={null} depth={0} locale={locale} expanded={locExpanded} onToggle={toggleLocExpand} onEdit={openEditLoc} onDelete={deleteLoc} onAddChild={openAddLoc} t={t} /> )} </div> </div> )} {/* ── Reports ── */} {tab === 'reports' && ( <div className="space-y-3"> {loading ? ( <div className="text-center py-10 text-gray-400">{t('common.loading')}</div> ) : reports.length === 0 ? ( <div className="text-center py-10 text-gray-400">{t('admin.reports.none')}</div> ) : ( reports.map((r) => ( <div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5"> <div className="flex items-start justify-between gap-4"> <div className="flex-1 min-w-0"> {/* Status + reason badges */} <div className="flex items-center gap-2 flex-wrap mb-2"> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${reportStatusColors[r.status] ?? 'bg-gray-100 text-gray-500'}`}> {r.status} </span> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${reasonColors[r.reason] ?? 'bg-gray-100 text-gray-600'}`}> {r.reason} </span> {/* Target link */} <span className="text-xs text-gray-400"> {r.targetType === 'task' ? t('admin.reports.target.task') : t('admin.reports.target.user')}:{' '} {r.targetTitle ? ( <Link href={r.targetType === 'task' ? `/tasks/${r.targetId}` : `/users/${r.targetId}`} className="text-green-600 hover:underline font-medium" target="_blank" > {r.targetTitle} </Link> ) : ( <span className="font-mono">#{r.targetId?.slice(0, 8)}</span> )} </span> </div> {/* Reporter */} {r.reporterName && ( <p className="text-xs text-gray-400 mb-1"> {t('admin.col.reporter')}: <span className="font-medium text-gray-600">{r.reporterName}</span> </p> )} {/* Description */} {r.description && ( <p className="text-sm text-gray-700">{r.description}</p> )} <p className="text-xs text-gray-400 mt-1.5"> {new Date(r.createdAt).toLocaleString(dateLocale)} </p> </div> {r.status === 'pending' && ( <div className="flex gap-2 flex-shrink-0"> <button onClick={() => handleReport(r.id, 'reviewed')} className="text-xs px-3 py-1.5 rounded-lg font-medium bg-green-50 text-green-700 hover:bg-green-100" > {t('admin.reports.reviewed')} </button> <button onClick={() => handleReport(r.id, 'dismissed')} className="text-xs px-3 py-1.5 rounded-lg font-medium bg-gray-100 text-gray-600 hover:bg-gray-200" > {t('admin.reports.dismiss')} </button> </div> )} </div> </div> )) )} <Pagination page={reportsPage} total={reportsTotal} limit={20} onChange={setReportsPage} totalLabel={t('admin.pagination.total')} /> </div> )} {/* ── Moderation ── */} {tab === 'moderation' && ( <div className="space-y-6"> <div className="grid md:grid-cols-2 gap-4"> <div className="bg-white rounded-xl border border-gray-200 p-5"> <div className="text-sm text-gray-500">{t('admin.moderation.pending_count', 'Pending')}</div> <div className="text-3xl font-bold text-gray-900 mt-1">{pendingModerationCards.length}</div> <div className="text-xs text-gray-400 mt-1">{t('admin.moderation.pending_hint', 'New proposals waiting for approval')}</div> </div> <div className="bg-white rounded-xl border border-gray-200 p-5"> <div className="text-sm text-gray-500">{t('admin.moderation.active_count', 'Active')}</div> <div className="text-3xl font-bold text-gray-900 mt-1">{activeModerationCards.length}</div> <div className="text-xs text-gray-400 mt-1">{t('admin.moderation.active_hint', 'Visible cards that can be blocked')}</div> </div> </div> {moderationCardsLoading ? ( <div className="text-center py-10 text-gray-400">{t('common.loading')}</div> ) : moderationCards.length === 0 ? ( <div className="text-center py-10 text-gray-400">{t('admin.moderation.none', 'No specialist cards found')}</div> ) : ( <div className="space-y-6"> <div> <h3 className="font-semibold text-gray-800 mb-3">{t('admin.moderation.pending_title', 'Pending approval')}</h3> <div className="space-y-3"> {pendingModerationCards.length === 0 ? ( <div className="text-sm text-gray-400 bg-white border border-dashed border-gray-200 rounded-xl p-4">{t('admin.moderation.pending_empty', 'No pending proposals right now')}</div> ) : pendingModerationCards.map((card) => ( <div key={card.id} className="bg-white rounded-xl border border-gray-200 p-4"> <div className="flex flex-wrap items-start justify-between gap-3"> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2 flex-wrap mb-1"> <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">{t('admin.moderation.pending', 'Pending')}</span> <span className="text-sm font-semibold text-gray-800 truncate">{card.title}</span> </div> <div className="text-xs text-gray-500 mb-2"> <Link href={`/users/${card.specialistId}`} className="hover:text-green-600 hover:underline font-medium"> {card.specialistName ?? card.specialistEmail ?? card.specialistId} </Link> </div> {card.description && <p className="text-sm text-gray-700 line-clamp-3">{card.description}</p>} {(card.categorySuggestions ?? []).length > 0 && ( <div className="mt-3 border-t border-amber-100 pt-3 space-y-2"> <p className="text-xs font-semibold text-amber-800"> {t('admin.moderation.category_suggestions', 'Requested categories')} </p> {card.categorySuggestions.map((suggestion: any) => ( <div key={suggestion.id} className="rounded-lg border border-amber-200 bg-amber-50 p-2.5"> <p className="text-xs font-medium text-gray-800">{suggestion.name}</p> <div className="mt-2 grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]"> <select value={categorySuggestionTargets[suggestion.id] ?? ''} onChange={(event) => setCategorySuggestionTargets((prev) => ({ ...prev, [suggestion.id]: event.target.value }))} className="min-w-0 rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-700" > <option value="">{t('admin.moderation.map_category', 'Map to existing category')}</option> {catRows.map((category) => ( <option key={category.id} value={category.id}>{getCatLabel(category, locale)}</option> ))} </select> <button onClick={() => resolveCategorySuggestion(card.id, suggestion, 'map')} disabled={!categorySuggestionTargets[suggestion.id] || resolvingCategorySuggestion === suggestion.id} className="rounded-lg bg-green-600 px-2.5 py-1.5 text-xs font-medium text-white disabled:opacity-50" > {t('admin.moderation.map', 'Map')} </button> <button onClick={() => resolveCategorySuggestion(card.id, suggestion, 'reject')} disabled={resolvingCategorySuggestion === suggestion.id} className="rounded-lg bg-white px-2.5 py-1.5 text-xs font-medium text-gray-600 ring-1 ring-gray-300 disabled:opacity-50" > {t('admin.moderation.reject', 'Reject')} </button> </div> <div className="mt-2 flex gap-2"> <input value={categorySuggestionSlugs[suggestion.id] ?? ''} onChange={(event) => 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" /> <button onClick={() => resolveCategorySuggestion(card.id, suggestion, 'create')} disabled={!categorySuggestionSlugs[suggestion.id]?.trim() || resolvingCategorySuggestion === suggestion.id} className="rounded-lg bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white disabled:opacity-50" > {t('admin.moderation.create_category', 'Create category')} </button> </div> </div> ))} </div> )} </div> <div className="flex gap-2 flex-shrink-0"> <button onClick={() => updateModerationCard(card.id, 'active')} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-green-600 text-white hover:bg-green-700" > {t('admin.moderation.approve', 'Approve')} </button> <button onClick={() => updateModerationCard(card.id, 'inactive')} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200" > {t('admin.moderation.block', 'Block')} </button> </div> </div> </div> ))} </div> </div> <div> <h3 className="font-semibold text-gray-800 mb-3">{t('admin.moderation.active_title', 'Active cards')}</h3> <div className="space-y-3"> {activeModerationCards.length === 0 ? ( <div className="text-sm text-gray-400 bg-white border border-dashed border-gray-200 rounded-xl p-4">{t('admin.moderation.active_empty', 'No active cards found')}</div> ) : activeModerationCards.map((card) => ( <div key={card.id} className="bg-white rounded-xl border border-gray-200 p-4"> <div className="flex flex-wrap items-start justify-between gap-3"> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2 flex-wrap mb-1"> <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">{t('admin.moderation.active', 'Active')}</span> <span className="text-sm font-semibold text-gray-800 truncate">{card.title}</span> </div> <div className="text-xs text-gray-500 mb-2"> <Link href={`/users/${card.specialistId}`} className="hover:text-green-600 hover:underline font-medium"> {card.specialistName ?? card.specialistEmail ?? card.specialistId} </Link> </div> {card.description && <p className="text-sm text-gray-700 line-clamp-3">{card.description}</p>} </div> <button onClick={() => updateModerationCard(card.id, 'inactive')} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-50 text-red-700 hover:bg-red-100" > {t('admin.moderation.block', 'Block')} </button> </div> </div> ))} </div> </div> <Pagination page={moderationCardsPage} total={moderationCardsTotal} limit={20} onChange={setModerationCardsPage} totalLabel={t('admin.pagination.total')} /> </div> )} </div> )} {/* ── Logs ── */} {tab === 'logs' && ( <div> {/* Sub-tabs */} <div className="flex gap-1 bg-gray-100 rounded-xl p-1 mb-5 w-fit"> {([ { 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 }) => ( <button key={key} onClick={() => setLogsSubTab(key)} className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-colors ${logsSubTab === key ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`} > {label} </button> ))} </div> {/* AI Logs */} {logsSubTab === 'ai' && ( <div> <div className="flex justify-between items-center mb-4"> <p className="text-sm text-gray-500">{t('admin.pagination.total')}: {logsTotal}</p> <button onClick={() => { setLogsPage(1); api.getAdminAiLogs(1).then((d) => { setLogs(d.data); setLogsTotal(d.total) }).catch(() => {}) }} className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 flex items-center gap-1.5" > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><polyline points="23 4 23 10 17 10"/><path strokeLinecap="round" strokeLinejoin="round" d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg> {t('common.refresh')} </button> </div> {loading ? ( <div className="text-center py-10 text-gray-400">{t('common.loading')}</div> ) : logs.length === 0 ? ( <div className="text-center py-10 text-gray-400">{t('admin.logs.empty')}</div> ) : ( <div className="overflow-x-auto rounded-xl border border-gray-200"> <table className="w-full text-sm"> <thead className="bg-gray-50 text-gray-500 text-xs uppercase"> <tr> <th className="px-4 py-3 text-left">{t('admin.logs.date')}</th> <th className="px-4 py-3 text-left">{t('admin.logs.provider')}</th> <th className="px-4 py-3 text-left">{t('admin.logs.model')}</th> <th className="px-4 py-3 text-left">{t('admin.logs.action')}</th> <th className="px-4 py-3 text-left">{t('admin.logs.langs')}</th> <th className="px-4 py-3 text-right">{t('admin.logs.in')}</th> <th className="px-4 py-3 text-right">{t('admin.logs.out')}</th> <th className="px-4 py-3 text-right">{t('admin.logs.total')}</th> <th className="px-4 py-3 text-right">{t('admin.logs.ms')}</th> <th className="px-4 py-3 text-center">{t('admin.logs.status')}</th> </tr> </thead> <tbody className="divide-y divide-gray-100"> {logs.map((log) => ( <tr key={log.id} className="bg-white hover:bg-gray-50"> <td className="px-4 py-3 text-gray-500 whitespace-nowrap">{new Date(log.createdAt).toLocaleString(dateLocale)}</td> <td className="px-4 py-3"> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${log.provider === 'anthropic' ? 'bg-purple-100 text-purple-700' : 'bg-green-100 text-green-700'}`}>{log.provider}</span> </td> <td className="px-4 py-3 text-gray-600 font-mono text-xs">{log.model}</td> <td className="px-4 py-3 text-gray-600">{log.action}</td> <td className="px-4 py-3 text-gray-500">{log.fromLocale && log.toLocale ? `${log.fromLocale} → ${log.toLocale}` : '—'}</td> <td className="px-4 py-3 text-right text-gray-600">{log.inputTokens ?? '—'}</td> <td className="px-4 py-3 text-right text-gray-600">{log.outputTokens ?? '—'}</td> <td className="px-4 py-3 text-right font-medium">{log.totalTokens ?? '—'}</td> <td className="px-4 py-3 text-right text-gray-500">{log.durationMs ?? '—'}</td> <td className="px-4 py-3 text-center"> {log.success ? ( <span className="text-green-600 flex justify-center"><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="20 6 9 17 4 12"/></svg></span> ) : ( <span className="text-red-500 flex justify-center" title={log.error ?? ''}><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span> )} </td> </tr> ))} </tbody> </table> </div> )} <Pagination page={logsPage} total={logsTotal} limit={50} onChange={setLogsPage} totalLabel={t('admin.pagination.total')} /> </div> )} {/* Activity Logs */} {logsSubTab === 'activity' && ( <div> <div className="bg-white rounded-xl border border-gray-200 p-4 mb-4 flex flex-wrap gap-3 items-end"> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.activity.event')}</label> <select value={activityEvent} onChange={(e) => setActivityEvent(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm min-w-[180px]" > <option value="">{t('admin.activity.all_events')}</option> {activityEventTypes.map((ev) => ( <option key={ev} value={ev}>{ev}</option> ))} </select> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.activity.from')}</label> <input type="date" value={activityDateFrom} onChange={(e) => setActivityDateFrom(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.activity.to')}</label> <input type="date" value={activityDateTo} onChange={(e) => setActivityDateTo(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> {(activityEvent || activityDateFrom || activityDateTo || activityUserId) && ( <button onClick={() => { setActivityEvent(''); setActivityDateFrom(''); setActivityDateTo(''); setActivityUserId('') }} className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 text-gray-500 hover:bg-gray-200 self-end flex items-center gap-1.5" > <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> {t('admin.activity.reset')} </button> )} <p className="text-xs text-gray-400 self-end ml-auto">{t('admin.activity.total')}: {activityTotal}</p> </div> {activityLog.length === 0 ? ( <div className="text-center py-10 text-gray-400">{t('admin.activity.empty')}</div> ) : ( <div className="space-y-2"> {activityLog.map((entry) => ( <ActivityLogRow key={entry.id} entry={entry} dateLocale={dateLocale} onFilterUser={(id) => { setActivityUserId(id); setActivityEvent('') }} t={t} /> ))} </div> )} <Pagination page={activityPage} total={activityTotal} limit={50} onChange={setActivityPage} totalLabel={t('admin.pagination.total')} /> </div> )} {/* Mail Logs */} {logsSubTab === 'mail' && ( <div> <div className="bg-white rounded-xl border border-gray-200 p-4 mb-4 flex flex-wrap gap-3 items-end"> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.mail.event')}</label> <select value={mailEvent} onChange={(e) => setMailEvent(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm min-w-[200px]" > <option value="">{t('admin.mail.all_events')}</option> {mailEventTypes.map((ev) => ( <option key={ev} value={ev}>{t(`admin.event.${ev.replace(/\./g, '_')}`, ev)}</option> ))} </select> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.mail.from')}</label> <input type="date" value={mailDateFrom} onChange={(e) => setMailDateFrom(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.mail.to')}</label> <input type="date" value={mailDateTo} onChange={(e) => setMailDateTo(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> {(mailEvent || mailDateFrom || mailDateTo) && ( <button onClick={() => { setMailEvent(''); setMailDateFrom(''); setMailDateTo('') }} className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 text-gray-500 hover:bg-gray-200 self-end flex items-center gap-1.5" > <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> {t('admin.mail.reset')} </button> )} <p className="text-xs text-gray-400 self-end ml-auto">{t('admin.mail.total')}: {mailTotal}</p> </div> {mailLog.length === 0 ? ( <div className="text-center py-10 text-gray-400">{t('admin.mail.empty')}</div> ) : ( <div className="overflow-x-auto rounded-xl border border-gray-200"> <table className="w-full text-sm"> <thead className="bg-gray-50 text-gray-500 text-xs uppercase"> <tr> <th className="px-4 py-3 text-left">{t('admin.logs.date')}</th> <th className="px-4 py-3 text-left">{t('admin.mail.recipient')}</th> <th className="px-4 py-3 text-left">{t('admin.mail.event')}</th> <th className="px-4 py-3 text-left">{t('admin.mail.provider')}</th> <th className="px-4 py-3 text-left">{t('admin.mail.details')}</th> <th className="px-4 py-3 text-center">{t('admin.mail.status')}</th> </tr> </thead> <tbody className="divide-y divide-gray-100"> {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 ( <tr key={entry.id} className="bg-white hover:bg-gray-50 align-top"> <td className="px-4 py-3 text-gray-500 whitespace-nowrap">{new Date(entry.createdAt).toLocaleString(dateLocale)}</td> <td className="px-4 py-3 text-gray-700">{recipient}</td> <td className="px-4 py-3"> <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-sky-50 text-sky-700">{t(`admin.event.${entry.event.replace(/\./g, '_')}`, entry.event)}</span> </td> <td className="px-4 py-3 text-gray-600">{provider}</td> <td className="px-4 py-3 text-gray-600 max-w-md"> <div className="truncate" title={subject || error || undefined}>{subject || error || '—'}</div> </td> <td className="px-4 py-3 text-center"> {success ? ( <span className="text-green-600 inline-flex justify-center" title={t('admin.mail.success')}><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="20 6 9 17 4 12"/></svg></span> ) : ( <span className="text-red-500 inline-flex justify-center" title={error || t('admin.mail.failed')}><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span> )} </td> </tr> ) })} </tbody> </table> </div> )} <Pagination page={mailPage} total={mailTotal} limit={50} onChange={setMailPage} totalLabel={t('admin.pagination.total')} /> </div> )} {/* Notification Logs */} {logsSubTab === 'notifications' && ( <div> <div className="bg-white rounded-xl border border-gray-200 p-4 mb-4 flex flex-wrap gap-3 items-end"> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.notif_logs.type')}</label> <select value={notifLogsType} onChange={(e) => setNotifLogsType(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm min-w-[200px]" > <option value="">{t('admin.filter.all')}</option> {notifLogsTypes.map((tp) => ( <option key={tp} value={tp}>{tp}</option> ))} </select> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.activity.from')}</label> <input type="date" value={notifLogsDateFrom} onChange={(e) => setNotifLogsDateFrom(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> <div> <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.activity.to')}</label> <input type="date" value={notifLogsDateTo} onChange={(e) => setNotifLogsDateTo(e.target.value)} className="border border-gray-300 rounded-lg px-3 py-1.5 text-sm" /> </div> {(notifLogsType || notifLogsDateFrom || notifLogsDateTo) && ( <button onClick={() => { setNotifLogsType(''); setNotifLogsDateFrom(''); setNotifLogsDateTo('') }} className="text-xs px-3 py-1.5 rounded-lg bg-gray-100 text-gray-500 hover:bg-gray-200 self-end flex items-center gap-1.5" > <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> {t('admin.activity.reset')} </button> )} <p className="text-xs text-gray-400 self-end ml-auto">{t('admin.pagination.total')}: {notifLogsTotal}</p> </div> {notifLogs.length === 0 ? ( <div className="text-center py-10 text-gray-400">{t('admin.notif_logs.empty')}</div> ) : ( <div className="overflow-x-auto rounded-xl border border-gray-200"> <table className="w-full text-sm"> <thead className="bg-gray-50 text-gray-500 text-xs uppercase"> <tr> <th className="px-4 py-3 text-left">{t('admin.logs.date')}</th> <th className="px-4 py-3 text-left">{t('admin.col.user')}</th> <th className="px-4 py-3 text-left">{t('admin.notif_logs.type')}</th> <th className="px-4 py-3 text-left">{t('admin.notif_logs.title')}</th> <th className="px-4 py-3 text-center">{t('admin.notif_logs.read')}</th> </tr> </thead> <tbody className="divide-y divide-gray-100"> {notifLogs.map((n) => ( <tr key={n.id} className="bg-white hover:bg-gray-50"> <td className="px-4 py-3 text-gray-500 whitespace-nowrap">{new Date(n.createdAt).toLocaleString(dateLocale)}</td> <td className="px-4 py-3 text-gray-700">{n.recipientName ?? n.recipientEmail ?? n.recipientId}</td> <td className="px-4 py-3"> <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">{n.type}</span> </td> <td className="px-4 py-3 text-gray-600 max-w-xs truncate">{n.title}</td> <td className="px-4 py-3 text-center"> {n.isRead ? ( <span className="text-green-600 flex justify-center"><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="20 6 9 17 4 12"/></svg></span> ) : ( <span className="text-gray-400 flex justify-center"><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10"/></svg></span> )} </td> </tr> ))} </tbody> </table> </div> )} <Pagination page={notifLogsPage} total={notifLogsTotal} limit={50} onChange={setNotifLogsPage} totalLabel={t('admin.pagination.total')} /> </div> )} </div> )} {/* ── Plans ── */} {tab === 'plans' && ( <div> <div className="flex items-center justify-between mb-4"> <h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2"> <svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg> {t('admin.plans.title')} </h2> <button onClick={openAddPlan} className="bg-green-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-green-700 font-medium" > {t('admin.plans.add')} </button> </div> {planForm && ( <div className="bg-white border border-gray-200 rounded-xl p-5 mb-4 space-y-3"> <h3 className="font-semibold text-gray-800">{planForm.id ? t('admin.plans.form.edit') : t('admin.plans.form.new')}</h3> {/* Basic */} <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.name')}</label> <input value={planForm.name} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.price')}</label> <input type="number" min="0" step="0.01" value={planForm.price} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.old_price')}</label> <input type="number" min="0" step="0.01" value={planForm.oldPrice ?? ''} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.tier')}</label> <select value={planForm.tier} onChange={(e) => setPlanForm({ ...planForm, tier: 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" > <option value="free">{t('admin.plans.form.tier.free')}</option> <option value="pro">{t('admin.plans.form.tier.pro')}</option> <option value="ultimate">{t('admin.plans.form.tier.ultimate')}</option> </select> </div> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.description')}</label> <input value={planForm.description} onChange={(e) => 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" /> </div> {/* Limits */} <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide pt-1">{t('admin.plans.form.section.limits')}</p> <div className="grid grid-cols-3 gap-3"> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.max_tasks')}</label> <input type="number" min="0" placeholder="∞" value={planForm.maxTasks} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.max_offers')}</label> <input type="number" min="0" placeholder="∞" value={planForm.maxOffers} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.max_cards')}</label> <input type="number" min="0" placeholder="∞" value={planForm.maxCards} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.max_messages_day')}</label> <input type="number" min="0" placeholder="∞" value={planForm.maxMessagesPerDay} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.max_orders_day')}</label> <input type="number" min="0" placeholder="∞" value={planForm.maxOrdersPerDay} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.max_skills')}</label> <input type="number" min="0" placeholder="∞" value={planForm.maxSkills} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.duration_days')}</label> <input type="number" min="0" placeholder="∞" value={planForm.durationDays} onChange={(e) => 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" /> </div> </div> {/* Multipliers */} <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide pt-1">{t('admin.plans.form.section.boost')}</p> <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.search_boost')}</label> <input type="number" min="0" value={planForm.searchBoost} onChange={(e) => 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.offers_multiplier')}</label> <input type="number" min="0" step="0.01" value={planForm.offersMultiplier} onChange={(e) => 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" /> </div> </div> {/* Features */} <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.plans.form.features')}</label> <textarea rows={3} value={planForm.features} onChange={(e) => setPlanForm({ ...planForm, features: 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 resize-none" /> </div> {/* Contact permissions */} <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide pt-1">{t('admin.plans.form.section.contact')}</p> <div className="grid grid-cols-2 gap-2"> {([ ['canContactFreePlan', t('admin.plans.form.can_contact_free')], ['canContactProPlan', t('admin.plans.form.can_contact_pro')], ['canContactAll', t('admin.plans.form.can_contact_all')], ['canShowContactInfo', t('admin.plans.form.can_show_contact')], ['canViewPhone', t('admin.plans.form.can_view_phone')], ] as [keyof typeof planForm, string][]).map(([key, label]) => ( <label key={key} className="flex items-center gap-2 cursor-pointer text-sm text-gray-700"> <input type="checkbox" checked={planForm[key] as boolean} onChange={(e) => setPlanForm({ ...planForm, [key]: e.target.checked })} className="w-4 h-4 rounded text-green-600" /> {label} </label> ))} </div> {/* Feature flags */} <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide pt-1">{t('admin.plans.form.section.features')}</p> <div className="grid grid-cols-2 gap-2"> {([ ['notifyNewTasks', t('admin.plans.form.notify')], ['canUploadVideo', t('admin.plans.form.upload_video')], ['hasFavorites', t('admin.plans.form.favorites')], ['hasGoogleCalendar', t('admin.plans.form.google_calendar')], ['hasVerifiedBadge', t('admin.plans.form.verified_badge')], ['highlightedReviews', t('admin.plans.form.highlighted_reviews')], ['hasPersonalSite', t('admin.plans.form.personal_site')], ['hasCanHelpNowStatus', t('admin.plans.form.can_help_now')], ['hasNeedHelpStatus', t('admin.plans.form.need_help')], ['hasAutoResponse', t('admin.plans.form.auto_response')], ['hasAutoMatch', t('admin.plans.form.auto_match')], ['hasPriceList', t('admin.plans.form.price_list')], ['receiveInstantDispatchFree', t('admin.plans.form.instant_dispatch_free')], ['receiveInstantDispatchPro', t('admin.plans.form.instant_dispatch_pro')], ] as [keyof typeof planForm, string][]).map(([key, label]) => ( <label key={key} className="flex items-center gap-2 cursor-pointer text-sm text-gray-700"> <input type="checkbox" checked={planForm[key] as boolean} onChange={(e) => setPlanForm({ ...planForm, [key]: e.target.checked })} className="w-4 h-4 rounded text-green-600" /> {label} </label> ))} </div> {/* Status & order */} <div className="flex items-center gap-6 pt-1"> <label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700"> <input type="checkbox" checked={planForm.isDefault} onChange={(e) => setPlanForm({ ...planForm, isDefault: e.target.checked })} className="w-4 h-4 rounded text-green-600" /> {t('admin.plans.form.default')} </label> <label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700"> <input type="checkbox" checked={planForm.isActive} onChange={(e) => setPlanForm({ ...planForm, isActive: e.target.checked })} className="w-4 h-4 rounded text-green-600" /> {t('admin.plans.form.active')} </label> <div className="flex items-center gap-2"> <label className="text-xs text-gray-600">{t('admin.plans.form.order')}</label> <input type="number" value={planForm.order} onChange={(e) => setPlanForm({ ...planForm, order: Number(e.target.value) })} className="w-16 border border-gray-300 rounded-lg px-2 py-1 text-sm focus:outline-none" /> </div> </div> <div className="flex gap-2 pt-1"> <button onClick={savePlan} disabled={planSaving || !planForm.name} className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {planSaving ? '...' : t('common.save')} </button> <button onClick={() => setPlanForm(null)} className="px-4 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100" > {t('common.cancel')} </button> </div> </div> )} <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> {planRows.length === 0 ? ( <div className="px-4 py-8 text-center text-gray-400 text-sm">{t('admin.plans.empty')}</div> ) : ( <div className="divide-y divide-gray-100"> {planRows.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((plan) => ( <div key={plan.id} className={`flex items-start gap-4 px-4 py-4 ${!plan.isActive ? 'opacity-50' : ''}`}> <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap"> <span className="font-semibold text-gray-900">{plan.name}</span> {plan.isDefault && ( <span className="bg-green-100 text-green-700 text-xs px-2 py-0.5 rounded-full font-medium">{t('admin.plans.badge.default')}</span> )} {!plan.isActive && ( <span className="bg-gray-100 text-gray-500 text-xs px-2 py-0.5 rounded-full">{t('admin.plans.badge.inactive')}</span> )} </div> {plan.description && ( <p className="text-sm text-gray-500 mt-0.5">{plan.description}</p> )} <div className="flex flex-wrap gap-3 mt-1 text-xs text-gray-500"> <span className="flex items-center gap-1.5 font-semibold text-gray-800"> {plan.oldPrice && ( <span className="font-normal text-gray-400 line-through">{plan.oldPrice} {plan.currency}</span> )} <span>{plan.price} {plan.currency}</span> </span> {plan.maxTasks != null && <span>{t('admin.plans.tasks')}: {plan.maxTasks}</span>} {plan.maxOffers != null && <span>{t('admin.plans.offers')}: {plan.maxOffers}</span>} {plan.maxCards != null && <span>{t('admin.plans.cards')}: {plan.maxCards}</span>} {plan.notifyNewTasks && ( <span className="text-green-700 flex items-center gap-1"> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> {t('admin.plans.notify_tasks')} </span> )} </div> {(plan.features ?? []).length > 0 && ( <ul className="mt-1.5 space-y-0.5"> {plan.features.map((f: string, i: number) => ( <li key={i} className="text-xs text-gray-600 flex items-center gap-1.5"> <span className="text-green-500"><svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="20 6 9 17 4 12"/></svg></span> {f} </li> ))} </ul> )} </div> <div className="flex gap-1 shrink-0"> {!plan.isDefault && ( <button onClick={() => api.updatePlan(plan.id, { isDefault: true }).then(() => api.getAdminPlans().then(setPlanRows)).catch(() => {})} className="text-xs px-2 py-1 rounded-lg bg-green-50 text-green-600 hover:bg-green-100 font-medium" title={t('admin.plans.make_default')} > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> </button> )} <button onClick={() => openEditPlan(plan)} className="text-xs px-2 py-1 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 font-medium" > {t('common.edit')} </button> <button onClick={() => deletePlan(plan.id, plan.name)} className="text-xs px-2 py-1 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 font-medium" > {t('common.delete')} </button> </div> </div> ))} </div> )} </div> </div> )} {/* ── Settings ── */} {tab === 'settings' && ( <div> {/* Settings sub-tabs */} <div className="flex gap-1 mb-6 bg-white rounded-xl border border-gray-200 p-1 w-fit"> {(['general', 'smtp', 'notifications', 'payment', 'referral', 'seo', 'socials', 'telegram'] as const).map((st) => { const labels: Record<string, string> = { general: t('admin.settings.tab.general'), smtp: t('admin.settings.tab.smtp'), notifications: t('admin.settings.tab.notifications'), payment: t('admin.settings.tab.payment'), referral: t('admin.settings.tab.referral'), seo: t('admin.settings.tab.seo'), socials: t('admin.settings.tab.socials'), telegram: t('admin.settings.tab.telegram'), } return ( <button key={st} onClick={() => setSettingsTab(st)} className={`px-4 py-2 rounded-lg text-sm font-medium transition ${ settingsTab === st ? 'bg-green-600 text-white shadow-sm' : 'text-gray-600 hover:bg-gray-100' }`} > {labels[st]} </button> ) })} </div> {/* General */} {settingsTab === 'general' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 max-w-lg"> <h3 className="font-semibold text-gray-800 mb-5">{t('admin.settings.general.title')}</h3> <div className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.general.site_name')}</label> <input value={generalForm.siteName} onChange={(e) => setGeneralForm((f) => ({ ...f, siteName: 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" placeholder="CanHelp" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.general.site_url')}</label> <input value={generalForm.siteUrl} onChange={(e) => setGeneralForm((f) => ({ ...f, siteUrl: 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" placeholder="https://canhelp.com" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.general.contact_email')}</label> <input type="email" value={generalForm.contactEmail} onChange={(e) => setGeneralForm((f) => ({ ...f, contactEmail: 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" placeholder="contact@canhelp.com" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.general.support_phone')}</label> <input value={generalForm.supportPhone} onChange={(e) => setGeneralForm((f) => ({ ...f, supportPhone: 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" placeholder="+30 21 000 0000" /> </div> <label className="flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-gray-200 px-3 py-3"> <span> <span className="block text-sm font-medium text-gray-700">{t('admin.settings.general.show_pricing')}</span> <span className="mt-0.5 block text-xs text-gray-500">{t('admin.settings.general.show_pricing_hint')}</span> </span> <input type="checkbox" checked={generalForm.showPricing} onChange={(event) => setGeneralForm((form) => ({ ...form, showPricing: event.target.checked }))} className="h-5 w-5 accent-green-600" /> </label> <button onClick={saveGeneralSettings} disabled={generalSaving} className="w-full py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 transition" > {generalSaving ? t('admin.settings.saving') : generalSaved ? t('admin.settings.saved') : t('common.save')} </button> </div> </div> )} {/* SMTP */} {settingsTab === 'smtp' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 max-w-lg"> <h3 className="font-semibold text-gray-800 mb-1">{t('admin.settings.smtp.title')}</h3> <p className="text-sm text-gray-400 mb-5">{t('admin.settings.smtp.hint')}</p> <div className="space-y-4"> <div className="grid grid-cols-3 gap-3"> <div className="col-span-2"> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.smtp.host')}</label> <input value={smtpForm.host} onChange={(e) => setSmtpForm((f) => ({ ...f, host: 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" placeholder="smtp.gmail.com" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.smtp.port')}</label> <input value={smtpForm.port} onChange={(e) => setSmtpForm((f) => ({ ...f, port: 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" placeholder="587" /> </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.smtp.encryption')}</label> <select value={smtpForm.encryption} onChange={(e) => setSmtpForm((f) => ({ ...f, encryption: e.target.value as 'none' | 'tls' | 'ssl' }))} 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" > <option value="none">{t('admin.settings.smtp.enc.none')}</option> <option value="tls">STARTTLS (587)</option> <option value="ssl">SSL/TLS (465)</option> </select> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.smtp.username')}</label> <input value={smtpForm.username} onChange={(e) => setSmtpForm((f) => ({ ...f, username: 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" placeholder="user@gmail.com" autoComplete="off" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.smtp.password')}</label> <input type="password" value={smtpForm.password} onChange={(e) => setSmtpForm((f) => ({ ...f, password: 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" autoComplete="new-password" placeholder="••••••••••••" /> </div> <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.smtp.from_name')}</label> <input value={smtpForm.fromName} onChange={(e) => setSmtpForm((f) => ({ ...f, fromName: 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" placeholder="CanHelp" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.smtp.from_email')}</label> <input type="email" value={smtpForm.fromEmail} onChange={(e) => setSmtpForm((f) => ({ ...f, fromEmail: 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" placeholder="noreply@canhelp.com" /> </div> </div> <button onClick={saveSmtpSettings} disabled={smtpSaving} className="w-full py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 transition" > {smtpSaving ? t('admin.settings.saving') : smtpSaved ? t('admin.settings.saved') : t('common.save')} </button> <button onClick={async () => { setTestEmailSending(true) setTestEmailResult(null) try { const res = await api.sendTestEmail() setTestEmailResult(`✓ Sent to ${res.to}`) } catch (e: any) { setTestEmailResult(`✗ Error: ${e.message}`) } finally { setTestEmailSending(false) setTimeout(() => setTestEmailResult(null), 5000) } }} disabled={testEmailSending} className="w-full py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 disabled:opacity-50 transition" > {testEmailSending ? t('admin.settings.smtp.sending') : ( <span className="flex items-center justify-center gap-2"> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> {t('admin.settings.smtp.test')} </span> )} </button> {testEmailResult && ( <p className={`text-sm text-center font-medium ${testEmailResult.startsWith('✓') ? 'text-green-600' : 'text-red-500'}`}> {testEmailResult} </p> )} </div> </div> )} {/* Payment */} {settingsTab === 'payment' && ( <div className="space-y-6 max-w-lg"> {/* ePay — Piraeus Bank */} <div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="flex items-center justify-between mb-5"> <div className="flex items-center gap-3"> <div className="w-10 h-10 rounded-lg bg-[#003087] flex items-center justify-center text-white text-xs font-bold shrink-0">PB</div> <div> <h3 className="font-semibold text-gray-800">ePay — Piraeus Bank</h3> <p className="text-xs text-gray-400">Греческий платёжный шлюз</p> </div> </div> <label className="flex items-center gap-2 cursor-pointer"> <span className="text-sm text-gray-500">{t('admin.settings.active')}</span> <button type="button" onClick={() => setEPayForm((f) => ({ ...f, enabled: !f.enabled }))} className={`relative w-10 h-6 rounded-full p-0 transition-colors cursor-pointer ${ ePayForm.enabled ? 'bg-green-600' : 'bg-gray-300' }`} > <span className={`absolute left-0 top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${ ePayForm.enabled ? 'translate-x-5' : 'translate-x-1' }`} /> </button> </label> </div> <div className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.env.label')}</label> <select value={ePayForm.environment} onChange={(e) => setEPayForm((f) => ({ ...f, environment: e.target.value as 'test' | 'production' }))} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="test">{t('admin.settings.env.test')}</option> <option value="production">{t('admin.settings.env.prod')}</option> </select> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.merchant_id')}</label> <input value={ePayForm.merchantId} onChange={(e) => setEPayForm((f) => ({ ...f, merchantId: e.target.value }))} placeholder={t('admin.settings.merchant_id')} 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" /> </div> <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">API User</label> <input value={ePayForm.apiUser} onChange={(e) => setEPayForm((f) => ({ ...f, apiUser: e.target.value }))} autoComplete="off" 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">API Password</label> <input type="password" value={ePayForm.apiPassword} onChange={(e) => setEPayForm((f) => ({ ...f, apiPassword: e.target.value }))} autoComplete="new-password" 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" /> </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Confirmation URL (webhook)</label> <input value={ePayForm.confirmationUrl} onChange={(e) => setEPayForm((f) => ({ ...f, confirmationUrl: e.target.value }))} placeholder="https://yoursite.com/api/payments/epay/confirm" 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Cancel URL</label> <input value={ePayForm.cancelUrl} onChange={(e) => setEPayForm((f) => ({ ...f, cancelUrl: e.target.value }))} placeholder="https://yoursite.com/payment/cancelled" 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" /> </div> </div> <div className="flex items-center gap-3 mt-5"> <button onClick={saveEPaySettings} disabled={ePaySaving} className="px-5 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {ePaySaving ? t('admin.settings.saving') : t('common.save')} </button> {ePaySaved && <span className="text-sm text-green-600">{t('admin.settings.saved')}</span>} </div> </div> <div className="p-3 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-700 flex items-start gap-2"> <svg className="w-4 h-4 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <span>{t('admin.settings.payment.hint')}</span> </div> </div> )} {/* Notification templates */} {settingsTab === 'notifications' && ( <div className="space-y-3 max-w-2xl"> <div className="flex items-start gap-2 mb-4 p-3 bg-green-50 border border-green-100 rounded-xl text-sm text-green-700"> <svg className="w-4 h-4 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <p> {t('admin.notif.vars_hint')}{' '} {TEMPLATE_HINT_VARS.map((name) => { const v = `{{${name}}}` return ( <code key={v} className="mx-0.5 bg-green-100 text-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">{v}</code> ) })} </p> </div> {[ { key: 'email_verification', label: t('admin.notif.email_verification'), icon: <svg className="w-5 h-5 text-gray-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> }, { key: 'registration_success', label: t('admin.notif.registration_success'), icon: <svg className="w-5 h-5 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> }, { key: 'password_reset', label: t('admin.notif.password_reset'), icon: <svg className="w-5 h-5 text-amber-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg> }, { key: 'new_offer', label: t('admin.notif.new_offer'), icon: <svg className="w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg> }, { key: 'new_task', label: t('admin.notif.new_task'), icon: <svg className="w-5 h-5 text-indigo-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg> }, { key: 'offer_accepted', label: t('admin.notif.offer_accepted'), icon: <svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/></svg> }, { key: 'new_message', label: t('admin.notif.new_message'), icon: <svg className="w-5 h-5 text-pink-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> }, { key: 'offer_declined', label: t('admin.notif.offer_declined'), icon: <svg className="w-5 h-5 text-red-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> }, { key: 'offer_other_accepted', label: t('admin.notif.offer_other_accepted'), icon: <svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg> }, { key: 'task_updated', label: t('admin.notif.task_updated'), icon: <svg className="w-5 h-5 text-gray-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> }, { key: 'deadline_reminder', label: t('admin.notif.deadline_reminder'), icon: <svg className="w-5 h-5 text-yellow-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> }, { key: 'task_archived', label: t('admin.notif.task_archived'), icon: <svg className="w-5 h-5 text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8l1 12a2 2 0 002 2h8a2 2 0 002-2l1-12M10 12v6m4-6v6"/></svg> }, { key: 'referral_reward', label: t('admin.notif.referral_reward'), icon: <svg className="w-5 h-5 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v13m0-13V6a2 2 0 112.83 2.83l-2.83 2.83zm0 0V5.5A2.5 2.5 0 119.27 8H12zm-7 4h14M5 12a2 2 0 110-4h3.586a1 1 0 01.707.293l2.414 2.414M19 12a2 2 0 110-4h-3.586a1 1 0 00-.707.293l-2.414 2.414"/></svg> }, // ── Plan / subscription emails ───────────────────────────── { key: 'plan_activated', label: t('admin.email_template.plan_activated'), icon: <svg className="w-5 h-5 text-gray-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> }, { key: 'plan_upgraded', label: t('admin.email_template.plan_upgraded'), icon: <svg className="w-5 h-5 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18"/></svg> }, { key: 'plan_downgrade_scheduled', label: t('admin.email_template.plan_downgrade_scheduled'), icon: <svg className="w-5 h-5 text-orange-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg> }, { key: 'plan_downgrade_applied', label: t('admin.email_template.plan_downgrade_applied'), icon: <svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3"/></svg> }, { key: 'plan_renewal_reminder', label: t('admin.email_template.plan_renewal_reminder'), icon: <svg className="w-5 h-5 text-yellow-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg> }, { key: 'plan_insufficient_funds', label: t('admin.email_template.plan_insufficient_funds'), icon: <svg className="w-5 h-5 text-red-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> }, ].map(({ key, label, icon }) => { const localeKey = `${key}_${templateLocale}` const tmpl = notifTemplates[localeKey] ?? notifTemplates[key] ?? { subject: '', body: '' } const isEditing = editingTemplate === key const templateVars = TEMPLATE_VARS[key] ?? [] return ( <div key={key} className="bg-white rounded-xl border border-gray-200 overflow-hidden"> <div className="flex items-center gap-3 px-5 py-4 cursor-pointer hover:bg-gray-50 select-none" onClick={() => setEditingTemplate(isEditing ? null : key)} > <span className="text-xl shrink-0">{icon}</span> <div className="flex-1 min-w-0"> <div className="font-medium text-gray-800 text-sm">{label}</div> {tmpl.subject ? ( <div className="text-xs text-gray-400 mt-0.5 truncate">{t('admin.notif.tmpl.subject_label')}: {tmpl.subject}</div> ) : ( <div className="text-xs text-gray-300 mt-0.5 italic">{t('admin.notif.tmpl.not_set')}</div> )} </div> <span className={`text-gray-400 transition-transform ${isEditing ? 'rotate-90' : ''}`}> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="9 18 15 12 9 6"/></svg> </span> </div> {isEditing && ( <div className="px-5 pb-5 border-t border-gray-100 pt-4 space-y-3"> <div className="flex gap-1 pb-2"> {(['el', 'en', 'uk', 'ru'] as const).map((loc) => ( <button key={loc} type="button" onClick={() => setTemplateLocale(loc)} className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${templateLocale === loc ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}> {loc === 'el' ? 'Ελληνικά' : loc === 'en' ? 'English' : loc === 'uk' ? 'Українська' : 'Русский'} </button> ))} </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.notif.tmpl.subject_label')}</label> <input value={tmpl.subject} onChange={(e) => setNotifTemplates((prev) => ({ ...prev, [localeKey]: { ...(prev[localeKey] ?? { subject: '', body: '' }), subject: 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" /> </div> <div> <label className="block text-xs font-medium text-gray-600 mb-1">{t('admin.notif.tmpl.body_label')}</label> <textarea rows={7} value={tmpl.body} onChange={(e) => setNotifTemplates((prev) => ({ ...prev, [localeKey]: { ...(prev[localeKey] ?? { subject: '', body: '' }), body: 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 font-mono resize-y" placeholder="<p>Уважаемый {{userName}},</p>..." /> </div> {templateVars.length > 0 && ( <div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2"> <div className="text-xs font-medium text-amber-800 mb-1">{t('admin.notif.vars_hint')}</div> <div className="flex flex-wrap gap-1.5"> {templateVars.map((name) => { const variable = `{{${name}}}` return ( <code key={variable} className="bg-amber-100 text-amber-900 px-1.5 py-0.5 rounded text-xs font-mono"> {variable} </code> ) })} </div> </div> )} <div className="flex gap-2 flex-wrap"> <button onClick={() => saveTemplate(key)} disabled={templateSaving || templateTranslating} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {templateSaving ? t('admin.notif.tmpl.saving') : t('admin.notif.tmpl.save')} </button> <button onClick={() => autoTranslateTemplate(key)} disabled={templateSaving || templateTranslating} className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1.5" > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" /></svg> {templateTranslating ? t('admin.notif.tmpl.translating') : t('admin.notif.tmpl.translate')} </button> <button onClick={() => setEditingTemplate(null)} className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200" > {t('admin.notif.tmpl.close')} </button> </div> </div> )} </div> ) })} </div> )} {/* Referral program */} {settingsTab === 'referral' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 max-w-lg space-y-5"> <div> <h3 className="font-semibold text-gray-800">{t('admin.settings.referral.title')}</h3> <p className="text-sm text-gray-400 mt-1">{t('admin.settings.referral.hint')}</p> </div> {/* Enable toggle */} <div className="flex items-center justify-between"> <div> <p className="text-sm font-medium text-gray-700">{t('admin.settings.referral.enabled_label')}</p> <p className="text-xs text-gray-400">{t('admin.settings.referral.enabled_hint')}</p> </div> <button type="button" onClick={() => setReferralForm((f) => ({ ...f, enabled: !f.enabled }))} className={`relative w-10 h-6 rounded-full p-0 transition-colors cursor-pointer ${ referralForm.enabled ? 'bg-green-600' : 'bg-gray-300' }`} > <span className={`absolute left-0 top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${ referralForm.enabled ? 'translate-x-5' : 'translate-x-1' }`} /> </button> </div> <div className="border border-gray-100 rounded-lg p-4 space-y-4"> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{t('admin.settings.referral.shared_section')}</p> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.referral.plan_label')}</label> <select value={referralForm.planId} onChange={(e) => setReferralForm((f) => ({ ...f, planId: 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" > <option value="">{t('admin.settings.referral.no_plan')}</option> {planRows.map((p: any) => ( <option key={p.id} value={p.id}>{p.name}</option> ))} </select> <p className="text-xs text-gray-400 mt-1">{t('admin.settings.referral.plan_hint')}</p> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.referral.days_label')}</label> <input type="number" min="1" max="365" value={referralForm.days} onChange={(e) => setReferralForm((f) => ({ ...f, days: 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" /> <p className="text-xs text-gray-400 mt-1">{t('admin.settings.referral.days_hint')}</p> </div> </div> <div className="flex items-center gap-3"> <button onClick={saveReferralSettings} disabled={referralSaving} className="px-5 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {referralSaving ? t('admin.settings.saving') : t('common.save')} </button> {referralSaved && <span className="text-sm text-green-600">{t('admin.settings.saved')}</span>} </div> </div> )} {/* SEO */} {settingsTab === 'socials' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 max-w-lg space-y-5"> <div> <h3 className="font-semibold text-gray-800 mb-1">{t('admin.settings.socials.title')}</h3> <p className="text-xs text-gray-400">{t('admin.settings.socials.hint')}</p> </div> {([ { key: 'telegram', label: 'Telegram', placeholder: 'https://t.me/yourname', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg> }, { key: 'instagram', label: 'Instagram', placeholder: 'https://instagram.com/yourname', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg> }, { key: 'facebook', label: 'Facebook', placeholder: 'https://facebook.com/yourpage', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg> }, { key: 'twitter', label: 'X (Twitter)', placeholder: 'https://x.com/yourname', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg> }, { key: 'youtube', label: 'YouTube', placeholder: 'https://youtube.com/@yourchannel', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg> }, { key: 'tiktok', label: 'TikTok', placeholder: 'https://tiktok.com/@yourname', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-2.88 2.5 2.89 2.89 0 0 1-2.89-2.89 2.89 2.89 0 0 1 2.89-2.89c.28 0 .54.04.79.1V9.01a6.27 6.27 0 0 0-.79-.05 6.34 6.34 0 0 0-6.34 6.34 6.34 6.34 0 0 0 6.34 6.34 6.34 6.34 0 0 0 6.33-6.34V8.73a8.16 8.16 0 0 0 4.77 1.52V6.79a4.85 4.85 0 0 1-1-.1z"/></svg> }, { key: 'whatsapp', label: 'WhatsApp', placeholder: 'https://wa.me/30xxxxxxxxxx', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"/></svg> }, { key: 'viber', label: 'Viber', placeholder: 'viber://chat?number=30xxxxxxxxxx', icon: <svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M11.398.002C8.473.028 4.414.625 2.138 4.15.2 7.044-.054 10.74.009 13.092c.064 2.352.277 4.799 1.97 6.758 1.32 1.527 3.276 2.39 5.262 2.674l.006 1.92c0 .307.368.47.592.257l2.245-2.143c.666.037 1.338.046 2.001.023 3.05-.107 6.238-.671 8.39-2.988 2.27-2.45 2.476-5.915 2.515-9.093.038-3.178-.15-6.648-2.467-9.052C18.337.606 14.323-.024 11.398.002zm.11 1.55c2.73-.023 6.267.538 8.3 2.688 1.878 2.004 2.034 5.155 2.001 8.172-.033 3.017-.245 5.967-2.069 7.944-1.792 1.943-4.607 2.44-7.265 2.533a18.35 18.35 0 0 1-2.198-.051l-.301-.028L7.8 24.51l-.004-2.463-.359-.064c-1.875-.333-3.693-1.152-4.832-2.47-1.377-1.597-1.574-3.702-1.632-5.915-.058-2.213.173-5.593 1.85-8.145C4.519 2.967 8.03 1.576 11.508 1.552zm-.082 2.19a8.79 8.79 0 0 0-2.65.399c-1.567.494-2.953 1.497-3.9 2.786C3.67 8.347 3.19 10.01 3.16 11.704c-.03 1.695.392 3.435 1.465 4.754.767.94 1.833 1.603 2.978 1.95l.004 1.232 1.197-1.143c.545.094 1.102.139 1.655.134 1.614-.015 3.27-.444 4.584-1.46 1.314-1.016 2.14-2.578 2.355-4.217.216-1.64-.017-3.369-.85-4.773-.832-1.403-2.23-2.412-3.773-2.74a8.777 8.777 0 0 0-1.459-.12zm.076 1.56c.39.003.784.049 1.165.14 1.215.27 2.319 1.066 2.975 2.17.657 1.103.843 2.518.66 3.857-.183 1.339-.826 2.596-1.87 3.408-1.044.813-2.41 1.172-3.76 1.185a10.75 10.75 0 0 1-1.675-.131l-.395-.064-1.017.97-.003-1.02-.384-.117c-1.027-.314-1.95-.925-2.574-1.693-.893-1.095-1.225-2.58-1.198-4.017.028-1.437.427-2.866 1.236-3.98.809-1.113 1.959-1.905 3.236-2.296a7.298 7.298 0 0 1 1.609-.412z"/></svg> }, ] as { key: keyof typeof socialForm; label: string; placeholder: string; icon: React.ReactNode }[]).map(({ key, label, placeholder, icon }) => ( <div key={key}> <label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2"> <span className={`${socialForm[key] ? 'text-green-600' : 'text-gray-300'}`}>{icon}</span> {label} {!socialForm[key] && <span className="text-xs font-normal text-gray-400 ml-auto">не заполнено</span>} </label> <input type="url" value={socialForm[key]} onChange={(e) => setSocialForm((f) => ({ ...f, [key]: e.target.value }))} placeholder={placeholder} className={`w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 ${ socialForm[key] ? 'border-green-300 bg-green-50/30' : 'border-gray-300' }`} /> </div> ))} <div className="flex items-center gap-3 pt-2"> <button onClick={saveSocialSettings} disabled={socialSaving} className="px-5 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {socialSaving ? t('admin.settings.saving') : t('common.save')} </button> {socialSaved && <span className="text-sm text-green-600">{t('admin.settings.saved')}</span>} </div> </div> )} {settingsTab === 'seo' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 max-w-lg space-y-6"> <h3 className="font-semibold text-gray-800">{t('admin.settings.seo.title')}</h3> {/* Maintenance Mode */} <div className="flex items-center justify-between"> <div> <p className="text-sm font-medium text-gray-700">{t('admin.settings.seo.maintenance_label')}</p> <p className="text-xs text-gray-400 mt-0.5">{t('admin.settings.seo.maintenance_hint')}</p> </div> <button type="button" onClick={() => setSeoForm((f) => ({ ...f, maintenanceMode: !f.maintenanceMode }))} className={`relative w-10 h-6 rounded-full p-0 transition-colors cursor-pointer flex-shrink-0 ${ seoForm.maintenanceMode ? 'bg-red-500' : 'bg-gray-300' }`} > <span className={`absolute left-0 top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${ seoForm.maintenanceMode ? 'translate-x-5' : 'translate-x-1' }`} /> </button> </div> {/* Indexing */} <div className="flex items-center justify-between"> <div> <p className="text-sm font-medium text-gray-700">{t('admin.settings.seo.indexing_label')}</p> <p className="text-xs text-gray-400 mt-0.5">{t('admin.settings.seo.indexing_hint')}</p> </div> <button type="button" onClick={() => setSeoForm((f) => ({ ...f, indexingEnabled: !f.indexingEnabled }))} className={`relative w-10 h-6 rounded-full p-0 transition-colors cursor-pointer flex-shrink-0 ${ seoForm.indexingEnabled ? 'bg-green-600' : 'bg-gray-300' }`} > <span className={`absolute left-0 top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${ seoForm.indexingEnabled ? 'translate-x-5' : 'translate-x-1' }`} /> </button> </div> {/* Site Title + Description — per locale */} <div className="space-y-4"> <div> <p className="text-sm font-medium text-gray-700 mb-2">{t('admin.settings.seo.site_title_label')} / {t('admin.settings.seo.site_description_label')}</p> {/* Locale tabs */} <div className="flex gap-1 mb-3"> {([ { code: 'el' as const, flag: '🇬🇷', label: 'EL' }, { code: 'en' as const, flag: '🇬🇧', label: 'EN' }, { code: 'ru' as const, flag: '🇷🇺', label: 'RU' }, { code: 'uk' as const, flag: '🇺🇦', label: 'UK' }, ]).map(({ code, flag, label }) => ( <button key={code} type="button" onClick={() => setSeoLangTab(code)} className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${ seoLangTab === code ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }`} > {flag} {label} </button> ))} </div> {/* Title field */} <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.settings.seo.site_title_label')}</label> <input value={seoForm[`siteTitle${seoLangTab.charAt(0).toUpperCase() + seoLangTab.slice(1)}` as keyof typeof seoForm] as string} onChange={(e) => setSeoForm((f) => ({ ...f, [`siteTitle${seoLangTab.charAt(0).toUpperCase() + seoLangTab.slice(1)}`]: 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 mb-3" placeholder={seoLangTab === 'el' ? 'CanHelp — Κάθε άνθρωπος έχει κάτι να προσφέρει' : seoLangTab === 'en' ? 'CanHelp — Everyone has something valuable to offer' : seoLangTab === 'ru' ? 'CanHelp — Каждый человек может чем-то помочь' : 'CanHelp — Кожен має навичку, яка може допомогти іншому'} /> {/* Description field */} <label className="block text-xs font-medium text-gray-500 mb-1">{t('admin.settings.seo.site_description_label')}</label> <textarea rows={3} value={seoForm[`siteDescription${seoLangTab.charAt(0).toUpperCase() + seoLangTab.slice(1)}` as keyof typeof seoForm] as string} onChange={(e) => setSeoForm((f) => ({ ...f, [`siteDescription${seoLangTab.charAt(0).toUpperCase() + seoLangTab.slice(1)}`]: 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 resize-none" placeholder={seoLangTab === 'el' ? 'Η ελληνική πλατφόρμα για online ανάθεση εργασιών...' : seoLangTab === 'en' ? 'The Greek platform for online task delegation...' : seoLangTab === 'ru' ? 'Греческая платформа для онлайн-поиска специалистов...' : 'Грецька платформа для пошуку фахівців онлайн...'} /> </div> </div> {/* Head Scripts */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.seo.head_scripts_label')}</label> <p className="text-xs text-gray-400 mb-2">{t('admin.settings.seo.head_scripts_hint')}</p> <textarea rows={6} value={seoForm.headScripts} onChange={(e) => setSeoForm((f) => ({ ...f, headScripts: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-400 resize-y" placeholder={'<!-- Google Tag Manager -->\n<script>...</script>'} spellCheck={false} /> </div> <div className="flex items-center gap-3"> <button onClick={saveSeoSettings} disabled={seoSaving} className="px-5 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {seoSaving ? t('admin.settings.saving') : t('common.save')} </button> {seoSaved && <span className="text-sm text-green-600">{t('admin.settings.saved')}</span>} </div> </div> )} {/* Telegram */} {settingsTab === 'telegram' && ( <div className="bg-white rounded-xl border border-gray-200 p-6 max-w-lg space-y-6"> <div> <h3 className="font-semibold text-gray-800 mb-1">{t('admin.settings.telegram.title')}</h3> <p className="text-xs text-gray-400">{t('admin.settings.telegram.help')}</p> </div> {/* Bot Token */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.telegram.bot_token')}</label> <div className="flex gap-2"> <input type="text" value={telegramForm.botToken} onChange={(e) => { setTelegramForm((f) => ({ ...f, botToken: e.target.value })); setVerifyResultTelegramToken(null) }} placeholder="1234567890:ABCDefgh..." 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 font-mono" /> <button onClick={verifyTelegramToken} disabled={verifyingTelegramToken || !telegramForm.botToken} className="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 disabled:opacity-40 flex-shrink-0" > {verifyingTelegramToken ? '...' : t('admin.settings.telegram.verify')} </button> </div> {verifyResultTelegramToken && ( <p className={`text-xs mt-1 font-medium ${verifyResultTelegramToken.ok ? 'text-green-600' : 'text-red-500'}`}> {verifyResultTelegramToken.ok ? `✓ ${verifyResultTelegramToken.msg}` : `✗ ${verifyResultTelegramToken.msg}`} </p> )} </div> {/* Admin Chat ID */} <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.settings.telegram.admin_chat_id')}</label> <div className="flex gap-2"> <input type="text" value={telegramForm.adminChatId} onChange={(e) => setTelegramForm((f) => ({ ...f, adminChatId: e.target.value }))} placeholder="-100123456789" 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 font-mono" /> <button onClick={() => testTelegram('admin')} disabled={testingTelegramAdmin || !telegramForm.botToken || !telegramForm.adminChatId} className="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 disabled:opacity-40 flex-shrink-0" > {testingTelegramAdmin ? '...' : t('admin.settings.telegram.test')} </button> </div> <p className="text-xs text-gray-400 mt-1">{t('admin.settings.telegram.admin_chat_hint')}</p> {testResultTelegramAdmin && ( <p className={`text-xs mt-1 font-medium ${testResultTelegramAdmin.ok ? 'text-green-600' : 'text-red-500'}`}> {testResultTelegramAdmin.ok ? `✓ ${testResultTelegramAdmin.msg}` : `✗ ${testResultTelegramAdmin.msg}`} </p> )} </div> {/* Language Channels */} <div className="space-y-3"> <label className="block text-sm font-medium text-gray-700">{t('admin.settings.telegram.channels_title')}</label> <p className="text-xs text-gray-400 -mt-2">{t('admin.settings.telegram.channel_hint')}</p> {([ { locale: 'el' as Locale, flag: '🇬🇷', label: 'Ελληνικά', field: 'channelIdEl' as const }, { locale: 'en' as Locale, flag: '🇬🇧', label: 'English', field: 'channelIdEn' as const }, { locale: 'ru' as Locale, flag: '🇷🇺', label: 'Русский', field: 'channelIdRu' as const }, { locale: 'uk' as Locale, flag: '🇺🇦', label: 'Українська', field: 'channelIdUk' as const }, ]).map(({ locale: loc, flag, label, field }) => ( <div key={loc}> <div className="flex items-center gap-1.5 mb-1"> <span className="text-base leading-none">{flag}</span> <span className="text-xs font-medium text-gray-600">{label}</span> </div> <div className="flex gap-2"> <input type="text" value={telegramForm[field]} onChange={(e) => setTelegramForm((f) => ({ ...f, [field]: e.target.value }))} placeholder="@channel или -100..." 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 font-mono" /> <button onClick={() => testTelegram(loc)} disabled={testingLocale === loc || !telegramForm.botToken || !telegramForm[field]} className="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 disabled:opacity-40 flex-shrink-0" > {testingLocale === loc ? '...' : t('admin.settings.telegram.test')} </button> </div> {testResultChannels[loc] && ( <p className={`text-xs mt-1 font-medium ${testResultChannels[loc]!.ok ? 'text-green-600' : 'text-red-500'}`}> {testResultChannels[loc]!.ok ? `✓ ${testResultChannels[loc]!.msg}` : `✗ ${testResultChannels[loc]!.msg}`} </p> )} </div> ))} </div> {/* Toggles */} <div className="space-y-4"> <div className="flex items-center justify-between"> <div> <p className="text-sm font-medium text-gray-700">{t('admin.settings.telegram.notify_registrations')}</p> <p className="text-xs text-gray-400 mt-0.5">{t('admin.settings.telegram.notify_registrations_hint')}</p> </div> <button type="button" onClick={() => setTelegramForm((f) => ({ ...f, notifyRegistrations: !f.notifyRegistrations }))} className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer flex-shrink-0 p-0 border-0 outline-none ${ telegramForm.notifyRegistrations ? 'bg-green-600' : 'bg-gray-300' }`} > <span className={`absolute top-1 left-0 w-4 h-4 bg-white rounded-full shadow transition-transform ${telegramForm.notifyRegistrations ? 'translate-x-5' : 'translate-x-1'}`} /> </button> </div> <div className="flex items-center justify-between"> <div> <p className="text-sm font-medium text-gray-700">{t('admin.settings.telegram.post_new_tasks')}</p> <p className="text-xs text-gray-400 mt-0.5">{t('admin.settings.telegram.post_new_tasks_hint')}</p> </div> <button type="button" onClick={() => setTelegramForm((f) => ({ ...f, postNewTasks: !f.postNewTasks }))} className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer flex-shrink-0 p-0 border-0 outline-none ${ telegramForm.postNewTasks ? 'bg-green-600' : 'bg-gray-300' }`} > <span className={`absolute top-1 left-0 w-4 h-4 bg-white rounded-full shadow transition-transform ${telegramForm.postNewTasks ? 'translate-x-5' : 'translate-x-1'}`} /> </button> </div> </div> <div className="flex items-center gap-3 pt-2"> <button onClick={saveTelegramSettings} disabled={telegramSaving} className="px-5 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {telegramSaving ? t('admin.settings.saving') : t('common.save')} </button> {telegramSaved && <span className="text-sm text-green-600">{t('admin.settings.saved')}</span>} </div> </div> )} </div> )} {/* ── Support Tickets ── */} {tab === 'support' && ( <div className="space-y-4"> {/* Filters */} <div className="flex flex-wrap gap-3 mb-2"> <form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); setSupportQ(supportQInput); setSupportPage(1) }}> <input value={supportQInput} onChange={(e) => setSupportQInput(e.target.value)} placeholder={t('admin.support.search', 'Search...')} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 w-52" /> <button type="submit" className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700"> {t('admin.search', 'Search')} </button> {supportQ && ( <button type="button" onClick={() => { setSupportQInput(''); setSupportQ(''); setSupportPage(1) }} className="text-sm text-gray-500 hover:text-gray-700 px-2">✕</button> )} </form> <select value={supportStatus} onChange={(e) => { setSupportStatus(e.target.value); setSupportPage(1) }} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" > <option value="">{t('admin.support.all_statuses', 'All statuses')}</option> <option value="open">{t('admin.support.status.open', 'Open')}</option> <option value="in_progress">{t('admin.support.status.in_progress', 'In progress')}</option> <option value="resolved">{t('admin.support.status.resolved', 'Resolved')}</option> <option value="closed">{t('admin.support.status.closed', 'Closed')}</option> </select> <select value={supportType} onChange={(e) => { setSupportType(e.target.value); setSupportPage(1) }} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" > <option value="">{t('admin.support.all_types', 'All types')}</option> <option value="bug">🐛 {t('support.type.bug', 'Bug')}</option> <option value="suggestion">💡 {t('support.type.suggestion', 'Suggestion')}</option> <option value="other">💬 {t('support.type.other', 'Other')}</option> </select> <span className="ml-auto text-sm text-gray-400 self-center">{supportTotal} {t('admin.pagination.total', 'total')}</span> </div> {loading ? ( <div className="text-center py-10 text-gray-400">{t('common.loading')}</div> ) : supportTickets.length === 0 ? ( <div className="text-center py-10 text-gray-400">{t('admin.support.none', 'No tickets')}</div> ) : ( supportTickets.map((ticket) => { const typeColors: Record<string, string> = { bug: 'bg-red-100 text-red-700', suggestion: 'bg-green-100 text-green-700', other: 'bg-gray-100 text-gray-600', } const statusColors2: Record<string, string> = { open: 'bg-green-100 text-green-700', in_progress: 'bg-green-100 text-green-700', resolved: 'bg-gray-100 text-gray-500', closed: 'bg-gray-200 text-gray-400', } const typeIcons: Record<string, string> = { bug: '🐛', suggestion: '💡', other: '💬' } const isReplying = supportReplyId === ticket.id return ( <div key={ticket.id} className="bg-white rounded-xl border border-gray-200 p-5"> <div className="flex items-start justify-between gap-4 mb-3"> <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap mb-1"> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${typeColors[ticket.type] ?? 'bg-gray-100 text-gray-600'}`}> {typeIcons[ticket.type]} {ticket.type} </span> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors2[ticket.status] ?? 'bg-gray-100 text-gray-500'}`}> {ticket.status} </span> <span className="text-xs text-gray-400">#{ticket.id.slice(0, 8)}</span> </div> <h3 className="font-semibold text-gray-900 text-sm">{ticket.subject}</h3> {ticket.userName && ( <p className="text-xs text-gray-400 mt-0.5"> {ticket.userName} · {ticket.userEmail} </p> )} <p className="text-xs text-gray-400 mt-0.5">{new Date(ticket.createdAt).toLocaleString(dateLocale)}</p> </div> <div className="flex gap-2 flex-shrink-0"> {ticket.status !== 'closed' && ( <button onClick={() => { setSupportReplyId(isReplying ? null : ticket.id); setSupportReplyText(ticket.adminReply || '') }} className="text-xs px-3 py-1.5 rounded-lg font-medium bg-green-50 text-green-700 hover:bg-green-100" > {isReplying ? t('admin.support.cancel', 'Cancel') : t('admin.support.reply', 'Reply')} </button> )} {ticket.status === 'open' && ( <button onClick={() => api.setSupportTicketStatus(ticket.id, 'closed').then(() => setSupportTickets((prev) => prev.map((t) => t.id === ticket.id ? { ...t, status: 'closed' } : t)))} className="text-xs px-3 py-1.5 rounded-lg font-medium bg-gray-100 text-gray-600 hover:bg-gray-200" > {t('admin.support.close', 'Close')} </button> )} </div> </div> {/* Message body */} <p className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 rounded-lg p-3 mb-3">{ticket.body}</p> {/* Admin reply display */} {ticket.adminReply && !isReplying && ( <div className="bg-green-50 border border-green-100 rounded-lg p-3 mb-2"> <p className="text-xs font-medium text-green-600 mb-1">{t('admin.support.your_reply', 'Your reply')}:</p> <p className="text-sm text-gray-800 whitespace-pre-wrap">{ticket.adminReply}</p> </div> )} {/* Reply form */} {isReplying && ( <div className="space-y-2 mt-2"> <textarea rows={3} value={supportReplyText} onChange={(e) => setSupportReplyText(e.target.value)} placeholder={t('admin.support.reply_placeholder', 'Write a reply...')} 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 resize-none" /> <div className="flex gap-2 items-center"> <select id={`status-${ticket.id}`} defaultValue="resolved" className="border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400" > <option value="open">{t('admin.support.status.open', 'Open')}</option> <option value="in_progress">{t('admin.support.status.in_progress', 'In progress')}</option> <option value="resolved">{t('admin.support.status.resolved', 'Resolved')}</option> <option value="closed">{t('admin.support.status.closed', 'Closed')}</option> </select> <button disabled={supportReplySaving || !supportReplyText.trim()} onClick={async () => { const statusEl = document.getElementById(`status-${ticket.id}`) as HTMLSelectElement setSupportReplySaving(true) try { const updated = await api.replyToSupportTicket(ticket.id, { adminReply: supportReplyText, status: statusEl?.value, }) setSupportTickets((prev) => prev.map((t) => t.id === ticket.id ? { ...t, ...updated } : t)) setSupportReplyId(null) } catch {} finally { setSupportReplySaving(false) } }} className="bg-green-600 text-white px-4 py-1.5 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {supportReplySaving ? '...' : t('admin.support.send_reply', 'Send')} </button> </div> </div> )} </div> ) }) )} <Pagination page={supportPage} total={supportTotal} limit={30} onChange={setSupportPage} totalLabel={t('admin.pagination.total')} /> </div> )} {/* ── Push ── */} {tab === 'push' && ( <div className="space-y-4"> <div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4"> <h2 className="text-lg font-semibold text-gray-800">Push-рассылка</h2> <div className="flex gap-2"> <button type="button" onClick={() => setPushMode('broadcast')} className={`px-3 py-1.5 text-sm rounded-lg border ${pushMode === 'broadcast' ? 'bg-green-600 text-white border-green-600' : 'bg-white text-gray-700 border-gray-300'}`} > Всем пользователям </button> <button type="button" onClick={() => setPushMode('user')} className={`px-3 py-1.5 text-sm rounded-lg border ${pushMode === 'user' ? 'bg-green-600 text-white border-green-600' : 'bg-white text-gray-700 border-gray-300'}`} > Конкретному пользователю </button> </div> {pushMode === 'user' && ( <div> <label className="text-sm text-gray-600 block mb-1">User ID</label> <input value={pushTargetUserId} onChange={(e) => setPushTargetUserId(e.target.value)} placeholder="UUID пользователя" 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" /> </div> )} {pushMode === 'broadcast' && ( <label className="flex items-center gap-2 text-sm text-gray-700"> <input type="checkbox" checked={pushOnlyActive} onChange={(e) => setPushOnlyActive(e.target.checked)} /> Только активным пользователям </label> )} <div> <label className="text-sm text-gray-600 block mb-1">Заголовок</label> <input value={pushTitle} onChange={(e) => setPushTitle(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" /> </div> <div> <label className="text-sm text-gray-600 block mb-1">Текст</label> <textarea rows={3} value={pushBody} onChange={(e) => setPushBody(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 resize-none" /> </div> <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div> <label className="text-sm text-gray-600 block mb-1">Тип</label> <input value={pushType} onChange={(e) => setPushType(e.target.value)} placeholder="task_update / chat_message / referral_reward" 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" /> </div> <div> <label className="text-sm text-gray-600 block mb-1">Reference ID (опционально)</label> <input value={pushReferenceId} onChange={(e) => setPushReferenceId(e.target.value)} placeholder="taskId, chatId и т.д." 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" /> </div> </div> <div className="flex items-center gap-3"> <button type="button" onClick={sendAdminPush} disabled={pushSending || !pushTitle.trim() || (pushMode === 'user' && !pushTargetUserId.trim())} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50" > {pushSending ? 'Отправка...' : 'Отправить Push'} </button> {pushError && <span className="text-sm text-red-600">{pushError}</span>} </div> </div> {pushResult && ( <div className="bg-white rounded-xl border border-gray-200 p-5"> <h3 className="font-semibold text-gray-800 mb-3">Результат</h3> {'usersTotal' in pushResult ? ( <div className="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm"> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Пользователей</div><div className="text-gray-900 font-semibold">{pushResult.usersTotal}</div></div> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Получили</div><div className="text-gray-900 font-semibold">{pushResult.usersWithSent}</div></div> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Отправлено push</div><div className="text-gray-900 font-semibold">{pushResult.pushesSent}</div></div> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Невалидные токены</div><div className="text-gray-900 font-semibold">{pushResult.invalidTokens}</div></div> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Ошибки по userId</div><div className="text-gray-900 font-semibold">{pushResult.failedUsers.length}</div></div> </div> ) : ( <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm"> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">User ID</div><div className="text-gray-900 font-mono text-xs break-all">{pushResult.targetUserId}</div></div> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Токенов найдено</div><div className="text-gray-900 font-semibold">{pushResult.tokensFound ?? 0}</div></div> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Отправлено push</div><div className="text-gray-900 font-semibold">{pushResult.sent}</div></div> <div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2"><div className="text-gray-500">Невалидные токены</div><div className="text-gray-900 font-semibold">{pushResult.invalidTokens}</div></div> </div> )} {'usersTotal' in pushResult ? ( <> {(pushResult.usersWithoutTokens ?? 0) > 0 || (pushResult.usersConfigBlocked ?? 0) > 0 || (pushResult.usersAuthFailed ?? 0) > 0 || (pushResult.usersAllFailed ?? 0) > 0 ? ( <div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <div>Диагностика: без токенов — {pushResult.usersWithoutTokens ?? 0}, FCM не настроен — {pushResult.usersConfigBlocked ?? 0}, ошибка auth FCM — {pushResult.usersAuthFailed ?? 0}, FCM send failed — {pushResult.usersAllFailed ?? 0}</div> {pushResult.failedReasonStats && Object.keys(pushResult.failedReasonStats).length > 0 ? ( <div className="mt-1">Коды ошибок FCM: {Object.entries(pushResult.failedReasonStats).map(([key, count]) => `${key}: ${count}`).join(', ')}</div> ) : null} {pushResult.failedReasonSample ? <div className="mt-1">Пример ошибки: {describeFcmFailure(pushResult.failedReasonStats ? Object.keys(pushResult.failedReasonStats)[0] : null, pushResult.failedReasonSample)}</div> : null} </div> ) : null} </> ) : ( <> {pushResult.reason ? ( <div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <div> Диагностика: {pushResult.reason === 'no_tokens' ? 'у пользователя нет push-токенов' : pushResult.reason === 'fcm_not_configured' ? 'FCM не настроен на API (переменные FCM_*)' : pushResult.reason === 'fcm_auth_failed' ? 'ошибка авторизации FCM (service account)' : describeFcmFailure(pushResult.failureKey, pushResult.failureMessage)} </div> {pushResult.failureKey ? <div className="mt-1">Код FCM: {pushResult.failureKey}</div> : null} {pushResult.failureMessage ? <div className="mt-1">Сообщение FCM: {pushResult.failureMessage}</div> : null} </div> ) : null} </> )} </div> )} </div> )} {/* ── Backup ── */} {tab === 'backup' && ( <div className="space-y-6"> <h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2"> <svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg> {t('admin.backup.title', 'Backup')} </h2> {/* Status message */} {backupMsg && ( <div className={`${backupMsgError ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 border-green-200 text-green-700'} border rounded-lg px-4 py-2 text-sm flex items-center justify-between`}> <span>{backupMsg}</span> <button onClick={() => setBackupMsg(null)} className={`${backupMsgError ? 'text-red-500 hover:text-red-700' : 'text-green-500 hover:text-green-700'} ml-4`}>×</button> </div> )} {/* Auto backup toggle */} <div className="bg-white rounded-xl border border-gray-200 p-5 flex items-center justify-between"> <div> <p className="font-medium text-gray-800 text-sm">{t('admin.backup.autoBackup', 'Auto Backup')}</p> <p className="text-xs text-gray-500 mt-0.5">{t('admin.backup.autoBackupDesc', 'Create a backup every day automatically')}</p> </div> <button disabled={backupAutoSaving} onClick={async () => { setBackupAutoSaving(true) try { await request('/admin/backup/settings', { method: 'PUT', body: JSON.stringify({ autoEnabled: !backupAutoEnabled }) }) setBackupAutoEnabled((v) => !v) } catch {} setBackupAutoSaving(false) }} className={`relative w-11 h-6 rounded-full transition-colors ${backupAutoEnabled ? 'bg-green-600' : 'bg-gray-300'} ${backupAutoSaving ? 'opacity-50' : ''}`} > <span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${backupAutoEnabled ? 'translate-x-5' : 'translate-x-0'}`} /> </button> </div> {/* Full DB export/import */} <div className="bg-white rounded-xl border border-gray-200 p-5"> <h3 className="font-semibold text-gray-800 text-sm mb-4">{t('admin.backup.title', 'Database Backup')}</h3> <div className="flex flex-wrap gap-3"> <button disabled={backupExporting} onClick={async () => { setBackupExporting(true) try { const BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' const res = await fetch(`${BASE}/api/admin/backup/export`, { credentials: 'include' }) if (!res.ok) throw new Error('Export failed') const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `backup_${new Date().toISOString().slice(0, 10)}.sql.gz` a.click() URL.revokeObjectURL(url) setBackupSuccess(t('admin.backup.exportSuccess', 'Export successful')) } catch (e: any) { setBackupError(e.message || 'Error') } setBackupExporting(false) }} className="bg-green-600 hover:bg-green-700 text-white text-sm px-4 py-2 rounded-lg font-medium disabled:opacity-50 flex items-center gap-2" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg> {backupExporting ? t('admin.backup.exporting', 'Exporting...') : t('admin.backup.exportAll', 'Export Full DB')} </button> <label className={`bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm px-4 py-2 rounded-lg font-medium cursor-pointer flex items-center gap-2 ${backupImporting === 'full' ? 'opacity-50 pointer-events-none' : ''}`}> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l4-4m0 0l4 4m-4-4v12"/></svg> {backupImporting === 'full' ? t('admin.backup.importing', 'Importing...') : t('admin.backup.importAll', 'Import Full DB')} <input type="file" accept=".sql.gz,.gz" className="hidden" onChange={async (e) => { const file = e.target.files?.[0] if (!file) return if (!window.confirm(t('admin.backup.restoreConfirm', 'Restore from this backup?'))) return setBackupImporting('full') 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/admin/backup/import`, { method: 'POST', credentials: 'include', body: fd }) if (!res.ok) throw new Error('Import failed') setBackupSuccess(t('admin.backup.importSuccess', 'Import successful')) } catch (err: any) { setBackupError(err.message || 'Error') } setBackupImporting(null) e.target.value = '' }} /> </label> </div> </div> {/* Stored backups */} <div className="bg-white rounded-xl border border-gray-200 p-5"> <h3 className="font-semibold text-gray-800 text-sm mb-4">{t('admin.backup.storedBackups', 'Stored Backups')}</h3> {backupStoredFiles.length === 0 ? ( <p className="text-sm text-gray-400">{t('admin.backup.noStoredBackups', 'No stored backups')}</p> ) : ( <div className="space-y-2"> {backupStoredFiles.map((f) => ( <div key={f.name} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg border border-gray-100"> <div> <p className="text-sm font-medium text-gray-800">{f.name === 'backup_today' ? t('admin.backup.today', 'Today') : t('admin.backup.yesterday', 'Yesterday')}</p> <p className="text-xs text-gray-400">{t('admin.backup.size', 'Size')}: {(f.size / 1024).toFixed(1)} KB · {t('admin.backup.date', 'Date')}: {new Date(f.date).toLocaleString()}</p> </div> <button disabled={backupRestoring === f.name} onClick={async () => { if (!window.confirm(t('admin.backup.restoreConfirm', 'Restore from this backup?'))) return setBackupRestoring(f.name) try { await request('/admin/backup/restore', { method: 'POST', body: JSON.stringify({ name: f.name }) }) setBackupSuccess(t('admin.backup.restoreSuccess', 'Restore successful')) } catch (err: any) { setBackupError(err.message || 'Error') } setBackupRestoring(null) }} className="text-sm text-green-600 hover:text-gray-800 font-medium disabled:opacity-50" > {backupRestoring === f.name ? t('admin.backup.restoring', 'Restoring...') : t('admin.backup.restore', 'Restore')} </button> </div> ))} </div> )} </div> {/* Per-table export/import */} <div className="bg-white rounded-xl border border-gray-200 p-5"> <h3 className="font-semibold text-gray-800 text-sm mb-4">{t('admin.backup.tables', 'Tables')}</h3> {backupTablesLoading ? ( <div className="flex items-center gap-2 text-sm text-gray-400"> <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg> {t('common.loading', 'Loading...')} </div> ) : backupTables.length === 0 ? ( <p className="text-sm text-gray-400">{t('admin.noData', 'No data')}</p> ) : ( <div className="overflow-x-auto"> <table className="w-full text-sm"> <tbody className="divide-y divide-gray-100"> {backupTables.map((tbl) => ( <tr key={tbl} className="hover:bg-gray-50"> <td className="py-2 pr-4 font-mono text-gray-700">{tbl}</td> <td className="py-2 text-right"> <div className="flex items-center justify-end gap-2"> <button onClick={async () => { try { const BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' const res = await fetch(`${BASE}/api/admin/backup/export/${tbl}`, { credentials: 'include' }) if (!res.ok) throw new Error('Export failed') const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${tbl}_${new Date().toISOString().slice(0, 10)}.sql.gz` a.click() URL.revokeObjectURL(url) } catch (err: any) { setBackupError(err.message || 'Error') } }} className="text-green-600 hover:text-gray-800 font-medium text-xs px-2 py-1 rounded border border-green-200 hover:bg-green-50" > {t('admin.backup.exportTable', 'Export')} </button> <label className="text-gray-600 hover:text-gray-800 font-medium text-xs px-2 py-1 rounded border border-gray-200 hover:bg-gray-100 cursor-pointer"> {backupImporting === tbl ? t('admin.backup.importing', 'Importing...') : t('admin.backup.importTable', 'Import')} <input type="file" accept=".sql.gz,.gz" className="hidden" onChange={async (e) => { const file = e.target.files?.[0] if (!file) return if (!window.confirm(t('admin.backup.restoreConfirm', 'Restore from this backup?'))) return setBackupImporting(tbl) 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/admin/backup/import/${tbl}`, { method: 'POST', credentials: 'include', body: fd }) if (!res.ok) { const errData = await res.json().catch(() => ({ error: 'Import failed' })) throw new Error(errData.error || 'Import failed') } setBackupSuccess(t('admin.backup.importSuccess', 'Import successful')) } catch (err: any) { setBackupError(err.message || 'Error') } setBackupImporting(null) e.target.value = '' }} /> </label> </div> </td> </tr> ))} </tbody> </table> </div> )} </div> </div> )} {/* ── Translations ── */} {tab === 'translations' && ( <div className="space-y-6"> <div> <h2 className="text-base font-semibold text-gray-900">Переводы</h2> <p className="text-sm text-gray-500 mt-0.5">Автоматический перевод пропущенных значений по таблицам</p> </div> {/* Language direction */} <div className="bg-white rounded-xl border border-gray-200 p-4 flex flex-wrap gap-4 items-end"> <div> <label className="block text-xs font-medium text-gray-600 mb-1">Источник</label> <select value={transFromLang} onChange={(e) => setTransFromLang(e.target.value)} className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-green-500" > <option value="el">🇬🇷 Greek</option> <option value="en">🇬🇧 English</option> <option value="ru">🇷🇺 Russian</option> <option value="uk">🇺🇦 Ukrainian</option> </select> </div> <svg className="w-5 h-5 text-gray-400 mb-1 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg> <div> <label className="block text-xs font-medium text-gray-600 mb-1">Перевести на</label> <select value={transLang} onChange={(e) => setTransLang(e.target.value)} className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-green-500" > <option value="el">🇬🇷 Greek</option> <option value="en">🇬🇧 English</option> <option value="ru">🇷🇺 Russian</option> <option value="uk">🇺🇦 Ukrainian</option> </select> </div> <div className="ml-auto self-end"> <span className="text-sm font-semibold text-gray-700 flex items-center gap-2"> {({ el: '🇬🇷', en: '🇬🇧', ru: '🇷🇺', uk: '🇺🇦' } as any)[transFromLang]} {transFromLang.toUpperCase()} <svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg> {({ el: '🇬🇷', en: '🇬🇧', ru: '🇷🇺', uk: '🇺🇦' } as any)[transLang]} {transLang.toUpperCase()} </span> </div> </div> {transMsg && ( <div className={`text-sm px-4 py-2 rounded-lg ${transMsg.error ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}> {transMsg.text} </div> )} {/* Table cards */} {transStatsLoading ? ( <div className="flex items-center gap-2 text-sm text-gray-400 py-6"> <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg> Загрузка… </div> ) : ( <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {(transStats ?? []).map((tbl) => { const isTasks = tbl.name === 'tasks' // For tasks: total missing across all langs (tasks may miss multiple langs each) const totalMissingAllLangs = Object.values(tbl.missing).reduce((a, b) => a + b, 0) const missingCount = isTasks ? totalMissingAllLangs : (tbl.missing[transLang] ?? 0) const isTranslating = !!transTableTranslating[tbl.name] const anyTranslating = Object.values(transTableTranslating).some(Boolean) return ( <div key={tbl.name} className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col"> <div className="flex items-start justify-between"> <div> <p className="font-semibold text-gray-900 text-sm">{tbl.label}</p> <p className="text-xs text-gray-400 mt-0.5">{tbl.name}</p> </div> <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ missingCount > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700' }`}> {missingCount > 0 ? `${missingCount} missing` : 'Complete'} </span> </div> <p className="text-xs text-gray-500 mt-3">Всего: {tbl.total}{isTasks && ' · с языка оригинала на все остальные'}</p> <div className="mt-2 flex flex-wrap gap-1"> {(['el', 'en', 'ru', 'uk'] as const).map((lang) => { const cnt = tbl.missing[lang] ?? 0 const isClickable = isTasks && cnt > 0 return ( <button key={lang} type="button" disabled={!isClickable} onClick={isClickable ? async () => { setTransPreview({ lang, loading: true, rows: [] }) try { const d = await api.getTasksTranslationPreview(lang, 50) setTransPreview({ lang, loading: false, rows: d.rows }) } catch { setTransPreview({ lang, loading: false, rows: [] }) } } : undefined} className={`text-xs px-2 py-0.5 rounded-full border transition ${ cnt > 0 ? 'border-red-200 bg-red-50 text-red-600' + (isClickable ? ' cursor-pointer hover:bg-red-100' : '') : 'border-green-200 bg-green-50 text-green-600 cursor-default' }`} > {lang}: {cnt > 0 ? `-${cnt}` : '✓'} </button> ) })} </div> <div className="mt-4 mt-auto pt-4"> {missingCount === 0 ? ( <div className="flex items-center gap-1.5 text-xs text-emerald-600"> <svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/></svg> Все переводы заполнены </div> ) : ( <button disabled={anyTranslating} onClick={async () => { if (!isTasks && transFromLang === transLang) { setTransMsg({ text: 'Исходный и целевой язык совпадают', error: true }) return } setTransTableTranslating((prev) => ({ ...prev, [tbl.name]: true })) setTransMsg(null) try { const res = isTasks ? await api.autoTranslateTasks(500) : tbl.name === 'email_templates' ? await api.autoTranslateEmailTemplates(transLang, transFromLang) : await api.autoTranslate(tbl.name, transLang, transFromLang, 500) setTransMsg({ text: `${tbl.label}: translated ${res.translated} of ${res.total}` + (res.failed > 0 ? ` · errors: ${res.failed}` : ''), error: res.failed > 0 && res.translated === 0, }) api.getTranslationsStats().then((d) => setTransStats(d.tables)).catch(() => {}) } catch (err: any) { const msg = err?.response ? await err.response.text().catch(() => err.message) : (err.message || 'Error') setTransMsg({ text: `Error: ${msg}`, error: true }) } finally { setTransTableTranslating((prev) => { const n = { ...prev }; delete n[tbl.name]; return n }) } }} className="w-full text-sm px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center justify-center gap-2 transition font-medium" > {isTranslating ? ( <> <svg className="w-3.5 h-3.5 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg> Translating... </> ) : isTasks ? ( <> <svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"/></svg> Fill all missing · {missingCount} </> ) : ( <> <svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"/></svg> Translate {missingCount} · {({ el: '🇬🇷', en: '🇬🇧', ru: '🇷🇺', uk: '🇺🇦' } as any)[transFromLang]} {transFromLang.toUpperCase()} <svg className="w-3 h-3 opacity-60 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 12h12"/></svg> {({ el: '🇬🇷', en: '🇬🇧', ru: '🇷🇺', uk: '🇺🇦' } as any)[transLang]} {transLang.toUpperCase()} </> )} </button> )} </div> </div> ) })} </div> )} </div> )} {/* ── DB Check ── */} {tab === 'dbcheck' && <DbCheckTab />} {/* ── Referrals ── */} {tab === 'referrals' && ( <div className="space-y-6"> {/* Stats */} <div className="grid grid-cols-3 gap-4"> {[ { label: 'Рефереров', value: referralsData?.totalReferrers ?? '—', color: 'text-green-600' }, { label: 'Приглашений', value: referralsData?.totalReferrals ?? '—', color: 'text-purple-600' }, { label: 'Активных наград', value: referralsData?.activeRewards ?? '—', color: 'text-green-600' }, ].map(({ label, value, color }) => ( <div key={label} className="bg-white rounded-2xl border border-gray-200 p-5 text-center"> <div className={`text-3xl font-bold ${color}`}>{value}</div> <div className="text-xs text-gray-500 mt-1">{label}</div> </div> ))} </div> {/* Tree table */} <div className="bg-white rounded-2xl border border-gray-200 overflow-hidden"> <div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between"> <h2 className="font-semibold text-gray-800">Дерево рефералов</h2> <button onClick={() => { setReferralsLoading(true); api.getAdminReferrals().then(setReferralsData).catch(() => {}).finally(() => setReferralsLoading(false)) }} className="text-xs text-green-600 hover:underline flex items-center gap-1" > {referralsLoading && <svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg>} Обновить </button> </div> {referralsLoading && !referralsData ? ( <div className="flex justify-center py-16"> <svg className="w-6 h-6 animate-spin text-purple-600" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg> </div> ) : !referralsData || referralsData.tree.length === 0 ? ( <div className="py-16 text-center text-sm text-gray-400">Нет данных о рефералах</div> ) : ( <div className="divide-y divide-gray-100"> {referralsData.tree.map((node) => { const rid = node.referrer?.id ?? 'unknown' const isExpanded = referralsExpanded.has(rid) const referrerName = node.referrer ? `${node.referrer.firstName ?? ''} ${node.referrer.lastName ?? ''}`.trim() || node.referrer.email : '(удалён)' return ( <div key={rid}> {/* Referrer row */} <button className="w-full flex items-center gap-3 px-5 py-3.5 hover:bg-gray-50 text-left transition-colors" onClick={() => setReferralsExpanded((prev) => { const next = new Set(prev) next.has(rid) ? next.delete(rid) : next.add(rid) return next })} > <svg className={`w-4 h-4 text-gray-400 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} > <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/> </svg> <div className="flex-1 min-w-0"> <span className="font-medium text-sm text-gray-900">{referrerName}</span> {node.referrer && ( <span className="ml-2 text-xs text-gray-400">{node.referrer.email}</span> )} </div> {node.referrer?.referralCode && ( <code className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded font-mono">{node.referrer.referralCode}</code> )} <span className="ml-3 text-xs font-semibold text-purple-700 bg-purple-50 px-2 py-0.5 rounded-full"> {node.referees.length} реф. </span> </button> {/* Referees */} {isExpanded && ( <div className="bg-gray-50 border-t border-gray-100"> <table className="w-full text-xs"> <thead> <tr className="border-b border-gray-200"> <th className="text-left px-10 py-2 font-semibold text-gray-500">Приглашённый</th> <th className="text-left px-3 py-2 font-semibold text-gray-500">Email</th> <th className="text-center px-3 py-2 font-semibold text-gray-500">+дней</th> <th className="text-center px-3 py-2 font-semibold text-gray-500">Истекает</th> <th className="text-center px-3 py-2 font-semibold text-gray-500">Дата</th> </tr> </thead> <tbody> {node.referees.map((referee) => { const refName = `${referee.firstName ?? ''} ${referee.lastName ?? ''}`.trim() || referee.email const expiresAt = new Date(referee.expiresAt) const createdAt = new Date(referee.createdAt) const isActive = expiresAt > new Date() return ( <tr key={referee.id} className="border-b border-gray-100 last:border-0 hover:bg-white transition-colors"> <td className="px-10 py-2.5 font-medium text-gray-800">{refName}</td> <td className="px-3 py-2.5 text-gray-500">{referee.email}</td> <td className="px-3 py-2.5 text-center"> <span className="font-semibold text-green-700">+{referee.daysAdded}</span> </td> <td className="px-3 py-2.5 text-center"> <span className={`px-1.5 py-0.5 rounded text-xs font-medium ${isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}> {expiresAt.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' })} </span> </td> <td className="px-3 py-2.5 text-center text-gray-400"> {createdAt.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' })} </td> </tr> ) })} </tbody> </table> </div> )} </div> ) })} </div> )} </div> </div> )} {/* ── Tasks Translation Preview Modal ── */} {transPreview && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4" onClick={() => setTransPreview(null)}> <div className="bg-white rounded-2xl shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 flex-shrink-0"> <div> <h3 className="font-semibold text-gray-900"> Задачи без перевода: <span className="uppercase font-bold text-red-600">{transPreview.lang}</span> </h3> <p className="text-xs text-gray-400 mt-0.5">Показаны первые 50 записей</p> </div> <button onClick={() => setTransPreview(null)} className="text-gray-400 hover:text-gray-600 p-1"> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> </button> </div> <div className="overflow-y-auto flex-1 px-6 py-4"> {transPreview.loading ? ( <div className="flex justify-center py-12"> <svg className="w-6 h-6 animate-spin text-purple-600" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg> </div> ) : transPreview.rows.length === 0 ? ( <p className="text-sm text-gray-500 text-center py-12">Нет задач без перевода</p> ) : ( <table className="w-full text-xs border-collapse"> <thead> <tr className="border-b border-gray-200"> <th className="text-left py-2 pr-3 font-semibold text-gray-600 w-[35%]">Оригинал (title)</th> <th className="text-center py-2 px-2 font-semibold text-gray-600">orig</th> {(['el', 'en', 'ru', 'uk'] as const).map((l) => ( <th key={l} className="text-center py-2 px-2 font-semibold text-gray-600 w-[14%]">{l}</th> ))} </tr> </thead> <tbody> {transPreview.rows.map((row) => ( <tr key={row.id} className="border-b border-gray-100 hover:bg-gray-50"> <td className="py-2 pr-3 text-gray-800 truncate max-w-[200px]" title={row.title}>{row.title}</td> <td className="py-2 px-2 text-center"> <span className="uppercase font-semibold text-purple-700 bg-purple-50 px-1.5 py-0.5 rounded">{row.originalLocale}</span> </td> {([['el', row.titleEl], ['en', row.titleEn], ['ru', row.titleRu], ['uk', row.titleUk]] as [string, string | null][]).map(([l, val]) => ( <td key={l} className="py-2 px-2 text-center" title={val ?? ''}> {val ? <span className="text-emerald-600 font-semibold">✓</span> : <span className="text-red-500 font-semibold">✗</span>} </td> ))} </tr> ))} </tbody> </table> )} </div> </div> </div> )} {/* ── User Edit Modal ── */} {editUser && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"> {/* Header */} <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 flex-shrink-0"> <div> <h2 className="text-base font-semibold text-gray-900">{t('admin.user_edit.title')}</h2> <p className="text-xs text-gray-400 mt-0.5">{editUser.email} · ID: {editUser.id}</p> </div> <button onClick={() => setEditUser(null)} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button> </div> {/* Body: sidebar + content */} <div className="flex flex-1 min-h-0"> {/* Sidebar nav */} <nav className="w-44 flex-shrink-0 border-r border-gray-100 py-3 space-y-0.5 px-2"> {([ { id: 'profile' as const, label: t('admin.user_edit.tab.profile') }, { id: 'account' as const, label: t('admin.user_edit.tab.account') }, { id: 'balance' as const, label: t('admin.user_edit.tab.balance') }, { id: 'settings' as const, label: t('admin.user_edit.tab.settings') }, { id: 'site' as const, label: t('admin.user_edit.tab.site') }, { id: 'specialist' as const, label: t('admin.user_edit.tab.specialist') }, ]).map((item) => ( <button key={item.id} onClick={() => { setEditUserTab(item.id) if (item.id === 'specialist' && editUser) { setEditUserCardsLoading(true) const loadCards = api.getAdminUserSpecialistCards(editUser.id).then(setEditUserCards).catch(() => {}) const loadCats = catRows.length === 0 ? api.getAdminCategories().then(setCatRows).catch(() => {}) : Promise.resolve() const loadLocs = locRows.length === 0 ? api.getAdminLocations().then(setLocRows).catch(() => {}) : Promise.resolve() Promise.all([loadCards, loadCats, loadLocs]).finally(() => setEditUserCardsLoading(false)) } }} className={`w-full text-left px-3 py-2.5 text-sm rounded-lg transition-colors ${ editUserTab === item.id ? 'bg-green-50 text-green-700 font-medium' : 'text-gray-600 hover:bg-gray-50' }`} > {item.label} </button> ))} </nav> {/* Content area */} <div className="flex-1 overflow-y-auto p-6"> {/* ── Профиль ── */} {editUserTab === 'profile' && ( <div className="space-y-4"> <div className="p-3 bg-gray-50 rounded-lg text-xs text-gray-500 space-y-0.5"> <p><span className="font-medium">{t('admin.user_edit.registered')}:</span> {new Date(editUser.createdAt).toLocaleDateString(dateLocale)}</p> {editUser.lastSeenAt && <p><span className="font-medium">{t('admin.user_edit.last_seen')}:</span> {new Date(editUser.lastSeenAt).toLocaleDateString(dateLocale)}</p>} </div> <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.first_name')}</label> <input value={editUser.firstName ?? ''} onChange={(e) => setEditUser((u: any) => ({ ...u, firstName: 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.last_name')}</label> <input value={editUser.lastName ?? ''} onChange={(e) => setEditUser((u: any) => ({ ...u, lastName: 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" /> </div> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.phone')}</label> <input value={editUser.phone ?? ''} onChange={(e) => setEditUser((u: any) => ({ ...u, phone: 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" /> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.bio')}</label> <textarea value={editUser.bio ?? ''} onChange={(e) => setEditUser((u: any) => ({ ...u, bio: e.target.value }))} rows={5} 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 resize-none" /> </div> </div> )} {/* ── Аккаунт ── */} {editUserTab === 'account' && ( <div className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.email')}</label> <div className="w-full border border-gray-200 bg-gray-50 rounded-lg px-3 py-2 text-sm text-gray-600 select-all">{editUser.email}</div> </div> <div className="flex items-center justify-between rounded-lg border px-3 py-2 bg-gray-50"> <div className="flex items-center gap-2"> {editUser.emailVerified ? ( <svg className="w-4 h-4 text-green-500 shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd"/></svg> ) : ( <svg className="w-4 h-4 text-red-400 shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L10 8.586 7.707 6.293a1 1 0 00-1.414 1.414L8.586 10l-2.293 2.293a1 1 0 101.414 1.414L10 11.414l2.293 2.293a1 1 0 001.414-1.414L11.414 10l2.293-2.293z" clipRule="evenodd"/></svg> )} <span className={`text-sm font-medium ${editUser.emailVerified ? 'text-green-700' : 'text-red-600'}`}> {editUser.emailVerified ? t('admin.user_edit.email_verified') : t('admin.user_edit.email_not_verified')} </span> </div> {!editUser.emailVerified && ( <button onClick={verifyEmail} disabled={verifyingEmail} className="text-xs px-3 py-1 rounded-md bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors" > {verifyingEmail ? '...' : t('admin.user_edit.verify_email_btn')} </button> )} </div> <div className="grid grid-cols-2 gap-3"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.role')}</label> <select value={editUser.role} onChange={(e) => setEditUser((u: any) => ({ ...u, role: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="user">{t('admin.user_edit.role.user')}</option> <option value="admin">{t('admin.user_edit.role.admin')}</option> </select> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.plan')}</label> <select value={editUser.planId ?? ''} onChange={(e) => setEditUser((u: any) => ({ ...u, planId: e.target.value || null }))} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="">{t('admin.user_edit.no_plan')}</option> {planRows.map((p: any) => ( <option key={p.id} value={p.id}>{p.name}</option> ))} </select> </div> </div> {editUser.planExpiresAt && ( <p className="text-xs text-gray-500">{t('admin.user_edit.plan_expires')}: {new Date(editUser.planExpiresAt).toLocaleDateString(dateLocale)}</p> )} <label className="flex items-center gap-2 cursor-pointer"> <input type="checkbox" checked={editUser.isActive !== false} onChange={(e) => setEditUser((u: any) => ({ ...u, isActive: e.target.checked }))} className="w-4 h-4 rounded border-gray-300 text-green-600" /> <span className="text-sm text-gray-700">{t('admin.user_edit.active')}</span> </label> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.interface_lang')}</label> <select value={editUser.locale ?? 'el'} onChange={(e) => setEditUser((u: any) => ({ ...u, locale: e.target.value }))} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="el">Ελληνικά</option> <option value="en">English</option> <option value="ru">Русский</option> </select> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-2">{t('admin.user_edit.comm_languages')}</label> <div className="flex gap-5"> {(['el', 'en', 'uk', 'ru'] as const).map((lang) => ( <label key={lang} className="flex items-center gap-1.5 cursor-pointer"> <input type="checkbox" checked={(editUser.languages ?? []).includes(lang)} onChange={(e) => { const langs: string[] = editUser.languages ?? [] setEditUser((u: any) => ({ ...u, languages: e.target.checked ? [...langs.filter((l: string) => l !== lang), lang] : langs.filter((l: string) => l !== lang), })) }} className="w-4 h-4 rounded border-gray-300 text-green-600" /> <span className="text-sm text-gray-700">{lang === 'el' ? 'Ελληνικά' : lang === 'en' ? 'English' : lang === 'uk' ? 'Українська' : 'Русский'}</span> </label> ))} </div> </div> </div> )} {/* ── Баланс ── */} {editUserTab === 'balance' && ( <div className="space-y-6"> <div className="flex items-center justify-between p-5 bg-gray-50 rounded-xl border border-gray-200"> <div> <div className="text-xs text-gray-500 mb-1">{t('admin.user_edit.balance')}</div> <div className="text-2xl font-bold text-gray-900">{Number(editUser.balance ?? 0).toFixed(2)} EUR</div> </div> <div className="flex gap-2"> <button onClick={() => setBalanceModal('topup')} className="flex items-center gap-1.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg> {t('admin.balance.topup.btn')} </button> <button onClick={() => setBalanceModal('withdraw')} className="flex items-center gap-1.5 px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg text-sm font-medium" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" /></svg> {t('admin.balance.withdraw.btn')} </button> </div> </div> <p className="text-xs text-center text-gray-400">{t('admin.balance.modal_hint')}</p> </div> )} {/* ── Настройки ── */} {editUserTab === 'settings' && ( <div className="space-y-4"> <div className="space-y-3 pt-1 divide-y divide-gray-100"> <label className="flex items-center justify-between cursor-pointer py-2"> <div> <div className="text-sm font-medium text-gray-700">{t('admin.user_edit.show_contact_info')}</div> <div className="text-xs text-gray-400">{t('admin.user_edit.show_contact_info.hint')}</div> </div> <input type="checkbox" checked={editUser.showContactInfo ?? false} onChange={(e) => setEditUser((u: any) => ({ ...u, showContactInfo: e.target.checked }))} className="w-4 h-4 rounded border-gray-300 text-green-600" /> </label> <label className="flex items-center justify-between cursor-pointer py-2"> <div> <div className="text-sm font-medium text-gray-700">{t('admin.user_edit.notify_new_tasks')}</div> <div className="text-xs text-gray-400">{t('admin.user_edit.notify_new_tasks.hint')}</div> </div> <input type="checkbox" checked={editUser.notifyNewTasks ?? false} onChange={(e) => setEditUser((u: any) => ({ ...u, notifyNewTasks: e.target.checked }))} className="w-4 h-4 rounded border-gray-300 text-green-600" /> </label> <label className="flex items-center justify-between cursor-pointer py-2"> <div> <div className="text-sm font-medium text-gray-700">{t('admin.user_edit.notif_messages')}</div> <div className="text-xs text-gray-400">{t('admin.user_edit.notif_messages.hint')}</div> </div> <input type="checkbox" checked={editUser.notifMessages ?? true} onChange={(e) => setEditUser((u: any) => ({ ...u, notifMessages: e.target.checked }))} className="w-4 h-4 rounded border-gray-300 text-green-600" /> </label> </div> </div> )} {/* ── Site tab ── */} {editUserTab === 'site' && ( <div className="space-y-4"> <div> <label className="block text-sm font-medium text-gray-700 mb-1">{t('admin.user_edit.personal_site_slug')}</label> <div className="flex items-center gap-2"> <div className="flex flex-1 items-center border border-gray-300 rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-blue-400"> <span className="px-3 py-2 text-sm text-gray-400 bg-gray-50 border-r border-gray-300 flex-shrink-0">canhelp.gr/site/</span> <input value={editUser.personalSiteSlug ?? ''} onChange={(e) => setEditUser((u: any) => ({ ...u, personalSiteSlug: e.target.value || null }))} placeholder="my-username" className="flex-1 px-3 py-2 text-sm focus:outline-none" /> </div> {editUser.personalSiteSlug && ( <a href={`/site/${editUser.personalSiteSlug}`} target="_blank" rel="noopener noreferrer" className="shrink-0 px-3 py-2 border border-gray-300 rounded-lg text-sm text-green-600 hover:bg-green-50 flex items-center gap-1" > <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg> </a> )} </div> </div> {editUser.personalSiteSlug && ( <> <h4 className="text-sm font-semibold text-gray-800 pt-3 border-t border-gray-100">{t('profile.site.visible_title')}</h4> <div className="space-y-1"> {([ { key: 'showBio', label: t('profile.site.visible.bio') }, { key: 'showRating', label: t('profile.site.visible.rating') }, { key: 'showServices', label: t('profile.site.visible.services') }, { key: 'showPortfolio', label: t('profile.site.visible.portfolio') }, { key: 'showReviews', label: t('profile.site.visible.reviews') }, { key: 'showPhone', label: t('profile.site.visible.contacts') }, { key: 'showFullLastName', label: t('profile.site.visible.full_last_name') }, { key: 'showStatus', label: t('profile.site.visible.status') }, ] as const).map((item) => ( <label key={item.key} className="flex items-center justify-between cursor-pointer py-1.5"> <span className="text-sm text-gray-700">{item.label}</span> <input type="checkbox" checked={(editUser.siteSettings ?? {} as any)[item.key] !== false} onChange={(e) => setEditUser((u: any) => ({ ...u, siteSettings: { ...(u.siteSettings ?? {}), [item.key]: e.target.checked }, }))} className="w-4 h-4 rounded border-gray-300 text-green-600" /> </label> ))} </div> {/* Theme */} <div className="pt-2"> <label className="block text-sm font-medium text-gray-700 mb-1.5">{t('profile.site.theme')}</label> <select value={(editUser.siteSettings ?? {}).siteTheme ?? 'dark'} onChange={(e) => setEditUser((u: any) => ({ ...u, siteSettings: { ...(u.siteSettings ?? {}), siteTheme: e.target.value }, }))} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" > <option value="dark">{t('profile.site.theme.dark')}</option> <option value="light">{t('profile.site.theme.light')}</option> </select> </div> </> )} </div> )} {/* ── Портфолио специалиста ── */} {editUserTab === 'specialist' && ( <div className="space-y-6"> {(() => { if (editUser?.role === 'specialist') return null const bio = String(editUser?.bio ?? '').trim() const publishedCards = (editUserCards ?? []).filter((c: any) => (c.publicationStatus ?? (c.isActive ? 'active' : 'inactive')) === 'active') const hasPublishedCard = publishedCards.length > 0 const hasCategories = publishedCards.some((c: any) => (c.categories?.length ?? 0) > 0) const hasLocations = publishedCards.some((c: any) => (c.locations?.length ?? 0) > 0) const missing: string[] = [] if (!bio) missing.push(t('admin.user_edit.visibility_requirements.bio', 'Fill in bio')) if (!hasPublishedCard) missing.push(t('admin.user_edit.visibility_requirements.published_card', 'Publish at least one card')) if (!hasCategories) missing.push(t('admin.user_edit.visibility_requirements.categories', 'Add a category in a published card')) if (!hasLocations) missing.push(t('admin.user_edit.visibility_requirements.locations', 'Add a location in a published card')) if (missing.length === 0) return null return ( <div className="rounded-xl border border-amber-300 bg-amber-50 p-3"> <p className="text-sm font-semibold text-amber-900"> {t('admin.user_edit.visibility_requirements.title', 'Profile is not yet visible in Offers')} </p> <p className="text-xs text-amber-800 mt-1"> {t('admin.user_edit.visibility_requirements.hint', 'Visibility requirements to complete:')} </p> <ul className="mt-2 text-xs text-amber-900 list-disc pl-5 space-y-0.5"> {missing.map((m) => ( <li key={m}>{m}</li> ))} </ul> </div> ) })()} {/* User-level skills */} <div> <label className="block text-sm font-medium text-gray-700 mb-2">{t('admin.user_edit.skills')}</label> <div className="flex flex-wrap gap-1.5 mb-2 min-h-[28px]"> {(editUser.skills ?? []).map((s: string) => ( <span key={s} className="inline-flex items-center gap-1 text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded-full"> {s} <button onClick={() => setEditUser((u: any) => ({ ...u, skills: (u.skills ?? []).filter((x: string) => x !== s) }))} className="text-gray-400 hover:text-green-700 leading-none" >×</button> </span> ))} </div> <div className="flex gap-2"> <input value={skillInput} onChange={(e) => setSkillInput(e.target.value)} onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ',') && skillInput.trim()) { e.preventDefault() const val = skillInput.trim().replace(/,$/, '') if (val && !(editUser.skills ?? []).includes(val)) { setEditUser((u: any) => ({ ...u, skills: [...(u.skills ?? []), val] })) } setSkillInput('') } }} placeholder={t('admin.user_edit.skill_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" /> <button onClick={() => { const val = skillInput.trim() if (val && !(editUser.skills ?? []).includes(val)) { setEditUser((u: any) => ({ ...u, skills: [...(u.skills ?? []), val] })) } setSkillInput('') }} className="px-3 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700" >{t('common.add')}</button> </div> </div> {/* Specialist cards */} <div className="border-t border-gray-100 pt-4"> <div className="text-sm font-medium text-gray-700 mb-3">{t('admin.user_edit.specialist_cards')}</div> {editUserCardsLoading ? ( <p className="text-xs text-gray-400 py-2">{t('common.loading')}</p> ) : editUserCards.length === 0 ? ( <p className="text-xs text-gray-400 py-4 text-center">{t('admin.user_edit.no_cards')}</p> ) : ( <div className="space-y-4"> {editUserCards.map((card: any) => ( <div key={card.id} className="border border-gray-200 bg-gray-50 rounded-xl p-4 space-y-3"> {/* Title + publication status */} <div className="flex items-center gap-3"> <input value={card.title ?? ''} onChange={(e) => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, title: e.target.value } : c))} placeholder={t('admin.user_edit.card_title')} 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" /> <select value={card.publicationStatus ?? (card.isActive ? 'active' : 'inactive')} onChange={(e) => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, publicationStatus: e.target.value, isActive: e.target.value === 'active', } : c))} className="rounded-lg border border-gray-300 bg-white px-2 py-2 text-xs text-gray-700" > <option value="inactive">{t('admin.moderation.inactive', 'Draft')}</option> <option value="pending">{t('admin.moderation.pending', 'Under review')}</option> <option value="active">{t('admin.moderation.active', 'Active')}</option> </select> </div> {/* Description */} <textarea value={card.description ?? ''} onChange={(e) => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, description: e.target.value } : c))} rows={3} placeholder={t('admin.user_edit.card_description')} 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 resize-none" /> {/* Card skills */} <div> <div className="text-xs font-medium text-gray-500 mb-1.5">{t('admin.user_edit.skills')}</div> <div className="flex flex-wrap gap-1.5 mb-1.5 min-h-[24px]"> {(card.skills ?? []).map((s: string) => ( <span key={s} className="inline-flex items-center gap-1 text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded-full"> {s} <button onClick={() => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, skills: (c.skills ?? []).filter((x: string) => x !== s) } : c))} className="text-gray-400 hover:text-green-700 leading-none" >×</button> </span> ))} </div> <div className="flex gap-2"> <input value={cardSkillInputs[card.id] ?? ''} onChange={(e) => setCardSkillInputs((prev) => ({ ...prev, [card.id]: e.target.value }))} onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ',') && (cardSkillInputs[card.id] ?? '').trim()) { e.preventDefault() const val = (cardSkillInputs[card.id] ?? '').trim().replace(/,$/, '') if (val && !(card.skills ?? []).includes(val)) { setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, skills: [...(c.skills ?? []), val] } : c)) } setCardSkillInputs((prev) => ({ ...prev, [card.id]: '' })) } }} placeholder={t('admin.user_edit.skill_placeholder')} className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-400" /> <button onClick={() => { const val = (cardSkillInputs[card.id] ?? '').trim() if (val && !(card.skills ?? []).includes(val)) { setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, skills: [...(c.skills ?? []), val] } : c)) } setCardSkillInputs((prev) => ({ ...prev, [card.id]: '' })) }} className="px-2.5 py-1.5 bg-green-600 text-white rounded-lg text-xs hover:bg-green-700" >+</button> </div> </div> {/* Card categories */} <div> <div className="text-xs font-medium text-gray-500 mb-1.5">{t('admin.user_edit.categories')}</div> <div className="flex flex-wrap gap-1.5 mb-1.5 min-h-[24px]"> {(card.categories ?? []).map((slug: string) => ( <span key={slug} className="inline-flex items-center gap-1 text-xs bg-purple-50 text-purple-700 px-2 py-0.5 rounded-full"> {getCatLabel(catRows.find((c: any) => c.slug === slug) ?? { slug }, locale)} <button onClick={() => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, categories: (c.categories ?? []).filter((x: string) => x !== slug) } : c))} className="text-purple-400 hover:text-purple-700 leading-none" >×</button> </span> ))} </div> <AdminCatPicker catRows={catRows} locale={locale} selected={card.categories ?? []} onAdd={(id) => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, categories: [...(c.categories ?? []), id] } : c))} addLabel={t('admin.user_edit.add_category')} /> </div> {/* Card locations */} <div> <div className="text-xs font-medium text-gray-500 mb-1.5">{t('admin.user_edit.locations')}</div> <div className="flex flex-wrap gap-1.5 mb-1.5 min-h-[24px]"> {(card.locations ?? []).map((slug: string) => ( <span key={slug} className="inline-flex items-center gap-1 text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded-full"> {getLocLabel(locRows.find((l: any) => l.slug === slug) ?? { slug }, locale)} <button onClick={() => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, locations: (c.locations ?? []).filter((x: string) => x !== slug) } : c))} className="text-green-400 hover:text-green-700 leading-none" >×</button> </span> ))} </div> <AdminLocPicker locRows={locRows} locale={locale} selected={card.locations ?? []} onAdd={(id) => setEditUserCards((prev) => prev.map((c: any) => c.id === card.id ? { ...c, locations: [...(c.locations ?? []), id] } : c))} addLabel={t('admin.user_edit.add_location')} /> </div> {/* Save card */} <div className="flex justify-end pt-1"> <button onClick={() => saveCard(card.id)} disabled={cardSaving[card.id]} className="px-4 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg text-xs font-medium disabled:opacity-50" > {cardSaving[card.id] ? '...' : t('common.save')} </button> </div> </div> ))} </div> )} </div> </div> )} </div> </div> {/* Footer */} {editUserTab !== 'balance' ? ( <div className="border-t border-gray-100 px-6 py-4 flex gap-3 flex-shrink-0"> <button onClick={saveEditUser} disabled={editUserSaving} className="flex-1 bg-green-600 hover:bg-green-700 text-white py-2 rounded-lg text-sm font-semibold disabled:opacity-50" > {editUserSaving ? '...' : t('common.save')} </button> <button onClick={() => setEditUser(null)} className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg text-sm font-medium" > {t('common.cancel')} </button> </div> ) : ( <div className="border-t border-gray-100 px-6 py-4 flex-shrink-0"> <button onClick={() => setEditUser(null)} className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg text-sm font-medium" > {t('common.close')} </button> </div> )} </div> </div> )} {/* ── Balance Mini-Modals ── */} {editUser && balanceModal && ( <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 px-4"> <div className={`bg-white rounded-2xl shadow-2xl p-6 w-full max-w-sm border-t-4 ${balanceModal === 'topup' ? 'border-emerald-500' : 'border-rose-500'}`}> <div className="flex items-center justify-between mb-4"> <h3 className={`text-base font-semibold ${balanceModal === 'topup' ? 'text-emerald-900' : 'text-rose-900'}`}> {balanceModal === 'topup' ? t('admin.balance.topup.title') : t('admin.balance.withdraw.title')} </h3> <button onClick={() => setBalanceModal(null)} className="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button> </div> <p className="text-xs text-gray-500 mb-4"> {t('admin.user_edit.balance')}: <span className="font-semibold text-gray-700">{Number(editUser.balance ?? 0).toFixed(2)} EUR</span> </p> <div className="space-y-3"> <div> <label className={`block text-sm font-medium mb-1 ${balanceModal === 'topup' ? 'text-emerald-800' : 'text-rose-800'}`}> {balanceModal === 'topup' ? t('admin.balance.topup.amount') : t('admin.balance.withdraw.amount')} </label> <input type="number" min="0.01" step="0.01" value={balanceModal === 'topup' ? (editUser.topUpAmount ?? '') : (editUser.withdrawAmount ?? '')} onChange={(e) => setEditUser((u: any) => balanceModal === 'topup' ? { ...u, topUpAmount: e.target.value } : { ...u, withdrawAmount: e.target.value } )} className={`w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 ${balanceModal === 'topup' ? 'border-emerald-300 focus:ring-emerald-400' : 'border-rose-300 focus:ring-rose-400'}`} /> </div> <div> <label className={`block text-sm font-medium mb-1 ${balanceModal === 'topup' ? 'text-emerald-800' : 'text-rose-800'}`}> {balanceModal === 'topup' ? t('admin.balance.topup.description') : t('admin.balance.withdraw.description')} </label> <input value={balanceModal === 'topup' ? (editUser.topUpDescription ?? '') : (editUser.withdrawDescription ?? '')} onChange={(e) => setEditUser((u: any) => balanceModal === 'topup' ? { ...u, topUpDescription: e.target.value } : { ...u, withdrawDescription: e.target.value } )} placeholder={balanceModal === 'topup' ? t('admin.balance.topup.description.placeholder') : t('admin.balance.withdraw.description.placeholder') } className={`w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 ${balanceModal === 'topup' ? 'border-emerald-300 focus:ring-emerald-400' : 'border-rose-300 focus:ring-rose-400'}`} /> </div> </div> <div className="flex gap-2 mt-5"> <button onClick={async () => { if (balanceModal === 'topup') { await handleAdminTopUp() } else { await handleAdminWithdraw() } setBalanceModal(null) }} disabled={balanceModal === 'topup' ? (topUpSaving || !Number(editUser.topUpAmount)) : (withdrawSaving || !Number(editUser.withdrawAmount))} className={`flex-1 text-white py-2 rounded-lg text-sm font-semibold disabled:opacity-50 ${balanceModal === 'topup' ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-rose-600 hover:bg-rose-700'}`} > {(balanceModal === 'topup' ? topUpSaving : withdrawSaving) ? t('common.loading') : balanceModal === 'topup' ? t('admin.balance.topup.submit') : t('admin.balance.withdraw.submit') } </button> <button onClick={() => setBalanceModal(null)} className="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg text-sm font-medium" > {t('common.cancel')} </button> </div> </div> </div> )} </div> </div> </div> </div> ) } // ─── DbCheckTab ───────────────────────────────────────────────────────────── function DbCheckTab() { // ── Skill duplicates ────────────────────────────────────────────────────── const [dedupLoading, setDedupLoading] = useState(false) const [dedupGroups, setDedupGroups] = useState<any[][] | null>(null) const [keepIds, setKeepIds] = useState<Record<string, string>>({}) const [merging, setMerging] = useState<string | null>(null) function gk(group: any[]) { return group[0]?.nameEl?.toLowerCase().trim() ?? group[0]?.id } async function findDuplicates() { setDedupLoading(true) setDedupGroups(null) setKeepIds({}) try { const groups = await api.getSkillDedup() // all categories setDedupGroups(groups) const auto: Record<string, string> = {} groups.forEach((group) => { const best = [...group].sort((a, b) => (b.usageCount ?? 0) - (a.usageCount ?? 0))[0] auto[gk(group)] = best.id }) setKeepIds(auto) } catch { setDedupGroups([]) } finally { setDedupLoading(false) } } async function mergeGroup(key: string, group: any[]) { const keepId = keepIds[key] if (!keepId) return const deleteIds = group.filter((s) => s.id !== keepId).map((s) => s.id) if (!deleteIds.length) return setMerging(key) try { await api.mergeSkillDuplicates(keepId, deleteIds) setDedupGroups((prev) => prev ? prev.filter((g) => gk(g) !== key) : prev) } catch { /* ignore */ } finally { setMerging(null) } } async function mergeAll() { if (!dedupGroups?.length) return setMerging('__all__') for (const group of [...dedupGroups]) { const key = gk(group) const keepId = keepIds[key] if (!keepId) continue const deleteIds = group.filter((s) => s.id !== keepId).map((s) => s.id) if (!deleteIds.length) continue try { await api.mergeSkillDuplicates(keepId, deleteIds) setDedupGroups((prev) => prev ? prev.filter((g) => gk(g) !== key) : prev) } catch { /* continue */ } } setMerging(null) } const totalDuplicates = dedupGroups?.reduce((acc, g) => acc + g.length - 1, 0) ?? 0 return ( <div className="space-y-6"> <div> <h2 className="text-base font-semibold text-gray-900">Проверка базы данных</h2> <p className="text-sm text-gray-500 mt-0.5">Диагностика и исправление проблем в базе данных</p> </div> {/* ── Skill duplicates check ── */} <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> <div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between"> <div> <h3 className="text-sm font-semibold text-gray-800">Дубликаты навыков</h3> <p className="text-xs text-gray-400 mt-0.5">Поиск навыков с одинаковым именем в одной категории. При объединении — ссылки в карточках специалистов обновляются автоматически.</p> </div> <button onClick={findDuplicates} disabled={dedupLoading} className="text-sm px-4 py-2 rounded-lg bg-orange-50 text-green-700 border border-orange-200 hover:bg-green-100 font-medium flex items-center gap-2 transition disabled:opacity-50" > <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="11" cy="11" r="8"/><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35"/></svg> {dedupLoading ? 'Ищу…' : 'Найти дубликаты'} </button> </div> {dedupGroups !== null && ( <div className="p-5"> {dedupGroups.length === 0 ? ( <div className="flex items-center gap-2 text-sm text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-lg px-4 py-3"> <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/></svg> Дубликатов не найдено — база в порядке </div> ) : ( <div className="space-y-3"> <div className="flex items-center justify-between"> <p className="text-sm text-green-700 font-semibold"> Найдено {dedupGroups.length} групп · {totalDuplicates} лишних записей </p> <button onClick={mergeAll} disabled={merging === '__all__'} className="text-sm px-4 py-1.5 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 disabled:opacity-50 transition" > {merging === '__all__' ? 'Объединяю…' : `Объединить все (${dedupGroups.length})`} </button> </div> {dedupGroups.map((group) => { const key = gk(group) return ( <div key={key} className="bg-white border border-orange-200 rounded-xl overflow-hidden shadow-sm"> <div className="px-4 py-2.5 bg-orange-50 border-b border-orange-100 flex items-center justify-between gap-3"> <div className="min-w-0"> <span className="text-sm font-semibold text-orange-800">«{group[0]?.nameEl}»</span> <span className="ml-2 text-xs text-green-600 font-mono">{group[0]?.categorySlug}</span> <span className="ml-2 text-xs text-orange-400">{group.length} варианта</span> </div> <button onClick={() => mergeGroup(key, group)} disabled={!!merging} className="shrink-0 text-xs px-3 py-1 bg-orange-600 text-white rounded-lg font-semibold hover:bg-orange-700 disabled:opacity-50 transition" > {merging === key ? 'Объединяю…' : 'Объединить'} </button> </div> <div className="divide-y divide-gray-50"> {group.map((skill) => ( <label key={skill.id} className="flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-gray-50 transition"> <input type="radio" name={`dedup-global-${key}`} checked={keepIds[key] === skill.id} onChange={() => setKeepIds((prev) => ({ ...prev, [key]: skill.id }))} className="accent-amber-600 shrink-0" /> <div className="flex-1 min-w-0"> <div className="flex items-center gap-3 flex-wrap"> <span className="text-sm font-medium text-gray-800">{skill.nameEl}</span> {skill.nameEn && skill.nameEn !== skill.nameEl && <span className="text-xs text-gray-400">EN: {skill.nameEn}</span>} {skill.nameRu && skill.nameRu !== skill.nameEl && <span className="text-xs text-gray-400">RU: {skill.nameRu}</span>} {skill.nameUk && <span className="text-xs text-gray-400">UK: {skill.nameUk}</span>} </div> <div className="flex items-center gap-3 mt-0.5"> <span className={`text-[11px] font-semibold ${skill.usageCount > 0 ? 'text-green-600' : 'text-gray-400'}`}> {skill.usageCount > 0 ? `${skill.usageCount} использований` : 'не использовался'} </span> <span className="text-[11px] text-gray-300">order: {skill.order}</span> {keepIds[key] === skill.id && ( <span className="text-[11px] font-bold text-amber-600 uppercase">← оставить</span> )} </div> </div> </label> ))} </div> </div> ) })} </div> )} </div> )} </div> </div> ) } function LocTree({ rows, parentId, depth, locale, expanded, onToggle, onEdit, onDelete, onAddChild, t }: { rows: any[] parentId: string | null depth: number locale: string expanded: Set<string> onToggle: (id: string) => void onEdit: (loc: any) => void onDelete: (id: string, name: string) => void onAddChild: (parentId: string) => void t: (key: string, fallback?: string) => string }) { const children = rows.filter((r) => (r.parentId ?? null) === parentId) if (children.length === 0) return null return ( <> {children.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((loc) => { const hasChildren = rows.some((r) => r.parentId === loc.id) const isOpen = expanded.has(loc.id) const name = (locale === 'en' ? loc.nameEn : locale === 'ru' ? loc.nameRu : locale === 'uk' ? loc.nameUk : loc.nameEl) || loc.nameEl || loc.slug return ( <div key={loc.id}> <div className={`flex items-center gap-2 px-4 py-3 hover:bg-gray-50 ${!loc.isActive ? 'opacity-50' : ''}`} style={{ paddingLeft: `${16 + depth * 24}px` }} > <button onClick={() => hasChildren && onToggle(loc.id)} className={`w-5 h-5 flex items-center justify-center text-gray-400 rounded ${hasChildren ? 'hover:bg-gray-200 cursor-pointer' : 'cursor-default'}`} > {hasChildren ? (isOpen ? <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="6 9 12 15 18 9"/></svg> : <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="9 18 15 12 9 6"/></svg> ) : <span className="w-3.5 h-3.5 block"/>} </button> {depth === 0 ? <svg className="w-5 h-5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/></svg> : <svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg> } <div className="flex-1 min-w-0"> <span className="font-medium text-gray-800 text-sm">{name}</span> <span className="ml-2 text-xs text-gray-400">{loc.nameEn}</span> <span className="ml-1.5 font-mono text-xs text-gray-300">/{loc.slug}</span> </div> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${loc.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}> {loc.isActive ? t('admin.cat.active') : t('admin.cat.inactive')} </span> <div className="flex gap-1 flex-shrink-0"> {depth === 0 && ( <button onClick={() => onAddChild(loc.id)} className="text-xs px-2 py-1 rounded-lg bg-green-50 text-green-600 hover:bg-green-100 font-medium" title={t('admin.loc.add_district')} > + </button> )} <button onClick={() => onEdit(loc)} className="text-xs px-2 py-1 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 font-medium" > {t('common.edit')} </button> <button onClick={() => onDelete(loc.id, name)} className="text-xs px-2 py-1 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 font-medium" > {t('common.delete')} </button> </div> </div> {hasChildren && isOpen && ( <LocTree rows={rows} parentId={loc.id} depth={depth + 1} locale={locale} expanded={expanded} onToggle={onToggle} onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} t={t} /> )} </div> ) })} </> ) } function CatTree({ rows, parentId, depth, locale, expanded, onToggle, onEdit, onDelete, onAddChild, skillsExpanded, skillsCache, skillsLoading, onToggleSkills, skillForm, skillSaving, onOpenAddSkill, onOpenEditSkill, onSaveSkill, onDeleteSkill, onCancelSkillForm, onSkillFormChange, t }: { rows: any[] parentId: string | null depth: number locale: string expanded: Set<string> onToggle: (id: string) => void onEdit: (cat: any) => void onDelete: (id: string, name: string) => void onAddChild: (parentId: string) => void skillsExpanded: Set<string> skillsCache: Record<string, any[]> skillsLoading: Set<string> onToggleSkills: (cat: any) => void skillForm: any skillSaving: boolean onOpenAddSkill: (slug: string) => void onOpenEditSkill: (skill: any, slug: string) => void onSaveSkill: () => void onDeleteSkill: (skill: any, slug: string) => void onCancelSkillForm: () => void onSkillFormChange: (f: any) => void t: (key: string, fallback?: string) => string }) { const children = rows.filter((r) => (r.parentId ?? null) === parentId) if (children.length === 0) return null return ( <> {children.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((cat) => { const hasChildren = rows.some((r) => r.parentId === cat.id) const isOpen = expanded.has(cat.id) const skillsOpen = skillsExpanded.has(cat.id) const isLoadingSkills = skillsLoading.has(cat.id) const skills = skillsCache[cat.slug] ?? [] const name = (locale === 'en' ? cat.namesEn : locale === 'ru' ? cat.namesRu : locale === 'uk' ? cat.namesUk : cat.namesEl) || cat.namesEl || cat.slug return ( <div key={cat.id}> <div className={`flex items-center gap-2 px-4 py-3 hover:bg-gray-50 ${!cat.isActive ? 'opacity-50' : ''}`} style={{ paddingLeft: `${16 + depth * 24}px` }} > {/* Expand toggle */} <button onClick={() => hasChildren && onToggle(cat.id)} className={`w-5 h-5 flex items-center justify-center text-gray-400 rounded ${hasChildren ? 'hover:bg-gray-200 cursor-pointer' : 'cursor-default'}`} > {hasChildren ? (isOpen ? <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="6 9 12 15 18 9"/></svg> : <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><polyline points="9 18 15 12 9 6"/></svg> ) : <span className="w-3.5 h-3.5 block"/>} </button> <span className="w-10 h-10 flex items-center justify-center rounded-xl bg-green-50"> <CategoryIcon icon={cat.icon} alt={name} className="h-7 w-7 object-contain text-2xl leading-none" fallback="📦" /> </span> <div className="flex-1 min-w-0"> <span className="font-medium text-gray-800 text-sm">{name}</span> <span className="ml-2 text-xs text-gray-400">{cat.namesEn || cat.names?.en}</span> <span className="ml-1.5 font-mono text-xs text-gray-300">/{cat.slug}</span> </div> <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${cat.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}> {cat.isActive ? t('admin.cat.active') : t('admin.cat.inactive')} </span> <div className="flex gap-1 flex-shrink-0"> {/* Skills toggle */} <button onClick={() => onToggleSkills(cat)} className={`text-xs px-2 py-1 rounded-lg font-medium flex items-center gap-1 ${skillsOpen ? 'bg-amber-100 text-amber-700' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`} title="Навыки" > <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/></svg> {skillsCache[cat.slug] ? skillsCache[cat.slug].length : t('admin.cat.skills', 'Skills')} </button> <button onClick={() => onAddChild(cat.id)} className="text-xs px-2 py-1 rounded-lg bg-green-50 text-green-600 hover:bg-green-100 font-medium" title={t('admin.cat.add_child')} > + </button> <button onClick={() => onEdit(cat)} className="text-xs px-2 py-1 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 font-medium" > {t('common.edit')} </button> <button onClick={() => onDelete(cat.id, name)} className="text-xs px-2 py-1 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 font-medium" > {t('common.delete')} </button> </div> </div> {/* Skills panel */} {skillsOpen && ( <SkillsPanel cat={cat} locale={locale} depth={depth} t={t} skills={skills} isLoading={isLoadingSkills} skillForm={skillForm} skillSaving={skillSaving} onOpenAddSkill={onOpenAddSkill} onOpenEditSkill={onOpenEditSkill} onSaveSkill={onSaveSkill} onDeleteSkill={onDeleteSkill} onCancelSkillForm={onCancelSkillForm} onSkillFormChange={onSkillFormChange} /> )} {hasChildren && isOpen && ( <CatTree rows={rows} parentId={cat.id} depth={depth + 1} locale={locale} expanded={expanded} onToggle={onToggle} onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} skillsExpanded={skillsExpanded} skillsCache={skillsCache} skillsLoading={skillsLoading} onToggleSkills={onToggleSkills} skillForm={skillForm} skillSaving={skillSaving} onOpenAddSkill={onOpenAddSkill} onOpenEditSkill={onOpenEditSkill} onSaveSkill={onSaveSkill} onDeleteSkill={onDeleteSkill} onCancelSkillForm={onCancelSkillForm} onSkillFormChange={onSkillFormChange} t={t} /> )} </div> ) })} </> ) } // ─── SkillsPanel ──────────────────────────────────────────────────────────── // Self-contained component for the skill list + edit form + dedup panel per category const SKILL_LANG_LABELS: Record<string, string> = { nameEl: '🇬🇷 EL', nameEn: '🇬🇧 EN', nameRu: '🇷🇺 RU', nameUk: '🇺🇦 UK' } function SkillForm({ form, saving, onSave, onCancel, onChange }: { form: any saving: boolean onSave: () => void onCancel: () => void onChange: (f: any) => void }) { return ( <div className="bg-white border border-amber-200 rounded-xl p-4 shadow-sm"> <div className="grid grid-cols-2 gap-3 mb-3"> {(['nameEl', 'nameEn', 'nameRu', 'nameUk'] as const).map((field, i) => ( <div key={field}> <label className="block text-[11px] font-semibold text-gray-500 mb-1 uppercase tracking-wide"> {SKILL_LANG_LABELS[field]} </label> <input value={form[field] ?? ''} onChange={(e) => onChange({ ...form, [field]: e.target.value })} className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent transition" autoFocus={i === 0} placeholder={field === 'nameUk' ? 'необовʼязково' : ''} /> </div> ))} </div> <div className="flex items-center gap-3 pt-1 border-t border-gray-100"> <label className="text-xs text-gray-400 font-medium">Порядок</label> <input type="number" value={form.order ?? 0} onChange={(e) => onChange({ ...form, order: Number(e.target.value) })} className="w-16 border border-gray-200 rounded-lg px-2 py-1 text-xs text-center" /> <div className="flex gap-2 ml-auto"> <button onClick={onCancel} className="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-xs font-medium hover:bg-gray-200 transition" > Отмена </button> <button onClick={onSave} disabled={saving || !form.nameEl?.trim()} className="px-4 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-semibold hover:bg-amber-700 disabled:opacity-40 transition" > {saving ? 'Сохраняю…' : 'Сохранить'} </button> </div> </div> </div> ) } function SkillsPanel({ cat, locale, depth, t, skills, isLoading, skillForm, skillSaving, onOpenAddSkill, onOpenEditSkill, onSaveSkill, onDeleteSkill, onCancelSkillForm, onSkillFormChange }: { cat: any locale: string depth: number t: (key: string, fallback?: string) => string skills: any[] isLoading: boolean skillForm: any skillSaving: boolean onOpenAddSkill: (slug: string) => void onOpenEditSkill: (skill: any, slug: string) => void onSaveSkill: () => void onDeleteSkill: (skill: any, slug: string) => void onCancelSkillForm: () => void onSkillFormChange: (f: any) => void }) { const showAddForm = skillForm?.catSlug === cat.slug && !skillForm?.id return ( <div className="border-t border-amber-100 bg-gradient-to-b from-amber-50/60 to-white" style={{ paddingLeft: `${16 + depth * 24 + 36}px`, paddingRight: '16px', paddingTop: '14px', paddingBottom: '14px' }} > {/* Header */} <div className="flex items-center gap-2 mb-3"> <span className="text-xs font-bold text-amber-700 uppercase tracking-wider">Навыки</span> <span className="text-xs text-amber-500 font-mono">/{cat.slug}</span> <span className="ml-1 text-xs text-amber-400">({skills.length})</span> <div className="ml-auto"> <button onClick={() => { onCancelSkillForm(); onOpenAddSkill(cat.slug) }} className="text-xs px-2.5 py-1 rounded-lg bg-amber-600 text-white hover:bg-amber-700 font-semibold flex items-center gap-1 transition" > <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Добавить </button> </div> </div> {/* Loading state */} {isLoading ? ( <div className="flex items-center gap-2 py-3 text-xs text-gray-400"> <svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25"/><path fill="currentColor" d="M4 12a8 8 0 018-8v8z" className="opacity-75"/></svg> Загрузка… </div> ) : ( <div className="space-y-1"> {skills.length === 0 && !showAddForm && ( <p className="text-xs text-gray-400 italic py-2">Навыки не добавлены</p> )} {skills.map((skill) => { const isEditingThis = skillForm?.id === skill.id const skillName = locale === 'en' ? skill.nameEn : locale === 'ru' ? skill.nameRu : locale === 'uk' ? (skill.nameUk || skill.nameEn) : skill.nameEl return ( <div key={skill.id}> <div className={`flex items-center gap-2 rounded-lg px-3 py-2 group transition ${isEditingThis ? 'bg-amber-50 border border-amber-200' : 'bg-white border border-gray-100 hover:border-amber-200 hover:bg-amber-50/30'}`}> <div className="flex-1 min-w-0"> <span className="text-sm font-medium text-gray-800">{skillName}</span> {locale !== 'en' && skill.nameEn && ( <span className="ml-2 text-xs text-gray-400">{skill.nameEn}</span> )} {locale !== 'ru' && skill.nameRu && skill.nameRu !== skill.nameEl && ( <span className="ml-1.5 text-xs text-gray-300">{skill.nameRu}</span> )} </div> {skill.order > 0 && ( <span className="text-[10px] text-gray-300 font-mono">#{skill.order}</span> )} <div className={`flex gap-1 transition-opacity ${isEditingThis ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}> <button onClick={() => isEditingThis ? onCancelSkillForm() : (onCancelSkillForm(), onOpenEditSkill(skill, cat.slug))} className={`text-xs px-2 py-0.5 rounded-md font-medium transition ${isEditingThis ? 'bg-amber-100 text-amber-700' : 'bg-gray-100 text-gray-600 hover:bg-amber-100 hover:text-amber-700'}`} > {isEditingThis ? '✕' : t('common.edit')} </button> <button onClick={() => onDeleteSkill(skill, cat.slug)} className="text-xs px-2 py-0.5 rounded-md bg-red-50 text-red-600 hover:bg-red-100 transition" > {t('common.delete')} </button> </div> </div> {/* Inline edit form — slides in below the skill row */} {isEditingThis && ( <div className="mt-1 mb-1 ml-0"> <SkillForm form={skillForm} saving={skillSaving} onSave={onSaveSkill} onCancel={onCancelSkillForm} onChange={onSkillFormChange} /> </div> )} </div> ) })} {/* Add new skill form */} {showAddForm && ( <div className="mt-2"> <SkillForm form={skillForm} saving={skillSaving} onSave={onSaveSkill} onCancel={onCancelSkillForm} onChange={onSkillFormChange} /> </div> )} </div> )} </div> ) } function Pagination({ page, total, limit, onChange, totalLabel }: { page: number total: number limit: number onChange: (p: number) => void totalLabel: string }) { const pages = Math.ceil(total / limit) if (pages <= 1) return null return ( <div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 text-sm text-gray-500"> <span>{totalLabel}: {total}</span> <div className="flex gap-1"> <button onClick={() => onChange(page - 1)} disabled={page === 1} className="px-3 py-1 rounded-lg border border-gray-200 disabled:opacity-40 hover:bg-gray-50" > ← </button> <span className="px-3 py-1">{page} / {pages}</span> <button onClick={() => onChange(page + 1)} disabled={page >= pages} className="px-3 py-1 rounded-lg border border-gray-200 disabled:opacity-40 hover:bg-gray-50" > → </button> </div> </div> ) } // ── Activity log helpers ────────────────────────────────────────────────── function parseLogDetails(details: string | null): Record<string, unknown> | null { try { return details ? JSON.parse(details) : null } catch { return null } } const EVENT_META: Record<string, { icon: ReactNode; labelKey: string; color: string }> = { 'user.login': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>, labelKey: 'admin.event.user_login', color: 'bg-green-50 text-green-700 border-green-100' }, 'user.register': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>, labelKey: 'admin.event.user_register', color: 'bg-green-50 text-green-700 border-green-100' }, 'admin.user.deactivate': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>, labelKey: 'admin.event.admin_user_deactivate', color: 'bg-red-50 text-red-700 border-red-100' }, 'admin.user.activate': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>, labelKey: 'admin.event.admin_user_activate', color: 'bg-emerald-50 text-emerald-700 border-emerald-100' }, 'admin.user.role_changed': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/></svg>, labelKey: 'admin.event.admin_user_role_changed', color: 'bg-purple-50 text-purple-700 border-purple-100' }, 'admin.task.cancelled': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>, labelKey: 'admin.event.admin_task_cancelled', color: 'bg-orange-50 text-green-700 border-orange-100' }, 'admin.task.deleted': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><polyline points="3 6 5 6 21 6"/><path strokeLinecap="round" strokeLinejoin="round" d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m5 0V4a1 1 0 011-1h2a1 1 0 011 1v2"/></svg>, labelKey: 'admin.event.admin_task_deleted', color: 'bg-red-50 text-red-700 border-red-100' }, 'admin.report.reviewed': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><polyline points="20 6 9 17 4 12"/></svg>, labelKey: 'admin.event.admin_report_reviewed', color: 'bg-teal-50 text-teal-700 border-teal-100' }, 'admin.report.dismissed': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>, labelKey: 'admin.event.admin_report_dismissed', color: 'bg-gray-50 text-gray-600 border-gray-200' }, 'task.created': { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>, labelKey: 'admin.event.task_created', color: 'bg-indigo-50 text-indigo-700 border-indigo-100' }, } function ActivityLogRow({ entry, dateLocale, onFilterUser, t, }: { entry: any dateLocale: string onFilterUser: (id: string) => void t: (key: string, fallback?: string) => string }) { const meta = EVENT_META[entry.event] ?? { icon: <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>, labelKey: entry.event, color: 'bg-gray-50 text-gray-700 border-gray-200' } const details = parseLogDetails(entry.details) return ( <div className="bg-white rounded-xl border border-gray-200 px-4 py-3 flex items-start gap-3"> {/* Event badge */} <span className={`shrink-0 mt-0.5 px-2.5 py-1 rounded-lg border text-xs font-semibold flex items-center gap-1.5 ${meta.color}`}> {meta.icon} {t(meta.labelKey, meta.labelKey)} </span> {/* Main info */} <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap"> {entry.userName && ( <button onClick={() => entry.userId && onFilterUser(entry.userId)} className="text-sm font-medium text-gray-800 hover:text-green-600" > {entry.userName} </button> )} {entry.userEmail && ( <span className="text-xs text-gray-400">{entry.userEmail}</span> )} </div> {details && Object.keys(details).length > 0 && ( <div className="mt-1 flex flex-wrap gap-2"> {Object.entries(details).map(([k, v]) => ( <span key={k} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded"> {k}: <span className="font-medium">{String(v)}</span> </span> ))} </div> )} {entry.ipAddress && ( <p className="text-xs text-gray-400 mt-0.5">{entry.ipAddress}</p> )} </div> {/* Date */} <time className="shrink-0 text-xs text-gray-400 whitespace-nowrap"> {new Date(entry.createdAt).toLocaleString(dateLocale)} </time> </div> ) } // ── Month bar chart (tokens, all-time by month) ─────────────────────────────── interface MonthChartSeries { data: Array<{ month: string; count: number }> color: string fill: string label: string } const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] function AdminMonthChart({ series, dateLocale = 'en-US' }: { series: MonthChartSeries[]; dateLocale?: string }) { // Collect all unique months from all series, sorted const monthSet = new Set<string>() series.forEach((s) => s.data.forEach((r) => monthSet.add(r.month))) const months = Array.from(monthSet).sort() if (months.length === 0) return null // Build lookup maps const seriesVals = series.map((s) => { const map: Record<string, number> = {} s.data.forEach((r) => { map[r.month] = Number(r.count) }) return months.map((m) => map[m] ?? 0) }) const allVals = seriesVals.flat() const maxVal = Math.max(...allVals, 1) const W = 700 const H = 160 const padL = maxVal >= 1_000_000 ? 50 : maxVal >= 10_000 ? 44 : maxVal >= 1_000 ? 40 : 36 const padB = 24 const chartH = H - padB const totalBars = months.length * series.length const groupW = (W - padL - 8) / months.length const barW = Math.max(4, Math.min(20, (groupW / series.length) - 2)) const yTicks = [0, Math.round(maxVal / 3), Math.round((maxVal * 2) / 3), maxVal] function barX(mi: number, si: number): number { const groupStart = padL + mi * groupW + (groupW - series.length * (barW + 1)) / 2 return groupStart + si * (barW + 1) } function barHeight(v: number): number { return (v / maxVal) * chartH } return ( <div className="overflow-x-auto"> <svg viewBox={`0 0 ${W} ${H + 14}`} className="w-full" style={{ minWidth: Math.max(360, months.length * 40) }}> {/* Horizontal grid lines */} {yTicks.map((v, i) => { const y = chartH - (v / maxVal) * chartH return ( <g key={i}> <line x1={padL} y1={y} x2={W - 4} y2={y} stroke="#f3f4f6" strokeWidth={1} /> <text x={padL - 5} y={y + 3.5} textAnchor="end" fontSize={9} fill="#9ca3af">{fmtYLabel(v)}</text> </g> ) })} {/* Bars */} {months.map((m, mi) => ( series.map((s, si) => { const v = seriesVals[si][mi] const bh = barHeight(v) const x = barX(mi, si) return ( <rect key={`bar-${mi}-${si}`} x={x} y={chartH - bh} width={barW} height={bh} fill={s.color} opacity={0.8} rx={2} /> ) }) ))} {/* X-axis labels */} {months.map((m, mi) => { const [yr, mo] = m.slice(0, 7).split('-').map(Number) const d = new Date(Date.UTC(yr, mo - 1, 2)) const label = new Intl.DateTimeFormat(dateLocale, { month: 'short' }).format(d) + (mi === 0 || mo === 1 ? ` ${yr}` : '') const cx = padL + mi * groupW + groupW / 2 return ( <text key={`xl-${mi}`} x={cx} y={H + 12} textAnchor="middle" fontSize={9} fill="#9ca3af"> {label} </text> ) })} {/* Bottom axis line */} <line x1={padL} y1={chartH} x2={W - 4} y2={chartH} stroke="#e5e7eb" strokeWidth={1} /> </svg> </div> ) } // ── Area chart (pure SVG, no external deps) ────────────────────────────────── interface ChartSeries { data: Array<{ day: string; count: number }> color: string fill: string label: string } function getLast30Days(): string[] { const days: string[] = [] for (let i = 29; i >= 0; i--) { const d = new Date(Date.now() - i * 86400000) days.push(d.toISOString().slice(0, 10)) } return days } function smoothPath(pts: [number, number][]): string { if (pts.length < 2) return '' let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}` for (let i = 1; i < pts.length; i++) { const cpx = ((pts[i - 1][0] + pts[i][0]) / 2).toFixed(1) d += ` C ${cpx} ${pts[i - 1][1].toFixed(1)} ${cpx} ${pts[i][1].toFixed(1)} ${pts[i][0].toFixed(1)} ${pts[i][1].toFixed(1)}` } return d } function fmtYLabel(v: number): string { if (v >= 1_000_000) return `${+(v / 1_000_000).toFixed(1)}M` if (v >= 1_000) return `${Math.round(v / 1000)}K` return String(v) } function AdminAreaChart({ series, days: daysProp }: { series: ChartSeries[]; days?: string[] }) { const days = daysProp ?? getLast30Days() const seriesVals = series.map((s) => { const map: Record<string, number> = {} s.data.forEach((r) => { map[r.day] = Number(r.count) }) return days.map((d) => map[d] ?? 0) }) const allVals = seriesVals.flat() const maxVal = Math.max(...allVals, 1) const W = 700 const H = 160 const padL = maxVal >= 10_000 ? 44 : maxVal >= 1_000 ? 40 : 36 const padB = 22 const chartW = W - padL - 4 const chartH = H - padB const stepX = chartW / (days.length - 1) function pts(vals: number[]): [number, number][] { return vals.map((v, i) => [padL + i * stepX, chartH - (v / maxVal) * chartH]) } function areaPath(vals: number[]): string { const points = pts(vals) const line = smoothPath(points) if (!line) return '' const lastX = points[points.length - 1][0].toFixed(1) return `${line} L ${lastX} ${chartH.toFixed(1)} L ${padL.toFixed(1)} ${chartH.toFixed(1)} Z` } // Y-axis: 4 labels (0, 1/3, 2/3, max) const yTicks = [0, Math.round(maxVal / 3), Math.round((maxVal * 2) / 3), maxVal] // X-axis: every 7 days + last const xTicks = days .map((d, i) => ({ i, label: d.slice(8) })) .filter((_, i) => i % 7 === 0 || i === days.length - 1) return ( <div className="overflow-x-auto"> <svg viewBox={`0 0 ${W} ${H + 12}`} className="w-full" style={{ minWidth: 360 }}> {/* Horizontal grid lines */} {yTicks.map((v, i) => { const y = chartH - (v / maxVal) * chartH return ( <g key={i}> <line x1={padL} y1={y} x2={W - 4} y2={y} stroke="#f3f4f6" strokeWidth={1} /> <text x={padL - 5} y={y + 3.5} textAnchor="end" fontSize={9} fill="#9ca3af">{fmtYLabel(v)}</text> </g> ) })} {/* Vertical grid lines (at x-tick positions) */} {xTicks.map(({ i }) => { const x = padL + i * stepX return ( <line key={`vg-${i}`} x1={x} y1={0} x2={x} y2={chartH} stroke="#f9fafb" strokeWidth={1} /> ) })} {/* X-axis labels */} {xTicks.map(({ i, label }) => ( <text key={i} x={padL + i * stepX} y={H + 10} textAnchor="middle" fontSize={9} fill="#9ca3af"> {label} </text> ))} {/* Area fills (rendered first so lines appear on top) */} {series.map((s, si) => ( <path key={`fill-${si}`} d={areaPath(seriesVals[si])} fill={s.fill} /> ))} {/* Lines */} {series.map((s, si) => ( <path key={`line-${si}`} d={smoothPath(pts(seriesVals[si]))} fill="none" stroke={s.color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> ))} {/* Dot at the last data point per series */} {series.map((s, si) => { const points = pts(seriesVals[si]) const last = points[points.length - 1] return ( <g key={`dot-${si}`}> <circle cx={last[0]} cy={last[1]} r={4} fill="white" stroke={s.color} strokeWidth={2} /> </g> ) })} {/* Bottom axis line */} <line x1={padL} y1={chartH} x2={W - 4} y2={chartH} stroke="#e5e7eb" strokeWidth={1} /> </svg> </div> ) }
Save
cmd:
run