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

276 lines
16 KiB
TypeScript
Executable File
Raw Permalink 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 { 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>
);
};