refactor: move PipelineItems from Zustand to Provider→Service→React Query

PipelineItems are domain data and must not live in Zustand. Moves the
full stack to the correct layer: MockupPipelineProvider (localStorage
persistence + seed fallback) → pipelineService → usePipeline hooks
(useQuery for reads, useMutation for writes with cache invalidation).

pipelineStore is now UI-only: dialogOpen, pendingItem, openSavedDialog,
closeSavedDialog. All consumers updated to use the new hooks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 12:32:57 +02:00
parent 0582031930
commit 723f553939
12 changed files with 303 additions and 115 deletions
@@ -1,6 +1,7 @@
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material' import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-react' import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { usePipelineItems } from '../../hooks/usePipeline'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineStore } from '../../stores/pipelineStore'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -20,7 +21,8 @@ interface Props {
export function CompareColumnHeader({ item, onRemove }: Props) { export function CompareColumnHeader({ item, onRemove }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const { items: pipelineItems, openSavedDialog } = usePipelineStore() const { data: pipelineItems = [] } = usePipelineItems()
const { openSavedDialog } = usePipelineStore()
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
@@ -7,7 +7,7 @@ import {
AlertTriangle, ChevronRight, CheckCircle, ExternalLink, FileText, AlertTriangle, ChevronRight, CheckCircle, ExternalLink, FileText,
MapPin, MessageSquare, Sparkles, StickyNote, X, MapPin, MessageSquare, Sparkles, StickyNote, X,
} from 'lucide-react' } from 'lucide-react'
import { usePipelineStore } from '../../stores/pipelineStore' import { useMoveStage, useUpdateNotes, useLoseItem } from '../../hooks/usePipeline'
import type { PipelineItem } from '../../domain/pipeline' import type { PipelineItem } from '../../domain/pipeline'
import { STAGES, NEXT_STAGE, MOCK_DOCS } from './pipelineConstants' import { STAGES, NEXT_STAGE, MOCK_DOCS } from './pipelineConstants'
import { scoreColor, detailPath, getKiInsight } from './pipelineUtils' import { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
@@ -16,7 +16,9 @@ import { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) { export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () => void }) {
const navigate = useNavigate() const navigate = useNavigate()
const { moveStage, updateNotes, loseItem } = usePipelineStore() const { mutate: moveStage } = useMoveStage()
const { mutate: updateNotes } = useUpdateNotes()
const { mutate: loseItem } = useLoseItem()
const path = detailPath(item) const path = detailPath(item)
const [notes, setNotes] = useState(item.notes ?? '') const [notes, setNotes] = useState(item.notes ?? '')
const stageConfig = STAGES.find(s => s.key === item.stage)! const stageConfig = STAGES.find(s => s.key === item.stage)!
@@ -128,7 +130,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: ()
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{nextStage && ( {nextStage && (
<Button size="small" variant="contained" endIcon={<ChevronRight size={14} />} <Button size="small" variant="contained" endIcon={<ChevronRight size={14} />}
onClick={() => moveStage(item.id, nextStage.key)} onClick={() => moveStage({ id: item.id, stage: nextStage.key })}
sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }} sx={{ bgcolor: stageConfig.color, '&:hover': { filter: 'brightness(0.9)' }, fontSize: '0.75rem', py: 0.5 }}
> >
{nextStage.label} {nextStage.label}
@@ -157,7 +159,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: ()
placeholder="Notiz hinzufügen…" placeholder="Notiz hinzufügen…"
value={notes} value={notes}
onChange={e => setNotes(e.target.value)} onChange={e => setNotes(e.target.value)}
onBlur={() => updateNotes(item.id, notes)} onBlur={() => updateNotes({ id: item.id, notes })}
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }} sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.8rem', borderRadius: 1.5 } }}
/> />
</Box> </Box>
@@ -5,29 +5,22 @@ import {
} from '@mui/material' } from '@mui/material'
import { Bookmark } from 'lucide-react' import { Bookmark } from 'lucide-react'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineStore } from '../../stores/pipelineStore'
import { useToastStore } from '../../stores/toastStore' import { useAddToPipeline } from '../../hooks/usePipeline'
export function AddToPipelineDialog() { export function AddToPipelineDialog() {
const { dialogOpen, pendingItem, closeSavedDialog, confirmSaved } = usePipelineStore() const { dialogOpen, pendingItem, closeSavedDialog } = usePipelineStore()
const showToast = useToastStore((s) => s.showToast) const { mutate: addToPipeline, isPending } = useAddToPipeline()
const [note, setNote] = useState('') const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
function handleClose() { function handleClose() {
closeSavedDialog() closeSavedDialog()
setNote('') setNote('')
} }
async function handleConfirm() { function handleConfirm() {
setSaving(true) if (!pendingItem) return
const result = confirmSaved(note.trim() || undefined) addToPipeline({ pending: pendingItem, notes: note.trim() || undefined })
setSaving(false)
setNote('') setNote('')
if (result === 'duplicate') {
showToast('Bereits in der Pipeline vorhanden.', 'warning')
} else {
showToast('Zur Pipeline (Gemerkt) hinzugefügt.')
}
} }
return ( return (
@@ -59,12 +52,12 @@ export function AddToPipelineDialog() {
/> />
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} disabled={saving}>Abbrechen</Button> <Button onClick={handleClose} disabled={isPending}>Abbrechen</Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleConfirm} onClick={handleConfirm}
disabled={saving} disabled={isPending}
endIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined} endIcon={isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
> >
Merken Merken
+14
View File
@@ -24,3 +24,17 @@ export interface PipelineItem {
notes?: string notes?: string
assignedTo?: string assignedTo?: string
} }
export interface PendingPipelineItem {
resultId: string
resultType: PipelineItem['resultType']
title: string
location?: string
matchScore: number
propertyId?: string
unitId?: string
propertyAddress?: string
areaLabel?: string
rentLabel?: string
availabilityLabel?: string
}
+1
View File
@@ -1,3 +1,4 @@
export { usePipelineItems, useAddToPipeline, useMoveStage, useUpdateNotes, useLoseItem, useLinkInquiry } from './usePipeline'
export { useProperties, useProperty, usePropertyDetail } from './useProperties' export { useProperties, useProperty, usePropertyDetail } from './useProperties'
export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch, useMatchDetail } from './useMatches' export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch, useMatchDetail } from './useMatches'
export { useFutureSignals, useFutureSignalsByProperty, useVerifySignal } from './useFutureSignals' export { useFutureSignals, useFutureSignalsByProperty, useVerifySignal } from './useFutureSignals'
+91
View File
@@ -0,0 +1,91 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { pipelineService } from '../services/pipelineService'
import { usePipelineStore } from '../stores/pipelineStore'
import { useToastStore } from '../stores/toastStore'
import type { PipelineStage, PendingPipelineItem } from '../domain/pipeline'
export const PIPELINE_QUERY_KEY = ['pipeline', 'items'] as const
export function usePipelineItems() {
return useQuery({
queryKey: PIPELINE_QUERY_KEY,
queryFn: () => pipelineService.getAll(),
select: (res) => res.data ?? [],
staleTime: Infinity,
})
}
export function useAddToPipeline() {
const queryClient = useQueryClient()
const { closeSavedDialog } = usePipelineStore()
return useMutation({
mutationFn: ({ pending, notes }: { pending: PendingPipelineItem; notes?: string }) =>
pipelineService.confirmSaved(pending, notes),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: PIPELINE_QUERY_KEY })
closeSavedDialog()
if (res.data === 'duplicate') {
useToastStore.getState().showToast('Bereits in der Pipeline vorhanden.', 'warning')
} else {
useToastStore.getState().showToast('Zur Pipeline (Gemerkt) hinzugefügt.')
}
},
onError: () => {
useToastStore.getState().showToast('Fehler beim Hinzufügen zur Pipeline.', 'error')
},
})
}
export function useMoveStage() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, stage }: { id: string; stage: PipelineStage }) =>
pipelineService.moveStage(id, stage),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PIPELINE_QUERY_KEY })
},
onError: () => {
useToastStore.getState().showToast('Fehler beim Verschieben.', 'error')
},
})
}
export function useUpdateNotes() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes: string }) =>
pipelineService.updateNotes(id, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PIPELINE_QUERY_KEY })
},
})
}
export function useLoseItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => pipelineService.loseItem(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PIPELINE_QUERY_KEY })
},
onError: () => {
useToastStore.getState().showToast('Fehler beim Ablehnen.', 'error')
},
})
}
export function useLinkInquiry() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, inquiryId }: { id: string; inquiryId: string }) =>
pipelineService.linkInquiry(id, inquiryId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PIPELINE_QUERY_KEY })
},
})
}
+9 -8
View File
@@ -6,7 +6,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react' import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
import { mockInquiries } from '../../mock-data/inquiries' import { mockInquiries } from '../../mock-data/inquiries'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import type { InquiryMessage } from '../../domain/inquiry' import type { InquiryMessage } from '../../domain/inquiry'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection' import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
@@ -34,7 +34,8 @@ const FILTER_TABS = [
export default function Anfragen() { export default function Anfragen() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const { findByPropertyId, findByInquiryId, moveStage } = usePipelineStore() const { data: pipelineItems = [] } = usePipelineItems()
const { mutate: moveStage } = useMoveStage()
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const preselectedId = searchParams.get('inquiry') const preselectedId = searchParams.get('inquiry')
@@ -65,8 +66,8 @@ export default function Anfragen() {
// Pipeline link for currently selected inquiry // Pipeline link for currently selected inquiry
const linkedPipelineItem = selected?.propertyId const linkedPipelineItem = selected?.propertyId
? findByPropertyId(selected.propertyId) ? pipelineItems.find(i => i.propertyId === selected.propertyId)
: (selected ? findByInquiryId(selected.id) : undefined) : (selected ? pipelineItems.find(i => i.inquiryId === selected.id) : undefined)
useEffect(() => { useEffect(() => {
if (threadRef.current) { if (threadRef.current) {
@@ -112,13 +113,13 @@ export default function Anfragen() {
const ki = detectKiStage(text) const ki = detectKiStage(text)
if (ki && selected) { if (ki && selected) {
const pipelineItem = selected.propertyId const pipelineItem = selected.propertyId
? findByPropertyId(selected.propertyId) ? pipelineItems.find(i => i.propertyId === selected.propertyId)
: findByInquiryId(selectedId) : pipelineItems.find(i => i.inquiryId === selectedId)
if (pipelineItem) { if (pipelineItem) {
const currentIdx = STAGE_ORDER.indexOf(pipelineItem.stage) const currentIdx = STAGE_ORDER.indexOf(pipelineItem.stage)
const targetIdx = STAGE_ORDER.indexOf(ki.stage) const targetIdx = STAGE_ORDER.indexOf(ki.stage)
if (targetIdx > currentIdx) { if (targetIdx > currentIdx) {
moveStage(pipelineItem.id, ki.stage) moveStage({ id: pipelineItem.id, stage: ki.stage })
setKiAlert({ title: pipelineItem.title, stage: STAGE_LABELS[ki.stage] }) setKiAlert({ title: pipelineItem.title, stage: STAGE_LABELS[ki.stage] })
} }
} }
@@ -180,7 +181,7 @@ export default function Anfragen() {
key={inq.id} key={inq.id}
inq={inq} inq={inq}
isSelected={inq.id === selectedId} isSelected={inq.id === selectedId}
hasPipeline={!!(inq.propertyId ? findByPropertyId(inq.propertyId) : findByInquiryId(inq.id))} hasPipeline={!!(inq.propertyId ? pipelineItems.find(i => i.propertyId === inq.propertyId) : pipelineItems.find(i => i.inquiryId === inq.id))}
onSelect={handleSelect} onSelect={handleSelect}
/> />
))} ))}
+4 -3
View File
@@ -8,7 +8,7 @@ import {
} from '@dnd-kit/core' } from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { TrendingUp } from 'lucide-react' import { TrendingUp } from 'lucide-react'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import type { PipelineItem, PipelineStage } from '../../domain/pipeline' import type { PipelineItem, PipelineStage } from '../../domain/pipeline'
import { STAGES } from '../../components/pipeline/pipelineConstants' import { STAGES } from '../../components/pipeline/pipelineConstants'
@@ -20,7 +20,8 @@ import { DetailPanel } from '../../components/pipeline/PipelineDetailPanel'
export default function Pipeline() { export default function Pipeline() {
const navigate = useNavigate() const navigate = useNavigate()
const { items, moveStage } = usePipelineStore() const { data: items = [] } = usePipelineItems()
const { mutate: moveStage } = useMoveStage()
const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null) const [selectedItem, setSelectedItem] = useState<PipelineItem | null>(null)
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
const [overId, setOverId] = useState<string | null>(null) const [overId, setOverId] = useState<string | null>(null)
@@ -53,7 +54,7 @@ export default function Pipeline() {
const targetStage = over.id as PipelineStage const targetStage = over.id as PipelineStage
const item = items.find(i => i.id === active.id) const item = items.find(i => i.id === active.id)
if (item && item.stage !== targetStage) { if (item && item.stage !== targetStage) {
moveStage(item.id, targetStage) moveStage({ id: item.id, stage: targetStage })
} }
} }
+10
View File
@@ -0,0 +1,10 @@
import type { PipelineItem, PipelineStage } from '../domain/pipeline'
export interface IPipelineProvider {
getAll(): Promise<PipelineItem[]>
add(item: PipelineItem): Promise<void>
moveStage(id: string, stage: PipelineStage): Promise<void>
updateNotes(id: string, notes: string): Promise<void>
loseItem(id: string): Promise<void>
linkInquiry(id: string, inquiryId: string): Promise<void>
}
+67
View File
@@ -0,0 +1,67 @@
import type { IPipelineProvider } from './IPipelineProvider'
import type { PipelineItem, PipelineStage } from '../domain/pipeline'
import { mockPipelineItems } from '../mock-data/pipelineItems'
const STORAGE_KEY = 'property_match_pipeline_items'
function load(): PipelineItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as PipelineItem[]
} catch { /* ignore */ }
return [...mockPipelineItems]
}
function save(items: PipelineItem[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
} catch { /* ignore */ }
}
export const MockupPipelineProvider: IPipelineProvider = {
async getAll() {
return load()
},
async add(item) {
const items = load()
items.push(item)
save(items)
},
async moveStage(id: string, stage: PipelineStage) {
const items = load()
const idx = items.findIndex(i => i.id === id)
if (idx !== -1) {
items[idx] = { ...items[idx], stage, updatedAt: new Date().toISOString() }
save(items)
}
},
async updateNotes(id: string, notes: string) {
const items = load()
const idx = items.findIndex(i => i.id === id)
if (idx !== -1) {
items[idx] = { ...items[idx], notes }
save(items)
}
},
async loseItem(id: string) {
const items = load()
const idx = items.findIndex(i => i.id === id)
if (idx !== -1) {
items[idx] = { ...items[idx], stage: 'CLOSED_LOST', updatedAt: new Date().toISOString() }
save(items)
}
},
async linkInquiry(id: string, inquiryId: string) {
const items = load()
const idx = items.findIndex(i => i.id === id)
if (idx !== -1) {
items[idx] = { ...items[idx], inquiryId }
save(items)
}
},
}
+85
View File
@@ -0,0 +1,85 @@
import { MockupPipelineProvider } from '../provider/MockupPipelineProvider'
import type { PipelineItem, PipelineStage, PendingPipelineItem } from '../domain/pipeline'
import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors'
const provider = MockupPipelineProvider
export const pipelineService = {
async getAll(): Promise<ListResponse<PipelineItem>> {
try {
const data = await provider.getAll()
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async confirmSaved(pending: PendingPipelineItem, notes?: string): Promise<ItemResponse<'added' | 'duplicate'>> {
try {
const items = await provider.getAll()
const alreadyExists = items.some(
i => i.id === pending.resultId || (pending.propertyId && i.propertyId === pending.propertyId)
)
if (alreadyExists) return { data: 'duplicate' }
const newItem: PipelineItem = {
id: pending.resultId,
matchId: pending.resultId,
title: pending.title,
location: pending.location ?? '',
matchScore: pending.matchScore,
resultType: pending.resultType,
stage: 'SAVED',
propertyId: pending.propertyId,
unitId: pending.unitId,
propertyAddress: pending.propertyAddress,
areaLabel: pending.areaLabel,
rentLabel: pending.rentLabel,
availabilityLabel: pending.availabilityLabel,
notes: notes || undefined,
addedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
await provider.add(newItem)
return { data: 'added' }
} catch (err) {
throwServiceError(err)
}
},
async moveStage(id: string, stage: PipelineStage): Promise<ItemResponse<void>> {
try {
await provider.moveStage(id, stage)
return { data: undefined }
} catch (err) {
throwServiceError(err)
}
},
async updateNotes(id: string, notes: string): Promise<ItemResponse<void>> {
try {
await provider.updateNotes(id, notes)
return { data: undefined }
} catch (err) {
throwServiceError(err)
}
},
async loseItem(id: string): Promise<ItemResponse<void>> {
try {
await provider.loseItem(id)
return { data: undefined }
} catch (err) {
throwServiceError(err)
}
},
async linkInquiry(id: string, inquiryId: string): Promise<ItemResponse<void>> {
try {
await provider.linkInquiry(id, inquiryId)
return { data: undefined }
} catch (err) {
throwServiceError(err)
}
},
}
+4 -83
View File
@@ -1,97 +1,18 @@
import { create } from 'zustand' import { create } from 'zustand'
import { mockPipelineItems } from '../mock-data/pipelineItems' import type { PendingPipelineItem } from '../domain/pipeline'
import type { PipelineItem, PipelineStage } from '../domain/pipeline'
export interface PendingPipelineItem { export type { PendingPipelineItem }
resultId: string
resultType: PipelineItem['resultType']
title: string
location?: string
matchScore: number
propertyId?: string
unitId?: string
propertyAddress?: string
areaLabel?: string
rentLabel?: string
availabilityLabel?: string
}
interface PipelineStore { interface PipelineUIStore {
items: PipelineItem[]
dialogOpen: boolean dialogOpen: boolean
pendingItem: PendingPipelineItem | null pendingItem: PendingPipelineItem | null
openSavedDialog: (item: PendingPipelineItem) => void openSavedDialog: (item: PendingPipelineItem) => void
closeSavedDialog: () => void closeSavedDialog: () => void
confirmSaved: (notes?: string) => 'added' | 'duplicate'
moveStage: (id: string, stage: PipelineStage) => void
updateNotes: (id: string, notes: string) => void
loseItem: (id: string) => void
linkInquiry: (id: string, inquiryId: string) => void
findByPropertyId: (propertyId: string) => PipelineItem | undefined
findByInquiryId: (inquiryId: string) => PipelineItem | undefined
} }
export const usePipelineStore = create<PipelineStore>((set, get) => ({ export const usePipelineStore = create<PipelineUIStore>((set) => ({
items: mockPipelineItems,
dialogOpen: false, dialogOpen: false,
pendingItem: null, pendingItem: null,
openSavedDialog: (item) => set({ dialogOpen: true, pendingItem: item }), openSavedDialog: (item) => set({ dialogOpen: true, pendingItem: item }),
closeSavedDialog: () => set({ dialogOpen: false, pendingItem: null }), closeSavedDialog: () => set({ dialogOpen: false, pendingItem: null }),
confirmSaved: (notes) => {
const { pendingItem, items } = get()
if (!pendingItem) return 'duplicate'
const alreadyExists = items.some(
i => i.id === pendingItem.resultId ||
(pendingItem.propertyId && i.propertyId === pendingItem.propertyId)
)
if (!alreadyExists) {
const newItem: PipelineItem = {
id: pendingItem.resultId,
matchId: pendingItem.resultId,
title: pendingItem.title,
location: pendingItem.location ?? '',
matchScore: pendingItem.matchScore,
resultType: pendingItem.resultType,
stage: 'SAVED',
propertyId: pendingItem.propertyId,
unitId: pendingItem.unitId,
propertyAddress: pendingItem.propertyAddress,
areaLabel: pendingItem.areaLabel,
rentLabel: pendingItem.rentLabel,
availabilityLabel: pendingItem.availabilityLabel,
notes: notes || undefined,
addedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
set({ items: [...items, newItem], dialogOpen: false, pendingItem: null })
return 'added'
}
set({ dialogOpen: false, pendingItem: null })
return 'duplicate'
},
moveStage: (id, stage) => set(state => ({
items: state.items.map(i =>
i.id === id ? { ...i, stage, updatedAt: new Date().toISOString() } : i
),
})),
updateNotes: (id, notes) => set(state => ({
items: state.items.map(i => i.id === id ? { ...i, notes } : i),
})),
loseItem: (id) => set(state => ({
items: state.items.map(i =>
i.id === id ? { ...i, stage: 'CLOSED_LOST' as PipelineStage, updatedAt: new Date().toISOString() } : i
),
})),
linkInquiry: (id, inquiryId) => set(state => ({
items: state.items.map(i => i.id === id ? { ...i, inquiryId } : i),
})),
findByPropertyId: (propertyId) => get().items.find(i => i.propertyId === propertyId),
findByInquiryId: (inquiryId) => get().items.find(i => i.inquiryId === inquiryId),
})) }))