import React, { useState, useEffect, useMemo } from 'react'; import { Briefcase, Megaphone, Wrench, Banknote, Scale, TrendingUp, TrendingDown, UsersRound, Calendar, CalendarDays, Pencil, X, Target, Share2, Send, MessageSquare, CheckCircle2, User as UserIcon, Clock, Plus, UserPlus, Eye, Send as SendIcon, Newspaper, ArrowRight, LayoutGrid, Inbox, AlertTriangle, Camera, Package, Gauge, FileText, PartyPopper, ClipboardList } from 'lucide-react'; import { StrategicGoal, Department, UserRole, DomaApplication, User, BuildingTask, CompanyNews } from '../types'; import { MOCK_STRATEGIC_GOALS, CURRENT_USER_MOCK } from '../constants'; import { backendApi } from '../services/apiClient'; import { DashboardTaskModal } from './DashboardTaskModal'; const DASHBOARD_TASKS_KEY = 'mkd_dashboard_tasks'; /** Роль текущего пользователя в задаче */ export type TaskMyRole = 'executor' | 'co_executor' | 'assigner' | 'observer'; /** Мок задач для сводки (если нет сохранённых) */ function getMockDashboardTasks(currentUserId: string): BuildingTask[] { const now = new Date().toISOString(); const d = (days: number) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10); return [ { id: 'dt-1', title: 'Проверка стояков отопления', deadline: d(5), status: 'in_progress', priority: 'high', createdAt: now, updatedAt: now, buildingId: 'b-1', buildingAddress: 'ул. Мира, 10', assignedTo: currentUserId, assignedToName: 'Я', createdBy: 'u2', createdByName: 'Смирнов А.В.', requirePhotoReport: false, comments: [{ id: 'c1', authorName: 'Смирнов А.В.', text: 'Датчики заказаны, ждём поставку.', createdAt: now }] }, { id: 'dt-2', title: 'Подготовка отчёта по дому', deadline: d(3), status: 'new', priority: 'medium', createdAt: now, updatedAt: now, buildingId: 'b-1', buildingAddress: 'ул. Мира, 10', assignedTo: 'u2', assignedToName: 'Смирнов А.В.', coAssignees: [currentUserId], coAssigneesNames: ['Я'], createdBy: 'u1', createdByName: 'Петров А.В.', requirePhotoReport: false }, { id: 'dt-3', title: 'Согласование сметы на ремонт', deadline: d(7), status: 'new', priority: 'urgent', createdAt: now, updatedAt: now, buildingId: 'b-2', buildingAddress: 'пр. Ленина, 5', assignedTo: 'u3', assignedToName: 'Кузнецов В.В.', createdBy: currentUserId, createdByName: 'Я', requirePhotoReport: false }, { id: 'dt-4', title: 'Контроль качества уборки', deadline: d(1), status: 'done', priority: 'medium', createdAt: now, updatedAt: now, buildingId: 'b-1', buildingAddress: 'ул. Мира, 10', assignedTo: 'u2', assignedToName: 'Смирнов А.В.', observers: [currentUserId], observersNames: ['Я'], createdBy: 'u2', createdByName: 'Смирнов А.В.', requirePhotoReport: false }, ]; } function loadDashboardTasks(currentUserId: string): BuildingTask[] { try { const raw = localStorage.getItem(DASHBOARD_TASKS_KEY); if (!raw) return getMockDashboardTasks(currentUserId); const parsed = JSON.parse(raw); if (!Array.isArray(parsed) || parsed.length === 0) return getMockDashboardTasks(currentUserId); return parsed as BuildingTask[]; } catch { return getMockDashboardTasks(currentUserId); } } function saveDashboardTasks(tasks: BuildingTask[]): void { try { localStorage.setItem(DASHBOARD_TASKS_KEY, JSON.stringify(tasks)); } catch (e) { console.error('[Dashboard] failed to persist tasks', e); } } function getTaskMyRole(task: BuildingTask, currentUserId: string): TaskMyRole | null { if (task.assignedTo === currentUserId) return 'executor'; if (task.coAssignees?.includes(currentUserId)) return 'co_executor'; if (task.createdBy === currentUserId) return 'assigner'; if (task.observers?.includes(currentUserId)) return 'observer'; return null; } // ==================================================================================== // Strategic Goals Components // ==================================================================================== const DEPT_CONFIG: Record = { production: { icon: Wrench, color: 'text-amber-500' }, pr: { icon: Megaphone, color: 'text-indigo-500' }, finance: { icon: Banknote, color: 'text-emerald-500' }, development: { icon: Briefcase, color: 'text-sky-500' }, legal: { icon: Scale, color: 'text-slate-600' }, hr: { icon: UsersRound, color: 'text-violet-500' }, }; const GoalCard: React.FC<{ goal: StrategicGoal }> = ({ goal }) => { const { icon: Icon, color } = DEPT_CONFIG[goal.department]; const lowerIsBetter = ['production', 'hr'].includes(goal.department); let progress = 0; if (lowerIsBetter) { const startValue = goal.targetValue * 2; progress = Math.max(0, (startValue - goal.currentValue) / (startValue - goal.targetValue)) * 100; } else { progress = (goal.currentValue / goal.targetValue) * 100; } progress = Math.min(100, Math.max(0, progress)); const formatValue = (val: number, unit: string) => { if (unit === '₽') return `${(val / 1000000).toFixed(1)}M ₽`; return `${val}${unit}`; }; return (

