276 lines
16 KiB
TypeScript
276 lines
16 KiB
TypeScript
|
|
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|