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