feat(agenten): Runde 4 — «Meine Agenten» mit fünf digitalen Mitarbeitenden

Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um.

Informationsarchitektur
- «Teamübersicht» heisst «Meine Agenten»; ihr bisheriger Inhalt (Kreisgrafik,
  Auswertung, Verbindungszusammenfassung) ist entfallen.
- Personalverwaltung, Bearbeitungsverlauf und Kanäle & Systeme sind jetzt
  Reiter derselben Seite (?section=), gestaltet wie die Auswahl im
  Bearbeitungsverlauf. Es wird nur der gewählte Bereich gerendert.
- Die alten Unterseitenpfade leiten um, damit Lesezeichen nicht brechen.

Agentenbestand
- Reto und Lea vollständig entfernt — aus Navigation, Dossiers, Protokoll,
  Verbindungen, Vorgängen und Porträtbestand.
- Retos Aufgaben liegen bei Bruno: Nachbereitung, WhatsApp-Anruf,
  Protokoll und Kundennotiz, Ablage im CRM.
- Livia ist «Exposé Master»: Lageberichte, Inserate, Angebotsbroschüren.
- Sidebar führt Ferdi, Bruno, Livia, Nora, Sina mit Porträt und Funktion.

Agentenseiten
- Ein gemeinsamer AgentWorkspaceHero auf allen fünf Seiten.
- Ferdi: Auswertungskarten, Priorität und Typfarben entfallen; Fälligkeit nur
  bei fünf Tagen oder weniger rot; Objektlinks nach «Meine Objekte»; neu die
  Terminplanung im verbundenen Kalender mit typgerechtem PDF-Ausschnitt.
- Sina: reduzierte, filter- und sortierbare Objektübersicht; Detailansicht
  direkt editierbar, leere Pflichtfelder rot umrandet.
- Nora: Signale ohne Prozentsätze und Konfidenzstufen, nur belegbare Angaben;
  Mehrfachauswahl leitet Objekte an Livia weiter.
- Livia: aktive und archivierte Leads, Arbeitsbereich gleitet an den oberen
  Rand; dreistufiger Exposé-Prozess Hochladen → Exposé → Export.
- Bruno: neue Seite mit Auftragsliste und Vor-/Nachbereitungs-Drawer;
  Glocke warnt bei Besichtigung unter 24 Stunden ohne Bericht.

Datenschicht
- Neu: Kalender, Exposé-Leads, Exposé-Entwürfe, Besichtigungsaufträge —
  je Domain, Provider, Service und Hook.
- IAIService um generateExposeText erweitert; der Entwurf nutzt ausschliesslich
  erfasste Objektdaten und meldet Lücken, statt sie zu füllen.
- Alle Objektverweise zeigen auf reale Einträge aus «Meine Objekte»; neue
  Detailroute /supply/properties/:propertyId.

Offen: Chat, Kalender, CRM und DMS sind Frontend-Simulation ohne Anbindung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-05 14:43:16 +02:00
1 parent cc5582b7fd
commit d200d4e930
352 files changed
+8069 -8512

No files matched your search

