Files
mkd/components/development/OSSRegistryModal.tsx

340 lines
20 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};