Files
mkd/components/development/OSSRegistryModal.tsx
2026-02-04 00:17:04 +05:00

340 lines
20 KiB
TypeScript
Executable File
Raw 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 { 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>
);
};