diff --git a/src/components/market-leads/PropertyMatchSection.tsx b/src/components/market-leads/PropertyMatchSection.tsx index ef1a4d0..caceaf5 100644 --- a/src/components/market-leads/PropertyMatchSection.tsx +++ b/src/components/market-leads/PropertyMatchSection.tsx @@ -20,13 +20,14 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - Alert, Box, Button, CircularProgress, FormControlLabel, IconButton, - Popover, Switch, Typography, + Alert, Box, Button, CircularProgress, Drawer, FormControlLabel, IconButton, + Popover, Switch, Typography, useMediaQuery, useTheme, } from '@mui/material' import { Info, Target } from 'lucide-react' import { PropertyFilterBar } from '../supply/PropertyFilterBar' import type { PropertyTableFilters } from '../supply/PropertyFilterBar' import { PropertyTable } from '../supply/PropertyTable' +import { PropertyDetailView } from '../supply/PropertyDetailView' import { ObjectDeepLink } from '../team' import { useProperties } from '../../hooks/useProperties' import { useRunMatching } from '../../hooks/useMatchRuns' @@ -93,6 +94,8 @@ interface Props { } export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, autoStart }: Props) { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) const { data: properties = [], isLoading } = useProperties() const runMatching = useRunMatching() @@ -141,6 +144,22 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, return new Map(ergebnis.matches.map(m => [m.property.id, m.score])) }, [ergebnis]) + /** + * Auswählbare Orte, aus den Ergebnissen abgeleitet (Runde 11, §2). + * + * Keine feste Ortsliste: Was der Lauf nicht gefunden hat, soll auch nicht + * auswählbar sein — ein Filter, der auf nichts zeigt, ist irreführend. + */ + const staedte = useMemo(() => { + if (!ergebnis) return [] + const gesehen = new Set() + for (const m of ergebnis.matches) { + const ort = m.property.location.city?.trim() + if (ort) gesehen.add(ort) + } + return [...gesehen].sort((a, b) => a.localeCompare(b, 'de-CH')) + }, [ergebnis]) + /** * Die Trefferliste — nach Match absteigend, solange nichts anderes gewählt ist. * @@ -162,6 +181,10 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, .filter(p => { if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false if (filters.availabilityStatus && p.availabilityStatus !== filters.availabilityStatus) return false + // Gefiltert wird auf dem bereits berechneten Wert — er wird hier weder + // neu gerechnet noch verändert (§2). + if (filters.minMatch !== undefined && (scores?.get(p.id) ?? 0) < filters.minMatch) return false + if (filters.city && p.location.city !== filters.city) return false if (suche) { const heuhaufen = `${p.title} ${p.location.city} ${p.address.street} ${p.currentTenant ?? ''}`.toLowerCase() if (!heuhaufen.includes(suche)) return false @@ -183,7 +206,7 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, } const richtung = filters.sortDir === 'asc' ? 1 : -1 return [...gefiltert].sort((a, b) => (wertVon(a) - wertVon(b)) * richtung) - }, [ergebnis, filters]) + }, [ergebnis, filters, scores]) const risiko = isRisiko(signal) @@ -260,7 +283,12 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, {sichtbar.length !== ergebnis.matches.length && ` · ${sichtbar.length} nach Filter sichtbar`} - + )} + + {/* + Objektdetail aus der Trefferliste (Runde 11, §4). + + Bewusst dieselbe `PropertyDetailView` in derselben Schublade wie in + «Meine Objekte» — geladen über die Objekt-ID, nicht über Titel oder + Listenposition. Eine zweite Detailansicht für denselben Datensatz wäre + eine zweite Stelle, an der Objektangaben auseinanderlaufen können. + + Die Schublade legt sich über die Seite, statt sie zu ersetzen: Der + Matching-Kontext bleibt darunter stehen, und das Schliessen führt + zurück in die unveränderte Trefferliste. Es läuft dabei kein Matching — + `ergebnis` wird nicht angerührt, also entsteht weder ein + Protokolleintrag noch ein Zähler-Zuwachs. + */} + setSelectedId(null)} + slotProps={{ + paper: { + sx: { + width: isMobile ? '100vw' : { md: 520, lg: 580, xl: 640 }, + boxShadow: '-4px 0 24px rgba(0,0,0,0.10)', + }, + }, + }} + > + {selectedId && ( + setSelectedId(null)} /> + )} + ) }) diff --git a/src/components/research/ResearchSourcesPanel.tsx b/src/components/research/ResearchSourcesPanel.tsx index 993d1d2..1131bc0 100644 --- a/src/components/research/ResearchSourcesPanel.tsx +++ b/src/components/research/ResearchSourcesPanel.tsx @@ -2,10 +2,21 @@ import { memo } from 'react' import { Box, Chip, Typography } from '@mui/material' import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react' import type { ResearchSourceResult } from '../../domain/researchLead' -import { RESEARCH_SOURCE_LIST } from '../../lib/researchSources' +import type { ResearchSourceDescriptor } from '../../lib/researchSources' import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds' interface Props { + /** + * Die Quellen, die der nächste Lauf abrufen wird. + * + * Kommen von aussen und nicht aus einer eigenen Liste: Bis Runde 11 stand + * hier `RESEARCH_SOURCE_LIST` — eine zweite, im Code gepflegte Aufzählung. + * Dadurch zeigte das Panel die drei Standardquellen, während die Zählung + * daneben bereits vier meldete und ein frisch angebundenes System nirgends + * auftauchte. Eine Anzeige, die ihre eigene Wahrheit führt, ist genau das + * Gegenteil dessen, was dieses Panel leisten soll. + */ + sources: ResearchSourceDescriptor[] /** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */ results?: ResearchSourceResult[] } @@ -13,15 +24,19 @@ interface Props { /** * «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15). * - * Zeigt die drei real abgerufenen Quellen mit Namen, Status und anklickbarem + * Zeigt die real abgerufenen Quellen mit Namen, Status und anklickbarem * Originallink. Die Links stehen hier nicht als Zierde: wer eine Aussage von * Livia prüfen will, soll die Quelle in einem Klick öffnen können, ohne sie * selbst suchen zu müssen. * + * Welche Quellen das sind, entscheidet das Personalblatt — die Liste kommt als + * Eigenschaft herein und stammt aus derselben Ableitung, die auch der Lauf + * verwendet (Runde 11, §1). + * * Vor dem ersten Lauf steht «Bereit» und nicht «Verbunden» — verbunden ist, * was tatsächlich geantwortet hat, und das weiss man erst danach. */ -export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results }: Props) { +export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ sources, results }: Props) { return ( @@ -33,7 +48,16 @@ export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results - {RESEARCH_SOURCE_LIST.map(quelle => { + {sources.length === 0 && ( + + + Keine aktive Lesequelle mit Adresse angebunden. Quellen werden im Personalblatt unter + «Kanäle & Systeme» erfasst. + + + )} + + {sources.map(quelle => { const ergebnis = results?.find(r => r.id === quelle.id) return ( )} - {!refresh.isPending && !ergebnis && !keineQuellen && ( + {/* + Die Zahl steht immer da, auch nach einem Lauf (Runde 11, §1.8). + Vorher trat sie hinter das Ergebnis zurück — wer danach ein System + ab- oder zuschaltete, sah die Wirkung nirgends. Sie kommt aus + derselben Ableitung wie die Liste darunter und wie der Lauf selbst; + eine gepflegte Zählvariable gibt es nicht. + */} + {!refresh.isPending && !keineQuellen && ( {`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`} {dokumente.length > 0 && ` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`} )} - - {!refresh.isPending && ergebnis && ( - - {`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `} - {`${ergebnis.discarded} nicht relevant · `} - {`${ergebnis.watchlist} Watchlist · `} - {ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `} - {`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead übernommen' : 'Leads übernommen'}`} - {` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`} - {ergebnis.model && ` · Modell ${ergebnis.model}`} - - )} + {!refresh.isPending && ergebnis && ( + + {`Letzter Lauf: ${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `} + {`${ergebnis.discarded} nicht relevant · `} + {`${ergebnis.watchlist} Watchlist · `} + {ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `} + {`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead übernommen' : 'Leads übernommen'}`} + {` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`} + {ergebnis.model && ` · Modell ${ergebnis.model}`} + + )} + {keineQuellen && ( Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt. @@ -154,7 +161,7 @@ export function ResearchWorkspace() { )} - + {ergebnis?.analysisError && ( diff --git a/src/components/supply/PropertyFilterBar.tsx b/src/components/supply/PropertyFilterBar.tsx index 477b975..dc5e29a 100644 --- a/src/components/supply/PropertyFilterBar.tsx +++ b/src/components/supply/PropertyFilterBar.tsx @@ -10,6 +10,7 @@ import { } from '@mui/material' import { AssetType, AvailabilityStatus } from '../../domain/enums' import { getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers' +import { SCORE_MODERATE, SCORE_STRONG } from '../../lib/constants' import { DS_BRAND } from '../../lib/ds' export interface PropertyTableFilters { @@ -18,13 +19,47 @@ export interface PropertyTableFilters { availabilityStatus?: string sortBy?: 'dataQuality' | 'availability' | 'area' | 'rent' | 'confidence' sortDir?: 'asc' | 'desc' + /** + * Mindest-Match in Prozent (Runde 11, §2). + * + * Nur in Noras Trefferliste gesetzt — in «Meine Objekte» gibt es keinen + * Match-Wert, und ein Filter ohne Datengrundlage wäre ein Bedienelement, + * das nichts tut. Die Schwellen kommen aus `SCORE_STRONG`/`SCORE_MODERATE`; + * eigene Kategorien werden nicht erfunden. + */ + minMatch?: number + /** Ort aus den Standortdaten der Ergebnisse. */ + city?: string } interface PropertyFilterBarProps { filters: PropertyTableFilters onFiltersChange: (f: PropertyTableFilters) => void + /** + * Schaltet den Matchqualitäts-Filter frei. Nur dort gesetzt, wo Match-Werte + * vorliegen — die Filterleiste ist sonst dieselbe wie in «Meine Objekte». + */ + showMatchFilter?: boolean + /** + * Auswählbare Orte, abgeleitet aus den tatsächlich vorliegenden Ergebnissen. + * Bewusst keine feste Ortsliste: Was nicht im Bestand vorkommt, soll auch + * nicht auswählbar sein. + */ + cities?: string[] } +/** + * Stufen der Matchqualität (Runde 11, §2). + * + * Die Grenzen sind die bereits im Projekt geführten Schwellen — dieselben, nach + * denen sich auch die Farbe des Match-Werts richtet. Damit stimmt der Filter + * mit dem überein, was der Nutzer in der Spalte sieht. + */ +const MATCH_OPTIONS = [ + { value: SCORE_STRONG, label: `Stark (ab ${SCORE_STRONG} %)` }, + { value: SCORE_MODERATE, label: `Moderat (ab ${SCORE_MODERATE} %)` }, +] as const + const SORT_OPTIONS = [ { value: 'dataQuality', label: 'Datenqualität' }, { value: 'area', label: 'Fläche' }, @@ -43,12 +78,16 @@ const SUPPLY_AVAILABILITY_STATUSES = [ AvailabilityStatus.OCCUPIED, ] as const -export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBarProps) { +export function PropertyFilterBar({ + filters, onFiltersChange, showMatchFilter = false, cities = [], +}: PropertyFilterBarProps) { const isActive = !!filters.search || (filters.assetTypes?.length ?? 0) > 0 || !!filters.availabilityStatus || - !!filters.sortBy + !!filters.sortBy || + filters.minMatch !== undefined || + !!filters.city function update(partial: Partial) { onFiltersChange({ ...filters, ...partial }) @@ -121,6 +160,36 @@ export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBa ))} + {showMatchFilter && ( + + )} + + {cities.length > 0 && ( + + )} + update({ search: e.target.value || undefined })} diff --git a/src/components/supply/PropertyTable.tsx b/src/components/supply/PropertyTable.tsx index 32622f5..d4d88f5 100644 --- a/src/components/supply/PropertyTable.tsx +++ b/src/components/supply/PropertyTable.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import { Alert, Box, @@ -46,7 +47,7 @@ interface PropertyTableProps { const COL_HEADERS = [ 'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete CHF/m²/Jahr', - 'Aktueller Mieter', 'Mietlaufzeit', 'Breakoutoption', 'Breakoutoption Zeitpunkt', + 'Aktueller Mieter', 'Mietlaufzeit', 'Breakout Option', 'Breakout Zeitpunkt', 'Datenqualität', 'Aktionen', ] @@ -56,8 +57,12 @@ const COL_HEADERS = [ * Sie ergeben zusammen 100. Kommt die Match-Spalte dazu, werden alle * anteilig gestaucht, statt die Tabelle zu verbreitern — eine waagrechte * Bildlaufleiste wäre für eine einzige Kennzahl ein schlechter Tausch. + * + * In Runde 11 (§3) neu verteilt: «Aktionen» hatte 4 % und war damit schmaler + * als sein eigener Kopftext, der daraufhin zeichenweise umbrach («AKTI/ONE/N»). + * Der Platz kommt aus «Objekt» und «Standort», die beide Reserve hatten. */ -const COL_WIDTHS = [19, 7, 9, 6, 8, 13, 9, 7, 9, 9, 4] +const COL_WIDTHS = [17, 7, 8, 6, 9, 13, 8, 8, 9, 9, 6] /** Platz für «Match» in Prozent. */ const MATCH_COL_WIDTH = 7 @@ -90,7 +95,8 @@ function SortableHeader({ field, label, sortBy, sortDir, onSort, }: { field: PropertyTableFilters['sortBy'] - label: string + /** Knoten statt nur Text, damit ein Kopf seinen Umbruch selbst setzen kann. */ + label: ReactNode sortBy: PropertyTableFilters['sortBy'] sortDir: PropertyTableFilters['sortDir'] onSort: (field: PropertyTableFilters['sortBy']) => void @@ -158,10 +164,27 @@ export function PropertyTable({ sx={{ tableLayout: 'fixed', '& .MuiTableCell-root': { - px: 1, + // Enger als zuvor (war 1) — elf bis zwölf Spalten gewinnen dadurch + // zusammen rund 100 Pixel, ohne dass eine Zelle gedrängt wirkt. + px: 0.75, py: 0.75, - // Lange Namen brechen um, statt die Spalte aufzuweiten. - wordBreak: 'break-word', + /* + * Umbruch nur an Wortgrenzen (Runde 11, §3.7/§3.8). + * + * Vorher stand hier `wordBreak: 'break-word'`. Das trennt ein Wort + * mitten drin, sobald es nicht in die Spalte passt — in der + * Kopfzeile wurde daraus «JAH/R» und «DATENQUALIT/ÄT». Jetzt bricht + * nur überlanger Text ohne Leerzeichen, und auch das erst, wenn er + * sonst überliefe. + */ + wordBreak: 'normal', + overflowWrap: 'break-word', + hyphens: 'none', + }, + // Kopfzeilen dürfen zwei Zeilen brauchen — aber an sinnvoller Stelle. + '& .MuiTableCell-head': { + lineHeight: 1.25, + verticalAlign: 'bottom', }, }} > @@ -182,11 +205,16 @@ export function PropertyTable({ Typ Standort - + Miete
CHF/m²/Jahr} sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} />
Aktueller Mieter Mietlaufzeit - Breakoutoption - Breakoutoption Zeitpunkt + {/* + «Breakoutoption» in zwei Wörtern (Runde 11, §3.9): Der Begriff + bleibt derselbe, nur der Umbruch hat jetzt eine Stelle, an der + er stattfinden darf. + */} + Breakout Option + Breakout Zeitpunkt Aktionen diff --git a/src/lib/researchSources.ts b/src/lib/researchSources.ts index 7e0889d..143be3c 100644 --- a/src/lib/researchSources.ts +++ b/src/lib/researchSources.ts @@ -42,11 +42,17 @@ export const RESEARCH_SOURCES = { }, } as const satisfies Record -export const RESEARCH_SOURCE_LIST: ResearchSourceDescriptor[] = [ - RESEARCH_SOURCES.ZHK, - RESEARCH_SOURCES.GZA, - RESEARCH_SOURCES.ZEFIX, -] +/* + * Hier stand bis Runde 11 (§1.9) eine `RESEARCH_SOURCE_LIST`, die der + * Research-Bereich angezeigt hat. Sie ist entfernt: Welche Quellen gelten, + * steht in Livias Systemzugängen und wird über `leseQuellenAus()` abgeleitet. + * Zwei Listen nebeneinander waren genau der Grund, warum ein frisch + * angebundenes System in der Anzeige fehlte, während die Zählung es schon + * mitzählte. + * + * `RESEARCH_SOURCES` bleibt — die drei Adressen sind der Seed für Livias + * Personalblatt und der Schlüssel für die spezialisierten Leser im Backend. + */ /** Der eine Endpoint des PoC (§20). */ export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh'