290 lines
16 KiB
TypeScript
Executable File
290 lines
16 KiB
TypeScript
Executable File
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>
|
||
);
|
||
};
|