Initial commit MKD fixes
This commit is contained in:
289
components/development/OSSMaster.tsx
Executable file
289
components/development/OSSMaster.tsx
Executable file
@@ -0,0 +1,289 @@
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user