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:
1 parent
cc5582b7fd
commit
d200d4e930
352 files changed
+8069
-8512
No files matched your search
@@ -1,19 +1,7 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import type { MockUser } from '../stores/sessionStore'
|
||||
|
||||
// Placeholder AuthContext — swap for real auth (Supabase, Auth0, etc.) later.
|
||||
// All call sites use this context; no component imports sessionStore directly.
|
||||
|
||||
interface AuthContextValue {
|
||||
user: MockUser | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
login: (user: MockUser) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
import { AuthContext } from './authContext'
|
||||
import type { AuthContextValue } from './authContext'
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const currentUser = useSessionStore(s => s.currentUser)
|
||||
@@ -31,9 +19,3 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AgentDirectoryEntry } from '../domain/agentDirectory'
|
||||
|
||||
export interface IAgentDirectoryProvider {
|
||||
getAll(): Promise<AgentDirectoryEntry[]>
|
||||
getByIds(ids: string[]): Promise<AgentDirectoryEntry[]>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { CalendarEvent, CreateCalendarEventInput } from '../domain/calendarEvent'
|
||||
|
||||
export interface ICalendarProvider {
|
||||
/** Ist ein Kalender verbunden? Steuert, ob die Terminplanung angeboten wird. */
|
||||
isConnected(): Promise<boolean>
|
||||
/** Anzeigename des verbundenen Kalenders, z. B. «Microsoft 365 Kalender». */
|
||||
getConnectionName(): Promise<string>
|
||||
getEventsByReminder(reminderId: string): Promise<CalendarEvent[]>
|
||||
create(input: CreateCalendarEventInput): Promise<CalendarEvent>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead'
|
||||
|
||||
export interface IExposeLeadProvider {
|
||||
getAll(): Promise<ExposeLead[]>
|
||||
getById(id: string): Promise<ExposeLead | null>
|
||||
create(input: CreateExposeLeadInput): Promise<ExposeLead>
|
||||
archive(id: string): Promise<ExposeLead>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ExposeDraft } from '../domain/expose'
|
||||
|
||||
export interface IExposeProvider {
|
||||
/** Entwurf zu einem Lead und Objekt — null, solange keiner besteht. */
|
||||
getDraft(leadId: string, propertyId: string): Promise<ExposeDraft | null>
|
||||
save(draft: ExposeDraft): Promise<ExposeDraft>
|
||||
/** Setzt den Erstellungszeitpunkt — trennt «gespeichert» von «erstellt». */
|
||||
markGenerated(draftId: string): Promise<ExposeDraft>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { VisitAssignment } from '../domain/visitAssignment'
|
||||
|
||||
export interface IVisitAssignmentProvider {
|
||||
getAll(): Promise<VisitAssignment[]>
|
||||
getById(id: string): Promise<VisitAssignment | null>
|
||||
/** Bericht anfordern — beendet die Warnung «Besichtigung in weniger als 24 h». */
|
||||
requestReport(id: string): Promise<VisitAssignment>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { agentDirectory } from '../mock-data/agentDirectory'
|
||||
import type { AgentDirectoryEntry } from '../domain/agentDirectory'
|
||||
import type { IAgentDirectoryProvider } from './IAgentDirectoryProvider'
|
||||
|
||||
export const MockupAgentDirectoryProvider: IAgentDirectoryProvider = {
|
||||
async getAll() {
|
||||
return [...agentDirectory]
|
||||
},
|
||||
|
||||
async getByIds(ids) {
|
||||
// Reihenfolge der angefragten IDs beibehalten — die Oberfläche zeigt sie so,
|
||||
// wie der aufrufende Datensatz sie führt, nicht alphabetisch.
|
||||
return ids
|
||||
.map(id => agentDirectory.find(a => a.id === id))
|
||||
.filter((a): a is AgentDirectoryEntry => Boolean(a))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CalendarEvent, CreateCalendarEventInput } from '../domain/calendarEvent'
|
||||
import type { ICalendarProvider } from './ICalendarProvider'
|
||||
import { mockAgentConnections } from '../mock-data/agentConnections'
|
||||
import { AgentConnectionCategory } from '../domain/agentConnection'
|
||||
import { AgentConnectionStatus } from '../domain/teamAgent'
|
||||
|
||||
/**
|
||||
* Kalender-Ablage der Demo.
|
||||
*
|
||||
* Es wird kein echter Kalender kontaktiert. Verbindungsname und Verbindungs-
|
||||
* zustand kommen aus dem bestehenden Bestand «Kanäle & Systeme» — so zeigt die
|
||||
* Terminplanung denselben Kalender an, den der Nutzer dort konfiguriert hat,
|
||||
* statt einen zweiten, erfundenen zu behaupten.
|
||||
*/
|
||||
let store: CalendarEvent[] = []
|
||||
let sequence = 0
|
||||
|
||||
function calendarConnection() {
|
||||
return mockAgentConnections.find(c => c.category === AgentConnectionCategory.CALENDAR)
|
||||
}
|
||||
|
||||
export const MockupCalendarProvider: ICalendarProvider = {
|
||||
async isConnected() {
|
||||
return calendarConnection()?.status === AgentConnectionStatus.CONNECTED
|
||||
},
|
||||
|
||||
async getConnectionName() {
|
||||
return calendarConnection()?.name ?? 'Kalender'
|
||||
},
|
||||
|
||||
async getEventsByReminder(reminderId) {
|
||||
return store.filter(e => e.reminderId === reminderId)
|
||||
},
|
||||
|
||||
async create(input: CreateCalendarEventInput) {
|
||||
sequence += 1
|
||||
const event: CalendarEvent = {
|
||||
...input,
|
||||
id: `cal-${String(sequence).padStart(3, '0')}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
store = [...store, event]
|
||||
return event
|
||||
},
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { IDashboardProvider, DashboardStats } from './IDashboardProvider'
|
||||
import { mockProperties } from '../mock-data/properties'
|
||||
import { mockMatches } from '../mock-data/matches'
|
||||
import { mockNeeds } from '../mock-data/needs'
|
||||
import { mockFutureSignals } from '../mock-data/futureSignals'
|
||||
import { mockReviewQueue } from '../mock-data/reviewQueue'
|
||||
import { mockDelay } from '../lib/mockUtils'
|
||||
|
||||
export const MockupDashboardProvider: IDashboardProvider = {
|
||||
async getStats(organizationId?) {
|
||||
await mockDelay()
|
||||
|
||||
let props = mockProperties
|
||||
let matches = mockMatches
|
||||
let needs = mockNeeds
|
||||
let signals = mockFutureSignals
|
||||
let queue = mockReviewQueue
|
||||
|
||||
if (organizationId) {
|
||||
props = props.filter(p => p.organizationId === organizationId)
|
||||
matches = matches.filter(m => m.organizationId === organizationId)
|
||||
needs = needs.filter(n => n.organizationId === organizationId)
|
||||
signals = signals.filter(s => s.organizationId === organizationId)
|
||||
queue = queue.filter(r => r.relatedOrganizationId === organizationId)
|
||||
}
|
||||
|
||||
const avgScore =
|
||||
matches.length > 0
|
||||
? matches.reduce((sum, m) => sum + m.matchScore, 0) / matches.length
|
||||
: 0
|
||||
|
||||
const stats: DashboardStats = {
|
||||
totalProperties: props.length,
|
||||
verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
|
||||
verifiedPortfolioProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length,
|
||||
futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length,
|
||||
totalMatches: matches.length,
|
||||
pendingReviews: queue.filter(r => r.status === 'PENDING').length,
|
||||
approvedMatches: matches.filter(m => m.isApproved === true).length,
|
||||
activeNeeds: needs.length,
|
||||
totalSignals: signals.length,
|
||||
verifiedSignals: signals.filter(s => s.isVerified).length,
|
||||
averageMatchScore: Math.round(avgScore),
|
||||
highConfidenceMatches: matches.filter(m => m.confidenceLevel >= 0.75).length,
|
||||
}
|
||||
return stats
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mockExposeLeads } from '../mock-data/exposeLeads'
|
||||
import type { ExposeLead } from '../domain/exposeLead'
|
||||
import { ExposeLeadStatus } from '../domain/exposeLead'
|
||||
import type { IExposeLeadProvider } from './IExposeLeadProvider'
|
||||
|
||||
let store: ExposeLead[] = [...mockExposeLeads]
|
||||
let sequence = store.length
|
||||
|
||||
export const MockupExposeLeadProvider: IExposeLeadProvider = {
|
||||
async getAll() {
|
||||
return [...store]
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
return store.find(l => l.id === id) ?? null
|
||||
},
|
||||
|
||||
async create(input) {
|
||||
sequence += 1
|
||||
const lead: ExposeLead = {
|
||||
...input,
|
||||
id: `lead-${String(sequence).padStart(3, '0')}`,
|
||||
receivedAt: input.receivedAt ?? new Date().toISOString(),
|
||||
status: input.status ?? ExposeLeadStatus.ACTIVE,
|
||||
}
|
||||
store = [lead, ...store]
|
||||
return lead
|
||||
},
|
||||
|
||||
async archive(id) {
|
||||
const idx = store.findIndex(l => l.id === id)
|
||||
if (idx === -1) throw new Error(`Lead ${id} nicht gefunden`)
|
||||
store[idx] = { ...store[idx], status: ExposeLeadStatus.ARCHIVED }
|
||||
return store[idx]
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ExposeDraft } from '../domain/expose'
|
||||
import type { IExposeProvider } from './IExposeProvider'
|
||||
|
||||
/**
|
||||
* Entwurfsablage der Demo — in-memory, wie alle Mockup-Provider.
|
||||
*
|
||||
* Der Schlüssel ist das Paar aus Lead und Objekt: derselbe Lead kann mehrere
|
||||
* Objekte empfehlen, und jedes bekommt sein eigenes Exposé.
|
||||
*/
|
||||
const store = new Map<string, ExposeDraft>()
|
||||
|
||||
function key(leadId: string, propertyId: string): string {
|
||||
return `${leadId}::${propertyId}`
|
||||
}
|
||||
|
||||
export const MockupExposeProvider: IExposeProvider = {
|
||||
async getDraft(leadId, propertyId) {
|
||||
return store.get(key(leadId, propertyId)) ?? null
|
||||
},
|
||||
|
||||
async save(draft) {
|
||||
const saved: ExposeDraft = { ...draft, updatedAt: new Date().toISOString() }
|
||||
store.set(key(draft.leadId, draft.propertyId), saved)
|
||||
return saved
|
||||
},
|
||||
|
||||
async markGenerated(draftId) {
|
||||
for (const [k, d] of store) {
|
||||
if (d.id === draftId) {
|
||||
const updated: ExposeDraft = { ...d, generatedAt: new Date().toISOString() }
|
||||
store.set(k, updated)
|
||||
return updated
|
||||
}
|
||||
}
|
||||
throw new Error(`Exposé-Entwurf ${draftId} nicht gefunden`)
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '
|
||||
import { MOCK_MARKET_SIGNALS } from '../mock-data/marketSignals'
|
||||
|
||||
// Mutable in-memory copy for status updates
|
||||
let signals: MarketSignal[] = [...MOCK_MARKET_SIGNALS]
|
||||
const signals: MarketSignal[] = [...MOCK_MARKET_SIGNALS]
|
||||
|
||||
function applyFilters(data: MarketSignal[], filters?: MarketSignalFilters): MarketSignal[] {
|
||||
if (!filters) return data
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Reminder, ReminderActivity } from '../domain/reminder'
|
||||
import { ReminderStatus } from '../domain/reminder'
|
||||
import type { IReminderProvider } from './IReminderProvider'
|
||||
|
||||
let store: Reminder[] = [...mockReminders]
|
||||
const store: Reminder[] = [...mockReminders]
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GateStatus, PipelineStage } from '../domain/signalPipeline'
|
||||
import type { GateType } from '../domain/signalPipeline'
|
||||
import { MOCK_PIPELINE_STATES, MOCK_AUDIT_TRAILS } from '../mock-data/signalPipelines'
|
||||
|
||||
let states: PipelineState[] = [...MOCK_PIPELINE_STATES]
|
||||
const states: PipelineState[] = [...MOCK_PIPELINE_STATES]
|
||||
|
||||
export const MockupSignalPipelineProvider: ISignalPipelineProvider = {
|
||||
async getPipelineState(signalId) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { mockVisitAssignments } from '../mock-data/visitAssignments'
|
||||
import type { VisitAssignment } from '../domain/visitAssignment'
|
||||
import type { IVisitAssignmentProvider } from './IVisitAssignmentProvider'
|
||||
|
||||
const store: VisitAssignment[] = [...mockVisitAssignments]
|
||||
|
||||
export const MockupVisitAssignmentProvider: IVisitAssignmentProvider = {
|
||||
async getAll() {
|
||||
return [...store]
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
return store.find(a => a.id === id) ?? null
|
||||
},
|
||||
|
||||
async requestReport(id) {
|
||||
const idx = store.findIndex(a => a.id === id)
|
||||
if (idx === -1) throw new Error(`Auftrag ${id} nicht gefunden`)
|
||||
const current = store[idx]
|
||||
if (!current.preparation) throw new Error('Nur Vorbereitungsaufträge kennen einen Bericht.')
|
||||
store[idx] = { ...current, preparation: { ...current.preparation, reportRequested: true } }
|
||||
return store[idx]
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContext } from 'react'
|
||||
import type { MockUser } from '../stores/sessionStore'
|
||||
|
||||
/**
|
||||
* Platzhalter-Auth-Kontext — wird später gegen eine echte Anmeldung getauscht
|
||||
* (Supabase, Auth0 o. ä.). Alle Aufrufer gehen über diesen Kontext; keine
|
||||
* Komponente greift direkt auf den `sessionStore` zu.
|
||||
*
|
||||
* Kontext und Provider liegen in getrennten Dateien, weil eine `.tsx`-Datei,
|
||||
* die eine Komponente und einen Nicht-Komponenten-Wert exportiert, im
|
||||
* Entwicklungsmodus bei jeder Änderung ihren Zustand verliert.
|
||||
*/
|
||||
export interface AuthContextValue {
|
||||
user: MockUser | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
login: (user: MockUser) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
Reference in new issue
Block a user