Files
mkd/components/development/OSSMaster.tsx

290 lines
17 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect, useCallback } from 'react';
import { DevOSSSession } from '../../types';
import { Vote, Plus, FileText, CheckCircle2, AlertCircle, Calendar, UserPlus, Pencil, Filter } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
import { CreateOSSModal } from './CreateOSSModal';
import { EditOSSModal } from './EditOSSModal';
import { AddBallotModal } from './AddBallotModal';
import { BulkBallotModal } from './BulkBallotModal';
import { OSSRegistryModal } from './OSSRegistryModal';
const CACHE_KEY = 'mkd_dev_oss_cache';
type OSSStatusFilter = '' | 'planned' | 'active' | 'completed';
export const OSSMaster: React.FC = () => {
const cached = readCache<DevOSSSession[]>(CACHE_KEY, []);
const [ossSessions, setOssSessions] = useState<DevOSSSession[]>(cached);
const [loading, setLoading] = useState(cached.length === 0);
const [statusFilter, setStatusFilter] = useState<OSSStatusFilter>('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingSession, setEditingSession] = useState<DevOSSSession | null>(null);
const [isBallotModalOpen, setIsBallotModalOpen] = useState(false);
const [isBulkBallotModalOpen, setIsBulkBallotModalOpen] = useState(false);
const [isRegistryModalOpen, setIsRegistryModalOpen] = useState(false);
const [selectedOSS, setSelectedOSS] = useState<{id: string, address: string} | null>(null);
const fetchOSS = useCallback(async (showSpinner = true) => {
try {
if (showSpinner && cached.length === 0) setLoading(true);
const params = statusFilter ? { status: statusFilter } : undefined;
const data = await backendApi.getDevelopmentOSS(params);
const formattedData = Array.isArray(data) ? data : [];
setOssSessions(formattedData);
if (!statusFilter) saveCache(CACHE_KEY, formattedData);
} catch (error) {
console.error('Error fetching OSS sessions:', error);
setOssSessions([]);
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchOSS();
}, [fetchOSS]);
useEffect(() => {
const onRefresh = () => fetchOSS(false);
window.addEventListener(REFRESH_EVENTS.oss, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.oss, onRefresh);
}, [fetchOSS]);
useEffect(() => {
const interval = setInterval(() => fetchOSS(false), 10 * 1000);
return () => clearInterval(interval);
}, [fetchOSS]);
const handleCreateOSS = () => {
setIsCreateModalOpen(true);
};
const handleSubmitBallot = (ossId: string, ossAddress: string) => {
setSelectedOSS({ id: ossId, address: ossAddress });
setIsBallotModalOpen(true);
};
const handleBulkBallot = (ossId: string, ossAddress: string) => {
setSelectedOSS({ id: ossId, address: ossAddress });
setIsBulkBallotModalOpen(true);
};
const handleViewRegistry = (ossId: string, ossAddress: string) => {
setSelectedOSS({ id: ossId, address: ossAddress });
setIsRegistryModalOpen(true);
};
const handleEditOSS = (oss: DevOSSSession) => {
setEditingSession(oss);
setIsEditModalOpen(true);
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-wrap justify-between items-center gap-3 px-1">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">ОСС</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Filter className="w-4 h-4 text-slate-400" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as OSSStatusFilter)}
className="text-[10px] font-bold text-slate-600 bg-white border border-slate-200 rounded-lg py-1.5 px-2.5 focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Все</option>
<option value="planned">Запланировано</option>
<option value="active">Активные</option>
<option value="completed">Завершённые</option>
</select>
</div>
<button
onClick={handleCreateOSS}
className="text-[10px] font-black text-primary-600 uppercase bg-primary-50 px-3 py-1.5 rounded-lg border border-primary-100 flex items-center gap-1.5 active:scale-95 transition-all"
>
<Plus className="w-3.5 h-3.5"/> Создать собрание
</button>
</div>
</div>
<div className="space-y-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>
) : ossSessions.length === 0 ? (
<div className="text-center py-20 text-slate-300">
<div className="w-10 h-10 border-2 border-dashed border-slate-200 rounded-full mx-auto mb-2" />
<p className="text-[10px] font-bold uppercase tracking-widest">{statusFilter ? 'Нет ОСС по выбранному фильтру' : 'Нет ОСС'}</p>
</div>
) : (
ossSessions.map(oss => {
const percent = Math.min(100, Math.round((oss.quorumCurrent / oss.quorumTotal) * 100));
const isPassed = percent > 50;
return (
<div key={oss.id} className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm relative overflow-hidden group hover:border-primary-200 transition-all">
{/* Animated Background Pulse for Active */}
{oss.status === 'active' && <div className="absolute top-0 right-0 w-32 h-32 bg-emerald-400/5 rounded-full -mr-16 -mt-16 animate-pulse" />}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className={`p-4 rounded-2xl ${oss.status === 'active' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-400'}`}>
<Vote className="w-7 h-7"/>
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<h4 className="font-black text-slate-800 text-base">{oss.address}</h4>
<span className={`text-[9px] font-black px-2 py-0.5 rounded uppercase ${
oss.status === 'active' ? 'bg-emerald-100 text-emerald-600' :
oss.status === 'completed' ? 'bg-slate-200 text-slate-600' : 'bg-slate-100 text-slate-500'
}`}>
{oss.status === 'active' ? 'Идет голосование' : oss.status === 'completed' ? 'Завершено' : 'Запланировано'}
</span>
</div>
<p className="text-xs text-slate-500 font-medium">{oss.type === 'annual' ? 'Ежегодное отчетное собрание' : (oss.description?.trim() || 'Внеочередное ОСС')}</p>
{(oss.agendaItems?.length ?? 0) > 0 && (
<p className="text-[10px] text-slate-400 mt-0.5">Пунктов повестки: {oss.agendaItems!.length}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-slate-400">
<Calendar className="w-4 h-4"/> до {oss.endDate?.slice(0, 10)}
</div>
<button
onClick={() => handleEditOSS(oss)}
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-xl transition-colors"
title="Редактировать ОСС"
>
<Pencil className="w-4 h-4"/>
</button>
</div>
</div>
{/* Quorum Progress */}
<div className="mb-6">
<div className="flex justify-between items-end mb-2">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Текущий кворум (м²)</span>
<span className={`text-sm font-black ${isPassed ? 'text-emerald-600' : 'text-slate-800'}`}>
{percent}% <span className="text-slate-400 font-bold text-[10px] ml-1">/ 50% + 1</span>
</span>
</div>
<div className="w-full h-4 bg-slate-100 rounded-full overflow-hidden relative shadow-inner">
<div className="absolute top-0 bottom-0 left-1/2 w-0.5 bg-slate-300 z-10" title="Порог кворума"></div>
<div className={`h-full rounded-full transition-all duration-1000 ${isPassed ? 'bg-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.4)]' : 'bg-amber-400 shadow-[0_0_15px_rgba(251,191,36,0.3)]'}`} style={{ width: `${percent}%` }}></div>
</div>
<div className="flex justify-between mt-2 text-[10px] text-slate-400 font-bold uppercase tracking-tight">
<span>{oss.quorumCurrent.toLocaleString()} м² собрано</span>
<span>Цель: {oss.quorumTotal.toLocaleString()} м²</span>
</div>
</div>
{/* Actions List */}
<div className="flex gap-2 pt-4 border-t border-slate-50 flex-wrap">
<button
onClick={() => handleSubmitBallot(oss.id, oss.address)}
disabled={oss.status === 'completed'}
className="py-3 px-4 bg-slate-900 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4"/> Один бюллетень
</button>
<button
onClick={() => handleBulkBallot(oss.id, oss.address)}
disabled={oss.status === 'completed'}
className="flex-1 py-3 bg-primary-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed min-w-[140px]"
>
<FileText className="w-4 h-4"/> Массовый ввод / CSV
</button>
<button
onClick={() => handleViewRegistry(oss.id, oss.address)}
className="px-4 py-3 bg-slate-100 text-slate-600 rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 hover:bg-slate-200 transition-colors"
>
<FileText className="w-4 h-4"/> Реестр
</button>
<button
onClick={() => handleEditOSS(oss)}
className="p-3 bg-slate-50 text-slate-400 rounded-2xl hover:text-primary-600 transition-colors"
title="Редактировать ОСС"
>
<Pencil className="w-4 h-4"/>
</button>
</div>
</div>
);
})
)}
</div>
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-100 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-500 shrink-0 mt-0.5"/>
<p className="text-[11px] text-blue-700 leading-snug font-medium">
С 2024 года бюллетени ОСС должны быть загружены в ГИС ЖКХ в течение 5 рабочих дней после завершения голосования. Контролируйте сроки передачи оригиналов в ГЖИ.
</p>
</div>
{/* Modals */}
<CreateOSSModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={() => fetchOSS()}
/>
<EditOSSModal
isOpen={isEditModalOpen}
onClose={() => { setIsEditModalOpen(false); setEditingSession(null); }}
onSuccess={() => { fetchOSS(); setIsEditModalOpen(false); setEditingSession(null); }}
session={editingSession}
/>
{selectedOSS && (
<>
<AddBallotModal
isOpen={isBallotModalOpen}
onClose={() => {
setIsBallotModalOpen(false);
if (!isRegistryModalOpen && !isBulkBallotModalOpen) {
setSelectedOSS(null);
}
}}
onSuccess={() => {
fetchOSS();
}}
ossId={selectedOSS.id}
ossAddress={selectedOSS.address}
agendaItems={(ossSessions.find(s => s.id === selectedOSS.id) as any)?.agendaItems ?? []}
/>
<BulkBallotModal
isOpen={isBulkBallotModalOpen}
onClose={() => {
setIsBulkBallotModalOpen(false);
if (!isRegistryModalOpen && !isBallotModalOpen) {
setSelectedOSS(null);
}
}}
onSuccess={() => {
fetchOSS();
}}
ossId={selectedOSS.id}
ossAddress={selectedOSS.address}
agendaItems={(ossSessions.find(s => s.id === selectedOSS.id) as any)?.agendaItems ?? []}
/>
<OSSRegistryModal
isOpen={isRegistryModalOpen}
onClose={() => {
setIsRegistryModalOpen(false);
if (!isBallotModalOpen && !isBulkBallotModalOpen) {
setSelectedOSS(null);
}
}}
ossId={selectedOSS.id}
ossAddress={selectedOSS.address}
/>
</>
)}
</div>
);
};