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:
Benjamin Sutter
2026-08-03 16:55:40 +02:00
parent 36ce548169
commit 52dc7de25e
38 changed files with 9055 additions and 0 deletions
+205
View File
@@ -32,6 +32,10 @@ export const STALE_MATCHES = 2 * 60 * 1000
export const STALE_SIGNALS = 5 * 60 * 1000
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_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
export const ROUTES = {
@@ -42,6 +46,15 @@ export const ROUTES = {
MATCH_CENTER: '/supply/match-center',
FUTURE_AVAILABILITY: '/supply/future-availability',
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: {
AI_SEARCH: '/demand/ai-search',
@@ -132,6 +145,198 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
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: 300800 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)
export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number }> = {
SHELL: { min: 800, max: 1500 },
+71
View File
@@ -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)
}