Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb12119d91 | ||
|
|
f390caaddc |
No files matched your search
@@ -136,8 +136,13 @@ const BATCH_SIZE = 5
|
||||
*
|
||||
* Grössenordnung je Lauf über rund fünfzig Meldungen:
|
||||
* claude-opus-5 ≈ 0.40 USD stärkstes Urteil
|
||||
* claude-sonnet-5 ≈ 0.20 USD
|
||||
* claude-sonnet-5 ≈ 0.16 USD
|
||||
* claude-haiku-4-5 ≈ 0.08 USD für eine Ja/Nein-Einordnung meist ausreichend
|
||||
*
|
||||
* Die Verhältnisse folgen den Listenpreisen je Million Token (Eingabe/Ausgabe):
|
||||
* Opus 5 bei 5/25, Sonnet 5 bei 2/10, Haiku 4.5 bei 1/5 US-Dollar. Sonnet
|
||||
* kostet damit genau das Doppelte von Haiku — hier stand bisher 0.20, was eine
|
||||
* ältere Preisstufe widerspiegelte.
|
||||
*/
|
||||
export const MODEL = process.env.LIVIA_RESEARCH_MODEL ?? 'claude-opus-5'
|
||||
|
||||
|
||||
@@ -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<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.
|
||||
*
|
||||
@@ -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`}
|
||||
</Typography>
|
||||
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
<PropertyFilterBar
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
showMatchFilter
|
||||
cities={staedte}
|
||||
/>
|
||||
|
||||
<PropertyTable
|
||||
properties={sichtbar}
|
||||
@@ -268,12 +296,45 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal,
|
||||
isError={false}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onViewDetail={setSelectedId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
matchScores={scores}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
})
|
||||
@@ -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 (
|
||||
<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' }}>
|
||||
@@ -33,7 +48,16 @@ export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results
|
||||
</Typography>
|
||||
</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 & Systeme» erfasst.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sources.map(quelle => {
|
||||
const ergebnis = results?.find(r => r.id === quelle.id)
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -125,27 +125,34 @@ export function ResearchWorkspace() {
|
||||
</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 }}>
|
||||
{`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`}
|
||||
{dokumente.length > 0 &&
|
||||
` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!refresh.isPending && ergebnis && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`${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}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!refresh.isPending && ergebnis && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`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}`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{keineQuellen && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt.
|
||||
@@ -154,7 +161,7 @@ export function ResearchWorkspace() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ResearchSourcesPanel results={ergebnis?.sources} />
|
||||
<ResearchSourcesPanel sources={quellen} results={ergebnis?.sources} />
|
||||
|
||||
{ergebnis?.analysisError && (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||
|
||||
@@ -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<PropertyTableFilters>) {
|
||||
onFiltersChange({ ...filters, ...partial })
|
||||
@@ -121,6 +160,36 @@ export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBa
|
||||
))}
|
||||
</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
|
||||
value={filters.search ?? ''}
|
||||
onChange={e => update({ search: e.target.value || undefined })}
|
||||
|
||||
@@ -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({
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</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="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 }}>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><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -42,11 +42,17 @@ export const RESEARCH_SOURCES = {
|
||||
},
|
||||
} as const satisfies Record<string, ResearchSourceDescriptor>
|
||||
|
||||
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'
|
||||
Reference in new issue
Block a user