Initial commit MKD fixes
This commit is contained in:
339
components/development/OSSRegistryModal.tsx
Executable file
339
components/development/OSSRegistryModal.tsx
Executable file
@@ -0,0 +1,339 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user