fix(livia,nora): Runde 11 — Quellensynchronisation, Filter, Tabellenbreite, Objektdetail

1. Livia: die Systemverwaltung ist jetzt die einzige Quellenbasis.
   `ResearchSourcesPanel` rendert nicht mehr die statische RESEARCH_SOURCE_LIST,
   sondern die übergebenen Quellen aus `leseQuellenAus(agent.systems)` — dieselbe
   Ableitung, die auch Zählung und Lauf verwenden. Die zweite Liste ist entfernt.
   Damit erscheint ein frisch angebundenes System ohne Reload, verschwindet beim
   Deaktivieren, beim Wechsel auf «Schreiben» und beim Löschen. Die Zahl der
   aktiven Lesequellen steht neu auch nach einem Lauf da — vorher trat sie hinter
   das Ergebnis zurück, und die Wirkung eines Umschaltens war nicht sichtbar.

2. Nora: Filter nach Matchqualität und Standort in der bestehenden Filterleiste.
   Die Schwellen sind die bereits geführten SCORE_STRONG/SCORE_MODERATE — dieselben,
   nach denen sich die Farbe des Match-Werts richtet. Die Ortsliste wird aus den
   tatsächlichen Ergebnissen abgeleitet, nicht hartcodiert. Beide filtern auf dem
   bereits berechneten Wert und kombinieren mit allen bestehenden Filtern.

3. Nora: Tabelle passt ohne Querscrollen in die Content-Breite.
   `wordBreak: 'break-word'` war die Ursache der zerhackten Kopfzeilen («AKTI/ONE/N»,
   «JAH/R») — ersetzt durch Umbruch an Wortgrenzen. «Aktionen» hatte 4 % und war
   schmaler als sein eigener Kopftext; Anteile neu verteilt, Zellabstand von 1 auf
   0.75. «Miete CHF/m²/Jahr» bricht explizit nach «Miete», «Breakoutoption» heisst
   in zwei Wörtern «Breakout Option» und kann dadurch umbrechen. Keine Spalte
   entfernt, keine Skalierung.

4. Nora: Objektdetail aus der Trefferliste.
   Wiederverwendet die bestehende PropertyDetailView in derselben Schublade wie
   «Meine Objekte», geladen über die Objekt-ID. Zeilenklick und Auge öffnen
   dasselbe Objekt. Das Öffnen löst kein Matching aus, erzeugt keinen
   Protokolleintrag und erhöht die Kennzahl nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-09-13 16:03:04 +02:00
1 parent 15c5719762
commit f390caaddc
6 files changed
+223 -28

No files matched your search