-41
View File
@@ -1,41 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { aiMonitoringService } from '../services/aiMonitoringService'
import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider'
import type { ReviewStatus } from '../domain/enums'
import { useToastStore } from '../stores/toastStore'
const QK = 'aiOutputs'
const STALE = 30_000
export function useAIOutputs(filters?: AIMonitoringFilters) {
return useQuery({
queryKey: [QK, filters ?? {}],
queryFn: () => aiMonitoringService.getOutputs(filters),
staleTime: STALE,
select: (res) => res.data ?? [],
})
}
export function useAIOutput(id: string | null) {
return useQuery({
queryKey: [QK, 'detail', id],
queryFn: () => aiMonitoringService.getOutput(id!),
staleTime: STALE,
enabled: !!id,
select: (res) => res.data ?? null,
})
}
export function useUpdateAIOutputReviewStatus() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
aiMonitoringService.updateReviewStatus(id, status),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [QK] })
},
onError: () => {
useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error')
},
})
}
+32
View File
@@ -0,0 +1,32 @@
import { useCallback, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { agentDirectoryService } from '../services/agentDirectoryService'
import type { AgentDirectoryEntry } from '../domain/agentDirectory'
import { STALE_TEAM_AGENTS } from '../lib/constants'
/**
* Stammdaten der digitalen Belegschaft.
*
* Das Verzeichnis war die letzte Datenquelle ohne Provider — die Porträtgruppe
* las es direkt aus den Mockdaten. Jetzt läuft es über dieselbe Schichtung wie
* alles andere und lässt sich später gegen ein echtes Verzeichnis tauschen.
*/
export function useAgentDirectory() {
return useQuery({
queryKey: ['agent-directory'],
queryFn: async () => (await agentDirectoryService.getAll()).data,
staleTime: STALE_TEAM_AGENTS,
})
}
/** Nachschlag anhand von IDs, in der Reihenfolge der Anfrage. */
export function useAgentDirectoryLookup() {
const { data: entries = [] } = useAgentDirectory()
const byId = useMemo(() => new Map(entries.map(e => [e.id, e])), [entries])
return useCallback(
(ids: string[]): AgentDirectoryEntry[] =>
ids.map(id => byId.get(id)).filter((e): e is AgentDirectoryEntry => Boolean(e)),
[byId],
)
}
+16
View File
@@ -1,8 +1,24 @@
import { useContext } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { authService } from '../services/authService'
import { AuthContext } from '../provider/authContext'
import type { AuthContextValue } from '../provider/authContext'
import type { UserRole } from '../domain/enums'
/**
* Zugriff auf die angemeldete Person.
*
* Lag bis zum Aufräumen neben der Provider-Komponente — ein Hook in einer
* Komponentendatei kostet dort den Fast-Refresh-Zustand und steht ausserdem
* nicht dort, wo man Hooks sucht.
*/
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden.')
return ctx
}
export function useLogin() {
const navigate = useNavigate()
return useMutation({
+38
View File
@@ -0,0 +1,38 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { calendarService } from '../services/calendarService'
import type { CreateCalendarEventInput } from '../domain/calendarEvent'
import { STALE_CALENDAR } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useCalendarStatus() {
return useQuery({
queryKey: ['calendar', 'status'],
queryFn: async () => (await calendarService.getStatus()).data,
staleTime: STALE_CALENDAR,
})
}
export function useCalendarEventsByReminder(reminderId: string) {
return useQuery({
queryKey: ['calendar', 'reminder', reminderId],
queryFn: async () => (await calendarService.getByReminder(reminderId)).data,
enabled: !!reminderId,
staleTime: STALE_CALENDAR,
})
}
export function useCreateCalendarEvent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateCalendarEventInput) => (await calendarService.create(input)).data,
onSuccess: (event) => {
queryClient.invalidateQueries({ queryKey: ['calendar'] })
if (event.reminderId) {
queryClient.invalidateQueries({ queryKey: ['calendar', 'reminder', event.reminderId] })
}
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message || 'Termin konnte nicht eingeplant werden.', 'error')
},
})
}
-81
View File
@@ -1,81 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import type { UnifiedMatchResult } from '../domain/unifiedResult'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../domain/needBuilder'
import type { WeightingKey } from '../domain/needBuilder'
import { aiService } from '../services/aiService'
import { needService } from '../services/needService'
import { CRITERION_ALIASES, getProp } from '../components/compare/compareUtils'
export function useCompareData(compareItems: UnifiedMatchResult[]) {
const { data: aiSummary, isLoading: aiLoading } = useQuery({
queryKey: ['ai-compare', compareItems.map(i => i.matchId)],
queryFn: () => aiService.summarizeComparison(compareItems),
enabled: compareItems.length >= 2,
select: r => r.data,
staleTime: Infinity,
})
const { data: needsData } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
select: r => r.data,
})
const activeNeed = needsData
? [...needsData].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
: undefined
const relevantCriteria = activeNeed
? WEIGHTING_KEYS
.map(key => ({ key, label: WEIGHTING_LABELS[key], weight: activeNeed.weightingProfile[key] ?? 0 }))
.filter(c => c.weight > 0)
.sort((a, b) => b.weight - a.weight)
: []
const weightedTotals = compareItems.map(item =>
relevantCriteria.reduce((sum, { key, weight }) => {
const factor = [...item.match.positiveFactors, ...item.match.negativeFactors]
.find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase()))
return sum + (factor?.score ?? 50) * weight
}, 0)
)
const maxWeightedTotal = Math.max(...weightedTotals)
const overallWinnerIdx = compareItems.length > 1 && weightedTotals.filter(t => t === maxWeightedTotal).length === 1
? weightedTotals.indexOf(maxWeightedTotal)
: -1
const bestScoreIdx = compareItems.length > 0
? compareItems.reduce(
(best, item, i) => item.matchScore > compareItems[best].matchScore ? i : best, 0
)
: -1
const worstConfIdx = compareItems.length > 0
? compareItems.reduce(
(worst, item, i) => item.match.confidenceLevel < compareItems[worst].match.confidenceLevel ? i : worst, 0
)
: -1
const dqScores = compareItems.map(item => getProp(item)?.dataQuality.score ?? 1)
const worstDQIdx = dqScores.length > 0 ? dqScores.indexOf(Math.min(...dqScores)) : -1
const missingCriticalCounts = compareItems.map(
item => item.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0
)
const maxMissingCritical = missingCriticalCounts.length > 0 ? Math.max(...missingCriticalCounts) : 0
return {
aiSummary,
aiLoading,
activeNeed,
relevantCriteria,
weightedTotals,
overallWinnerIdx,
bestScoreIdx,
worstConfIdx,
dqScores,
worstDQIdx,
missingCriticalCounts,
maxMissingCritical,
}
}
+60
View File
@@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { exposeService } from '../services/exposeService'
import { aiService } from '../services/aiService'
import type { ExposeDraft } from '../domain/expose'
import type { Property } from '../domain/property'
import type { ExposeTextInput } from '../services/ai/IAIService'
import { STALE_EXPOSE_LEADS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useExposeDraft(leadId: string, property: Property | undefined) {
return useQuery({
queryKey: ['expose-draft', leadId, property?.id],
queryFn: async () => (await exposeService.getOrCreateDraft(leadId, property!)).data,
enabled: !!leadId && !!property,
staleTime: STALE_EXPOSE_LEADS,
})
}
export function useSaveExposeDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draft: ExposeDraft) => (await exposeService.save(draft)).data,
onSuccess: (draft) => {
queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft)
},
onError: () => {
useToastStore.getState().showToast('Exposé konnte nicht gespeichert werden.', 'error')
},
})
}
export function useGenerateExpose() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draft: ExposeDraft) => (await exposeService.generate(draft)).data,
onSuccess: (draft) => {
queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft)
useToastStore.getState().showToast('Exposé erstellt — bereit für den Export.', 'success')
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message || 'Exposé konnte nicht erstellt werden.', 'error')
},
})
}
/**
* KI-Textentwurf für einen Abschnitt.
*
* Fehlen belegte Angaben, liefert der Dienst keinen Text, sondern die Liste der
* Lücken — die Oberfläche sagt das dann so, statt einen Text zu zeigen, der
* Angaben behauptet, die niemand geprüft hat (§8.5.2, §9.6).
*/
export function useGenerateExposeText() {
return useMutation({
mutationFn: async (input: ExposeTextInput) => (await aiService.generateExposeText(input)).data,
onError: () => {
useToastStore.getState().showToast('Textentwurf konnte nicht erzeugt werden — bitte manuell erfassen.', 'error')
},
})
}
+39
View File
@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { exposeLeadService } from '../services/exposeLeadService'
import type { CreateExposeLeadInput } from '../domain/exposeLead'
import { STALE_EXPOSE_LEADS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useExposeLeads() {
return useQuery({
queryKey: ['expose-leads'],
queryFn: async () => (await exposeLeadService.getAll()).data,
staleTime: STALE_EXPOSE_LEADS,
})
}
export function useCreateExposeLead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateExposeLeadInput) => (await exposeLeadService.create(input)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expose-leads'] })
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message || 'Lead konnte nicht weitergeleitet werden.', 'error')
},
})
}
export function useArchiveExposeLead() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => (await exposeLeadService.archive(id)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expose-leads'] })
},
onError: () => {
useToastStore.getState().showToast('Lead konnte nicht archiviert werden.', 'error')
},
})
}
-51
View File
@@ -1,51 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useMatchDetail } from './useMatches'
import { propertyService } from '../services/propertyService'
import { needService } from '../services/needService'
import { futureSignalService } from '../services/futureSignalService'
import type { ItemResponse } from '../services/types'
import type { Property } from '../domain/property'
import type { Need } from '../domain/need'
import type { FutureSignal } from '../domain/futureSignal'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
export function useMatchDetailData(matchId: string) {
const { data: match, isLoading } = useMatchDetail(matchId)
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
const { data: property = null } = useQuery<ItemResponse<Property | null>, Error, Property | null>({
queryKey: ['property', match?.propertyId],
queryFn: () => propertyService.getById(match!.propertyId),
enabled: !!match && !isFuture,
select: r => r.data ?? null,
})
const { data: need = null } = useQuery<ItemResponse<Need | null>, Error, Need | null>({
queryKey: ['need', match?.needId],
queryFn: () => needService.getById(match!.needId),
enabled: !!match?.needId,
select: r => r.data ?? null,
})
const { data: signal = null } = useQuery<FutureSignal | null>({
queryKey: ['signal', match?.resultId],
queryFn: async () => {
// resultId may be a signal ID ('signal-002') or a property ID ('prop-006')
const byId = await futureSignalService.getById(match!.resultId!)
if (byId.data) return byId.data
const byProp = await futureSignalService.getByProperty(match!.resultId!)
return byProp.data[0] ?? null
},
enabled: !!match && isFuture && !!match.resultId,
})
return {
match: match as Match | null | undefined,
property,
need,
signal,
isLoading,
isFuture,
}
}
+35
View File
@@ -0,0 +1,35 @@
import { useCallback, useMemo } from 'react'
import { useProperties } from './useProperties'
import type { Property } from '../domain/property'
/**
* Nachschlagen von Objekten anhand ihrer ID.
*
* Mehrere Komponenten haben dafür bis zum Aufräumen direkt `mock-data/properties`
* importiert und damit Provider, Service und Hook übersprungen — beim Wechsel
* auf ein echtes Backend hätten genau diese Stellen weiter Demodaten angezeigt.
* Der Hook liest aus derselben Abfrage wie alle anderen Objektansichten.
*/
export function usePropertyLookup() {
const { data: properties = [] } = useProperties()
const byId = useMemo(() => new Map(properties.map(p => [p.id, p])), [properties])
const findProperty = useCallback(
(id: string): Property | undefined => byId.get(id),
[byId],
)
/** Objektname, oder die ID selbst, wenn kein Objekt dazu existiert. */
const propertyTitle = useCallback(
(id: string): string => byId.get(id)?.title ?? id,
[byId],
)
const findProperties = useCallback(
(ids: string[]): Property[] => ids.map(id => byId.get(id)).filter((p): p is Property => Boolean(p)),
[byId],
)
return { findProperty, findProperties, propertyTitle }
}
+2 -1
View File
@@ -2,9 +2,10 @@ import { useMemo } from 'react'
import type { Property, PropertyUnit } from '../domain/property'
import type { FutureSignal } from '../domain/futureSignal'
import { SignalType, RiskLevel, ResultType } from '../domain/enums'
import { mockToday } from '../lib/constants'
// Matches the mock date used throughout the prototype (currentDate context: 2026-05-20)
const MOCK_TODAY = new Date('2026-05-20')
const MOCK_TODAY = mockToday()
export function useSchattenmarktSignals(properties: Property[]): FutureSignal[] {
return useMemo(() => {
-28
View File
@@ -1,28 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { teamDemoService } from '../services/teamDemoService'
import { useToastStore } from '../stores/toastStore'
import { QK_TEAM_AGENTS } from './useTeamAgents'
import { QK_AGENT_WORK_ITEMS, QK_AGENT_KPIS } from './useAgentWorkItems'
import { QK_AGENT_PROTOCOL } from './useAgentProtocol'
import { QK_AGENT_CONNECTIONS } from './useAgentConnections'
/**
* «Demo zurücksetzen». Entwertet alle vier Property-On-Bestände, damit die
* Oberfläche unmittelbar den Auslieferungszustand zeigt — ein Reload ist nicht
* nötig.
*/
export function useResetTeamDemo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => teamDemoService.reset(),
onSuccess: () => {
for (const key of [QK_TEAM_AGENTS, QK_AGENT_WORK_ITEMS, QK_AGENT_KPIS, QK_AGENT_PROTOCOL, QK_AGENT_CONNECTIONS]) {
queryClient.invalidateQueries({ queryKey: [key] })
}
useToastStore.getState().showToast('Demo zurückgesetzt. Alle lokalen Änderungen wurden verworfen.', 'success')
},
onError: () => {
useToastStore.getState().showToast('Demo konnte nicht zurückgesetzt werden.', 'error')
},
})
}
+17 -3
View File
@@ -7,6 +7,20 @@ import type {
UnifiedMatchResult,
VerifiedPortfolioResult,
} from '../domain/unifiedResult'
import type { Match } from '../domain/match'
import type { Property } from '../domain/property'
import type { FutureSignal } from '../domain/futureSignal'
/**
* Stabile Leerwerte.
*
* `query.data ?? []` erzeugt bei jedem Rendern ein neues Array. Jedes `useMemo`,
* das darauf hört, rechnet dann bei jedem Rendern neu — der Zwischenspeicher
* war wirkungslos, ohne dass man es sah.
*/
const EMPTY_MATCHES: Match[] = []
const EMPTY_PROPERTIES: Property[] = []
const EMPTY_SIGNALS: FutureSignal[] = []
export function useUnifiedResults(needId?: string) {
const needMatchesQuery = useMatchesByNeed(needId ?? '')
@@ -20,9 +34,9 @@ export function useUnifiedResults(needId?: string) {
matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading
const error = matchesQuery.error ?? propertiesQuery.error ?? signalsQuery.error
const matches = matchesQuery.data ?? []
const properties = propertiesQuery.data ?? []
const signals = signalsQuery.data ?? []
const matches = matchesQuery.data ?? EMPTY_MATCHES
const properties = propertiesQuery.data ?? EMPTY_PROPERTIES
const signals = signalsQuery.data ?? EMPTY_SIGNALS
const schattenmarktSignals = useSchattenmarktSignals(properties)
const allSignals = useMemo(() => [...signals, ...schattenmarktSignals], [signals, schattenmarktSignals])
+36
View File
@@ -0,0 +1,36 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { unitService } from '../services/unitService'
import type { PropertyUnit } from '../domain/property'
import { useToastStore } from '../stores/toastStore'
/**
* Änderung an einer Mieteinheit.
*
* Die Pre-Market-Freigabe griff bis zum Aufräumen direkt auf
* `MockupUnitProvider` zu und übersprang damit Service und Hook. Beim Wechsel
* auf ein echtes Backend wäre genau diese Stelle stehen geblieben — sie hätte
* weiter in den lokalen Mockup geschrieben, während alles andere schon am
* Server hing.
*/
interface UpdateUnitInput {
unitId: string
data: Partial<PropertyUnit>
/** Objekt, dessen Zwischenspeicher mit erneuert wird. */
propertyId?: string
}
export function useUpdateUnit() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ unitId, data }: UpdateUnitInput) =>
(await unitService.update(unitId, data)).data,
onSuccess: (_unit, { propertyId }) => {
queryClient.invalidateQueries({ queryKey: ['units'] })
queryClient.invalidateQueries({ queryKey: ['properties'] })
if (propertyId) queryClient.invalidateQueries({ queryKey: ['property', propertyId] })
},
onError: () => {
useToastStore.getState().showToast('Einheit konnte nicht gespeichert werden.', 'error')
},
})
}
+35
View File
@@ -0,0 +1,35 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { visitAssignmentService } from '../services/visitAssignmentService'
import { STALE_VISIT_ASSIGNMENTS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
export function useVisitAssignments() {
return useQuery({
queryKey: ['visit-assignments'],
queryFn: async () => (await visitAssignmentService.getAll()).data,
staleTime: STALE_VISIT_ASSIGNMENTS,
})
}
/** Grundlage der Benachrichtigungsglocke: Besichtigung in unter 24 h ohne Bericht. */
export function useVisitReportWarnings() {
return useQuery({
queryKey: ['visit-assignments', 'warnings'],
queryFn: async () => (await visitAssignmentService.getPendingReportWarnings()).data,
staleTime: STALE_VISIT_ASSIGNMENTS,
})
}
export function useRequestVisitReport() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: string) => (await visitAssignmentService.requestReport(id)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['visit-assignments'] })
useToastStore.getState().showToast('Bericht bei Bruno angefordert.', 'success')
},
onError: () => {
useToastStore.getState().showToast('Bericht konnte nicht angefordert werden.', 'error')
},
})
}