340 lines
20 KiB
TypeScript
Executable File
340 lines
20 KiB
TypeScript
Executable File
import React, { useState, useEffect } from 'react';
|
||
import { X, Download, CheckCircle2, XCircle, Minus, FileText, User, Home, Calendar } from 'lucide-react';
|
||
import { backendApi } from '../../services/apiClient';
|
||
|
||
interface RegistryItem {
|
||
id: number;
|
||
apartment: string;
|
||
ownerName?: string;
|
||
area: number;
|
||
ballotSubmitted: boolean;
|
||
ballotDate?: string;
|
||
voteResult?: 'for' | 'against' | 'abstain';
|
||
votesByItem?: Record<string, string>;
|
||
notes?: string;
|
||
}
|
||
|
||
interface Props {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
ossId: string;
|
||
ossAddress: string;
|
||
}
|
||
|
||
export const OSSRegistryModal: React.FC<Props> = ({ isOpen, onClose, ossId, ossAddress }) => {
|
||
const [registry, setRegistry] = useState<RegistryItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (isOpen && ossId) {
|
||
fetchRegistry();
|
||
}
|
||
}, [isOpen, ossId]);
|
||
|
||
const fetchRegistry = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const data = await backendApi.getOSSRegistry(ossId);
|
||
setRegistry(Array.isArray(data) ? data : []);
|
||
} catch (err: any) {
|
||
console.error('Error fetching registry:', err);
|
||
setError(err?.message || 'Ошибка при загрузке реестра');
|
||
setRegistry([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
|
||
// Статистика
|
||
const totalEntries = registry.length;
|
||
const submittedCount = registry.filter(r => r.ballotSubmitted).length;
|
||
const totalArea = registry.reduce((sum, r) => sum + (r.area || 0), 0);
|
||
const submittedArea = registry.filter(r => r.ballotSubmitted).reduce((sum, r) => sum + (r.area || 0), 0);
|
||
const votesFor = registry.filter(r => r.voteResult === 'for').length;
|
||
const votesAgainst = registry.filter(r => r.voteResult === 'against').length;
|
||
const votesAbstain = registry.filter(r => r.voteResult === 'abstain').length;
|
||
|
||
const getVoteIcon = (voteResult?: string) => {
|
||
switch (voteResult) {
|
||
case 'for':
|
||
return <CheckCircle2 className="w-4 h-4 text-emerald-600" />;
|
||
case 'against':
|
||
return <XCircle className="w-4 h-4 text-red-600" />;
|
||
case 'abstain':
|
||
return <Minus className="w-4 h-4 text-amber-600" />;
|
||
default:
|
||
return <div className="w-4 h-4 rounded-full border-2 border-slate-300" />;
|
||
}
|
||
};
|
||
|
||
const getVoteLabel = (voteResult?: string) => {
|
||
switch (voteResult) {
|
||
case 'for':
|
||
return 'За';
|
||
case 'against':
|
||
return 'Против';
|
||
case 'abstain':
|
||
return 'Воздержался';
|
||
default:
|
||
return '—';
|
||
}
|
||
};
|
||
|
||
const formatVotesByItem = (votesByItem?: Record<string, string>) => {
|
||
if (!votesByItem || Object.keys(votesByItem).length === 0) return '—';
|
||
const parts = Object.entries(votesByItem)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([idx, v]) => `${Number(idx) + 1}: ${getVoteLabel(v)}`);
|
||
return parts.join('; ');
|
||
};
|
||
|
||
const handleExport = () => {
|
||
// Экспорт в CSV
|
||
const headers = ['Помещение', 'Собственник', 'Площадь (м²)', 'Бюллетень подан', 'Дата подачи', 'Результат голосования', 'Примечания'];
|
||
const rows = registry.map(r => [
|
||
r.apartment,
|
||
r.ownerName || '—',
|
||
r.area.toFixed(2),
|
||
r.ballotSubmitted ? 'Да' : 'Нет',
|
||
r.ballotDate || '—',
|
||
getVoteLabel(r.voteResult),
|
||
r.notes || '—'
|
||
]);
|
||
|
||
const csvContent = [
|
||
headers.join(','),
|
||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||
].join('\n');
|
||
|
||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = `реестр_ОСС_${ossAddress.replace(/[^a-zA-Zа-яА-Я0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.csv`;
|
||
link.click();
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className="bg-white rounded-2xl w-full max-w-6xl shadow-2xl animate-slide-up max-h-[90vh] flex flex-col"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-start rounded-t-2xl">
|
||
<div className="flex-1">
|
||
<h3 className="text-xl font-black text-slate-800 mb-1">Реестр участников ОСС</h3>
|
||
<p className="text-sm text-slate-500 font-medium">{ossAddress}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleExport}
|
||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors flex items-center gap-2"
|
||
title="Экспорт в CSV"
|
||
>
|
||
<Download className="w-4 h-4" /> Экспорт
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
|
||
>
|
||
<X className="w-5 h-5 text-slate-400" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Statistics */}
|
||
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200">
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-white p-3 rounded-xl border border-slate-200">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Всего записей</p>
|
||
<p className="text-xl font-black text-slate-800">{totalEntries}</p>
|
||
</div>
|
||
<div className="bg-white p-3 rounded-xl border border-slate-200">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Бюллетеней подано</p>
|
||
<p className="text-xl font-black text-primary-600">{submittedCount} / {totalEntries}</p>
|
||
</div>
|
||
<div className="bg-white p-3 rounded-xl border border-slate-200">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Площадь собрана</p>
|
||
<p className="text-xl font-black text-emerald-600">{submittedArea.toFixed(2)} м²</p>
|
||
</div>
|
||
<div className="bg-white p-3 rounded-xl border border-slate-200">
|
||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Общая площадь</p>
|
||
<p className="text-xl font-black text-slate-800">{totalArea.toFixed(2)} м²</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Vote Statistics */}
|
||
{(votesFor > 0 || votesAgainst > 0 || votesAbstain > 0) && (
|
||
<div className="mt-4 flex gap-4 flex-wrap">
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||
<span className="font-bold text-slate-700">За:</span>
|
||
<span className="font-black text-emerald-600">{votesFor}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<XCircle className="w-4 h-4 text-red-600" />
|
||
<span className="font-bold text-slate-700">Против:</span>
|
||
<span className="font-black text-red-600">{votesAgainst}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<Minus className="w-4 h-4 text-amber-600" />
|
||
<span className="font-bold text-slate-700">Воздержались:</span>
|
||
<span className="font-black text-amber-600">{votesAbstain}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||
{loading ? (
|
||
<div className="text-center py-20 text-slate-400">
|
||
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
|
||
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка реестра...</p>
|
||
</div>
|
||
) : error ? (
|
||
<div className="text-center py-20 text-red-400">
|
||
<XCircle className="w-12 h-12 mx-auto mb-4" />
|
||
<p className="text-sm font-bold">{error}</p>
|
||
<button
|
||
onClick={fetchRegistry}
|
||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors"
|
||
>
|
||
Попробовать снова
|
||
</button>
|
||
</div>
|
||
) : registry.length === 0 ? (
|
||
<div className="text-center py-20 text-slate-300">
|
||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||
<p className="text-sm font-bold uppercase tracking-widest">Реестр пуст</p>
|
||
<p className="text-xs text-slate-400 mt-2">Бюллетени еще не были внесены</p>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b-2 border-slate-200">
|
||
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
<div className="flex items-center gap-2">
|
||
<Home className="w-4 h-4" /> Помещение
|
||
</div>
|
||
</th>
|
||
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
<div className="flex items-center gap-2">
|
||
<User className="w-4 h-4" /> Собственник
|
||
</div>
|
||
</th>
|
||
<th className="text-right py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
Площадь (м²)
|
||
</th>
|
||
<th className="text-center py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
Статус
|
||
</th>
|
||
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
<div className="flex items-center gap-2">
|
||
<Calendar className="w-4 h-4" /> Дата подачи
|
||
</div>
|
||
</th>
|
||
<th className="text-center py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
Голос
|
||
</th>
|
||
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
По пунктам
|
||
</th>
|
||
<th className="text-left py-3 px-4 text-[10px] font-black text-slate-500 uppercase tracking-wider">
|
||
Примечания
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{registry.map((item, index) => (
|
||
<tr
|
||
key={item.id}
|
||
className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${
|
||
item.ballotSubmitted ? 'bg-emerald-50/30' : ''
|
||
}`}
|
||
>
|
||
<td className="py-3 px-4">
|
||
<span className="font-black text-slate-800">{item.apartment}</span>
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<span className="text-sm text-slate-700">
|
||
{item.ownerName || <span className="text-slate-400 italic">Не указано</span>}
|
||
</span>
|
||
</td>
|
||
<td className="py-3 px-4 text-right">
|
||
<span className="font-bold text-slate-800">{item.area.toFixed(2)}</span>
|
||
</td>
|
||
<td className="py-3 px-4 text-center">
|
||
{item.ballotSubmitted ? (
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-100 text-emerald-700 text-[10px] font-black uppercase">
|
||
<CheckCircle2 className="w-3.5 h-3.5" /> Подано
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-slate-100 text-slate-500 text-[10px] font-black uppercase">
|
||
Не подано
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<span className="text-sm text-slate-600">
|
||
{item.ballotDate ? new Date(item.ballotDate).toLocaleDateString('ru-RU') : '—'}
|
||
</span>
|
||
</td>
|
||
<td className="py-3 px-4 text-center">
|
||
<div className="flex items-center justify-center gap-1.5">
|
||
{getVoteIcon(item.voteResult)}
|
||
<span className={`text-xs font-bold ${
|
||
item.voteResult === 'for' ? 'text-emerald-600' :
|
||
item.voteResult === 'against' ? 'text-red-600' :
|
||
item.voteResult === 'abstain' ? 'text-amber-600' :
|
||
'text-slate-400'
|
||
}`}>
|
||
{getVoteLabel(item.voteResult)}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<span className="text-xs text-slate-600 max-w-xs block" title={formatVotesByItem(item.votesByItem)}>
|
||
{formatVotesByItem(item.votesByItem)}
|
||
</span>
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<span className="text-xs text-slate-500 max-w-xs truncate block" title={item.notes || ''}>
|
||
{item.notes || '—'}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="sticky bottom-0 bg-slate-50 border-t border-slate-200 px-6 py-4 rounded-b-2xl flex justify-between items-center">
|
||
<div className="text-xs text-slate-500">
|
||
<span className="font-bold">Всего записей:</span> {totalEntries} |
|
||
<span className="font-bold ml-2">Подано:</span> {submittedCount} |
|
||
<span className="font-bold ml-2">Площадь:</span> {submittedArea.toFixed(2)} м² из {totalArea.toFixed(2)} м²
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors"
|
||
>
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|