{goal.title}

{goal.description}

Прогресс {formatValue(goal.currentValue, goal.unit)} / {formatValue(goal.targetValue, goal.unit)}
Срок: {goal.deadline}
); }; // ==================================================================================== // Operational Panel Components // ==================================================================================== /** Блок дашборда, по которому можно открыть продвинутую статистику и перейти в модуль */ const DASHBOARD_BLOCK_TO_MODULE: Record = { production: { tabId: 'objects', label: 'Участки' }, pr: { tabId: 'pr', label: 'PR и NPS' }, finance: { tabId: 'finance', label: 'Финансы' }, development: { tabId: 'development', label: 'Развитие' }, legal: { tabId: 'legal', label: 'Юр. отдел' }, hr: { tabId: 'hr', label: 'Кадры' }, news: { tabId: 'office', label: 'Офис' }, }; /** Модалка продвинутой статистики по отделу с кнопкой «Перейти в модуль» */ const DepartmentStatsModal: React.FC<{ title: string; blockId: string; onClose: () => void; onGoToModule?: (tabId: string) => void; children: React.ReactNode; }> = ({ title, blockId, onClose, onGoToModule, children }) => { const moduleInfo = DASHBOARD_BLOCK_TO_MODULE[blockId]; return (
e.stopPropagation()} >

{title} — продвинутая статистика