@@ -20,13 +20,14 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Alert, Box, Button, CircularProgress, FormControlLabel, IconButton, Alert, Box, Button, CircularProgress, Drawer, FormControlLabel, IconButton,
Popover, Switch, Typography, Popover, Switch, Typography, useMediaQuery, useTheme,
} from '@mui/material' } from '@mui/material'
import { Info, Target } from 'lucide-react' import { Info, Target } from 'lucide-react'
import { PropertyFilterBar } from '../supply/PropertyFilterBar' import { PropertyFilterBar } from '../supply/PropertyFilterBar'
import type { PropertyTableFilters } from '../supply/PropertyFilterBar' import type { PropertyTableFilters } from '../supply/PropertyFilterBar'
import { PropertyTable } from '../supply/PropertyTable' import { PropertyTable } from '../supply/PropertyTable'
import { PropertyDetailView } from '../supply/PropertyDetailView'
import { ObjectDeepLink } from '../team' import { ObjectDeepLink } from '../team'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { useRunMatching } from '../../hooks/useMatchRuns' import { useRunMatching } from '../../hooks/useMatchRuns'
@@ -93,6 +94,8 @@ interface Props {
} }
export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, autoStart }: 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 { data: properties = [], isLoading } = useProperties()
const runMatching = useRunMatching() 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])) return new Map(ergebnis.matches.map(m => [m.property.id, m.score]))
}, [ergebnis]) }, [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<string>()
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. * Die Trefferliste — nach Match absteigend, solange nichts anderes gewählt ist.
* *
@@ -162,6 +181,10 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal,
.filter(p => { .filter(p => {
if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false
if (filters.availabilityStatus && p.availabilityStatus !== filters.availabilityStatus) 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) { if (suche) {
const heuhaufen = `${p.title} ${p.location.city} ${p.address.street} ${p.currentTenant ?? ''}`.toLowerCase() const heuhaufen = `${p.title} ${p.location.city} ${p.address.street} ${p.currentTenant ?? ''}`.toLowerCase()
if (!heuhaufen.includes(suche)) return false if (!heuhaufen.includes(suche)) return false
@@ -183,7 +206,7 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal,
} }
const richtung = filters.sortDir === 'asc' ? 1 : -1 const richtung = filters.sortDir === 'asc' ? 1 : -1
return [...gefiltert].sort((a, b) => (wertVon(a) - wertVon(b)) * richtung) return [...gefiltert].sort((a, b) => (wertVon(a) - wertVon(b)) * richtung)
}, [ergebnis, filters]) }, [ergebnis, filters, scores])
const risiko = isRisiko(signal) 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`} {sichtbar.length !== ergebnis.matches.length && ` · ${sichtbar.length} nach Filter sichtbar`}
</Typography> </Typography>
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} /> <PropertyFilterBar
filters={filters}
onFiltersChange={setFilters}
showMatchFilter
cities={staedte}
/>
<PropertyTable <PropertyTable
properties={sichtbar} properties={sichtbar}
@@ -268,12 +296,45 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal,
isError={false} isError={false}
selectedId={selectedId} selectedId={selectedId}
onSelect={setSelectedId} onSelect={setSelectedId}
onViewDetail={setSelectedId}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setFilters}
matchScores={scores} matchScores={scores}
/> />
</Box> </Box>
)} )}
{/*
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.
*/}
<Drawer
anchor="right"
open={selectedId !== null}
onClose={() => 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 && (
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
)}
</Drawer>
</Box> </Box>
) )
}) })
@@ -2,10 +2,21 @@ import { memo } from 'react'
import { Box, Chip, Typography } from '@mui/material' import { Box, Chip, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react' import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'
import type { ResearchSourceResult } from '../../domain/researchLead' 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' import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props { 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. */ /** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */
results?: ResearchSourceResult[] results?: ResearchSourceResult[]
} }
@@ -13,15 +24,19 @@ interface Props {
/** /**
* «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15). * «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 * 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 * Livia prüfen will, soll die Quelle in einem Klick öffnen können, ohne sie
* selbst suchen zu müssen. * 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, * Vor dem ersten Lauf steht «Bereit» und nicht «Verbunden» — verbunden ist,
* was tatsächlich geantwortet hat, und das weiss man erst danach. * 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 ( return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: 'white', overflow: 'hidden' }}> <Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: 'white', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}> <Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}>
@@ -33,7 +48,16 @@ export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results
</Typography> </Typography>
</Box> </Box>
{RESEARCH_SOURCE_LIST.map(quelle => { {sources.length === 0 && (
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Keine aktive Lesequelle mit Adresse angebunden. Quellen werden im Personalblatt unter
«Kanäle &amp; Systeme» erfasst.
</Typography>
</Box>
)}
{sources.map(quelle => {
const ergebnis = results?.find(r => r.id === quelle.id) const ergebnis = results?.find(r => r.id === quelle.id)
return ( return (
<Box <Box
+11 -4
View File
@@ -125,17 +125,25 @@ export function ResearchWorkspace() {
</Typography> </Typography>
)} )}
{!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 && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}> <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`} {`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`}
{dokumente.length > 0 && {dokumente.length > 0 &&
` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`} ` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`}
</Typography> </Typography>
)} )}
</Box>
{!refresh.isPending && ergebnis && ( {!refresh.isPending && ergebnis && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}> <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `} {`Letzter Lauf: ${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
{`${ergebnis.discarded} nicht relevant · `} {`${ergebnis.discarded} nicht relevant · `}
{`${ergebnis.watchlist} Watchlist · `} {`${ergebnis.watchlist} Watchlist · `}
{ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `} {ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `}
@@ -144,7 +152,6 @@ export function ResearchWorkspace() {
{ergebnis.model && ` · Modell ${ergebnis.model}`} {ergebnis.model && ` · Modell ${ergebnis.model}`}
</Typography> </Typography>
)} )}
</Box>
{keineQuellen && ( {keineQuellen && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}> <Alert severity="info" sx={{ fontSize: '0.8rem' }}>
@@ -154,7 +161,7 @@ export function ResearchWorkspace() {
</Alert> </Alert>
)} )}
<ResearchSourcesPanel results={ergebnis?.sources} /> <ResearchSourcesPanel sources={quellen} results={ergebnis?.sources} />
{ergebnis?.analysisError && ( {ergebnis?.analysisError && (
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}> <Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
+71 -2
View File
@@ -10,6 +10,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { AssetType, AvailabilityStatus } from '../../domain/enums' import { AssetType, AvailabilityStatus } from '../../domain/enums'
import { getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers' import { getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
import { SCORE_MODERATE, SCORE_STRONG } from '../../lib/constants'
import { DS_BRAND } from '../../lib/ds' import { DS_BRAND } from '../../lib/ds'
export interface PropertyTableFilters { export interface PropertyTableFilters {
@@ -18,13 +19,47 @@ export interface PropertyTableFilters {
availabilityStatus?: string availabilityStatus?: string
sortBy?: 'dataQuality' | 'availability' | 'area' | 'rent' | 'confidence' sortBy?: 'dataQuality' | 'availability' | 'area' | 'rent' | 'confidence'
sortDir?: 'asc' | 'desc' 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 { interface PropertyFilterBarProps {
filters: PropertyTableFilters filters: PropertyTableFilters
onFiltersChange: (f: PropertyTableFilters) => void 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 = [ const SORT_OPTIONS = [
{ value: 'dataQuality', label: 'Datenqualität' }, { value: 'dataQuality', label: 'Datenqualität' },
{ value: 'area', label: 'Fläche' }, { value: 'area', label: 'Fläche' },
@@ -43,12 +78,16 @@ const SUPPLY_AVAILABILITY_STATUSES = [
AvailabilityStatus.OCCUPIED, AvailabilityStatus.OCCUPIED,
] as const ] as const
export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBarProps) { export function PropertyFilterBar({
filters, onFiltersChange, showMatchFilter = false, cities = [],
}: PropertyFilterBarProps) {
const isActive = const isActive =
!!filters.search || !!filters.search ||
(filters.assetTypes?.length ?? 0) > 0 || (filters.assetTypes?.length ?? 0) > 0 ||
!!filters.availabilityStatus || !!filters.availabilityStatus ||
!!filters.sortBy !!filters.sortBy ||
filters.minMatch !== undefined ||
!!filters.city
function update(partial: Partial<PropertyTableFilters>) { function update(partial: Partial<PropertyTableFilters>) {
onFiltersChange({ ...filters, ...partial }) onFiltersChange({ ...filters, ...partial })
@@ -121,6 +160,36 @@ export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBa
))} ))}
</Select> </Select>
{showMatchFilter && (
<Select
value={filters.minMatch ?? 0}
onChange={e => update({ minMatch: e.target.value === 0 ? undefined : Number(e.target.value) })}
displayEmpty
size="small"
sx={{ minWidth: 160 }}
>
<MenuItem value={0}>Matchqualität (alle)</MenuItem>
{MATCH_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
)}
{cities.length > 0 && (
<Select
value={filters.city ?? ''}
onChange={e => update({ city: e.target.value || undefined })}
displayEmpty
size="small"
sx={{ minWidth: 160 }}
>
<MenuItem value="">Standort (alle)</MenuItem>
{cities.map(c => (
<MenuItem key={c} value={c}>{c}</MenuItem>
))}
</Select>
)}
<TextField <TextField
value={filters.search ?? ''} value={filters.search ?? ''}
onChange={e => update({ search: e.target.value || undefined })} onChange={e => update({ search: e.target.value || undefined })}
+37 -9
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { import {
Alert, Alert,
Box, Box,
@@ -46,7 +47,7 @@ interface PropertyTableProps {
const COL_HEADERS = [ const COL_HEADERS = [
'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete CHF/m²/Jahr', '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', 'Datenqualität', 'Aktionen',
] ]
@@ -56,8 +57,12 @@ const COL_HEADERS = [
* Sie ergeben zusammen 100. Kommt die Match-Spalte dazu, werden alle * Sie ergeben zusammen 100. Kommt die Match-Spalte dazu, werden alle
* anteilig gestaucht, statt die Tabelle zu verbreitern — eine waagrechte * anteilig gestaucht, statt die Tabelle zu verbreitern — eine waagrechte
* Bildlaufleiste wäre für eine einzige Kennzahl ein schlechter Tausch. * 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. */ /** Platz für «Match» in Prozent. */
const MATCH_COL_WIDTH = 7 const MATCH_COL_WIDTH = 7
@@ -90,7 +95,8 @@ function SortableHeader({
field, label, sortBy, sortDir, onSort, field, label, sortBy, sortDir, onSort,
}: { }: {
field: PropertyTableFilters['sortBy'] field: PropertyTableFilters['sortBy']
label: string /** Knoten statt nur Text, damit ein Kopf seinen Umbruch selbst setzen kann. */
label: ReactNode
sortBy: PropertyTableFilters['sortBy'] sortBy: PropertyTableFilters['sortBy']
sortDir: PropertyTableFilters['sortDir'] sortDir: PropertyTableFilters['sortDir']
onSort: (field: PropertyTableFilters['sortBy']) => void onSort: (field: PropertyTableFilters['sortBy']) => void
@@ -158,10 +164,27 @@ export function PropertyTable({
sx={{ sx={{
tableLayout: 'fixed', tableLayout: 'fixed',
'& .MuiTableCell-root': { '& .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, 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({
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell>
<TableCell><SortableHeader field="area" label="Fläche (m²)" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell> <TableCell><SortableHeader field="area" label="Fläche (m²)" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell>
<TableCell><SortableHeader field="rent" label="Miete CHF/m²/Jahr" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell> <TableCell><SortableHeader field="rent" label={<>Miete<br />CHF/m²/Jahr</>} sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktueller Mieter</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktueller Mieter</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Mietlaufzeit</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Mietlaufzeit</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption</Typography></TableCell> {/*
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption Zeitpunkt</Typography></TableCell> «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.
*/}
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakout Option</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakout Zeitpunkt</Typography></TableCell>
<TableCell><SortableHeader field="dataQuality" label="Datenqualität" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell> <TableCell><SortableHeader field="dataQuality" label="Datenqualität" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
</TableRow> </TableRow>
+11 -5
View File
@@ -42,11 +42,17 @@ export const RESEARCH_SOURCES = {
}, },
} as const satisfies Record<string, ResearchSourceDescriptor> } as const satisfies Record<string, ResearchSourceDescriptor>
export const RESEARCH_SOURCE_LIST: ResearchSourceDescriptor[] = [ /*
RESEARCH_SOURCES.ZHK, * Hier stand bis Runde 11 (§1.9) eine `RESEARCH_SOURCE_LIST`, die der
RESEARCH_SOURCES.GZA, * Research-Bereich angezeigt hat. Sie ist entfernt: Welche Quellen gelten,
RESEARCH_SOURCES.ZEFIX, * 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). */ /** Der eine Endpoint des PoC (§20). */
export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh' export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh'