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

290 lines
16 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, 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>
);
};