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