Files
mkd/components/development/BulkBallotModal.tsx

290 lines
16 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useRef } from 'react';
import { X, Plus, Trash2, Upload, Download } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
export interface BulkBallotRow {
apartment: string;
ownerName: string;
area: number;
voteResult: '' | 'for' | 'against' | 'abstain';
notes: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
ossId: string;
ossAddress: string;
agendaItems?: string[];
}
const emptyRow = (): BulkBallotRow => ({
apartment: '',
ownerName: '',
area: 0,
voteResult: '',
notes: '',
});
export const BulkBallotModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, ossId, ossAddress, agendaItems: _agendaItems }) => {
const [rows, setRows] = useState<BulkBallotRow[]>([emptyRow()]);
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
if (!isOpen) return null;
const addRow = () => setRows((r) => [...r, emptyRow()]);
const removeRow = (index: number) => {
if (rows.length <= 1) return;
setRows((r) => r.filter((_, i) => i !== index));
};
const updateRow = (index: number, field: keyof BulkBallotRow, value: string | number) => {
setRows((r) => {
const next = [...r];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const validRows = rows.filter((r) => r.apartment.trim() && r.area > 0);
const handleImportCSV = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result);
const lines = text.split(/\r?\n/).filter((line) => line.trim());
if (lines.length < 2) return;
const sep = lines[0].includes(';') ? ';' : ',';
const cols = (line: string) => line.split(sep).map((c) => c.replace(/^"|"$/g, '').trim());
const headerCols = cols(lines[0]).map((c) => c.toLowerCase());
const colIndex = (names: string[]) => headerCols.findIndex((h) => names.some((n) => h.includes(n)));
const getCol = (names: string[], arr: string[]) => {
const idx = colIndex(names);
return idx >= 0 && arr[idx] !== undefined ? arr[idx] : '';
};
const newRows: BulkBallotRow[] = [];
for (let i = 1; i < lines.length; i++) {
const arr = cols(lines[i]);
const apartment = getCol(['apartment', 'помещение', 'кв'], arr) || arr[0] || '';
const ownerName = getCol(['owner_name', 'owner', 'собственник', 'фио'], arr) || arr[1] || '';
const areaStr = getCol(['area', 'площадь', 'площадь (м²)', 'м²'], arr) || arr[2] || '0';
const area = parseFloat(areaStr.replace(/,/, '.')) || 0;
const voteStr = (getCol(['vote_result', 'vote', 'голос', 'результат'], arr) || arr[3] || '').toLowerCase();
let voteResult: '' | 'for' | 'against' | 'abstain' = '';
if (voteStr === 'for' || voteStr === 'за') voteResult = 'for';
else if (voteStr === 'against' || voteStr === 'против') voteResult = 'against';
else if (voteStr === 'abstain' || voteStr === 'воздержался') voteResult = 'abstain';
const notes = getCol(['notes', 'примечания'], arr) || arr[4] || '';
if (apartment.trim() || area > 0) {
newRows.push({ apartment, ownerName, area, voteResult, notes });
}
}
if (newRows.length > 0) setRows(newRows);
};
reader.readAsText(file, 'UTF-8');
e.target.value = '';
};
const handleDownloadTemplate = () => {
const header = 'Помещение;Собственник;Площадь (м²);Голос;Примечания';
const sample = '15;Иванов И.И.;45.2;За;\n25;Петров П.П.;52;Против;\n101;;38.5;Воздержался;';
const csv = [header, sample].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = аблон_бюллетеней.csv';
link.click();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (validRows.length === 0) {
alert('Добавьте хотя бы одну строку с помещением и площадью > 0');
return;
}
try {
setLoading(true);
const ballots = validRows.map((r) => ({
apartment: r.apartment.trim(),
owner_name: r.ownerName.trim() || undefined,
area: r.area,
vote_result: r.voteResult || undefined,
notes: r.notes.trim() || undefined,
}));
const result = await backendApi.submitOSSBallotsBulk(ossId, ballots);
alert(`Внесено: ${result.inserted} новых, обновлено: ${result.updated}. Всего: ${result.total}`);
window.dispatchEvent(new CustomEvent('mkd-oss-changed'));
onSuccess();
onClose();
setRows([emptyRow()]);
} catch (error: any) {
console.error('Error submitting bulk ballots:', error);
const msg = error?.response?.data?.error || error?.message || error?.error || 'Ошибка при загрузке бюллетеней';
alert(msg);
} finally {
setLoading(false);
}
};
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-4xl shadow-2xl animate-slide-up max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center rounded-t-2xl">
<div>
<h3 className="text-lg font-black text-slate-800">Массовый ввод бюллетеней</h3>
<p className="text-xs text-slate-500 mt-1">{ossAddress}</p>
</div>
<button type="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 className="px-6 py-4 flex gap-2 flex-wrap border-b border-slate-100">
<button
type="button"
onClick={addRow}
className="px-3 py-2 bg-slate-100 text-slate-700 rounded-xl text-xs font-bold flex items-center gap-2 hover:bg-slate-200"
>
<Plus className="w-4 h-4" /> Добавить строку
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3 py-2 bg-primary-50 text-primary-700 rounded-xl text-xs font-bold flex items-center gap-2 hover:bg-primary-100"
>
<Upload className="w-4 h-4" /> Импорт CSV
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt"
className="hidden"
onChange={handleImportCSV}
/>
<button
type="button"
onClick={handleDownloadTemplate}
className="px-3 py-2 bg-slate-100 text-slate-600 rounded-xl text-xs font-bold flex items-center gap-2 hover:bg-slate-200"
>
<Download className="w-4 h-4" /> Скачать шаблон
</button>
</div>
<form onSubmit={handleSubmit} className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b-2 border-slate-200">
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Помещение *</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Собственник</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Площадь (м²) *</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Голос</th>
<th className="text-left py-2 px-2 text-[10px] font-black text-slate-500 uppercase">Примечания</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index} className="border-b border-slate-100">
<td className="py-1.5 px-2">
<input
type="text"
value={row.apartment}
onChange={(e) => updateRow(index, 'apartment', e.target.value)}
placeholder="15, 25А"
className="w-full p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-2">
<input
type="text"
value={row.ownerName}
onChange={(e) => updateRow(index, 'ownerName', e.target.value)}
placeholder="ФИО"
className="w-full p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-2">
<input
type="number"
min="0"
step="0.01"
value={row.area || ''}
onChange={(e) => updateRow(index, 'area', parseFloat(e.target.value) || 0)}
placeholder="0"
className="w-20 p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-2">
<select
value={row.voteResult}
onChange={(e) => updateRow(index, 'voteResult', e.target.value as any)}
className="p-1.5 rounded-lg border border-slate-200 text-sm bg-white"
>
<option value=""></option>
<option value="for">За</option>
<option value="against">Против</option>
<option value="abstain">Воздержался</option>
</select>
</td>
<td className="py-1.5 px-2">
<input
type="text"
value={row.notes}
onChange={(e) => updateRow(index, 'notes', e.target.value)}
placeholder="—"
className="w-full p-1.5 rounded-lg border border-slate-200 text-sm"
/>
</td>
<td className="py-1.5 px-1">
<button
type="button"
onClick={() => removeRow(index)}
disabled={rows.length === 1}
className="p-1.5 text-slate-400 hover:text-red-600 disabled:opacity-40"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-slate-400 mt-2">
Строки без помещения или с площадью 0 не отправляются. Формат CSV: разделитель «;», колонки: Помещение;Собственник;Площадь (м²);Голос (За/Против/Воздержался);Примечания
</p>
</div>
<div className="sticky bottom-0 bg-white border-t border-slate-200 px-6 py-4 flex justify-between items-center rounded-b-2xl">
<span className="text-xs text-slate-500">
К отправке: <strong>{validRows.length}</strong> строк
</span>
<div className="flex gap-2">
<button type="button" onClick={onClose} className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200">
Отмена
</button>
<button
type="submit"
disabled={loading || validRows.length === 0}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Отправка...' : 'Внести бюллетени'}
</button>
</div>
</div>
</form>
</div>
</div>
);
};