feat(team): Datenschicht für Property On
Property On wird als Funktionsbereich "Teamübersicht" in Property Match eingebettet — sieben digitale Mitarbeiter, die ausführen, während der Mensch freigibt, anpasst oder zurückweist. Durchgehend Provider → Service → Hook, kein übersprungener Layer: - domain/: teamAgent, agentWorkItem, agentProtocol, agentConnection, agentFilters - mock-data/: 7 Personalblätter aus dem Agenten-Katalog (Aufgabenzahlen exakt: Ferdi 9, Sina 7, Lea 11, Bruno 9, Reto 10, Livia 11, Nora 8), 14 Vorgänge, 38 Protokolleinträge, 9 Verbindungen - provider/: vier Mockup-Provider mit versionierter localStorage-Persistenz - services/: Fachlogik inkl. Freigabe, Zurückweisung, Kennzahlen, Demo-Reset - hooks/: React Query, Mutationen entwerten Vorgänge, Kennzahlen und Protokoll gemeinsam — sonst zeigt die Oberfläche widersprüchliche Zahlen Namensgebung: Der Entitätstyp heisst TeamAgent, nicht Agent. Im Repo existiert bereits PowerOn als KI-Backend-Proxy, und Claude Code legt Arbeitskopien unter agent-* ab; ein nackter Typ Agent wäre in Suchen nicht auffindbar. Alle Satellitentypen sind Agent*-präfixiert, weil domain/index.ts per export * bündelt und generische Namen dort kollidieren. Eigener Protokolltyp statt ActivityEvent: den Namen gibt es im Repo dreifach und gegenseitig inkompatibel (domain/activityEvent.ts, governanceService.ts, PropertyActivityLogPanel.tsx). Ein Anschluss hätte den Konflikt zementiert. Demo-Uhr in lib/teamClock.ts: die Mockdaten sind auf den 20.05.2026 verankert. Gegen die Systemzeit gerechnet wären die Filter "heute / diese Woche / dieser Monat" an jedem anderen Kalendertag leer und die Auswertung sähe kaputt aus, obwohl sie korrekt rechnet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Subreiter «Kanäle & Systeme»: die zentralen Verbindungen der
|
||||||
|
* Organisation.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt von `domain/dataSource.ts` (`DataSource`/`ConnectorRun`):
|
||||||
|
* jenes Modell beschreibt Crawler-Quellen mit AGB-Status und Crawl-Läufen, hier
|
||||||
|
* geht es um angebundene Kommunikationskanäle und Fachsysteme. Zwei fachlich
|
||||||
|
* verschiedene Dinge im selben Typ zu führen wäre eine Scheinvereinfachung.
|
||||||
|
*
|
||||||
|
* Alle Verbindungen sind Frontend-Simulation — es werden nie echte Zugangsdaten
|
||||||
|
* erfasst und nie echte Systeme kontaktiert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentAccessLevel, AgentConnectionStatus } from './teamAgent'
|
||||||
|
|
||||||
|
export const AgentConnectionCategory = {
|
||||||
|
EMAIL_M365: 'EMAIL_M365',
|
||||||
|
WHATSAPP: 'WHATSAPP',
|
||||||
|
TEAMS: 'TEAMS',
|
||||||
|
CALENDAR: 'CALENDAR',
|
||||||
|
DOCUMENT_STORE: 'DOCUMENT_STORE',
|
||||||
|
PHONE_VOICE: 'PHONE_VOICE',
|
||||||
|
ERP: 'ERP',
|
||||||
|
CRM: 'CRM',
|
||||||
|
PUBLIC_SOURCES: 'PUBLIC_SOURCES',
|
||||||
|
} as const
|
||||||
|
export type AgentConnectionCategory = typeof AgentConnectionCategory[keyof typeof AgentConnectionCategory]
|
||||||
|
|
||||||
|
/** ERP-Auswahl aus §13.4. */
|
||||||
|
export const AgentErpVendor = {
|
||||||
|
IMMOTOP2: 'IMMOTOP2',
|
||||||
|
ABACUS_ABAIMMO: 'ABACUS_ABAIMMO',
|
||||||
|
RIMO_R5: 'RIMO_R5',
|
||||||
|
GARAIO_REM: 'GARAIO_REM',
|
||||||
|
OTHER: 'OTHER',
|
||||||
|
} as const
|
||||||
|
export type AgentErpVendor = typeof AgentErpVendor[keyof typeof AgentErpVendor]
|
||||||
|
|
||||||
|
export interface AgentConnectionPermission {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
access: AgentAccessLevel
|
||||||
|
granted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentConnection {
|
||||||
|
id: string
|
||||||
|
category: AgentConnectionCategory
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
status: AgentConnectionStatus
|
||||||
|
accountCount: number
|
||||||
|
usedByAgentIds: string[]
|
||||||
|
lastSyncAt?: string
|
||||||
|
/** Nur bei `category === ERP` gesetzt. */
|
||||||
|
vendor?: AgentErpVendor
|
||||||
|
/** Frei benannte Verbindung aus dem Assistenten, z. B. «Bewirtschaftung Zürich». */
|
||||||
|
connectionLabel?: string
|
||||||
|
permissions: AgentConnectionPermission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis von «Verbindung testen» — deterministisch, kein Zufall (§19.2). */
|
||||||
|
export interface AgentConnectionTestResult {
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAgentConnectionInput {
|
||||||
|
connectionId: string
|
||||||
|
vendor?: AgentErpVendor
|
||||||
|
connectionLabel: string
|
||||||
|
permissionIds: string[]
|
||||||
|
agentIds: string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Filter- und Sortierdimensionen.
|
||||||
|
*
|
||||||
|
* Eigene Datei, weil sowohl die Auswertung auf der Teamübersicht (§4.2) als auch
|
||||||
|
* der Bearbeitungsverlauf (§5.3) und das Protokoll (§12.3) denselben Zeitraum-
|
||||||
|
* begriff teilen. Ohne gemeinsamen Typ driften die drei Filter auseinander.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AgentPeriod = {
|
||||||
|
TODAY: 'TODAY',
|
||||||
|
WEEK: 'WEEK',
|
||||||
|
MONTH: 'MONTH',
|
||||||
|
YEAR: 'YEAR',
|
||||||
|
ALL: 'ALL',
|
||||||
|
} as const
|
||||||
|
export type AgentPeriod = typeof AgentPeriod[keyof typeof AgentPeriod]
|
||||||
|
|
||||||
|
export const AgentWorkItemSort = {
|
||||||
|
NEWEST: 'NEWEST',
|
||||||
|
OLDEST: 'OLDEST',
|
||||||
|
PRIORITY: 'PRIORITY',
|
||||||
|
AGENT: 'AGENT',
|
||||||
|
OBJECT_ID: 'OBJECT_ID',
|
||||||
|
} as const
|
||||||
|
export type AgentWorkItemSort = typeof AgentWorkItemSort[keyof typeof AgentWorkItemSort]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grenze eines Zeitraums, gemessen am Referenzdatum.
|
||||||
|
* Gibt den frühesten noch enthaltenen Zeitpunkt zurück, oder null für ALL.
|
||||||
|
*/
|
||||||
|
export function periodStart(period: AgentPeriod, reference: Date): Date | null {
|
||||||
|
const d = new Date(reference)
|
||||||
|
switch (period) {
|
||||||
|
case AgentPeriod.TODAY:
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
case AgentPeriod.WEEK: {
|
||||||
|
// Woche beginnt am Montag — Schweizer Konvention
|
||||||
|
const day = (d.getDay() + 6) % 7
|
||||||
|
d.setDate(d.getDate() - day)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
case AgentPeriod.MONTH:
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
case AgentPeriod.YEAR:
|
||||||
|
d.setMonth(0, 1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
case AgentPeriod.ALL:
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liegt `isoTimestamp` im gewählten Zeitraum? */
|
||||||
|
export function isWithinPeriod(isoTimestamp: string, period: AgentPeriod, reference: Date): boolean {
|
||||||
|
const start = periodStart(period, reference)
|
||||||
|
if (!start) return true
|
||||||
|
const t = new Date(isoTimestamp).getTime()
|
||||||
|
return !Number.isNaN(t) && t >= start.getTime()
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Protokolleinträge.
|
||||||
|
*
|
||||||
|
* Bewusst ein eigener Typ und nicht `ActivityEvent`: den Namen gibt es im Repo
|
||||||
|
* bereits dreifach und gegenseitig inkompatibel — in `domain/activityEvent.ts`
|
||||||
|
* (über das Domain-Barrel exportiert), lokal in `services/governanceService.ts`
|
||||||
|
* und noch einmal in `components/supply/PropertyActivityLogPanel.tsx`. Ein
|
||||||
|
* Anschluss an einen dieser Typen würde den Konflikt zementieren.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentChannelType } from './teamAgent'
|
||||||
|
import type { AgentDetailField, AgentProcessingStep, AgentSourceReference } from './agentWorkItem'
|
||||||
|
|
||||||
|
export const AgentProtocolStatus = {
|
||||||
|
SUCCESS: 'SUCCESS',
|
||||||
|
WARNING: 'WARNING',
|
||||||
|
ERROR: 'ERROR',
|
||||||
|
INFO: 'INFO',
|
||||||
|
} as const
|
||||||
|
export type AgentProtocolStatus = typeof AgentProtocolStatus[keyof typeof AgentProtocolStatus]
|
||||||
|
|
||||||
|
export const AgentProtocolEventType = {
|
||||||
|
TASK_RUN: 'TASK_RUN',
|
||||||
|
MESSAGE_PROCESSED: 'MESSAGE_PROCESSED',
|
||||||
|
REMINDER_SENT: 'REMINDER_SENT',
|
||||||
|
ESCALATION: 'ESCALATION',
|
||||||
|
APPROVAL: 'APPROVAL',
|
||||||
|
REJECTION: 'REJECTION',
|
||||||
|
EDIT: 'EDIT',
|
||||||
|
SETTINGS_SAVED: 'SETTINGS_SAVED',
|
||||||
|
TASKS_SAVED: 'TASKS_SAVED',
|
||||||
|
AGENT_PAUSED: 'AGENT_PAUSED',
|
||||||
|
AGENT_RESUMED: 'AGENT_RESUMED',
|
||||||
|
CHANNEL_TESTED: 'CHANNEL_TESTED',
|
||||||
|
CHANNEL_TOGGLED: 'CHANNEL_TOGGLED',
|
||||||
|
CONNECTION_CREATED: 'CONNECTION_CREATED',
|
||||||
|
CONNECTION_REMOVED: 'CONNECTION_REMOVED',
|
||||||
|
DEMO_RESET: 'DEMO_RESET',
|
||||||
|
} as const
|
||||||
|
export type AgentProtocolEventType = typeof AgentProtocolEventType[keyof typeof AgentProtocolEventType]
|
||||||
|
|
||||||
|
/** Wer den Eintrag ausgelöst hat — Mensch oder Zeitplan/Systemtrigger. */
|
||||||
|
export const AgentTriggerSource = {
|
||||||
|
USER: 'USER',
|
||||||
|
SYSTEM: 'SYSTEM',
|
||||||
|
} as const
|
||||||
|
export type AgentTriggerSource = typeof AgentTriggerSource[keyof typeof AgentTriggerSource]
|
||||||
|
|
||||||
|
export const AgentApprovalDecision = {
|
||||||
|
APPROVED: 'APPROVED',
|
||||||
|
REJECTED: 'REJECTED',
|
||||||
|
EDITED: 'EDITED',
|
||||||
|
} as const
|
||||||
|
export type AgentApprovalDecision = typeof AgentApprovalDecision[keyof typeof AgentApprovalDecision]
|
||||||
|
|
||||||
|
export interface AgentProtocolEntry {
|
||||||
|
id: string
|
||||||
|
/**
|
||||||
|
* Verantwortlicher digitaler Mitarbeiter. Fehlt bei organisationsweiten
|
||||||
|
* Ereignissen — eine Verbindung im Subreiter «Kanäle & Systeme» gehört keinem
|
||||||
|
* einzelnen Mitarbeiter. Solche Einträge erscheinen im Gesamtprotokoll, nicht
|
||||||
|
* im Personalblatt.
|
||||||
|
*/
|
||||||
|
agentId?: string
|
||||||
|
/** Nur bei Verbindungsereignissen gesetzt. */
|
||||||
|
connectionId?: string
|
||||||
|
timestamp: string
|
||||||
|
eventType: AgentProtocolEventType
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: AgentProtocolStatus
|
||||||
|
objectId?: string
|
||||||
|
channel?: AgentChannelType
|
||||||
|
triggeredBy: AgentTriggerSource
|
||||||
|
/** Klarname der auslösenden Person, wenn `triggeredBy === USER`. */
|
||||||
|
triggeredByName?: string
|
||||||
|
approvalDecision?: AgentApprovalDecision
|
||||||
|
workItemId?: string
|
||||||
|
// Detailansicht (§12.5)
|
||||||
|
input?: AgentDetailField[]
|
||||||
|
processingSteps?: AgentProcessingStep[]
|
||||||
|
output?: AgentDetailField[]
|
||||||
|
sourceReferences?: AgentSourceReference[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAgentProtocolEntryInput = Omit<AgentProtocolEntry, 'id'>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Vorgänge im «Bearbeitungsverlauf».
|
||||||
|
*
|
||||||
|
* Ein Vorgang ist entweder bereits autonom erledigt («Erledigte Aufträge») oder
|
||||||
|
* wartet auf einen Menschen («Pendente Anfragen»). Das Bedienprinzip ist
|
||||||
|
* human-led, agent-operated: der Agent arbeitet, der Mensch entscheidet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentChannelType } from './teamAgent'
|
||||||
|
|
||||||
|
export const AgentWorkItemStatus = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
APPROVED: 'APPROVED',
|
||||||
|
REJECTED: 'REJECTED',
|
||||||
|
EDITING: 'EDITING',
|
||||||
|
COMPLETED: 'COMPLETED',
|
||||||
|
} as const
|
||||||
|
export type AgentWorkItemStatus = typeof AgentWorkItemStatus[keyof typeof AgentWorkItemStatus]
|
||||||
|
|
||||||
|
export const AgentWorkItemPriority = {
|
||||||
|
LOW: 'LOW',
|
||||||
|
MEDIUM: 'MEDIUM',
|
||||||
|
HIGH: 'HIGH',
|
||||||
|
CRITICAL: 'CRITICAL',
|
||||||
|
} as const
|
||||||
|
export type AgentWorkItemPriority = typeof AgentWorkItemPriority[keyof typeof AgentWorkItemPriority]
|
||||||
|
|
||||||
|
/** Vorgangstyp — Filterdimension in §5.3. */
|
||||||
|
export const AgentWorkItemKind = {
|
||||||
|
MESSAGE_TRIAGE: 'MESSAGE_TRIAGE',
|
||||||
|
REMINDER_CREATED: 'REMINDER_CREATED',
|
||||||
|
CONTRACT_ANSWER: 'CONTRACT_ANSWER',
|
||||||
|
LOCATION_MODULE: 'LOCATION_MODULE',
|
||||||
|
INQUIRY_QUALIFIED: 'INQUIRY_QUALIFIED',
|
||||||
|
AUTO_REJECTION: 'AUTO_REJECTION',
|
||||||
|
APPOINTMENT: 'APPOINTMENT',
|
||||||
|
BRIEFING: 'BRIEFING',
|
||||||
|
RECAP: 'RECAP',
|
||||||
|
MARKET_SIGNAL: 'MARKET_SIGNAL',
|
||||||
|
ESCALATION: 'ESCALATION',
|
||||||
|
DOC_GAP: 'DOC_GAP',
|
||||||
|
SYSTEM_ISSUE: 'SYSTEM_ISSUE',
|
||||||
|
} as const
|
||||||
|
export type AgentWorkItemKind = typeof AgentWorkItemKind[keyof typeof AgentWorkItemKind]
|
||||||
|
|
||||||
|
/** Fachbereich — Filterdimension «Bereich» in §4.2 und §5.3. */
|
||||||
|
export const AgentDomainArea = {
|
||||||
|
CONTRACT_DEADLINES: 'CONTRACT_DEADLINES',
|
||||||
|
DOCUMENTATION: 'DOCUMENTATION',
|
||||||
|
INQUIRIES: 'INQUIRIES',
|
||||||
|
VIEWINGS: 'VIEWINGS',
|
||||||
|
LOCATION: 'LOCATION',
|
||||||
|
MARKET: 'MARKET',
|
||||||
|
} as const
|
||||||
|
export type AgentDomainArea = typeof AgentDomainArea[keyof typeof AgentDomainArea]
|
||||||
|
|
||||||
|
export const AgentWorkItemAction = {
|
||||||
|
APPROVE: 'APPROVE',
|
||||||
|
ONE_CLICK_CONFIRM: 'ONE_CLICK_CONFIRM',
|
||||||
|
DECIDE: 'DECIDE',
|
||||||
|
EDIT: 'EDIT',
|
||||||
|
REJECT: 'REJECT',
|
||||||
|
ANSWER_QUERY: 'ANSWER_QUERY',
|
||||||
|
OPEN_SOURCE: 'OPEN_SOURCE',
|
||||||
|
MARK_DONE: 'MARK_DONE',
|
||||||
|
} as const
|
||||||
|
export type AgentWorkItemAction = typeof AgentWorkItemAction[keyof typeof AgentWorkItemAction]
|
||||||
|
|
||||||
|
// ── Bestandteile der Detailansicht ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geordnetes Schlüssel-Wert-Paar. Die Spezifikation nennt
|
||||||
|
* `Record<string, string | string[]>`; ein Array erhält die fachliche
|
||||||
|
* Reihenfolge verlässlich und lässt sich direkt rendern.
|
||||||
|
*/
|
||||||
|
export interface AgentDetailField {
|
||||||
|
label: string
|
||||||
|
value: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fundstelle: Dokument, Seite, Zitat — Sinas Quellenzwang hängt daran. */
|
||||||
|
export interface AgentSourceReference {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
documentName?: string
|
||||||
|
locator?: string
|
||||||
|
quote?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Nachrichten- oder Mailverlauf, wie er im Detail gezeigt wird (§5.4). */
|
||||||
|
export interface AgentMessageEntry {
|
||||||
|
id: string
|
||||||
|
at: string
|
||||||
|
from: string
|
||||||
|
to?: string
|
||||||
|
channel: AgentChannelType
|
||||||
|
subject?: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulierter Verarbeitungsschritt — bewusst in Alltagssprache. */
|
||||||
|
export interface AgentProcessingStep {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentWorkItemHistoryEntry {
|
||||||
|
id: string
|
||||||
|
at: string
|
||||||
|
actor: string
|
||||||
|
action: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Im «Anpassen»-Dialog editierbares Feld. */
|
||||||
|
export interface AgentEditableField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
multiline?: boolean
|
||||||
|
helperText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entität ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentWorkItem {
|
||||||
|
id: string
|
||||||
|
agentId: string
|
||||||
|
kind: AgentWorkItemKind
|
||||||
|
area: AgentDomainArea
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
objectId?: string
|
||||||
|
objectLabel?: string
|
||||||
|
sourceChannel: AgentChannelType
|
||||||
|
targetChannel?: AgentChannelType
|
||||||
|
createdAt: string
|
||||||
|
completedAt?: string
|
||||||
|
priority: AgentWorkItemPriority
|
||||||
|
status: AgentWorkItemStatus
|
||||||
|
/** true → Reiter «Pendente Anfragen», false → «Erledigte Aufträge». */
|
||||||
|
requiresDecision: boolean
|
||||||
|
/** Grund der Rückfrage (§5.5) — nur bei pendenten Vorgängen. */
|
||||||
|
escalationReason?: string
|
||||||
|
/** Die konkrete Frage, die der Mensch beantworten muss. */
|
||||||
|
decisionQuestion?: string
|
||||||
|
inputs: AgentDetailField[]
|
||||||
|
result: AgentDetailField[]
|
||||||
|
processingSteps: AgentProcessingStep[]
|
||||||
|
sourceReferences: AgentSourceReference[]
|
||||||
|
messageThread?: AgentMessageEntry[]
|
||||||
|
availableActions: AgentWorkItemAction[]
|
||||||
|
editableFields?: AgentEditableField[]
|
||||||
|
history: AgentWorkItemHistoryEntry[]
|
||||||
|
rejectionReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auswertung ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die drei Kennzahlen der Teamübersicht (§4.2).
|
||||||
|
*
|
||||||
|
* Abgrenzung der beiden pendenten Werte: Ein Vorgang zählt als Freigabeanfrage,
|
||||||
|
* wenn er dem Menschen eine Entscheidung zur Bestätigung vorlegt (Freigeben,
|
||||||
|
* 1-Klick-Bestätigung, Entscheidung treffen). Alles andere Pendente — Rückfragen,
|
||||||
|
* fehlende Angaben, Verarbeitungs- und Systemprobleme — zählt als Nachricht.
|
||||||
|
*/
|
||||||
|
export interface AgentWorkloadKpis {
|
||||||
|
completedTasks: number
|
||||||
|
pendingMessages: number
|
||||||
|
pendingApprovals: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schreib-Inputs ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UpdateAgentWorkItemInput {
|
||||||
|
status?: AgentWorkItemStatus
|
||||||
|
result?: AgentDetailField[]
|
||||||
|
editableFields?: AgentEditableField[]
|
||||||
|
rejectionReason?: string
|
||||||
|
completedAt?: string
|
||||||
|
requiresDecision?: boolean
|
||||||
|
historyEntry?: Omit<AgentWorkItemHistoryEntry, 'id'>
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Property On — «Teamübersicht»: digitale Mitarbeiter.
|
||||||
|
*
|
||||||
|
* Namensgebung: Der Entitätstyp heisst `TeamAgent`, nicht `Agent`. Im Repo existiert
|
||||||
|
* bereits `PowerOn` (AI-Backend-Proxy) und Claude-Code legt Worktrees unter `agent-*`
|
||||||
|
* ab — ein nackter Typ `Agent` wäre in Suchen praktisch nicht auffindbar. Alle
|
||||||
|
* Satellitentypen tragen deshalb das Präfix `Agent*`; generische Namen wie `Status`,
|
||||||
|
* `Priority` oder `Channel` sind tabu, weil `src/domain/index.ts` per `export *`
|
||||||
|
* bündelt und dort bereits `ReviewStatus`, `SourceType`, `RiskLevel` u. a. liegen.
|
||||||
|
*
|
||||||
|
* Fachliche Wahrheit ist der Agenten-Katalog: Rollen, Personalnummern und
|
||||||
|
* Zuständigkeiten der sieben Kernagenten werden nicht verändert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Status & Autonomie ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AgentStatus = {
|
||||||
|
ACTIVE: 'ACTIVE',
|
||||||
|
BUILDING: 'BUILDING',
|
||||||
|
PAUSED: 'PAUSED',
|
||||||
|
} as const
|
||||||
|
export type AgentStatus = typeof AgentStatus[keyof typeof AgentStatus]
|
||||||
|
|
||||||
|
export const AgentAutonomyLevel = {
|
||||||
|
AUTONOMOUS: 'AUTONOMOUS',
|
||||||
|
APPROVAL_REQUIRED: 'APPROVAL_REQUIRED',
|
||||||
|
PROPOSAL_ONLY: 'PROPOSAL_ONLY',
|
||||||
|
} as const
|
||||||
|
export type AgentAutonomyLevel = typeof AgentAutonomyLevel[keyof typeof AgentAutonomyLevel]
|
||||||
|
|
||||||
|
// ── Kanäle ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AgentChannelType = {
|
||||||
|
EMAIL: 'EMAIL',
|
||||||
|
TEAMS: 'TEAMS',
|
||||||
|
WHATSAPP: 'WHATSAPP',
|
||||||
|
WORKSPACE_CHAT: 'WORKSPACE_CHAT',
|
||||||
|
CALENDAR: 'CALENDAR',
|
||||||
|
WEB_FORM: 'WEB_FORM',
|
||||||
|
PORTAL_LEAD_MAIL: 'PORTAL_LEAD_MAIL',
|
||||||
|
PHONE_VOICE: 'PHONE_VOICE',
|
||||||
|
PHONE_NOTE: 'PHONE_NOTE',
|
||||||
|
AUDIO_OUTPUT: 'AUDIO_OUTPUT',
|
||||||
|
STRUCTURED_MAIL_FALLBACK: 'STRUCTURED_MAIL_FALLBACK',
|
||||||
|
} as const
|
||||||
|
export type AgentChannelType = typeof AgentChannelType[keyof typeof AgentChannelType]
|
||||||
|
|
||||||
|
export const AgentChannelDirection = {
|
||||||
|
INBOUND: 'INBOUND',
|
||||||
|
OUTBOUND: 'OUTBOUND',
|
||||||
|
BOTH: 'BOTH',
|
||||||
|
} as const
|
||||||
|
export type AgentChannelDirection = typeof AgentChannelDirection[keyof typeof AgentChannelDirection]
|
||||||
|
|
||||||
|
export const AgentConnectionStatus = {
|
||||||
|
CONNECTED: 'CONNECTED',
|
||||||
|
DISCONNECTED: 'DISCONNECTED',
|
||||||
|
ROADMAP: 'ROADMAP',
|
||||||
|
} as const
|
||||||
|
export type AgentConnectionStatus = typeof AgentConnectionStatus[keyof typeof AgentConnectionStatus]
|
||||||
|
|
||||||
|
/** Simulierte Kanalkonfiguration — es werden nie echte Zugangsdaten erfasst. */
|
||||||
|
export interface AgentChannelConfig {
|
||||||
|
displayName: string
|
||||||
|
senderAddress?: string
|
||||||
|
inboxAddress?: string
|
||||||
|
defaultRecipients: string[]
|
||||||
|
autoReplyEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentChannel {
|
||||||
|
id: string
|
||||||
|
type: AgentChannelType
|
||||||
|
direction: AgentChannelDirection
|
||||||
|
description: string
|
||||||
|
status: AgentConnectionStatus
|
||||||
|
enabled: boolean
|
||||||
|
/** Kennzeichnet im Katalog als «optional» geführte Kanäle. */
|
||||||
|
optional?: boolean
|
||||||
|
config: AgentChannelConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Systeme ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AgentSystemType = {
|
||||||
|
M365_EXCHANGE: 'M365_EXCHANGE',
|
||||||
|
OUTLOOK: 'OUTLOOK',
|
||||||
|
SHAREPOINT: 'SHAREPOINT',
|
||||||
|
ONEDRIVE: 'ONEDRIVE',
|
||||||
|
FILESHARE: 'FILESHARE',
|
||||||
|
DMS: 'DMS',
|
||||||
|
CALENDAR: 'CALENDAR',
|
||||||
|
IMMOTOP2: 'IMMOTOP2',
|
||||||
|
ABACUS_ABAIMMO: 'ABACUS_ABAIMMO',
|
||||||
|
RIMO_R5: 'RIMO_R5',
|
||||||
|
GARAIO_REM: 'GARAIO_REM',
|
||||||
|
CRM: 'CRM',
|
||||||
|
ZEFIX: 'ZEFIX',
|
||||||
|
SHAB: 'SHAB',
|
||||||
|
PUBLIC_WEB: 'PUBLIC_WEB',
|
||||||
|
PORTAL_LEAD_INBOX: 'PORTAL_LEAD_INBOX',
|
||||||
|
WORKSPACE: 'WORKSPACE',
|
||||||
|
} as const
|
||||||
|
export type AgentSystemType = typeof AgentSystemType[keyof typeof AgentSystemType]
|
||||||
|
|
||||||
|
export const AgentAccessLevel = {
|
||||||
|
READ: 'READ',
|
||||||
|
WRITE: 'WRITE',
|
||||||
|
READ_WRITE: 'READ_WRITE',
|
||||||
|
} as const
|
||||||
|
export type AgentAccessLevel = typeof AgentAccessLevel[keyof typeof AgentAccessLevel]
|
||||||
|
|
||||||
|
export interface AgentSystem {
|
||||||
|
id: string
|
||||||
|
type: AgentSystemType
|
||||||
|
access: AgentAccessLevel
|
||||||
|
status: AgentConnectionStatus
|
||||||
|
/** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */
|
||||||
|
usage: string
|
||||||
|
/** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */
|
||||||
|
permissionNote: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aufgaben ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentTask {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
enabled: boolean
|
||||||
|
/** Zeitplan oder auslösendes Ereignis, z. B. «täglich 06:00» oder «bei Eingang». */
|
||||||
|
schedule: string
|
||||||
|
requiresApproval: boolean
|
||||||
|
dependsOnChannel?: AgentChannelType
|
||||||
|
dependsOnSystem?: AgentSystemType
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Einstellungen ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AgentSettingKind = {
|
||||||
|
TEXT: 'TEXT',
|
||||||
|
NUMBER: 'NUMBER',
|
||||||
|
SELECT: 'SELECT',
|
||||||
|
MULTI_SELECT: 'MULTI_SELECT',
|
||||||
|
BOOLEAN: 'BOOLEAN',
|
||||||
|
TIME: 'TIME',
|
||||||
|
} as const
|
||||||
|
export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind]
|
||||||
|
|
||||||
|
export const AgentSettingGroup = {
|
||||||
|
GENERAL: 'GENERAL',
|
||||||
|
APPROVAL: 'APPROVAL',
|
||||||
|
DELIVERY: 'DELIVERY',
|
||||||
|
SPECIFIC: 'SPECIFIC',
|
||||||
|
} as const
|
||||||
|
export type AgentSettingGroup = typeof AgentSettingGroup[keyof typeof AgentSettingGroup]
|
||||||
|
|
||||||
|
export interface AgentSettingOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSetting {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
kind: AgentSettingKind
|
||||||
|
group: AgentSettingGroup
|
||||||
|
value: string | number | boolean | string[]
|
||||||
|
options?: AgentSettingOption[]
|
||||||
|
unit?: string
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
/** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */
|
||||||
|
locked?: boolean
|
||||||
|
lockedReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kennzahlen & Profil ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentMetric {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Informationsbox unter dem Kopfbereich des Personalblatts (§7.6). */
|
||||||
|
export interface AgentProfile {
|
||||||
|
purpose: string
|
||||||
|
input: string[]
|
||||||
|
coreFlow: string[]
|
||||||
|
output: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entität ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbton des Avatars. Bewusst ein Token-Schlüssel statt eines Hex-Werts —
|
||||||
|
* `npm run check:tokens` liegt über dem Schwellwert und darf nicht wachsen.
|
||||||
|
*/
|
||||||
|
export const AgentAvatarTone = {
|
||||||
|
BRAND: 'BRAND',
|
||||||
|
SUCCESS: 'SUCCESS',
|
||||||
|
INFO: 'INFO',
|
||||||
|
SIGNAL: 'SIGNAL',
|
||||||
|
WARNING: 'WARNING',
|
||||||
|
GOLD: 'GOLD',
|
||||||
|
SLATE: 'SLATE',
|
||||||
|
} as const
|
||||||
|
export type AgentAvatarTone = typeof AgentAvatarTone[keyof typeof AgentAvatarTone]
|
||||||
|
|
||||||
|
export interface TeamAgent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
personnelNumber: string
|
||||||
|
department: string
|
||||||
|
email: string
|
||||||
|
avatarTone: AgentAvatarTone
|
||||||
|
status: AgentStatus
|
||||||
|
autonomyLevel: AgentAutonomyLevel
|
||||||
|
/** Ein Satz für Karte und Listeneintrag. */
|
||||||
|
shortDescription: string
|
||||||
|
/** Freitext zur Autonomie, wo der Katalog differenziert («autonom beim Erinnern, Freigabe bei Eskalationen»). */
|
||||||
|
autonomyNote: string
|
||||||
|
profile: AgentProfile
|
||||||
|
responsibilities: string[]
|
||||||
|
tasks: AgentTask[]
|
||||||
|
channels: AgentChannel[]
|
||||||
|
systems: AgentSystem[]
|
||||||
|
settings: AgentSetting[]
|
||||||
|
metrics: AgentMetric[]
|
||||||
|
/** Kurzkennzahl für die Agentenkarte auf der Teamübersicht. */
|
||||||
|
headlineMetricId: string
|
||||||
|
lastRun?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schreib-Inputs ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UpdateTeamAgentInput {
|
||||||
|
status?: AgentStatus
|
||||||
|
tasks?: AgentTask[]
|
||||||
|
channels?: AgentChannel[]
|
||||||
|
settings?: AgentSetting[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { agentConnectionService } from '../services/agentConnectionService'
|
||||||
|
import type { CreateAgentConnectionInput } from '../domain/agentConnection'
|
||||||
|
import { useToastStore } from '../stores/toastStore'
|
||||||
|
import { STALE_AGENT_CONNECTIONS } from '../lib/constants'
|
||||||
|
import { QK_AGENT_PROTOCOL } from './useAgentProtocol'
|
||||||
|
|
||||||
|
export const QK_AGENT_CONNECTIONS = 'agentConnections'
|
||||||
|
|
||||||
|
export function useAgentConnections() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QK_AGENT_CONNECTIONS],
|
||||||
|
queryFn: () => agentConnectionService.getAll(),
|
||||||
|
staleTime: STALE_AGENT_CONNECTIONS,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useConnectionInvalidation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QK_AGENT_CONNECTIONS] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QK_AGENT_PROTOCOL] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConnectAgentConnection() {
|
||||||
|
const invalidate = useConnectionInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateAgentConnectionInput) => agentConnectionService.connect(input),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore
|
||||||
|
.getState()
|
||||||
|
.showToast(`${res.data.name} verbunden. Frontend-Simulation, kein echtes System.`, 'success')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Verbindung konnte nicht angelegt werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDisconnectAgentConnection() {
|
||||||
|
const invalidate = useConnectionInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => agentConnectionService.disconnect(id),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast(`${res.data.name} getrennt.`, 'warning')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Verbindung konnte nicht getrennt werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Verbindung testen». Ein fehlgeschlagener Test ist ein gültiges fachliches
|
||||||
|
* Ergebnis, kein Fehler — deshalb landet er in `onSuccess` mit Warnton und wird
|
||||||
|
* nicht als roter Fehler-Toast ausgegeben.
|
||||||
|
*/
|
||||||
|
export function useTestAgentConnection() {
|
||||||
|
const invalidate = useConnectionInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => agentConnectionService.test(id),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast(res.data.message, res.data.ok ? 'success' : 'warning')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Test konnte nicht ausgeführt werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { agentProtocolService } from '../services/agentProtocolService'
|
||||||
|
import type { AgentProtocolFilters } from '../provider/IAgentProtocolProvider'
|
||||||
|
import { STALE_AGENT_PROTOCOL } from '../lib/constants'
|
||||||
|
|
||||||
|
export const QK_AGENT_PROTOCOL = 'agentProtocol'
|
||||||
|
|
||||||
|
export function useAgentProtocol(filters?: AgentProtocolFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QK_AGENT_PROTOCOL, filters ?? {}],
|
||||||
|
queryFn: () => agentProtocolService.getAll(filters),
|
||||||
|
staleTime: STALE_AGENT_PROTOCOL,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { agentWorkItemService } from '../services/agentWorkItemService'
|
||||||
|
import type { AgentWorkItemFilters } from '../provider/IAgentWorkItemProvider'
|
||||||
|
import type { AgentEditableField } from '../domain/agentWorkItem'
|
||||||
|
import { useToastStore } from '../stores/toastStore'
|
||||||
|
import { STALE_AGENT_WORK_ITEMS } from '../lib/constants'
|
||||||
|
import { QK_AGENT_PROTOCOL } from './useAgentProtocol'
|
||||||
|
|
||||||
|
export const QK_AGENT_WORK_ITEMS = 'agentWorkItems'
|
||||||
|
export const QK_AGENT_KPIS = 'agentKpis'
|
||||||
|
|
||||||
|
export function useAgentWorkItems(filters?: AgentWorkItemFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QK_AGENT_WORK_ITEMS, filters ?? {}],
|
||||||
|
queryFn: () => agentWorkItemService.getAll(filters),
|
||||||
|
staleTime: STALE_AGENT_WORK_ITEMS,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentWorkItem(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QK_AGENT_WORK_ITEMS, 'item', id],
|
||||||
|
queryFn: () => agentWorkItemService.getById(id!),
|
||||||
|
staleTime: STALE_AGENT_WORK_ITEMS,
|
||||||
|
enabled: !!id,
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die drei Kennzahlen der Auswertung — reagieren auf dieselben Filter wie der Verlauf. */
|
||||||
|
export function useAgentKpis(filters?: AgentWorkItemFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QK_AGENT_KPIS, filters ?? {}],
|
||||||
|
queryFn: () => agentWorkItemService.getKpis(filters),
|
||||||
|
staleTime: STALE_AGENT_WORK_ITEMS,
|
||||||
|
select: (res) => res.data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jede Entscheidung verschiebt einen Vorgang zwischen den Reitern, verändert die
|
||||||
|
* Kennzahlen und schreibt ins Protokoll — alle drei Bestände müssen zusammen
|
||||||
|
* entwertet werden, sonst zeigt die Oberfläche widersprüchliche Zahlen.
|
||||||
|
*/
|
||||||
|
function useWorkItemInvalidation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QK_AGENT_WORK_ITEMS] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QK_AGENT_KPIS] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QK_AGENT_PROTOCOL] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveWorkItem() {
|
||||||
|
const invalidate = useWorkItemInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => agentWorkItemService.approve(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast('Vorgang freigegeben.', 'success')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Freigabe fehlgeschlagen.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectWorkItem() {
|
||||||
|
const invalidate = useWorkItemInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
|
||||||
|
agentWorkItemService.reject(id, reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast('Vorgang zurückgewiesen.', 'warning')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Zurückweisung fehlgeschlagen.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveWorkItemEdit() {
|
||||||
|
const invalidate = useWorkItemInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, fields }: { id: string; fields: AgentEditableField[] }) =>
|
||||||
|
agentWorkItemService.saveEdit(id, fields),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore
|
||||||
|
.getState()
|
||||||
|
.showToast('Änderungen gespeichert. Der Vorgang wartet weiterhin auf die Entscheidung.', 'info')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Änderungen konnten nicht gespeichert werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnswerWorkItemQuery() {
|
||||||
|
const invalidate = useWorkItemInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, answer }: { id: string; answer: string }) =>
|
||||||
|
agentWorkItemService.answerQuery(id, answer),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast('Rückfrage beantwortet.', 'success')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Antwort konnte nicht gespeichert werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkWorkItemDone() {
|
||||||
|
const invalidate = useWorkItemInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => agentWorkItemService.markDone(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast('Vorgang als erledigt markiert.', 'success')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Vorgang konnte nicht abgeschlossen werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { teamAgentService } from '../services/teamAgentService'
|
||||||
|
import type { AgentTask, AgentChannel, AgentSetting } from '../domain/teamAgent'
|
||||||
|
import { useToastStore } from '../stores/toastStore'
|
||||||
|
import { STALE_TEAM_AGENTS } from '../lib/constants'
|
||||||
|
import { QK_AGENT_PROTOCOL } from './useAgentProtocol'
|
||||||
|
|
||||||
|
export const QK_TEAM_AGENTS = 'teamAgents'
|
||||||
|
|
||||||
|
export function useTeamAgents() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QK_TEAM_AGENTS],
|
||||||
|
queryFn: () => teamAgentService.getAll(),
|
||||||
|
staleTime: STALE_TEAM_AGENTS,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTeamAgent(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QK_TEAM_AGENTS, id],
|
||||||
|
queryFn: () => teamAgentService.getById(id!),
|
||||||
|
staleTime: STALE_TEAM_AGENTS,
|
||||||
|
enabled: !!id,
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jede Mutation berührt auch das Protokoll — beide Bestände zusammen entwerten. */
|
||||||
|
function useAgentMutationInvalidation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QK_TEAM_AGENTS] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QK_AGENT_PROTOCOL] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetAgentActive() {
|
||||||
|
const invalidate = useAgentMutationInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
|
||||||
|
teamAgentService.setActive(id, active),
|
||||||
|
onSuccess: (res, variables) => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore
|
||||||
|
.getState()
|
||||||
|
.showToast(
|
||||||
|
variables.active
|
||||||
|
? `${res.data.name} ist wieder aktiv.`
|
||||||
|
: `${res.data.name} wurde pausiert.`,
|
||||||
|
variables.active ? 'success' : 'warning',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Status konnte nicht geändert werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveAgentTasks() {
|
||||||
|
const invalidate = useAgentMutationInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, tasks }: { id: string; tasks: AgentTask[] }) =>
|
||||||
|
teamAgentService.saveTasks(id, tasks),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast('Aufgaben gespeichert.', 'success')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Aufgaben konnten nicht gespeichert werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveAgentSettings() {
|
||||||
|
const invalidate = useAgentMutationInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, settings }: { id: string; settings: AgentSetting[] }) =>
|
||||||
|
teamAgentService.saveSettings(id, settings),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
useToastStore.getState().showToast('Einstellungen gespeichert.', 'success')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Einstellungen konnten nicht gespeichert werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveAgentChannels() {
|
||||||
|
const invalidate = useAgentMutationInvalidation()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, channels }: { id: string; channels: AgentChannel[] }) =>
|
||||||
|
teamAgentService.saveChannels(id, channels),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate()
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
useToastStore.getState().showToast('Kanal konnte nicht geändert werden.', 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -32,6 +32,10 @@ export const STALE_MATCHES = 2 * 60 * 1000
|
|||||||
export const STALE_SIGNALS = 5 * 60 * 1000
|
export const STALE_SIGNALS = 5 * 60 * 1000
|
||||||
export const STALE_MARKET_SIGNALS = 30 * 1000 // aggressive — market leads refresh often
|
export const STALE_MARKET_SIGNALS = 30 * 1000 // aggressive — market leads refresh often
|
||||||
export const STALE_REVIEW_QUEUE = 30 * 1000 // aggressive — ops team works in real-time
|
export const STALE_REVIEW_QUEUE = 30 * 1000 // aggressive — ops team works in real-time
|
||||||
|
export const STALE_TEAM_AGENTS = 5 * 60 * 1000
|
||||||
|
export const STALE_AGENT_WORK_ITEMS = 15 * 1000 // aggressive — Freigaben ändern die Liste sofort
|
||||||
|
export const STALE_AGENT_PROTOCOL = 15 * 1000 // aggressive — jede Aktion schreibt einen Eintrag
|
||||||
|
export const STALE_AGENT_CONNECTIONS = 60 * 1000
|
||||||
|
|
||||||
// Route paths — single source of truth
|
// Route paths — single source of truth
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
@@ -42,6 +46,15 @@ export const ROUTES = {
|
|||||||
MATCH_CENTER: '/supply/match-center',
|
MATCH_CENTER: '/supply/match-center',
|
||||||
FUTURE_AVAILABILITY: '/supply/future-availability',
|
FUTURE_AVAILABILITY: '/supply/future-availability',
|
||||||
DATA_QUALITY: '/supply/data-quality',
|
DATA_QUALITY: '/supply/data-quality',
|
||||||
|
// Property On — «Teamübersicht». Basispfad bewusst `/supply/team` statt
|
||||||
|
// `/supply/teamuebersicht`: kürzer, kollidiert nicht mit `PowerOn` in Suchen
|
||||||
|
// und passt zum Ordner `components/team/`. Hierarchie und Deep-Links bleiben.
|
||||||
|
TEAM: '/supply/team',
|
||||||
|
TEAM_PERSONNEL: '/supply/team/personalverwaltung',
|
||||||
|
TEAM_HISTORY: '/supply/team/bearbeitungsverlauf',
|
||||||
|
TEAM_HISTORY_DONE: '/supply/team/bearbeitungsverlauf/erledigte-auftraege',
|
||||||
|
TEAM_HISTORY_PENDING: '/supply/team/bearbeitungsverlauf/pendente-anfragen',
|
||||||
|
TEAM_CONNECTIONS: '/supply/team/kanaele-systeme',
|
||||||
},
|
},
|
||||||
DEMAND: {
|
DEMAND: {
|
||||||
AI_SEARCH: '/demand/ai-search',
|
AI_SEARCH: '/demand/ai-search',
|
||||||
@@ -132,6 +145,198 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
|
|||||||
INCOMPLETE: 'Unvollständig',
|
INCOMPLETE: 'Unvollständig',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Property On — «Teamübersicht»
|
||||||
|
//
|
||||||
|
// Schweizer Schreibweise, kein Eszett. Sprache der Zielgruppe: Immobilien-
|
||||||
|
// bewirtschafter, nicht KI-Entwickler — deshalb «Aufgabe», «Freigabe»,
|
||||||
|
// «Systemzugang» statt Prompt, Token, Tool Call.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const AGENT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
ACTIVE: 'Aktiv',
|
||||||
|
BUILDING: 'Im Aufbau',
|
||||||
|
PAUSED: 'Pausiert',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_AUTONOMY_LABELS: Record<string, string> = {
|
||||||
|
AUTONOMOUS: 'Autonom',
|
||||||
|
APPROVAL_REQUIRED: 'Freigabe erforderlich',
|
||||||
|
PROPOSAL_ONLY: 'Nur Vorschlag',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_CHANNEL_LABELS: Record<string, string> = {
|
||||||
|
EMAIL: 'E-Mail',
|
||||||
|
TEAMS: 'Microsoft Teams',
|
||||||
|
WHATSAPP: 'WhatsApp Business',
|
||||||
|
WORKSPACE_CHAT: 'Workspace-Chat',
|
||||||
|
CALENDAR: 'Kalender',
|
||||||
|
WEB_FORM: 'Website-Formular',
|
||||||
|
PORTAL_LEAD_MAIL: 'Portal-Lead-Mail',
|
||||||
|
PHONE_VOICE: 'Telefon / Voice',
|
||||||
|
PHONE_NOTE: 'Telefonnotiz',
|
||||||
|
AUDIO_OUTPUT: 'Audioausgabe',
|
||||||
|
STRUCTURED_MAIL_FALLBACK: 'Strukturierter Mail-Fallback',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_CHANNEL_DIRECTION_LABELS: Record<string, string> = {
|
||||||
|
INBOUND: 'Eingang',
|
||||||
|
OUTBOUND: 'Ausgang',
|
||||||
|
BOTH: 'Eingang und Ausgang',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_CONNECTION_STATUS_LABELS: Record<string, string> = {
|
||||||
|
CONNECTED: 'Verbunden',
|
||||||
|
DISCONNECTED: 'Nicht verbunden',
|
||||||
|
ROADMAP: 'Geplant',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_SYSTEM_LABELS: Record<string, string> = {
|
||||||
|
M365_EXCHANGE: 'Microsoft 365 / Exchange',
|
||||||
|
OUTLOOK: 'Outlook',
|
||||||
|
SHAREPOINT: 'SharePoint',
|
||||||
|
ONEDRIVE: 'OneDrive',
|
||||||
|
FILESHARE: 'Windows-Fileshare',
|
||||||
|
DMS: 'Dokumentenablage (DMS)',
|
||||||
|
CALENDAR: 'Kalender',
|
||||||
|
IMMOTOP2: 'ImmoTop2',
|
||||||
|
ABACUS_ABAIMMO: 'Abacus AbaImmo',
|
||||||
|
RIMO_R5: 'Rimo R5',
|
||||||
|
GARAIO_REM: 'Garaio REM',
|
||||||
|
CRM: 'CRM',
|
||||||
|
ZEFIX: 'Zefix',
|
||||||
|
SHAB: 'SHAB',
|
||||||
|
PUBLIC_WEB: 'Öffentliche Webquellen',
|
||||||
|
PORTAL_LEAD_INBOX: 'Portal-Lead-Postfach',
|
||||||
|
WORKSPACE: 'Property-On-Workspace',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_ACCESS_LABELS: Record<string, string> = {
|
||||||
|
READ: 'Lesen',
|
||||||
|
WRITE: 'Schreiben',
|
||||||
|
READ_WRITE: 'Lesen und schreiben',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_WORK_ITEM_STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Offen',
|
||||||
|
APPROVED: 'Freigegeben',
|
||||||
|
REJECTED: 'Zurückgewiesen',
|
||||||
|
EDITING: 'In Bearbeitung',
|
||||||
|
COMPLETED: 'Abgeschlossen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_PRIORITY_LABELS: Record<string, string> = {
|
||||||
|
LOW: 'Niedrig',
|
||||||
|
MEDIUM: 'Mittel',
|
||||||
|
HIGH: 'Hoch',
|
||||||
|
CRITICAL: 'Kritisch',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_WORK_ITEM_KIND_LABELS: Record<string, string> = {
|
||||||
|
MESSAGE_TRIAGE: 'Nachricht kategorisiert',
|
||||||
|
REMINDER_CREATED: 'Reminder erstellt',
|
||||||
|
CONTRACT_ANSWER: 'Vertragsauskunft',
|
||||||
|
LOCATION_MODULE: 'Lagebaustein',
|
||||||
|
INQUIRY_QUALIFIED: 'Anfrage qualifiziert',
|
||||||
|
AUTO_REJECTION: 'Automatische Absage',
|
||||||
|
APPOINTMENT: 'Termin',
|
||||||
|
BRIEFING: 'Besichtigungs-Briefing',
|
||||||
|
RECAP: 'Gesprächsprotokoll',
|
||||||
|
MARKET_SIGNAL: 'Marktsignal',
|
||||||
|
ESCALATION: 'Eskalation',
|
||||||
|
DOC_GAP: 'Dokumentationslücke',
|
||||||
|
SYSTEM_ISSUE: 'Systemproblem',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_DOMAIN_AREA_LABELS: Record<string, string> = {
|
||||||
|
CONTRACT_DEADLINES: 'Fristen und Verträge',
|
||||||
|
DOCUMENTATION: 'Dokumentation',
|
||||||
|
INQUIRIES: 'Anfragen',
|
||||||
|
VIEWINGS: 'Besichtigungen',
|
||||||
|
LOCATION: 'Lage',
|
||||||
|
MARKET: 'Markt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_WORK_ITEM_ACTION_LABELS: Record<string, string> = {
|
||||||
|
APPROVE: 'Freigeben',
|
||||||
|
ONE_CLICK_CONFIRM: 'Mit einem Klick bestätigen',
|
||||||
|
DECIDE: 'Entscheidung treffen',
|
||||||
|
EDIT: 'Anpassen',
|
||||||
|
REJECT: 'Zurückweisen',
|
||||||
|
ANSWER_QUERY: 'Rückfrage beantworten',
|
||||||
|
OPEN_SOURCE: 'Quelle öffnen',
|
||||||
|
MARK_DONE: 'Als erledigt markieren',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_PROTOCOL_STATUS_LABELS: Record<string, string> = {
|
||||||
|
SUCCESS: 'Erfolgreich',
|
||||||
|
WARNING: 'Warnung',
|
||||||
|
ERROR: 'Fehler',
|
||||||
|
INFO: 'Hinweis',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_PROTOCOL_EVENT_LABELS: Record<string, string> = {
|
||||||
|
TASK_RUN: 'Aufgabe ausgeführt',
|
||||||
|
MESSAGE_PROCESSED: 'Nachricht verarbeitet',
|
||||||
|
REMINDER_SENT: 'Erinnerung versendet',
|
||||||
|
ESCALATION: 'Eskalation',
|
||||||
|
APPROVAL: 'Freigabe',
|
||||||
|
REJECTION: 'Zurückweisung',
|
||||||
|
EDIT: 'Anpassung',
|
||||||
|
SETTINGS_SAVED: 'Einstellungen gespeichert',
|
||||||
|
TASKS_SAVED: 'Aufgaben gespeichert',
|
||||||
|
AGENT_PAUSED: 'Mitarbeiter pausiert',
|
||||||
|
AGENT_RESUMED: 'Mitarbeiter reaktiviert',
|
||||||
|
CHANNEL_TESTED: 'Verbindung getestet',
|
||||||
|
CHANNEL_TOGGLED: 'Kanal umgeschaltet',
|
||||||
|
CONNECTION_CREATED: 'Verbindung angelegt',
|
||||||
|
CONNECTION_REMOVED: 'Verbindung getrennt',
|
||||||
|
DEMO_RESET: 'Demo zurückgesetzt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_CONNECTION_CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
EMAIL_M365: 'E-Mail / Microsoft 365',
|
||||||
|
WHATSAPP: 'WhatsApp Business',
|
||||||
|
TEAMS: 'Microsoft Teams',
|
||||||
|
CALENDAR: 'Kalender',
|
||||||
|
DOCUMENT_STORE: 'Dokumentenablage',
|
||||||
|
PHONE_VOICE: 'Telefon / Voice',
|
||||||
|
ERP: 'ERP',
|
||||||
|
CRM: 'CRM',
|
||||||
|
PUBLIC_SOURCES: 'Öffentliche Datenquellen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_ERP_VENDOR_LABELS: Record<string, string> = {
|
||||||
|
IMMOTOP2: 'ImmoTop2',
|
||||||
|
ABACUS_ABAIMMO: 'Abacus AbaImmo',
|
||||||
|
RIMO_R5: 'Rimo R5',
|
||||||
|
GARAIO_REM: 'Garaio REM',
|
||||||
|
OTHER: 'Anderes ERP',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_SETTING_GROUP_LABELS: Record<string, string> = {
|
||||||
|
GENERAL: 'Allgemein',
|
||||||
|
APPROVAL: 'Freigabe und Eskalation',
|
||||||
|
DELIVERY: 'Zustellung',
|
||||||
|
SPECIFIC: 'Fachliche Einstellungen',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitraumfilter für Auswertung und Bearbeitungsverlauf (§4.2, §5.3). */
|
||||||
|
export const AGENT_PERIOD_LABELS: Record<string, string> = {
|
||||||
|
TODAY: 'Heute',
|
||||||
|
WEEK: 'Diese Woche',
|
||||||
|
MONTH: 'Dieser Monat',
|
||||||
|
YEAR: 'Dieses Jahr',
|
||||||
|
ALL: 'Gesamter Zeitraum',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulierte Ladezeit für Demo-Aktionen (§19.1: 300–800 ms). */
|
||||||
|
export const AGENT_SIM_DELAY_MS = 420
|
||||||
|
export const AGENT_SIM_DELAY_SLOW_MS = 760
|
||||||
|
|
||||||
|
/** Hinweis, der auf jeder Property-On-Seite dezent mitläuft (§19.3). */
|
||||||
|
export const AGENT_DEMO_NOTICE = 'Demo-Modus – keine echten Daten oder Systemverbindungen.'
|
||||||
|
|
||||||
// Swiss CRB/BKP fit-out investment benchmarks (CHF/m², tenant's cost)
|
// Swiss CRB/BKP fit-out investment benchmarks (CHF/m², tenant's cost)
|
||||||
export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number }> = {
|
export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number }> = {
|
||||||
SHELL: { min: 800, max: 1500 },
|
SHELL: { min: 800, max: 1500 },
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Demo-Uhr.
|
||||||
|
*
|
||||||
|
* Die Mockdaten sind fachlich auf den 20.05.2026 verankert. Würden die
|
||||||
|
* Zeitraumfilter «heute / diese Woche / dieser Monat» gegen die echte Systemzeit
|
||||||
|
* rechnen, wären sie an jedem anderen Kalendertag leer und die Auswertung sähe
|
||||||
|
* kaputt aus, obwohl sie korrekt arbeitet.
|
||||||
|
*
|
||||||
|
* Deshalb läuft der gesamte Property-On-Bereich gegen eine Demo-Uhr: sie startet
|
||||||
|
* am Anker und schreitet ab Anwendungsstart in Echtzeit fort. Damit landet jede
|
||||||
|
* Freigabe, die der Nutzer während der Demo auslöst, verlässlich im Bucket
|
||||||
|
* «heute» — und die Seed-Daten liegen dort, wo der Katalog sie erwartet.
|
||||||
|
*
|
||||||
|
* Ausschliesslich für Property On. Der Rest der Anwendung rechnet unverändert
|
||||||
|
* mit `new Date()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MOCK_TODAY } from '../mock-data/teamAgents'
|
||||||
|
|
||||||
|
const ANCHOR_MS = new Date(MOCK_TODAY).getTime()
|
||||||
|
const BOOT_MS = Date.now()
|
||||||
|
|
||||||
|
/** Aktueller Zeitpunkt der Demo-Uhr. */
|
||||||
|
export function teamNow(): Date {
|
||||||
|
return new Date(ANCHOR_MS + (Date.now() - BOOT_MS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktueller Zeitpunkt als ISO-String — für Zeitstempel in Vorgängen und Protokoll. */
|
||||||
|
export function teamNowIso(): string {
|
||||||
|
return teamNow().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum und Uhrzeit, de-CH: «20.05.2026, 08:15». */
|
||||||
|
export function formatTeamDateTime(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-CH', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die Uhrzeit, de-CH: «08:15». */
|
||||||
|
export function formatTeamTime(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-CH', { hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative Angabe gegen die Demo-Uhr.
|
||||||
|
*
|
||||||
|
* `formatRelativeDate` aus `lib/utils.ts` rechnet gegen `Date.now()` und würde
|
||||||
|
* für die auf den 20.05.2026 verankerten Bestände durchgehend Abstände von
|
||||||
|
* Monaten ausgeben — jeder Vorgang sähe veraltet aus.
|
||||||
|
*/
|
||||||
|
export function formatTeamRelative(iso: string): string {
|
||||||
|
const diff = teamNow().getTime() - new Date(iso).getTime()
|
||||||
|
if (diff < 0) return formatTeamDateTime(iso)
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60_000)
|
||||||
|
if (minutes < 1) return 'gerade eben'
|
||||||
|
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`
|
||||||
|
|
||||||
|
return formatTeamDateTime(iso)
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Subreiter «Kanäle & Systeme»: die zentralen Verbindungen der
|
||||||
|
* Organisation.
|
||||||
|
*
|
||||||
|
* Genau neun Einträge, einer je `AgentConnectionCategory`, in der Reihenfolge, in
|
||||||
|
* der die Seite sie rendert. Zwei Verbindungen stehen bewusst auf
|
||||||
|
* `DISCONNECTED` — Teams und das Bewirtschaftungssystem — damit der
|
||||||
|
* Verbindungsassistent eine echte Aufgabe hat; die Telefonie ist als
|
||||||
|
* `ROADMAP` ausgewiesen.
|
||||||
|
*
|
||||||
|
* `usedByAgentIds` ist aus den Kanälen und Systemen der sieben Personalblätter
|
||||||
|
* abgeleitet und nicht frei gesetzt: E-Mail nutzen alle, den Kalender die vier
|
||||||
|
* Mitarbeiter mit Terminbezug, die Dokumentenablage alle mit Aktenbezug.
|
||||||
|
* Reihenfolge alphabetisch nach Vorname, wie in `mockTeamAgents`.
|
||||||
|
*
|
||||||
|
* Alle Verbindungen sind Frontend-Simulation — es werden nie echte Zugangsdaten
|
||||||
|
* erfasst und nie echte Systeme kontaktiert. Zeitanker: 20.05.2026.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentConnection } from '../domain/agentConnection'
|
||||||
|
import { AgentConnectionCategory, AgentErpVendor } from '../domain/agentConnection'
|
||||||
|
import { AgentAccessLevel, AgentConnectionStatus } from '../domain/teamAgent'
|
||||||
|
import { AGENT_IDS } from './teamAgents'
|
||||||
|
|
||||||
|
export const mockAgentConnections: AgentConnection[] = [
|
||||||
|
// ── E-Mail ──────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-email-m365',
|
||||||
|
category: AgentConnectionCategory.EMAIL_M365,
|
||||||
|
name: 'Microsoft 365 — gemeinsame Postfächer',
|
||||||
|
description:
|
||||||
|
'Die geteilten Postfächer von Bewirtschaftung und Vermarktung: Fristenmeldungen, Anfragen von Interessenten, Rückfragen, Absagen und Terminvorschläge laufen über diese vier Adressen. Eingehende Post wird gelesen und dem richtigen Objekt zugeordnet; ausgehende Post folgt der Regel des jeweiligen Mitarbeiters — Erinnerungen gehen selbständig hinaus, Eskalationen erst nach Freigabe.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
accountCount: 4,
|
||||||
|
usedByAgentIds: [
|
||||||
|
AGENT_IDS.BRUNO,
|
||||||
|
AGENT_IDS.FERDI,
|
||||||
|
AGENT_IDS.LEA,
|
||||||
|
AGENT_IDS.LIVIA,
|
||||||
|
AGENT_IDS.NORA,
|
||||||
|
AGENT_IDS.RETO,
|
||||||
|
AGENT_IDS.SINA,
|
||||||
|
],
|
||||||
|
lastSyncAt: '2026-05-20T08:42:00.000Z',
|
||||||
|
connectionLabel: 'Postfächer Zürich, Winterthur, Basel',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-email-m365-perm-01',
|
||||||
|
label: 'Postfach lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-email-m365-perm-02',
|
||||||
|
label: 'Nachrichten senden',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-email-m365-perm-03',
|
||||||
|
label: 'Entwürfe zur Freigabe ablegen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-email-m365-perm-04',
|
||||||
|
label: 'Anhänge öffnen und ablegen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-email-m365-perm-05',
|
||||||
|
label: 'Ordner und Regeln des Postfachs ändern',
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── WhatsApp ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-whatsapp',
|
||||||
|
category: AgentConnectionCategory.WHATSAPP,
|
||||||
|
name: 'WhatsApp Business — Diensthandys',
|
||||||
|
description:
|
||||||
|
'Der kurze Draht zu den Personen unterwegs: Meldung aufs Diensthandy, wenn eine Frist in weniger als 30 Tagen abläuft und am Arbeitsplatz niemand erreichbar ist, sowie Eingang der Sprachmemos direkt nach einem Aussentermin — etwa nach der Begehung an der Bahnhofstrasse 44 in Zürich (OBJ-2451).',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
accountCount: 2,
|
||||||
|
usedByAgentIds: [AGENT_IDS.FERDI, AGENT_IDS.RETO],
|
||||||
|
lastSyncAt: '2026-05-19T17:35:00.000Z',
|
||||||
|
connectionLabel: 'Diensthandys Bewirtschaftung und Vermarktung',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-whatsapp-perm-01',
|
||||||
|
label: 'Eingehende Nachrichten lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-whatsapp-perm-02',
|
||||||
|
label: 'Kurzmeldung senden',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-whatsapp-perm-03',
|
||||||
|
label: 'Sprachmemos abholen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-whatsapp-perm-04',
|
||||||
|
label: 'Kontaktliste des Geräts lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Teams ───────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-teams',
|
||||||
|
category: AgentConnectionCategory.TEAMS,
|
||||||
|
name: 'Microsoft Teams — Kanal Marktbeobachtung',
|
||||||
|
description:
|
||||||
|
'Vorgesehen für die Kurzmeldung in den Teamkanal, sobald ein Marktsignal mit hoher Konfidenz auftaucht — zum Beispiel eine angekündigte Filialschliessung in Basel. Die Verbindung ist noch nicht eingerichtet; bis dahin erscheinen die Signale ausschliesslich in der Wochen-Mail vom Dienstag.',
|
||||||
|
status: AgentConnectionStatus.DISCONNECTED,
|
||||||
|
accountCount: 0,
|
||||||
|
usedByAgentIds: [AGENT_IDS.NORA],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-teams-perm-01',
|
||||||
|
label: 'Kanalbeiträge lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-teams-perm-02',
|
||||||
|
label: 'In Kanal schreiben',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-teams-perm-03',
|
||||||
|
label: 'Kanalmitglieder lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-teams-perm-04',
|
||||||
|
label: 'Dateien im Kanal ablegen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Kalender ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-calendar',
|
||||||
|
category: AgentConnectionCategory.CALENDAR,
|
||||||
|
name: 'Kalender der Standortteams',
|
||||||
|
description:
|
||||||
|
'Fristenkalender der Bewirtschaftung, Besichtigungskalender der Vermarktung und der Terminkalender für Aussentermine. Gelesen werden freie Zeitfenster und bestehende Termine; geschrieben werden bestätigte Fristen mit ihren Vorlaufmarken, vorläufige Reservationen für Terminvorschläge und die bestätigte Besichtigung samt Erinnerung. Fremde Termine werden nie verschoben.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
accountCount: 3,
|
||||||
|
usedByAgentIds: [AGENT_IDS.BRUNO, AGENT_IDS.FERDI, AGENT_IDS.LEA, AGENT_IDS.RETO],
|
||||||
|
lastSyncAt: '2026-05-20T08:15:00.000Z',
|
||||||
|
connectionLabel: 'Fristen-, Besichtigungs- und Terminkalender',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-calendar-perm-01',
|
||||||
|
label: 'Termine lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-calendar-perm-02',
|
||||||
|
label: 'Verfügbarkeiten und freie Zeitfenster abfragen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-calendar-perm-03',
|
||||||
|
label: 'In Kalender schreiben',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-calendar-perm-04',
|
||||||
|
label: 'Zeitfenster vorläufig reservieren',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-calendar-perm-05',
|
||||||
|
label: 'Fremde Termine verschieben oder löschen',
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Dokumentenablage ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-document-store',
|
||||||
|
category: AgentConnectionCategory.DOCUMENT_STORE,
|
||||||
|
name: 'Dokumentenablage und Standortbibliotheken',
|
||||||
|
description:
|
||||||
|
'Mietverträge, Nachträge, Zusatzvereinbarungen, Übergabeprotokolle, Hausordnungen und Objektunterlagen — aus der zentralen Ablage, den Standortbibliotheken von Zürich, Winterthur und St. Gallen sowie den historischen Vertragsordnern der übernommenen Mandate. Jede Aussage aus einem Dokument wird mit Dateiname, Seite und Zitat belegt.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
accountCount: 4,
|
||||||
|
usedByAgentIds: [
|
||||||
|
AGENT_IDS.BRUNO,
|
||||||
|
AGENT_IDS.FERDI,
|
||||||
|
AGENT_IDS.LIVIA,
|
||||||
|
AGENT_IDS.RETO,
|
||||||
|
AGENT_IDS.SINA,
|
||||||
|
],
|
||||||
|
lastSyncAt: '2026-05-20T05:30:00.000Z',
|
||||||
|
connectionLabel: 'Ablage Bewirtschaftung und Standortbibliotheken',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-document-store-perm-01',
|
||||||
|
label: 'Dokumente lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-document-store-perm-02',
|
||||||
|
label: 'Ordnerstruktur und Objektzuordnung lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-document-store-perm-03',
|
||||||
|
label: 'Fundstellen und Seitenverweise festhalten',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-document-store-perm-04',
|
||||||
|
label: 'Protokolle in der Fristenliste ablegen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-document-store-perm-05',
|
||||||
|
label: 'Dokumente verschieben oder löschen',
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Telefonie ───────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-phone-voice',
|
||||||
|
category: AgentConnectionCategory.PHONE_VOICE,
|
||||||
|
name: 'Telefonanlage und Diktatlinie',
|
||||||
|
description:
|
||||||
|
'Geplante Anbindung der Telefonanlage: Anrufe von Interessenten am Empfang in Zürich und Winterthur sowie die Diktatlinie für Sprachmemos nach einem Aussentermin. Bis zur Freigabe erfasst der Empfang die Telefonnotizen von Hand, und die Sprachmemos kommen über das Diensthandy herein.',
|
||||||
|
status: AgentConnectionStatus.ROADMAP,
|
||||||
|
accountCount: 0,
|
||||||
|
usedByAgentIds: [AGENT_IDS.LEA, AGENT_IDS.RETO],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-phone-voice-perm-01',
|
||||||
|
label: 'Anrufliste lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-phone-voice-perm-02',
|
||||||
|
label: 'Sprachmemos abholen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-phone-voice-perm-03',
|
||||||
|
label: 'Gesprächsnotiz zum Vorgang ablegen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-phone-voice-perm-04',
|
||||||
|
label: 'Ansage für den Empfang abspielen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Bewirtschaftungssystem ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-erp-immotop2',
|
||||||
|
category: AgentConnectionCategory.ERP,
|
||||||
|
name: 'Bewirtschaftungssystem ImmoTop2',
|
||||||
|
description:
|
||||||
|
'Objekt- und Mieterstammdaten, Vertragslaufzeiten, Verfügbarkeiten, Must-Kriterien der ausgeschriebenen Flächen und die Terminexporte. Der Zugang ist noch nicht eingerichtet: bis dahin stützen sich Fristen, Vertragsauskünfte und Anfragenprüfung auf die Excel-Terminlisten und die Unterlagen der Standortteams. Der Verbindungsassistent richtet den Lesezugang ein.',
|
||||||
|
status: AgentConnectionStatus.DISCONNECTED,
|
||||||
|
accountCount: 0,
|
||||||
|
usedByAgentIds: [
|
||||||
|
AGENT_IDS.FERDI,
|
||||||
|
AGENT_IDS.LEA,
|
||||||
|
AGENT_IDS.LIVIA,
|
||||||
|
AGENT_IDS.RETO,
|
||||||
|
AGENT_IDS.SINA,
|
||||||
|
],
|
||||||
|
vendor: AgentErpVendor.IMMOTOP2,
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-erp-immotop2-perm-01',
|
||||||
|
label: 'Objektstammdaten lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-erp-immotop2-perm-02',
|
||||||
|
label: 'Mieter- und Vertragsdaten lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-erp-immotop2-perm-03',
|
||||||
|
label: 'Terminexporte abholen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-erp-immotop2-perm-04',
|
||||||
|
label: 'Verfügbarkeiten und Flächenangaben lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-erp-immotop2-perm-05',
|
||||||
|
label: 'Stammdaten ändern',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── CRM ─────────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-crm',
|
||||||
|
category: AgentConnectionCategory.CRM,
|
||||||
|
name: 'CRM der Vermarktung',
|
||||||
|
description:
|
||||||
|
'Führt Interessenten, Bedarfsprofile, Anfrageverlauf und Terminstatus je Fläche und erkennt, wenn dieselbe Firma bereits über Portal, Formular und Telefon angefragt hat. Hier landen auch die freigegebenen Leads der Marktbeobachtung — zuletzt die Expansion der Vollenweider Gastro AG Richtung Winterthur.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
accountCount: 1,
|
||||||
|
usedByAgentIds: [AGENT_IDS.LEA, AGENT_IDS.NORA, AGENT_IDS.RETO],
|
||||||
|
lastSyncAt: '2026-05-19T19:05:00.000Z',
|
||||||
|
connectionLabel: 'Vermarktung Zürich',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-crm-perm-01',
|
||||||
|
label: 'Kontakte und Firmen lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-crm-perm-02',
|
||||||
|
label: 'Anfragen und Notizen schreiben',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-crm-perm-03',
|
||||||
|
label: 'Statuswechsel setzen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-crm-perm-04',
|
||||||
|
label: 'Freigegebene Leads übergeben',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-crm-perm-05',
|
||||||
|
label: 'Datensätze löschen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Öffentliche Quellen ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'conn-public-sources',
|
||||||
|
category: AgentConnectionCategory.PUBLIC_SOURCES,
|
||||||
|
name: 'Öffentliche Quellen — SHAB, Zefix und Web',
|
||||||
|
description:
|
||||||
|
'Amtliche Meldungen zu Gründungen, Sitzverlegungen und Liquidationen, der öffentliche Handelsregisterindex und frei zugängliche Firmenwebseiten, Standortmeldungen und Medienberichte aus Zürich, Winterthur, Zug, Basel, Bern, Luzern und St. Gallen. Grundlage für Lagebeschreibungen, Besichtigungs-Briefings und Marktsignale — jede Angabe wird mit Quelle und Datum ausgewiesen.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
accountCount: 3,
|
||||||
|
usedByAgentIds: [AGENT_IDS.BRUNO, AGENT_IDS.LIVIA, AGENT_IDS.NORA],
|
||||||
|
lastSyncAt: '2026-05-20T06:00:00.000Z',
|
||||||
|
connectionLabel: 'Marktbeobachtung Deutschschweiz',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: 'conn-public-sources-perm-01',
|
||||||
|
label: 'Amtliche Meldungen lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-public-sources-perm-02',
|
||||||
|
label: 'Handelsregisterindex abfragen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-public-sources-perm-03',
|
||||||
|
label: 'Frei zugängliche Webseiten lesen',
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-public-sources-perm-04',
|
||||||
|
label: 'Quellenangabe mit Datum festhalten',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conn-public-sources-perm-05',
|
||||||
|
label: 'Formulare ausfüllen oder Inhalte veröffentlichen',
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Personalblatt «Bruno», Besichtigungs-Briefing (Abteilung Vermarktung).
|
||||||
|
*
|
||||||
|
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und die
|
||||||
|
* neun Aufgaben werden unverändert übernommen. Zeitanker der Mockdaten: 20.05.2026.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
export const brunoAgent: TeamAgent = {
|
||||||
|
id: 'bruno',
|
||||||
|
name: 'Bruno',
|
||||||
|
role: 'Besichtigungs-Briefing',
|
||||||
|
personnelNumber: 'PO-ZD-13',
|
||||||
|
department: 'Vermarktung',
|
||||||
|
email: 'bruno@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.GOLD,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
|
||||||
|
shortDescription:
|
||||||
|
'Bereitet Aussentermine automatisch mit Objekt-, Gegenüber- und Argumentationsinformationen vor.',
|
||||||
|
autonomyNote: '«Autonom»',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Bruno bereitet jeden Aussentermin der Vermarktung rechtzeitig vor und liefert der verantwortlichen Person ein einseitiges Briefing zu Objekt, Gegenüber und Argumentation.',
|
||||||
|
input: [
|
||||||
|
'Kalendertermin mit Zeit, Ort, Objektbezug und Teilnehmenden',
|
||||||
|
'Objektinformationen aus Dossier, Ablage und Bewirtschaftungssystem',
|
||||||
|
'Öffentlich verfügbare Firmeninformationen zum Gegenüber',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Zwei Stunden vor dem Aussentermin selbständig anlaufen und den Termin einlesen',
|
||||||
|
'Alles zum Objekt zusammentragen: Fläche, Ausbau, Konditionen, Verfügbarkeit, Historie',
|
||||||
|
'Offene Punkte klar als fehlende Information kennzeichnen, statt sie zu überspielen',
|
||||||
|
'Das Gegenüber über öffentlich zugängliche Quellen einordnen und die Herkunft der Angaben festhalten',
|
||||||
|
'Drei Verkaufsargumente und drei erwartbare Einwände samt Antwort formulieren',
|
||||||
|
'Alles auf einer Seite bündeln, per E-Mail zustellen und bei Terminverschiebung nachführen',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Einseitiges Briefing zum Aussentermin',
|
||||||
|
'Audiofassung des Briefings für die Anfahrt',
|
||||||
|
'Drei Verkaufsargumente je Objekt und Gegenüber',
|
||||||
|
'Drei erwartbare Einwände mit vorbereiteter Antwort',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Automatischer Anlauf zwei Stunden vor jedem Aussentermin der Vermarktung',
|
||||||
|
'Zusammentragen der Objektinformationen aus Dossier, Ablage und Bewirtschaftungssystem',
|
||||||
|
'Sichtbares Markieren fehlender oder veralteter Angaben',
|
||||||
|
'Recherche des Gegenübers über öffentlich zugängliche Quellen',
|
||||||
|
'Aufbereitung von Verkaufsargumenten und erwartbaren Einwänden',
|
||||||
|
'Erstellung und Zustellung des einseitigen Briefings samt optionaler Audiofassung',
|
||||||
|
'Nachführung des Briefings bei Terminverschiebung oder geänderter Teilnehmerliste',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'bruno-task-01',
|
||||||
|
title: 'Zwei Stunden vor einem Aussentermin automatisch starten',
|
||||||
|
description:
|
||||||
|
'Beobachtet den Kalender der Vermarktung und startet die Vorbereitung selbständig, sobald ein Aussentermin in den eingestellten Vorlauf rückt. Erkennt Ort, Objektbezug und Teilnehmende aus dem Termineintrag.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'zwei Stunden vor dem Termin',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.CALENDAR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-02',
|
||||||
|
title: 'Objektinformationen zusammentragen',
|
||||||
|
description:
|
||||||
|
'Sammelt Fläche, Ausbaustandard, Mietzins, Nebenkosten, Verfügbarkeit, Parkplätze, Erschliessung und die bisherige Vermarktungshistorie aus Objektdossier, Ablage und Bewirtschaftungssystem.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'direkt nach dem Start der Vorbereitung',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-03',
|
||||||
|
title: 'Fehlende Informationen markieren',
|
||||||
|
description:
|
||||||
|
'Kennzeichnet jede Angabe, die nicht auffindbar oder älter als der hinterlegte Zeitraum ist, sichtbar als Lücke. Es werden keine Werte geschätzt und keine Annahmen als Tatsachen ausgewiesen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'im Anschluss an das Zusammentragen der Objektinformationen',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-04',
|
||||||
|
title: 'Gegenüber über öffentliche Quellen recherchieren',
|
||||||
|
description:
|
||||||
|
'Ordnet Firma und Ansprechperson des Gegenübers über öffentlich zugängliche Quellen ein: Handelsregistereintrag, amtliche Meldungen, Firmenwebsite und Medienberichte. Zu jeder Angabe wird die Quelle mitgeführt; nicht belegbare Angaben bleiben aussen vor.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'parallel zur Objektaufbereitung',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.ZEFIX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-05',
|
||||||
|
title: 'Drei Verkaufsargumente formulieren',
|
||||||
|
description:
|
||||||
|
'Leitet aus Objektstärken und dem Profil des Gegenübers die eingestellte Anzahl Verkaufsargumente ab, je Argument ein Satz mit dem konkreten Nutzen für dieses Unternehmen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach Abschluss von Objekt- und Gegenüber-Recherche',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-06',
|
||||||
|
title: 'Drei erwartbare Einwände vorbereiten',
|
||||||
|
description:
|
||||||
|
'Benennt die wahrscheinlichsten Einwände zu Mietzins, Lage, Ausbau, Bezugstermin oder Vertragsdauer und hinterlegt zu jedem eine sachliche Antwort mit Beleg aus dem Objektdossier.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'unmittelbar nach den Verkaufsargumenten',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-07',
|
||||||
|
title: 'Einseitiges Briefing erstellen',
|
||||||
|
description:
|
||||||
|
'Bündelt Termindaten, Objektkennzahlen, Gegenüber, Verkaufsargumente, Einwände und markierte Lücken auf einer Seite und stellt es der verantwortlichen Person per E-Mail zu.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'spätestens 90 Minuten vor dem Termin',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-08',
|
||||||
|
title: 'Optionale Audiofassung erstellen',
|
||||||
|
description:
|
||||||
|
'Erstellt auf Wunsch eine gesprochene Fassung des Briefings für die Anfahrt. Die Audiofassung folgt demselben Aufbau wie das Dokument und wird zusammen mit diesem zugestellt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'zusammen mit dem Briefing, sofern aktiviert',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.AUDIO_OUTPUT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-task-09',
|
||||||
|
title: 'Briefing bei Terminverschiebung aktualisieren',
|
||||||
|
description:
|
||||||
|
'Erkennt verschobene, verlegte oder abgesagte Termine sowie geänderte Teilnehmende, führt das Briefing nach und stellt die aktualisierte Fassung mit Hinweis auf die Änderung zu.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Änderung des Termineintrags',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.CALENDAR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'bruno-ch-calendar',
|
||||||
|
type: AgentChannelType.CALENDAR,
|
||||||
|
direction: AgentChannelDirection.INBOUND,
|
||||||
|
description:
|
||||||
|
'Terminkalender der Vermarktung Zürich und Winterthur: liefert Aussentermine mit Zeit, Ort, Objektbezug und Teilnehmenden sowie deren Änderungen.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Terminkalender Vermarktung',
|
||||||
|
inboxAddress: 'termine@property-on.ch',
|
||||||
|
defaultRecipients: [],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Versand des einseitigen Briefings an die verantwortliche Person und die weiteren Teilnehmenden des Aussentermins.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Briefing-Versand Vermarktung',
|
||||||
|
senderAddress: 'bruno@property-on.ch',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch', 'nadine.brunner@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-ch-audio-output',
|
||||||
|
type: AgentChannelType.AUDIO_OUTPUT,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Gesprochene Fassung des Briefings für die Anfahrt — im Katalog als optionaler Kanal geführt.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
optional: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Audiofassung Briefing',
|
||||||
|
senderAddress: 'bruno@property-on.ch',
|
||||||
|
defaultRecipients: ['nadine.brunner@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'bruno-sys-calendar',
|
||||||
|
type: AgentSystemType.CALENDAR,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Aussentermine samt Ort, Objektbezug und Teilnehmenden und erkennt Verschiebungen und Absagen.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Lesezugriff — es werden keine Termine erstellt, verschoben oder gelöscht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-sys-dms',
|
||||||
|
type: AgentSystemType.DMS,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Objektdossiers, Grundrisse, Ausbaubeschriebe und frühere Besichtigungsnotizen zur betroffenen Fläche.',
|
||||||
|
permissionNote: 'Nur Lesezugriff — Dokumente werden weder verändert noch abgelegt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-sys-sharepoint',
|
||||||
|
type: AgentSystemType.SHAREPOINT,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Vermarktungsunterlagen, Konditionenblätter und Bildmaterial aus der Teamablage der Vermarktung.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Lesezugriff auf die freigegebenen Bibliotheken der Vermarktung; andere Bereiche bleiben unberührt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-sys-zefix',
|
||||||
|
type: AgentSystemType.ZEFIX,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Prüft Firmensitz, Rechtsform, Gründungsjahr und Zeichnungsberechtigte des Gegenübers im Handelsregister.',
|
||||||
|
permissionNote: 'Nur Lesezugriff auf öffentlich einsehbare Registerangaben.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-sys-public-web',
|
||||||
|
type: AgentSystemType.PUBLIC_WEB,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Sichtet Firmenwebsite, Standortangaben und Medienberichte zum Gegenüber und hält zu jeder Angabe die Quelle fest.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur öffentlich zugängliche Quellen; es werden keine Zugangsdaten verwendet und keine Anmeldebereiche geöffnet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-sys-immotop2',
|
||||||
|
type: AgentSystemType.IMMOTOP2,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Stammdaten, Konditionen und Verfügbarkeit der kommerziell genutzten Immobilien und meldet veraltete Angaben als Lücke.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Lesezugriff — Stammdaten der Bewirtschaftung werden nicht verändert.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'bruno-set-lead-time',
|
||||||
|
label: 'Triggerzeit vor Termin',
|
||||||
|
description:
|
||||||
|
'Vorlauf, mit dem Bruno die Vorbereitung eines Aussentermins selbständig startet.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.GENERAL,
|
||||||
|
value: 2,
|
||||||
|
unit: 'Stunden',
|
||||||
|
min: 1,
|
||||||
|
max: 24,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-set-briefing-length',
|
||||||
|
label: 'Gewünschte Briefinglänge',
|
||||||
|
description:
|
||||||
|
'Bestimmt, wie ausführlich das Briefing ausfällt. Der Katalogstandard ist die einseitige Fassung.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 'one_pager',
|
||||||
|
options: [
|
||||||
|
{ value: 'short', label: 'Kurzfassung, halbe Seite' },
|
||||||
|
{ value: 'one_pager', label: 'Einseitiges Briefing' },
|
||||||
|
{ value: 'extended', label: 'Ausführlich, zwei Seiten mit Objekthistorie' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-set-audio',
|
||||||
|
label: 'Audiofassung aktiv',
|
||||||
|
description:
|
||||||
|
'Ist die Einstellung aktiv, wird zusätzlich zum Dokument eine gesprochene Fassung für die Anfahrt zugestellt.',
|
||||||
|
kind: AgentSettingKind.BOOLEAN,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-set-sources',
|
||||||
|
label: 'Bevorzugte Quellen',
|
||||||
|
description:
|
||||||
|
'Quellen, die für die Recherche zum Gegenüber und zum Objekt herangezogen werden. Jede Angabe im Briefing wird mit ihrer Quelle ausgewiesen.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['zefix', 'shab', 'company_website', 'property_dossier'],
|
||||||
|
options: [
|
||||||
|
{ value: 'zefix', label: 'Zefix' },
|
||||||
|
{ value: 'shab', label: 'SHAB' },
|
||||||
|
{ value: 'company_website', label: 'Firmenwebsite' },
|
||||||
|
{ value: 'property_dossier', label: 'Objektdossier' },
|
||||||
|
{ value: 'media_reports', label: 'Medienberichte' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-set-selling-points',
|
||||||
|
label: 'Anzahl Verkaufsargumente',
|
||||||
|
description: 'Anzahl der Argumente, die Bruno je Termin auf das Gegenüber zuschneidet.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 3,
|
||||||
|
unit: 'Argumente',
|
||||||
|
min: 1,
|
||||||
|
max: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-set-objections',
|
||||||
|
label: 'Anzahl erwartbarer Einwände',
|
||||||
|
description:
|
||||||
|
'Anzahl der Einwände, die Bruno vorwegnimmt und mit einer belegten Antwort hinterlegt.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 3,
|
||||||
|
unit: 'Einwände',
|
||||||
|
min: 1,
|
||||||
|
max: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'bruno-metric-upcoming',
|
||||||
|
label: 'Kommende Termine',
|
||||||
|
value: '14',
|
||||||
|
hint: 'Aussentermine der Vermarktung in den nächsten 14 Tagen, Stand 20.05.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-metric-briefings',
|
||||||
|
label: 'Erstellte Briefings',
|
||||||
|
value: '96',
|
||||||
|
hint: 'letzte 30 Tage, inklusive 11 Aktualisierungen nach Terminverschiebung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-metric-coverage',
|
||||||
|
label: 'Termine mit Briefing',
|
||||||
|
value: '92 %',
|
||||||
|
hint: 'Anteil der Aussentermine, die rechtzeitig vorbereitet wurden',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-metric-gaps',
|
||||||
|
label: 'Erkannte Informationslücken',
|
||||||
|
value: '23',
|
||||||
|
hint: 'fehlende oder veraltete Objektangaben, im Briefing sichtbar markiert',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'bruno-metric-briefings',
|
||||||
|
lastRun: '2026-05-20T12:00:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Personalblatt «Ferdi», Fristen-Wächter (Abteilung Bewirtschaftung).
|
||||||
|
*
|
||||||
|
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und die
|
||||||
|
* neun Aufgaben werden unverändert übernommen. Zeitanker der Mockdaten: 20.05.2026.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
export const ferdiAgent: TeamAgent = {
|
||||||
|
id: 'ferdi',
|
||||||
|
name: 'Ferdi',
|
||||||
|
role: 'Fristen-Wächter',
|
||||||
|
personnelNumber: 'PO-ZD-02',
|
||||||
|
department: 'Bewirtschaftung',
|
||||||
|
email: 'ferdi@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.BRAND,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.APPROVAL_REQUIRED,
|
||||||
|
shortDescription:
|
||||||
|
'Überwacht Termine, Optionen, Break-Klauseln und Anpassungsfristen und eskaliert ausbleibende Reaktionen.',
|
||||||
|
autonomyNote: '«Autonom beim Erinnern, Freigabe bei Eskalationen»',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Ferdi hält den gesamten Terminbestand der kommerziell genutzten Immobilien im Blick, erinnert rechtzeitig an jede Frist und legt der Bewirtschaftung eine Eskalation vor, sobald eine Reaktion ausbleibt.',
|
||||||
|
input: [
|
||||||
|
'Mietverträge der bewirtschafteten Liegenschaften in Zürich, Winterthur, Basel, Zug, Bern und St. Gallen',
|
||||||
|
'Nachträge, Zusatzvereinbarungen und Optionsausübungen',
|
||||||
|
'Terminexporte aus dem Bewirtschaftungssystem',
|
||||||
|
'Excel-Terminlisten der Standortteams',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Verträge und Nachträge aus Ablage und Bewirtschaftungssystem einlesen',
|
||||||
|
'Fristen, Optionen und Break-Klauseln herauslesen und mit Fundstelle festhalten',
|
||||||
|
'Jeden neu gefundenen Termin einmalig durch die Bewirtschaftung bestätigen lassen',
|
||||||
|
'Den bestätigten Terminbestand jeden Morgen gegen den Kalender abgleichen',
|
||||||
|
'Gestaffelt erinnern und nach fünf Arbeitstagen ohne Reaktion ein zweites Mal melden',
|
||||||
|
'Bleibt die Reaktion weiter aus, eine Eskalation zur Freigabe vorlegen und alles protokollieren',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Fristen-Alerts 12, 9, 6 und 3 Monate vor dem Stichtag',
|
||||||
|
'Wochen-Pipeline der anstehenden Fristen, montags um 07:00 Uhr',
|
||||||
|
'Eskalationsvorschläge zur Freigabe durch die Bewirtschaftung',
|
||||||
|
'Lückenloses Meldeprotokoll je Vertrag, Frist und Reaktion',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Vollständige Erfassung aller Verträge und Nachträge im Terminbestand',
|
||||||
|
'Herauslesen von Kündigungsfristen, Optionsfristen und Break-Klauseln samt Fundstelle',
|
||||||
|
'Einmalige Bestätigung jedes erfassten Termins durch die Bewirtschaftung',
|
||||||
|
'Tägliche Prüfung des Terminbestands auf anstehende und überschrittene Fristen',
|
||||||
|
'Gestaffelte Erinnerungen 12, 9, 6 und 3 Monate vor Frist',
|
||||||
|
'Zweitmeldung bei ausbleibender Reaktion',
|
||||||
|
'Vorlage von Eskalationen zur Freigabe durch die zuständige Leitung',
|
||||||
|
'Wöchentliche Fristen-Pipeline für die Standortteams',
|
||||||
|
'Protokollierung sämtlicher Meldungen, Reaktionen und Freigaben',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-01',
|
||||||
|
title: 'Verträge und Nachträge einlesen',
|
||||||
|
description:
|
||||||
|
'Liest Mietverträge, Nachträge und Zusatzvereinbarungen aus der Dokumentenablage, den Standortordnern und dem Bewirtschaftungssystem ein und ordnet jedes Dokument der richtigen Liegenschaft und Mieteinheit zu.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang, zusätzlich täglicher Abgleich 05:30',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-02',
|
||||||
|
title: 'Fristen, Optionen und Break-Klauseln extrahieren',
|
||||||
|
description:
|
||||||
|
'Liest aus jedem Vertrag die Kündigungsfristen, Optionsfristen, Break-Klauseln sowie Index- und Staffelanpassungen heraus und hält je Termin Stichtag, Fristenlauf und die Fundstelle im Dokument fest. Unklare Formulierungen werden als offen markiert statt geschätzt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'direkt nach dem Einlesen eines Dokuments',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-03',
|
||||||
|
title: 'Extrahierte Termine zur einmaligen Bestätigung vorlegen',
|
||||||
|
description:
|
||||||
|
'Legt jeden neu gefundenen Termin der zuständigen Person der Bewirtschaftung mit Stichtag, Fristenlauf und Fundstelle einmalig zur Bestätigung vor. Erst ein bestätigter Termin wird in den überwachten Terminbestand aufgenommen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'werktags 08:00, Sammelvorlage je Standort',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-04',
|
||||||
|
title: 'Terminbestand täglich um 06:00 Uhr prüfen',
|
||||||
|
description:
|
||||||
|
'Gleicht den gesamten bestätigten Terminbestand jeden Morgen gegen das aktuelle Datum und den Kalender ab, ermittelt die fälligen Meldeschwellen und erkennt Termine, die verschoben, erledigt oder überschritten wurden.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'täglich 06:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.CALENDAR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-05',
|
||||||
|
title: 'Erinnerungen 12, 9, 6 und 3 Monate vor Frist auslösen',
|
||||||
|
description:
|
||||||
|
'Meldet jede anstehende Frist gestaffelt an die zuständige Person: zwölf, neun, sechs und drei Monate vor dem Stichtag, jeweils mit Objekt, Mieter, Fristart und der verbleibenden Zeit bis zur Reaktion.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'täglich 06:00, sobald eine Meldeschwelle erreicht ist',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-06',
|
||||||
|
title: 'Zweitmeldung nach fünf Arbeitstagen ohne Reaktion',
|
||||||
|
description:
|
||||||
|
'Bleibt eine Erinnerung fünf Arbeitstage ohne Rückmeldung, meldet Ferdi dieselbe Frist ein zweites Mal, zusätzlich im Team-Kanal des Standorts, und kennzeichnet den Vorgang als offen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'fünf Arbeitstage nach der ersten Meldung ohne Reaktion',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-07',
|
||||||
|
title: 'Eskalation zur Freigabe vorlegen',
|
||||||
|
description:
|
||||||
|
'Reagiert auch auf die Zweitmeldung niemand, bereitet Ferdi eine Eskalation an die hinterlegten Eskalationsempfänger vor: Objekt, Mieter, Fristart, Stichtag, Meldeverlauf und die Folgen einer versäumten Frist. Die Eskalation geht ausschliesslich nach Freigabe hinaus.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei ausbleibender Reaktion auf die Zweitmeldung',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-08',
|
||||||
|
title: 'Fristen-Pipeline montags um 07:00 Uhr erstellen',
|
||||||
|
description:
|
||||||
|
'Stellt zum Wochenstart eine Übersicht aller Fristen der nächsten zwölf Monate zusammen, gegliedert nach Standort, Meldeschwelle und Bearbeitungsstand, und stellt sie den Standortteams zu.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'montags 07:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-task-09',
|
||||||
|
title: 'Meldungen und Reaktionen protokollieren',
|
||||||
|
description:
|
||||||
|
'Hält je Frist fest, wann gemeldet wurde, wer reagiert hat, welche Freigabe erteilt wurde und wie der Vorgang abgeschlossen worden ist — damit der Verlauf jederzeit nachvollziehbar bleibt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'laufend, Tagesabschluss 18:30',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.SHAREPOINT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'ferdi-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Fristen-Postfach der Bewirtschaftung: versendet Bestätigungsanfragen, Erinnerungen und Eskalationen und nimmt die Rückmeldungen der zuständigen Personen entgegen.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Fristen-Postfach Bewirtschaftung',
|
||||||
|
senderAddress: 'ferdi@property-on.ch',
|
||||||
|
inboxAddress: 'fristen@property-on.ch',
|
||||||
|
defaultRecipients: ['bewirtschaftung@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-ch-whatsapp',
|
||||||
|
type: AgentChannelType.WHATSAPP,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Kurzmeldung auf das Diensthandy, wenn eine Frist in weniger als 30 Tagen abläuft und die zuständige Person nicht am Arbeitsplatz erreichbar ist.',
|
||||||
|
status: AgentConnectionStatus.ROADMAP,
|
||||||
|
enabled: false,
|
||||||
|
optional: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Kurzmeldung Diensthandy',
|
||||||
|
senderAddress: '+41 44 500 21 02',
|
||||||
|
defaultRecipients: ['+41 79 412 66 08'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-ch-calendar',
|
||||||
|
type: AgentChannelType.CALENDAR,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Fristenkalender der Bewirtschaftung: liest bestehende Termine und trägt bestätigte Fristen samt Vorlaufmarken für 12, 9, 6 und 3 Monate ein.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Fristenkalender Bewirtschaftung',
|
||||||
|
senderAddress: 'ferdi@property-on.ch',
|
||||||
|
defaultRecipients: ['bewirtschaftung@property-on.ch', 'andrea.hersche@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-ch-workspace-chat',
|
||||||
|
type: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Team-Kanal der Standorte: erhält die Zweitmeldungen und montags die Fristen-Pipeline der nächsten zwölf Monate.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Team-Kanal «Fristen Bewirtschaftung»',
|
||||||
|
senderAddress: 'ferdi@property-on.ch',
|
||||||
|
defaultRecipients: ['#fristen-zuerich', '#fristen-winterthur', '#fristen-basel'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'ferdi-sys-dms',
|
||||||
|
type: AgentSystemType.DMS,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Mietverträge, Nachträge und Zusatzvereinbarungen und merkt sich je Termin die Fundstelle im Dokument.',
|
||||||
|
permissionNote: 'Nur Lesezugriff — Dokumente werden weder verändert noch verschoben.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-sys-sharepoint',
|
||||||
|
type: AgentSystemType.SHAREPOINT,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest die Standortbibliotheken mit Excel-Terminlisten, Übergabeprotokollen und Optionsvereinbarungen der Standortteams.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Lesezugriff — das Meldeprotokoll wird in der dafür vorgesehenen Fristenliste geführt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-sys-fileshare',
|
||||||
|
type: AgentSystemType.FILESHARE,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest die historischen Vertragsordner der übernommenen Mandate, die noch nicht in der Dokumentenablage erfasst sind.',
|
||||||
|
permissionNote: 'Nur Lesezugriff — Ordnerstruktur und Dateien bleiben unberührt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-sys-immotop2',
|
||||||
|
type: AgentSystemType.IMMOTOP2,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Objekt- und Mieterstammdaten, Vertragslaufzeiten und die Terminexporte des Bewirtschaftungssystems zum Abgleich mit dem eigenen Terminbestand.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Lesezugriff — Stammdaten und Vertragsdaten der Bewirtschaftung werden nicht verändert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-sys-calendar',
|
||||||
|
type: AgentSystemType.CALENDAR,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Trägt bestätigte Fristen mit ihren Vorlaufmarken ein und hält den Fristenkalender der Standortteams aktuell.',
|
||||||
|
permissionNote:
|
||||||
|
'Trägt ausschliesslich eigene Fristeneinträge ein; fremde Termine werden nie verschoben oder gelöscht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-sys-exchange',
|
||||||
|
type: AgentSystemType.M365_EXCHANGE,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Versendet Bestätigungsanfragen, Erinnerungen und Zweitmeldungen und liest die Rückmeldungen aus dem Fristen-Postfach.',
|
||||||
|
permissionNote:
|
||||||
|
'Erinnerungen gehen selbständig hinaus; Eskalationen erst nach Freigabe der zuständigen Leitung.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'ferdi-set-thresholds',
|
||||||
|
label: 'Meldeschwellen vor Frist',
|
||||||
|
description:
|
||||||
|
'Zeitpunkte, zu denen Ferdi vor einem Stichtag erinnert. Jede aktivierte Schwelle löst eine eigene Meldung an die zuständige Person aus.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['m12', 'm9', 'm6', 'm3'],
|
||||||
|
options: [
|
||||||
|
{ value: 'm18', label: '18 Monate vor Frist' },
|
||||||
|
{ value: 'm12', label: '12 Monate vor Frist' },
|
||||||
|
{ value: 'm9', label: '9 Monate vor Frist' },
|
||||||
|
{ value: 'm6', label: '6 Monate vor Frist' },
|
||||||
|
{ value: 'm3', label: '3 Monate vor Frist' },
|
||||||
|
{ value: 'm1', label: '1 Monat vor Frist' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-set-second-notice',
|
||||||
|
label: 'Zweitmeldung nach Arbeitstagen',
|
||||||
|
description:
|
||||||
|
'Anzahl Arbeitstage ohne Rückmeldung, nach denen dieselbe Frist ein zweites Mal gemeldet wird — zusätzlich im Team-Kanal des Standorts.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 5,
|
||||||
|
unit: 'Arbeitstage',
|
||||||
|
min: 1,
|
||||||
|
max: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-set-escalation-recipients',
|
||||||
|
label: 'Eskalationsempfänger',
|
||||||
|
description:
|
||||||
|
'Erhalten die Eskalation, wenn auch auf die Zweitmeldung keine Reaktion erfolgt, und erteilen die Freigabe für den Versand.',
|
||||||
|
kind: AgentSettingKind.TEXT,
|
||||||
|
group: AgentSettingGroup.APPROVAL,
|
||||||
|
value:
|
||||||
|
'Andrea Hersche, Leitung Bewirtschaftung Zürich (Stellvertretung: Thomas Bühlmann, Winterthur; in Kopie: Mandatsleitung Basel)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-set-weekly-report',
|
||||||
|
label: 'Wochenreport-Zeitpunkt',
|
||||||
|
description:
|
||||||
|
'Uhrzeit, zu der die Fristen-Pipeline der nächsten zwölf Monate zusammengestellt und den Standortteams zugestellt wird. Wochentag ist fix montags.',
|
||||||
|
kind: AgentSettingKind.TIME,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: '07:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-set-deadline-types',
|
||||||
|
label: 'Einzubeziehende Fristarten',
|
||||||
|
description:
|
||||||
|
'Arten von Terminen, die Ferdi in den Terminbestand aufnimmt und überwacht. Nicht aktivierte Fristarten werden erfasst, aber nicht gemeldet.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: [
|
||||||
|
'termination',
|
||||||
|
'option',
|
||||||
|
'break_clause',
|
||||||
|
'index',
|
||||||
|
'staggered_rent',
|
||||||
|
'service_charges',
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
{ value: 'termination', label: 'Kündigungsfristen' },
|
||||||
|
{ value: 'option', label: 'Optionsfristen' },
|
||||||
|
{ value: 'break_clause', label: 'Break-Klauseln' },
|
||||||
|
{ value: 'index', label: 'Indexanpassungen' },
|
||||||
|
{ value: 'staggered_rent', label: 'Staffelmieten' },
|
||||||
|
{ value: 'service_charges', label: 'Nebenkostenabrechnung' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'ferdi-metric-contracts',
|
||||||
|
label: 'Erfasste Verträge',
|
||||||
|
value: '842',
|
||||||
|
hint: 'Mietverträge und Nachträge über alle Mandate, Stand 20.05.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-metric-deadlines',
|
||||||
|
label: 'Überwachte Fristen',
|
||||||
|
value: '1 316',
|
||||||
|
hint: 'bestätigte Termine im aktiven Terminbestand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-metric-next-critical',
|
||||||
|
label: 'Nächste kritische Frist',
|
||||||
|
value: '05.06.2026',
|
||||||
|
hint: 'Optionsfrist Sihlfeld Logistik GmbH, Bahnhofstrasse 44, Zürich',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-metric-open-confirmations',
|
||||||
|
label: 'Offene Bestätigungen',
|
||||||
|
value: '17',
|
||||||
|
hint: 'neu gefundene Termine, die auf die einmalige Bestätigung warten',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ferdi-metric-escalations',
|
||||||
|
label: 'Eskalationen',
|
||||||
|
value: '3',
|
||||||
|
hint: 'zur Freigabe vorgelegt, letzte 30 Tage',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'ferdi-metric-deadlines',
|
||||||
|
lastRun: '2026-05-20T06:00:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Personalblatt «Lea», Anfragen-Managerin (Abteilung Vermarktung).
|
||||||
|
*
|
||||||
|
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und die
|
||||||
|
* elf Aufgaben werden unverändert übernommen. Zeitanker der Mockdaten: 20.05.2026.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
const ABSAGEVORLAGE = `Guten Tag {Anrede} {Nachname}
|
||||||
|
|
||||||
|
Besten Dank für Ihre Anfrage zur Fläche {Objekt} in {Ort}.
|
||||||
|
|
||||||
|
Wir haben Ihre Angaben geprüft. Leider erfüllt Ihr Bedarf die für dieses Objekt
|
||||||
|
festgelegten Grundvoraussetzungen nicht: {Grund}.
|
||||||
|
|
||||||
|
Sobald eine passende Fläche in unserem Portfolio frei wird, melden wir uns
|
||||||
|
unaufgefordert bei Ihnen.
|
||||||
|
|
||||||
|
Freundliche Grüsse
|
||||||
|
Property On — Vermarktung
|
||||||
|
{Verantwortliche Person}`
|
||||||
|
|
||||||
|
export const leaAgent: TeamAgent = {
|
||||||
|
id: 'lea',
|
||||||
|
name: 'Lea',
|
||||||
|
role: 'Anfragen-Managerin',
|
||||||
|
personnelNumber: 'PO-ZD-07',
|
||||||
|
department: 'Vermarktung',
|
||||||
|
email: 'lea@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.SUCCESS,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.APPROVAL_REQUIRED,
|
||||||
|
shortDescription:
|
||||||
|
'Normalisiert, qualifiziert und koordiniert Anfragen bis zum bestätigten Besichtigungstermin.',
|
||||||
|
autonomyNote:
|
||||||
|
'«Automatische Absage bei Nichterfüllung; Freigabe für Schritte Richtung Termin»',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Lea nimmt jede eingehende Flächenanfrage auf, prüft sie gegen die Must-Kriterien der ausgeschriebenen Fläche und begleitet sie bis zum bestätigten Besichtigungstermin.',
|
||||||
|
input: [
|
||||||
|
'Portal-Lead-Mails der Immobilienplattformen',
|
||||||
|
'Direktmails an das Anfragen-Postfach der Vermarktung',
|
||||||
|
'Website-Formulare von property-on.ch',
|
||||||
|
'Telefonnotizen aus dem Empfang Zürich und Winterthur',
|
||||||
|
'Must-Kriterien je ausgeschriebener Fläche',
|
||||||
|
'Besichtigungskalender der verantwortlichen Person',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Anfragen aus allen Kanälen einsammeln und in ein einheitliches Bedarfsprofil überführen',
|
||||||
|
'Prüfen, ob dieselbe Person bereits über einen anderen Kanal angefragt hat',
|
||||||
|
'Fehlende Pflichtangaben mit einem vorbereiteten Rückfragetext nachfassen',
|
||||||
|
'Bedarfsprofil mit den Must-Kriterien der Fläche abgleichen',
|
||||||
|
'Nicht passende Interessenten sachlich absagen, passende zur Entscheidung vorlegen',
|
||||||
|
'Nach Freigabe freie Zeitfenster suchen, Termin vorschlagen, bestätigen und den Verlauf ablegen',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Automatische Absagen mit nachvollziehbarer Begründung',
|
||||||
|
'Qualifizierte Kurzprofile für die Entscheidung der Vermarktung',
|
||||||
|
'Bestätigte Besichtigungstermine samt vorbereitetem Reminder',
|
||||||
|
'Lückenloses Anfrage-Log je Fläche und Interessent',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Erfassung sämtlicher Anfragen aus Portalen, Website, E-Mail und Telefon',
|
||||||
|
'Überführung von Freitext in ein einheitliches Bedarfsprofil',
|
||||||
|
'Deduplizierung über alle Kanäle hinweg',
|
||||||
|
'Qualifizierung gegen die Must-Kriterien der ausgeschriebenen Flächen',
|
||||||
|
'Automatische Absage bei klarer Nichterfüllung',
|
||||||
|
'Kalendersteuerung und Terminkoordination bis zur Bestätigung',
|
||||||
|
'Protokollierung von Status und Verlauf je Anfrage',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'lea-task-01',
|
||||||
|
title: 'Anfragen aus verschiedenen Kanälen erfassen',
|
||||||
|
description:
|
||||||
|
'Nimmt Portal-Lead-Mails, Direktmails, Website-Formulare und Telefonnotizen entgegen und legt zu jeder Anfrage einen Eintrag auf der betroffenen Fläche an.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PORTAL_LEAD_INBOX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-02',
|
||||||
|
title: 'Freitext in ein Bedarfsprofil überführen',
|
||||||
|
description:
|
||||||
|
'Liest den Anfragetext und trägt Flächenbedarf, Nutzungsart, Wunschtermin, Budgetrahmen und Vertragsdauer in ein einheitliches Bedarfsprofil ein. Unklare Angaben werden als offen markiert statt geraten.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang, direkt nach der Erfassung',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-03',
|
||||||
|
title: 'Duplikate kanalübergreifend erkennen',
|
||||||
|
description:
|
||||||
|
'Gleicht Name, Firma, Telefonnummer und E-Mail mit den Anfragen der letzten 90 Tage ab. Mehrfachanfragen derselben Person über Portal, Formular und Telefon werden zu einem Vorgang zusammengeführt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang, Abgleich über die letzten 90 Tage',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.CRM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-04',
|
||||||
|
title: 'Fehlende Angaben über Rückfrageentwürfe einholen',
|
||||||
|
description:
|
||||||
|
'Erstellt bei unvollständigen Anfragen einen Rückfrageentwurf mit den fehlenden Mindestangaben. Der Entwurf geht erst nach Freigabe der verantwortlichen Person hinaus.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei unvollständiger Anfrage, Sammelvorlage werktags 08:30',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-05',
|
||||||
|
title: 'Anfrage gegen Must-Kriterien prüfen',
|
||||||
|
description:
|
||||||
|
'Vergleicht das Bedarfsprofil mit den hinterlegten Must-Kriterien der Fläche und hält je Kriterium fest, ob es erfüllt, nicht erfüllt oder noch offen ist.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'unmittelbar nach Vollständigkeit des Bedarfsprofils',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-06',
|
||||||
|
title: 'Nicht passende Interessenten automatisch absagen',
|
||||||
|
description:
|
||||||
|
'Versendet bei klarer Nichterfüllung eines Must-Kriteriums die hinterlegte Absagevorlage mit dem konkreten Grund und schliesst den Vorgang. Grenzfälle werden nie automatisch abgesagt, sondern vorgelegt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'stündlich, werktags 07:00–19:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-07',
|
||||||
|
title: 'Passende Interessenten zur Entscheidung vorlegen',
|
||||||
|
description:
|
||||||
|
'Legt der verantwortlichen Person ein Kurzprofil vor: Bedarf, erfüllte und offene Kriterien, Herkunftskanal und Dringlichkeit. Ohne Freigabe geht kein Schritt Richtung Termin hinaus.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'werktags 07:30 und 13:30',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnSystem: AgentSystemType.CRM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-08',
|
||||||
|
title: 'Nach Freigabe Kalender abgleichen',
|
||||||
|
description:
|
||||||
|
'Sucht im Besichtigungskalender der verantwortlichen Person freie Zeitfenster gemäss den hinterlegten Kalenderregeln, unter Berücksichtigung von Termindauer und Pufferzeit.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach erteilter Freigabe',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnSystem: AgentSystemType.CALENDAR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-09',
|
||||||
|
title: 'Terminvorschläge senden',
|
||||||
|
description:
|
||||||
|
'Sendet dem Interessenten drei Terminvorschläge mit Treffpunkt und Ansprechperson und reserviert die Zeitfenster vorläufig im Kalender.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'innerhalb von 30 Minuten nach Freigabe',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-10',
|
||||||
|
title: 'Termin bestätigen und Reminder vorbereiten',
|
||||||
|
description:
|
||||||
|
'Bestätigt den gewählten Termin, trägt ihn verbindlich im Kalender ein, löst die übrigen Reservationen und bereitet den Reminder für Interessent und Ansprechperson vor.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Zusage des Interessenten',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnChannel: AgentChannelType.CALENDAR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-task-11',
|
||||||
|
title: 'Status und Verlauf protokollieren',
|
||||||
|
description:
|
||||||
|
'Hält je Anfrage Kanal, Eingangszeit, Prüfergebnis, Freigaben, versandte Nachrichten und Terminstatus im Protokoll fest, damit der Verlauf jederzeit nachvollziehbar bleibt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'laufend, Tagesabschluss 19:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.CRM,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'lea-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Anfragen-Postfach der Vermarktung: Direktmails von Interessenten sowie Rückfragen, Absagen und Terminvorschläge im Versand.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Anfragen-Postfach Vermarktung',
|
||||||
|
senderAddress: 'lea@property-on.ch',
|
||||||
|
inboxAddress: 'anfragen@property-on.ch',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-ch-portal-lead-mail',
|
||||||
|
type: AgentChannelType.PORTAL_LEAD_MAIL,
|
||||||
|
direction: AgentChannelDirection.INBOUND,
|
||||||
|
description:
|
||||||
|
'Lead-Mails der Immobilienplattformen zu den ausgeschriebenen Flächen in Zürich, Winterthur, Basel, Zug, Bern und St. Gallen.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Portal-Leads Plattformen',
|
||||||
|
inboxAddress: 'portal-leads@property-on.ch',
|
||||||
|
defaultRecipients: [],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-ch-web-form',
|
||||||
|
type: AgentChannelType.WEB_FORM,
|
||||||
|
direction: AgentChannelDirection.INBOUND,
|
||||||
|
description:
|
||||||
|
'Formular «Fläche anfragen» auf property-on.ch — liefert bereits strukturierte Felder zu Bedarf, Nutzung und Wunschtermin.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Website-Formular «Fläche anfragen»',
|
||||||
|
inboxAddress: 'formular@property-on.ch',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-ch-phone-note',
|
||||||
|
type: AgentChannelType.PHONE_NOTE,
|
||||||
|
direction: AgentChannelDirection.INBOUND,
|
||||||
|
description:
|
||||||
|
'Telefonnotizen des Empfangs in Zürich und Winterthur, als Freitext erfasst und für die Deduplizierung mitgeführt.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
optional: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Telefonnotizen Empfang',
|
||||||
|
inboxAddress: 'telefonnotiz@property-on.ch',
|
||||||
|
defaultRecipients: [],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-ch-calendar',
|
||||||
|
type: AgentChannelType.CALENDAR,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Besichtigungskalender der Vermarktung: liest freie Zeitfenster, setzt Reservationen und trägt bestätigte Termine ein.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Besichtigungskalender Vermarktung',
|
||||||
|
senderAddress: 'lea@property-on.ch',
|
||||||
|
defaultRecipients: ['nadine.brunner@property-on.ch', 'vermarktung@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'lea-sys-portal-inbox',
|
||||||
|
type: AgentSystemType.PORTAL_LEAD_INBOX,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Holt die Lead-Mails der Immobilienplattformen ab und ordnet sie der ausgeschriebenen Fläche zu.',
|
||||||
|
permissionNote: 'Nur Lesezugriff — es werden keine Einträge im Portal verändert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-sys-crm',
|
||||||
|
type: AgentSystemType.CRM,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Führt Interessent, Bedarfsprofil, Prüfergebnis und Terminstatus, erkennt Duplikate und schreibt das Anfrage-Log.',
|
||||||
|
permissionNote:
|
||||||
|
'Schreibt Anfragen, Notizen und Statuswechsel selbständig; Vertragsdaten bleiben unberührt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-sys-calendar',
|
||||||
|
type: AgentSystemType.CALENDAR,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Sucht freie Zeitfenster, reserviert Terminvorschläge und trägt bestätigte Besichtigungen samt Reminder ein.',
|
||||||
|
permissionNote:
|
||||||
|
'Schreibt erst nach Freigabe der verantwortlichen Person; bestehende Termine werden nie verschoben oder gelöscht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-sys-exchange',
|
||||||
|
type: AgentSystemType.M365_EXCHANGE,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest das Anfragen-Postfach und versendet Absagen, Rückfragen, Terminvorschläge und Bestätigungen.',
|
||||||
|
permissionNote:
|
||||||
|
'Automatischer Versand ausschliesslich für Absagen; alle übrigen Nachrichten gehen erst nach Freigabe hinaus.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-sys-immotop2',
|
||||||
|
type: AgentSystemType.IMMOTOP2,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Flächenstammdaten, Verfügbarkeit und die hinterlegten Must-Kriterien der kommerziell genutzten Immobilien.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Lesezugriff — Stammdaten der Bewirtschaftung werden nicht verändert.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'lea-set-must-criteria',
|
||||||
|
label: 'Must-Kriterien je Fläche',
|
||||||
|
description:
|
||||||
|
'Kriterien, die eine Anfrage zwingend erfüllen muss. Wird eines davon klar verfehlt, folgt die automatische Absage.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['area_fit', 'usage_allowed', 'start_date', 'budget_range', 'credit_check'],
|
||||||
|
options: [
|
||||||
|
{ value: 'area_fit', label: 'Flächenbedarf im ausgeschriebenen Rahmen' },
|
||||||
|
{ value: 'usage_allowed', label: 'Nutzungsart für das Objekt zulässig' },
|
||||||
|
{ value: 'start_date', label: 'Wunschtermin Mietbeginn passt zur Verfügbarkeit' },
|
||||||
|
{ value: 'budget_range', label: 'Budgetrahmen erreicht die Mietpreisvorstellung' },
|
||||||
|
{ value: 'credit_check', label: 'Bonitätsnachweis kann beigebracht werden' },
|
||||||
|
{ value: 'lease_term', label: 'Vertragsdauer mindestens fünf Jahre' },
|
||||||
|
{ value: 'parking', label: 'Parkplatzbedarf am Standort abgedeckt' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-auto-reject',
|
||||||
|
label: 'Automatische Absage aktiv',
|
||||||
|
description:
|
||||||
|
'Ist die Einstellung aktiv, sagt Lea bei klarer Nichterfüllung eines Must-Kriteriums ohne Rückfrage ab. Grenzfälle werden weiterhin vorgelegt.',
|
||||||
|
kind: AgentSettingKind.BOOLEAN,
|
||||||
|
group: AgentSettingGroup.APPROVAL,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-reject-template',
|
||||||
|
label: 'Absagevorlage',
|
||||||
|
description:
|
||||||
|
'Text der automatischen Absage. Platzhalter in geschweiften Klammern werden je Anfrage eingesetzt.',
|
||||||
|
kind: AgentSettingKind.TEXT,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: ABSAGEVORLAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-min-fields',
|
||||||
|
label: 'Mindestangaben',
|
||||||
|
description:
|
||||||
|
'Angaben, die für eine Qualifizierung vorliegen müssen. Fehlt eine davon, erstellt Lea einen Rückfrageentwurf.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['contact', 'company', 'area_sqm', 'usage', 'start_date'],
|
||||||
|
options: [
|
||||||
|
{ value: 'contact', label: 'Name und Kontaktangaben' },
|
||||||
|
{ value: 'company', label: 'Firma und Branche' },
|
||||||
|
{ value: 'area_sqm', label: 'Gewünschte Fläche in m²' },
|
||||||
|
{ value: 'usage', label: 'Geplante Nutzungsart' },
|
||||||
|
{ value: 'start_date', label: 'Wunschtermin Mietbeginn' },
|
||||||
|
{ value: 'budget', label: 'Budgetrahmen' },
|
||||||
|
{ value: 'lease_term', label: 'Gewünschte Vertragsdauer' },
|
||||||
|
{ value: 'parking', label: 'Parkplatzbedarf' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-owner',
|
||||||
|
label: 'Verantwortliche Person',
|
||||||
|
description:
|
||||||
|
'Erhält die qualifizierten Kurzprofile und erteilt die Freigabe für alle Schritte Richtung Termin.',
|
||||||
|
kind: AgentSettingKind.TEXT,
|
||||||
|
group: AgentSettingGroup.APPROVAL,
|
||||||
|
value: 'Nadine Brunner, Vermarktung Zürich (Stellvertretung: Marco Steinegger, Winterthur)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-calendar-rules',
|
||||||
|
label: 'Kalenderregeln',
|
||||||
|
description: 'Bestimmt, welche Zeitfenster Lea für Besichtigungen vorschlagen darf.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 'business_hours',
|
||||||
|
options: [
|
||||||
|
{ value: 'business_hours', label: 'Geschäftszeiten Mo–Fr, 08:00–17:00' },
|
||||||
|
{ value: 'extended', label: 'Erweitert inkl. Samstagvormittag' },
|
||||||
|
{ value: 'owner_availability', label: 'Nach Verfügbarkeit der verantwortlichen Person' },
|
||||||
|
{ value: 'fixed_slots', label: 'Feste Besichtigungsfenster Dienstag und Donnerstag' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-duration',
|
||||||
|
label: 'Termindauer',
|
||||||
|
description: 'Dauer eines Besichtigungstermins, die Lea im Kalender reserviert.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 45,
|
||||||
|
unit: 'Minuten',
|
||||||
|
min: 15,
|
||||||
|
max: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-buffer',
|
||||||
|
label: 'Pufferzeit',
|
||||||
|
description: 'Freizuhaltende Zeit vor und nach einem Termin, etwa für Anfahrt und Übergabe.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 15,
|
||||||
|
unit: 'Minuten',
|
||||||
|
min: 0,
|
||||||
|
max: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-set-reminder',
|
||||||
|
label: 'Reminder-Zeitpunkt',
|
||||||
|
description: 'Wann Interessent und Ansprechperson an den bestätigten Termin erinnert werden.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: 'h24',
|
||||||
|
options: [
|
||||||
|
{ value: 'h24', label: '24 Stunden vor dem Termin' },
|
||||||
|
{ value: 'h4', label: 'vier Stunden vor dem Termin' },
|
||||||
|
{ value: 'h2', label: 'zwei Stunden vor dem Termin' },
|
||||||
|
{ value: 'h1', label: 'eine Stunde vor dem Termin' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'lea-metric-new',
|
||||||
|
label: 'Neue Anfragen',
|
||||||
|
value: '128',
|
||||||
|
hint: 'letzte 30 Tage, alle Kanäle, nach Deduplizierung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-metric-qualified',
|
||||||
|
label: 'Qualifizierte Anfragen',
|
||||||
|
value: '74',
|
||||||
|
hint: 'Mindestangaben vollständig und Must-Kriterien erfüllt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-metric-auto-rejected',
|
||||||
|
label: 'Automatische Absagen',
|
||||||
|
value: '39',
|
||||||
|
hint: 'klare Nichterfüllung eines Must-Kriteriums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-metric-open-decisions',
|
||||||
|
label: 'Offene Entscheidungen',
|
||||||
|
value: '6',
|
||||||
|
hint: 'wartet auf Freigabe der verantwortlichen Person',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-metric-confirmed',
|
||||||
|
label: 'Bestätigte Termine',
|
||||||
|
value: '31',
|
||||||
|
hint: 'Besichtigungen mit vorbereitetem Reminder',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lea-metric-first-response',
|
||||||
|
label: 'Durchschnittliche Erstreaktionszeit',
|
||||||
|
value: '11 Minuten',
|
||||||
|
hint: 'vom Eingang bis zur ersten Rückmeldung, Stand 20.05.2026',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'lea-metric-qualified',
|
||||||
|
lastRun: '2026-05-20T06:15:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Personalblatt «Livia», Lage-Analystin (Vermarktung).
|
||||||
|
*
|
||||||
|
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und
|
||||||
|
* die elf Katalogaufgaben werden hier unverändert abgebildet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
export const liviaAgent: TeamAgent = {
|
||||||
|
id: 'livia',
|
||||||
|
name: 'Livia',
|
||||||
|
role: 'Lage-Analystin',
|
||||||
|
personnelNumber: 'PO-ZD-21',
|
||||||
|
department: 'Vermarktung',
|
||||||
|
email: 'livia@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.WARNING,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
|
||||||
|
shortDescription:
|
||||||
|
'Erstellt aktuelle Mikro- und Makrolagebeschriebe in zwei Längen und mit Quellen.',
|
||||||
|
autonomyNote:
|
||||||
|
'Autonom. Livia recherchiert, schreibt und frischt Lagebeschriebe ohne vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die Vermarktung jeden Satz nachprüfen kann.',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Livia beschreibt die Lage einer kommerziell genutzten Immobilie so, dass Vermarktung und Interessenten in wenigen Minuten verstehen, wo das Objekt steht, wie es erreichbar ist und was sich im Umfeld verändert.',
|
||||||
|
input: [
|
||||||
|
'Objektadresse aus dem Vermarktungsauftrag',
|
||||||
|
'Öffentliche Umfeld- und Verkehrsdaten zu Quartier, Anbindung und Nahversorgung',
|
||||||
|
'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Umfeldentwicklungen',
|
||||||
|
'Bestehende Objektunterlagen aus der Dokumentenablage',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Adresse aufnehmen und dem richtigen Quartier zuordnen, etwa Zürich Kreis 5 oder Winterthur Neuhegi.',
|
||||||
|
'Umfeld im festgelegten Radius abgehen: Nachbarschaft, Erdgeschossnutzungen, Erreichbarkeit, Einkaufen und Verpflegung.',
|
||||||
|
'Gemeinde und Region einordnen: Wirtschaftsstruktur, Steuerlage, Entwicklung der Nachfrage.',
|
||||||
|
'Amtliche Publikationen und Bauprojekte im Umfeld sichten und bewerten, ob sie für das Objekt relevant sind.',
|
||||||
|
'Aus den gesammelten Bausteinen einen Kurztext fürs Inserat und einen Langtext fürs Exposé schreiben.',
|
||||||
|
'Quellen anhängen, Ergebnis je Objekt ablegen und quartalsweise gegen den aktuellen Stand prüfen.',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Kurztext für Inserat und Portalauftritt',
|
||||||
|
'Langtext für das Exposé mit Mikro-, Makro- und Umfeldteil',
|
||||||
|
'Quellenverzeichnis mit Abrufdatum je Aussage',
|
||||||
|
'Änderungshinweise, sobald sich das Umfeld spürbar verändert',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Mikro- und Makrolage kommerziell genutzter Immobilien nachvollziehbar beschreiben',
|
||||||
|
'Erreichbarkeit, Nahversorgung und geplante Umfeldentwicklungen sauber trennen und darstellen',
|
||||||
|
'Kurz- und Langtexte in der Firmen-Tonalität liefern, ohne Werbefloskeln ohne Beleg',
|
||||||
|
'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen',
|
||||||
|
'Lagebeschriebe quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'livia-task-01',
|
||||||
|
title: 'Objektadresse aufnehmen',
|
||||||
|
description:
|
||||||
|
'Übernimmt Strasse, Hausnummer, Postleitzahl und Ort aus dem Vermarktungsauftrag, prüft die Schreibweise gegen die amtlichen Adressdaten und ordnet das Objekt einem Quartier zu, etwa Zürich Kreis 5, Winterthur Neuhegi oder Basel Gundeldingen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang eines Vermarktungsauftrags',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-02',
|
||||||
|
title: 'Mikrolage analysieren',
|
||||||
|
description:
|
||||||
|
'Beschreibt das unmittelbare Umfeld im eingestellten Radius: Nachbarschaft, Erdgeschossnutzungen, Lärm- und Immissionslage, Publikumsfrequenz sowie Parkierung. Trennt beobachtbare Fakten klar von Einschätzungen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Adressaufnahme',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-03',
|
||||||
|
title: 'Makrolage einordnen',
|
||||||
|
description:
|
||||||
|
'Ordnet Gemeinde und Wirtschaftsraum ein: Bevölkerungs- und Beschäftigtenentwicklung, Branchenmix, Steuerlage und Bedeutung des Standorts innerhalb der Region, etwa Zug im Wirtschaftsraum Zentralschweiz oder St. Gallen als Zentrum der Ostschweiz.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Mikrolage-Analyse',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-04',
|
||||||
|
title: 'Erreichbarkeit und Nahversorgung darstellen',
|
||||||
|
description:
|
||||||
|
'Stellt Anbindung an öffentlichen Verkehr, Autobahnanschluss, Velo- und Fusswege sowie Einkaufen, Verpflegung, Kinderbetreuung und Gesundheitsversorgung dar — jeweils mit Distanz oder Wegzeit statt mit unbelegten Werbeaussagen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Makrolage-Einordnung',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-05',
|
||||||
|
title: 'Geplante Projekte und Umfeldentwicklungen aufnehmen',
|
||||||
|
description:
|
||||||
|
'Sichtet amtliche Publikationen und Bauprojekte im Umfeld — Arealentwicklungen, Zonenänderungen, Verkehrsvorhaben, neue Haltestellen — und hält fest, ab wann sie für das Objekt spürbar werden, etwa das Entwicklungsgebiet Bern Wankdorf.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'montags 07:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.SHAB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-06',
|
||||||
|
title: 'Kurzen Inserattext erstellen',
|
||||||
|
description:
|
||||||
|
'Verdichtet die Lagebausteine auf einen Kurztext für Inserat und Portalauftritt, innerhalb des eingestellten Zeichenlimits und in der gewählten Firmen-Tonalität.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'sobald alle Lagebausteine vorliegen',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-07',
|
||||||
|
title: 'Ausführlichen Exposé-Text erstellen',
|
||||||
|
description:
|
||||||
|
'Schreibt den Langtext für das Exposé mit Mikrolage, Makrolage, Erreichbarkeit, Nahversorgung und Umfeldentwicklung in getrennten Abschnitten, damit sich Interessenten gezielt einlesen können.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'direkt im Anschluss an den Kurztext',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-08',
|
||||||
|
title: 'Quellen hinterlegen',
|
||||||
|
description:
|
||||||
|
'Hängt jeder Aussage die verwendete Quelle mit Bezeichnung und Abrufdatum an. Lässt sich eine Aussage nicht belegen, wird sie gestrichen statt abgeschwächt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei jeder Texterstellung',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-09',
|
||||||
|
title: 'Ergebnisse je Objekt lokal zwischenspeichern',
|
||||||
|
description:
|
||||||
|
'Legt Kurztext, Langtext und Quellenverzeichnis je Objekt in der Dokumentenablage ab, sodass der nächste Arbeitsauftrag auf dem bestehenden Stand aufsetzt und nichts doppelt recherchiert wird.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach jedem abgeschlossenen Arbeitsauftrag',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-10',
|
||||||
|
title: 'Quartalsweisen Aktualisierungslauf simulieren',
|
||||||
|
description:
|
||||||
|
'Führt den «Quartals-Refresh» durch: prüft alle abgelegten Lagebeschriebe gegen den aktuellen Stand der Quellen, markiert veraltete Abschnitte und erneuert sie. Der Lauf wird protokolliert und ist je Objekt nachvollziehbar.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'quartalsweise am ersten Werktag 05:30',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-11',
|
||||||
|
title: 'Relevante Änderungen melden',
|
||||||
|
description:
|
||||||
|
'Meldet der Vermarktung nur, was den Lagebeschrieb wirklich verändert — neue Haltestelle, bewilligte Arealentwicklung, wegfallende Nahversorgung — mit Hinweis auf den betroffenen Textabschnitt und die Quelle.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei erkannter Abweichung im Aktualisierungslauf',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'livia-ch-workspace-chat',
|
||||||
|
type: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Livia nimmt Objektadressen und Rückfragen im Arbeitsbereich entgegen und liefert Kurztext, Langtext und Änderungshinweise direkt im Kanal der Vermarktung.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Livia — Lage & Umfeld',
|
||||||
|
inboxAddress: 'arbeitsbereich/vermarktung/lage',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Versand der fertigen Lagebeschriebe und der Quartalsmeldung an Vermarktung und Bewirtschaftung. Eingehende Mails werden nicht bearbeitet.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Livia (Property On)',
|
||||||
|
senderAddress: 'livia@property-on.ch',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch', 'bewirtschaftung@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'livia-sys-public-web',
|
||||||
|
type: AgentSystemType.PUBLIC_WEB,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Öffentliche Umfeld- und Verkehrsdaten: Haltestellen und Fahrpläne, Wegzeiten, Einkaufen und Verpflegung, Gemeindeangaben zu Bevölkerung und Beschäftigung.',
|
||||||
|
permissionNote: 'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-shab',
|
||||||
|
type: AgentSystemType.SHAB,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen im Umfeld des Objekts.',
|
||||||
|
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-immotop2',
|
||||||
|
type: AgentSystemType.IMMOTOP2,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Objektstammdaten und Adressen aus der Bewirtschaftung als Ausgangspunkt jedes Lagebeschriebs.',
|
||||||
|
permissionNote: 'Nur lesend. Livia verändert keine Stammdaten in der Bewirtschaftungslösung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-dms',
|
||||||
|
type: AgentSystemType.DMS,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Ablage von Kurztext, Langtext und Quellenverzeichnis je Objekt sowie Zwischenstand für den Quartalslauf.',
|
||||||
|
permissionNote:
|
||||||
|
'Schreibt ausschliesslich in den Ordner «Lagebeschriebe» des jeweiligen Objekts; bestehende Fassungen bleiben als Version erhalten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-workspace',
|
||||||
|
type: AgentSystemType.WORKSPACE,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Aufträge, Rückfragen und Änderungshinweise im Arbeitsbereich der Vermarktung, inklusive Protokoll der Aktualisierungsläufe.',
|
||||||
|
permissionNote:
|
||||||
|
'Schreibt nur in den eigenen Bereich «Lage & Umfeld»; Objektdossiers anderer Teams bleiben unberührt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'livia-set-short-limit',
|
||||||
|
label: 'Kurztext-Zeichenlimit',
|
||||||
|
description: 'Obergrenze für den Lagetext im Inserat und auf den Portalen.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 600,
|
||||||
|
unit: 'Zeichen',
|
||||||
|
min: 200,
|
||||||
|
max: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-long-limit',
|
||||||
|
label: 'Langtext-Zeichenlimit',
|
||||||
|
description: 'Obergrenze für den ausführlichen Lageteil im Exposé.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 2400,
|
||||||
|
unit: 'Zeichen',
|
||||||
|
min: 800,
|
||||||
|
max: 5000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-tonality',
|
||||||
|
label: 'Firmen-Tonalität',
|
||||||
|
description: 'Sprachstil, in dem Kurz- und Langtext verfasst werden.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.GENERAL,
|
||||||
|
value: 'sachlich',
|
||||||
|
options: [
|
||||||
|
{ value: 'sachlich', label: 'sachlich' },
|
||||||
|
{ value: 'hochwertig', label: 'hochwertig' },
|
||||||
|
{ value: 'zurueckhaltend', label: 'zurückhaltend' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-refresh-interval',
|
||||||
|
label: 'Aktualisierungsintervall',
|
||||||
|
description: 'Rhythmus, in dem bestehende Lagebeschriebe gegen die Quellen geprüft werden.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 'quartalsweise',
|
||||||
|
options: [
|
||||||
|
{ value: 'monatlich', label: 'monatlich' },
|
||||||
|
{ value: 'quartalsweise', label: 'quartalsweise' },
|
||||||
|
{ value: 'halbjaehrlich', label: 'halbjährlich' },
|
||||||
|
{ value: 'jaehrlich', label: 'jährlich' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-analysis-radius',
|
||||||
|
label: 'Radius der Umfeldanalyse',
|
||||||
|
description: 'Umkreis um die Objektadresse, der für die Mikrolage ausgewertet wird.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 800,
|
||||||
|
unit: 'Meter',
|
||||||
|
min: 200,
|
||||||
|
max: 3000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-source-types',
|
||||||
|
label: 'Relevante Quellentypen',
|
||||||
|
description: 'Welche Quellen Livia für Lagebeschriebe und Aktualisierungsläufe heranzieht.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['amtliche_publikationen', 'verkehrsdaten', 'bauprojekte', 'nahversorgung'],
|
||||||
|
options: [
|
||||||
|
{ value: 'amtliche_publikationen', label: 'amtliche Publikationen' },
|
||||||
|
{ value: 'verkehrsdaten', label: 'Verkehrsdaten' },
|
||||||
|
{ value: 'bauprojekte', label: 'Bauprojekte' },
|
||||||
|
{ value: 'nahversorgung', label: 'Nahversorgung' },
|
||||||
|
{ value: 'medienberichte', label: 'Medienberichte' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'livia-metric-objects',
|
||||||
|
label: 'Analysierte Objekte',
|
||||||
|
value: '184',
|
||||||
|
hint: 'Objekte mit vollständigem Lagebeschrieb, unter anderem in Zürich, Winterthur, Zug und St. Gallen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-metric-current-blocks',
|
||||||
|
label: 'Aktuelle Lagebausteine',
|
||||||
|
value: '162',
|
||||||
|
hint: 'Bausteine, die im letzten Aktualisierungslauf gegen die Quellen bestätigt wurden.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-metric-due-refresh',
|
||||||
|
label: 'Fällige Aktualisierungen',
|
||||||
|
value: '9',
|
||||||
|
hint: 'Objekte, deren Lagebeschrieb im nächsten Quartals-Refresh erneuert wird.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-metric-detected-changes',
|
||||||
|
label: 'Erkannte Umfeldänderungen',
|
||||||
|
value: '14',
|
||||||
|
hint: 'Seit dem letzten Lauf gemeldete Änderungen, etwa die Arealentwicklung Winterthur Neuhegi.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'livia-metric-objects',
|
||||||
|
lastRun: '2026-05-20T06:00:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Personalblatt «Nora», Markt-Scout.
|
||||||
|
*
|
||||||
|
* Nora beobachtet öffentliche Quellen und simulierte Feeds zu den definierten
|
||||||
|
* Regionen und Segmenten, stuft jedes Signal ein und legt unsichere Fälle in die
|
||||||
|
* Review-Queue. Sie arbeitet ausschliesslich auf Vorschlagsbasis: Wochen-Mail,
|
||||||
|
* Leads und Markteinschätzung gehen erst nach Freigabe durch die Marktbeobachtung
|
||||||
|
* hinaus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
export const noraAgent: TeamAgent = {
|
||||||
|
id: 'nora',
|
||||||
|
name: 'Nora',
|
||||||
|
role: 'Markt-Scout',
|
||||||
|
personnelNumber: 'PO-ZD-19',
|
||||||
|
department: 'Marktbeobachtung',
|
||||||
|
email: 'nora@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.SLATE,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.PROPOSAL_ONLY,
|
||||||
|
shortDescription:
|
||||||
|
'Beobachtet Marktsignale, ordnet sie ein und erstellt Wochen-Mails, Leads und Marktbausteine.',
|
||||||
|
autonomyNote: '«Vorschlag»',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Nora hält die Marktbeobachtung ohne tägliche Handarbeit auf dem Laufenden, indem sie Bewegungen bei kommerziell genutzten Immobilien früh erkennt, einordnet und als prüffertigen Vorschlag vorlegt.',
|
||||||
|
input: [
|
||||||
|
'Regionen: Zürich, Winterthur, Zug, Basel, Bern und St. Gallen',
|
||||||
|
'Segmente: Büro, Retail, Gastronomie, Logistik, Gewerbe und Produktion',
|
||||||
|
'Öffentliche Quellen wie SHAB-Meldungen, Zefix-Einträge und Firmenwebseiten',
|
||||||
|
'Simulierte Feeds zu Ausschreibungen, Standortmeldungen und Medienberichten',
|
||||||
|
'Objekt- und Portfoliostammdaten aus dem Arbeitsbereich',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Nora sichtet die freigegebenen Quellen und Feeds zu den eingestellten Regionen und Segmenten.',
|
||||||
|
'Jede Meldung wird als Signalart eingeordnet — zum Beispiel Umzug, Expansion oder Filialschliessung.',
|
||||||
|
'Zu jedem Signal vergibt sie eine Konfidenz und hinterlegt die Quelle mit Datum.',
|
||||||
|
'Liegt die Konfidenz unter der Review-Schwelle, wandert das Signal in die Review-Queue statt in den Versand.',
|
||||||
|
'Verifizierte Signale werden einer Region und, wenn möglich, einem konkreten Objekt zugeordnet.',
|
||||||
|
'Wochen-Mail, Lead-Liste und Markteinschätzung entstehen als Vorschlag und gehen erst nach Freigabe raus.',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Wochen-Mail mit den wichtigsten Marktsignalen, dienstags um 08:00 Uhr',
|
||||||
|
'Einzelne Marktsignale mit Signalart, Konfidenz, Quelle und Regionsbezug',
|
||||||
|
'Lead-Liste aus Umzugs- und Expansionssignalen zur Übergabe an das CRM',
|
||||||
|
'Monatliche Markteinschätzung je Region und Segment',
|
||||||
|
'Review-Queue mit den unsicheren Signalen zur Sichtung durch das Team',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Laufende Beobachtung der freigegebenen Quellen und Feeds in den eingestellten Regionen',
|
||||||
|
'Nachvollziehbare Einordnung jedes Signals nach Signalart und Konfidenz',
|
||||||
|
'Saubere Trennung zwischen verifizierten Signalen und Fällen für die Review-Queue',
|
||||||
|
'Zuordnung der Signale zu Regionen und, wo belegbar, zu einzelnen Objekten',
|
||||||
|
'Vorbereitung von Wochen-Mail, Lead-Liste und monatlicher Markteinschätzung als Vorschlag',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'nora-task-01',
|
||||||
|
title: 'Quellen und Feeds simuliert überwachen',
|
||||||
|
description:
|
||||||
|
'Sichtet die freigegebenen öffentlichen Quellen und die simulierten Feeds zu den eingestellten Regionen und Segmenten. Neue Meldungen werden mit Quelle, Datum und Fundort erfasst; es wird nichts veröffentlicht und nichts weitergegeben.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'täglich 06:00, zusätzlich stündlich zwischen 08:00 und 18:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-task-02',
|
||||||
|
title: 'Marktsignale klassifizieren',
|
||||||
|
description:
|
||||||
|
'Ordnet jede erfasste Meldung einer Signalart zu — Umzug, Expansion, Verkleinerung, Neugründung oder Filialschliessung — und hält fest, welche Textstelle der Quelle die Einordnung stützt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang einer neuen Meldung',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-task-03',
|
||||||
|
title: 'Konfidenz vergeben',
|
||||||
|
description:
|
||||||
|
'Vergibt jedem Signal einen Konfidenzwert in Prozent. Grundlage sind Verlässlichkeit der Quelle, Aktualität der Meldung und ob sich dieselbe Information in einer zweiten Quelle bestätigt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei jeder Klassifizierung',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-task-04',
|
||||||
|
title: 'unsichere Signale in eine Review-Queue legen',
|
||||||
|
description:
|
||||||
|
'Liegt die Konfidenz unter der eingestellten Review-Schwelle oder widersprechen sich die Quellen, legt Nora das Signal in die Review-Queue der Marktbeobachtung. Solche Signale erscheinen weder in der Wochen-Mail noch in der Lead-Liste, bevor jemand sie geprüft hat.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'sobald die Konfidenz unter der Review-Schwelle liegt',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-task-05',
|
||||||
|
title: 'Signale Regionen und Objekten zuordnen',
|
||||||
|
description:
|
||||||
|
'Verknüpft jedes verifizierte Signal mit der betroffenen Region und, sofern Adresse oder Firmensitz eindeutig belegt sind, mit einem konkreten Objekt im Portfolio. Bleibt die Zuordnung unklar, wird sie offen gelassen und als offen ausgewiesen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei jedem verifizierten Signal',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-task-06',
|
||||||
|
title: 'Wochen-Mail dienstags um 08:00 Uhr erstellen',
|
||||||
|
description:
|
||||||
|
'Stellt die wichtigsten Signale der vergangenen Woche nach Region und Segment zusammen, jeweils mit Konfidenz und Quellenangabe. Der Entwurf geht als Vorschlag an die Marktbeobachtung und wird erst nach Freigabe an die eingetragenen Empfänger versendet.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'dienstags 08:00',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-task-07',
|
||||||
|
title: 'Umzugs- und Expansionssignale als Leads ausweisen',
|
||||||
|
description:
|
||||||
|
'Kennzeichnet Signale zu Umzug und Expansion als Lead, ergänzt Firma, Region, gesuchtes Segment und Zeithorizont und bereitet die Übergabe an das simulierte CRM vor. Übergeben wird erst nach ausdrücklicher Freigabe.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Erkennung eines Umzugs- oder Expansionssignals',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnSystem: AgentSystemType.CRM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-task-08',
|
||||||
|
title: 'monatliche Markteinschätzung erstellen',
|
||||||
|
description:
|
||||||
|
'Fasst die Signale des Monats je Region und Segment zu einer Einschätzung zusammen: Bewegung im Markt, auffällige Häufungen, offene Reviews und Vergleich zum Vormonat. Der Bericht wird als Vorschlag vorgelegt und nach Freigabe verteilt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'monatlich am 1. um 07:00',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'nora-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Versand der freigegebenen Wochen-Mail und der monatlichen Markteinschätzung an Marktbeobachtung, Akquisition und Bewirtschaftung. Nora nimmt über diesen Kanal keine Post entgegen.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Nora — Markt-Scout',
|
||||||
|
senderAddress: 'nora@property-on.ch',
|
||||||
|
defaultRecipients: [
|
||||||
|
'marktbeobachtung@property-on.ch',
|
||||||
|
'akquisition.zuerich@property-on.ch',
|
||||||
|
'bewirtschaftung.winterthur@property-on.ch',
|
||||||
|
],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-ch-teams',
|
||||||
|
type: AgentChannelType.TEAMS,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Kurzmeldung in den Teamkanal, sobald ein Signal mit hoher Konfidenz auftaucht — etwa eine angekündigte Filialschliessung in Basel. Reine Ausgabe, ohne Rückkanal.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Nora — Markt-Scout',
|
||||||
|
defaultRecipients: ['marktbeobachtung', 'akquisition-mitte'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-ch-workspace-chat',
|
||||||
|
type: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Arbeitskanal für die Review-Queue: Das Team fragt Signale ab, bestätigt oder verwirft sie, und Nora meldet zurück, welche Vorschläge noch auf Freigabe warten.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Nora — Markt-Scout',
|
||||||
|
defaultRecipients: ['marktbeobachtung', 'portfolio-steuerung'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'nora-sys-public-web',
|
||||||
|
type: AgentSystemType.PUBLIC_WEB,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Öffentlich zugängliche Firmenwebseiten, Standortmeldungen und Medienberichte aus Zürich, Winterthur, Zug, Basel, Bern und St. Gallen als Ausgangspunkt für Marktsignale.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht auf frei zugängliche Seiten. Nora meldet sich nirgends an, füllt keine Formulare aus und veröffentlicht nichts.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-sys-shab',
|
||||||
|
type: AgentSystemType.SHAB,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Amtliche Meldungen zu Gründungen, Sitzverlegungen, Umfirmierungen und Liquidationen — die verlässlichste Grundlage für Umzugs- und Neugründungssignale.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht. Nora liest veröffentlichte Meldungen und übernimmt Meldungsnummer und Datum als Quellenangabe.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-sys-zefix',
|
||||||
|
type: AgentSystemType.ZEFIX,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Abgleich von Firmenname, Rechtsform und eingetragenem Sitz, damit ein Signal der richtigen Firma und der richtigen Region zugeordnet wird.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht auf den öffentlichen Handelsregisterindex. Es werden keine Einträge erfasst oder verändert.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-sys-crm',
|
||||||
|
type: AgentSystemType.CRM,
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Ablage der freigegebenen Leads aus Umzugs- und Expansionssignalen, damit die Akquisition direkt weiterarbeiten kann.',
|
||||||
|
permissionNote:
|
||||||
|
'Übergibt Leads an ein simuliertes CRM. Geschrieben wird erst nach Freigabe durch die Marktbeobachtung; bestehende Datensätze werden weder überschrieben noch gelöscht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-sys-workspace',
|
||||||
|
type: AgentSystemType.WORKSPACE,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Führt die Signalliste, die Review-Queue und die Entwürfe für Wochen-Mail und Markteinschätzung; liest Objekt- und Portfoliostammdaten für die Zuordnung.',
|
||||||
|
permissionNote:
|
||||||
|
'Lese- und Schreibrecht im eigenen Arbeitsbereich. Nora legt dort Signale, Reviews und Entwürfe ab — der Versand nach aussen bleibt an die Freigabe gebunden.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'nora-set-regions',
|
||||||
|
label: 'Regionen',
|
||||||
|
description: 'Nur Meldungen aus diesen Regionen werden als Marktsignal erfasst.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.GENERAL,
|
||||||
|
value: ['ZUERICH', 'WINTERTHUR', 'ZUG', 'BASEL', 'BERN', 'ST_GALLEN'],
|
||||||
|
options: [
|
||||||
|
{ value: 'ZUERICH', label: 'Zürich' },
|
||||||
|
{ value: 'WINTERTHUR', label: 'Winterthur' },
|
||||||
|
{ value: 'ZUG', label: 'Zug' },
|
||||||
|
{ value: 'BASEL', label: 'Basel' },
|
||||||
|
{ value: 'BERN', label: 'Bern' },
|
||||||
|
{ value: 'ST_GALLEN', label: 'St. Gallen' },
|
||||||
|
{ value: 'LUZERN', label: 'Luzern' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-set-segments',
|
||||||
|
label: 'Segmente',
|
||||||
|
description:
|
||||||
|
'Nutzungsarten der kommerziell genutzten Immobilien, die Nora beobachtet.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.GENERAL,
|
||||||
|
value: ['BUERO', 'RETAIL', 'GASTRONOMIE', 'LOGISTIK'],
|
||||||
|
options: [
|
||||||
|
{ value: 'BUERO', label: 'Büro' },
|
||||||
|
{ value: 'RETAIL', label: 'Retail' },
|
||||||
|
{ value: 'GASTRONOMIE', label: 'Gastronomie' },
|
||||||
|
{ value: 'LOGISTIK', label: 'Logistik' },
|
||||||
|
{ value: 'GEWERBE', label: 'Gewerbe' },
|
||||||
|
{ value: 'PRODUKTION', label: 'Produktion' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-set-signal-types',
|
||||||
|
label: 'Signalarten',
|
||||||
|
description: 'Diese Arten von Marktbewegungen werden erfasst und eingeordnet.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['UMZUG', 'EXPANSION', 'NEUGRUENDUNG', 'FILIALSCHLIESSUNG'],
|
||||||
|
options: [
|
||||||
|
{ value: 'UMZUG', label: 'Umzug' },
|
||||||
|
{ value: 'EXPANSION', label: 'Expansion' },
|
||||||
|
{ value: 'VERKLEINERUNG', label: 'Verkleinerung' },
|
||||||
|
{ value: 'NEUGRUENDUNG', label: 'Neugründung' },
|
||||||
|
{ value: 'FILIALSCHLIESSUNG', label: 'Filialschliessung' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-set-min-confidence',
|
||||||
|
label: 'Mindestkonfidenz',
|
||||||
|
description:
|
||||||
|
'Ab diesem Wert gilt ein Signal als verifiziert und darf in Wochen-Mail und Lead-Liste aufgenommen werden.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 70,
|
||||||
|
unit: '%',
|
||||||
|
min: 40,
|
||||||
|
max: 95,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-set-review-threshold',
|
||||||
|
label: 'Review-Schwelle',
|
||||||
|
description:
|
||||||
|
'Signale unterhalb dieses Werts gehen in die Review-Queue und warten auf Sichtung durch die Marktbeobachtung.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.APPROVAL,
|
||||||
|
value: 55,
|
||||||
|
unit: '%',
|
||||||
|
min: 20,
|
||||||
|
max: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-set-lead-handover',
|
||||||
|
label: 'Lead-Weitergabe aktiv',
|
||||||
|
description:
|
||||||
|
'Legt fest, ob freigegebene Umzugs- und Expansionssignale als Lead an das simulierte CRM übergeben werden.',
|
||||||
|
kind: AgentSettingKind.BOOLEAN,
|
||||||
|
group: AgentSettingGroup.APPROVAL,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-set-weekly-recipients',
|
||||||
|
label: 'Empfänger der Wochen-Mail',
|
||||||
|
description:
|
||||||
|
'Adressen, die die freigegebene Wochen-Mail erhalten — mehrere Adressen mit Komma trennen.',
|
||||||
|
kind: AgentSettingKind.TEXT,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value:
|
||||||
|
'marktbeobachtung@property-on.ch, akquisition.zuerich@property-on.ch, bewirtschaftung.winterthur@property-on.ch',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-set-send-time',
|
||||||
|
label: 'Versandzeit',
|
||||||
|
description: 'Uhrzeit des wöchentlichen Versands, jeweils dienstags.',
|
||||||
|
kind: AgentSettingKind.TIME,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: '08:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'nora-metric-weekly-signals',
|
||||||
|
label: 'Signale der Woche',
|
||||||
|
value: '128',
|
||||||
|
hint: 'Erfasste Meldungen aus Quellen und Feeds vom 14.05.2026 bis 20.05.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-metric-verified-signals',
|
||||||
|
label: 'Verifizierte Signale',
|
||||||
|
value: '86',
|
||||||
|
hint: 'Konfidenz von mindestens 70 % und mit einer zweiten Quelle bestätigt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-metric-open-reviews',
|
||||||
|
label: 'Offene Reviews',
|
||||||
|
value: '19',
|
||||||
|
hint: 'Signale in der Review-Queue, ältester Eintrag seit 3 Tagen offen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-metric-generated-leads',
|
||||||
|
label: 'Erzeugte Leads',
|
||||||
|
value: '23',
|
||||||
|
hint: 'Freigegebene Umzugs- und Expansionssignale, letzte Übergabe am 19.05.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nora-metric-monitored-regions',
|
||||||
|
label: 'Überwachte Regionen',
|
||||||
|
value: '6',
|
||||||
|
hint: 'Zürich, Winterthur, Zug, Basel, Bern und St. Gallen',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'nora-metric-weekly-signals',
|
||||||
|
lastRun: '2026-05-20T06:00:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reto — Recap-Agent der Vermarktung.
|
||||||
|
*
|
||||||
|
* Nimmt Sprachmemos und Kurztexte nach Besichtigungen und Telefonaten entgegen und
|
||||||
|
* legt daraus ein sauberes Protokoll, einen CRM-Entwurf und Folgeaufgaben vor.
|
||||||
|
* Geschrieben wird nie ohne die 1-Klick-Freigabe der Bewirtschafterin oder des
|
||||||
|
* Bewirtschafters.
|
||||||
|
*/
|
||||||
|
export const retoAgent: TeamAgent = {
|
||||||
|
id: 'reto',
|
||||||
|
name: 'Reto',
|
||||||
|
role: 'Recap-Agent',
|
||||||
|
personnelNumber: 'PO-ZD-14',
|
||||||
|
department: 'Vermarktung',
|
||||||
|
email: 'reto@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.SIGNAL,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.APPROVAL_REQUIRED,
|
||||||
|
shortDescription: 'Macht aus Sprachmemos strukturierte Protokolle, CRM-Einträge und Folgeaufgaben.',
|
||||||
|
autonomyNote: '«Mit 1-Klick-Freigabe»',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Reto verwandelt das gesprochene Wort nach einer Besichtigung oder einem Telefonat in ein sauberes Protokoll, einen CRM-Entwurf und konkrete Folgeaufgaben — freigegeben mit einem einzigen Klick.',
|
||||||
|
input: [
|
||||||
|
'Sprachmemo per WhatsApp direkt vom Objekt, etwa nach einer Besichtigung in Zürich-Altstetten',
|
||||||
|
'Telefonaufnahme aus einem Interessentengespräch',
|
||||||
|
'Kurztext oder Stichwortnotiz per E-Mail',
|
||||||
|
'Termin-Kontext aus dem Kalender: Objekt, Interessent, Uhrzeit, Teilnehmende',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Memo entgegennehmen und das Gesprochene in Text übertragen',
|
||||||
|
'Füllwörter, Wiederholungen und Nebengeräusche entfernen',
|
||||||
|
'Aussagen sortieren nach Feedback, Einwänden, Zusagen und offenen Punkten',
|
||||||
|
'Termin-Kontext ergänzen: Objekt, Interessent, Datum, Teilnehmende',
|
||||||
|
'Protokoll, CRM-Entwurf und Aufgabenliste als Vorschlag zusammenstellen',
|
||||||
|
'Zur 1-Klick-Bestätigung vorlegen und erst danach in die Zielsysteme übertragen',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Besichtigungsprotokoll als abgelegtes Dokument im Dossier',
|
||||||
|
'CRM-Entwurf mit Gesprächsnotiz, Interessentenstatus und nächstem Schritt',
|
||||||
|
'Folgeaufgaben mit Fälligkeit und zuständiger Person',
|
||||||
|
'Objektbeobachtungen für die Bewirtschaftung, etwa Mängel oder Rückfragen zum Ausbau',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Jedes Gespräch der Vermarktung ist am selben Tag protokolliert und im Dossier auffindbar.',
|
||||||
|
'Feedback, Einwände und Zusagen der Interessenten sind strukturiert festgehalten, nicht nur als Fliesstext.',
|
||||||
|
'Kein Eintrag gelangt ohne ausdrückliche Freigabe in CRM, Dossier oder Aufgabenliste.',
|
||||||
|
'Termine ohne Recap werden aktiv nachgefasst, damit keine Zusage verloren geht.',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'reto-task-01',
|
||||||
|
title: 'Sprachmemo oder Kurztext entgegennehmen',
|
||||||
|
description:
|
||||||
|
'Nimmt Sprachmemos aus WhatsApp, Telefonaufnahmen und Kurztexte per E-Mail entgegen und ordnet sie über Zeitpunkt und Objektbezug dem richtigen Termin zu — etwa der Besichtigung an der Technoparkstrasse in Winterthur.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WHATSAPP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-02',
|
||||||
|
title: 'Sprache transkribieren',
|
||||||
|
description:
|
||||||
|
'Überträgt das Gesprochene in Text, inklusive Schweizer Ortsnamen, Objektbezeichnungen und Fachbegriffen aus der Bewirtschaftung. Unsichere Stellen werden markiert statt geraten.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'unmittelbar nach Eingang des Memos',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.PHONE_VOICE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-03',
|
||||||
|
title: 'Inhalt bereinigen',
|
||||||
|
description:
|
||||||
|
'Entfernt Füllwörter, Versprecher und Wiederholungen, ordnet die Sätze chronologisch und behält dabei jede inhaltliche Aussage unverändert bei.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'direkt nach der Transkription',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-04',
|
||||||
|
title: 'Feedback, Einwände und Zusagen strukturieren',
|
||||||
|
description:
|
||||||
|
'Trennt das Gespräch in vier Blöcke: positives Feedback zum Objekt, Einwände wie Fläche oder Mietzins, verbindliche Zusagen beider Seiten und offene Fragen. Jeder Punkt behält den Bezug zur Aussage im Memo.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Bereinigung',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-05',
|
||||||
|
title: 'Nächste Schritte identifizieren',
|
||||||
|
description:
|
||||||
|
'Leitet aus Zusagen und offenen Fragen die konkreten nächsten Schritte ab — Unterlagen nachreichen, Zweitbesichtigung ansetzen, Mietzins intern klären — je mit Frist und zuständiger Person.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Strukturierung',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-06',
|
||||||
|
title: 'CRM- und Protokolleinträge vorschlagen',
|
||||||
|
description:
|
||||||
|
'Stellt den Entwurf für die Gesprächsnotiz im CRM und das Besichtigungsprotokoll für das Objektdossier zusammen. Beides bleibt Vorschlag, bis die Freigabe erteilt ist.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Strukturierung',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnSystem: AgentSystemType.CRM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-07',
|
||||||
|
title: 'Aufgaben aus dem Gespräch ableiten',
|
||||||
|
description:
|
||||||
|
'Formuliert aus den nächsten Schritten einzelne Aufgaben mit Titel, Fälligkeit und Zuständigkeit — etwa «Grundrisse 3. OG an Interessent senden, bis 22.05.2026». Die Aufgaben werden erst nach Bestätigung angelegt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Strukturierung',
|
||||||
|
requiresApproval: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-08',
|
||||||
|
title: 'Ergebnis zur 1-Klick-Bestätigung vorlegen',
|
||||||
|
description:
|
||||||
|
'Legt Protokoll, CRM-Entwurf und Aufgabenliste in einer Übersicht vor. Alles lässt sich vor dem Bestätigen anpassen; ein Klick gibt das gesamte Paket frei.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'sobald der Entwurf vollständig ist',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-09',
|
||||||
|
title: 'Nach Bestätigung simuliert ins Zielsystem schreiben',
|
||||||
|
description:
|
||||||
|
'Überträgt das freigegebene Paket simuliert in das eingestellte Zielsystem: Gesprächsnotiz ins CRM, Protokoll ins Dossier, Aufgaben in die Aufgabenliste. Steht kein CRM- oder ERP-Zugang zur Verfügung, greift der eingestellte CRM-/ERP-Fallback und das Paket geht als strukturierte E-Mail an die zuständige Person. Ohne Freigabe passiert nichts.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'unmittelbar nach der Freigabe',
|
||||||
|
requiresApproval: true,
|
||||||
|
dependsOnSystem: AgentSystemType.CRM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-task-10',
|
||||||
|
title: 'Drei Stunden nach Termin an fehlendes Diktat erinnern',
|
||||||
|
description:
|
||||||
|
'Prüft nach jedem Vermarktungstermin im Kalender, ob ein Memo eingegangen ist. Fehlt es, folgt eine kurze Erinnerung mit Objekt und Interessent auf demselben Weg, auf dem sonst diktiert wird.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'drei Stunden nach Terminende',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.CALENDAR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'reto-ch-whatsapp',
|
||||||
|
type: AgentChannelType.WHATSAPP,
|
||||||
|
direction: AgentChannelDirection.INBOUND,
|
||||||
|
description:
|
||||||
|
'Hauptweg für Sprachmemos direkt nach der Besichtigung — diktiert im Auto oder im Treppenhaus, ganz ohne Formular.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Reto Diktat (WhatsApp)',
|
||||||
|
inboxAddress: '+41 44 500 21 14',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-ch-phone',
|
||||||
|
type: AgentChannelType.PHONE_VOICE,
|
||||||
|
direction: AgentChannelDirection.INBOUND,
|
||||||
|
description:
|
||||||
|
'Diktatnummer für längere Gesprächsnotizen und Telefonaufnahmen aus Interessentengesprächen.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Reto Diktatlinie',
|
||||||
|
inboxAddress: '+41 44 500 21 15',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Nimmt Kurztexte und Stichwortnotizen entgegen und versendet das fertige Protokoll sowie den strukturierten Fallback.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Reto — Recap Vermarktung',
|
||||||
|
senderAddress: 'reto@property-on.ch',
|
||||||
|
inboxAddress: 'recap@property-on.ch',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch', 'bewirtschaftung@property-on.ch'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-ch-chat',
|
||||||
|
type: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Legt die Freigabekarte mit Protokoll, CRM-Entwurf und Aufgaben im Arbeitsbereich vor und erinnert dort an fehlende Diktate.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Property On — Vermarktung',
|
||||||
|
defaultRecipients: ['#vermarktung-zuerich', '#vermarktung-winterthur'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'reto-sys-crm',
|
||||||
|
type: AgentSystemType.CRM,
|
||||||
|
access: AgentAccessLevel.WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Legt Gesprächsnotiz, Interessentenstatus und nächsten Schritt beim passenden Kontakt und Objekt ab.',
|
||||||
|
permissionNote:
|
||||||
|
'Schreibt ausschliesslich nach der 1-Klick-Freigabe — und in dieser Ausbaustufe nur simuliert. Ohne Bestätigung entsteht kein Eintrag.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-sys-immotop2',
|
||||||
|
type: AgentSystemType.IMMOTOP2,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest Objekt- und Flächendaten, damit Protokoll und Notiz die richtige Liegenschaft, Einheit und Fläche nennen.',
|
||||||
|
permissionNote: 'Nur lesender Zugang. Reto verändert keine Objekt- oder Vertragsdaten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-sys-dms',
|
||||||
|
type: AgentSystemType.DMS,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Liest bestehende Protokolle desselben Objekts und legt das freigegebene Besichtigungsprotokoll im Objektdossier ab.',
|
||||||
|
permissionNote:
|
||||||
|
'Ablage erfolgt erst nach Freigabe und nur im Dossier des betroffenen Objekts. Bestehende Dokumente werden nie überschrieben.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-sys-calendar',
|
||||||
|
type: AgentSystemType.CALENDAR,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Holt den Termin-Kontext: Objekt, Interessent, Uhrzeit und Teilnehmende — und erkennt Termine ohne Recap.',
|
||||||
|
permissionNote: 'Nur lesender Zugang. Es werden keine Termine erstellt, verschoben oder abgesagt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-sys-exchange',
|
||||||
|
type: AgentSystemType.M365_EXCHANGE,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Empfängt Kurztexte aus dem Postfach und versendet Protokoll sowie strukturierten Fallback an die zuständige Person.',
|
||||||
|
permissionNote:
|
||||||
|
'Versand nur an interne Adressen der eigenen Organisation und erst nach Freigabe. Interessenten erhalten nie automatisch eine Nachricht.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'reto-set-structure',
|
||||||
|
label: 'Standardstruktur des Recaps',
|
||||||
|
description: 'Welche Blöcke jedes Protokoll enthält. Leere Blöcke werden im Ergebnis ausgeblendet.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['SUMMARY', 'FEEDBACK', 'OBJECTIONS', 'COMMITMENTS', 'NEXT_STEPS', 'OBJECT_NOTES'],
|
||||||
|
options: [
|
||||||
|
{ value: 'SUMMARY', label: 'Kurzfazit in zwei Sätzen' },
|
||||||
|
{ value: 'FEEDBACK', label: 'Feedback zum Objekt' },
|
||||||
|
{ value: 'OBJECTIONS', label: 'Einwände und Bedenken' },
|
||||||
|
{ value: 'COMMITMENTS', label: 'Zusagen beider Seiten' },
|
||||||
|
{ value: 'NEXT_STEPS', label: 'Nächste Schritte' },
|
||||||
|
{ value: 'OBJECT_NOTES', label: 'Objektbeobachtungen für die Bewirtschaftung' },
|
||||||
|
{ value: 'PARTICIPANTS', label: 'Teilnehmende und Rollen' },
|
||||||
|
{ value: 'BUDGET', label: 'Mietzins- und Kostenthemen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-set-crm-target',
|
||||||
|
label: 'CRM-Zielsystem',
|
||||||
|
description: 'Wohin die freigegebene Gesprächsnotiz übertragen wird.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: 'IMMOTOP2_CRM',
|
||||||
|
options: [
|
||||||
|
{ value: 'IMMOTOP2_CRM', label: 'ImmoTop2 — Interessentenbetreuung' },
|
||||||
|
{ value: 'ABACUS_ABAIMMO', label: 'Abacus AbaImmo' },
|
||||||
|
{ value: 'RIMO_R5', label: 'Rimo R5' },
|
||||||
|
{ value: 'GARAIO_REM', label: 'Garaio REM' },
|
||||||
|
{ value: 'INTERNAL_CRM', label: 'Property On — internes Kontaktjournal' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-set-fallback',
|
||||||
|
label: 'Fallback-Ziel',
|
||||||
|
description:
|
||||||
|
'Was geschieht, wenn das CRM- oder ERP-System keinen Zugang für den Eintrag bereitstellt oder nicht erreichbar ist.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: 'STRUCTURED_MAIL',
|
||||||
|
options: [
|
||||||
|
{ value: 'STRUCTURED_MAIL', label: 'Strukturierter Mail-Fallback an die zuständige Person' },
|
||||||
|
{ value: 'DMS_DOSSIER', label: 'Ablage als Protokoll im Objektdossier' },
|
||||||
|
{ value: 'WORKSPACE_CARD', label: 'Karte im Arbeitsbereich der Vermarktung' },
|
||||||
|
{ value: 'HOLD', label: 'Zurückhalten und erneut zur Bestätigung vorlegen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-set-approval-locked',
|
||||||
|
label: 'Freigabe zwingend aktiv',
|
||||||
|
description: 'Jedes Ergebnis wird vor der Übertragung zur Bestätigung vorgelegt.',
|
||||||
|
kind: AgentSettingKind.BOOLEAN,
|
||||||
|
group: AgentSettingGroup.APPROVAL,
|
||||||
|
value: true,
|
||||||
|
locked: true,
|
||||||
|
lockedReason:
|
||||||
|
'Fachlich nicht abschaltbar: Ohne erteilte Freigabe schreibt Reto nie in ein Zielsystem — weder ins CRM noch ins Dossier noch in die Aufgabenliste.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-set-reminder-hours',
|
||||||
|
label: 'Erinnerung nach Termin',
|
||||||
|
description: 'Nach wie vielen Stunden ohne Diktat eine Erinnerung ausgelöst wird.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 3,
|
||||||
|
unit: 'Stunden',
|
||||||
|
min: 1,
|
||||||
|
max: 24,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-set-auto-tasks',
|
||||||
|
label: 'Aufgaben automatisch vorschlagen',
|
||||||
|
description:
|
||||||
|
'Leitet aus jedem Gespräch Folgeaufgaben mit Frist und Zuständigkeit ab. Angelegt werden sie erst nach der Freigabe.',
|
||||||
|
kind: AgentSettingKind.BOOLEAN,
|
||||||
|
group: AgentSettingGroup.GENERAL,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'reto-metric-open-approvals',
|
||||||
|
label: 'Offene Bestätigungen',
|
||||||
|
value: '4',
|
||||||
|
hint: 'Fertige Recaps, die auf die 1-Klick-Freigabe warten — älteste seit gestern 16:20.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-metric-processed',
|
||||||
|
label: 'Verarbeitete Diktate',
|
||||||
|
value: '128',
|
||||||
|
hint: 'Seit dem 01.05.2026 entgegengenommene Sprachmemos und Kurztexte.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-metric-duration',
|
||||||
|
label: 'Durchschnittliche Verarbeitungszeit',
|
||||||
|
value: '2 Min. 40 Sek.',
|
||||||
|
hint: 'Vom Eingang des Memos bis zum vorgelegten Recap.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-metric-tasks-created',
|
||||||
|
label: 'Erstellte Aufgaben',
|
||||||
|
value: '96',
|
||||||
|
hint: 'Nach Freigabe angelegte Folgeaufgaben, davon 11 aktuell überfällig.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reto-metric-missing-recaps',
|
||||||
|
label: 'Termine ohne Recap',
|
||||||
|
value: '3',
|
||||||
|
hint: 'Vermarktungstermine der letzten sieben Tage ohne eingegangenes Diktat.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'reto-metric-open-approvals',
|
||||||
|
lastRun: '2026-05-20T16:42:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Personalblatt «Sina», Vertragsauskunft (Abteilung Bewirtschaftung).
|
||||||
|
*
|
||||||
|
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und die
|
||||||
|
* sieben Aufgaben werden unverändert übernommen. Sina arbeitet ausschliesslich lesend
|
||||||
|
* und belegt jede Antwort mit einer Fundstelle. Zeitanker der Mockdaten: 20.05.2026.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
export const sinaAgent: TeamAgent = {
|
||||||
|
id: 'sina',
|
||||||
|
name: 'Sina',
|
||||||
|
role: 'Vertragsauskunft',
|
||||||
|
personnelNumber: 'PO-ZD-03',
|
||||||
|
department: 'Bewirtschaftung',
|
||||||
|
email: 'sina@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.INFO,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
|
||||||
|
shortDescription:
|
||||||
|
'Beantwortet Vertrags- und Objektfragen ausschliesslich aus hinterlegten Dokumenten und immer mit Fundstelle.',
|
||||||
|
autonomyNote: '«Autonom, ausschliesslich lesend»',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Sina gibt dem Bewirtschaftungsteam in Sekunden eine belegte Auskunft zu Vertrags- und Objektfragen, statt dass jemand Ordner und Ablagen durchsuchen muss.',
|
||||||
|
input: [
|
||||||
|
'Mietverträge der kommerziell genutzten Immobilien in Zürich, Winterthur und Basel',
|
||||||
|
'Nachträge, Zusatzvereinbarungen und Indexklauseln',
|
||||||
|
'Abnahme-, Übergabe- und Sitzungsprotokolle',
|
||||||
|
'Grundriss- und Flächenpläne inklusive Flächenausweise',
|
||||||
|
'Korrespondenz mit Mieterschaft, Eigentümerschaft und Behörden',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Neue und geänderte Dokumente aus den freigegebenen Ablagen werden laufend erfasst und durchsuchbar gemacht.',
|
||||||
|
'Eine Frage trifft über den Teamkanal oder per E-Mail ein.',
|
||||||
|
'Sina sucht die massgebenden Stellen in den erlaubten Dokumentquellen.',
|
||||||
|
'Die Antwort geht mit wörtlichem Zitat, Dokumentname und Seitenzahl zurück.',
|
||||||
|
'Findet sie keine belegte Stelle, antwortet sie «nicht dokumentiert» und meldet die Lücke.',
|
||||||
|
'Jede Auskunft wird mit Frage, Quelle und Zeitpunkt protokolliert.',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Antwort mit Direktzitat aus dem Originaldokument',
|
||||||
|
'Fundstelle mit Dokumentname und Seitenzahl',
|
||||||
|
'Hinweis «nicht dokumentiert», wenn keine Quelle vorliegt',
|
||||||
|
'Meldungen zu erkannten Dokumentationslücken',
|
||||||
|
'Monatlicher Lückenreport je Liegenschaft',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Belegte Auskunft zu Verträgen, Nachträgen und Objektunterlagen',
|
||||||
|
'Nachweis der Fundstelle bei jeder einzelnen Antwort',
|
||||||
|
'Aktuelles, durchsuchbares Verzeichnis der Objektdokumente',
|
||||||
|
'Transparenz über fehlende oder unvollständige Unterlagen',
|
||||||
|
'Lückenlose Protokollierung sämtlicher Auskünfte',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'sina-task-01',
|
||||||
|
title: 'Objektdokumente laufend indexieren',
|
||||||
|
description:
|
||||||
|
'Erfasst neue und geänderte Verträge, Nachträge, Protokolle und Pläne aus den freigegebenen Ablagen und hält sie durchsuchbar. Genutzt werden ausschliesslich Leserechte — es wird nichts abgelegt, verschoben oder verändert.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Dokumenteingang, zusätzlich täglich 05:30',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-task-02',
|
||||||
|
title: 'Vertrags- und Objektfragen beantworten',
|
||||||
|
description:
|
||||||
|
'Beantwortet Fragen zu Laufzeiten, Kündigungsfristen, Indexierung, Nebenkosten, Flächen und Zuständigkeiten allein auf Basis der hinterlegten Dokumente. Eigene Annahmen oder Schätzungen sind ausgeschlossen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang der Frage',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-task-03',
|
||||||
|
title: 'Antworten immer mit Zitat und Fundstelle ausgeben',
|
||||||
|
description:
|
||||||
|
'Jede Antwort enthält das wörtliche Zitat der massgebenden Stelle sowie Dokumentname, Stand und Seitenzahl. Damit ist jede Auskunft im Original nachprüfbar — der Quellenzwang gilt ohne Ausnahme.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei jeder Antwort',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-task-04',
|
||||||
|
title: 'bei fehlender Quelle «nicht dokumentiert» anzeigen',
|
||||||
|
description:
|
||||||
|
'Lässt sich eine Frage nicht aus einem hinterlegten Dokument belegen, gibt Sina «nicht dokumentiert» aus und nennt, welche Unterlage fehlt. Eine Antwort ohne Fundstelle wird nie ausgegeben.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'sobald keine belegte Fundstelle vorliegt',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-task-05',
|
||||||
|
title: 'Dokumentationslücken aktiv melden',
|
||||||
|
description:
|
||||||
|
'Meldet dem zuständigen Bewirtschaftungsteam, zu welchen Liegenschaften Unterlagen fehlen, veraltet sind oder ohne Unterschrift abgelegt wurden — etwa fehlende Nachträge am Standort Winterthur.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'montags 07:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-task-06',
|
||||||
|
title: 'sämtliche Auskünfte protokollieren',
|
||||||
|
description:
|
||||||
|
'Hält Frage, Antwort, verwendete Fundstelle, Kanal und Zeitpunkt in einem nachvollziehbaren Protokoll fest. Das Protokoll ist die Grundlage für Rückfragen aus Bewirtschaftung und Revision.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei jeder Auskunft',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-task-07',
|
||||||
|
title: 'monatlichen Lückenreport erstellen',
|
||||||
|
description:
|
||||||
|
'Fasst alle offenen Dokumentationslücken je Liegenschaft und Portfolio zusammen und stellt den Report der Bewirtschaftung in Zürich, Winterthur und Basel zu.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'monatlich am 1. um 06:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'sina-ch-workspace-chat',
|
||||||
|
type: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Hauptkanal für Sofortfragen aus der Bewirtschaftung. Die Antwort erscheint direkt im Teamkanal, immer mit Direktzitat und Fundstelle.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Sina — Vertragsauskunft',
|
||||||
|
defaultRecipients: ['bewirtschaftung-zuerich', 'bewirtschaftung-winterthur'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Fragen per E-Mail sowie Versand von Lückenmeldungen und des monatlichen Lückenreports an die Standorte Zürich, Winterthur und Basel.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Sina — Vertragsauskunft',
|
||||||
|
senderAddress: 'sina@property-on.ch',
|
||||||
|
inboxAddress: 'sina@property-on.ch',
|
||||||
|
defaultRecipients: [
|
||||||
|
'bewirtschaftung.zuerich@property-on.ch',
|
||||||
|
'bewirtschaftung.winterthur@property-on.ch',
|
||||||
|
'bewirtschaftung.basel@property-on.ch',
|
||||||
|
],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'sina-sys-dms',
|
||||||
|
type: AgentSystemType.DMS,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Führende Ablage für Mietverträge, Nachträge und Protokolle der Seefeld Immobilien AG, Zürich — Grundlage für Zitat und Seitenzahl.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht. Sina liest Dokumente und Versionsstände; sie legt nichts ab, ändert nichts und löscht nichts.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-sys-sharepoint',
|
||||||
|
type: AgentSystemType.SHAREPOINT,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Objektbibliotheken der Winterthurer Liegenschaften AG mit Übergabeprotokollen, Flächenausweisen und Sitzungsunterlagen.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht. Sina öffnet die Bibliotheken ausschliesslich lesend und schreibt dort unter keinen Umständen zurück.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-sys-onedrive',
|
||||||
|
type: AgentSystemType.ONEDRIVE,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Ausdrücklich freigegebene Arbeitsordner einzelner Bewirtschafterinnen und Bewirtschafter mit noch nicht abgelegter Korrespondenz.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht auf freigegebene Ordner. Sina erstellt, ändert und verschiebt keine Dateien.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-sys-fileshare',
|
||||||
|
type: AgentSystemType.FILESHARE,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Historische Laufwerksablage der Rheinpark Bewirtschaftung AG, Basel — Altverträge und Pläne aus der Zeit vor der Übernahme ins Dokumentensystem.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht. Sina greift ausschliesslich lesend auf die Laufwerke zu; ein Schreibzugriff ist fachlich wie technisch ausgeschlossen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-sys-immotop2',
|
||||||
|
type: AgentSystemType.IMMOTOP2,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Stammdaten zu Liegenschaften, Mietobjekten und Vertragsbezügen, um die richtige Akte und den gültigen Vertragsstand zu finden.',
|
||||||
|
permissionNote:
|
||||||
|
'Nur Leserecht. Sina liest Stammdaten und verknüpfte Belege; sie erfasst und mutiert keine Datensätze im Bewirtschaftungssystem.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'sina-set-sources',
|
||||||
|
label: 'Erlaubte Dokumentquellen',
|
||||||
|
description:
|
||||||
|
'Nur Dokumente aus diesen Ablagen werden für Antworten und Fundstellen verwendet.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['DMS', 'SHAREPOINT', 'FILESHARE', 'IMMOTOP2'],
|
||||||
|
options: [
|
||||||
|
{ value: 'DMS', label: 'Dokumentensystem' },
|
||||||
|
{ value: 'SHAREPOINT', label: 'SharePoint-Objektbibliotheken' },
|
||||||
|
{ value: 'ONEDRIVE', label: 'Freigegebene OneDrive-Ordner' },
|
||||||
|
{ value: 'FILESHARE', label: 'Laufwerksablage' },
|
||||||
|
{ value: 'IMMOTOP2', label: 'ImmoTop2-Belege' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-set-source-requirement',
|
||||||
|
label: 'Quellenzwang',
|
||||||
|
description:
|
||||||
|
'Jede Antwort muss auf eine belegte Fundstelle in einem hinterlegten Dokument zurückgehen.',
|
||||||
|
kind: AgentSettingKind.BOOLEAN,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: true,
|
||||||
|
locked: true,
|
||||||
|
lockedReason:
|
||||||
|
'Nicht abschaltbar: Eine Vertragsauskunft ohne Fundstelle ist fachlich unzulässig, weil sie weder nachprüfbar noch gegenüber Eigentümerschaft und Revision belegbar wäre.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-set-max-length',
|
||||||
|
label: 'Maximale Antwortlänge',
|
||||||
|
description:
|
||||||
|
'Obergrenze für den Antworttext ohne Zitat und Fundstelle — hält Auskünfte knapp und lesbar.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: 1200,
|
||||||
|
unit: 'Zeichen',
|
||||||
|
min: 300,
|
||||||
|
max: 4000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-set-direct-quote',
|
||||||
|
label: 'Ausgabe mit Direktzitat',
|
||||||
|
description:
|
||||||
|
'Die massgebende Stelle wird zusätzlich zu Dokumentname und Seitenzahl wörtlich zitiert.',
|
||||||
|
kind: AgentSettingKind.BOOLEAN,
|
||||||
|
group: AgentSettingGroup.DELIVERY,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-set-missing-doc',
|
||||||
|
label: 'Verhalten bei fehlender Dokumentation',
|
||||||
|
description: 'Legt fest, wie Sina reagiert, wenn keine belegte Fundstelle vorliegt.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 'NOT_DOCUMENTED',
|
||||||
|
options: [
|
||||||
|
{ value: 'NOT_DOCUMENTED', label: '«nicht dokumentiert» anzeigen' },
|
||||||
|
{ value: 'ASK_BACK', label: 'Rückfrage stellen' },
|
||||||
|
{ value: 'REPORT_GAP', label: 'Lücke melden' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'sina-metric-documents',
|
||||||
|
label: 'Indexierte Dokumente',
|
||||||
|
value: '18 420',
|
||||||
|
hint: 'Durchsuchbare Verträge, Nachträge, Protokolle und Pläne per 20.05.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-metric-questions',
|
||||||
|
label: 'Beantwortete Fragen',
|
||||||
|
value: '312',
|
||||||
|
hint: 'Auskünfte über Teamkanal und E-Mail in den letzten 30 Tagen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-metric-response-time',
|
||||||
|
label: 'Durchschnittliche Antwortzeit',
|
||||||
|
value: '14 Sekunden',
|
||||||
|
hint: 'Von der Frage bis zur Antwort mit Fundstelle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-metric-gaps',
|
||||||
|
label: 'Erkannte Dokumentationslücken',
|
||||||
|
value: '27',
|
||||||
|
hint: 'Offene Lücken aus dem Report vom 01.05.2026, davon 9 in Winterthur',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sina-metric-sourced',
|
||||||
|
label: 'Antworten mit Fundstelle',
|
||||||
|
value: '100 %',
|
||||||
|
hint: 'Quellenzwang ist gesperrt — Antworten ohne Fundstelle sind nicht möglich',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'sina-metric-questions',
|
||||||
|
lastRun: '2026-05-20T06:00:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Kernteam der sieben digitalen Mitarbeiter.
|
||||||
|
*
|
||||||
|
* Reihenfolge alphabetisch nach Vorname (§7.2 der Spezifikation): Bruno, Ferdi,
|
||||||
|
* Lea, Livia, Nora, Reto, Sina. Die Agentenliste in der Personalverwaltung
|
||||||
|
* rendert genau diese Reihenfolge, ohne selbst zu sortieren.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../domain/teamAgent'
|
||||||
|
import { brunoAgent } from './agents/bruno'
|
||||||
|
import { ferdiAgent } from './agents/ferdi'
|
||||||
|
import { leaAgent } from './agents/lea'
|
||||||
|
import { liviaAgent } from './agents/livia'
|
||||||
|
import { noraAgent } from './agents/nora'
|
||||||
|
import { retoAgent } from './agents/reto'
|
||||||
|
import { sinaAgent } from './agents/sina'
|
||||||
|
|
||||||
|
export const mockTeamAgents: TeamAgent[] = [
|
||||||
|
brunoAgent,
|
||||||
|
ferdiAgent,
|
||||||
|
leaAgent,
|
||||||
|
liviaAgent,
|
||||||
|
noraAgent,
|
||||||
|
retoAgent,
|
||||||
|
sinaAgent,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Stabile IDs — Mockdaten anderer Bestände referenzieren ausschliesslich diese. */
|
||||||
|
export const AGENT_IDS = {
|
||||||
|
BRUNO: 'bruno',
|
||||||
|
FERDI: 'ferdi',
|
||||||
|
LEA: 'lea',
|
||||||
|
LIVIA: 'livia',
|
||||||
|
NORA: 'nora',
|
||||||
|
RETO: 'reto',
|
||||||
|
SINA: 'sina',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Zeitanker sämtlicher Property-On-Mockdaten. */
|
||||||
|
export const MOCK_TODAY = '2026-05-20T09:00:00.000Z'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type {
|
||||||
|
AgentConnection,
|
||||||
|
AgentConnectionTestResult,
|
||||||
|
CreateAgentConnectionInput,
|
||||||
|
} from '../domain/agentConnection'
|
||||||
|
|
||||||
|
export interface IAgentConnectionProvider {
|
||||||
|
getAll(): Promise<AgentConnection[]>
|
||||||
|
getById(id: string): Promise<AgentConnection | null>
|
||||||
|
/** Simulierter Verbindungsaufbau aus dem mehrstufigen Assistenten (§13.5). */
|
||||||
|
connect(input: CreateAgentConnectionInput): Promise<AgentConnection>
|
||||||
|
disconnect(id: string): Promise<AgentConnection>
|
||||||
|
/** Deterministisches Testergebnis — kein Zufall (§19.2). */
|
||||||
|
test(id: string): Promise<AgentConnectionTestResult>
|
||||||
|
reset(): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type {
|
||||||
|
AgentProtocolEntry,
|
||||||
|
AgentProtocolEventType,
|
||||||
|
AgentProtocolStatus,
|
||||||
|
CreateAgentProtocolEntryInput,
|
||||||
|
} from '../domain/agentProtocol'
|
||||||
|
import type { AgentChannelType } from '../domain/teamAgent'
|
||||||
|
import type { AgentPeriod } from '../domain/agentFilters'
|
||||||
|
|
||||||
|
/** Filterdimensionen des Protokoll-Tabs (§12.3). */
|
||||||
|
export interface AgentProtocolFilters {
|
||||||
|
agentId?: string
|
||||||
|
period?: AgentPeriod
|
||||||
|
eventType?: AgentProtocolEventType
|
||||||
|
status?: AgentProtocolStatus
|
||||||
|
objectId?: string
|
||||||
|
channel?: AgentChannelType
|
||||||
|
/** Nur Einträge mit Freigabeentscheidung. */
|
||||||
|
onlyApprovals?: boolean
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAgentProtocolProvider {
|
||||||
|
getAll(filters?: AgentProtocolFilters): Promise<AgentProtocolEntry[]>
|
||||||
|
create(entry: CreateAgentProtocolEntryInput): Promise<AgentProtocolEntry>
|
||||||
|
reset(): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type {
|
||||||
|
AgentWorkItem,
|
||||||
|
AgentWorkItemKind,
|
||||||
|
AgentWorkItemPriority,
|
||||||
|
AgentWorkItemStatus,
|
||||||
|
AgentDomainArea,
|
||||||
|
UpdateAgentWorkItemInput,
|
||||||
|
} from '../domain/agentWorkItem'
|
||||||
|
import type { AgentChannelType } from '../domain/teamAgent'
|
||||||
|
import type { AgentPeriod, AgentWorkItemSort } from '../domain/agentFilters'
|
||||||
|
|
||||||
|
/** Filterdimensionen des Bearbeitungsverlaufs (§5.3). */
|
||||||
|
export interface AgentWorkItemFilters {
|
||||||
|
/** true → «Pendente Anfragen», false → «Erledigte Aufträge», undefined → alle. */
|
||||||
|
requiresDecision?: boolean
|
||||||
|
agentId?: string
|
||||||
|
area?: AgentDomainArea
|
||||||
|
kind?: AgentWorkItemKind
|
||||||
|
priority?: AgentWorkItemPriority
|
||||||
|
channel?: AgentChannelType
|
||||||
|
status?: AgentWorkItemStatus
|
||||||
|
period?: AgentPeriod
|
||||||
|
search?: string
|
||||||
|
sort?: AgentWorkItemSort
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAgentWorkItemProvider {
|
||||||
|
getAll(filters?: AgentWorkItemFilters): Promise<AgentWorkItem[]>
|
||||||
|
getById(id: string): Promise<AgentWorkItem | null>
|
||||||
|
update(id: string, data: UpdateAgentWorkItemInput): Promise<AgentWorkItem>
|
||||||
|
reset(): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { TeamAgent, UpdateTeamAgentInput } from '../domain/teamAgent'
|
||||||
|
|
||||||
|
export interface ITeamAgentProvider {
|
||||||
|
getAll(): Promise<TeamAgent[]>
|
||||||
|
getById(id: string): Promise<TeamAgent | null>
|
||||||
|
update(id: string, data: UpdateTeamAgentInput): Promise<TeamAgent>
|
||||||
|
/** Stellt den Auslieferungszustand her («Demo zurücksetzen», §16.2). */
|
||||||
|
reset(): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { IAgentConnectionProvider } from './IAgentConnectionProvider'
|
||||||
|
import type {
|
||||||
|
AgentConnection,
|
||||||
|
AgentConnectionTestResult,
|
||||||
|
CreateAgentConnectionInput,
|
||||||
|
} from '../domain/agentConnection'
|
||||||
|
import { AgentConnectionCategory } from '../domain/agentConnection'
|
||||||
|
import { AgentConnectionStatus } from '../domain/teamAgent'
|
||||||
|
import { mockAgentConnections } from '../mock-data/agentConnections'
|
||||||
|
import {
|
||||||
|
TEAM_STORAGE_KEYS,
|
||||||
|
loadVersioned,
|
||||||
|
persistVersioned,
|
||||||
|
simulateLatency,
|
||||||
|
} from './teamPersistence'
|
||||||
|
import { teamNowIso } from '../lib/teamClock'
|
||||||
|
import { AGENT_SIM_DELAY_MS, AGENT_SIM_DELAY_SLOW_MS } from '../lib/constants'
|
||||||
|
|
||||||
|
function seed(): AgentConnection[] {
|
||||||
|
return loadVersioned<AgentConnection[]>(TEAM_STORAGE_KEYS.CONNECTIONS) ?? structuredClone(mockAgentConnections)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: AgentConnection[] = seed()
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
persistVersioned(TEAM_STORAGE_KEYS.CONNECTIONS, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feste Testszenarien statt Zufall (§19.2): eine unverbundene und eine geplante
|
||||||
|
* Verbindung schlagen reproduzierbar fehl, damit der Fehlerzustand in der Demo
|
||||||
|
* gezeigt werden kann, ohne dass die Oberfläche unvorhersehbar wird.
|
||||||
|
*/
|
||||||
|
function evaluateTest(connection: AgentConnection): AgentConnectionTestResult {
|
||||||
|
const checkedAt = teamNowIso()
|
||||||
|
if (connection.status === AgentConnectionStatus.ROADMAP) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: 'Diese Anbindung ist geplant und steht in der Demo noch nicht zur Verfügung.',
|
||||||
|
checkedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (connection.status === AgentConnectionStatus.DISCONNECTED) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: 'Es ist keine Verbindung hinterlegt. Zuerst über «Verbinden» einrichten.',
|
||||||
|
checkedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: 'Verbindung erreichbar. Simulierter Test, es wurde kein echtes System kontaktiert.',
|
||||||
|
checkedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockupAgentConnectionProvider: IAgentConnectionProvider = {
|
||||||
|
async getAll() {
|
||||||
|
return structuredClone(store)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const found = store.find(c => c.id === id)
|
||||||
|
return found ? structuredClone(found) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async connect(input: CreateAgentConnectionInput) {
|
||||||
|
await simulateLatency(AGENT_SIM_DELAY_SLOW_MS)
|
||||||
|
const idx = store.findIndex(c => c.id === input.connectionId)
|
||||||
|
if (idx === -1) throw new Error(`Verbindung «${input.connectionId}» nicht gefunden.`)
|
||||||
|
|
||||||
|
const current = store[idx]
|
||||||
|
store[idx] = {
|
||||||
|
...current,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
accountCount: Math.max(1, current.accountCount),
|
||||||
|
connectionLabel: input.connectionLabel,
|
||||||
|
usedByAgentIds: input.agentIds,
|
||||||
|
lastSyncAt: teamNowIso(),
|
||||||
|
...(current.category === AgentConnectionCategory.ERP && input.vendor
|
||||||
|
? { vendor: input.vendor }
|
||||||
|
: {}),
|
||||||
|
permissions: current.permissions.map(p => ({
|
||||||
|
...p,
|
||||||
|
granted: input.permissionIds.includes(p.id),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
return structuredClone(store[idx])
|
||||||
|
},
|
||||||
|
|
||||||
|
async disconnect(id) {
|
||||||
|
await simulateLatency(AGENT_SIM_DELAY_MS)
|
||||||
|
const idx = store.findIndex(c => c.id === id)
|
||||||
|
if (idx === -1) throw new Error(`Verbindung «${id}» nicht gefunden.`)
|
||||||
|
|
||||||
|
store[idx] = {
|
||||||
|
...store[idx],
|
||||||
|
status: AgentConnectionStatus.DISCONNECTED,
|
||||||
|
accountCount: 0,
|
||||||
|
lastSyncAt: undefined,
|
||||||
|
connectionLabel: undefined,
|
||||||
|
permissions: store[idx].permissions.map(p => ({ ...p, granted: false })),
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
return structuredClone(store[idx])
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(id) {
|
||||||
|
await simulateLatency(AGENT_SIM_DELAY_SLOW_MS)
|
||||||
|
const found = store.find(c => c.id === id)
|
||||||
|
if (!found) throw new Error(`Verbindung «${id}» nicht gefunden.`)
|
||||||
|
return evaluateTest(found)
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
store = structuredClone(mockAgentConnections)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { IAgentProtocolProvider, AgentProtocolFilters } from './IAgentProtocolProvider'
|
||||||
|
import type { AgentProtocolEntry, CreateAgentProtocolEntryInput } from '../domain/agentProtocol'
|
||||||
|
import { AgentPeriod, isWithinPeriod } from '../domain/agentFilters'
|
||||||
|
import { mockAgentProtocol } from '../mock-data/agentProtocol'
|
||||||
|
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
|
||||||
|
import { teamNow } from '../lib/teamClock'
|
||||||
|
|
||||||
|
function seed(): AgentProtocolEntry[] {
|
||||||
|
return loadVersioned<AgentProtocolEntry[]>(TEAM_STORAGE_KEYS.PROTOCOL) ?? structuredClone(mockAgentProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: AgentProtocolEntry[] = seed()
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
persistVersioned(TEAM_STORAGE_KEYS.PROTOCOL, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockupAgentProtocolProvider: IAgentProtocolProvider = {
|
||||||
|
async getAll(filters?: AgentProtocolFilters) {
|
||||||
|
let results = store
|
||||||
|
|
||||||
|
if (filters?.agentId) results = results.filter(e => e.agentId === filters.agentId)
|
||||||
|
if (filters?.eventType) results = results.filter(e => e.eventType === filters.eventType)
|
||||||
|
if (filters?.status) results = results.filter(e => e.status === filters.status)
|
||||||
|
if (filters?.objectId) results = results.filter(e => e.objectId === filters.objectId)
|
||||||
|
if (filters?.channel) results = results.filter(e => e.channel === filters.channel)
|
||||||
|
if (filters?.onlyApprovals) results = results.filter(e => e.approvalDecision !== undefined)
|
||||||
|
if (filters?.period && filters.period !== AgentPeriod.ALL) {
|
||||||
|
const now = teamNow()
|
||||||
|
results = results.filter(e => isWithinPeriod(e.timestamp, filters.period!, now))
|
||||||
|
}
|
||||||
|
if (filters?.search) {
|
||||||
|
const q = filters.search.trim().toLowerCase()
|
||||||
|
results = results.filter(
|
||||||
|
e =>
|
||||||
|
e.title.toLowerCase().includes(q) ||
|
||||||
|
e.description.toLowerCase().includes(q) ||
|
||||||
|
(e.objectId?.toLowerCase().includes(q) ?? false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neueste zuerst — das Protokoll wird immer von oben gelesen.
|
||||||
|
return structuredClone([...results].sort((a, b) => b.timestamp.localeCompare(a.timestamp)))
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(entry: CreateAgentProtocolEntryInput) {
|
||||||
|
const next: AgentProtocolEntry = { id: crypto.randomUUID(), ...entry }
|
||||||
|
store = [next, ...store]
|
||||||
|
save()
|
||||||
|
return structuredClone(next)
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
store = structuredClone(mockAgentProtocol)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { IAgentWorkItemProvider, AgentWorkItemFilters } from './IAgentWorkItemProvider'
|
||||||
|
import type { AgentWorkItem, UpdateAgentWorkItemInput } from '../domain/agentWorkItem'
|
||||||
|
import { AgentWorkItemPriority } from '../domain/agentWorkItem'
|
||||||
|
import { AgentPeriod, AgentWorkItemSort, isWithinPeriod } from '../domain/agentFilters'
|
||||||
|
import { mockAgentWorkItems } from '../mock-data/agentWorkItems'
|
||||||
|
import {
|
||||||
|
TEAM_STORAGE_KEYS,
|
||||||
|
loadVersioned,
|
||||||
|
persistVersioned,
|
||||||
|
simulateLatency,
|
||||||
|
} from './teamPersistence'
|
||||||
|
import { teamNow } from '../lib/teamClock'
|
||||||
|
import { AGENT_SIM_DELAY_MS } from '../lib/constants'
|
||||||
|
|
||||||
|
const PRIORITY_RANK: Record<string, number> = {
|
||||||
|
[AgentWorkItemPriority.CRITICAL]: 0,
|
||||||
|
[AgentWorkItemPriority.HIGH]: 1,
|
||||||
|
[AgentWorkItemPriority.MEDIUM]: 2,
|
||||||
|
[AgentWorkItemPriority.LOW]: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(): AgentWorkItem[] {
|
||||||
|
return loadVersioned<AgentWorkItem[]>(TEAM_STORAGE_KEYS.WORK_ITEMS) ?? structuredClone(mockAgentWorkItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: AgentWorkItem[] = seed()
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
persistVersioned(TEAM_STORAGE_KEYS.WORK_ITEMS, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Volltextsuche über die Felder, die ein Bewirtschafter tatsächlich im Kopf hat. */
|
||||||
|
function matchesSearch(item: AgentWorkItem, query: string): boolean {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return true
|
||||||
|
return (
|
||||||
|
item.title.toLowerCase().includes(q) ||
|
||||||
|
item.summary.toLowerCase().includes(q) ||
|
||||||
|
(item.objectId?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(item.objectLabel?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(item.escalationReason?.toLowerCase().includes(q) ?? false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortItems(items: AgentWorkItem[], sort: AgentWorkItemSort): AgentWorkItem[] {
|
||||||
|
const sorted = [...items]
|
||||||
|
switch (sort) {
|
||||||
|
case AgentWorkItemSort.OLDEST:
|
||||||
|
return sorted.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
||||||
|
case AgentWorkItemSort.PRIORITY:
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const delta = (PRIORITY_RANK[a.priority] ?? 9) - (PRIORITY_RANK[b.priority] ?? 9)
|
||||||
|
return delta !== 0 ? delta : b.createdAt.localeCompare(a.createdAt)
|
||||||
|
})
|
||||||
|
case AgentWorkItemSort.AGENT:
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const delta = a.agentId.localeCompare(b.agentId)
|
||||||
|
return delta !== 0 ? delta : b.createdAt.localeCompare(a.createdAt)
|
||||||
|
})
|
||||||
|
case AgentWorkItemSort.OBJECT_ID:
|
||||||
|
return sorted.sort((a, b) => {
|
||||||
|
const delta = (a.objectId ?? '').localeCompare(b.objectId ?? '')
|
||||||
|
return delta !== 0 ? delta : b.createdAt.localeCompare(a.createdAt)
|
||||||
|
})
|
||||||
|
case AgentWorkItemSort.NEWEST:
|
||||||
|
default:
|
||||||
|
return sorted.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockupAgentWorkItemProvider: IAgentWorkItemProvider = {
|
||||||
|
async getAll(filters?: AgentWorkItemFilters) {
|
||||||
|
let results = store
|
||||||
|
|
||||||
|
if (filters?.requiresDecision !== undefined) {
|
||||||
|
results = results.filter(i => i.requiresDecision === filters.requiresDecision)
|
||||||
|
}
|
||||||
|
if (filters?.agentId) results = results.filter(i => i.agentId === filters.agentId)
|
||||||
|
if (filters?.area) results = results.filter(i => i.area === filters.area)
|
||||||
|
if (filters?.kind) results = results.filter(i => i.kind === filters.kind)
|
||||||
|
if (filters?.priority) results = results.filter(i => i.priority === filters.priority)
|
||||||
|
if (filters?.status) results = results.filter(i => i.status === filters.status)
|
||||||
|
if (filters?.channel) {
|
||||||
|
results = results.filter(
|
||||||
|
i => i.sourceChannel === filters.channel || i.targetChannel === filters.channel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filters?.period && filters.period !== AgentPeriod.ALL) {
|
||||||
|
const now = teamNow()
|
||||||
|
results = results.filter(i => isWithinPeriod(i.completedAt ?? i.createdAt, filters.period!, now))
|
||||||
|
}
|
||||||
|
if (filters?.search) {
|
||||||
|
results = results.filter(i => matchesSearch(i, filters.search!))
|
||||||
|
}
|
||||||
|
|
||||||
|
return structuredClone(sortItems(results, filters?.sort ?? AgentWorkItemSort.NEWEST))
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const found = store.find(i => i.id === id)
|
||||||
|
return found ? structuredClone(found) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, data: UpdateAgentWorkItemInput) {
|
||||||
|
await simulateLatency(AGENT_SIM_DELAY_MS)
|
||||||
|
const idx = store.findIndex(i => i.id === id)
|
||||||
|
if (idx === -1) throw new Error(`Vorgang «${id}» nicht gefunden.`)
|
||||||
|
|
||||||
|
const current = store[idx]
|
||||||
|
const history = data.historyEntry
|
||||||
|
? [...current.history, { id: crypto.randomUUID(), ...data.historyEntry }]
|
||||||
|
: current.history
|
||||||
|
|
||||||
|
store[idx] = {
|
||||||
|
...current,
|
||||||
|
...(data.status !== undefined ? { status: data.status } : {}),
|
||||||
|
...(data.result ? { result: data.result } : {}),
|
||||||
|
...(data.editableFields ? { editableFields: data.editableFields } : {}),
|
||||||
|
...(data.rejectionReason !== undefined ? { rejectionReason: data.rejectionReason } : {}),
|
||||||
|
...(data.completedAt !== undefined ? { completedAt: data.completedAt } : {}),
|
||||||
|
...(data.requiresDecision !== undefined ? { requiresDecision: data.requiresDecision } : {}),
|
||||||
|
history,
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
return structuredClone(store[idx])
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
store = structuredClone(mockAgentWorkItems)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ITeamAgentProvider } from './ITeamAgentProvider'
|
||||||
|
import type { TeamAgent, UpdateTeamAgentInput } from '../domain/teamAgent'
|
||||||
|
import { mockTeamAgents } from '../mock-data/teamAgents'
|
||||||
|
import {
|
||||||
|
TEAM_STORAGE_KEYS,
|
||||||
|
loadVersioned,
|
||||||
|
persistVersioned,
|
||||||
|
simulateLatency,
|
||||||
|
} from './teamPersistence'
|
||||||
|
import { AGENT_SIM_DELAY_MS } from '../lib/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Bestand wird beim Laden aus dem Local Storage überlagert: geänderte
|
||||||
|
* Einstellungen, Aufgabenschalter, Kanalschalter und pausierte Mitarbeiter
|
||||||
|
* überleben so einen Reload. Fehlt oder passt der gespeicherte Bestand nicht
|
||||||
|
* zur Schemaversion, greift der Seed aus `src/mock-data/`.
|
||||||
|
*/
|
||||||
|
function seed(): TeamAgent[] {
|
||||||
|
return loadVersioned<TeamAgent[]>(TEAM_STORAGE_KEYS.AGENTS) ?? structuredClone(mockTeamAgents)
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: TeamAgent[] = seed()
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
persistVersioned(TEAM_STORAGE_KEYS.AGENTS, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MockupTeamAgentProvider: ITeamAgentProvider = {
|
||||||
|
async getAll() {
|
||||||
|
return structuredClone(store)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id) {
|
||||||
|
const found = store.find(a => a.id === id)
|
||||||
|
return found ? structuredClone(found) : null
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, data: UpdateTeamAgentInput) {
|
||||||
|
await simulateLatency(AGENT_SIM_DELAY_MS)
|
||||||
|
const idx = store.findIndex(a => a.id === id)
|
||||||
|
if (idx === -1) throw new Error(`Digitaler Mitarbeiter «${id}» nicht gefunden.`)
|
||||||
|
store[idx] = {
|
||||||
|
...store[idx],
|
||||||
|
...(data.status !== undefined ? { status: data.status } : {}),
|
||||||
|
...(data.tasks ? { tasks: data.tasks } : {}),
|
||||||
|
...(data.channels ? { channels: data.channels } : {}),
|
||||||
|
...(data.settings ? { settings: data.settings } : {}),
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
return structuredClone(store[idx])
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset() {
|
||||||
|
store = structuredClone(mockTeamAgents)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Property On — lokale Persistenz (§16.2).
|
||||||
|
*
|
||||||
|
* Ausschliesslich von den Mockup-Providern benutzt: die Providerschicht ist der
|
||||||
|
* einzige Ort, der Datenhaltung berührt (CLAUDE.md §4.1). Services, Hooks und
|
||||||
|
* Komponenten sehen davon nichts.
|
||||||
|
*
|
||||||
|
* Persistiert werden geänderte Einstellungen, Aufgabenstatus, erledigte
|
||||||
|
* Vorgänge, Verbindungsstatus und pausierte Mitarbeiter. «Demo zurücksetzen»
|
||||||
|
* löscht genau diese Schlüssel und stellt die ursprünglichen Mockdaten her.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PREFIX = 'property-on:'
|
||||||
|
|
||||||
|
export const TEAM_STORAGE_KEYS = {
|
||||||
|
AGENTS: `${PREFIX}agents`,
|
||||||
|
WORK_ITEMS: `${PREFIX}work-items`,
|
||||||
|
PROTOCOL: `${PREFIX}protocol`,
|
||||||
|
CONNECTIONS: `${PREFIX}connections`,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TeamStorageKey = typeof TEAM_STORAGE_KEYS[keyof typeof TEAM_STORAGE_KEYS]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest einen persistierten Bestand.
|
||||||
|
* Gibt null zurück, wenn nichts gespeichert ist oder der Inhalt unlesbar wurde —
|
||||||
|
* dann greift der Seed aus `src/mock-data/`. Ein defekter Local-Storage-Eintrag
|
||||||
|
* darf die Anwendung nie blockieren.
|
||||||
|
*/
|
||||||
|
export function loadPersisted<T>(key: TeamStorageKey): T | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Bestand. Fehler (z. B. volles Kontingent) werden bewusst geschluckt. */
|
||||||
|
export function persist<T>(key: TeamStorageKey, value: T): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// Persistenz ist ein Komfort, kein Muss — die Demo läuft auch ohne weiter.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schemaversion der persistierten Bestände. Erhöhen, sobald sich eine Struktur
|
||||||
|
* unter `src/domain/` ändert — sonst überlagert ein alter Local-Storage-Eintrag
|
||||||
|
* die neuen Mockdaten und die Demo zeigt stillschweigend veraltete Inhalte.
|
||||||
|
*/
|
||||||
|
export const TEAM_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
interface PersistedEnvelope<T> {
|
||||||
|
version: number
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie `loadPersisted`, verwirft aber Bestände einer fremden Schemaversion. */
|
||||||
|
export function loadVersioned<T>(key: TeamStorageKey): T | null {
|
||||||
|
const envelope = loadPersisted<PersistedEnvelope<T>>(key)
|
||||||
|
if (!envelope || envelope.version !== TEAM_SCHEMA_VERSION) return null
|
||||||
|
return envelope.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistVersioned<T>(key: TeamStorageKey, data: T): void {
|
||||||
|
persist<PersistedEnvelope<T>>(key, { version: TEAM_SCHEMA_VERSION, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht sämtliche Property-On-Schlüssel («Demo zurücksetzen»). */
|
||||||
|
export function clearTeamPersistence(): void {
|
||||||
|
try {
|
||||||
|
for (const key of Object.values(TEAM_STORAGE_KEYS)) {
|
||||||
|
window.localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// siehe oben
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulierte Ladezeit (§19.1). Bewusst deterministisch statt zufällig — ein
|
||||||
|
* flackernder Zufallswert macht die Demo unvorhersehbar und Tests instabil.
|
||||||
|
*/
|
||||||
|
export function simulateLatency(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { MockupAgentConnectionProvider } from '../provider/MockupAgentConnectionProvider'
|
||||||
|
import type {
|
||||||
|
AgentConnection,
|
||||||
|
AgentConnectionTestResult,
|
||||||
|
CreateAgentConnectionInput,
|
||||||
|
} from '../domain/agentConnection'
|
||||||
|
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
|
||||||
|
import { AGENT_CONNECTION_CATEGORY_LABELS } from '../lib/constants'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
import { agentProtocolService } from './agentProtocolService'
|
||||||
|
|
||||||
|
const provider = MockupAgentConnectionProvider
|
||||||
|
|
||||||
|
export const agentConnectionService = {
|
||||||
|
async getAll(): Promise<ListResponse<AgentConnection>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getAll()
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<ItemResponse<AgentConnection | null>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async connect(input: CreateAgentConnectionInput): Promise<ItemResponse<AgentConnection>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.connect(input)
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
connectionId: data.id,
|
||||||
|
eventType: AgentProtocolEventType.CONNECTION_CREATED,
|
||||||
|
title: `Verbindung angelegt: ${data.name}`,
|
||||||
|
description: `«${input.connectionLabel}» wurde simuliert verbunden. ${input.agentIds.length} digitale Mitarbeiter nutzen diese Verbindung.`,
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async disconnect(id: string): Promise<ItemResponse<AgentConnection>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.disconnect(id)
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
connectionId: data.id,
|
||||||
|
eventType: AgentProtocolEventType.CONNECTION_REMOVED,
|
||||||
|
title: `Verbindung getrennt: ${data.name}`,
|
||||||
|
description: `${AGENT_CONNECTION_CATEGORY_LABELS[data.category] ?? data.name} steht den digitalen Mitarbeitern nicht mehr zur Verfügung.`,
|
||||||
|
status: AgentProtocolStatus.WARNING,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(id: string): Promise<ItemResponse<AgentConnectionTestResult>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.test(id)
|
||||||
|
const connection = await provider.getById(id)
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
connectionId: id,
|
||||||
|
eventType: AgentProtocolEventType.CHANNEL_TESTED,
|
||||||
|
title: `Verbindung getestet: ${connection?.name ?? id}`,
|
||||||
|
description: data.message,
|
||||||
|
status: data.ok ? AgentProtocolStatus.SUCCESS : AgentProtocolStatus.ERROR,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { MockupAgentProtocolProvider } from '../provider/MockupAgentProtocolProvider'
|
||||||
|
import type { AgentProtocolFilters } from '../provider/IAgentProtocolProvider'
|
||||||
|
import type {
|
||||||
|
AgentProtocolEntry,
|
||||||
|
AgentProtocolEventType,
|
||||||
|
AgentProtocolStatus,
|
||||||
|
AgentApprovalDecision,
|
||||||
|
CreateAgentProtocolEntryInput,
|
||||||
|
} from '../domain/agentProtocol'
|
||||||
|
import { AgentTriggerSource } from '../domain/agentProtocol'
|
||||||
|
import type { AgentChannelType } from '../domain/teamAgent'
|
||||||
|
import type { AgentDetailField } from '../domain/agentWorkItem'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
|
import { teamNowIso } from '../lib/teamClock'
|
||||||
|
|
||||||
|
const provider = MockupAgentProtocolProvider
|
||||||
|
|
||||||
|
/** Eingabe für eine vom Menschen ausgelöste Aktion — Zeit und Person füllt der Service. */
|
||||||
|
export interface LogUserActionInput {
|
||||||
|
agentId?: string
|
||||||
|
connectionId?: string
|
||||||
|
eventType: AgentProtocolEventType
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: AgentProtocolStatus
|
||||||
|
objectId?: string
|
||||||
|
channel?: AgentChannelType
|
||||||
|
approvalDecision?: AgentApprovalDecision
|
||||||
|
workItemId?: string
|
||||||
|
output?: AgentDetailField[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentProtocolService = {
|
||||||
|
async getAll(filters?: AgentProtocolFilters): Promise<ListResponse<AgentProtocolEntry>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async log(entry: CreateAgentProtocolEntryInput): Promise<ItemResponse<AgentProtocolEntry>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.create(entry)
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokolliert eine Handlung des Nutzers. Jede freigabepflichtige Aktion in
|
||||||
|
* Property On läuft hierüber — ohne Eintrag gäbe es keine Nachvollziehbarkeit,
|
||||||
|
* und genau die ist der Zweck des Protokolls (§12.1).
|
||||||
|
*/
|
||||||
|
async logUserAction(input: LogUserActionInput): Promise<ItemResponse<AgentProtocolEntry>> {
|
||||||
|
const userName = useSessionStore.getState().currentUser?.name ?? 'Unbekannt'
|
||||||
|
return agentProtocolService.log({
|
||||||
|
...input,
|
||||||
|
timestamp: teamNowIso(),
|
||||||
|
triggeredBy: AgentTriggerSource.USER,
|
||||||
|
triggeredByName: userName,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { MockupAgentWorkItemProvider } from '../provider/MockupAgentWorkItemProvider'
|
||||||
|
import type { AgentWorkItemFilters } from '../provider/IAgentWorkItemProvider'
|
||||||
|
import type {
|
||||||
|
AgentWorkItem,
|
||||||
|
AgentEditableField,
|
||||||
|
AgentWorkloadKpis,
|
||||||
|
} from '../domain/agentWorkItem'
|
||||||
|
import { AgentWorkItemStatus, AgentWorkItemAction } from '../domain/agentWorkItem'
|
||||||
|
import {
|
||||||
|
AgentProtocolEventType,
|
||||||
|
AgentProtocolStatus,
|
||||||
|
AgentApprovalDecision,
|
||||||
|
} from '../domain/agentProtocol'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
import { agentProtocolService } from './agentProtocolService'
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
|
import { teamNowIso } from '../lib/teamClock'
|
||||||
|
|
||||||
|
const provider = MockupAgentWorkItemProvider
|
||||||
|
|
||||||
|
/** Aktionen, die eine Entscheidung zur Bestätigung vorlegen — siehe `AgentWorkloadKpis`. */
|
||||||
|
const APPROVAL_ACTIONS: string[] = [
|
||||||
|
AgentWorkItemAction.APPROVE,
|
||||||
|
AgentWorkItemAction.ONE_CLICK_CONFIRM,
|
||||||
|
AgentWorkItemAction.DECIDE,
|
||||||
|
]
|
||||||
|
|
||||||
|
function actorName(): string {
|
||||||
|
return useSessionStore.getState().currentUser?.name ?? 'Unbekannt'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApprovalRequest(item: AgentWorkItem): boolean {
|
||||||
|
return item.availableActions.some(a => APPROVAL_ACTIONS.includes(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentWorkItemService = {
|
||||||
|
async getAll(filters?: AgentWorkItemFilters): Promise<ListResponse<AgentWorkItem>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getAll(filters)
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<ItemResponse<AgentWorkItem | null>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennzahlen der Auswertung. Bewusst ein einziger Durchlauf über die gefilterte
|
||||||
|
* Liste statt dreier `.filter()`-Aufrufe (CLAUDE.md §10.4).
|
||||||
|
*
|
||||||
|
* Zurückgewiesene Vorgänge zählen nicht als erledigte Aufgabe — sie wurden
|
||||||
|
* entschieden, aber nicht ausgeführt. Eine Kennzahl, die sie mitzählt, würde
|
||||||
|
* die Leistung des Teams zu gut darstellen.
|
||||||
|
*/
|
||||||
|
async getKpis(filters?: AgentWorkItemFilters): Promise<ItemResponse<AgentWorkloadKpis>> {
|
||||||
|
try {
|
||||||
|
// requiresDecision bewusst ausgeklammert: die Kennzahlen betrachten beide Reiter.
|
||||||
|
const scoped: AgentWorkItemFilters = { ...filters, requiresDecision: undefined }
|
||||||
|
const items = await provider.getAll(scoped)
|
||||||
|
|
||||||
|
let completedTasks = 0
|
||||||
|
let pendingMessages = 0
|
||||||
|
let pendingApprovals = 0
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.requiresDecision) {
|
||||||
|
if (isApprovalRequest(item)) pendingApprovals++
|
||||||
|
else pendingMessages++
|
||||||
|
} else if (item.status !== AgentWorkItemStatus.REJECTED) {
|
||||||
|
completedTasks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: { completedTasks, pendingMessages, pendingApprovals } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Freigabe bzw. 1-Klick-Bestätigung: Vorgang wechselt zu «Erledigte Aufträge». */
|
||||||
|
async approve(id: string): Promise<ItemResponse<AgentWorkItem>> {
|
||||||
|
try {
|
||||||
|
const now = teamNowIso()
|
||||||
|
const data = await provider.update(id, {
|
||||||
|
status: AgentWorkItemStatus.APPROVED,
|
||||||
|
requiresDecision: false,
|
||||||
|
completedAt: now,
|
||||||
|
historyEntry: { at: now, actor: actorName(), action: 'Freigegeben' },
|
||||||
|
})
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: data.agentId,
|
||||||
|
eventType: AgentProtocolEventType.APPROVAL,
|
||||||
|
title: `Freigegeben: ${data.title}`,
|
||||||
|
description: data.summary,
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
objectId: data.objectId,
|
||||||
|
channel: data.targetChannel ?? data.sourceChannel,
|
||||||
|
approvalDecision: AgentApprovalDecision.APPROVED,
|
||||||
|
workItemId: data.id,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async reject(id: string, reason?: string): Promise<ItemResponse<AgentWorkItem>> {
|
||||||
|
try {
|
||||||
|
const now = teamNowIso()
|
||||||
|
const data = await provider.update(id, {
|
||||||
|
status: AgentWorkItemStatus.REJECTED,
|
||||||
|
requiresDecision: false,
|
||||||
|
completedAt: now,
|
||||||
|
rejectionReason: reason,
|
||||||
|
historyEntry: { at: now, actor: actorName(), action: 'Zurückgewiesen', note: reason },
|
||||||
|
})
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: data.agentId,
|
||||||
|
eventType: AgentProtocolEventType.REJECTION,
|
||||||
|
title: `Zurückgewiesen: ${data.title}`,
|
||||||
|
description: reason?.trim()
|
||||||
|
? `Begründung: ${reason.trim()}`
|
||||||
|
: 'Ohne Angabe einer Begründung zurückgewiesen.',
|
||||||
|
status: AgentProtocolStatus.WARNING,
|
||||||
|
objectId: data.objectId,
|
||||||
|
approvalDecision: AgentApprovalDecision.REJECTED,
|
||||||
|
workItemId: data.id,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Anpassen»: Änderungen werden lokal gespeichert, der Vorgang bleibt aber bis
|
||||||
|
* zur finalen Entscheidung pendent (§5.6).
|
||||||
|
*/
|
||||||
|
async saveEdit(id: string, fields: AgentEditableField[]): Promise<ItemResponse<AgentWorkItem>> {
|
||||||
|
try {
|
||||||
|
const now = teamNowIso()
|
||||||
|
const data = await provider.update(id, {
|
||||||
|
status: AgentWorkItemStatus.EDITING,
|
||||||
|
requiresDecision: true,
|
||||||
|
editableFields: fields,
|
||||||
|
historyEntry: { at: now, actor: actorName(), action: 'Angepasst' },
|
||||||
|
})
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: data.agentId,
|
||||||
|
eventType: AgentProtocolEventType.EDIT,
|
||||||
|
title: `Angepasst: ${data.title}`,
|
||||||
|
description: 'Der Vorgang wurde bearbeitet und wartet weiterhin auf die finale Entscheidung.',
|
||||||
|
status: AgentProtocolStatus.INFO,
|
||||||
|
objectId: data.objectId,
|
||||||
|
approvalDecision: AgentApprovalDecision.EDITED,
|
||||||
|
workItemId: data.id,
|
||||||
|
output: fields.map(f => ({ label: f.label, value: f.value })),
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Rückfrage beantworten — die Antwort wird Teil des Ergebnisses. */
|
||||||
|
async answerQuery(id: string, answer: string): Promise<ItemResponse<AgentWorkItem>> {
|
||||||
|
try {
|
||||||
|
const current = await provider.getById(id)
|
||||||
|
if (!current) throw new Error(`Vorgang «${id}» nicht gefunden.`)
|
||||||
|
const now = teamNowIso()
|
||||||
|
const data = await provider.update(id, {
|
||||||
|
status: AgentWorkItemStatus.COMPLETED,
|
||||||
|
requiresDecision: false,
|
||||||
|
completedAt: now,
|
||||||
|
result: [...current.result, { label: 'Antwort der Bewirtschaftung', value: answer }],
|
||||||
|
historyEntry: { at: now, actor: actorName(), action: 'Rückfrage beantwortet', note: answer },
|
||||||
|
})
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: data.agentId,
|
||||||
|
eventType: AgentProtocolEventType.APPROVAL,
|
||||||
|
title: `Rückfrage beantwortet: ${data.title}`,
|
||||||
|
description: answer,
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
objectId: data.objectId,
|
||||||
|
workItemId: data.id,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async markDone(id: string): Promise<ItemResponse<AgentWorkItem>> {
|
||||||
|
try {
|
||||||
|
const now = teamNowIso()
|
||||||
|
const data = await provider.update(id, {
|
||||||
|
status: AgentWorkItemStatus.COMPLETED,
|
||||||
|
requiresDecision: false,
|
||||||
|
completedAt: now,
|
||||||
|
historyEntry: { at: now, actor: actorName(), action: 'Als erledigt markiert' },
|
||||||
|
})
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: data.agentId,
|
||||||
|
eventType: AgentProtocolEventType.APPROVAL,
|
||||||
|
title: `Als erledigt markiert: ${data.title}`,
|
||||||
|
description: data.summary,
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
objectId: data.objectId,
|
||||||
|
workItemId: data.id,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { MockupTeamAgentProvider } from '../provider/MockupTeamAgentProvider'
|
||||||
|
import type { TeamAgent, AgentTask, AgentChannel, AgentSetting } from '../domain/teamAgent'
|
||||||
|
import { AgentStatus } from '../domain/teamAgent'
|
||||||
|
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
|
||||||
|
import type { ListResponse, ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
import { agentProtocolService } from './agentProtocolService'
|
||||||
|
|
||||||
|
const provider = MockupTeamAgentProvider
|
||||||
|
|
||||||
|
export const teamAgentService = {
|
||||||
|
async getAll(): Promise<ListResponse<TeamAgent>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getAll()
|
||||||
|
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: string): Promise<ItemResponse<TeamAgent | null>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.getById(id)
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pausiert oder reaktiviert einen digitalen Mitarbeiter (§7.5).
|
||||||
|
* Der Aktiv-Haken auf der Teamübersicht spiegelt genau diesen Zustand.
|
||||||
|
*/
|
||||||
|
async setActive(id: string, active: boolean): Promise<ItemResponse<TeamAgent>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.update(id, {
|
||||||
|
status: active ? AgentStatus.ACTIVE : AgentStatus.PAUSED,
|
||||||
|
})
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: id,
|
||||||
|
eventType: active ? AgentProtocolEventType.AGENT_RESUMED : AgentProtocolEventType.AGENT_PAUSED,
|
||||||
|
title: active ? `${data.name} reaktiviert` : `${data.name} pausiert`,
|
||||||
|
description: active
|
||||||
|
? `${data.name} nimmt die Arbeit wieder auf. Alle aktivierten Aufgaben laufen erneut nach Zeitplan.`
|
||||||
|
: `${data.name} wurde pausiert. Bis zur Reaktivierung werden keine Aufgaben ausgeführt.`,
|
||||||
|
status: active ? AgentProtocolStatus.SUCCESS : AgentProtocolStatus.WARNING,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveTasks(id: string, tasks: AgentTask[]): Promise<ItemResponse<TeamAgent>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.update(id, { tasks })
|
||||||
|
const disabled = tasks.filter(t => !t.enabled).length
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: id,
|
||||||
|
eventType: AgentProtocolEventType.TASKS_SAVED,
|
||||||
|
title: 'Aufgaben gespeichert',
|
||||||
|
description:
|
||||||
|
disabled === 0
|
||||||
|
? `Alle ${tasks.length} Aufgaben von ${data.name} sind aktiv.`
|
||||||
|
: `${tasks.length - disabled} von ${tasks.length} Aufgaben aktiv, ${disabled} deaktiviert.`,
|
||||||
|
status: disabled === 0 ? AgentProtocolStatus.SUCCESS : AgentProtocolStatus.WARNING,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveSettings(id: string, settings: AgentSetting[]): Promise<ItemResponse<TeamAgent>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.update(id, { settings })
|
||||||
|
await agentProtocolService.logUserAction({
|
||||||
|
agentId: id,
|
||||||
|
eventType: AgentProtocolEventType.SETTINGS_SAVED,
|
||||||
|
title: 'Einstellungen gespeichert',
|
||||||
|
description: `Die Einstellungen von ${data.name} wurden angepasst.`,
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
})
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveChannels(id: string, channels: AgentChannel[]): Promise<ItemResponse<TeamAgent>> {
|
||||||
|
try {
|
||||||
|
const data = await provider.update(id, { channels })
|
||||||
|
return { data }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MockupTeamAgentProvider } from '../provider/MockupTeamAgentProvider'
|
||||||
|
import { MockupAgentWorkItemProvider } from '../provider/MockupAgentWorkItemProvider'
|
||||||
|
import { MockupAgentProtocolProvider } from '../provider/MockupAgentProtocolProvider'
|
||||||
|
import { MockupAgentConnectionProvider } from '../provider/MockupAgentConnectionProvider'
|
||||||
|
import { clearTeamPersistence } from '../provider/teamPersistence'
|
||||||
|
import type { ItemResponse } from './types'
|
||||||
|
import { throwServiceError } from './errors'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Demo zurücksetzen» (§16.2).
|
||||||
|
*
|
||||||
|
* Löscht alle lokalen Änderungen und stellt die ursprünglichen Mockdaten her.
|
||||||
|
* Bewusst OHNE Protokolleintrag: nach dem Zurücksetzen soll exakt der
|
||||||
|
* Auslieferungszustand stehen — ein Eintrag über den Reset selbst wäre bereits
|
||||||
|
* eine Abweichung davon.
|
||||||
|
*/
|
||||||
|
export const teamDemoService = {
|
||||||
|
async reset(): Promise<ItemResponse<void>> {
|
||||||
|
try {
|
||||||
|
clearTeamPersistence()
|
||||||
|
await Promise.all([
|
||||||
|
MockupTeamAgentProvider.reset(),
|
||||||
|
MockupAgentWorkItemProvider.reset(),
|
||||||
|
MockupAgentProtocolProvider.reset(),
|
||||||
|
MockupAgentConnectionProvider.reset(),
|
||||||
|
])
|
||||||
|
return { data: undefined }
|
||||||
|
} catch (err) {
|
||||||
|
throwServiceError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type {
|
||||||
|
AgentWorkItemKind,
|
||||||
|
AgentWorkItemPriority,
|
||||||
|
AgentWorkItemStatus,
|
||||||
|
AgentDomainArea,
|
||||||
|
} from '../domain/agentWorkItem'
|
||||||
|
import type { AgentChannelType } from '../domain/teamAgent'
|
||||||
|
import { AgentPeriod, AgentWorkItemSort } from '../domain/agentFilters'
|
||||||
|
|
||||||
|
/** Der jeweils andere Reiter des Bearbeitungsverlaufs (§5.2). */
|
||||||
|
export const HistoryTab = {
|
||||||
|
DONE: 'DONE',
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
} as const
|
||||||
|
export type HistoryTab = typeof HistoryTab[keyof typeof HistoryTab]
|
||||||
|
|
||||||
|
export type AgentFilterValue<T> = T | 'ALL'
|
||||||
|
|
||||||
|
interface TeamStore {
|
||||||
|
// ── Auswertung auf der Teamübersicht (§4.2) ─────────────────────────────────
|
||||||
|
overviewPeriod: AgentPeriod
|
||||||
|
overviewArea: AgentFilterValue<AgentDomainArea>
|
||||||
|
overviewAgentId: AgentFilterValue<string>
|
||||||
|
setOverviewPeriod: (p: AgentPeriod) => void
|
||||||
|
setOverviewArea: (a: AgentFilterValue<AgentDomainArea>) => void
|
||||||
|
setOverviewAgentId: (id: AgentFilterValue<string>) => void
|
||||||
|
resetOverviewFilters: () => void
|
||||||
|
|
||||||
|
// ── Bearbeitungsverlauf (§5.3) ──────────────────────────────────────────────
|
||||||
|
historyTab: HistoryTab
|
||||||
|
setHistoryTab: (t: HistoryTab) => void
|
||||||
|
historyPeriod: AgentPeriod
|
||||||
|
historyAgentId: AgentFilterValue<string>
|
||||||
|
historyArea: AgentFilterValue<AgentDomainArea>
|
||||||
|
historyKind: AgentFilterValue<AgentWorkItemKind>
|
||||||
|
historyPriority: AgentFilterValue<AgentWorkItemPriority>
|
||||||
|
historyChannel: AgentFilterValue<AgentChannelType>
|
||||||
|
historyStatus: AgentFilterValue<AgentWorkItemStatus>
|
||||||
|
historySearch: string
|
||||||
|
historySort: AgentWorkItemSort
|
||||||
|
setHistoryPeriod: (p: AgentPeriod) => void
|
||||||
|
setHistoryAgentId: (id: AgentFilterValue<string>) => void
|
||||||
|
setHistoryArea: (a: AgentFilterValue<AgentDomainArea>) => void
|
||||||
|
setHistoryKind: (k: AgentFilterValue<AgentWorkItemKind>) => void
|
||||||
|
setHistoryPriority: (p: AgentFilterValue<AgentWorkItemPriority>) => void
|
||||||
|
setHistoryChannel: (c: AgentFilterValue<AgentChannelType>) => void
|
||||||
|
setHistoryStatus: (s: AgentFilterValue<AgentWorkItemStatus>) => void
|
||||||
|
setHistorySearch: (q: string) => void
|
||||||
|
setHistorySort: (s: AgentWorkItemSort) => void
|
||||||
|
resetHistoryFilters: () => void
|
||||||
|
|
||||||
|
// ── Detail-Drawer ───────────────────────────────────────────────────────────
|
||||||
|
selectedWorkItemId: string | null
|
||||||
|
setSelectedWorkItemId: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_FILTER_DEFAULTS = {
|
||||||
|
historyPeriod: AgentPeriod.ALL,
|
||||||
|
historyAgentId: 'ALL' as AgentFilterValue<string>,
|
||||||
|
historyArea: 'ALL' as AgentFilterValue<AgentDomainArea>,
|
||||||
|
historyKind: 'ALL' as AgentFilterValue<AgentWorkItemKind>,
|
||||||
|
historyPriority: 'ALL' as AgentFilterValue<AgentWorkItemPriority>,
|
||||||
|
historyChannel: 'ALL' as AgentFilterValue<AgentChannelType>,
|
||||||
|
historyStatus: 'ALL' as AgentFilterValue<AgentWorkItemStatus>,
|
||||||
|
historySearch: '',
|
||||||
|
historySort: AgentWorkItemSort.NEWEST,
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERVIEW_FILTER_DEFAULTS = {
|
||||||
|
// Der Monat ist der Zeitraum, in dem eine Bewirtschaftung ihre Leistung liest —
|
||||||
|
// «heute» wäre für die Startseite zu eng und wirkte oft leer.
|
||||||
|
overviewPeriod: AgentPeriod.MONTH,
|
||||||
|
overviewArea: 'ALL' as AgentFilterValue<AgentDomainArea>,
|
||||||
|
overviewAgentId: 'ALL' as AgentFilterValue<string>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTeamStore = create<TeamStore>((set) => ({
|
||||||
|
...OVERVIEW_FILTER_DEFAULTS,
|
||||||
|
setOverviewPeriod: (p) => set({ overviewPeriod: p }),
|
||||||
|
setOverviewArea: (a) => set({ overviewArea: a }),
|
||||||
|
setOverviewAgentId: (id) => set({ overviewAgentId: id }),
|
||||||
|
resetOverviewFilters: () => set({ ...OVERVIEW_FILTER_DEFAULTS }),
|
||||||
|
|
||||||
|
historyTab: HistoryTab.PENDING,
|
||||||
|
setHistoryTab: (t) => set({ historyTab: t }),
|
||||||
|
...HISTORY_FILTER_DEFAULTS,
|
||||||
|
setHistoryPeriod: (p) => set({ historyPeriod: p }),
|
||||||
|
setHistoryAgentId: (id) => set({ historyAgentId: id }),
|
||||||
|
setHistoryArea: (a) => set({ historyArea: a }),
|
||||||
|
setHistoryKind: (k) => set({ historyKind: k }),
|
||||||
|
setHistoryPriority: (p) => set({ historyPriority: p }),
|
||||||
|
setHistoryChannel: (c) => set({ historyChannel: c }),
|
||||||
|
setHistoryStatus: (s) => set({ historyStatus: s }),
|
||||||
|
setHistorySearch: (q) => set({ historySearch: q }),
|
||||||
|
setHistorySort: (s) => set({ historySort: s }),
|
||||||
|
resetHistoryFilters: () => set({ ...HISTORY_FILTER_DEFAULTS }),
|
||||||
|
|
||||||
|
selectedWorkItemId: null,
|
||||||
|
setSelectedWorkItemId: (id) => set({ selectedWorkItemId: id }),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user