Files
mkd/components/SummaryDashboard.tsx
2026-02-04 00:17:04 +05:00

1828 lines
108 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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<Department, { icon: React.ElementType; color: string; }> = {
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 (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div>
<div className="flex items-center gap-3 mb-3">
<div className={`p-2 bg-slate-100 rounded-lg ${color}`}>
<Icon className="w-5 h-5" />
</div>
<h4 className="font-bold text-slate-700">{goal.title}</h4>
</div>
<p className="text-sm text-slate-600 mb-4">{goal.description}</p>
</div>
<div>
<div className="flex justify-between items-end mb-1">
<span className="text-xs text-slate-500">Прогресс</span>
<span className="text-sm font-bold text-slate-800">
{formatValue(goal.currentValue, goal.unit)} / <span className={color}>{formatValue(goal.targetValue, goal.unit)}</span>
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2.5">
<div className={`${color.replace('text-', 'bg-')} h-2.5 rounded-full`} style={{ width: `${progress}%` }}></div>
</div>
<div className="text-xs text-slate-400 mt-2 flex items-center gap-1.5">
<Calendar className="w-3 h-3"/>
<span>Срок: {goal.deadline}</span>
</div>
</div>
</div>
);
};
// ====================================================================================
// Operational Panel Components
// ====================================================================================
/** Блок дашборда, по которому можно открыть продвинутую статистику и перейти в модуль */
const DASHBOARD_BLOCK_TO_MODULE: Record<string, { tabId: string; label: string }> = {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pb-24 md:pb-28 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-xl border border-slate-200 w-full max-w-5xl max-h-[calc(100vh-7rem)] md:max-h-[calc(100vh-8rem)] overflow-hidden flex flex-col animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-4 p-4 border-b border-slate-200 bg-slate-50/80">
<h3 className="text-lg font-bold text-slate-800">{title} продвинутая статистика</h3>
<div className="flex items-center gap-3">
{moduleInfo && onGoToModule && (
<button
type="button"
onClick={() => {
onGoToModule(moduleInfo.tabId);
onClose();
}}
className="flex items-center gap-2 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-sm hover:bg-primary-700 transition-all shadow-sm"
>
Перейти в модуль
<ArrowRight className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 rounded-lg transition-colors"
aria-label="Закрыть"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
);
};
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 (
<Wrapper {...wrapperProps}>
<div className="flex items-center gap-3">
<div className={`p-2 bg-slate-50 rounded-lg ${iconColor}`}>
<Icon className="w-5 h-5" />
</div>
<h3 className="font-bold text-slate-700">{title}</h3>
</div>
<div className="space-y-4">
{children}
</div>
</Wrapper>
);
};
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 (
<div className="flex justify-between items-center">
<div>
<p className="text-2xl font-black text-slate-800">{value}</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
{trend && <TrendIcon className={`w-6 h-6 ${trendColor}`} />}
</div>
);
};
const ProgressBar: React.FC<{ value: number; label: string }> = ({ value, label }) => (
<div>
<div className="flex justify-between items-center mb-1">
<p className="text-xs text-slate-500 font-bold">{label}</p>
<p className="text-sm font-bold text-slate-700">{value}%</p>
</div>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-sky-500 h-2 rounded-full" style={{ width: `${value}%` }}></div>
</div>
</div>
);
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 (
<div>
<div className="flex justify-between items-baseline">
<p className="text-xs text-slate-500">{label}</p>
<p className="text-2xl font-black text-slate-800">{value}%</p>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 mt-2">
<div className={`${color} h-2 rounded-full`} style={{ width: `${value}%` }}></div>
</div>
</div>
);
};
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<TaskMyRole, string> = { executor: 'Исполнитель', co_executor: 'Соисполнитель', assigner: 'Постановщик', observer: 'Наблюдатель' };
const DashboardTaskCard: React.FC<{
task: BuildingTask;
myRole: TaskMyRole;
onToggleDone: () => void;
onOpen: () => void;
}> = ({ task, myRole, onToggleDone, onOpen }) => (
<div
onClick={onOpen}
className="flex items-start gap-3 p-3 hover:bg-slate-50 rounded-xl border border-slate-100 transition-colors cursor-pointer"
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleDone(); }}
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center transition-all ${
task.status === 'done' ? 'bg-emerald-500 border-emerald-500' : 'border-slate-300 hover:border-primary-400'
}`}
title={task.status === 'done' ? 'Отметить невыполненной' : 'Отметить выполненной'}
>
{task.status === 'done' && <svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>}
</button>
<div className="flex-1 min-w-0">
<p className={`text-sm font-bold truncate ${task.status === 'done' ? 'text-slate-400 line-through' : 'text-slate-800'}`}>{task.title}</p>
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<span className="text-[10px] text-slate-400 font-bold flex items-center gap-1"><Calendar className="w-3 h-3" /> {formatTaskDate(task.deadline)}</span>
<span className={`px-1.5 py-0.5 rounded text-[9px] font-black uppercase ${getPriorityColor(task.priority)}`}>{getPriorityLabel(task.priority)}</span>
{task.buildingAddress && <span className="text-[10px] text-slate-500 truncate max-w-[120px]" title={task.buildingAddress}>{task.buildingAddress}</span>}
<span className="text-[9px] font-bold text-primary-600 bg-primary-50 px-1.5 py-0.5 rounded">{ROLE_LABELS[myRole]}</span>
{(task.comments?.length ?? 0) > 0 && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1" title={task.comments!.map(c => `${c.authorName}: ${c.text}`).join('\n')}>
<MessageSquare className="w-3 h-3" /> {task.comments!.length} коммент.
</span>
)}
</div>
</div>
</div>
);
// ====================================================================================
// 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<string | null>(null);
const [tasksTab, setTasksTab] = useState<TaskMyRole>('executor');
const [dashboardTasks, setDashboardTasks] = useState<BuildingTask[]>(() => loadDashboardTasks(currentUser.id));
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [editingTask, setEditingTask] = useState<BuildingTask | null>(null);
const [applications, setApplications] = useState<DomaApplication[]>([]);
const [appsLoading, setAppsLoading] = useState(false);
const [appsError, setAppsError] = useState<string | null>(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<Awaited<ReturnType<typeof backendApi.getProductionDashboard>>['data'] | null>(null);
const [productionDashboardLoading, setProductionDashboardLoading] = useState(false);
const [companyNews, setCompanyNews] = useState<CompanyNews[]>([]);
const [companyNewsLoading, setCompanyNewsLoading] = useState(false);
const [isSuggestNewsModalOpen, setIsSuggestNewsModalOpen] = useState(false);
const [viewNewsItem, setViewNewsItem] = useState<CompanyNews | null>(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<Awaited<ReturnType<typeof backendApi.getPRDashboard>>['data'] | null>(null);
const [prDashboardLoading, setPrDashboardLoading] = useState(false);
/** Дашборд офиса для карточки и модалки продвинутой статистики */
const [officeDashboard, setOfficeDashboard] = useState<Awaited<ReturnType<typeof backendApi.getOfficeDashboard>>['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 (
<div className="animate-fade-in pb-20 space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-slate-800">Сводка</h2>
<p className="text-sm text-slate-500">Ключевые показатели для вашей роли.</p>
</div>
{/* 'Set Goals' Button - prominent placement */}
{canShowBlock('goals') && (
<button
onClick={() => setIsGoalsModalOpen(true)}
className="flex items-center justify-center gap-2 px-6 py-3 bg-white border-2 border-primary-600 text-primary-700 rounded-2xl font-bold text-sm hover:bg-primary-50 transition-all shadow-sm active:scale-95 group"
>
<Target className="w-5 h-5 text-primary-600 group-hover:scale-110 transition-transform" />
Стратегические цели
</button>
)}
</div>
{/* KPI Dashboards Section - Main focus */}
<div className="space-y-6">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-slate-800">Оперативные Панели</h3>
<div className="h-px flex-1 bg-slate-200"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{showDashboardCard('production', 'production') && (
<DashboardCard title="Производство" icon={Wrench} iconColor="text-amber-500" onClick={() => setStatsModalDepartment('production')}>
{metricsLoading ? (
<div className="text-center py-4 text-slate-400 text-sm">Загрузка...</div>
) : productionMetrics ? (
<>
{/* Состояние домов */}
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs text-slate-600 font-bold">Состояние домов</span>
<span className={`text-sm font-black ${
productionMetrics.buildingCondition.condition === 'excellent' ? 'text-emerald-600' :
productionMetrics.buildingCondition.condition === 'good' ? 'text-blue-600' :
productionMetrics.buildingCondition.condition === 'fair' ? 'text-amber-600' :
'text-red-600'
}`}>
{productionMetrics.buildingCondition.label}
</span>
</div>
<div className="text-[10px] text-slate-400">
Средний износ: {productionMetrics.buildingCondition.value > 0 ? `${productionMetrics.buildingCondition.value.toFixed(1)}%` : 'Н/Д'}
({productionMetrics.buildingCondition.buildingsWithWear} из {productionMetrics.buildingCondition.totalBuildings} домов)
</div>
</div>
{/* График работ */}
<div className="space-y-2 pt-2 border-t border-slate-100">
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600 font-bold">График работ</span>
</div>
<div className="space-y-1.5">
<div className="flex justify-between items-center">
<span className="text-[10px] text-slate-500">В срок:</span>
<span className="text-sm font-black text-emerald-600">{productionMetrics.workSchedule.onTimeRate}%</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[10px] text-slate-500">С опозданием:</span>
<span className="text-sm font-black text-red-600">{productionMetrics.workSchedule.lateRate}%</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[10px] text-slate-500">Просрочено:</span>
<span className="text-sm font-black text-amber-600">{productionMetrics.workSchedule.overdue}</span>
</div>
</div>
</div>
{/* Рейтинг SLA */}
<div className="pt-2 border-t border-slate-100">
<GaugeChart
value={productionMetrics.slaRating}
label={`Рейтинг SLA`}
/>
<div className="text-[10px] text-slate-400 mt-1 text-center">
{productionMetrics.workSchedule.onTime} из {productionMetrics.workSchedule.onTime + productionMetrics.workSchedule.late} выполнено в срок
</div>
</div>
</>
) : (
<div className="text-center py-4 text-slate-400 text-sm">Нет данных</div>
)}
</DashboardCard>
)}
{showDashboardCard('pr', 'pr') && (
<DashboardCard title="PR и Клиентский Опыт" icon={Megaphone} iconColor="text-indigo-500" onClick={() => setStatsModalDepartment('pr')}>
{prDashboardLoading && !prDashboard ? (
<div className="text-center py-4 text-slate-400 text-sm">Загрузка...</div>
) : (
<>
<GaugeChart value={prDashboard?.npsCompany ?? 0} label="Общий NPS по компании" />
<Stat value={`${prDashboard?.csatPercent ?? 0}%`} label="Удовлетворенность (CSAT)" />
<div className="pt-4 border-t border-slate-100 space-y-2">
<div className="flex items-center gap-2">
<Share2 className="w-4 h-4 text-indigo-500" />
<span className="text-xs font-bold text-slate-600">SMM подписчики</span>
</div>
{(prDashboardLoading && !prDashboard) ? (
<p className="text-xs text-slate-400">Загрузка...</p>
) : (() => {
const smm = prDashboard?.smm ?? smmSummary;
return smm && (smm.byChannel?.length > 0 || (smm.total ?? 0) > 0) ? (
<>
<div className="flex justify-between items-baseline">
<p className="text-2xl font-black text-slate-800">{(smm.total ?? 0).toLocaleString('ru-RU')}</p>
<span className="text-[10px] text-slate-400 font-bold uppercase">всего</span>
</div>
{smm.byChannel && smm.byChannel.length > 0 ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
{smm.byChannel.map((c: { id: number; name: string; type: string; subscribersCount: number }) => (
<span key={c.id} className="flex items-center gap-1">
{c.type === 'tg' && <Send className="w-3 h-3 text-sky-600" />}
{c.type === 'vk' && <Share2 className="w-3 h-3 text-blue-600" />}
{c.type === 'wa' && <MessageSquare className="w-3 h-3 text-emerald-600" />}
<span className={c.type === 'tg' ? 'text-sky-600' : c.type === 'vk' ? 'text-blue-600' : c.type === 'wa' ? 'text-emerald-600' : 'text-slate-600'}>
{c.type === 'tg' ? 'TG' : c.type === 'vk' ? 'VK' : c.type === 'wa' ? 'WA' : c.name} {(c.subscribersCount ?? 0).toLocaleString('ru-RU')}
</span>
</span>
))}
</div>
) : (
<p className="text-[10px] text-slate-400">Добавьте каналы в модуле PR SMM</p>
)}
</>
) : (
<p className="text-[10px] text-slate-400">Добавьте каналы в модуле PR SMM</p>
);
})()}
</div>
</>
)}
</DashboardCard>
)}
{showDashboardCard('finance', 'finance') && (
<DashboardCard title="Финансы" icon={Banknote} iconColor="text-emerald-500" onClick={() => setStatsModalDepartment('finance')}>
<Stat value="1.2M ₽" label="Чистая прибыль (мес)" trend="up" />
<Stat value="8.5M ₽" label="Общая дебиторка" />
<div className="p-3 bg-slate-50 rounded-lg text-center">
<TrendingUp className="w-10 h-10 text-emerald-300 mx-auto opacity-50" />
<p className="text-[10px] text-slate-400 mt-1 uppercase font-bold tracking-widest">Тренд прибыли</p>
</div>
</DashboardCard>
)}
{showDashboardCard('development', 'development') && (
<DashboardCard title="Отдел Развития" icon={Briefcase} iconColor="text-sky-500" onClick={() => setStatsModalDepartment('development')}>
<Stat value="+3" label="Новых дома (квартал)" />
<Stat value="12" label="Домов в воронке продаж" />
<ProgressBar value={25} label="Конверсия" />
</DashboardCard>
)}
{showDashboardCard('legal', 'legal') && (
<DashboardCard title="Юридический отдел" icon={Scale} iconColor="text-slate-600" onClick={() => setStatsModalDepartment('legal')}>
<Stat value="2.1M ₽" label="Взыскано за квартал" trend="up"/>
<Stat value="42" label="Активных судебных дел" />
<ProgressBar value={85} label="Процент выигранных дел" />
</DashboardCard>
)}
{showDashboardCard('hr', 'hr') && (
<DashboardCard title="Отдел Кадров (HR)" icon={UsersRound} iconColor="text-violet-500" onClick={() => setStatsModalDepartment('hr')}>
<Stat value="24" label="Всего сотрудников" />
<Stat value="2" label="Кандидатов в воронке" />
<ProgressBar value={12} label="Текучесть кадров (год)" />
</DashboardCard>
)}
{(showDashboardCard('office') || showDashboardCard('news')) && (allowedDashboardBlocks === 'all' ? currentUserRole !== 'MASTER' : true) && (
<DashboardCard title="Офис" icon={LayoutGrid} iconColor="text-slate-600" onClick={() => setStatsModalDepartment('news')}>
{officeDashboardLoading && !officeDashboard ? (
<div className="text-center py-4 text-slate-400 text-sm">Загрузка...</div>
) : officeDashboard ? (
<>
<Stat value={String(officeDashboard.companyNews.publishedThisMonth)} label="Новостей за месяц" />
<Stat value={String(officeDashboard.repairRequests.open + officeDashboard.repairRequests.in_progress)} label="Заявок на ремонт (активных)" />
<div className="pt-2 border-t border-slate-100 space-y-1">
<div className="flex justify-between items-center text-xs">
<span className="text-slate-600 font-bold">Документы</span>
<span className="text-slate-800 font-black">{officeDashboard.documents.total}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-slate-500">Заявки на закупку</span>
<span className="text-slate-700 font-bold">{officeDashboard.supplyRequests.total}</span>
</div>
</div>
</>
) : (
<div className="text-center py-4 text-slate-400 text-sm">Нет данных</div>
)}
</DashboardCard>
)}
</div>
</div>
{/* Новости компании — чтение и предложение новости */}
{canShowBlock('news') && (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-slate-800">Новости компании</h3>
<div className="h-px flex-1 bg-slate-200"></div>
</div>
<button
type="button"
onClick={() => setIsSuggestNewsModalOpen(true)}
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-sm hover:bg-primary-700 transition-all shadow-sm active:scale-95"
>
<Newspaper className="w-4 h-4" /> Предложить новость
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{companyNewsLoading ? (
<div className="p-6 text-center text-slate-400 text-sm">Загрузка...</div>
) : companyNews.length === 0 ? (
<div className="p-6 text-center text-slate-400 text-sm">Нет опубликованных новостей</div>
) : (
<ul className="divide-y divide-slate-100">
{companyNews.map((n) => (
<li key={n.id} className="p-4 hover:bg-slate-50/50 transition-colors">
<div className="flex justify-between items-start gap-2">
<div className="min-w-0 flex-1">
<p className="font-bold text-slate-800 truncate">{n.title}</p>
<p className="text-xs text-slate-500 mt-0.5">
{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}`}
</p>
{n.body && <p className="text-sm text-slate-600 mt-1 line-clamp-2">{n.body}</p>}
</div>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)}
{/* Мои задачи — список по ролям (исполнитель / соисполнитель / постановщик / наблюдатель) */}
{canShowBlock('tasks') && (
<div className="space-y-6">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-slate-800">Мои задачи</h3>
<div className="h-px flex-1 bg-slate-200"></div>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="flex p-1 bg-slate-100/80 border-b border-slate-200 overflow-x-auto no-scrollbar gap-1">
{([
{ 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 }) => (
<button
key={id}
onClick={() => setTasksTab(id)}
className={`flex-shrink-0 min-w-[7rem] flex items-center gap-2 px-4 py-2.5 text-xs font-bold whitespace-nowrap rounded-lg transition-all ${tasksTab === id ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<Icon className="w-3.5 h-3.5" /> {label}
</button>
))}
</div>
<div className="p-4 min-h-[200px]">
{dashboardTasks
.filter(t => getTaskMyRole(t, currentUser.id) === tasksTab)
.length === 0 ? (
<p className="text-center py-8 text-slate-400 text-sm">Нет задач в этой роли</p>
) : (
<div className="space-y-3">
{dashboardTasks
.filter(t => getTaskMyRole(t, currentUser.id) === tasksTab)
.map(task => (
<DashboardTaskCard
key={task.id}
task={task}
myRole={getTaskMyRole(task, currentUser.id)!}
onToggleDone={() => {
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); }}
/>
))}
</div>
)}
</div>
<div className="p-4 border-t border-slate-100">
<button
type="button"
onClick={() => { setEditingTask(null); setIsTaskModalOpen(true); }}
className="w-full py-3 border-2 border-dashed border-slate-200 rounded-xl text-slate-500 hover:border-primary-300 hover:text-primary-600 hover:bg-primary-50/50 text-sm font-bold flex items-center justify-center gap-2 transition-colors"
>
<Plus className="w-4 h-4" /> Добавить задачу
</button>
</div>
</div>
</div>
)}
{/* Модалка задачи (без фото отчёта) */}
{isTaskModalOpen && (
<DashboardTaskModal
isOpen={isTaskModalOpen}
onClose={() => { 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 && (
<DepartmentStatsModal
title={
statsModalDepartment === 'production' ? 'Производство' :
statsModalDepartment === 'pr' ? 'PR и Клиентский Опыт' :
statsModalDepartment === 'finance' ? 'Финансы' :
statsModalDepartment === 'development' ? 'Отдел Развития' :
statsModalDepartment === 'legal' ? 'Юридический отдел' :
statsModalDepartment === 'hr' ? 'Отдел Кадров (HR)' :
statsModalDepartment === 'news' ? 'Офис' :
'Новости компании'
}
blockId={statsModalDepartment}
onClose={() => setStatsModalDepartment(null)}
onGoToModule={onNavigateToModule ?? undefined}
>
{statsModalDepartment === 'production' && (
<div className="space-y-8">
{productionDashboardLoading ? (
<p className="text-slate-400 text-sm">Загрузка дашборда...</p>
) : !productionDashboard ? (
<p className="text-slate-400 text-sm">Нет данных</p>
) : (
<>
{/* 4.1 Состояние домов, SLA, график работ */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Wrench className="w-4 h-4" /> Состояние и график</h4>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Состояние домов</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.buildingCondition.label}</p>
<p className="text-xs text-slate-500 mt-1">Средний износ {productionDashboard.buildingCondition.value > 0 ? `${productionDashboard.buildingCondition.value.toFixed(1)}%` : 'Н/Д'} · {productionDashboard.buildingCondition.buildingsWithWear} из {productionDashboard.buildingCondition.totalBuildings} домов</p>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Рейтинг SLA</p>
<p className="text-2xl font-black text-slate-800">{productionDashboard.slaRating}%</p>
</div>
</div>
<div className="mt-3 pt-3 border-t border-slate-200">
<p className="text-sm font-bold text-slate-700 mb-1">График работ</p>
<p className="text-sm text-slate-600">В срок: <span className="font-bold text-emerald-600">{productionDashboard.workSchedule.onTimeRate}%</span> · С опозданием: <span className="font-bold text-red-600">{productionDashboard.workSchedule.lateRate}%</span> · Просрочено: <span className="font-bold text-amber-600">{productionDashboard.workSchedule.overdue}</span></p>
</div>
</section>
{/* 4.2 По участкам */}
{productionDashboard.districts && productionDashboard.districts.length > 0 && (
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><LayoutGrid className="w-4 h-4" /> По участкам</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 font-bold text-slate-600">Участок</th>
<th className="text-left py-2 font-bold text-slate-600">Руководитель</th>
<th className="text-right py-2 font-bold text-slate-600">Домов</th>
<th className="text-right py-2 font-bold text-slate-600">Заявок</th>
<th className="text-right py-2 font-bold text-slate-600">Выполнено</th>
<th className="text-right py-2 font-bold text-slate-600">Просрочено</th>
<th className="text-right py-2 font-bold text-slate-600">Успеваемость %</th>
</tr>
</thead>
<tbody>
{productionDashboard.districts.map((d) => (
<tr key={d.districtId} className="border-b border-slate-100">
<td className="py-2 font-medium text-slate-800">{d.districtName}</td>
<td className="py-2 text-slate-600">{d.managerName}</td>
<td className="py-2 text-right">{d.buildingCount}</td>
<td className="py-2 text-right">{d.totalApplications}</td>
<td className="py-2 text-right text-emerald-600">{d.totalCompleted}</td>
<td className="py-2 text-right text-amber-600">{d.totalOverdue}</td>
<td className="py-2 text-right font-bold">{Math.round(d.completionRate)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* 4.3 Заявки — временные срезы */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Clock className="w-4 h-4" /> Заявки временные срезы</h4>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-amber-50 rounded-xl border border-amber-100">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Сегодня</p>
<p className="text-lg font-black text-slate-800">Создано: {productionDashboard.applicationsTimeSlices.today.created}</p>
<p className="text-sm text-slate-600">Закрыто: {productionDashboard.applicationsTimeSlices.today.closed} · Просрочено: {productionDashboard.applicationsTimeSlices.today.overdueNow}</p>
</div>
<div className="p-4 bg-blue-50 rounded-xl border border-blue-100">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Неделя</p>
<p className="text-lg font-black text-slate-800">Создано: {productionDashboard.applicationsTimeSlices.week.created}</p>
<p className="text-sm text-slate-600">Закрыто: {productionDashboard.applicationsTimeSlices.week.closed} · Просрочено: {productionDashboard.applicationsTimeSlices.week.overdueNow}</p>
</div>
<div className="p-4 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Месяц</p>
<p className="text-lg font-black text-slate-800">Создано: {productionDashboard.applicationsTimeSlices.month.created}</p>
<p className="text-sm text-slate-600">Закрыто: {productionDashboard.applicationsTimeSlices.month.closed} · Просрочено: {productionDashboard.applicationsTimeSlices.month.overdueNow}</p>
</div>
</div>
</section>
{/* 4.4 Заявки — общая сводка */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Inbox className="w-4 h-4" /> Заявки сводка</h4>
<div className="flex flex-wrap gap-3">
<span className="px-3 py-1.5 bg-red-50 text-red-700 rounded-lg text-xs font-bold">Новые: {productionDashboard.applicationsSummary.new}</span>
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">В работе: {productionDashboard.applicationsSummary.inProgress}</span>
<span className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-xs font-bold">Отложено: {productionDashboard.applicationsSummary.deferred}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Выполнено: {productionDashboard.applicationsSummary.done}</span>
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-xs font-bold flex items-center gap-1"><AlertTriangle className="w-3 h-3" /> Просрочено: {productionDashboard.applicationsSummary.overdue}</span>
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Индекс SLA: {productionDashboard.applicationsSummary.completionRate}%</span>
</div>
</section>
{/* 4.5 Загрузка мастеров */}
{productionDashboard.topPerformers && productionDashboard.topPerformers.length > 0 && (
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><UsersRound className="w-4 h-4" /> Загрузка мастеров</h4>
<ul className="space-y-2">
{productionDashboard.topPerformers.map((p, i) => (
<li key={i} className="flex justify-between items-center py-1.5 px-3 rounded-lg bg-slate-50">
<span className="font-medium text-slate-800">{p.name}</span>
<span className={`font-bold ${p.activeCount > 5 ? 'text-amber-600' : 'text-slate-700'}`}>{p.activeCount} активных</span>
</li>
))}
</ul>
</section>
)}
{/* 4.6 План работ */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><CalendarDays className="w-4 h-4" /> План работ (текущий месяц)</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Всего работ</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.planWorks.total}</p>
</div>
<div className="p-3 bg-emerald-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Выполнено</p>
<p className="text-xl font-black text-emerald-700">{productionDashboard.planWorks.completed}</p>
</div>
<div className="p-3 bg-amber-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Перенесено</p>
<p className="text-xl font-black text-amber-700">{productionDashboard.planWorks.carriedOver}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Смета (мес)</p>
<p className="text-lg font-black text-slate-800">{productionDashboard.planWorks.estimatedCostMonth > 0 ? `${(productionDashboard.planWorks.estimatedCostMonth / 1000).toFixed(0)}k ₽` : '—'}</p>
</div>
</div>
{productionDashboard.planWorks.byDistrict && productionDashboard.planWorks.byDistrict.length > 0 && (
<p className="text-xs text-slate-500 mt-2">По участкам: {productionDashboard.planWorks.byDistrict.map((d) => `${d.districtId}: ${d.total} работ, выполнено ${d.completed}`).join('; ')}</p>
)}
</section>
{/* 4.7 Обходы (осмотры) */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Camera className="w-4 h-4" /> Обходы (осмотры)</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">За месяц</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.inspections.countMonth}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">За квартал</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.inspections.countQuarter}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Последний осмотр</p>
<p className="text-sm font-bold text-slate-800">{productionDashboard.inspections.lastDate ? new Date(productionDashboard.inspections.lastDate).toLocaleDateString('ru-RU') : '—'}</p>
</div>
</div>
</section>
{/* 4.8 Списания */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Package className="w-4 h-4" /> Списания</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Сегодня</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.writeOffs.countToday}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">За неделю</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.writeOffs.countWeek}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">За месяц</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.writeOffs.countMonth}</p>
</div>
<div className="p-3 bg-emerald-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Сумма (мес)</p>
<p className="text-lg font-black text-emerald-700">{productionDashboard.writeOffs.sumMonth > 0 ? `${(productionDashboard.writeOffs.sumMonth / 1000).toFixed(0)}k ₽` : '—'}</p>
</div>
</div>
{productionDashboard.districtInventorySummary && productionDashboard.districtInventorySummary.length > 0 && (
<p className="text-xs text-slate-500 mt-2">Остатки складов участков: {productionDashboard.districtInventorySummary.map((d) => `${d.districtName}: ${d.totalQuantity} ед.`).join('; ')}</p>
)}
</section>
{/* 4.9 Обходы счетчиков */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Gauge className="w-4 h-4" /> Обходы счетчиков</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">За месяц</p>
<p className="text-xl font-black text-slate-800">{productionDashboard.meterRounds.countMonth}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Последний обход</p>
<p className="text-sm font-bold text-slate-800">{productionDashboard.meterRounds.lastDate ? new Date(productionDashboard.meterRounds.lastDate).toLocaleDateString('ru-RU') : '—'}</p>
</div>
</div>
</section>
</>
)}
</div>
)}
{statsModalDepartment === 'pr' && (
<div className="space-y-8">
{prDashboardLoading ? (
<p className="text-slate-400 text-sm">Загрузка дашборда...</p>
) : !prDashboard ? (
<p className="text-slate-400 text-sm">Нет данных</p>
) : (
<>
{/* 1. NPS по компании */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Megaphone className="w-4 h-4" /> NPS по компании</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="p-4 bg-indigo-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Индекс NPS</p>
<p className="text-2xl font-black text-indigo-700">{prDashboard.npsCompany}</p>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Ответов за месяц</p>
<p className="text-xl font-black text-slate-800">{prDashboard.npsResponsesCount}</p>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Средний балл</p>
<p className="text-xl font-black text-slate-800">{prDashboard.npsAvgScore > 0 ? prDashboard.npsAvgScore.toFixed(1) : '—'}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-2">
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Промоутеры: {prDashboard.npsPromoters}</span>
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Нейтралы: {prDashboard.npsPassives}</span>
<span className="px-3 py-1.5 bg-red-50 text-red-700 rounded-lg text-xs font-bold">Критики: {prDashboard.npsDetractors}</span>
</div>
</section>
{/* 2. Удовлетворённость (CSAT) */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Gauge className="w-4 h-4" /> Удовлетворённость (CSAT)</h4>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">CSAT, %</p>
<p className="text-2xl font-black text-slate-800">{prDashboard.csatPercent}%</p>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Средний рейтинг отзывов</p>
<p className="text-xl font-black text-slate-800">{prDashboard.reviewsAvgRating > 0 ? prDashboard.reviewsAvgRating.toFixed(1) : '—'}</p>
</div>
<div className="col-span-2 p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Всего отзывов</p>
<p className="text-lg font-black text-slate-800">{prDashboard.reviewsTotal}</p>
</div>
</div>
</section>
{/* 3. SMM — подписчики */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Share2 className="w-4 h-4" /> SMM подписчики</h4>
<div className="flex flex-wrap gap-4">
<div className="p-4 bg-indigo-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Всего</p>
<p className="text-2xl font-black text-indigo-700">{prDashboard.smm.total.toLocaleString('ru-RU')}</p>
</div>
{prDashboard.smm.byChannel && prDashboard.smm.byChannel.length > 0 && (
<div className="flex flex-wrap gap-2">
{prDashboard.smm.byChannel.map((c) => (
<span key={c.id} className="px-3 py-1.5 bg-slate-100 rounded-lg text-xs font-bold text-slate-700">
{c.type === 'tg' ? 'TG' : c.type === 'vk' ? 'VK' : c.type === 'wa' ? 'WA' : c.name}: {c.subscribersCount.toLocaleString('ru-RU')}
</span>
))}
</div>
)}
</div>
{(!prDashboard.smm.byChannel || prDashboard.smm.byChannel.length === 0) && prDashboard.smm.total === 0 && (
<p className="text-xs text-slate-400 mt-2">Добавьте каналы в модуле PR SMM</p>
)}
</section>
{/* 4. Отзывы */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><MessageSquare className="w-4 h-4" /> Отзывы</h4>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {prDashboard.reviewsStats.total}</span>
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">Новые: {prDashboard.reviewsStats.new_count}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Обработано: {prDashboard.reviewsStats.processed_count}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Положительные: {prDashboard.reviewsStats.positive_count}</span>
<span className="px-3 py-1.5 bg-red-50 text-red-700 rounded-lg text-xs font-bold">Негативные: {prDashboard.reviewsStats.negative_count}</span>
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-xs font-bold">Яндекс: {prDashboard.reviewsStats.yandex_count}</span>
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-xs font-bold">2GIS: {prDashboard.reviewsStats.gis2_count}</span>
</div>
</section>
{/* 5. Инциденты */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><AlertTriangle className="w-4 h-4" /> Инциденты</h4>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {prDashboard.incidentsSummary.total}</span>
<span className="px-3 py-1.5 bg-red-50 text-red-700 rounded-lg text-xs font-bold">Открытые: {prDashboard.incidentsSummary.open}</span>
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-xs font-bold">В работе: {prDashboard.incidentsSummary.in_progress}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Закрыты: {prDashboard.incidentsSummary.resolved}</span>
</div>
</section>
{/* 6. Мероприятия */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><PartyPopper className="w-4 h-4" /> Мероприятия</h4>
<div className="grid grid-cols-3 gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">За месяц</p>
<p className="text-xl font-black text-slate-800">{prDashboard.events.countMonth}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Предстоящие</p>
<p className="text-xl font-black text-slate-800">{prDashboard.events.upcoming}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Прошедшие</p>
<p className="text-xl font-black text-slate-800">{prDashboard.events.past}</p>
</div>
</div>
</section>
{/* 7. Отчёты жителям */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><FileText className="w-4 h-4" /> Отчёты жителям</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Всего отчётов</p>
<p className="text-xl font-black text-slate-800">{prDashboard.residentReports.total}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Опубликовано за месяц</p>
<p className="text-xl font-black text-slate-800">{prDashboard.residentReports.publishedThisMonth}</p>
</div>
</div>
</section>
{/* 8. Фото отчёты */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Camera className="w-4 h-4" /> Фото отчёты</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Всего</p>
<p className="text-xl font-black text-slate-800">{prDashboard.workPhotos.total}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">За месяц</p>
<p className="text-xl font-black text-slate-800">{prDashboard.workPhotos.thisMonth}</p>
</div>
</div>
</section>
{/* 9. NPS опросы */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><ClipboardList className="w-4 h-4" /> NPS опросы</h4>
<div className="flex flex-wrap gap-2 mb-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {prDashboard.npsSurveys.total}</span>
<span className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-xs font-bold">Активных: {prDashboard.npsSurveys.active}</span>
</div>
{prDashboard.npsSurveys.list && prDashboard.npsSurveys.list.length > 0 && (
<ul className="space-y-1.5 text-sm text-slate-600">
{prDashboard.npsSurveys.list.map((s) => (
<li key={s.id} className="flex justify-between items-center py-1 border-b border-slate-100 last:border-0">
<span className="font-medium truncate mr-2">{s.title}</span>
<span className="text-xs font-bold text-slate-500">{s.responsesCount} ответов · {s.status}</span>
</li>
))}
</ul>
)}
</section>
{/* 10. Отложенные посты */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Send className="w-4 h-4" /> Отложенные посты</h4>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Черновик: {prDashboard.scheduledPosts.draft}</span>
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-xs font-bold">На модерации: {prDashboard.scheduledPosts.pending_approval}</span>
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">Одобрено: {prDashboard.scheduledPosts.approved}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Опубликовано: {prDashboard.scheduledPosts.published}</span>
<span className="px-3 py-1.5 bg-red-50 text-red-700 rounded-lg text-xs font-bold">Отклонено: {prDashboard.scheduledPosts.rejected}</span>
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">На редактировании: {prDashboard.scheduledPosts.edited}</span>
</div>
</section>
{/* 11. Негатив в работе */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><AlertTriangle className="w-4 h-4" /> Негатив в работе</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-red-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Открытые инциденты</p>
<p className="text-xl font-black text-red-700">{prDashboard.negative.openIncidents}</p>
</div>
<div className="p-3 bg-amber-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Негативных отзывов</p>
<p className="text-xl font-black text-amber-700">{prDashboard.negative.negativeReviewsCount}</p>
</div>
</div>
</section>
</>
)}
</div>
)}
{statsModalDepartment === 'finance' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-emerald-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Чистая прибыль (мес)</p>
<p className="text-2xl font-black text-emerald-700">1.2M </p>
<span className="text-xs text-emerald-600 font-bold flex items-center gap-1 mt-1"><TrendingUp className="w-3 h-3" /> тренд вверх</span>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Общая дебиторка</p>
<p className="text-2xl font-black text-slate-800">8.5M </p>
</div>
</div>
<p className="text-sm text-slate-600">В модуле «Финансы» доступны кассовый план, реестр счетов, платёжный календарь и отчёты по домам.</p>
</div>
)}
{statsModalDepartment === 'development' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-sky-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Новых дома (квартал)</p>
<p className="text-2xl font-black text-sky-700">+3</p>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Домов в воронке</p>
<p className="text-2xl font-black text-slate-800">12</p>
</div>
<div className="col-span-2 p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-2">Конверсия</p>
<div className="w-full bg-slate-200 rounded-full h-3"><div className="bg-sky-500 h-3 rounded-full" style={{ width: '25%' }} /></div>
<p className="text-sm font-bold text-slate-700 mt-1">25%</p>
</div>
</div>
<p className="text-sm text-slate-600">В модуле «Развитие» воронка продаж, ОСС, маркетинг и технический аудит.</p>
</div>
)}
{statsModalDepartment === 'legal' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-100 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Взыскано за квартал</p>
<p className="text-2xl font-black text-slate-800">2.1M </p>
<span className="text-xs text-emerald-600 font-bold flex items-center gap-1 mt-1"><TrendingUp className="w-3 h-3" /> тренд вверх</span>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Активных судебных дел</p>
<p className="text-2xl font-black text-slate-800">42</p>
</div>
<div className="col-span-2 p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-2">Процент выигранных дел</p>
<div className="w-full bg-slate-200 rounded-full h-3"><div className="bg-emerald-500 h-3 rounded-full" style={{ width: '85%' }} /></div>
<p className="text-sm font-bold text-slate-700 mt-1">85%</p>
</div>
</div>
<p className="text-sm text-slate-600">В модуле «Юр. отдел» досудебная работа, договоры, судебные дела и проверки контрагентов.</p>
</div>
)}
{statsModalDepartment === 'hr' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-violet-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Всего сотрудников</p>
<p className="text-2xl font-black text-violet-700">24</p>
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-1">Кандидатов в воронке</p>
<p className="text-2xl font-black text-slate-800">2</p>
</div>
<div className="col-span-2 p-4 bg-slate-50 rounded-xl">
<p className="text-xs font-bold text-slate-500 uppercase mb-2">Текучесть кадров (год)</p>
<div className="w-full bg-slate-200 rounded-full h-3"><div className="bg-violet-500 h-3 rounded-full" style={{ width: '12%' }} /></div>
<p className="text-sm font-bold text-slate-700 mt-1">12%</p>
</div>
</div>
<p className="text-sm text-slate-600">В модуле «Кадры» штат, вакансии, кандидаты, отпуска и календарь событий.</p>
</div>
)}
{statsModalDepartment === 'news' && (
<div className="space-y-8">
{officeDashboardLoading ? (
<p className="text-slate-400 text-sm">Загрузка дашборда...</p>
) : !officeDashboard ? (
<p className="text-slate-400 text-sm">Нет данных</p>
) : (
<>
{/* 1. Новости компании */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Newspaper className="w-4 h-4" /> Новости компании</h4>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {officeDashboard.companyNews.total}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Опубликовано: {officeDashboard.companyNews.published}</span>
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-xs font-bold">Черновики: {officeDashboard.companyNews.draft}</span>
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">На модерации: {officeDashboard.companyNews.pending}</span>
<span className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-xs font-bold">За месяц: {officeDashboard.companyNews.publishedThisMonth}</span>
</div>
</section>
{/* 2. Оборудование */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Package className="w-4 h-4" /> Оборудование</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Всего</p>
<p className="text-xl font-black text-slate-800">{officeDashboard.equipment.total}</p>
</div>
<div className="p-3 bg-emerald-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Хорошее</p>
<p className="text-xl font-black text-emerald-700">{officeDashboard.equipment.byCondition.good}</p>
</div>
<div className="p-3 bg-amber-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Удовлетв.</p>
<p className="text-xl font-black text-amber-700">{officeDashboard.equipment.byCondition.fair}</p>
</div>
<div className="p-3 bg-red-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Плохое</p>
<p className="text-xl font-black text-red-700">{officeDashboard.equipment.byCondition.poor}</p>
</div>
<div className="col-span-2 sm:col-span-4 p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Гарантия истекает (30 дн.)</p>
<p className="text-lg font-black text-slate-800">{officeDashboard.equipment.warrantyExpiringSoon}</p>
</div>
</div>
</section>
{/* 3. Заявки на ремонт */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Wrench className="w-4 h-4" /> Заявки на ремонт</h4>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {officeDashboard.repairRequests.total}</span>
<span className="px-3 py-1.5 bg-red-50 text-red-700 rounded-lg text-xs font-bold">Открытые: {officeDashboard.repairRequests.open}</span>
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 rounded-lg text-xs font-bold">В работе: {officeDashboard.repairRequests.in_progress}</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 rounded-lg text-xs font-bold">Выполнено: {officeDashboard.repairRequests.completed}</span>
</div>
</section>
{/* 4. Заявки на закупку */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Package className="w-4 h-4" /> Заявки на закупку</h4>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {officeDashboard.supplyRequests.total}</span>
{Object.entries(officeDashboard.supplyRequests.byStatus || {}).map(([status, cnt]) => (
<span key={status} className="px-3 py-1.5 bg-slate-50 text-slate-700 rounded-lg text-xs font-bold">{status}: {cnt}</span>
))}
</div>
</section>
{/* 5. Инвентарь */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Package className="w-4 h-4" /> Инвентарь</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Позиций всего</p>
<p className="text-xl font-black text-slate-800">{officeDashboard.inventory.totalItems}</p>
</div>
<div className="p-3 bg-amber-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Низкий остаток</p>
<p className="text-xl font-black text-amber-700">{officeDashboard.inventory.lowStockCount}</p>
</div>
</div>
</section>
{/* 6. Документооборот */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><FileText className="w-4 h-4" /> Документооборот</h4>
<div className="flex flex-wrap gap-2 mb-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {officeDashboard.documents.total}</span>
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-xs font-bold">Входящие: {officeDashboard.documents.incoming}</span>
<span className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-xs font-bold">Исходящие: {officeDashboard.documents.outgoing}</span>
</div>
{Object.keys(officeDashboard.documents.byStatus || {}).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(officeDashboard.documents.byStatus).map(([status, cnt]) => (
<span key={status} className="px-2 py-1 bg-slate-50 text-slate-600 rounded text-xs font-bold">{status}: {cnt}</span>
))}
</div>
)}
</section>
{/* 7. Заказы */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Package className="w-4 h-4" /> Заказы</h4>
<div className="flex flex-wrap gap-2">
<span className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold">Всего: {officeDashboard.orders.total}</span>
{Object.entries(officeDashboard.orders.byStatus || {}).map(([status, cnt]) => (
<span key={status} className="px-3 py-1.5 bg-slate-50 text-slate-700 rounded-lg text-xs font-bold">{status}: {cnt}</span>
))}
</div>
</section>
{/* 8. Совещания */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><Calendar className="w-4 h-4" /> Совещания</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">На эту неделю</p>
<p className="text-xl font-black text-slate-800">{officeDashboard.meetings.thisWeek}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Предстоящие</p>
<p className="text-xl font-black text-slate-800">{officeDashboard.meetings.upcoming}</p>
</div>
</div>
</section>
{/* 9. Переговорные */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><LayoutGrid className="w-4 h-4" /> Переговорные</h4>
<div className="p-3 bg-slate-50 rounded-xl inline-block">
<p className="text-xs text-slate-500 uppercase font-bold">Комнат</p>
<p className="text-xl font-black text-slate-800">{officeDashboard.meetingRooms.total}</p>
</div>
</section>
{/* 10. База знаний */}
<section>
<h4 className="text-sm font-bold text-slate-500 uppercase mb-3 flex items-center gap-2"><ClipboardList className="w-4 h-4" /> База знаний</h4>
<div className="flex flex-wrap gap-4">
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Категорий</p>
<p className="text-xl font-black text-slate-800">{officeDashboard.knowledgeBase.categoriesCount}</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl">
<p className="text-xs text-slate-500 uppercase font-bold">Статей</p>
<p className="text-xl font-black text-slate-800">{officeDashboard.knowledgeBase.articlesCount}</p>
</div>
</div>
</section>
</>
)}
</div>
)}
</DepartmentStatsModal>
)}
{/* Strategic Goals Modal */}
{isGoalsModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-fade-in" onClick={() => setIsGoalsModalOpen(false)}>
<div className="bg-slate-50 rounded-[2rem] w-full max-w-5xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
{/* Modal Header */}
<div className="p-8 border-b border-slate-200 flex justify-between items-start sticky top-0 bg-slate-50 z-10 rounded-t-[2rem]">
<div>
<div className="flex items-center gap-2 text-primary-600 mb-1">
<Target className="w-6 h-6" />
<span className="text-xs font-black uppercase tracking-[0.2em]">Стратегическое планирование</span>
</div>
<h3 className="text-3xl font-black text-slate-900">Цели компании 2024</h3>
</div>
<button
onClick={() => setIsGoalsModalOpen(false)}
className="p-3 bg-white hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-2xl transition-all shadow-sm border border-slate-200"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Modal Content */}
<div className="p-8">
{filteredGoals.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredGoals.map(goal => <GoalCard key={goal.id} goal={goal} />)}
</div>
) : (
<div className="py-20 text-center text-slate-400 italic">
Нет доступных целей для вашей роли
</div>
)}
</div>
{/* Modal Footer */}
<div className="p-8 bg-white border-t border-slate-200 rounded-b-[2rem] flex justify-between items-center">
<p className="text-sm text-slate-500 font-medium italic">Обновлено: {new Date().toLocaleDateString('ru-RU')}</p>
{currentUserRole === 'DIRECTOR' && (
<button className="flex items-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-xl font-bold text-sm hover:bg-slate-800 transition-all shadow-lg active:scale-95">
<Pencil className="w-4 h-4" /> Редактировать показатели
</button>
)}
</div>
</div>
</div>
)}
{/* Модалка «Предложить новость» */}
{isSuggestNewsModalOpen && (
<SuggestNewsModal
onClose={() => setIsSuggestNewsModalOpen(false)}
onSuccess={() => {
setIsSuggestNewsModalOpen(false);
backendApi.getCompanyNews({ status: 'published', limit: 5 }).then((list) => Array.isArray(list) && setCompanyNews(list));
}}
/>
)}
{/* Модалка просмотра новости (из уведомления) */}
{openNewsId != null && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-fade-in" onClick={() => onCloseNews?.()}>
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto shadow-2xl animate-slide-up" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-start sticky top-0 bg-white z-10 rounded-t-2xl">
<h3 className="text-lg font-black text-slate-900">Новость компании</h3>
<button type="button" onClick={() => onCloseNews?.()} className="p-2 text-slate-400 hover:text-slate-600 rounded-xl hover:bg-slate-100">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6">
{viewNewsLoading ? (
<div className="text-center py-8 text-slate-400 text-sm">Загрузка...</div>
) : viewNewsItem ? (
<>
<p className="font-bold text-slate-800 text-xl">{viewNewsItem.title}</p>
<p className="text-xs text-slate-500 mt-1">
{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}`}
</p>
{viewNewsItem.body && <div className="mt-4 text-slate-700 whitespace-pre-wrap">{viewNewsItem.body}</div>}
</>
) : (
<p className="text-slate-500 text-sm">Новость не найдена</p>
)}
</div>
</div>
</div>
)}
</div>
);
};
// ====================================================================================
// Suggest News Modal — предложить новость (черновик), выбор уведомлений по отделам и лично
// ====================================================================================
const DEPT_LABELS: Record<Department, string> = {
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<Department[]>([]);
const [notifyEmployeeIds, setNotifyEmployeeIds] = useState<string[]>([]);
const [employeesList, setEmployeesList] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center sticky top-0 bg-white z-10 rounded-t-2xl">
<h3 className="text-xl font-black text-slate-900">Предложить новость</h3>
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600 rounded-xl hover:bg-slate-100">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Заголовок *</label>
<input
type="text"
value={title}
onChange={(e) => 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="Заголовок новости"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Текст</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-y"
placeholder="Текст новости"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-2">Уведомить по отделам</label>
<div className="flex flex-wrap gap-2">
{(['production', 'pr', 'finance', 'development', 'legal', 'hr'] as Department[]).map((d) => (
<button
key={d}
type="button"
onClick={() => toggleDepartment(d)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${notifyDepartments.includes(d) ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
{DEPT_LABELS[d]}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-2">Уведомить лично (сотрудники)</label>
<div className="max-h-32 overflow-y-auto border border-slate-200 rounded-xl p-2 space-y-1">
{employeesList.length === 0 && <p className="text-xs text-slate-400 py-2">Список сотрудников загружается...</p>}
{employeesList.map((emp) => (
<label key={emp.id} className="flex items-center gap-2 py-1 px-2 rounded-lg hover:bg-slate-50 cursor-pointer">
<input
type="checkbox"
checked={notifyEmployeeIds.includes(emp.id)}
onChange={() => toggleEmployee(emp.id)}
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-slate-700">{emp.name}</span>
</label>
))}
</div>
</div>
{error && <p className="text-sm text-red-600 font-medium">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 py-3 border border-slate-200 rounded-xl font-bold text-slate-600 hover:bg-slate-50">
Отмена
</button>
<button type="submit" disabled={loading} className="flex-1 py-3 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 disabled:opacity-50">
{loading ? 'Сохранение...' : 'Сохранить как черновик'}
</button>
</div>
</form>
</div>
</div>
);
};