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

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