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