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