Files
property-match/src/components/team/AgentMetricsTab.tsx
T
Benjamin SutterandClaude Opus 5 c6011ac093 feat(nora): Runde 10 Prompt 3 — Property Matching gegen den eigenen Bestand
Nora arbeitet jetzt mit dem Objektbestand statt mit Quellen.

- Lead-Detail auf ganzer Seite statt in der Schublade; darunter der neue
  Bereich «Property Match». Derselbe Baustein wie bisher, nur mit einem
  Layoutschalter — keine zweite Detailansicht
- «Das hat Livia recherchiert» heisst «Erstelltes Suchprofil» und zeigt
  lead.semanticSearchProfile. Ein Kasten, nicht zwei: Bestandsleads fallen auf
  ihren bisherigen Text zurück
- Matching-Logik in features/matching/leadPropertyMatcher.ts: Das Suchprofil
  wird nach Merkmalen gelesen, die darin tatsächlich vorkommen, und gegen das
  Objekt geprüft. Ein fehlendes Objektmerkmal geht nicht in die Rechnung ein —
  es gilt als nicht bekannt, nicht als erfüllt. Deterministisch, kein Zufall
- Trefferliste ist dieselbe Tabelle wie «Meine Objekte», ergänzt um eine erste
  Spalte «Match». Die übrigen Spalten werden anteilig gestaucht, damit keine
  waagrechte Bildlaufleiste entsteht
- «Belegte Objekte einbeziehen», Vorgabe aus; Umschalten rechnet neu
- MatchRun als eigene Entität mit Persistenz. Genau ein Eintrag je Lauf, auch
  bei der Übergabe aus Livia — eine Sperre verhindert den zweiten Lauf durch
  Neuaufbau der Komponente
- Aus Livia übergebene Leads starten das Matching einmal; ein normal geöffneter
  Lead startet nichts
- Kennzahlen «Anzahl durchgeführte Matchings» und «Anzahl betroffene Objekte»
  kommen aus dem laufenden Stand, nicht aus dem Katalog
- Aufgaben, Systeme (ERP statt Zefix/SHAB/Webquellen) und Einstellungen auf
  das Matching zugeschnitten
- Info-Knopf erklärt den Match-Wert entlang der tatsächlichen Rechnung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:02:06 +02:00

123 lines
3.9 KiB
TypeScript

import { memo, useMemo } from 'react'
import { Box, Typography } from '@mui/material'
import type { AgentMetric, TeamAgent } from '../../domain/teamAgent'
import { EmptyState } from '../ui'
import { useMatchRuns } from '../../hooks/useMatchRuns'
import { useFutureSignals } from '../../hooks/useFutureSignals'
import { isRisiko } from '../../lib/leadSignal'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
/**
* Reiter «Kennzahlen».
*
* Zeigt die Kennzahlen, die das Personalblatt führt. Es wird nichts
* hochgerechnet und nichts ergänzt — was der Katalog nicht führt, steht hier
* auch nicht.
*
* Ausnahme seit Runde 10 (§3.11): Noras beide Kennzahlen kommen aus dem
* laufenden Stand der Anwendung und nicht aus dem Katalog. Ein fester Wert
* wäre dort eine Behauptung — die eine Zahl zählt tatsächlich ausgeführte
* Matching-Läufe, die andere tatsächlich zugeordnete Objekte.
*/
/** Kennungen, deren Wert zur Laufzeit entsteht. */
const MATCH_RUNS_METRIC = 'nora-metric-match-runs'
const AFFECTED_OBJECTS_METRIC = 'nora-metric-affected-objects'
const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric }) {
return (
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
bgcolor: DS_BG.surface,
p: 2,
minWidth: 0,
}}
>
<Typography
sx={{
fontWeight: 700,
fontSize: '1.5rem',
lineHeight: 1.15,
color: DS_TEXT.primary,
fontVariantNumeric: 'tabular-nums',
}}
>
{metric.value}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600, mt: 0.5 }}>
{metric.label}
</Typography>
{metric.hint && (
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mt: 0.25 }}>
{metric.hint}
</Typography>
)}
</Box>
)
})
export function AgentMetricsTab({ agent }: { agent: TeamAgent }) {
const { data: matchRuns = [] } = useMatchRuns()
const { data: signals = [] } = useFutureSignals()
/**
* Die beiden Laufzeit-Kennzahlen einsetzen.
*
* «Betroffene Objekte» zählt Risikosignale mit eindeutig zugeordnetem
* Objekt — und zwar die Objekte, nicht die Signale: Zwei Meldungen über
* dasselbe Objekt sind ein betroffenes Objekt, nicht zwei.
*/
const metrics = useMemo((): AgentMetric[] => {
const laeufe = matchRuns.length
const betroffene = new Set(
signals.filter(sig => isRisiko(sig) && sig.propertyId).map(sig => sig.propertyId!),
).size
return agent.metrics.map((m) => {
if (m.id === MATCH_RUNS_METRIC) {
return {
...m,
value: String(laeufe),
hint: laeufe === 0
? 'Noch kein Matching ausgeführt. Die Zahl steigt nur durch einen tatsächlichen Lauf.'
: `Zuletzt am ${new Date(matchRuns[0].timestamp).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })} für «${matchRuns[0].leadTitle}».`,
}
}
if (m.id === AFFECTED_OBJECTS_METRIC) {
return {
...m,
value: String(betroffene),
hint: betroffene === 0
? 'Derzeit ist keinem Risikosignal ein Objekt aus dem Bestand eindeutig zugeordnet.'
: m.hint,
}
}
return m
})
}, [agent.metrics, matchRuns, signals])
if (metrics.length === 0) {
return (
<EmptyState
title="Keine Kennzahlen hinterlegt"
description={`Für ${agent.name} führt das Personalblatt derzeit keine Kennzahlen.`}
/>
)
}
return (
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' },
}}
>
{metrics.map((metric) => (
<MetricTile key={metric.id} metric={metric} />
))}
</Box>
)
}