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

294 lines
17 KiB
TypeScript
Executable File
Raw Permalink 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 } 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>
);
};