Files
mkd/components/building/Overview.tsx

294 lines
17 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { Building, BuildingTask, Employee } from '../../types';
import {
Users,
Wallet,
Activity,
TrendingDown,
CheckCircle2,
Calendar,
User,
Flag,
Clock,
MessageSquare,
Camera,
Image as ImageIcon
} from 'lucide-react';
import { TaskModal } from './TaskModal';
import { OutagesJournal } from '../applications/OutagesJournal';
import { backendApi } from '../../services/apiClient';
import { storageService } from '../../services/storageService';
export const Overview: React.FC<{ building: Building, onNavigate: (tab: any) => void, setBuilding?: React.Dispatch<React.SetStateAction<Building>> }> = ({ building, onNavigate, setBuilding }) => {
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<BuildingTask | null>(null);
const [employees, setEmployees] = useState<Employee[]>([]);
useEffect(() => {
fetchEmployees();
}, []);
const fetchEmployees = async () => {
try {
const fetchedEmployees = await backendApi.getEmployees();
setEmployees(fetchedEmployees);
} catch (error) {
console.error('Failed to fetch employees:', error);
}
};
const handleTaskClick = (task: BuildingTask) => {
setSelectedTask(task);
setIsTaskModalOpen(true);
};
const handleNewTask = () => {
setSelectedTask(null);
setIsTaskModalOpen(true);
};
const tasks = building.tasks ?? [];
const handleSaveTask = (task: BuildingTask) => {
const updatedTasks = task.id && tasks.find(t => t.id === task.id)
? tasks.map(t => t.id === task.id ? task : t)
: [...tasks, task];
const updatedBuilding = {
...building,
tasks: updatedTasks,
isDirty: true
};
if (setBuilding) {
setBuilding(updatedBuilding);
}
// Сохраняем в localStorage
storageService.saveBuildingData(updatedBuilding);
// Пытаемся сохранить на сервер
backendApi.updateBuilding(updatedBuilding).catch(err => {
console.error('Failed to save task to backend:', err);
});
};
const formatDate = (dateString: string) => {
if (!dateString) return '—';
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
};
const getPriorityColor = (priority: BuildingTask['priority']) => {
switch (priority) {
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';
case 'low': return 'bg-slate-100 text-slate-600';
default: return 'bg-slate-100 text-slate-600';
}
};
const getPriorityLabel = (priority: BuildingTask['priority']) => {
switch (priority) {
case 'urgent': return 'Срочно';
case 'high': return 'Высокий';
case 'medium': return 'Средний';
case 'low': return 'Низкий';
default: return priority;
}
};
// Текущий месяц для блока «Статус работ»
const MONTH_NAMES = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const now = new Date();
const currentYear = now.getFullYear();
const currentMonthIndex = now.getMonth();
const currentMonthName = MONTH_NAMES[currentMonthIndex];
const currentMonthNameLower = currentMonthName.charAt(0).toLowerCase() + currentMonthName.slice(1);
const annualPlan = building.annualPlan ?? [];
const plansForCurrentMonth = annualPlan.filter(
p => (p.month === currentMonthName || p.month?.toLowerCase() === currentMonthNameLower) && (p.year === currentYear || p.year == null)
);
return (
<div className="space-y-6 animate-fade-in">
<div className="bg-white rounded-3xl shadow-sm border border-slate-200 overflow-hidden">
<div className="h-56 w-full bg-slate-200 relative">
<img src={building.imageUrl} alt="Building" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent p-8 flex flex-col justify-end">
<h2 className="text-3xl font-black text-white leading-tight">{building.passport?.address ?? 'Адрес не указан'}</h2>
<p className="text-white/70 text-sm mt-1 font-bold uppercase tracking-widest">
{building.passport?.general?.constructionYear ?? '—'} г. {building.passport?.general?.floors ?? '—'} эт. {building.passport?.general?.totalArea ?? '—'} м²
</p>
</div>
</div>
<div className="p-6 grid grid-cols-2 md:grid-cols-4 gap-6 divide-x divide-slate-100 bg-white">
<div className="text-center px-2 cursor-pointer group" onClick={() => onNavigate('accounts')}>
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">Жителей</p>
<p className="text-2xl font-black text-slate-800 group-hover:text-primary-600 transition-colors">
{(building.accounts ?? []).reduce((sum, acc) => {
const count = acc.registered?.length || acc.registeredCount || 0;
return sum + (typeof count === 'number' ? count : 0);
}, 0)}
</p>
</div>
<div className="text-center px-4 cursor-pointer group" onClick={() => onNavigate('finance')}>
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">Баланс</p>
<p className={`text-2xl font-black group-hover:scale-105 transition-transform ${(building.financials?.balance ?? 0) >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{((building.financials?.balance ?? 0) / 1000).toFixed(0)}k
</p>
</div>
<div className="text-center px-4 cursor-pointer group" onClick={() => onNavigate('inspections')}>
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">Состояние</p>
<p className="text-2xl font-black text-slate-800 flex items-center justify-center gap-2">
<Activity className="w-5 h-5 text-amber-500"/> <span className="hidden sm:inline">Удовл.</span>
</p>
</div>
<div className="text-center px-4">
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">NPS</p>
<p className={`text-2xl font-black ${(building.nps ?? 0) >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
{(building.nps ?? 0) > 0 ? '+' : ''}{building.nps ?? 0}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm">
<h3 className="font-black text-slate-800 text-[10px] uppercase tracking-[0.2em] mb-6 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-primary-500"/> Оперативный план
</h3>
<div className="space-y-4">
{tasks.map(task => (
<div
key={task.id}
className="flex items-start gap-4 p-3 hover:bg-slate-50 rounded-2xl transition-colors border border-transparent hover:border-slate-100"
>
<button
onClick={(e) => {
e.stopPropagation();
const newStatus = task.status === 'done' ? 'new' : 'done';
const updatedTask = {
...task,
status: newStatus as BuildingTask['status'],
updatedAt: new Date().toISOString()
};
handleSaveTask(updatedTask);
}}
className={`mt-1 w-5 h-5 rounded-full border-2 flex-shrink-0 cursor-pointer transition-all flex items-center justify-center ${
task.status === 'done'
? 'bg-emerald-500 border-emerald-500 hover:bg-emerald-600'
: 'border-slate-300 hover:border-primary-400 bg-white'
}`}
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
onClick={() => handleTaskClick(task)}
className="flex-1 min-w-0 cursor-pointer"
>
<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" title="Крайняя дата">
<Calendar className="w-3 h-3"/> {formatDate(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.assignedToName && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1" title="Ответственный">
<User className="w-3 h-3"/> {task.assignedToName}
</span>
)}
{task.createdByName && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1" title="Постановщик">
{task.createdByName}
</span>
)}
{task.estimatedHours && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1">
<Clock className="w-3 h-3"/> {task.estimatedHours}ч
</span>
)}
{(task.comments?.length ?? 0) > 0 && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1" title="Комментарии">
<MessageSquare className="w-3 h-3"/> {task.comments!.length} коммент.
</span>
)}
{task.requirePhotoReport !== false && (
<span className="text-[10px] font-bold flex items-center gap-1" title={task.photoReportId ? 'Фото отчёт приложен' : 'Требуется фото отчёт'}>
{task.photoReportId ? (
<ImageIcon className="w-3 h-3 text-emerald-600"/>
) : (
<Camera className="w-3 h-3 text-amber-500"/>
)}
{task.photoReportId ? 'Фото приложен' : 'Нужен фото'}
</span>
)}
</div>
</div>
</div>
))}
{tasks.length === 0 && (
<p className="text-center py-6 text-slate-400 italic text-sm">Нет задач</p>
)}
<button
onClick={handleNewTask}
className="w-full py-3 text-[10px] font-black uppercase tracking-widest text-primary-600 border-2 border-dashed border-primary-100 rounded-2xl hover:bg-primary-50 transition-colors mt-2"
>
+ Новая задача
</button>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm">
<h3 className="font-black text-slate-800 text-[10px] uppercase tracking-[0.2em] mb-6 flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-500"/> Статус работ за {currentMonthNameLower}
</h3>
<div className="space-y-6">
{plansForCurrentMonth.map(plan => (
<div key={plan.id}>
<div className="flex justify-between items-end mb-2">
<span className="font-bold text-slate-700 text-xs">{plan.workName}</span>
<span className="text-[10px] font-black text-primary-600 bg-primary-50 px-1.5 rounded">{plan.progress}%</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5 overflow-hidden">
<div className={`h-full transition-all duration-1000 ${plan.progress === 100 ? 'bg-emerald-500' : 'bg-primary-500'}`} style={{ width: `${Math.min(100, Math.max(0, plan.progress ?? 0))}%` }}></div>
</div>
</div>
))}
{plansForCurrentMonth.length === 0 && <p className="text-center py-10 text-slate-400 italic text-sm">Работ на {currentMonthNameLower} не запланировано</p>}
</div>
</div>
{/* Журнал отключений по дому */}
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm">
<OutagesJournal buildingId={building.id} compact />
</div>
</div>
{isTaskModalOpen && (
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => {
setIsTaskModalOpen(false);
setSelectedTask(null);
}}
onSave={handleSaveTask}
task={selectedTask}
buildingId={building.id}
employees={employees}
/>
)}
</div>
);
};