{moduleInfo && onGoToModule && ( )}
{children}
); }; const DashboardCard: React.FC<{ title: string; icon: React.ElementType; iconColor: string; children: React.ReactNode; /** При указании карточка кликабельна — открывается продвинутая статистика по отделу */ onClick?: () => void; }> = ({ title, icon: Icon, iconColor, children, onClick }) => { const Wrapper = onClick ? 'button' : 'div'; const wrapperProps = onClick ? { type: 'button' as const, onClick, className: 'w-full text-left bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex flex-col gap-4 hover:shadow-md transition-shadow cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500/50' } : { className: 'bg-white p-5 rounded-xl border border-slate-200 shadow-sm flex flex-col gap-4 hover:shadow-md transition-shadow' }; return (

{title}

{children}
); }; const Stat: React.FC<{ value: string; label: string; trend?: 'up' | 'down'; }> = ({ value, label, trend }) => { const trendColor = trend === 'up' ? 'text-emerald-500' : 'text-red-500'; const TrendIcon = trend === 'up' ? TrendingUp : TrendingDown; return (

{value}

{label}

{trend && }
); }; const ProgressBar: React.FC<{ value: number; label: string }> = ({ value, label }) => (

{label}

{value}%

); const GaugeChart: React.FC<{ value: number; label: string }> = ({ value, label }) => { const color = value > 75 ? 'bg-emerald-500' : value > 50 ? 'bg-blue-500' : value > 25 ? 'bg-amber-500' : 'bg-red-500'; return (

{label}

{value}%

); }; const getPriorityColor = (p: BuildingTask['priority']) => { switch (p) { case 'urgent': return 'bg-red-50 text-red-600'; case 'high': return 'bg-amber-50 text-amber-600'; case 'medium': return 'bg-blue-50 text-blue-600'; default: return 'bg-slate-100 text-slate-600'; } }; const getPriorityLabel = (p: BuildingTask['priority']) => { switch (p) { case 'urgent': return 'Срочно'; case 'high': return 'Высокий'; case 'medium': return 'Средний'; case 'low': return 'Низкий'; default: return p; } }; const formatTaskDate = (dateStr: string) => { try { const d = new Date(dateStr); return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); } catch { return '—'; } }; const ROLE_LABELS: Record = { executor: 'Исполнитель', co_executor: 'Соисполнитель', assigner: 'Постановщик', observer: 'Наблюдатель' }; const DashboardTaskCard: React.FC<{ task: BuildingTask; myRole: TaskMyRole; onToggleDone: () => void; onOpen: () => void; }> = ({ task, myRole, onToggleDone, onOpen }) => (

{task.title}

{formatTaskDate(task.deadline)} {getPriorityLabel(task.priority)} {task.buildingAddress && {task.buildingAddress}} {ROLE_LABELS[myRole]} {(task.comments?.length ?? 0) > 0 && ( `${c.authorName}: ${c.text}`).join('\n')}> {task.comments!.length} коммент. )}
); // ==================================================================================== // Main Dashboard Component // ==================================================================================== export const SummaryDashboard: React.FC<{ currentUserRole: UserRole; currentUser?: User; /** Какие блоки сводки показывать: 'all' или список id (goals, production, pr, finance, development, legal, hr, tasks, news) */ allowedDashboardBlocks?: string[] | 'all'; initialOpenTaskModal?: boolean; onQuickActionHandled?: () => void; /** Открыть модалку просмотра новости по id (из уведомления) */ openNewsId?: number | null; onCloseNews?: () => void; /** Переход в модуль (HR, PR, Финансы и т.д.) из продвинутой статистики */ onNavigateToModule?: (tabId: string) => void; }> = ({ currentUserRole, currentUser: currentUserProp, allowedDashboardBlocks = 'all', initialOpenTaskModal, onQuickActionHandled, openNewsId, onCloseNews, onNavigateToModule }) => { const currentUser = currentUserProp ?? CURRENT_USER_MOCK; const [isGoalsModalOpen, setIsGoalsModalOpen] = useState(false); /** Открытая модалка продвинутой статистики по отделу (production, pr, finance, development, legal, hr, news) */ const [statsModalDepartment, setStatsModalDepartment] = useState(null); const [tasksTab, setTasksTab] = useState('executor'); const [dashboardTasks, setDashboardTasks] = useState(() => loadDashboardTasks(currentUser.id)); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [editingTask, setEditingTask] = useState(null); const [applications, setApplications] = useState([]); const [appsLoading, setAppsLoading] = useState(false); const [appsError, setAppsError] = useState(null); const [productionMetrics, setProductionMetrics] = useState<{ buildingCondition: { value: number; label: string; condition: string; totalBuildings: number; buildingsWithWear: number }; workSchedule: { onTime: number; late: number; overdue: number; onTimeRate: number; lateRate: number }; slaRating: number; } | null>(null); const [metricsLoading, setMetricsLoading] = useState(false); /** Полный дашборд производства для модалки продвинутой статистики */ const [productionDashboard, setProductionDashboard] = useState>['data'] | null>(null); const [productionDashboardLoading, setProductionDashboardLoading] = useState(false); const [companyNews, setCompanyNews] = useState([]); const [companyNewsLoading, setCompanyNewsLoading] = useState(false); const [isSuggestNewsModalOpen, setIsSuggestNewsModalOpen] = useState(false); const [viewNewsItem, setViewNewsItem] = useState(null); const [viewNewsLoading, setViewNewsLoading] = useState(false); const [smmSummary, setSmmSummary] = useState<{ total: number; byChannel: Array<{ id: number; name: string; type: string; subscribersCount: number }> } | null>(null); const [smmSummaryLoading, setSmmSummaryLoading] = useState(false); /** Дашборд PR для карточки и модалки продвинутой статистики */ const [prDashboard, setPrDashboard] = useState>['data'] | null>(null); const [prDashboardLoading, setPrDashboardLoading] = useState(false); /** Дашборд офиса для карточки и модалки продвинутой статистики */ const [officeDashboard, setOfficeDashboard] = useState>['data'] | null>(null); const [officeDashboardLoading, setOfficeDashboardLoading] = useState(false); const canShowBlock = (blockId: string) => allowedDashboardBlocks === 'all' || (Array.isArray(allowedDashboardBlocks) && allowedDashboardBlocks.includes(blockId)); const canSee = (dept: Department) => { if (currentUserRole === 'DIRECTOR') return true; if (currentUserRole === 'ENGINEER') return dept === 'production' || dept === 'development'; if (currentUserRole === 'MASTER') return dept === 'production'; if (currentUserRole === 'LAWYER') return dept === 'legal'; if (currentUserRole === 'FINANCIER') return dept === 'finance'; if (currentUserRole === 'HR_MANAGER') return dept === 'hr'; if (currentUserRole === 'PR_MANAGER') return dept === 'pr'; return false; }; /** Показывать карточку дашборда: при тонкой настройке прав — только canShowBlock, при доступе «всё» — ещё и canSee по роли */ const showDashboardCard = (blockId: string, dept?: Department) => canShowBlock(blockId) && (allowedDashboardBlocks === 'all' ? (dept == null || canSee(dept)) : true); const filteredGoals = allowedDashboardBlocks === 'all' ? MOCK_STRATEGIC_GOALS.filter((g) => canSee(g.department)) : canShowBlock('goals') ? MOCK_STRATEGIC_GOALS : []; // Быстрое действие «Добавить задачу» — открыть модалку при переходе со сводки useEffect(() => { if (initialOpenTaskModal) { setIsTaskModalOpen(true); onQuickActionHandled?.(); } }, [initialOpenTaskModal, onQuickActionHandled]); // Сохраняем задачи сводки в localStorage при любом изменении useEffect(() => { saveDashboardTasks(dashboardTasks); }, [dashboardTasks]); // Загружаем метрики производства (для карточки на сводке) useEffect(() => { let cancelled = false; const loadMetrics = async () => { setMetricsLoading(true); try { const response = await backendApi.getProductionMetrics(); if (!cancelled && response.success) { setProductionMetrics(response.data); } } catch (err: any) { console.error('[SummaryDashboard] Ошибка загрузки метрик производства:', err); } finally { if (!cancelled) { setMetricsLoading(false); } } }; loadMetrics(); return () => { cancelled = true; }; }, []); // Загружаем полный дашборд производства при открытии модалки продвинутой статистики useEffect(() => { if (statsModalDepartment !== 'production') { setProductionDashboard(null); return; } let cancelled = false; setProductionDashboardLoading(true); setProductionDashboard(null); backendApi .getProductionDashboard() .then((res) => { if (!cancelled && res.success && res.data) setProductionDashboard(res.data); }) .catch((err) => { if (!cancelled) console.error('[SummaryDashboard] Ошибка загрузки дашборда производства:', err); }) .finally(() => { if (!cancelled) setProductionDashboardLoading(false); }); return () => { cancelled = true; }; }, [statsModalDepartment]); // Загрузка дашборда PR (NPS, CSAT, SMM и др.) для карточки и модалки useEffect(() => { if (!showDashboardCard('pr', 'pr')) return; let cancelled = false; setPrDashboardLoading(true); setPrDashboard(null); backendApi .getPRDashboard() .then((res) => { if (!cancelled && res.success && res.data) setPrDashboard(res.data); }) .catch((err) => { if (!cancelled) console.error('[SummaryDashboard] Ошибка загрузки дашборда PR:', err); }) .finally(() => { if (!cancelled) setPrDashboardLoading(false); }); return () => { cancelled = true; }; }, [allowedDashboardBlocks, currentUserRole]); // Загрузка сводки SMM (подписчики по каналам) для блока PR — fallback, если дашборд не загружен useEffect(() => { if (!showDashboardCard('pr', 'pr')) return; let cancelled = false; setSmmSummaryLoading(true); backendApi .getSMMChannelsSummary() .then((data) => { if (!cancelled && data) setSmmSummary({ total: data.total ?? 0, byChannel: data.byChannel ?? [] }); }) .catch(() => { if (!cancelled) setSmmSummary(null); }) .finally(() => { if (!cancelled) setSmmSummaryLoading(false); }); return () => { cancelled = true; }; }, [allowedDashboardBlocks, currentUserRole]); // Загрузка дашборда офиса для карточки и модалки (блок office или news) useEffect(() => { if (!showDashboardCard('office') && !showDashboardCard('news')) return; let cancelled = false; setOfficeDashboardLoading(true); setOfficeDashboard(null); backendApi .getOfficeDashboard() .then((res) => { if (!cancelled && res.success && res.data) setOfficeDashboard(res.data); }) .catch((err) => { if (!cancelled) console.error('[SummaryDashboard] Ошибка загрузки дашборда офиса:', err); }) .finally(() => { if (!cancelled) setOfficeDashboardLoading(false); }); return () => { cancelled = true; }; }, [allowedDashboardBlocks]); // Загрузка опубликованных новостей компании (блок «Новости компании») useEffect(() => { if (!canShowBlock('news')) return; let cancelled = false; const load = async () => { setCompanyNewsLoading(true); try { const list = await backendApi.getCompanyNews({ status: 'published', limit: 5 }); if (!cancelled && Array.isArray(list)) setCompanyNews(list); } catch (err) { if (!cancelled) setCompanyNews([]); } finally { if (!cancelled) setCompanyNewsLoading(false); } }; load(); return () => { cancelled = true; }; }, [allowedDashboardBlocks]); // Открыть модалку просмотра новости по openNewsId (из уведомления) useEffect(() => { if (openNewsId == null) { setViewNewsItem(null); return; } let cancelled = false; setViewNewsLoading(true); setViewNewsItem(null); backendApi .getCompanyNewsById(openNewsId) .then((item) => { if (!cancelled) { setViewNewsItem(item); } }) .catch(() => { if (!cancelled) setViewNewsItem(null); }) .finally(() => { if (!cancelled) setViewNewsLoading(false); }); return () => { cancelled = true; }; }, [openNewsId]); const appsStats = useMemo(() => { const stats = { total: applications.length, new: 0, inProgress: 0, deferred: 0, done: 0, canceled: 0, overdue: 0, overduePercent: 0, completionRate: 0, active: 0, }; if (applications.length === 0) { return stats; } stats.new = applications.filter(a => a.status === 'new').length; stats.inProgress = applications.filter(a => a.status === 'in_progress').length; stats.deferred = applications.filter(a => a.status === 'deferred').length; stats.done = applications.filter(a => a.status === 'done').length; stats.canceled = applications.filter(a => a.status === 'canceled').length; // Используем поле isOverdue из БД, если есть, иначе вычисляем stats.overdue = applications.filter(a => a.isOverdue !== undefined ? a.isOverdue : (a.status !== 'done' && a.status !== 'canceled' && new Date(a.deadlineAt) < new Date()) ).length; stats.active = stats.new + stats.inProgress + stats.deferred; if (stats.total > 0) { stats.overduePercent = Math.round((stats.overdue / stats.total) * 100); } if (stats.done + stats.active > 0) { stats.completionRate = Math.round((stats.done / (stats.done + stats.active)) * 100); } return stats; }, [applications]); return (

