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:
|
* Grössenordnung je Lauf über rund fünfzig Meldungen:
|
||||||
* claude-opus-5 ≈ 0.40 USD stärkstes Urteil
|
* 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
|
* 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'
|
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 { 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 & 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
|
||||||
|
|||||||
@@ -125,27 +125,34 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!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>
|
</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 && (
|
{keineQuellen && (
|
||||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||||
Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt.
|
Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt.
|
||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
Reference in new issue
Block a user