6aa4f96bd2
- Auto-collapse sidebar at <1536px (all laptops), expand at ≥1536px - Responsive drawer/panel widths across Pipeline, Properties, MyListings, Anfragen, MatchDetail, AISearch - Reminder Manager: dot+label priority badge, Fläche column removed, status only for non-active rows - ReminderKpiBar: focal Überfällig card, three secondary KPIs - ReminderDetailDrawer: Task Panel redesign — Finanzen removed, Notiz promoted, Pre-Market as inline badge, full create form - useCreateReminder hook wired to reminderService.create with cache invalidation - Mock data: contract-derived reminders (LEASE_EXPIRY, BREAK_OPTION, RENT_REVIEW, INSURANCE_RENEWAL, SCHATTENMARKT_RELEASE) now show auto-creation note in Verlauf Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
6.8 KiB
TypeScript
164 lines
6.8 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate } from 'react-router'
|
|
import {
|
|
Box, Chip, Typography,
|
|
} from '@mui/material'
|
|
import {
|
|
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
|
|
} from '@dnd-kit/core'
|
|
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
|
import { TrendingUp } from 'lucide-react'
|
|
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
|
|
import { useInquiryStore } from '../../stores/inquiryStore'
|
|
import { AddToPipelineDialog } from '../../components/shortlist'
|
|
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
|
|
import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
|
|
import { STAGES } from '../../components/pipeline/pipelineConstants'
|
|
import { DraggableCard } from '../../components/pipeline/PipelineCard'
|
|
import { DroppableColumn } from '../../components/pipeline/PipelineColumn'
|
|
import { DetailPanel } from '../../components/pipeline/PipelineDetailPanel'
|
|
|
|
// ── Pipeline page ─────────────────────────────────────────────────────────────
|
|
|
|
export default function Pipeline() {
|
|
const navigate = useNavigate()
|
|
const { data: items = [] } = usePipelineItems()
|
|
const { mutate: moveStage } = useMoveStage()
|
|
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
|
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
|
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
const [overId, setOverId] = useState<string | null>(null)
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
|
|
)
|
|
|
|
const activeItem = activeId ? items.find(i => i.id === activeId) ?? null : null
|
|
const syncedSelected = selectedItem ? items.find(i => i.id === selectedItem.id) ?? null : null
|
|
|
|
const activeCount = items.filter(i => i.stage !== 'CLOSED_WON' && i.stage !== 'CLOSED_LOST').length
|
|
const wonItems = items.filter(i => i.stage === 'CLOSED_WON')
|
|
const wonScore = wonItems.length > 0
|
|
? Math.round(wonItems.reduce((s, i) => s + i.matchScore, 0) / wonItems.length)
|
|
: 0
|
|
|
|
function handleDragStart({ active }: DragStartEvent) {
|
|
setActiveId(active.id as string)
|
|
}
|
|
|
|
function handleDragOver({ over }: { over: { id: string | number } | null }) {
|
|
setOverId(over ? String(over.id) : null)
|
|
}
|
|
|
|
function handleDragEnd({ active, over }: DragEndEvent) {
|
|
setActiveId(null)
|
|
setOverId(null)
|
|
if (!over) return
|
|
const targetStage = over.id as PipelineStage
|
|
const item = items.find(i => i.id === active.id)
|
|
if (item && item.stage !== targetStage) {
|
|
moveStage({ id: item.id, stage: targetStage })
|
|
}
|
|
}
|
|
|
|
function handleSelect(item: PipelineItem) {
|
|
if (activeId) return // ignore clicks that fire after a drag
|
|
setSelectedItem(prev => prev?.id === item.id ? null : item)
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
<AddToPipelineDialog />
|
|
<InquiryQuickDialog />
|
|
|
|
{/* Header */}
|
|
<Box sx={{
|
|
bgcolor: 'white', borderBottom: '1px solid #e8e7e4',
|
|
px: 3, py: 2.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
|
|
}}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Deal Pipeline</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Von der ersten Idee bis zum Abschluss — per Drag & Drop verschieben
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
{wonItems.length > 0 && (
|
|
<Chip icon={<TrendingUp size={12} />} label={`${wonItems.length} gewonnen · ø ${wonScore}%`} size="small"
|
|
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', fontWeight: 600, border: '1px solid #86efac' }} />
|
|
)}
|
|
<Chip label={`${activeCount} aktiv`} size="small" sx={{ fontWeight: 600 }} />
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Body */}
|
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
{/* Kanban */}
|
|
<Box sx={{ flex: 1, display: 'flex', gap: 2, px: 3, py: 2, overflowX: 'auto', alignItems: 'flex-start' }}>
|
|
{STAGES.map(stage => {
|
|
const columnItems = items.filter(i => i.stage === stage.key)
|
|
const isOver = overId === stage.key
|
|
|
|
return (
|
|
<Box
|
|
key={stage.key}
|
|
sx={{ minWidth: syncedSelected ? 170 : 200, maxWidth: syncedSelected ? 210 : 260, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
|
|
>
|
|
<Box sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography sx={{ fontWeight: 700, color: stage.color, fontSize: '0.8125rem' }}>
|
|
{stage.label}
|
|
</Typography>
|
|
<Chip
|
|
label={columnItems.length}
|
|
size="small"
|
|
sx={{ bgcolor: stage.bgColor, color: stage.color, border: `1px solid ${stage.color}30`, fontWeight: 600, height: 20, fontSize: '0.7rem' }}
|
|
/>
|
|
</Box>
|
|
<DroppableColumn
|
|
stage={stage}
|
|
items={columnItems}
|
|
selectedId={syncedSelected?.id ?? null}
|
|
onSelect={handleSelect}
|
|
onChatClick={(inquiryId) => navigate(`/demand/anfragen?inquiry=${inquiryId}`)}
|
|
onCardChatClick={(item) => openInquiryDialog({
|
|
propertyTitle: item.title,
|
|
location: item.location ?? '',
|
|
matchScore: item.matchScore ?? 0,
|
|
propertyId: item.propertyId,
|
|
})}
|
|
isOver={isOver}
|
|
/>
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
|
|
{/* Drag overlay — the card that floats under the cursor */}
|
|
<DragOverlay dropAnimation={{ duration: 180, easing: 'ease' }}>
|
|
{activeItem && (
|
|
<DraggableCard
|
|
item={activeItem}
|
|
isSelected={false}
|
|
onSelect={() => {}}
|
|
isDragOverlay
|
|
/>
|
|
)}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
|
|
{/* Detail panel */}
|
|
{syncedSelected && (
|
|
<DetailPanel item={syncedSelected} onClose={() => setSelectedItem(null)} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|