Сводка

Ключевые показатели для вашей роли.

{/* 'Set Goals' Button - prominent placement */} {canShowBlock('goals') && ( )}
{/* KPI Dashboards Section - Main focus */}

Оперативные Панели

{showDashboardCard('production', 'production') && ( setStatsModalDepartment('production')}> {metricsLoading ? (
Загрузка...
) : productionMetrics ? ( <> {/* Состояние домов */}
Состояние домов {productionMetrics.buildingCondition.label}
Средний износ: {productionMetrics.buildingCondition.value > 0 ? `${productionMetrics.buildingCondition.value.toFixed(1)}%` : 'Н/Д'} ({productionMetrics.buildingCondition.buildingsWithWear} из {productionMetrics.buildingCondition.totalBuildings} домов)
{/* График работ */}
График работ
В срок: {productionMetrics.workSchedule.onTimeRate}%
С опозданием: {productionMetrics.workSchedule.lateRate}%
Просрочено: {productionMetrics.workSchedule.overdue}
{/* Рейтинг SLA */}
{productionMetrics.workSchedule.onTime} из {productionMetrics.workSchedule.onTime + productionMetrics.workSchedule.late} выполнено в срок
) : (
Нет данных
)}
)} {showDashboardCard('pr', 'pr') && ( setStatsModalDepartment('pr')}> {prDashboardLoading && !prDashboard ? (
Загрузка...
) : ( <>
SMM — подписчики
{(prDashboardLoading && !prDashboard) ? (

Загрузка...

) : (() => { const smm = prDashboard?.smm ?? smmSummary; return smm && (smm.byChannel?.length > 0 || (smm.total ?? 0) > 0) ? ( <>

{(smm.total ?? 0).toLocaleString('ru-RU')}

всего
{smm.byChannel && smm.byChannel.length > 0 ? (
{smm.byChannel.map((c: { id: number; name: string; type: string; subscribersCount: number }) => ( {c.type === 'tg' && } {c.type === 'vk' && } {c.type === 'wa' && } {c.type === 'tg' ? 'TG' : c.type === 'vk' ? 'VK' : c.type === 'wa' ? 'WA' : c.name} {(c.subscribersCount ?? 0).toLocaleString('ru-RU')} ))}
) : (

Добавьте каналы в модуле PR → SMM

)} ) : (

Добавьте каналы в модуле PR → SMM

); })()}
)}
)} {showDashboardCard('finance', 'finance') && ( setStatsModalDepartment('finance')}>

Тренд прибыли

)} {showDashboardCard('development', 'development') && ( setStatsModalDepartment('development')}> )} {showDashboardCard('legal', 'legal') && ( setStatsModalDepartment('legal')}> )} {showDashboardCard('hr', 'hr') && ( setStatsModalDepartment('hr')}> )} {(showDashboardCard('office') || showDashboardCard('news')) && (allowedDashboardBlocks === 'all' ? currentUserRole !== 'MASTER' : true) && ( setStatsModalDepartment('news')}> {officeDashboardLoading && !officeDashboard ? (
Загрузка...
) : officeDashboard ? ( <>
Документы {officeDashboard.documents.total}
Заявки на закупку {officeDashboard.supplyRequests.total}
) : (
Нет данных
)}
)}
{/* Новости компании — чтение и предложение новости */} {canShowBlock('news') && (

Новости компании

{companyNewsLoading ? (
Загрузка...
) : companyNews.length === 0 ? (
Нет опубликованных новостей
) : (
    {companyNews.map((n) => (
  • {n.title}

    {n.publishedAt ? new Date(n.publishedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' }) : new Date(n.createdAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })} {n.createdByName && ` · ${n.createdByName}`}

    {n.body &&

    {n.body}

    }
  • ))}
)}
)} {/* Мои задачи — список по ролям (исполнитель / соисполнитель / постановщик / наблюдатель) */} {canShowBlock('tasks') && (

Мои задачи

{([ { id: 'executor' as TaskMyRole, label: 'Я исполнитель', icon: UserIcon }, { id: 'co_executor' as TaskMyRole, label: 'Я соисполнитель', icon: UserPlus }, { id: 'assigner' as TaskMyRole, label: 'Я постановщик', icon: SendIcon }, { id: 'observer' as TaskMyRole, label: 'Я наблюдатель', icon: Eye }, ]).map(({ id, label, icon: Icon }) => ( ))}
{dashboardTasks .filter(t => getTaskMyRole(t, currentUser.id) === tasksTab) .length === 0 ? (

Нет задач в этой роли

) : (
{dashboardTasks .filter(t => getTaskMyRole(t, currentUser.id) === tasksTab) .map(task => ( { const nextStatus = task.status === 'done' ? 'new' : 'done'; setDashboardTasks(prev => prev.map(t => (t.id === task.id ? { ...t, status: nextStatus as BuildingTask['status'], updatedAt: new Date().toISOString() } : t)) ); }} onOpen={() => { setEditingTask(task); setIsTaskModalOpen(true); }} /> ))}
)}
)} {/* Модалка задачи (без фото отчёта) */} {isTaskModalOpen && ( { setIsTaskModalOpen(false); setEditingTask(null); }} currentUser={currentUser} task={editingTask} onSave={(task) => { setDashboardTasks(prev => { const idx = prev.findIndex(t => t.id === task.id); if (idx >= 0) return prev.map((t, i) => (i === idx ? task : t)); return [...prev, task]; }); setIsTaskModalOpen(false); setEditingTask(null); }} /> )} {/* Модалка продвинутой статистики по отделу */} {statsModalDepartment && ( setStatsModalDepartment(null)} onGoToModule={onNavigateToModule ?? undefined} > {statsModalDepartment === 'production' && (
{productionDashboardLoading ? (

Загрузка дашборда...

) : !productionDashboard ? (

Нет данных

) : ( <> {/* 4.1 Состояние домов, SLA, график работ */}

Состояние и график

Состояние домов

{productionDashboard.buildingCondition.label}

Средний износ {productionDashboard.buildingCondition.value > 0 ? `${productionDashboard.buildingCondition.value.toFixed(1)}%` : 'Н/Д'} · {productionDashboard.buildingCondition.buildingsWithWear} из {productionDashboard.buildingCondition.totalBuildings} домов

Рейтинг SLA

{productionDashboard.slaRating}%

График работ

В срок: {productionDashboard.workSchedule.onTimeRate}% · С опозданием: {productionDashboard.workSchedule.lateRate}% · Просрочено: {productionDashboard.workSchedule.overdue}

{/* 4.2 По участкам */} {productionDashboard.districts && productionDashboard.districts.length > 0 && (

По участкам

{productionDashboard.districts.map((d) => ( ))}
Участок Руководитель Домов Заявок Выполнено Просрочено Успеваемость %
{d.districtName} {d.managerName} {d.buildingCount} {d.totalApplications} {d.totalCompleted} {d.totalOverdue} {Math.round(d.completionRate)}%
)} {/* 4.3 Заявки — временные срезы */}

Заявки — временные срезы

Сегодня

Создано: {productionDashboard.applicationsTimeSlices.today.created}

Закрыто: {productionDashboard.applicationsTimeSlices.today.closed} · Просрочено: {productionDashboard.applicationsTimeSlices.today.overdueNow}

Неделя

Создано: {productionDashboard.applicationsTimeSlices.week.created}

Закрыто: {productionDashboard.applicationsTimeSlices.week.closed} · Просрочено: {productionDashboard.applicationsTimeSlices.week.overdueNow}

Месяц

Создано: {productionDashboard.applicationsTimeSlices.month.created}

Закрыто: {productionDashboard.applicationsTimeSlices.month.closed} · Просрочено: {productionDashboard.applicationsTimeSlices.month.overdueNow}

{/* 4.4 Заявки — общая сводка */}

Заявки — сводка

Новые: {productionDashboard.applicationsSummary.new} В работе: {productionDashboard.applicationsSummary.inProgress} Отложено: {productionDashboard.applicationsSummary.deferred} Выполнено: {productionDashboard.applicationsSummary.done} Просрочено: {productionDashboard.applicationsSummary.overdue} Индекс SLA: {productionDashboard.applicationsSummary.completionRate}%
{/* 4.5 Загрузка мастеров */} {productionDashboard.topPerformers && productionDashboard.topPerformers.length > 0 && (

Загрузка мастеров

    {productionDashboard.topPerformers.map((p, i) => (
  • {p.name} 5 ? 'text-amber-600' : 'text-slate-700'}`}>{p.activeCount} активных
  • ))}
)} {/* 4.6 План работ */}

План работ (текущий месяц)

Всего работ

{productionDashboard.planWorks.total}

Выполнено

{productionDashboard.planWorks.completed}

Перенесено

{productionDashboard.planWorks.carriedOver}

Смета (мес)

{productionDashboard.planWorks.estimatedCostMonth > 0 ? `${(productionDashboard.planWorks.estimatedCostMonth / 1000).toFixed(0)}k ₽` : '—'}

{productionDashboard.planWorks.byDistrict && productionDashboard.planWorks.byDistrict.length > 0 && (

По участкам: {productionDashboard.planWorks.byDistrict.map((d) => `${d.districtId}: ${d.total} работ, выполнено ${d.completed}`).join('; ')}

)}
{/* 4.7 Обходы (осмотры) */}

Обходы (осмотры)

За месяц

{productionDashboard.inspections.countMonth}

За квартал

{productionDashboard.inspections.countQuarter}

Последний осмотр

{productionDashboard.inspections.lastDate ? new Date(productionDashboard.inspections.lastDate).toLocaleDateString('ru-RU') : '—'}

{/* 4.8 Списания */}

Списания

Сегодня

{productionDashboard.writeOffs.countToday}

За неделю

{productionDashboard.writeOffs.countWeek}

За месяц

{productionDashboard.writeOffs.countMonth}

Сумма (мес)

{productionDashboard.writeOffs.sumMonth > 0 ? `${(productionDashboard.writeOffs.sumMonth / 1000).toFixed(0)}k ₽` : '—'}

{productionDashboard.districtInventorySummary && productionDashboard.districtInventorySummary.length > 0 && (

Остатки складов участков: {productionDashboard.districtInventorySummary.map((d) => `${d.districtName}: ${d.totalQuantity} ед.`).join('; ')}

)}
{/* 4.9 Обходы счетчиков */}

Обходы счетчиков

За месяц

{productionDashboard.meterRounds.countMonth}

Последний обход

{productionDashboard.meterRounds.lastDate ? new Date(productionDashboard.meterRounds.lastDate).toLocaleDateString('ru-RU') : '—'}

)}
)} {statsModalDepartment === 'pr' && (
{prDashboardLoading ? (

Загрузка дашборда...

) : !prDashboard ? (

Нет данных

) : ( <> {/* 1. NPS по компании */}

NPS по компании

Индекс NPS

{prDashboard.npsCompany}

Ответов за месяц

{prDashboard.npsResponsesCount}

Средний балл

{prDashboard.npsAvgScore > 0 ? prDashboard.npsAvgScore.toFixed(1) : '—'}

Промоутеры: {prDashboard.npsPromoters} Нейтралы: {prDashboard.npsPassives} Критики: {prDashboard.npsDetractors}
{/* 2. Удовлетворённость (CSAT) */}

Удовлетворённость (CSAT)

CSAT, %

{prDashboard.csatPercent}%

Средний рейтинг отзывов

{prDashboard.reviewsAvgRating > 0 ? prDashboard.reviewsAvgRating.toFixed(1) : '—'}

Всего отзывов

{prDashboard.reviewsTotal}

{/* 3. SMM — подписчики */}

SMM — подписчики

Всего

{prDashboard.smm.total.toLocaleString('ru-RU')}

{prDashboard.smm.byChannel && prDashboard.smm.byChannel.length > 0 && (
{prDashboard.smm.byChannel.map((c) => ( {c.type === 'tg' ? 'TG' : c.type === 'vk' ? 'VK' : c.type === 'wa' ? 'WA' : c.name}: {c.subscribersCount.toLocaleString('ru-RU')} ))}
)}
{(!prDashboard.smm.byChannel || prDashboard.smm.byChannel.length === 0) && prDashboard.smm.total === 0 && (

Добавьте каналы в модуле PR → SMM

)}
{/* 4. Отзывы */}

Отзывы

Всего: {prDashboard.reviewsStats.total} Новые: {prDashboard.reviewsStats.new_count} Обработано: {prDashboard.reviewsStats.processed_count} Положительные: {prDashboard.reviewsStats.positive_count} Негативные: {prDashboard.reviewsStats.negative_count} Яндекс: {prDashboard.reviewsStats.yandex_count} 2GIS: {prDashboard.reviewsStats.gis2_count}
{/* 5. Инциденты */}

Инциденты

Всего: {prDashboard.incidentsSummary.total} Открытые: {prDashboard.incidentsSummary.open} В работе: {prDashboard.incidentsSummary.in_progress} Закрыты: {prDashboard.incidentsSummary.resolved}
{/* 6. Мероприятия */}

Мероприятия

За месяц

{prDashboard.events.countMonth}

Предстоящие

{prDashboard.events.upcoming}

Прошедшие

{prDashboard.events.past}

{/* 7. Отчёты жителям */}

Отчёты жителям

Всего отчётов

{prDashboard.residentReports.total}

Опубликовано за месяц

{prDashboard.residentReports.publishedThisMonth}

{/* 8. Фото отчёты */}

Фото отчёты

Всего

{prDashboard.workPhotos.total}

За месяц

{prDashboard.workPhotos.thisMonth}

{/* 9. NPS опросы */}

NPS опросы

Всего: {prDashboard.npsSurveys.total} Активных: {prDashboard.npsSurveys.active}
{prDashboard.npsSurveys.list && prDashboard.npsSurveys.list.length > 0 && (
    {prDashboard.npsSurveys.list.map((s) => (
  • {s.title} {s.responsesCount} ответов · {s.status}
  • ))}
)}
{/* 10. Отложенные посты */}

Отложенные посты

Черновик: {prDashboard.scheduledPosts.draft} На модерации: {prDashboard.scheduledPosts.pending_approval} Одобрено: {prDashboard.scheduledPosts.approved} Опубликовано: {prDashboard.scheduledPosts.published} Отклонено: {prDashboard.scheduledPosts.rejected} На редактировании: {prDashboard.scheduledPosts.edited}
{/* 11. Негатив в работе */}

Негатив в работе

Открытые инциденты

{prDashboard.negative.openIncidents}

Негативных отзывов

{prDashboard.negative.negativeReviewsCount}

)}
)} {statsModalDepartment === 'finance' && (

Чистая прибыль (мес)

1.2M ₽

тренд вверх

Общая дебиторка

8.5M ₽

В модуле «Финансы» доступны кассовый план, реестр счетов, платёжный календарь и отчёты по домам.

)} {statsModalDepartment === 'development' && (

Новых дома (квартал)

+3

Домов в воронке

12

Конверсия

25%

В модуле «Развитие» — воронка продаж, ОСС, маркетинг и технический аудит.

)} {statsModalDepartment === 'legal' && (

Взыскано за квартал

2.1M ₽

тренд вверх

Активных судебных дел

42

Процент выигранных дел

85%

В модуле «Юр. отдел» — досудебная работа, договоры, судебные дела и проверки контрагентов.

)} {statsModalDepartment === 'hr' && (

Всего сотрудников

24

Кандидатов в воронке

2

Текучесть кадров (год)

12%

В модуле «Кадры» — штат, вакансии, кандидаты, отпуска и календарь событий.

)} {statsModalDepartment === 'news' && (
{officeDashboardLoading ? (

Загрузка дашборда...

) : !officeDashboard ? (

Нет данных

) : ( <> {/* 1. Новости компании */}

Новости компании

Всего: {officeDashboard.companyNews.total} Опубликовано: {officeDashboard.companyNews.published} Черновики: {officeDashboard.companyNews.draft} На модерации: {officeDashboard.companyNews.pending} За месяц: {officeDashboard.companyNews.publishedThisMonth}
{/* 2. Оборудование */}

Оборудование

Всего

{officeDashboard.equipment.total}

Хорошее

{officeDashboard.equipment.byCondition.good}

Удовлетв.

{officeDashboard.equipment.byCondition.fair}

Плохое

{officeDashboard.equipment.byCondition.poor}

Гарантия истекает (30 дн.)

{officeDashboard.equipment.warrantyExpiringSoon}

{/* 3. Заявки на ремонт */}

Заявки на ремонт

Всего: {officeDashboard.repairRequests.total} Открытые: {officeDashboard.repairRequests.open} В работе: {officeDashboard.repairRequests.in_progress} Выполнено: {officeDashboard.repairRequests.completed}
{/* 4. Заявки на закупку */}

Заявки на закупку

Всего: {officeDashboard.supplyRequests.total} {Object.entries(officeDashboard.supplyRequests.byStatus || {}).map(([status, cnt]) => ( {status}: {cnt} ))}
{/* 5. Инвентарь */}

Инвентарь

Позиций всего

{officeDashboard.inventory.totalItems}

Низкий остаток

{officeDashboard.inventory.lowStockCount}

{/* 6. Документооборот */}

Документооборот

Всего: {officeDashboard.documents.total} Входящие: {officeDashboard.documents.incoming} Исходящие: {officeDashboard.documents.outgoing}
{Object.keys(officeDashboard.documents.byStatus || {}).length > 0 && (
{Object.entries(officeDashboard.documents.byStatus).map(([status, cnt]) => ( {status}: {cnt} ))}
)}
{/* 7. Заказы */}

Заказы

Всего: {officeDashboard.orders.total} {Object.entries(officeDashboard.orders.byStatus || {}).map(([status, cnt]) => ( {status}: {cnt} ))}
{/* 8. Совещания */}

Совещания

На эту неделю

{officeDashboard.meetings.thisWeek}

Предстоящие

{officeDashboard.meetings.upcoming}

{/* 9. Переговорные */}

Переговорные

Комнат

{officeDashboard.meetingRooms.total}

{/* 10. База знаний */}

База знаний

Категорий

{officeDashboard.knowledgeBase.categoriesCount}

Статей

{officeDashboard.knowledgeBase.articlesCount}

)}
)} )} {/* Strategic Goals Modal */} {isGoalsModalOpen && (
setIsGoalsModalOpen(false)}>
e.stopPropagation()}> {/* Modal Header */}
Стратегическое планирование

Цели компании 2024

{/* Modal Content */}
{filteredGoals.length > 0 ? (
{filteredGoals.map(goal => )}
) : (
Нет доступных целей для вашей роли
)}
{/* Modal Footer */}

Обновлено: {new Date().toLocaleDateString('ru-RU')}

{currentUserRole === 'DIRECTOR' && ( )}
)} {/* Модалка «Предложить новость» */} {isSuggestNewsModalOpen && ( setIsSuggestNewsModalOpen(false)} onSuccess={() => { setIsSuggestNewsModalOpen(false); backendApi.getCompanyNews({ status: 'published', limit: 5 }).then((list) => Array.isArray(list) && setCompanyNews(list)); }} /> )} {/* Модалка просмотра новости (из уведомления) */} {openNewsId != null && (
onCloseNews?.()}>
e.stopPropagation()}>

Новость компании

{viewNewsLoading ? (
Загрузка...
) : viewNewsItem ? ( <>

{viewNewsItem.title}

{viewNewsItem.publishedAt ? new Date(viewNewsItem.publishedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) : new Date(viewNewsItem.createdAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })} {viewNewsItem.createdByName && ` · ${viewNewsItem.createdByName}`}

{viewNewsItem.body &&
{viewNewsItem.body}
} ) : (

Новость не найдена

)}
)}
); }; // ==================================================================================== // Suggest News Modal — предложить новость (черновик), выбор уведомлений по отделам и лично // ==================================================================================== const DEPT_LABELS: Record = { production: 'Производство', pr: 'PR', finance: 'Финансы', development: 'Развитие', legal: 'Юр. отдел', hr: 'Кадры', }; const SuggestNewsModal: React.FC<{ onClose: () => void; onSuccess: () => void }> = ({ onClose, onSuccess }) => { const [title, setTitle] = useState(''); const [body, setBody] = useState(''); const [notifyDepartments, setNotifyDepartments] = useState([]); const [notifyEmployeeIds, setNotifyEmployeeIds] = useState([]); const [employeesList, setEmployeesList] = useState>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { backendApi.getEmployeesList().then((list) => setEmployeesList(Array.isArray(list) ? list : [])).catch(() => setEmployeesList([])); }, []); const toggleDepartment = (d: Department) => { setNotifyDepartments((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d])); }; const toggleEmployee = (id: string) => { setNotifyEmployeeIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) { setError('Укажите заголовок'); return; } setError(null); setLoading(true); try { await backendApi.createCompanyNews({ title: title.trim(), body: body.trim() || null, status: 'draft', notifyDepartments: notifyDepartments.length ? notifyDepartments : undefined, notifyEmployeeIds: notifyEmployeeIds.length ? notifyEmployeeIds : undefined, }); window.dispatchEvent(new CustomEvent('mkd-news-changed')); onSuccess(); } catch (err: any) { setError(err?.message || 'Не удалось сохранить новость'); } finally { setLoading(false); } }; return (
e.stopPropagation()}>

Предложить новость

setTitle(e.target.value)} className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-medium text-slate-800 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent" placeholder="Заголовок новости" />