Files
mkd/components/development/PipelineRegistry.tsx

276 lines
16 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect, useCallback } from 'react';
import { DevPipelineStatus, DevPipelineItem } from '../../types';
import { Building2, Search, Filter, Plus, GripVertical } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
import { AddPipelineObjectModal } from './AddPipelineObjectModal';
import { EditPipelineObjectModal } from './EditPipelineObjectModal';
const STAGES: { id: DevPipelineStatus; label: string; color: string }[] = [
{ id: 'incoming', label: 'Входящие', color: 'bg-slate-500' },
{ id: 'analysis', label: 'Анализ', color: 'bg-sky-500' },
{ id: 'agenda_approval', label: 'Согласование повестки', color: 'bg-blue-500' },
{ id: 'in_person', label: 'Очная часть', color: 'bg-indigo-500' },
{ id: 'absentee', label: 'Заочная часть', color: 'bg-violet-500' },
{ id: 'protocol_formation', label: 'Формирование протокола', color: 'bg-amber-500' },
{ id: 'protocol_to_gzhi', label: 'Отправка протокола в ГЖИ', color: 'bg-orange-500' },
{ id: 'gzhi_order', label: 'Приказ ГЖИ', color: 'bg-rose-500' },
{ id: 'success', label: 'Успех', color: 'bg-emerald-500' },
{ id: 'failure', label: 'Провал', color: 'bg-red-500' },
];
const DRAG_TYPE = 'application/x-pipeline-item';
const CACHE_KEY = 'mkd_dev_pipeline_cache';
/** Событие: перейти на воронку с поиском по адресу (из аудита) */
const PIPELINE_SEARCH_EVENT = 'mkd-pipeline-search-request';
export const PipelineRegistry: React.FC = () => {
const cached = readCache<DevPipelineItem[]>(CACHE_KEY, []);
const [pipeline, setPipeline] = useState<DevPipelineItem[]>(cached);
const [loading, setLoading] = useState(cached.length === 0);
const [search, setSearch] = useState('');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<DevPipelineItem | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOverStageId, setDragOverStageId] = useState<DevPipelineStatus | null>(null);
const [updatingStatusId, setUpdatingStatusId] = useState<string | null>(null);
const fetchPipeline = useCallback(async (showSpinner = true) => {
try {
if (showSpinner && cached.length === 0 && !search) setLoading(true);
const data = await backendApi.getDevelopmentPipeline({ search: search || undefined });
setPipeline(data);
if (!search) saveCache(CACHE_KEY, data);
} catch (error) {
console.error('Error fetching pipeline:', error);
setPipeline([]);
} finally {
setLoading(false);
}
}, [search]);
useEffect(() => {
const delay = search ? 300 : 0;
const t = setTimeout(() => fetchPipeline(), delay);
return () => clearTimeout(t);
}, [search, fetchPipeline]);
useEffect(() => {
const onRefresh = () => fetchPipeline(false);
window.addEventListener(REFRESH_EVENTS.pipeline, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.pipeline, onRefresh);
}, [fetchPipeline]);
useEffect(() => {
const onSearchRequest = (e: Event) => {
const customEvent = e as CustomEvent<{ search?: string }>;
const q = customEvent.detail?.search;
if (typeof q === 'string' && q.trim()) setSearch(q.trim());
};
window.addEventListener(PIPELINE_SEARCH_EVENT, onSearchRequest);
return () => window.removeEventListener(PIPELINE_SEARCH_EVENT, onSearchRequest);
}, []);
useEffect(() => {
const interval = setInterval(() => fetchPipeline(false), 10 * 1000);
return () => clearInterval(interval);
}, [fetchPipeline]);
const updateItemStatus = useCallback(async (itemId: string, newStatus: DevPipelineStatus) => {
try {
setUpdatingStatusId(itemId);
await backendApi.updateDevelopmentPipeline(itemId, { status: newStatus });
window.dispatchEvent(new CustomEvent('mkd-pipeline-changed'));
await fetchPipeline();
} catch (err) {
console.error('Error updating status:', err);
} finally {
setUpdatingStatusId(null);
}
}, [fetchPipeline]);
const handleDragStart = (e: React.DragEvent, item: DevPipelineItem) => {
setDraggingId(item.id);
e.dataTransfer.setData(DRAG_TYPE, JSON.stringify({ id: item.id, status: item.status }));
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setDragImage(e.currentTarget, 0, 0);
};
const handleDragEnd = () => {
setDraggingId(null);
setDragOverStageId(null);
};
const handleDragOver = (e: React.DragEvent, stageId: DevPipelineStatus) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverStageId(stageId);
};
const handleDragLeave = () => {
setDragOverStageId(null);
};
const handleDrop = (e: React.DragEvent, targetStageId: DevPipelineStatus) => {
e.preventDefault();
setDragOverStageId(null);
setDraggingId(null);
const raw = e.dataTransfer.getData(DRAG_TYPE);
if (!raw) return;
try {
const { id, status } = JSON.parse(raw) as { id: string; status: DevPipelineStatus };
if (status === targetStageId) return;
updateItemStatus(id, targetStageId);
} catch {
// ignore
}
};
const handleCardClick = (e: React.MouseEvent, item: DevPipelineItem) => {
if ((e.target as HTMLElement).closest('select') || (e.target as HTMLElement).closest('[data-drag-handle]')) return;
setEditingItem(item);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Toolbar */}
<div className="flex gap-4 items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по адресу, менеджеру..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
/>
</div>
<button className="p-2.5 bg-white border border-slate-200 rounded-xl text-slate-500 hover:bg-slate-50 shadow-sm"><Filter className="w-5 h-5"/></button>
<button
onClick={() => setIsAddModalOpen(true)}
className="bg-primary-600 text-white p-2.5 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-4 text-xs font-black uppercase tracking-wider active:scale-95 transition-all"
>
<Plus className="w-4 h-4" /> Добавить объект
</button>
</div>
{/* Kanban Horizontal View */}
<div className="overflow-x-auto pb-6 no-scrollbar">
<div className="flex gap-4 min-w-[1000px]">
{STAGES.map(stage => {
const items = pipeline.filter(p => p.status === stage.id);
return (
<div key={stage.id} className="w-72 flex-shrink-0 flex flex-col gap-4">
<div className="flex justify-between items-center px-1">
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest">{stage.label}</h3>
<span className="bg-slate-200 text-slate-600 text-[10px] font-black px-2 py-0.5 rounded-full">{items.length}</span>
</div>
<div
className={`bg-slate-100/50 p-2 rounded-2xl min-h-[400px] border-2 border-dashed transition-colors space-y-3 ${dragOverStageId === stage.id ? 'border-primary-400 bg-primary-50/30' : 'border-slate-200/50'}`}
onDragOver={(e) => handleDragOver(e, stage.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, stage.id)}
>
{items.map(item => (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={(e) => handleCardClick(e, item)}
onKeyDown={(e) => e.key === 'Enter' && setEditingItem(item)}
className={`bg-white p-4 rounded-xl border shadow-sm hover:shadow-md hover:border-primary-300 transition-all group cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500 ${draggingId === item.id ? 'opacity-50 ring-2 ring-primary-400' : 'border-slate-200'}`}
draggable
onDragStart={(e) => handleDragStart(e, item)}
onDragEnd={handleDragEnd}
>
<div className="flex items-start gap-1 mb-2">
<span
data-drag-handle
className="mt-0.5 p-0.5 rounded cursor-grab active:cursor-grabbing text-slate-300 hover:text-slate-500 touch-none"
title="Перетащите в другой этап"
>
<GripVertical className="w-4 h-4" />
</span>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded uppercase tracking-tighter ${item.type === 'new' ? 'bg-blue-50 text-blue-600' : 'bg-orange-50 text-orange-600'}`}>
{item.type === 'new' ? 'Новостройка' : 'Вторичка'}
</span>
<span className="text-[10px] font-black text-emerald-600">{item.probability}%</span>
</div>
<h4 className="font-bold text-slate-800 text-sm mb-1 leading-tight group-hover:text-primary-600 transition-colors">{item.address}</h4>
</div>
</div>
<div className="mb-3">
<select
value={item.status}
disabled={!!updatingStatusId}
onChange={(e) => {
e.stopPropagation();
const v = e.target.value as DevPipelineStatus;
if (v !== item.status) updateItemStatus(item.id, v);
}}
onClick={(e) => e.stopPropagation()}
className="w-full text-[10px] font-medium rounded-lg border border-slate-200 bg-slate-50 py-1.5 px-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none cursor-pointer disabled:opacity-60"
>
{STAGES.map(s => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
<p className="text-[10px] text-slate-400 font-medium mb-3 flex items-center gap-1">
<Building2 className="w-3 h-3"/> {item.apartments} кв {item.area} м²
</p>
<div className="pt-3 border-t border-slate-50 flex justify-between items-center">
<div className="flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-slate-100 flex items-center justify-center text-[10px] font-black text-slate-500">{item.manager?.[0] ?? '?'}</div>
<span className="text-[10px] font-bold text-slate-500 truncate max-w-[80px]">{item.manager}</span>
</div>
<div className="text-right">
<p className="text-[10px] font-black text-slate-800">{((item.expectedRevenue ?? 0) / 1000).toFixed(0)}k </p>
<p className="text-[8px] text-slate-400 font-bold uppercase">Маржа</p>
</div>
</div>
</div>
))}
{items.length === 0 && !loading && (
<div className="py-20 text-center 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">Пусто</p>
</div>
)}
{loading && items.length === 0 && (
<div className="py-20 text-center text-slate-300">
<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>
)}
</div>
</div>
)
})}
</div>
</div>
<AddPipelineObjectModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
onSuccess={() => { fetchPipeline(); }}
/>
<EditPipelineObjectModal
item={editingItem}
onClose={() => setEditingItem(null)}
onSuccess={() => {
setEditingItem(null);
fetchPipeline();
}}
/>
</div>
);
};