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>
123 lines
3.9 KiB
TypeScript
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>
|
|
)
|
|
}
|