Files
property-match/src/pages/demand/Pipeline.tsx
T
Benjamin Sutter 6aa4f96bd2 feat: responsive layout + Reminder Manager redesign
- 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>
2026-06-16 14:30:06 +02:00

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 &amp; 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>
)
}