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 SutterandClaude Opus 5 committed 2026-08-03 16:55:40 +02:00
1 parent 36ce548169
commit 52dc7de25e
38 files changed
+9055

No files matched your search

+16
View File
@@ -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>
}
+27
View File
@@ -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>
}
+32
View File
@@ -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>
}
+9
View File
@@ -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)
},
}
+131
View File
@@ -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)
},
}
+56
View File
@@ -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)
},
}
+89
View File
@@ -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) })
}