23 Commits

Author SHA1 Message Date
Benjamin Sutter 177641386a refactor(team): zweite Überarbeitungsrunde — Oberfläche bereinigen
Gezielte UI-Bereinigung ohne neue Architektur. Bestehende Komponenten angepasst,
Datenstrukturen und Routing unverändert.

GLOBAL
Chip «Verwaltung» und Seitenname in der Kopfzeile entfallen auf /supply/team/*;
die Seiten tragen ihren Titel bereits gross im Inhalt. Andere Module behalten die
Zeile. Demo-Modus-Hinweis und alle Untertitel entfernt. Autonomiegrad aus der
Oberfläche genommen — die Daten bleiben unangetastet.

PERSONALVERWALTUNG
Beschrieb auf zwei Sätze gekürzt, Input/Kernablauf/Output entfallen: das war
Prozessnotation, sie beschrieb wie gearbeitet wird, nicht wofür man jemanden hat.
Zuständigkeiten stehen jetzt im Kopfbereich statt tief im Dossier. Reiterfolge
neu: Agentenbeschrieb, Aufgaben, Kennzahlen, Kanäle & Systeme, Protokoll.
Kanäle und Systeme sind über eine dünne Hülle zusammengeführt — die bestehende
Logik samt Konfigurationsdialog bleibt unberührt. Aufgaben dreispaltig ohne
Zeitplan, Ereignisfilter im Protokoll entfernt.

Der abgelöste Reiter «Systeme» wird weiterhin aufgelöst, damit bestehende
Verweise nicht still auf den ersten Reiter zurückfallen.

ORGANIGRAMM — die eigentliche Korrektur
Der falsch gewählte Agent lag an der Geometrie: `rotate(a) translateY(r)` schiebt
bei a = 0 nach UNTEN, die Drehung wurde aber als `180 − index·step` berechnet.
Damit stand der gewählte Mitarbeitende oben und das Dossier darunter gehörte zum
falschen. Jetzt `−index·step`.

Dazu beruhigt: Zugempfindlichkeit von 0.5 auf 0.22 Grad je Pixel, 10 px Totzone
gegen Zittern, kurzer Zug wechselt höchstens einen Platz, Einrasten in 250 ms,
Auswahl erst nach dem Einrasten. Kreis von 720 auf 460 px.

BEARBEITUNGSVERLAUF
Filter ohne Vorgangstyp, Priorität und Kanal; Suche in Zeile eins, Rest in Zeile
zwei. Karten tragen nur noch Agent, Titel mit Liegenschaft, zwei Zeilen Text und
Zeitstempel — Priorität, Status, Vorgangstyp und Kanal standen auf praktisch
jeder Karte und trugen damit keine Information mehr.

Drawer ohne Verarbeitungsschritte und Aktionshistorie; nur die auslösende
Nachricht plus Quicklink ins Anfragencenter statt des ganzen Verlaufs.
«Fundstellen» heisst «Quellen». Der Entscheidungshinweis heisst jetzt
«Hierzu brauche ich Ihre Entscheidung». Aktionsleiste: pendent Freigeben,
Zurückweisen, Quellen anzeigen, Inserat anzeigen — erledigt nur «Objekt anzeigen».

KANÄLE & SYSTEME, TEAMÜBERSICHT
Kanäle vor Systemen als eigene Abschnitte, vier Karten pro Zeile, Beschreibung
und Statusbadge raus, Regler mit Klartext Aktiv/Inaktiv/Geplant. Die Kreisgrafik
skaliert nach Anzahl sichtbarer Ringe und ist auf die Viewport-Höhe begrenzt,
damit das Kernteam ohne Scrollen vollständig sichtbar bleibt.

Drei Testzusicherungen prüfen jetzt die Abwesenheit statt der Anwesenheit von
Objekt-ID und Rückfragegrund auf der Karte — beides wurde bewusst entfernt.

Typecheck grün, ESLint über die geänderten Dateien grün, Build grün, 413 Tests grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:30:32 +02:00
Benjamin Sutter 8377ce03b5 feat(team): Kreisdarstellung der Belegschaft und Organigramm-Ansicht
Setzt die Referenzgrafik «Agenten_Bewirtschaftung_v2» um: die 36 digitalen
Mitarbeitenden in konzentrischen Ringen, Kernteam innen und gold gerahmt.

DATENBASIS
Alle 36 Mitarbeitenden samt Porträts sind aus dem Konzeptdokument extrahiert.
Die Stufen stecken bereits in den Quelldaten und decken sich exakt mit den
Bereichen: lvl 1 = 7 Kernteam, lvl 2 = 18 Zentrale Dienste, lvl 3 = 7 GB
Kommerziell, lvl 4 = 4 GB Wohnen. Auch die Reiterbeschriftungen sind wörtlich
übernommen — keine neuen Stufennamen erfunden.

Neu getrennt: `AgentDirectoryEntry` trägt die Stammdaten aller 36 für die
Kreisdarstellung, `TeamAgent` bleibt das vollständige Dossier der sieben
Kernteammitglieder. Für die übrigen 29 werden bewusst keine Aufgaben, Kanäle
oder Tätigkeiten erfunden; ein Klick zeigt nur eine Vorschau aus dem Verzeichnis.

ABWEICHUNG VOM AUFTRAG
Der Auftrag nennt als Kernteam Ferdi, Sami, Leo, Bruno, Reto, Milo, Nora.
Referenzgrafik und Konzeptdaten sagen übereinstimmend Ferdi, Sina, Lea, Bruno,
Reto, Livia, Nora — dort tragen genau diese sieben `kern: true` und `lvl: 1`.
Da der Auftrag die Grafik selbst als verbindlich erklärt und Namen laut §10 kein
Primärschlüssel sind, folge ich Grafik und Daten. Sami, Leo und Milo kommen in
keiner Quelle vor.

OBERFLÄCHE
- Teamübersicht: Beschreibungstext und «Demo zurücksetzen» entfernt, Kreis mit
  kumulativen Stufenreitern direkt unter dem Titel
- Personalverwaltung: Umschaltung Dossieransicht / Organigramm über ?view=,
  Organigramm als oben abgeschnittener Halbkreis mit drehbarem Agentenrad
  (Ziehen, Touch, Pfeiltasten, Klick), aktiv ist wer unten mittig einrastet
- Dossier: Untermenü direkt unter den Stammdaten, «Agentenbeschrieb» als erster
  Reiter, Zuständigkeiten auf drei gekürzt
- Kanäle & Systeme: vier Karten pro Zeile, Kategorie-Sinnbild links vor dem
  Titel, Porträtgruppe statt Namensliste, ein Regler statt Verbinden/Trennen
- Bearbeitungsverlauf: neutrale Karten, Farbe nur noch bei Ausnahmepriorität und
  abweichendem Status, mehr Weissraum

Markenlogos gibt es im Repo nicht und wurden nicht nachgezeichnet — je Kategorie
steht ein neutrales Sinnbild. Fehlendes Asset, bewusst so gelöst.

Positionsdaten stehen ausschliesslich in `agentRingConfig.ts`; Vollkreis und
Halbkreis teilen sich dieselbe Geometrie.

AUFGERÄUMT
`AgentCard` ist durch die Kreisdarstellung ersetzt und entfernt, seine Zusagen
sind in `TeamOverviewRing.test.tsx` übergegangen — inklusive der wichtigsten:
ausserhalb des Kernteams führt kein Klick in ein Dossier.

Zwei echte Fehler unterwegs behoben: jsdom kennt keinen `ResizeObserver` (jetzt
mit Rückfallebene statt Absturz), und das Rad las eine Ref im Render.

413 Tests grün, Build grün, ESLint über den Property-On-Code sauber,
check:tokens unverändert bei 2249.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:42:04 +02:00
Benjamin Sutter 576733d7da feat(team): Porträts der digitalen Mitarbeiter aus dem Konzept übernehmen
Bisher zeigten die Avatare Initialen auf einer Tonfarbe. Ich hatte das bewusst
so gebaut, weil erfundene Gesichter in einer Kundendemo irreführend wären — das
Konzeptdokument «Agenten_Bewirtschaftung_v2.html» trifft diese Entscheidung
jedoch bereits: dort hängt an jedem der 36 Agenten ein Porträt als eingebettetes
JPEG. Die sieben Kernagenten sind daraus extrahiert.

- src/assets/team/*.jpg — sieben Porträts, je rund 6 KB, Zuordnung über die
  Mailadresse aus dem Konzept, die exakt unseren Agenten-IDs entspricht
- agentPhotos.ts hält die Zuordnung in der Darstellungsschicht. Ein Domain-Typ,
  der Binärdateien importiert, wäre an den Bundler gekoppelt und weder im Test
  noch später gegen ein echtes Backend sauber verwendbar.
- Fehlt zu einer ID ein Bild, zeigt der Avatar weiterhin die Initialen; dasselbe
  greift, wenn das Laden fehlschlägt

Der zugängliche Name hängt weiterhin an genau einem Element, nie an zweien: mit
Porträt am erzeugten <img> über `alt`, ohne Porträt am Container über role="img"
und aria-label.

vite-env.d.ts ergänzt — die Referenz auf vite/client fehlte, ohne sie kennt
TypeScript die Modul-Deklarationen für Assets nicht.

415 Tests grün, Build grün, die sieben JPEGs werden korrekt als Assets emittiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:23:03 +02:00
Benjamin Sutter a7e05fb7f0 test(team): Render-Harness, 151 neue Tests und README
Vorher gab es im Repo kein Muster für Komponententests — @testing-library/react
wurde ausschliesslich für renderHook verwendet. test/teamTestUtils.tsx stellt den
fehlenden Kontext bereit (Router, MUI-Theme, frischer QueryClient je Test).

test/setup.ts registriert jetzt afterEach(cleanup). vitest.config.ts setzt
`globals` nicht, deshalb erkennt Testing Library kein globales afterEach und
richtet sein automatisches Cleanup nie ein; ohne diese Zeile stapeln sich
gerenderte Komponenten und getByRole scheitert ab dem zweiten Test einer Datei.

Abgedeckt: Kennzahlenberechnung, Freigabe-, Zurückweisungs-, Anpassungs- und
Rückfrageprozess, lokale Persistenz samt Schemaversion und Demo-Reset,
Badge-Familie, Vorgangs- und Agentenkarte, Aufgaben-Aktivierung,
Einstellungsvalidierung und die Position des Navigationseintrags.

Die Tests haben fünf echte Mängel aufgedeckt, alle in diesem Commit behoben:
- AgentAvatar hatte keinen zugänglichen Namen. MUI reicht `alt` nur an den
  img-Slot weiter, und ohne `src` rendert Avatar gar kein <img> — der Wert landete
  nirgends im DOM. Jetzt role="img" + aria-label direkt am Element.
- WorkItemCard verschluckte objectLabel, wenn keine objectId gesetzt war. Genau
  dort steht bei zuordnungsfreien Vorgängen die entscheidende Einordnung.
- aria-current rendert nicht mehr "false", sondern entfällt.
- AgentChannelBadge war auf `string` statt AgentChannelType typisiert.

414 Tests grün (vorher 263), Build grün, ESLint über den Property-On-Code sauber,
check:tokens unverändert bei 2249.

README ersetzt das unveränderte Vite-Template durch Startanleitung und die
Architekturentscheidungen samt bekannter Altlasten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:56:20 +02:00
Benjamin Sutter 78e2dab655 feat(team): Oberfläche der Teamübersicht
Vier Seiten, eingebettet in die bestehende App-Shell — keine zweite Sidebar,
keine zweite Kopfzeile, kein eigenes Property-On-Branding:

- Teamübersicht: Auswertung (drei Kennzahlen, gemeinsam filterbar nach Zeitraum,
  Bereich und Mitarbeiter), Kernteam, Kanäle & Systeme — in dieser Reihenfolge
- Bearbeitungsverlauf: zwei Reiter, neun Filterdimensionen, Detail-Drawer mit
  Ergebnis, Eingabedaten, Verarbeitungsschritten, Fundstellen, Nachrichtenverlauf
  und Aktionshistorie; Freigeben, Anpassen, Zurückweisen, Rückfrage beantworten
- Personalverwaltung: dreigeteilt (Hauptnavigation, Agentenliste, Dossier) mit
  fünf Reitern — Aufgaben, Kanäle, Systeme, Einstellungen, Protokoll
- Kanäle & Systeme: neun Verbindungen, mehrstufiger Assistent, ERP-Auswahl,
  deterministischer Verbindungstest

Navigation: NavItem um `children` erweitert, "Teamübersicht" steht exakt zwischen
"Meine Objekte" und "Reminder Manager", die drei Subreiter bleiben eingerückt
sichtbar, solange man im Funktionsbereich ist.

Subreiter sind echte Routen, keine lokalen Tabs. Die App kannte bisher keine
Feature-Route mit eigenem Outlet und baut Tabs sonst über lokalen useState — hier
trug das nicht: die Subreiter müssen in der Sidebar stehen und deep-linkbar sein.
Die Routen sind flache Geschwister, kein Outlet-Baum.

Freigabepflichtige Aktionen laufen über einen Bestätigungsdialog mit sichtbarer
Konsequenz; die 1-Klick-Bestätigung ist die fachlich vorgesehene Ausnahme. Jeder
Status trägt neben der Farbe immer einen Text.

Null rohe Hex-Werte: check:tokens bleibt unverändert bei 2249. Der in der
Spezifikation genannte dunkelblaue Banner "Heute geleistet …" existiert in dieser
Codebasis nicht — er wird deshalb auch nicht erzeugt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:56:00 +02:00
Benjamin Sutter 52dc7de25e 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>
2026-08-03 16:55:40 +02:00
Benjamin Sutter 36ce548169 fix: 16 vorbestehende Typfehler beheben, Typecheck wieder grün
`npx tsc -b` brach mit 16 Fehlern ab. Neue Fehler wären darin untergegangen.

Echte Defekte:
- LatentInquiriesTab: OwnPropertyMatchList wurde verwendet, aber nie importiert
  — der Zweig "Eigene Objekte" auf Mobil hätte zur Laufzeit geworfen
- unitService: throwServiceError nimmt ein Argument, nicht zwei
- SavedNeedDetail: MUI v9 kennt kein `inputProps` mehr, ersetzt durch slotProps
- Review{Status,Priority}Badge: `showBorder` existiert an GenericBadge nicht;
  die Default-Variante "outlined" zeichnet den Rand ohnehin, Darstellung unverändert
- UnifiedResultFeed: die Ternärkette verglich theme.text gegen zwei Werte, die
  SCORE_THEME seit einer Palettenumstellung nicht mehr führt — sie lief immer in
  denselben Zweig. Auf diesen einen Wert zusammengezogen, Darstellung unverändert,
  nebenbei zwei rohe Hex-Werte weniger

Der Rest waren ungenutzte Importe und Bindings (noUnusedLocals).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:55:19 +02:00
Benjamin Sutter 3849b78f83 chore: Alt-Worktree aus Git entfernen und ESLint entsperren
.claude/worktrees/agent-a82a3716/ lag mit 369 getrackten Dateien im Repo — eine
vollständige, veraltete Kopie von src/. Zwei Folgen: `eslint .` brach mit 965
fatalen Parse-Errors ab, weil der Worktree eine eigene tsconfig mitbringt und
tsconfigRootDir mehrdeutig wurde. Und jeder repo-weite Glob traf Duplikate, was
die reale Gefahr barg, Änderungen in der Kopie statt im Original zu machen.

- git rm -r --cached auf das Verzeichnis, Dateien auf der Platte bleiben
- .claude/worktrees/ in .gitignore
- .claude/** in die ESLint-Ignoreliste; Untracking allein genügt ESLint nicht,
  es liest die Platte, nicht den Index

ESLint liefert damit statt 965 Parse-Errors wieder echte Befunde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:55:04 +02:00
Benjamin Sutter 362514681a feat(demand): Ergebnis-Grid — Spalten-Umschalter, sauberere Karten, mehr Abstand
- 3/5/10-Spalten-Umschalter im Ergebnis-Header (nur Grid-Ansicht, in localStorage gemerkt) analog zu "Meine Objekte"
- Karten-Bild auf aspectRatio 15/8 statt fester Höhe → keine Verzerrung bei beliebiger Spaltenzahl
- Orts-Overlay vom Bild entfernt → Match-Score überdeckt das Ort-Label nicht mehr
- Sidebar bleibt bis < lg (1200px) ausgeklappt → Navigation + Account auf Laptops sichtbar
- Größere vertikale Abstände im Ergebnis-Feed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:56:35 +02:00
Benjamin Sutter 07cea21143 feat(supply): Anfragencenter — embedded property cards in public need detail
Add EmbeddedPropertyCard and surface matching properties inside the
public need detail view. Add needToParsedCriteria() mapper to convert a
stored Need back into parsed criteria for re-running search/matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:03:36 +02:00
Benjamin Sutter be5523bcee fix(supply): Anfragencenter parse error — stray quote in JSX attribute string
The "Gesendet" empty-state description embedded an ASCII double-quote inside a German „…" pair, which Vite/oxc parsed as the end of the attribute string (PARSE_ERROR). Reworded without embedded quotes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:36:39 +02:00
Benjamin Sutter a8b54af0b8 feat(messages): unified conversation inbox — connect demand ↔ supply, offers reach the seeker
Phase 1 of the messaging rework: one conversation/thread model (Inquiry) is the single source of truth, read perspectivally by both sides.

- domain/inquiry: add kind (INQUIRY|OFFER), tenantOrgId (demand org), offeredPropertyIds
- provider/service: InquiryFilters gains tenantOrgId+kind; new createInquiry (demand→supply) & createOffer (supply→demand) materialize threads; addMessage bumps unread; reply carries senderType (tenant vs supply_user)
- hooks: useCreateInquiry, useCreateOffer; reply payload carries sender perspective
- demand: InquiryQuickDialog → createInquiry via provider (session-based tenant/org, no hardcode); Anfragen.tsx reads via React Query (tenantOrgId) with Gesendet/Erhalten tabs, replies via provider, shows offered properties for OFFER threads; inquiryStore reduced to dialog-only (removes Zustand server-data island)
- supply: OfferChatComposer send now materializes an OFFER conversation into the seeker's inbox (routed via latentNeed.tenantOrgId); Anfragencenter gains a "Gesendet" tab; ActiveInquiriesTab filters by perspective (org + kind)
- seed: merge demand inquiries with tenantOrgId=org-mobimo + 2 OFFER seeds; provider seeds from both sets

Closes the loop: a sent inquiry reaches the Verwaltung and a sent offer reaches the Nachfrager — both answerable in one thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:32:13 +02:00
Benjamin Sutter 6206447dae fix(demand): new search profile inherits creator's organizationId
A newly created need (from "Jetzt suchen") had no organizationId, so it fell out of the org-filtered need list — the results "Aktive Suche" banner then showed a different (first saved) profile while the result cards already reflected the new search. needService.create now injects the current user's org, so banner and results stay consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:57:14 +02:00
Benjamin Sutter 8cb6f21581 fix(matching): exclude external scraped listings from demand results
External third-party scrapes (Homegate/ImmoScout/newhome/MatchOffice) were mis-tagged as VERIFIED_PORTFOLIO in mock data and surfaced as "own platform" matches. Skip them in match generation — demand results now only contain genuine platform objects, Maison Work, and Future Availability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:51:16 +02:00
Benjamin Sutter 29be4f6e54 refactor(supply): pre-market unit config inline — attributes first, then price (no double price)
- Released pre-market unit now shows a single always-visible config: fit-out/parking/cost-bearer (auto-save) THEN the KI price recommendation with explicit deviation vs. current price
- Removes the duplicate price (base price no longer shown here; only the pre-market expected price is set), and removes the edit-only pencil for released units
- Not-yet-released units keep the compact read strip + pencil editor

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:42:51 +02:00
Benjamin Sutter 09095a1a32 fix(ai): realistic pre-market rent recommendation — annual units + anchor on current price
- getMarketRent now returns CHF/m²/YEAR (stored medians are monthly → ×12); fixes annual-vs-monthly mismatch that also affected MarketPricePanel and NegotiationInsightsPanel
- recommendPreMarketRent: anchor on the unit's current rent and adjust within a bounded ±15% by market momentum (vacancy=supply, demand strength, days-on-market, trend) instead of jumping to the city-wide (prime-skewed) median — no more "double the price" suggestions; median only used as a headroom check
- tests updated to annual market values

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:39:37 +02:00
Benjamin Sutter fb029cf0bc feat(ai): AI pre-market rent recommendation from regional comparables, supply & demand
- IAIService.recommendPreMarketRent: recommended price + range, verdict (UNDERPRICED/FAIR/AMBITIOUS), drivers, rationale, confidence
- MockAIService: deterministic recommendation from locationIntelligence — regional comp median, vacancy (supply), demand strength + days-on-market, rent trend (forward for pre-market)
- BackendAIService: LLM prompt with market context + mock fallback
- usePreMarketRentRecommendation hook; PreMarketPriceAdvisor component shows the recommendation per released unit with verdict ("zu günstig" when underpriced) + adjustable expected price + "Empfehlung übernehmen"
- Replaces the simple indexed suggestion with a market-driven AI recommendation that flags underpricing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:22:14 +02:00
Benjamin Sutter 3e1e945661 fix(supply): unit edits now persist + regroup editor + expected price as pre-market KI view
- MockupUnitProvider.update: immutable update (new unit/units/property refs) so React Query detects the change and re-renders — fixes "parking/price edit doesn't take effect"
- UnitFieldsEditor: group Ausbaustandard + Wer-baut-aus + MAB into one "Ausbau" section; conditions (price/availability/parking) on top; removed the plain expected-price field
- PreMarketUnitGrid: expected price is now a KI recommendation view (indexed suggestion + rationale) shown only for released units, with an adjustable override + "Vorschlag übernehmen"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:14:27 +02:00
Benjamin Sutter 74f9660581 refactor(supply): per-unit details always visible + shared editor in unit list AND pre-market
- Extract UnitFieldsEditor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation, expected price) — single source for per-unit editing
- UnitStructurePanel: always-visible per-unit details strip (fit-out, cost-bearer, parking, expected price) in read mode; pencil opens the shared editor
- PreMarketUnitGrid: per-unit details shown + same editor reachable per unit (pencil) so fit-out/price/parking are adjustable right in the release flow
- Removes the previous edit-only / fragmented per-unit fields

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:03:50 +02:00
Benjamin Sutter fca113e7ab feat(demand): market rent estimate (asking vs. fair) + pre-market expected price
- lib/rentEstimate: deterministic market estimate from Standort-Intelligence (median rent benchmark + vacancy/trend) → fair rent, verdict BELOW/AT/ABOVE, deltaPct, rationale; suggestFutureRent indexes today's price by city rent trend
- MarketPricePanel in MatchDetail: asking vs. fair market rent + verdict badge + rationale; shows pre-market expected price (heute → erwartet)
- unit.expectedRentPerSqm: future pre-market price; scorer prefers it over current rent for FUTURE_AVAILABILITY
- UnitStructurePanel editor: expected-price field (with indexed suggestion) shown for pre-market-released units
- tests: rentEstimate verdict thresholds + future-rent indexing

Note: rent estimate is a deterministic market-data calc (not an LLM) — honest & testable; can be routed through IAIService later if a real model is wanted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:53:00 +02:00
Benjamin Sutter fd6ed105bb feat(supply): per-unit fit-out/cost-bearer/MAB/parking with object fallback (resolveUnitFacts)
- unit: add fitOutByLandlord, mieterausbaubeitragPerSqm, parkingSpots (parking = allocation from object pool)
- lib/unitFacts.resolveUnitFacts: single resolver (unit value ?? object value) used by scorer + card adapter
- matchSyncService: pre-market unit scored via resolved facts (fit-out, MAB, cost-bearer, parking allocation)
- matchCardAdapter: fit-out label/viability/investment via resolveUnitFacts
- UnitStructurePanel: consolidate ALL per-unit overrides into the one expandable row editor (price, availability, Ausbaustandard, Wer-baut-aus, MAB, parking allocation); show parking on row
- PreMarketUnitGrid/Panel: drop the duplicate per-unit fit-out select (now in the unit editor)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:44:56 +02:00
Benjamin Sutter 647493bf3c feat(supply): per-unit price/availability editor + mandatory fit-out cost-bearer for shell/edelrohbau
- unit.availableFrom: explicit per-unit availability date
- UnitStructurePanel: inline expandable editor (pencil) for per-unit price + availability via useUpdateUnit
- fitOutByLandlord now boolean|undefined ("not chosen"); toggle has no default for SHELL/BASIC
- NewListing: validate() blocks save until cost-bearer chosen for Rohbau/Edelrohbau
- PropertyDetailView.saveEdit: same guard for existing listings; required asterisk + hint in both forms
- newListingMapper persists explicit tenant(false) choice (was collapsed to undefined)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:26:45 +02:00
Benjamin Sutter 5d26dd4a6c feat(supply): unit-centric fit-out & overview — per-unit fit-out, editable demand budget, multi-unit price/availability
- domain/unit: optional per-unit fitOut override (falls back to property.hardFacts.fitOut)
- matchSyncService: pre-market unit scored with its own fitOut; matchCardAdapter prefers unit fitOut
- PreMarketUnitGrid/PreMarketPanel: per-unit Ausbaustandard override in the release list (default "Wie Objekt")
- PropertyDetailOverview: edit/read "Wer baut aus?" for existing listings (SHELL/BASIC), central FIT_OUT_LABELS
- Phase 1 unit-centric overview: header shows area+count, price as range, "Verfügbar ab → pro Einheit"; unit table shows per-unit price + availability; "Objekt & Lage" labels marked as object-defaults for multi-unit
- NeedExtendedRequirements: "Eigenes Ausbaubudget" only shown when min fit-out set + clearer helper text
- UnitStructurePanel: spin-off prefill carries fitOutByLandlord

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:06:31 +02:00
573 changed files with 18893 additions and 37304 deletions
@@ -1,28 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Windows system files
desktop.ini
Thumbs.db
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -1,80 +0,0 @@
# property-match — Development Guidelines
## Stack
- **Vite 8** + **React 19** + **TypeScript 6**
- **MUI v9** (`@mui/material`) — primary component library
- **Tailwind CSS v4** — utility classes via `@tailwindcss/vite` (no `tailwind.config.js`)
- **React Router v7** — import from `react-router`, not `react-router-dom`
## Components
Always reach for an existing MUI component before writing a custom one. Check the [MUI component list](https://mui.com/material-ui/all-components/) first. Only build a custom component when MUI has no equivalent or the required behavior diverges significantly from what MUI provides.
## Styling
Use Tailwind utility classes for all layout and styling. Do not write plain CSS rules or add styles to `.css` files. The only CSS file is `src/index.css`, which holds the Tailwind layer imports — do not add project styles there.
## Providers
All data access and data actions live in `src/provider/`.
### Naming
| Rule | Example |
|------|---------|
| Every provider file/class is suffixed `Provider` | `PropertyProvider`, `UserProvider` |
| Every provider backed by mock data is also prefixed `Mockup` | `MockupPropertyProvider`, `MockupUserProvider` |
### Interface pattern
Define a TypeScript interface for each provider so the mockup and the real implementation are interchangeable:
Every Interface should be prefixed with a capitalized I.
```ts
// src/provider/IPropertyProvider.ts
export interface PropertyProvider {
getAll(): Promise<Property[]>
getById(id: string): Promise<Property | null>
create(data: CreatePropertyInput): Promise<Property>
update(id: string, data: UpdatePropertyInput): Promise<Property>
remove(id: string): Promise<void>
}
```
### Async methods
Every method in a provider must be `async` and return a `Promise`, even in the mockup. This ensures the real provider can be swapped in without changing any call sites.
```ts
// src/provider/MockupPropertyProvider.ts
import type { PropertyProvider } from './PropertyProvider'
const properties: Property[] = [ /* seed data */ ]
export const MockupPropertyProvider: PropertyProvider = {
async getAll() {
return [...properties]
},
async getById(id) {
return properties.find(p => p.id === id) ?? null
},
async create(data) {
const next: Property = { id: crypto.randomUUID(), ...data }
properties.push(next)
return next
},
async update(id, data) {
const idx = properties.findIndex(p => p.id === id)
properties[idx] = { ...properties[idx], ...data }
return properties[idx]
},
async remove(id) {
const idx = properties.findIndex(p => p.id === id)
properties.splice(idx, 1)
},
}
```
Swap to a real implementation by replacing `MockupPropertyProvider` with a provider that calls an API — no other code changes required.
@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -1,22 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Property Match</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,41 +0,0 @@
{
"name": "property-match",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-query": "^5.75.2",
"lucide-react": "^0.511.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router": "^7.15.0",
"zod": "^3.25.17",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
@@ -1,94 +0,0 @@
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router'
import { LoadingPage, AppErrorBoundary } from './components/ui'
import { AppShell } from './components/layout'
import { ProtectedRoute } from './components/auth'
import { WorkspaceType } from './domain/enums'
import { useSessionStore } from './stores/sessionStore'
const WORKSPACE_HOME: Record<string, string> = {
[WorkspaceType.SUPPLY]: '/supply/dashboard',
[WorkspaceType.DEMAND]: '/demand/ai-search',
[WorkspaceType.OPERATIONS]: '/ops/review-queue',
}
function RoleRedirect() {
const { currentUser } = useSessionStore()
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
}
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
const Properties = lazy(() => import('./pages/supply/Properties'))
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const AISearch = lazy(() => import('./pages/demand/AISearch'))
const Results = lazy(() => import('./pages/demand/Results'))
const MatchDetail = lazy(() => import('./pages/demand/MatchDetail'))
const Compare = lazy(() => import('./pages/demand/Compare'))
const Shortlists = lazy(() => import('./pages/demand/Shortlists'))
const ReviewQueue = lazy(() => import('./pages/ops/ReviewQueue'))
const AIMonitoring = lazy(() => import('./pages/ops/AIMonitoring'))
const Governance = lazy(() => import('./pages/ops/Governance'))
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
const SourceMonitoring = lazy(() => import('./pages/ops/SourceMonitoring'))
const ActivityTimeline = lazy(() => import('./pages/ops/ActivityTimeline'))
const SignalPipeline = lazy(() => import('./pages/ops/SignalPipeline'))
function App() {
return (
<AppErrorBoundary>
<Suspense fallback={<LoadingPage />}>
<Routes>
{/* Public */}
<Route path="/auth/login" element={<LoginScreen />} />
{/* Protected: auth check only */}
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/" element={<RoleRedirect />} />
{/* Supply Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
<Route path="/supply/properties" element={<Properties />} />
<Route path="/supply/match-center" element={<MatchCenter />} />
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
</Route>
{/* Demand Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.DEMAND} />}>
<Route path="/demand/ai-search" element={<AISearch />} />
<Route path="/demand/results" element={<Results />} />
<Route path="/demand/results/:matchId" element={<MatchDetail />} />
<Route path="/demand/compare" element={<Compare />} />
<Route path="/demand/shortlists" element={<Shortlists />} />
</Route>
{/* Operations Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.OPERATIONS} />}>
<Route path="/ops/review-queue" element={<ReviewQueue />} />
<Route path="/ops/ai-monitoring" element={<AIMonitoring />} />
<Route path="/ops/governance" element={<Governance />} />
<Route path="/ops/market-intelligence" element={<MarketIntelligence />} />
<Route path="/ops/source-monitoring" element={<SourceMonitoring />} />
<Route path="/ops/activity-timeline" element={<ActivityTimeline />} />
<Route path="/ops/signal-pipeline" element={<SignalPipeline />} />
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</AppErrorBoundary>
)
}
export default App
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,23 +0,0 @@
import { Chip, Tooltip } from '@mui/material'
import type { AIOutputError } from '../../domain/aiOutput'
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
SCHEMA_VALIDATION: { label: 'Schema', color: '#ea580c' },
PROVIDER_TIMEOUT: { label: 'Timeout', color: '#c0392b' },
INVALID_JSON: { label: 'JSON', color: '#c0392b' },
EMPTY_RESPONSE: { label: 'Leer', color: '#d97706' },
RATE_LIMIT: { label: 'Rate Limit', color: '#7c3aed' },
}
export function AIErrorBadge({ error }: { error: AIOutputError }) {
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
return (
<Tooltip title={error.message} arrow>
<Chip
size="small"
label={label}
sx={{ bgcolor: `${color}18`, color, fontWeight: 700, fontSize: '0.65rem', cursor: 'default' }}
/>
</Tooltip>
)
}
@@ -1,38 +0,0 @@
import { Box, Typography } from '@mui/material'
import { Bot, Filter, MousePointer } from 'lucide-react'
interface Props {
context: 'no-outputs' | 'filtered-empty' | 'no-selection'
}
const CONFIG = {
'no-outputs': {
icon: Bot,
color: '#94a3b8',
title: 'Keine AI-Outputs',
desc: 'Es wurden noch keine AI-Outputs generiert.',
},
'filtered-empty': {
icon: Filter,
color: '#94a3b8',
title: 'Keine Ergebnisse',
desc: 'Kein AI-Output entspricht den aktiven Filtern.',
},
'no-selection': {
icon: MousePointer,
color: '#94a3b8',
title: 'Output auswählen',
desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.',
},
}
export function AIMonitoringEmptyState({ context }: Props) {
const { icon: Icon, color, title, desc } = CONFIG[context]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200, p: 4, textAlign: 'center' }}>
<Icon size={36} color={color} style={{ marginBottom: 12, opacity: 0.6 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#1e293b' }}>{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 260 }}>{desc}</Typography>
</Box>
)
}
@@ -1,51 +0,0 @@
import { Box, Typography } from '@mui/material'
import type { AIOutput } from '../../domain/aiOutput'
interface Props {
outputs: AIOutput[]
}
function MetricCell({ label, value, color }: { label: string; value: string | number; color?: string }) {
return (
<Box sx={{ px: 2, py: 1.25, borderRight: '1px solid #e2e8f0', '&:last-child': { borderRight: 'none' }, minWidth: 0, flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', whiteSpace: 'nowrap', mb: 0.25 }}>
{label}
</Typography>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, fontSize: '0.9375rem', color: color ?? '#1e293b', lineHeight: 1.2 }}
>
{value}
</Typography>
</Box>
)
}
function mostCommon(arr: string[]): string {
if (!arr.length) return ''
const freq = arr.reduce<Record<string, number>>((acc, v) => ({ ...acc, [v]: (acc[v] ?? 0) + 1 }), {})
return Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]
}
export function AIMonitoringMetrics({ outputs }: Props) {
const total = outputs.length
const failed = outputs.filter(o => !!o.error).length
const needsReview = outputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').length
const approved = outputs.filter(o => o.reviewStatus === 'APPROVED').length
const approvalRate = total > 0 ? Math.round((approved / total) * 100) : 0
const topPrompt = mostCommon(outputs.map(o => o.promptVersion))
const latestModel = outputs.length > 0
? outputs.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0].model
: ''
return (
<Box sx={{ display: 'flex', bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<MetricCell label="AI-Outputs total" value={total} />
<MetricCell label="Fehlgeschlagen" value={failed} color={failed > 0 ? '#c0392b' : undefined} />
<MetricCell label="Prüfung ausstehend" value={needsReview} color={needsReview > 0 ? '#d97706' : undefined} />
<MetricCell label="Genehmigungsrate" value={`${approvalRate}%`} color={approvalRate >= 70 ? '#1a7a4a' : '#d97706'} />
<MetricCell label="Häufigste Version" value={topPrompt} />
<MetricCell label="Aktives Modell" value={latestModel.replace('claude-3-5-sonnet-20241022', 'sonnet-3.5').replace('claude-3-haiku-20240307', 'haiku-3').replace('claude-3-opus-20240229', 'opus-3')} />
</Box>
)
}
@@ -1,191 +0,0 @@
import { Alert, Box, Divider, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
import { PromptVersionBadge } from './PromptVersionBadge'
import { AIErrorBadge } from './AIErrorBadge'
import { AIReviewActionToolbar } from './AIReviewActionToolbar'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
const TYPE_LABELS: Record<AIOutputType, string> = {
NEED_PARSE: 'Bedarf-Parsing',
FOLLOW_UP_QUESTIONS: 'Rückfragen',
MATCH_EXPLANATION: 'Match-Begründung',
COMPARE_SUMMARY: 'Vergleich',
DECISION_BRIEF: 'Entscheidungs-Brief',
DATA_QUALITY_SUMMARY: 'Datenqualität',
}
const ERROR_TYPE_LABELS: Record<string, string> = {
SCHEMA_VALIDATION: 'Schema-Validierungsfehler',
PROVIDER_TIMEOUT: 'Provider-Timeout',
INVALID_JSON: 'Ungültiges JSON',
EMPTY_RESPONSE: 'Leere Antwort',
RATE_LIMIT: 'Rate-Limit erreicht',
}
const MODEL_LABELS: Record<string, string> = {
'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
'claude-3-haiku-20240307': 'Claude 3 Haiku',
'claude-3-opus-20240229': 'Claude 3 Opus',
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
NEED: 'Gesuch', MATCH: 'Match', PROPERTY: 'Objekt', SIGNAL: 'Signal',
}
interface Props {
output: AIOutput
onClose: () => void
onUpdateStatus: (status: ReviewStatus) => void
isSubmitting?: boolean
}
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Box sx={{ display: 'flex', gap: 1, mb: 0.625, alignItems: 'flex-start' }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 116, flexShrink: 0, pt: 0.125 }}>
{label}
</Typography>
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
</Box>
)
}
export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitting }: Props) {
const handleCopyJson = () => {
navigator.clipboard.writeText(output.outputPreview).catch(() => {})
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 0.5, alignItems: 'center' }}>
<AIOutputStatusBadge status={output.reviewStatus} />
{output.error && <AIErrorBadge error={output.error} />}
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem' }}>
{TYPE_LABELS[output.type] ?? output.type}
</Typography>
</Box>
<IconButton size="small" onClick={onClose} sx={{ flexShrink: 0, mt: -0.25 }}>
<X size={16} />
</IconButton>
</Box>
</Box>
{/* Scrollable body */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1.5 }}>
{/* Metadata */}
<MetaRow label="Output-ID">
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569' }}>
{output.id}
</Typography>
</MetaRow>
<MetaRow label="Erstellt">
<Typography variant="caption" sx={{ color: '#334155' }}>
{new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
</Typography>
</MetaRow>
<MetaRow label="Modell">
<Typography variant="caption" sx={{ fontWeight: 600, color: '#334155' }}>
{MODEL_LABELS[output.model] ?? output.model}
</Typography>
</MetaRow>
<MetaRow label="Provider">
<Typography variant="caption" sx={{ color: '#334155', textTransform: 'capitalize' }}>
{output.provider}
</Typography>
</MetaRow>
<MetaRow label="Prompt-Version">
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
</MetaRow>
<MetaRow label="Input-Hash">
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
{output.inputHash}
</Typography>
</MetaRow>
<MetaRow label="Bezug">
<Typography variant="caption" sx={{ color: '#334155' }}>
{ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '}
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
{output.relatedEntityId}
</span>
</Typography>
</MetaRow>
{output.latencyMs != null && (
<MetaRow label="Latenz">
<Typography variant="caption" sx={{ color: output.latencyMs > 5000 ? '#c0392b' : '#334155', fontWeight: output.latencyMs > 5000 ? 700 : 400 }}>
{(output.latencyMs / 1000).toFixed(2)}s
</Typography>
</MetaRow>
)}
{output.costEstimate != null && (
<MetaRow label="Kostenschätzung">
<Typography variant="caption" sx={{ color: '#334155' }}>
${output.costEstimate.toFixed(4)}
</Typography>
</MetaRow>
)}
<Divider sx={{ my: 1.5 }} />
{/* Error details */}
{output.error && (
<Box sx={{ mb: 1.5 }}>
<Alert
severity="error"
sx={{ '& .MuiAlert-message': { fontSize: '0.8rem' }, mb: 1 }}
>
<strong>{ERROR_TYPE_LABELS[output.error.type] ?? output.error.type}</strong>
<br />
{output.error.message}
{output.error.recoverable && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: '#92400e' }}>
Wiederholbar kann erneut ausgelöst werden.
</Typography>
)}
</Alert>
</Box>
)}
{/* Output preview */}
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#64748b', display: 'block', mb: 0.5 }}>
Output-Vorschau
</Typography>
<Box
sx={{
p: 1.25,
bgcolor: '#f8fafc',
borderRadius: 1,
border: '1px solid #e2e8f0',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: '#334155',
lineHeight: 1.6,
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{output.outputPreview || '(kein Output)'}
</Box>
</Box>
<Divider sx={{ mb: 1.5 }} />
{/* Actions */}
<AIReviewActionToolbar
output={output}
onUpdateStatus={onUpdateStatus}
onCopyJson={handleCopyJson}
isSubmitting={isSubmitting}
/>
</Box>
</Box>
)
}
@@ -1,21 +0,0 @@
import { Chip } from '@mui/material'
import type { ReviewStatus } from '../../domain/enums'
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
FLAGGED: { label: 'Markiert', color: '#ea580c' },
}
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
return (
<Chip
size="small"
label={label}
sx={{ bgcolor: `${color}18`, color, fontWeight: 600, fontSize: '0.7rem' }}
/>
)
}
@@ -1,137 +0,0 @@
import {
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@mui/material'
import { AIOutputStatusBadge } from './AIOutputStatusBadge'
import { PromptVersionBadge } from './PromptVersionBadge'
import { AIErrorBadge } from './AIErrorBadge'
import { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
const TYPE_LABELS: Record<AIOutputType, string> = {
NEED_PARSE: 'Bedarf-Parsing',
FOLLOW_UP_QUESTIONS: 'Rückfragen',
MATCH_EXPLANATION: 'Match-Begründung',
COMPARE_SUMMARY: 'Vergleich',
DECISION_BRIEF: 'Entscheidungs-Brief',
DATA_QUALITY_SUMMARY: 'Datenqualität',
}
const TYPE_COLORS: Record<AIOutputType, string> = {
NEED_PARSE: '#1e3a5f',
FOLLOW_UP_QUESTIONS: '#0891b2',
MATCH_EXPLANATION: '#4f46e5',
COMPARE_SUMMARY: '#1a7a4a',
DECISION_BRIEF: '#7c3aed',
DATA_QUALITY_SUMMARY: '#d97706',
}
const MODEL_SHORT: Record<string, string> = {
'claude-3-5-sonnet-20241022': 'Sonnet 3.5',
'claude-3-haiku-20240307': 'Haiku 3',
'claude-3-opus-20240229': 'Opus 3',
}
function shortTime(iso: string) {
return new Date(iso).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })
}
interface Props {
outputs: AIOutput[]
selectedId: string | null
onSelect: (output: AIOutput) => void
isEmpty: boolean
}
export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props) {
if (isEmpty && outputs.length === 0) {
return <AIMonitoringEmptyState context="no-outputs" />
}
if (outputs.length === 0) {
return <AIMonitoringEmptyState context="filtered-empty" />
}
return (
<Table size="small" stickyHeader>
<TableHead>
<TableRow sx={{ '& th': { bgcolor: '#f8fafc', fontSize: '0.7rem', fontWeight: 700, color: '#64748b', py: 0.75, textTransform: 'uppercase', letterSpacing: 0.4 } }}>
<TableCell sx={{ minWidth: 110 }}>Zeitpunkt</TableCell>
<TableCell sx={{ minWidth: 130 }}>Typ</TableCell>
<TableCell sx={{ minWidth: 100 }}>Modell</TableCell>
<TableCell sx={{ minWidth: 140 }}>Version</TableCell>
<TableCell sx={{ minWidth: 95 }}>Status</TableCell>
<TableCell sx={{ minWidth: 65 }}>Latenz</TableCell>
<TableCell sx={{ minWidth: 80 }}>Fehler</TableCell>
</TableRow>
</TableHead>
<TableBody>
{outputs.map(output => {
const isSelected = selectedId === output.id
const color = TYPE_COLORS[output.type] ?? '#64748b'
return (
<TableRow
key={output.id}
hover
onClick={() => onSelect(output)}
sx={{
cursor: 'pointer',
bgcolor: isSelected ? '#eff6ff' : undefined,
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
'& td': { py: 0.75, borderBottom: '1px solid #f1f5f9' },
}}
>
<TableCell>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{shortTime(output.createdAt)}
</Typography>
</TableCell>
<TableCell>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
px: 0.75,
py: 0.2,
borderRadius: 1,
bgcolor: `${color}12`,
}}
>
<Typography variant="caption" sx={{ color, fontWeight: 700, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{TYPE_LABELS[output.type] ?? output.type}
</Typography>
</Box>
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 500, fontSize: '0.7rem', color: '#334155' }}>
{MODEL_SHORT[output.model] ?? output.model}
</Typography>
</TableCell>
<TableCell>
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
</TableCell>
<TableCell>
<AIOutputStatusBadge status={output.reviewStatus} />
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: output.latencyMs && output.latencyMs > 5000 ? '#c0392b' : '#64748b' }}>
{output.latencyMs != null ? `${(output.latencyMs / 1000).toFixed(1)}s` : ''}
</Typography>
</TableCell>
<TableCell>
{output.error ? <AIErrorBadge error={output.error} /> : (
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.7rem' }}></Typography>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}
@@ -1,71 +0,0 @@
import { Box, Button, Tooltip } from '@mui/material'
import { CheckCircle, XCircle, Send, Copy } from 'lucide-react'
import type { AIOutput } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
interface Props {
output: AIOutput
onUpdateStatus: (status: ReviewStatus) => void
onCopyJson: () => void
isSubmitting?: boolean
}
export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSubmitting }: Props) {
const { reviewStatus } = output
const canSendToReview = reviewStatus === 'UNREVIEWED' || reviewStatus === 'FLAGGED'
const canApprove = reviewStatus === 'IN_REVIEW' || reviewStatus === 'UNREVIEWED'
const canReject = reviewStatus !== 'REJECTED'
return (
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
{canSendToReview && (
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
startIcon={<Send size={13} />}
onClick={() => onUpdateStatus('IN_REVIEW')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#d97706', borderColor: '#d97706', '&:hover': { bgcolor: '#fffbeb', borderColor: '#b45309' } }}
>
Zur Prüfung
</Button>
)}
{canApprove && (
<Button
size="small"
variant="contained"
disabled={isSubmitting}
startIcon={<CheckCircle size={13} />}
onClick={() => onUpdateStatus('APPROVED')}
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
>
Genehmigen
</Button>
)}
{canReject && (
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
startIcon={<XCircle size={13} />}
onClick={() => onUpdateStatus('REJECTED')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#c0392b', borderColor: '#c0392b', '&:hover': { bgcolor: '#fef2f2', borderColor: '#a93226' } }}
>
Ablehnen
</Button>
)}
<Tooltip title="Output-JSON kopieren">
<Button
size="small"
variant="outlined"
onClick={onCopyJson}
startIcon={<Copy size={13} />}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
>
JSON
</Button>
</Tooltip>
</Box>
)
}
@@ -1,46 +0,0 @@
import { Box, Tooltip, Typography } from '@mui/material'
interface Props {
promptVersion: string
schemaVersion?: string
}
export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
const badge = (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 0.75,
py: 0.2,
bgcolor: '#f1f5f9',
borderRadius: 1,
border: '1px solid #e2e8f0',
cursor: schemaVersion ? 'default' : undefined,
}}
>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', fontWeight: 600, lineHeight: 1.4 }}
>
{promptVersion}
</Typography>
{schemaVersion && (
<>
<Box sx={{ width: '1px', height: 10, bgcolor: '#cbd5e1', flexShrink: 0 }} />
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#94a3b8', lineHeight: 1.4 }}
>
{schemaVersion}
</Typography>
</>
)}
</Box>
)
return schemaVersion ? (
<Tooltip title={`Prompt: ${promptVersion} · Schema: ${schemaVersion}`}>{badge}</Tooltip>
) : badge
}
@@ -1,8 +0,0 @@
export { AIOutputStatusBadge } from './AIOutputStatusBadge'
export { PromptVersionBadge } from './PromptVersionBadge'
export { AIErrorBadge } from './AIErrorBadge'
export { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
export { AIMonitoringMetrics } from './AIMonitoringMetrics'
export { AIOutputTable } from './AIOutputTable'
export { AIReviewActionToolbar } from './AIReviewActionToolbar'
export { AIOutputDetailPanel } from './AIOutputDetailPanel'
@@ -1,87 +0,0 @@
import { Box, Button, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { AssistantAction } from '../../domain/assistant'
interface Props {
actions: AssistantAction[]
onExecute?: (action: AssistantAction) => void
}
const ACTION_COLORS: Record<string, string> = {
NAVIGATE: '#1e3a5f',
OPEN_REVIEW: '#7c3aed',
ADD_TO_SHORTLIST: '#1a7a4a',
REQUEST_DATA: '#d97706',
SEND_TO_REVIEW: '#ea580c',
}
export function AssistantActionCards({ actions, onExecute }: Props) {
const navigate = useNavigate()
const handleExecute = (action: AssistantAction) => {
if (action.actionType === 'NAVIGATE' && action.payload?.path) {
navigate(action.payload.path as string)
} else if (action.actionType === 'OPEN_REVIEW') {
navigate('/ops/review-queue')
}
onExecute?.(action)
}
if (!actions.length) return null
return (
<Box sx={{ px: 2, pt: 0.75, pb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.625, fontSize: '0.6rem' }}>
Vorgeschlagene Aktionen
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{actions.map(action => {
const color = ACTION_COLORS[action.actionType] ?? '#64748b'
return (
<Box
key={action.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1.25,
py: 0.875,
borderRadius: 1.5,
border: `1px solid ${color}30`,
bgcolor: `${color}08`,
}}
>
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color, fontSize: '0.75rem', display: 'block' }}>
{action.label}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{action.description}
</Typography>
</Box>
<Button
size="small"
variant="outlined"
endIcon={<ArrowRight size={12} />}
onClick={() => handleExecute(action)}
sx={{
textTransform: 'none',
fontSize: '0.7rem',
color,
borderColor: `${color}60`,
flexShrink: 0,
ml: 1,
py: 0.4,
'&:hover': { borderColor: color, bgcolor: `${color}12` },
}}
>
Ausführen
</Button>
</Box>
)
})}
</Box>
</Box>
)
}
@@ -1,79 +0,0 @@
import { Box, Chip, Typography } from '@mui/material'
import type { AssistantContext } from '../../domain/assistant'
const PAGE_LABELS: Record<string, string> = {
'/supply/dashboard': 'Übersicht',
'/supply/properties': 'Meine Objekte',
'/supply/match-center': 'Eingehende Bedarfe',
'/supply/data-quality': 'Datenpflege',
'/supply/future-availability':'Marktchancen',
'/demand/ai-search': 'Flächensuche',
'/demand/results': 'Ergebnisse',
'/demand/compare': 'Vergleich',
'/demand/shortlists': 'Shortlists',
'/ops/review-queue': 'Review Queue',
'/ops/ai-monitoring': 'AI Monitoring',
'/ops/governance': 'Governance',
}
function resolvePageLabel(route: string): string {
for (const [path, label] of Object.entries(PAGE_LABELS)) {
if (route.startsWith(path)) return label
}
return route.split('/').filter(Boolean).pop()?.replace(/-/g, ' ') ?? 'Seite'
}
const ENTITY_LABELS: Record<string, string> = {
PROPERTY: 'Objekt', NEED: 'Gesuch', MATCH: 'Match',
SIGNAL: 'Signal', AI_OUTPUT: 'AI-Output',
}
interface Props {
context: AssistantContext
}
export function AssistantContextSummary({ context }: Props) {
const pageLabel = resolvePageLabel(context.currentRoute)
return (
<Box sx={{ px: 2, py: 1, bgcolor: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
label={pageLabel}
size="small"
sx={{ bgcolor: '#e0f2fe', color: '#075985', fontWeight: 600, fontSize: '0.7rem', height: 20 }}
/>
{context.selectedEntityType && context.selectedEntityId && (
<Chip
label={`${ENTITY_LABELS[context.selectedEntityType] ?? context.selectedEntityType}: ${context.selectedEntityId}`}
size="small"
sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 500, fontSize: '0.65rem', height: 20, fontFamily: 'monospace' }}
/>
)}
{context.visibleScores?.quality !== undefined && (
<Chip
label={`Qualität: ${context.visibleScores.quality}%`}
size="small"
sx={{
bgcolor: context.visibleScores.quality >= 70 ? '#dcfce7' : '#fef3c7',
color: context.visibleScores.quality >= 70 ? '#166534' : '#92400e',
fontWeight: 600, fontSize: '0.65rem', height: 20,
}}
/>
)}
{context.visibleScores?.matchScore !== undefined && (
<Chip
label={`Match: ${context.visibleScores.matchScore}%`}
size="small"
sx={{ bgcolor: '#ede9fe', color: '#5b21b6', fontWeight: 600, fontSize: '0.65rem', height: 20 }}
/>
)}
</Box>
{context.visibleMissingData && context.visibleMissingData.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontSize: '0.65rem' }}>
Fehlende Daten: {context.visibleMissingData.slice(0, 3).join(', ')}
</Typography>
)}
</Box>
)
}
@@ -1,27 +0,0 @@
import { Alert, Box, Button } from '@mui/material'
import { RefreshCw } from 'lucide-react'
interface Props {
error: string
onRetry?: () => void
}
export function AssistantErrorState({ error, onRetry }: Props) {
return (
<Box sx={{ px: 2, py: 1 }}>
<Alert
severity="error"
sx={{ '& .MuiAlert-message': { fontSize: '0.8rem' } }}
action={
onRetry ? (
<Button size="small" onClick={onRetry} startIcon={<RefreshCw size={12} />} sx={{ textTransform: 'none', fontSize: '0.75rem' }}>
Erneut
</Button>
) : undefined
}
>
{error}
</Alert>
</Box>
)
}
@@ -1,51 +0,0 @@
import { Box, Typography } from '@mui/material'
export function AssistantLoadingState() {
return (
<Box sx={{ display: 'flex', gap: 1, px: 2, py: 1.5, alignItems: 'flex-start' }}>
<Box
sx={{
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: '#4f46e5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Typography variant="caption" sx={{ color: 'white', fontWeight: 700, fontSize: '0.625rem' }}>AI</Typography>
</Box>
<Box
sx={{
bgcolor: '#f1f5f9',
borderRadius: '0 8px 8px 8px',
px: 1.5,
py: 1,
display: 'flex',
gap: 0.5,
alignItems: 'center',
}}
>
{[0, 1, 2].map(i => (
<Box
key={i}
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: '#94a3b8',
animation: 'bounce 1.2s ease-in-out infinite',
animationDelay: `${i * 0.2}s`,
'@keyframes bounce': {
'0%, 80%, 100%': { transform: 'scale(0.8)', opacity: 0.5 },
'40%': { transform: 'scale(1.2)', opacity: 1 },
},
}}
/>
))}
</Box>
</Box>
)
}
@@ -1,91 +0,0 @@
import { Box, Typography } from '@mui/material'
import { AssistantActionCards } from './AssistantActionCards'
import type { AssistantMessage } from '../../domain/assistant'
function MessageBubble({ message }: { message: AssistantMessage }) {
const isUser = message.role === 'user'
return (
<Box sx={{ display: 'flex', flexDirection: isUser ? 'row-reverse' : 'row', gap: 1, px: 2, py: 0.75, alignItems: 'flex-start' }}>
{!isUser && (
<Box
sx={{
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, mt: 0.25,
}}
>
<Typography variant="caption" sx={{ color: 'white', fontWeight: 700, fontSize: '0.625rem' }}>AI</Typography>
</Box>
)}
<Box sx={{ maxWidth: '80%', minWidth: 0 }}>
{/* Bubble */}
<Box
sx={{
px: 1.5,
py: 1,
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
color: isUser ? 'white' : '#1e293b',
}}
>
<Typography
variant="body2"
sx={{
fontSize: '0.8125rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
color: 'inherit',
'& strong': { fontWeight: 700 },
}}
dangerouslySetInnerHTML={{
__html: message.content
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br/>'),
}}
/>
</Box>
{/* Metadata */}
{!isUser && (message.confidence !== undefined || (message.sources && message.sources.length > 0)) && (
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
{message.confidence !== undefined && (
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8' }}>
Konfidenz: {Math.round(message.confidence * 100)}%
</Typography>
)}
{message.sources?.map(s => (
<Typography key={s} variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8', bgcolor: '#f8fafc', px: 0.5, py: 0.125, borderRadius: 0.5, border: '1px solid #e2e8f0' }}>
{s}
</Typography>
))}
</Box>
)}
{/* Timestamp */}
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.6rem', color: '#cbd5e1', mt: 0.25, textAlign: isUser ? 'right' : 'left' }}>
{new Date(message.createdAt).toLocaleTimeString('de-CH', { timeStyle: 'short' })}
</Typography>
</Box>
</Box>
)
}
interface Props {
messages: AssistantMessage[]
}
export function AssistantMessageList({ messages }: Props) {
return (
<Box>
{messages.map((msg) => (
<Box key={msg.id}>
<MessageBubble message={msg} />
{msg.role === 'assistant' && msg.actions && msg.actions.length > 0 && (
<AssistantActionCards actions={msg.actions} />
)}
</Box>
))}
</Box>
)
}
@@ -1,91 +0,0 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SuggestedQuestion } from '../../domain/assistant'
interface Props {
suggestions: SuggestedQuestion[]
onSelect: (question: string) => void
disabled?: boolean
}
const CATEGORY_COLORS: Record<string, string> = {
Match: '#4f46e5',
Datenqualität: '#d97706',
Priorisierung: '#1e3a5f',
Empfehlung: '#1a7a4a',
Risiko: '#c0392b',
Tradeoffs: '#ea580c',
Strategie: '#0891b2',
Analyse: '#7c3aed',
Erklärung: '#0891b2',
Evidenz: '#64748b',
Review: '#7c3aed',
Konfidenz: '#d97706',
Fehler: '#c0392b',
Fehleranalyse: '#ea580c',
Eskalation: '#ea580c',
Prozess: '#64748b',
Kosten: '#1a7a4a',
Impact: '#d97706',
Optimierung: '#1a7a4a',
Aktion: '#1e3a5f',
Überblick: '#64748b',
Ranking: '#4f46e5',
}
export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }: Props) {
if (suggestions.length === 0) return null
return (
<Box sx={{ px: 2, py: 1.25 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Vorschläge
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{suggestions.map(s => {
const catColor = CATEGORY_COLORS[s.category] ?? '#64748b'
return (
<Box
key={s.id}
onClick={() => !disabled && onSelect(s.question)}
sx={{
px: 1.25,
py: 0.875,
borderRadius: 1.5,
border: '1px solid #e2e8f0',
cursor: disabled ? 'default' : 'pointer',
bgcolor: 'white',
opacity: disabled ? 0.5 : 1,
'&:hover': disabled ? {} : { bgcolor: '#f8fafc', borderColor: '#cbd5e1' },
transition: 'all 0.1s ease',
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Box
sx={{
width: 3,
height: 28,
borderRadius: 2,
bgcolor: catColor,
flexShrink: 0,
opacity: 0.7,
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontSize: '0.8rem', color: '#334155', lineHeight: 1.4 }}>
{s.question}
</Typography>
<Chip
label={s.category}
size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: `${catColor}14`, color: catColor, fontWeight: 600, ml: 0.5, verticalAlign: 'middle' }}
/>
</Box>
</Box>
)
})}
</Box>
</Box>
)
}
@@ -1,35 +0,0 @@
import { Box, IconButton, Tooltip } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { useAssistantStore } from '../../stores/assistantStore'
export function GlobalAIAssistantButton() {
const { isOpen, open } = useAssistantStore()
return (
<Tooltip title="AI Assistent öffnen" placement="left">
<Box
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 1250,
display: isOpen ? 'none' : 'flex',
}}
>
<IconButton
onClick={open}
sx={{
width: 48,
height: 48,
bgcolor: '#4f46e5',
color: 'white',
boxShadow: '0 4px 16px rgba(79,70,229,0.4)',
'&:hover': { bgcolor: '#4338ca' },
}}
>
<Sparkles size={20} />
</IconButton>
</Box>
</Tooltip>
)
}
@@ -1,264 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material'
import { RotateCcw, Send, Sparkles, X } from 'lucide-react'
import { useLocation } from 'react-router'
import { useAssistantStore } from '../../stores/assistantStore'
import { useSessionStore } from '../../stores/sessionStore'
import { aiAssistantService } from '../../services/aiAssistantService'
import { AssistantContextSummary } from './AssistantContextSummary'
import { AssistantMessageList } from './AssistantMessageList'
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant'
import type { WorkspaceType } from '../../domain/enums'
function resolveWorkspace(pathname: string): WorkspaceType | null {
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
if (pathname.startsWith('/demand')) return 'DEMAND' as WorkspaceType
if (pathname.startsWith('/ops')) return 'OPERATIONS' as WorkspaceType
return null
}
export function GlobalAIAssistantDrawer() {
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
useAssistantStore()
const { currentUser } = useSessionStore()
const location = useLocation()
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
const [inputText, setInputText] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
// Build context from route when drawer opens
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [isOpen, location.pathname])
// Refresh suggestions when route changes while open
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [location.pathname])
// Auto-scroll on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isLoading])
const handleQuestion = useCallback(async (question: string) => {
if (!question.trim() || isLoading) return
setInputText('')
setError(null)
const userMsg = {
id: crypto.randomUUID(),
role: 'user' as const,
content: question.trim(),
createdAt: new Date().toISOString(),
}
addMessage(userMsg)
setLoading(true)
try {
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
const answer = await aiAssistantService.answerQuestion(ctx, question)
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
} catch {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
} finally {
setLoading(false)
}
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuestion(inputText)
}
}
const handleClear = () => {
clearConversation()
setSuggestions([])
if (context) {
aiAssistantService.getSuggestions(context).then(setSuggestions)
}
}
const showSuggestions = suggestions.length > 0 && messages.length === 0
return (
<Drawer
anchor="right"
open={isOpen}
onClose={close}
variant="temporary"
slotProps={{
paper: {
sx: {
width: 420,
top: '56px',
height: 'calc(100% - 56px)',
boxShadow: '-4px 0 24px rgba(0,0,0,0.12)',
display: 'flex',
flexDirection: 'column',
},
},
}}
>
{/* Header */}
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
>
<Sparkles size={14} color="white" />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.2 }}>
AI Assistent
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
Kontextbasierte Entscheidungsunterstützung
</Typography>
</Box>
<Tooltip title="Gespräch zurücksetzen">
<IconButton size="small" onClick={handleClear} disabled={messages.length === 0} sx={{ color: '#94a3b8' }}>
<RotateCcw size={14} />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={close} sx={{ color: '#94a3b8' }}>
<X size={16} />
</IconButton>
</Box>
{/* Context summary */}
{context && <AssistantContextSummary context={context} />}
{/* Scrollable body */}
<Box
ref={scrollRef}
sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
>
{/* Welcome message */}
{messages.length === 0 && !isLoading && (
<Box sx={{ px: 2, py: 1.5 }}>
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}>
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: '#334155', lineHeight: 1.6 }}>
Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten keine erfundenen Fakten.
</Typography>
</Box>
</Box>
)}
{/* Suggestions */}
{showSuggestions && (
<>
<AssistantPromptSuggestions
suggestions={suggestions}
onSelect={handleQuestion}
disabled={isLoading}
/>
<Divider sx={{ mx: 2, my: 0.5 }} />
</>
)}
{/* Messages */}
{messages.length > 0 && (
<Box sx={{ py: 0.5 }}>
<AssistantMessageList messages={messages} />
</Box>
)}
{/* Inline suggestions after messages */}
{messages.length > 0 && suggestions.length > 0 && !isLoading && (
<>
<Divider sx={{ mx: 2, my: 0.5 }} />
<AssistantPromptSuggestions
suggestions={suggestions.slice(0, 2)}
onSelect={handleQuestion}
disabled={isLoading}
/>
</>
)}
{/* Loading */}
{isLoading && <AssistantLoadingState />}
{/* Error */}
{error && <AssistantErrorState error={error} onRetry={() => setError(null)} />}
</Box>
{/* Input area */}
<Box sx={{ px: 2, py: 1.25, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
<TextField
multiline
maxRows={4}
fullWidth
size="small"
placeholder="Frage stellen…"
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
sx={{
'& .MuiOutlinedInput-root': { fontSize: '0.8125rem', borderRadius: 2 },
}}
/>
<Tooltip title="Senden (Enter)">
<span>
<IconButton
onClick={() => handleQuestion(inputText)}
disabled={!inputText.trim() || isLoading}
sx={{
bgcolor: '#4f46e5',
color: 'white',
flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' },
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
}}
>
<Send size={16} />
</IconButton>
</span>
</Tooltip>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, fontSize: '0.65rem', textAlign: 'center' }}>
Antworten sind datenbasiert Aktionen erfordern manuelle Bestätigung
</Typography>
</Box>
</Drawer>
)
}
@@ -1,8 +0,0 @@
export { GlobalAIAssistantButton } from './GlobalAIAssistantButton'
export { GlobalAIAssistantDrawer } from './GlobalAIAssistantDrawer'
export { AssistantMessageList } from './AssistantMessageList'
export { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
export { AssistantContextSummary } from './AssistantContextSummary'
export { AssistantActionCards } from './AssistantActionCards'
export { AssistantLoadingState } from './AssistantLoadingState'
export { AssistantErrorState } from './AssistantErrorState'
@@ -1,75 +0,0 @@
import { Box, Button, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import { ShieldOff } from 'lucide-react'
import { useNavigate } from 'react-router'
interface AccessDeniedProps {
title?: string
message?: string
onBack?: () => void
sx?: SxProps<Theme>
}
export function AccessDenied({
title = 'Kein Zugriff',
message = 'Sie haben keine Berechtigung, diesen Bereich zu öffnen.',
onBack,
sx,
}: AccessDeniedProps) {
const navigate = useNavigate()
function handleBack() {
if (onBack) {
onBack()
} else {
navigate(-1)
}
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 400,
gap: 2,
p: 4,
...sx,
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: 'rgba(239,68,68,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ShieldOff size={28} color="#ef4444" />
</Box>
<Box sx={{ textAlign: 'center', maxWidth: 400 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{message}
</Typography>
</Box>
<Button
variant="outlined"
size="small"
onClick={handleBack}
sx={{ mt: 1, textTransform: 'none' }}
>
Zurück
</Button>
</Box>
)
}
@@ -1,55 +0,0 @@
import { Box, Chip, Typography } from '@mui/material'
import { UserRole } from '../../domain/enums'
import { authService } from '../../services/authService'
import { useSessionStore } from '../../stores/sessionStore'
const ROLE_LABELS: Record<UserRole, string> = {
[UserRole.SUPER_ADMIN]: 'Super Admin',
[UserRole.ORGANIZATION_ADMIN]: 'Org Admin',
[UserRole.PROPERTY_MANAGER]: 'Prop. Manager',
[UserRole.REVIEWER]: 'Reviewer',
[UserRole.OWNER_VIEWER]: 'Owner Viewer',
[UserRole.DEMAND_USER]: 'Demand User',
}
export function DemoRoleSwitcher() {
const { currentUser } = useSessionStore()
async function handleSwitch(role: UserRole) {
await authService.switchDemoRole(role)
}
return (
<Box sx={{ px: 1, py: 0.5 }}>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}
>
Demo-Modus
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{Object.values(UserRole).map((role) => {
const active = currentUser?.role === role
return (
<Chip
key={role}
label={ROLE_LABELS[role]}
size="small"
clickable
onClick={() => handleSwitch(role)}
sx={{
fontSize: '0.7rem',
height: 22,
bgcolor: active ? '#1e3a5f' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
)
})}
</Box>
</Box>
)
}
@@ -1,40 +0,0 @@
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
import type { SelectChangeEvent } from '@mui/material'
import { authService } from '../../services/authService'
import { useSessionStore } from '../../stores/sessionStore'
const MOCK_ORGANIZATIONS = [
{ id: 'org-wincasa', name: 'Wincasa AG' },
{ id: 'org-mobimo', name: 'Mobimo Management AG' },
{ id: 'org-ubs', name: 'UBS Asset Management RE' },
]
export function OrganizationSwitcher() {
const { activeOrganizationId } = useSessionStore()
async function handleChange(e: SelectChangeEvent<string>) {
await authService.switchOrganization(e.target.value)
}
return (
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.5, display: 'block' }}
>
Organisation
</Typography>
<Select
value={activeOrganizationId ?? ''}
onChange={handleChange}
sx={{ fontSize: '0.8rem' }}
>
{MOCK_ORGANIZATIONS.map((org) => (
<MenuItem key={org.id} value={org.id} sx={{ fontSize: '0.8rem' }}>
{org.name}
</MenuItem>
))}
</Select>
</FormControl>
)
}
@@ -1,19 +0,0 @@
import type { ReactNode } from 'react'
import type { MockUser } from '../../stores/sessionStore'
import { useSessionStore } from '../../stores/sessionStore'
interface PermissionGateProps {
check: (user: MockUser) => boolean
fallback?: ReactNode
children: ReactNode
}
export function PermissionGate({ check, fallback = null, children }: PermissionGateProps) {
const { currentUser } = useSessionStore()
if (!currentUser || !check(currentUser)) {
return <>{fallback}</>
}
return <>{children}</>
}
@@ -1,38 +0,0 @@
import { Navigate, Outlet } from 'react-router'
import type { WorkspaceType } from '../../domain/enums'
import { useSessionStore } from '../../stores/sessionStore'
import { SessionStatus } from '../../stores/sessionStore'
import { canAccessWorkspace } from '../../lib/permissions'
import { AccessDenied } from './AccessDenied'
import { SessionExpired } from './SessionExpired'
interface ProtectedRouteProps {
workspace?: WorkspaceType
}
export function ProtectedRoute({ workspace }: ProtectedRouteProps) {
const { isAuthenticated, currentUser, sessionStatus } = useSessionStore()
if (!isAuthenticated || sessionStatus === SessionStatus.UNAUTHENTICATED) {
return <Navigate to="/auth/login" replace />
}
if (sessionStatus === SessionStatus.EXPIRED) {
return <SessionExpired />
}
if (workspace && currentUser && !canAccessWorkspace(currentUser, workspace)) {
const workspaceLabel: Record<string, string> = {
SUPPLY: 'Supply',
DEMAND: 'Demand',
OPERATIONS: 'Operations',
}
return (
<AccessDenied
message={`Sie haben keine Berechtigung für den ${workspaceLabel[workspace] ?? workspace}-Bereich.`}
/>
)
}
return <Outlet />
}
@@ -1,20 +0,0 @@
import type { ReactNode } from 'react'
import type { UserRole } from '../../domain/enums'
import { useSessionStore } from '../../stores/sessionStore'
import { AccessDenied } from './AccessDenied'
interface RoleGuardProps {
roles: UserRole[]
fallback?: ReactNode
children: ReactNode
}
export function RoleGuard({ roles, fallback, children }: RoleGuardProps) {
const { currentUser } = useSessionStore()
if (!currentUser || !roles.includes(currentUser.role)) {
return <>{fallback ?? <AccessDenied />}</>
}
return <>{children}</>
}
@@ -1,60 +0,0 @@
import { Box, Button, Typography } from '@mui/material'
import { Clock } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useSessionStore } from '../../stores/sessionStore'
export function SessionExpired() {
const { logout } = useSessionStore()
const navigate = useNavigate()
function handleRelogin() {
logout()
navigate('/auth/login')
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
bgcolor: '#f8fafc',
p: 4,
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: 'rgba(245,158,11,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Clock size={28} color="#f59e0b" />
</Box>
<Box sx={{ textAlign: 'center', maxWidth: 380 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 0.5 }}>
Sitzung abgelaufen
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.
</Typography>
</Box>
<Button
variant="contained"
onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
>
Erneut anmelden
</Button>
</Box>
)
}
@@ -1,7 +0,0 @@
export { AccessDenied } from './AccessDenied'
export { SessionExpired } from './SessionExpired'
export { PermissionGate } from './PermissionGate'
export { RoleGuard } from './RoleGuard'
export { ProtectedRoute } from './ProtectedRoute'
export { DemoRoleSwitcher } from './DemoRoleSwitcher'
export { OrganizationSwitcher } from './OrganizationSwitcher'
@@ -1,39 +0,0 @@
import { Chip } from '@mui/material'
import { CheckCircle2, Clock, Sparkles, XCircle, HelpCircle } from 'lucide-react'
import type { AvailabilityStatus } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { AVAILABILITY_LABELS } from '../../lib/constants'
interface AvailabilityBadgeProps {
status: AvailabilityStatus
size?: 'small' | 'medium'
}
const ICONS: Record<AvailabilityStatus, React.ElementType> = {
AVAILABLE_NOW: CheckCircle2,
AVAILABLE_SOON: Clock,
FUTURE_SIGNAL: Sparkles,
OCCUPIED: XCircle,
UNKNOWN: HelpCircle,
}
export function AvailabilityBadge({ status, size = 'small' }: AvailabilityBadgeProps) {
const { bg, fg } = DS_COLORS.availability[status]
const Icon = ICONS[status]
return (
<Chip
size={size}
label={AVAILABILITY_LABELS[status] ?? status}
icon={<Icon size={11} color={fg} />}
aria-label={`Verfügbarkeit: ${AVAILABILITY_LABELS[status] ?? status}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
@@ -1,35 +0,0 @@
import { Chip } from '@mui/material'
import { ShieldCheck } from 'lucide-react'
import type { ConfidenceLevel } from '../../domain/enums'
import { DS_COLORS, scoreToConfidenceLevel } from '../../lib/ds'
import { CONFIDENCE_LABELS } from '../../lib/constants'
interface ConfidenceBadgeProps {
level?: ConfidenceLevel
score?: number
size?: 'small' | 'medium'
}
export function ConfidenceBadge({ level, score, size = 'small' }: ConfidenceBadgeProps) {
const resolved: ConfidenceLevel =
level ?? (score !== undefined ? scoreToConfidenceLevel(score) : 'MEDIUM')
const { bg, fg } = DS_COLORS.confidence[resolved]
const scoreLabel = score !== undefined ? ` (${Math.round(score * 100)}%)` : ''
const label = `${CONFIDENCE_LABELS[resolved] ?? resolved}${scoreLabel}`
return (
<Chip
size={size}
label={label}
icon={<ShieldCheck size={11} color={fg} />}
aria-label={`Konfidenz: ${label}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
@@ -1,44 +0,0 @@
import { Chip } from '@mui/material'
import { CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'
import type { DataQualityLevel } from '../../domain/enums'
import { DS_COLORS, scoreToDataQualityLevel } from '../../lib/ds'
import { DATA_QUALITY_LABELS } from '../../lib/constants'
interface DataQualityBadgeProps {
level?: DataQualityLevel
score?: number
showScore?: boolean
size?: 'small' | 'medium'
}
const ICONS: Record<DataQualityLevel, React.ElementType> = {
HIGH: CheckCircle2,
MEDIUM: AlertTriangle,
LOW: XCircle,
INCOMPLETE: XCircle,
}
export function DataQualityBadge({ level, score, showScore = false, size = 'small' }: DataQualityBadgeProps) {
const resolved: DataQualityLevel =
level ?? (score !== undefined ? scoreToDataQualityLevel(score) : 'LOW')
const { bg, fg } = DS_COLORS.dataQuality[resolved]
const Icon = ICONS[resolved]
const scoreLabel = showScore && score !== undefined ? ` ${Math.round(score * 100)}%` : ''
const label = `${DATA_QUALITY_LABELS[resolved] ?? resolved}${scoreLabel}`
return (
<Chip
size={size}
label={label}
icon={<Icon size={11} color={fg} />}
aria-label={`Datenqualität: ${label}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
@@ -1,37 +0,0 @@
import { Chip } from '@mui/material'
import { Zap, Clock, AlertCircle } from 'lucide-react'
import type { FreshnessStatus } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { FRESHNESS_LABELS } from '../../lib/constants'
interface FreshnessBadgeProps {
status: FreshnessStatus
size?: 'small' | 'medium'
}
const ICONS: Record<FreshnessStatus, React.ElementType> = {
FRESH: Zap,
STALE: Clock,
OUTDATED: AlertCircle,
}
export function FreshnessBadge({ status, size = 'small' }: FreshnessBadgeProps) {
const { bg, fg } = DS_COLORS.freshness[status]
const Icon = ICONS[status]
return (
<Chip
size={size}
label={FRESHNESS_LABELS[status] ?? status}
icon={<Icon size={11} color={fg} />}
aria-label={`Datenaktualität: ${FRESHNESS_LABELS[status] ?? status}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
@@ -1,37 +0,0 @@
import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants'
interface ResultTypeBadgeProps {
type: ResultType
size?: 'small' | 'medium'
}
const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe,
FUTURE_AVAILABILITY: Sparkles,
}
export function ResultTypeBadge({ type, size = 'small' }: ResultTypeBadgeProps) {
const { bg, fg } = DS_COLORS.resultType[type]
const Icon = ICONS[type]
return (
<Chip
size={size}
label={RESULT_TYPE_LABELS[type] ?? type}
icon={<Icon size={11} color={fg} />}
aria-label={RESULT_TYPE_LABELS[type] ?? type}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
@@ -1,31 +0,0 @@
import { Chip } from '@mui/material'
import { AlertTriangle, Shield } from 'lucide-react'
import type { RiskLevel } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { RISK_LABELS } from '../../lib/constants'
interface RiskBadgeProps {
level: RiskLevel
size?: 'small' | 'medium'
}
export function RiskBadge({ level, size = 'small' }: RiskBadgeProps) {
const { bg, fg } = DS_COLORS.risk[level]
const Icon = level === 'LOW' || level === 'MEDIUM' ? Shield : AlertTriangle
return (
<Chip
size={size}
label={RISK_LABELS[level] ?? level}
icon={<Icon size={11} color={fg} />}
aria-label={`Risiko: ${RISK_LABELS[level] ?? level}`}
sx={{
bgcolor: bg,
color: fg,
border: 'none',
fontWeight: 600,
fontSize: '0.7rem',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
)
}
@@ -1,6 +0,0 @@
export { ResultTypeBadge } from './ResultTypeBadge'
export { ConfidenceBadge } from './ConfidenceBadge'
export { RiskBadge } from './RiskBadge'
export { AvailabilityBadge } from './AvailabilityBadge'
export { FreshnessBadge } from './FreshnessBadge'
export { DataQualityBadge } from './DataQualityBadge'
@@ -1,53 +0,0 @@
import { Box, Card, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
interface CompactCardProps {
title: string
meta?: string
leading?: ReactNode
trailing?: ReactNode
onClick?: () => void
selected?: boolean
sx?: SxProps<Theme>
}
export function CompactCard({ title, meta, leading, trailing, onClick, selected = false, sx }: CompactCardProps) {
return (
<Card
onClick={onClick}
sx={{
outline: selected ? '2px solid #1e3a5f' : 'none',
outlineOffset: -1,
bgcolor: selected ? 'rgba(30,58,95,0.03)' : 'background.paper',
cursor: onClick ? 'pointer' : 'default',
'&:hover': onClick ? { bgcolor: 'rgba(0,0,0,0.015)' } : {},
...sx,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1,
minHeight: 48,
}}
>
{leading && <Box sx={{ flexShrink: 0 }}>{leading}</Box>}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 500, fontSize: '0.875rem', lineHeight: 1.3 }} noWrap>
{title}
</Typography>
{meta && (
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', lineHeight: 1.3 }} noWrap>
{meta}
</Typography>
)}
</Box>
{trailing && <Box sx={{ flexShrink: 0 }}>{trailing}</Box>}
</Box>
</Card>
)
}
@@ -1,95 +0,0 @@
import { Box, Card, CardActions, CardContent, Divider, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
import { CardSkeleton } from '../ui/CardSkeleton'
import { RestrictedState } from '../ui/RestrictedState'
interface DecisionCardProps {
title: string
subtitle?: string
badges?: ReactNode
score?: ReactNode
body?: ReactNode
actions?: ReactNode
selected?: boolean
isLoading?: boolean
isRestricted?: boolean
onClick?: () => void
sx?: SxProps<Theme>
}
export function DecisionCard({
title,
subtitle,
badges,
score,
body,
actions,
selected = false,
isLoading = false,
isRestricted = false,
onClick,
sx,
}: DecisionCardProps) {
if (isLoading) return <CardSkeleton hasActions={!!actions} />
return (
<Card
onClick={onClick}
sx={{
outline: selected ? '2px solid #1e3a5f' : 'none',
outlineOffset: -1,
bgcolor: selected ? 'rgba(30,58,95,0.03)' : 'background.paper',
cursor: onClick ? 'pointer' : 'default',
transition: 'outline 0.1s, background-color 0.1s',
'&:hover': onClick ? { bgcolor: 'rgba(0,0,0,0.01)' } : {},
position: 'relative',
...sx,
}}
>
{isRestricted ? (
<RestrictedState sx={{ py: 5 }} />
) : (
<>
<CardContent sx={{ pb: body || actions ? 1 : 2 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{ fontWeight: 600, fontSize: '0.9375rem', lineHeight: 1.3, mb: subtitle ? 0.25 : 0 }}
noWrap
>
{title}
</Typography>
{subtitle && (
<Typography sx={{ fontSize: '0.8125rem', color: 'text.secondary', lineHeight: 1.4 }}>
{subtitle}
</Typography>
)}
</Box>
{score && <Box sx={{ flexShrink: 0 }}>{score}</Box>}
</Box>
{badges && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
{badges}
</Box>
)}
</CardContent>
{body && (
<>
<Divider />
<CardContent sx={{ py: 1.5 }}>{body}</CardContent>
</>
)}
{actions && (
<>
<Divider />
<CardActions sx={{ px: 2, py: 1 }}>{actions}</CardActions>
</>
)}
</>
)}
</Card>
)
}
@@ -1,64 +0,0 @@
import { Box, Card, Typography } from '@mui/material'
import { TrendingUp, TrendingDown } from 'lucide-react'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
interface MetricDelta {
value: string
positive: boolean
}
interface MetricCardProps {
label: string
value: string | number
delta?: MetricDelta
icon?: ReactNode
color?: string
sx?: SxProps<Theme>
}
export function MetricCard({ label, value, delta, icon, color = '#1e3a5f', sx }: MetricCardProps) {
return (
<Card sx={{ p: 2.5, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box>
<Typography
sx={{ fontSize: '0.6875rem', fontWeight: 600, color: 'text.disabled', textTransform: 'uppercase', letterSpacing: '0.08em', mb: 0.5 }}
>
{label}
</Typography>
<Typography sx={{ fontSize: '1.75rem', fontWeight: 700, color, lineHeight: 1, fontVariantNumeric: 'tabular-nums' }}>
{value}
</Typography>
{delta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.5 }}>
{delta.positive
? <TrendingUp size={12} color="#1a7a4a" />
: <TrendingDown size={12} color="#c0392b" />}
<Typography
sx={{ fontSize: '0.75rem', color: delta.positive ? '#1a7a4a' : '#c0392b', fontWeight: 500 }}
>
{delta.value}
</Typography>
</Box>
)}
</Box>
{icon && (
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1.5,
bgcolor: `${color}18`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{icon}
</Box>
)}
</Box>
</Card>
)
}
@@ -1,27 +0,0 @@
import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums'
import { RESULT_TYPE_LABELS } from '../../lib/constants'
interface SourceTypeBadgeProps {
type: ResultType
size?: 'small' | 'medium'
}
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
}
export function SourceTypeBadge({ type, size = 'small' }: SourceTypeBadgeProps) {
const { bg, color, Icon } = CONFIG[type] ?? { bg: '#f1f5f9', color: '#475569', Icon: Globe }
return (
<Chip
icon={<Icon size={11} color={color} />}
label={RESULT_TYPE_LABELS[type] ?? type}
size={size}
sx={{ bgcolor: bg, color, fontWeight: 600, border: 'none', '& .MuiChip-icon': { ml: 0.5 } }}
/>
)
}
@@ -1,4 +0,0 @@
export { SourceTypeBadge } from './SourceTypeBadge'
export { DecisionCard } from './DecisionCard'
export { CompactCard } from './CompactCard'
export { MetricCard } from './MetricCard'
@@ -1,133 +0,0 @@
import { useState } from 'react'
import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight } from 'lucide-react'
import type { ComparisonSummary } from '../../services/aiService'
interface Props {
summary?: ComparisonSummary
isLoading: boolean
}
export function AICompareSummary({ summary, isLoading }: Props) {
const [open, setOpen] = useState(true)
return (
<Box sx={{ mb: 2, border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
<Box
onClick={() => setOpen(v => !v)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1.25,
bgcolor: '#f8fafc',
cursor: 'pointer',
'&:hover': { bgcolor: '#f1f5f9' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AI Vergleichs-Zusammenfassung</Typography>
<Chip label="Beta" size="small" sx={{ fontSize: 10, bgcolor: '#ede9fe', color: '#6d28d9' }} />
</Box>
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</Box>
<Collapse in={open}>
<Box sx={{ p: 2 }}>
{isLoading && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">Analyse wird erstellt</Typography>
</Box>
)}
{!isLoading && summary && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Strongest option */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Trophy size={16} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Stärkstes Match</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.strongestOption.label}</Typography>
<Typography variant="caption" color="text.secondary">{summary.strongestOption.reason}</Typography>
</Box>
</Box>
{/* Best value */}
{summary.bestValue && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<TrendingDown size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Bestes Preis-Leistungs-Verhältnis</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.bestValue.label}</Typography>
<Typography variant="caption" color="text.secondary">{summary.bestValue.reason}</Typography>
</Box>
</Box>
)}
{/* Highest confidence */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<ShieldCheck size={16} color="#0891b2" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Höchste Datenkonfidenz</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{summary.highestConfidence.label}
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
({Math.round(summary.highestConfidence.confidenceLevel * 100)}%)
</Typography>
</Typography>
</Box>
</Box>
{/* Tradeoffs */}
{summary.biggestTradeoffs.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<AlertTriangle size={16} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Wichtigste Abwägungen</Typography>
{summary.biggestTradeoffs.map((t, i) => (
<Typography key={i} variant="caption" color="text.secondary" sx={{ display: 'block' }}>· {t}</Typography>
))}
</Box>
</Box>
)}
{/* Missing data */}
{summary.missingDataWarnings.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Info size={16} color="#c0392b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Fehlende Informationen</Typography>
{summary.missingDataWarnings.map((w, i) => (
<Typography key={i} variant="caption" color="error" sx={{ display: 'block' }}>· {w}</Typography>
))}
</Box>
</Box>
)}
{/* Next step */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', pt: 0.5, borderTop: '1px solid #f1f5f9', mt: 0.5 }}>
<ArrowRight size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfohlener nächster Schritt</Typography>
<Typography variant="body2">{summary.recommendedNextStep}</Typography>
</Box>
</Box>
</Box>
)}
{!isLoading && !summary && (
<Alert severity="info" sx={{ py: 0.5 }}>
Mindestens 2 Ergebnisse auswählen, um die Zusammenfassung zu generieren.
</Alert>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1.5, fontStyle: 'italic' }}>
Diese Zusammenfassung basiert ausschliesslich auf den vorliegenden Daten und trifft keine endgültige Entscheidung.
</Typography>
</Box>
</Collapse>
</Box>
)
}
@@ -1,48 +0,0 @@
import type { ReactNode } from 'react'
import { Box, Tooltip, Typography } from '@mui/material'
import { Info } from 'lucide-react'
export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none'
interface Props {
highlight?: CellHighlight
icon?: ReactNode
iconTooltip?: string
children: ReactNode
}
const HIGHLIGHT_SX: Record<CellHighlight, object> = {
best: { bgcolor: '#f0fdf4', borderLeft: '3px solid #1a7a4a' },
worst: { bgcolor: '#fef3c7', borderLeft: '3px solid #d97706' },
critical: { bgcolor: '#fef2f2', borderLeft: '3px solid #c0392b' },
future: { bgcolor: '#faf5ff', borderLeft: '3px solid #7c3aed' },
none: {},
}
export function CompareCell({ highlight = 'none', icon, iconTooltip, children }: Props) {
return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75, ...HIGHLIGHT_SX[highlight] }}>
{icon && (
iconTooltip
? <Tooltip title={iconTooltip}><Box sx={{ flexShrink: 0, display: 'flex', mt: 0.25 }}>{icon}</Box></Tooltip>
: <Box sx={{ flexShrink: 0, display: 'flex', mt: 0.25 }}>{icon}</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
</Box>
)
}
export function MissingDataCell({ reason }: { reason?: string }) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Tooltip title={reason ?? 'Keine Daten vorhanden — kann Konfidenz beeinflussen'}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Info size={12} color="#94a3b8" />
</Box>
</Tooltip>
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}>
Nicht verfügbar
</Typography>
</Box>
)
}
@@ -1,85 +0,0 @@
import { Box, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { X, AlertTriangle } from 'lucide-react'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Marktinserat', color: '#d97706' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
interface Props {
item: UnifiedMatchResult
onRemove: () => void
}
export function CompareColumnHeader({ item, onRemove }: Props) {
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? ''
const subtitle = prop?.location?.city ?? sig?.locationHint ?? ''
const availability = prop?.availabilityDate ?? (sig ? `~${sig.timeHorizonMonths} Monate` : null)
const confidence = Math.round(item.match.confidenceLevel * 100)
const source = prop?.sourceLabel ?? sig?.source?.type ?? ''
return (
<Box sx={{ p: 0.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Chip label={meta.label} size="small" sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }} />
<IconButton size="small" onClick={onRemove} sx={{ p: 0.25, ml: 1 }}>
<X size={14} />
</IconButton>
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.25, lineHeight: 1.3 }}>
{title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{subtitle}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
{item.matchScore}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
<Tooltip title="Konfidenz">
<Chip
label={`${confidence}% Konfidenz`}
size="small"
icon={confidence < 60 ? <AlertTriangle size={10} /> : undefined}
sx={{
fontSize: 10,
bgcolor: confidence < 60 ? '#fef3c7' : '#f0fdf4',
color: confidence < 60 ? '#92400e' : '#166534',
}}
/>
</Tooltip>
{source && source !== '' && (
<Chip label={source} size="small" sx={{ fontSize: 10 }} />
)}
{availability && (
<Chip label={availability} size="small" sx={{ fontSize: 10 }} />
)}
</Box>
{item.resultType === 'FUTURE_AVAILABILITY' && sig && (
<Box sx={{ mt: 1, p: 0.75, bgcolor: '#faf5ff', borderRadius: 1, border: '1px solid #e9d5ff' }}>
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, display: 'block' }}>
Probabilistisches Signal
</Typography>
<Typography variant="caption" color="text.secondary">
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
</Typography>
</Box>
)}
</Box>
)
}
@@ -1,24 +0,0 @@
import { Box, Button, Typography } from '@mui/material'
import { Columns2 } from 'lucide-react'
import { useNavigate } from 'react-router'
export function CompareEmptyState() {
const navigate = useNavigate()
return (
<Box sx={{ px: 3, py: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, py: 8 }}>
<Columns2 size={40} color="#94a3b8" />
<Typography variant="h6" color="text.secondary" sx={{ fontWeight: 600 }}>
Keine Ergebnisse zum Vergleich
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', maxWidth: 360 }}>
Fügen Sie 24 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
</Typography>
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
Zu den Suchergebnissen
</Button>
</Box>
</Box>
)
}
@@ -1,4 +0,0 @@
export { CompareEmptyState } from './CompareEmptyState'
export { CompareColumnHeader } from './CompareColumnHeader'
export { CompareCell, MissingDataCell } from './CompareCell'
export { AICompareSummary } from './AICompareSummary'
@@ -1,33 +0,0 @@
import { Alert, Box, Chip, Typography } from '@mui/material'
interface CriticalFieldWarningProps {
fields: string[]
warnings?: string[]
}
export function CriticalFieldWarning({ fields, warnings = [] }: CriticalFieldWarningProps) {
if (fields.length === 0 && warnings.length === 0) return null
return (
<Box sx={{ mb: 2 }}>
{fields.length > 0 && (
<Alert severity="error" sx={{ mb: 1, py: 0.5, px: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
{fields.length} Pflichtfeld{fields.length > 1 ? 'er' : ''} fehlen Match-Qualität reduziert
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{fields.map(f => (
<Chip key={f} label={f} size="small" color="error" variant="outlined"
sx={{ height: 18, fontSize: '0.65rem' }} />
))}
</Box>
</Alert>
)}
{warnings.map((w, i) => (
<Alert key={i} severity="warning" sx={{ mb: 0.5, py: 0.25, px: 1.5, fontSize: '0.8125rem' }}>
{w}
</Alert>
))}
</Box>
)
}
@@ -1,40 +0,0 @@
import { Chip, Tooltip } from '@mui/material'
import { dataQualityHex } from '../../lib/utils'
import type { DataQuality } from '../../domain/property'
interface DataQualityBadgeProps {
quality: DataQuality
showLabel?: boolean
size?: 'small' | 'medium'
}
export function DataQualityBadge({ quality, showLabel = false, size = 'small' }: DataQualityBadgeProps) {
const pct = Math.round(quality.score * 100)
const hex = dataQualityHex(quality.score)
const hasCritical = quality.missingCriticalFields.length > 0
const levelLabel = quality.qualityLevel
? { HIGH: 'Hoch', MEDIUM: 'Mittel', LOW: 'Niedrig', INCOMPLETE: 'Unvollständig' }[quality.qualityLevel]
: null
const tooltipText = hasCritical
? `${quality.missingCriticalFields.length} Pflichtfeld(er) fehlen`
: `Datenqualität: ${pct}%`
return (
<Tooltip title={tooltipText} arrow>
<Chip
size={size}
label={showLabel && levelLabel ? `${pct}% · ${levelLabel}` : `${pct}%`}
sx={{
bgcolor: `${hex}18`,
color: hex,
fontWeight: 700,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
border: `1px solid ${hex}40`,
cursor: 'default',
}}
/>
</Tooltip>
)
}
@@ -1,101 +0,0 @@
import { Box, LinearProgress, Tooltip, Typography, Chip } from '@mui/material'
import { AlertTriangle } from 'lucide-react'
import { dataQualityColor, dataQualityHex, formatPercent } from '../../lib/utils'
import { FRESHNESS_LABELS } from '../../lib/constants'
import type { DataQuality } from '../../domain/property'
interface DataQualityBarProps {
quality: DataQuality
compact?: boolean
showWarnings?: boolean
}
export function DataQualityBar({ quality, compact = false, showWarnings = true }: DataQualityBarProps) {
const color = dataQualityColor(quality.score)
const hex = dataQualityHex(quality.score)
const pct = Math.round(quality.score * 100)
const tooltipContent = (
<Box sx={{ p: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
Datenqualität {formatPercent(quality.score)}
</Typography>
{quality.missingCriticalFields.length > 0 && (
<Box sx={{ mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#fca5a5', display: 'block' }}>Fehlende Pflichtfelder:</Typography>
{quality.missingCriticalFields.map(f => (
<Typography key={f} variant="caption" sx={{ display: 'block', pl: 1 }}> {f}</Typography>
))}
</Box>
)}
{quality.warnings.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: '#fcd34d', display: 'block' }}>Warnungen:</Typography>
{quality.warnings.map((w, i) => (
<Typography key={i} variant="caption" sx={{ display: 'block', pl: 1 }}> {w}</Typography>
))}
</Box>
)}
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mt: 0.5 }}>
Aktualität: {FRESHNESS_LABELS[quality.freshness]}
{quality.lastVerifiedAt ? ` · Geprüft: ${quality.lastVerifiedAt}` : ''}
</Typography>
</Box>
)
if (compact) {
return (
<Tooltip title={tooltipContent} arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, cursor: 'default' }}>
<Box sx={{ width: 56 }}>
<LinearProgress
variant="determinate"
value={pct}
color={color}
sx={{ height: 5, borderRadius: 3 }}
/>
</Box>
<Typography variant="caption" sx={{ color: hex, fontWeight: 600, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{pct}%
</Typography>
{quality.missingCriticalFields.length > 0 && showWarnings && (
<AlertTriangle size={11} color="#d97706" />
)}
</Box>
</Tooltip>
)
}
return (
<Box>
<Tooltip title={tooltipContent} arrow placement="top">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: 'default' }}>
<Box sx={{ flex: 1, minWidth: 80 }}>
<LinearProgress
variant="determinate"
value={pct}
color={color}
sx={{ height: 6, borderRadius: 3 }}
/>
</Box>
<Typography variant="caption" sx={{ color: hex, fontWeight: 600, whiteSpace: 'nowrap' }}>
{pct}%
</Typography>
</Box>
</Tooltip>
{showWarnings && quality.missingCriticalFields.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.25, mt: 0.5 }}>
{quality.missingCriticalFields.slice(0, 2).map(f => (
<Chip key={f} label={f} size="small" color="error" variant="outlined"
sx={{ height: 16, fontSize: '0.625rem' }} />
))}
{quality.missingCriticalFields.length > 2 && (
<Typography variant="caption" sx={{ color: 'error.main' }}>
+{quality.missingCriticalFields.length - 2}
</Typography>
)}
</Box>
)}
</Box>
)
}
@@ -1,91 +0,0 @@
import { Box, Button, Divider, Paper, Typography } from '@mui/material'
import { DataQualityProgress } from './DataQualityProgress'
import { CriticalFieldWarning } from './CriticalFieldWarning'
import { MissingDataList } from './MissingDataList'
import { ProvenancePanel } from './ProvenancePanel'
import { DataQualityBadge } from './DataQualityBadge'
import { getRecommendedActions } from '../../services/dataQualityService'
import type { Property } from '../../domain/property'
interface DataQualityPanelProps {
property: Property
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" sx={{
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: '#64748b',
display: 'block',
mb: 1,
}}>
{children}
</Typography>
)
}
export function DataQualityPanel({ property }: DataQualityPanelProps) {
const q = property.dataQuality
const actions = getRecommendedActions(q, q.freshness)
return (
<Box>
{/* ── Score & Dimensions ─────────────────────────────────────────── */}
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: '#fafafa' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Datenqualität</Typography>
<DataQualityBadge quality={q} showLabel size="medium" />
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{q.missingCriticalFields.length === 0 ? 'Alle Pflichtfelder vorhanden' : `${q.missingCriticalFields.length} Pflichtfeld(er) fehlen`}
</Typography>
<Typography variant="caption" color="text.secondary">
{q.missingOptionalFields.length} optionale Felder fehlen
</Typography>
</Box>
</Box>
<DataQualityProgress property={property} />
</Paper>
{/* ── Critical warnings ──────────────────────────────────────────── */}
<CriticalFieldWarning
fields={q.missingCriticalFields}
warnings={q.warnings}
/>
{/* ── Missing data with actions ──────────────────────────────────── */}
<Box sx={{ mb: 2 }}>
<SectionLabel>Fehlende Daten & Massnahmen</SectionLabel>
<MissingDataList
criticalFields={q.missingCriticalFields}
optionalFields={q.missingOptionalFields}
recommendedActions={actions}
/>
</Box>
<Divider sx={{ mb: 2 }} />
{/* ── Provenance ─────────────────────────────────────────────────── */}
<Box sx={{ mb: 2 }}>
<SectionLabel>Datenherkunft & Verifikation</SectionLabel>
<ProvenancePanel property={property} />
</Box>
{/* ── Action button ──────────────────────────────────────────────── */}
<Box sx={{ pt: 1 }}>
<Button variant="outlined" size="small" sx={{ textTransform: 'none', mr: 1 }}>
Datenaktualisierung anfragen
</Button>
{q.missingCriticalFields.length > 0 && (
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#1e3a5f' }}>
Fehlende Felder ergänzen
</Button>
)}
</Box>
</Box>
)
}
@@ -1,122 +0,0 @@
import { Box, LinearProgress, Typography } from '@mui/material'
import { dataQualityHex } from '../../lib/utils'
import { FreshnessStatus } from '../../domain/enums'
import type { Property } from '../../domain/property'
interface Dimension {
label: string
score: number
color: string
}
function freshnessScore(f: string): number {
if (f === FreshnessStatus.FRESH) return 100
if (f === FreshnessStatus.STALE) return 50
return 15
}
function completenessScore(missingCritical: number, missingOptional: number): number {
const critPenalty = missingCritical * 15
const optPenalty = missingOptional * 5
return Math.max(0, 100 - critPenalty - optPenalty)
}
const SOURCE_PROVENANCE: Record<string, number> = {
ERP_IMPORT: 95, MANUAL_ENTRY: 90, PARTNER_FEED: 80,
IMMOSCOUT_SCRAPE: 65, HOMEGATE_SCRAPE: 65, NEWHOME_SCRAPE: 60,
MATCHOFFICE_SCRAPE: 60, MAISON_WORK_SCRAPE: 60, AI_SIGNAL: 40, UNKNOWN: 30,
}
function dimColor(score: number): string {
if (score >= 80) return '#1a7a4a'
if (score >= 55) return '#d97706'
return '#c0392b'
}
function buildDimensions(p: Property): Dimension[] {
const compScore = completenessScore(
p.dataQuality.missingCriticalFields.length,
p.dataQuality.missingOptionalFields.length,
)
const freshScore = freshnessScore(p.dataQuality.freshness)
const confScore = Math.round(p.confidenceScore * 100)
const provScore = SOURCE_PROVENANCE[p.sourceType] ?? 50
const lastVerified = p.dataQuality.lastVerifiedAt
const verScore = lastVerified
? Math.max(10, 100 - Math.floor((Date.now() - new Date(lastVerified).getTime()) / (1000 * 60 * 60 * 24)) * 2)
: 10
return [
{ label: 'Vollständigkeit', score: compScore, color: dimColor(compScore) },
{ label: 'Aktualität', score: freshScore, color: dimColor(freshScore) },
{ label: 'Vertrauensscore', score: confScore, color: dimColor(confScore) },
{ label: 'Herkunft', score: Math.min(100, provScore), color: dimColor(provScore) },
{ label: 'Verifikation', score: Math.min(100, verScore), color: dimColor(verScore) },
]
}
interface DataQualityProgressProps {
property: Property
compact?: boolean
}
export function DataQualityProgress({ property, compact = false }: DataQualityProgressProps) {
const dims = buildDimensions(property)
const overallPct = Math.round(property.dataQuality.score * 100)
const hex = dataQualityHex(property.dataQuality.score)
if (compact) {
return (
<Box sx={{ display: 'flex', gap: 0.5 }}>
{dims.map(d => (
<Box key={d.label} sx={{ flex: 1 }}>
<LinearProgress
variant="determinate"
value={d.score}
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
</Box>
))}
</Box>
)
}
return (
<Box>
{/* Overall score header */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 1.5 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: hex, lineHeight: 1 }}>
{overallPct}%
</Typography>
<Typography variant="body2" color="text.secondary">Gesamtqualität</Typography>
</Box>
{/* Dimension bars */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{dims.map(d => (
<Box key={d.label}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{d.label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: d.color, fontSize: '0.7rem' }}>{d.score}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={d.score}
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
</Box>
))}
</Box>
</Box>
)
}
@@ -1,46 +0,0 @@
import { Chip, Tooltip } from '@mui/material'
import { CheckCircle, Clock, AlertTriangle } from 'lucide-react'
import { FreshnessStatus } from '../../domain/enums'
import { FRESHNESS_LABELS } from '../../lib/constants'
import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums'
interface FreshnessIndicatorProps {
freshness: FreshnessStatusType
lastUpdated?: string
size?: 'small' | 'medium'
}
const CONFIG: Record<FreshnessStatusType, { color: string; icon: typeof CheckCircle }> = {
[FreshnessStatus.FRESH]: { color: '#1a7a4a', icon: CheckCircle },
[FreshnessStatus.STALE]: { color: '#d97706', icon: Clock },
[FreshnessStatus.OUTDATED]: { color: '#c0392b', icon: AlertTriangle },
}
export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) {
const { color, icon: Icon } = CONFIG[freshness] ?? CONFIG[FreshnessStatus.OUTDATED]
const label = FRESHNESS_LABELS[freshness] ?? freshness
const chip = (
<Chip
size={size}
icon={<Icon size={11} color={color} />}
label={label}
sx={{
bgcolor: `${color}18`,
color,
fontWeight: 600,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
border: `1px solid ${color}40`,
'& .MuiChip-icon': { color },
}}
/>
)
if (!lastUpdated) return chip
return (
<Tooltip title={`Zuletzt aktualisiert: ${new Date(lastUpdated).toLocaleDateString('de-CH')}`} arrow>
{chip}
</Tooltip>
)
}
@@ -1,128 +0,0 @@
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import { AlertTriangle, Info } from 'lucide-react'
import type { RecommendedAction } from '../../services/dataQualityService'
interface MissingDataListProps {
criticalFields: string[]
optionalFields: string[]
recommendedActions: RecommendedAction[]
onAction?: (action: RecommendedAction) => void
}
export function MissingDataList({
criticalFields,
optionalFields,
recommendedActions,
onAction,
}: MissingDataListProps) {
if (criticalFields.length === 0 && optionalFields.length === 0) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, px: 2, bgcolor: '#f0fdf4', borderRadius: 1, mb: 2 }}>
<Info size={14} color="#1a7a4a" />
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500 }}>
Alle wichtigen Felder sind vollständig.
</Typography>
</Box>
)
}
const criticalActions = recommendedActions.filter(a => a.priority === 'HIGH')
const otherActions = recommendedActions.filter(a => a.priority !== 'HIGH')
return (
<Box sx={{ mb: 2 }}>
{criticalFields.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<AlertTriangle size={13} color="#c0392b" />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Pflichtfelder ({criticalFields.length})
</Typography>
</Box>
{criticalActions.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1.5,
py: 0.75,
mb: 0.5,
bgcolor: '#fff1f2',
border: '1px solid #fecdd3',
borderRadius: 1,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>{a.label}</Typography>
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
</Box>
{onAction && (
<Button size="small" variant="outlined" color="error"
onClick={() => onAction(a)}
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
Ergänzen
</Button>
)}
</Box>
))}
{criticalFields
.filter(f => !criticalActions.find(a => a.field === f))
.map(f => (
<Box key={f} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Chip label={f} size="small" color="error" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
</Box>
))
}
</Box>
)}
{optionalFields.length > 0 && (
<>
{criticalFields.length > 0 && <Divider sx={{ mb: 1.5 }} />}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Optionale Felder ({optionalFields.length})
</Typography>
{otherActions.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1.5,
py: 0.75,
mb: 0.5,
bgcolor: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 1,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>{a.label}</Typography>
<Typography variant="caption" color="text.secondary">{a.detail}</Typography>
</Box>
{onAction && (
<Button size="small" variant="outlined" color="warning"
onClick={() => onAction(a)}
sx={{ textTransform: 'none', fontSize: '0.75rem', flexShrink: 0, ml: 1 }}>
Ergänzen
</Button>
)}
</Box>
))}
{optionalFields
.filter(f => !otherActions.find(a => a.field === f))
.map(f => (
<Chip key={f} label={f} size="small" color="warning" variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', mr: 0.5, mb: 0.5 }} />
))
}
</Box>
</>
)}
</Box>
)
}
@@ -1,126 +0,0 @@
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
import { ExternalLink, Shield, ShieldAlert } from 'lucide-react'
import { FreshnessIndicator } from './FreshnessIndicator'
import type { Property } from '../../domain/property'
interface ProvenancePanelProps {
property: Property
}
const SOURCE_TYPE_LABELS: Record<string, string> = {
ERP_IMPORT: 'ERP-Import',
MANUAL_ENTRY: 'Manuelle Eingabe',
IMMOSCOUT_SCRAPE: 'ImmoScout24',
HOMEGATE_SCRAPE: 'Homegate',
NEWHOME_SCRAPE: 'Newhome',
MATCHOFFICE_SCRAPE: 'MatchOffice',
MAISON_WORK_SCRAPE: 'Maison & Work',
AI_SIGNAL: 'KI-Signal',
PARTNER_FEED: 'Partner-Feed',
UNKNOWN: 'Unbekannt',
}
const SOURCE_CONFIDENCE: Record<string, number> = {
ERP_IMPORT: 0.95,
MANUAL_ENTRY: 0.90,
PARTNER_FEED: 0.80,
IMMOSCOUT_SCRAPE: 0.65,
HOMEGATE_SCRAPE: 0.65,
NEWHOME_SCRAPE: 0.60,
MATCHOFFICE_SCRAPE: 0.60,
MAISON_WORK_SCRAPE: 0.60,
AI_SIGNAL: 0.40,
UNKNOWN: 0.30,
}
function getSourceConfidence(sourceType: string): number {
return SOURCE_CONFIDENCE[sourceType] ?? 0.50
}
function provenanceColor(conf: number): string {
if (conf >= 0.8) return '#1a7a4a'
if (conf >= 0.6) return '#d97706'
return '#c0392b'
}
export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
const sourceLabel = SOURCE_TYPE_LABELS[p.sourceType] ?? p.sourceType
const sourceConf = getSourceConfidence(p.sourceType)
const confPct = Math.round(sourceConf * 100)
const color = provenanceColor(sourceConf)
const isVerified = sourceConf >= 0.85
const VerifyIcon = isVerified ? Shield : ShieldAlert
return (
<Box>
{/* Source header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<VerifyIcon size={16} color={color} />
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceLabel}</Typography>
{p.sourceLabel && p.sourceLabel !== sourceLabel && (
<Typography variant="caption" color="text.secondary">· {p.sourceLabel}</Typography>
)}
<Chip
size="small"
label={isVerified ? 'Verifiziert' : 'Ungeprüft'}
sx={{ bgcolor: `${color}18`, color, fontSize: '0.65rem', height: 18, ml: 'auto' }}
/>
</Box>
{/* Source confidence bar */}
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Quell-Vertrauen</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color }}>{confPct}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={confPct}
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': { bgcolor: color },
}}
/>
</Box>
{/* Dates */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Quellaktualisierung</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
{p.sourceUpdatedAt ? new Date(p.sourceUpdatedAt).toLocaleDateString('de-CH') : '—'}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Letzte Verifikation</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, fontSize: '0.8125rem' }}>
{p.dataQuality.lastVerifiedAt ? new Date(p.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH') : '—'}
</Typography>
</Box>
</Box>
{/* Freshness */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="caption" color="text.secondary">Aktualität:</Typography>
<FreshnessIndicator freshness={p.dataQuality.freshness} lastUpdated={p.sourceUpdatedAt} />
</Box>
{/* External URL */}
{p.sourceUrl && (
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={12} />}
href={p.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
>
Originalquelle öffnen
</Button>
)}
</Box>
)
}
@@ -1,8 +0,0 @@
export { DataQualityBar } from './DataQualityBar'
export { DataQualityBadge } from './DataQualityBadge'
export { DataQualityPanel } from './DataQualityPanel'
export { DataQualityProgress } from './DataQualityProgress'
export { FreshnessIndicator } from './FreshnessIndicator'
export { CriticalFieldWarning } from './CriticalFieldWarning'
export { MissingDataList } from './MissingDataList'
export { ProvenancePanel } from './ProvenancePanel'
@@ -1,22 +0,0 @@
import { Chip } from '@mui/material'
interface Props {
confidence: number
size?: 'small' | 'medium'
}
function confidenceColor(c: number): string {
if (c >= 0.8) return '#1a7a4a'
if (c >= 0.6) return '#d97706'
return '#c0392b'
}
export function ConfidenceFieldBadge({ confidence, size = 'small' }: Props) {
return (
<Chip
label={`${Math.round(confidence * 100)}%`}
size={size}
sx={{ bgcolor: confidenceColor(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
/>
)
}
@@ -1,155 +0,0 @@
import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
import type { ParseNeedResult, ParsedNeedCriteria } from '../../domain/needBuilder'
import { ExtractedFieldRow } from './ExtractedFieldRow'
interface Props {
result: ParseNeedResult
criteria: ParsedNeedCriteria
onCriteriaChange: (c: ParsedNeedCriteria) => void
}
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
}
// ── Parse helpers ──────────────────────────────────────────────────────────────
function parseAreaRange(s: string): ParsedNeedCriteria['areaRange'] {
const m = s.match(/(\d+)\s*[\-]\s*(\d+)/)
if (m) return { min: parseInt(m[1]), max: parseInt(m[2]) }
const n = s.match(/(\d+)/)
if (n) { const v = parseInt(n[1]); return { min: Math.round(v * 0.8), max: Math.round(v * 1.2) } }
return undefined
}
function parseBudget(s: string): ParsedNeedCriteria['budgetRange'] {
const n = s.match(/(\d+)/)
if (!n) return undefined
return { maxPerSqm: parseInt(n[1]), currency: 'CHF' }
}
function parseList(s: string): string[] {
return s.split(',').map(x => x.trim()).filter(Boolean)
}
function displayAreaRange(v: ParsedNeedCriteria['areaRange']): string {
return v ? `${v.min}${v.max}` : ''
}
function displayBudget(v: ParsedNeedCriteria['budgetRange']): string {
return v ? `CHF ${v.maxPerSqm}/m²` : ''
}
function displayTiming(v: ParsedNeedCriteria['timing']): string {
if (!v) return ''
return `ab ${v.earliestMoveIn}${v.flexibleTiming ? ' (flexibel)' : ''}`
}
// ── Section wrapper ────────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Box sx={{ mb: 2 }}>
<Typography variant="overline" sx={{ fontWeight: 700, color: '#64748b', fontSize: 10, letterSpacing: 1 }}>
{title}
</Typography>
{children}
</Box>
)
}
// ── Main component ─────────────────────────────────────────────────────────────
export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set }: Props) {
const { confidenceByField: conf, missingFields, assumptions } = result
return (
<Card sx={{ p: 3, height: '100%', overflowY: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
Extrahierte Kriterien
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
{result.rawSummary}
</Typography>
{/* ── Hard Facts ──────────────────────────────────────────────────── */}
<Section title="Hard Facts">
<ExtractedFieldRow
label="Nutzungstyp"
value={c.assetType ? (ASSET_LABELS[c.assetType] ?? c.assetType) : ''}
confidence={conf.assetType ?? 0.2}
missing={!c.assetType}
onEdit={v => set({ ...c, assetType: (v.toUpperCase() as ParsedNeedCriteria['assetType']) })}
/>
<ExtractedFieldRow
label="Flächenbedarf (z.B. 500800)"
value={displayAreaRange(c.areaRange)}
confidence={conf.areaRange ?? 0.2}
missing={!c.areaRange}
onEdit={v => set({ ...c, areaRange: parseAreaRange(v) })}
/>
<ExtractedFieldRow
label="Standort (Komma-getrennt)"
value={c.preferredLocations?.join(', ') ?? ''}
confidence={conf.preferredLocations ?? 0.15}
missing={!c.preferredLocations?.length}
onEdit={v => set({ ...c, preferredLocations: parseList(v) })}
/>
<ExtractedFieldRow
label="Budget (max CHF/m²)"
value={displayBudget(c.budgetRange)}
confidence={conf.budgetRange ?? 0.2}
missing={!c.budgetRange}
onEdit={v => set({ ...c, budgetRange: parseBudget(v) })}
/>
<ExtractedFieldRow
label="Verfügbarkeit (ab Datum)"
value={displayTiming(c.timing)}
confidence={conf.timing ?? 0.2}
missing={!c.timing}
onEdit={v => set({ ...c, timing: { earliestMoveIn: v, latestMoveIn: v, flexibleTiming: v.toLowerCase().includes('flex') } })}
/>
</Section>
{/* ── Must-haves ──────────────────────────────────────────────────── */}
<Section title="Must-haves">
<ExtractedFieldRow
label="Pflichtkriterien (Komma-getrennt)"
value={c.mustHaveCriteria?.join(', ') ?? ''}
missing={!c.mustHaveCriteria?.length}
onEdit={v => set({ ...c, mustHaveCriteria: parseList(v) })}
/>
<ExtractedFieldRow
label="Parkplatzbedarf"
value={c.parkingNeed === true ? 'Ja' : c.parkingNeed === false ? 'Nein' : ''}
missing={c.parkingNeed === undefined}
onEdit={v => set({ ...c, parkingNeed: v.toLowerCase().startsWith('j') })}
/>
</Section>
{/* ── AI Assumptions ──────────────────────────────────────────────── */}
{assumptions.length > 0 && (
<Section title="KI-Annahmen">
<Stack spacing={0.5}>
{assumptions.map((a, i) => (
<Alert key={i} severity="warning" sx={{ py: 0, px: 1, '& .MuiAlert-message': { fontSize: 12 } }}>
{a}
</Alert>
))}
</Stack>
</Section>
)}
{/* ── Missing Information ─────────────────────────────────────────── */}
{missingFields.length > 0 && (
<Section title="Fehlende Angaben">
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{missingFields.map(f => (
<Chip key={f} label={f} size="small" color="warning" variant="outlined" />
))}
</Stack>
</Section>
)}
</Card>
)
}
@@ -1,74 +0,0 @@
import { useState } from 'react'
import { Box, IconButton, TextField, Typography } from '@mui/material'
import { Pencil } from 'lucide-react'
interface Props {
label: string
value: string
confidence?: number
missing?: boolean
onEdit?: (value: string) => void
}
export function ExtractedFieldRow({ label, value, missing = false, onEdit }: Props) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
function commit() {
setEditing(false)
if (editValue !== value) onEdit?.(editValue)
}
if (editing) {
return (
<Box sx={{ py: 1, borderBottom: '1px solid #f1f5f9' }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
{label}
</Typography>
<TextField
size="small"
fullWidth
value={editValue}
autoFocus
onChange={e => setEditValue(e.target.value)}
onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') commit() }}
/>
</Box>
)
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 1,
py: 1,
borderBottom: '1px solid #f1f5f9',
opacity: missing ? 0.55 : 1,
'&:hover .edit-btn': { visibility: 'visible' },
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontStyle: missing ? 'italic' : 'normal' }}>
{value || '—'}
</Typography>
</Box>
{onEdit && (
<IconButton
size="small"
className="edit-btn"
sx={{ visibility: 'hidden', p: 0.25, flexShrink: 0 }}
onClick={() => { setEditValue(value); setEditing(true) }}
>
<Pencil size={12} />
</IconButton>
)}
</Box>
)
}
@@ -1,76 +0,0 @@
import { Box, Button, Card, Typography } from '@mui/material'
import { ArrowRight, RefreshCw } from 'lucide-react'
import type { FollowUpQuestion } from '../../domain/needBuilder'
import { FollowUpQuestionCard } from './FollowUpQuestionCard'
interface Props {
questions: FollowUpQuestion[]
answers: Record<string, string>
onAnswer: (id: string, answer: string) => void
onContinue: () => void
onReparse?: () => void
}
export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onReparse }: Props) {
const requiredUnanswered = questions
.filter(q => q.importance === 'required')
.filter(q => !answers[q.id])
const sorted = [
...questions.filter(q => q.importance === 'required'),
...questions.filter(q => q.importance === 'recommended'),
...questions.filter(q => q.importance === 'optional'),
]
return (
<Card sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 0.5 }}>
Rückfragen der KI
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
Beantworten Sie die Pflichtfelder für optimale Ergebnisse. Optionale Fragen können übersprungen werden.
</Typography>
<Box sx={{ flex: 1, overflow: 'auto' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{sorted.map(q => (
<FollowUpQuestionCard
key={q.id}
question={q}
answer={answers[q.id] ?? ''}
onAnswer={ans => onAnswer(q.id, ans)}
/>
))}
</Box>
</Box>
<Box sx={{ pt: 2, mt: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
{requiredUnanswered.length > 0 && (
<Typography variant="caption" color="error">
{requiredUnanswered.length} Pflichtfeld{requiredUnanswered.length > 1 ? 'er fehlen' : ' fehlt'} noch.
</Typography>
)}
{onReparse && (
<Button
variant="outlined"
size="small"
startIcon={<RefreshCw size={14} />}
onClick={onReparse}
>
Rückfragen neu generieren
</Button>
)}
<Button
variant="contained"
fullWidth
disabled={requiredUnanswered.length > 0}
onClick={onContinue}
endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Weiter zur Gewichtung
</Button>
</Box>
</Card>
)
}
@@ -1,66 +0,0 @@
import { Box, Chip, Stack, TextField, Typography } from '@mui/material'
import type { FollowUpQuestion } from '../../domain/needBuilder'
interface Props {
question: FollowUpQuestion
answer: string
onAnswer: (answer: string) => void
}
const IMPORTANCE_LABEL: Record<FollowUpQuestion['importance'], string> = {
required: 'Pflichtfeld',
recommended: 'Empfohlen',
optional: 'Optional',
}
const IMPORTANCE_COLOR: Record<FollowUpQuestion['importance'], 'error' | 'warning' | 'default'> = {
required: 'error',
recommended: 'warning',
optional: 'default',
}
export function FollowUpQuestionCard({ question, answer, onAnswer }: Props) {
return (
<Box sx={{ pb: 2, borderBottom: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500, flex: 1 }}>
{question.questionText}
</Typography>
<Chip
label={IMPORTANCE_LABEL[question.importance]}
size="small"
color={IMPORTANCE_COLOR[question.importance]}
variant="outlined"
sx={{ flexShrink: 0 }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{question.reason}
</Typography>
{question.suggestedAnswerOptions && question.suggestedAnswerOptions.length > 0 ? (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{question.suggestedAnswerOptions.map(opt => (
<Chip
key={opt}
label={opt}
size="small"
clickable
variant={answer === opt ? 'filled' : 'outlined'}
color={answer === opt ? 'primary' : 'default'}
onClick={() => onAnswer(answer === opt ? '' : opt)}
/>
))}
</Stack>
) : (
<TextField
size="small"
fullWidth
placeholder="Ihre Antwort (optional)"
value={answer}
onChange={e => onAnswer(e.target.value)}
/>
)}
</Box>
)
}
@@ -1,31 +0,0 @@
import { Box, Button, Typography } from '@mui/material'
import { AlertTriangle, RotateCcw } from 'lucide-react'
interface Props {
message: string
onRetry: () => void
}
export function NeedBuilderErrorState({ message, onRetry }: Props) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 12, gap: 3, maxWidth: 480, mx: 'auto' }}>
<AlertTriangle size={48} color="#c0392b" />
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}>
Analyse fehlgeschlagen
</Typography>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Box>
<Button
variant="contained"
startIcon={<RotateCcw size={16} />}
onClick={onRetry}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Erneut versuchen
</Button>
</Box>
)
}
@@ -1,34 +0,0 @@
import { Box, Stepper, Step, StepLabel } from '@mui/material'
import type { NeedBuilderStep } from '../../domain/needBuilder'
import { NeedBuilderStep as S } from '../../domain/needBuilder'
interface Props {
step: NeedBuilderStep
}
const STEPS = ['Suchkriterien & Gewichtung', 'Vorschau & Speichern']
function toStepIndex(step: NeedBuilderStep): number {
if (
step === S.IDLE ||
step === S.PARSING ||
step === S.PARSED_REQUIRES_REVIEW ||
step === S.CLARIFICATION_REQUIRED
) return 0
return 1
}
export function NeedBuilderProgress({ step }: Props) {
if (step === S.IDLE) return null
return (
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc' }}>
<Stepper activeStep={toStepIndex(step)} alternativeLabel>
{STEPS.map(label => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
</Box>
)
}
@@ -1,184 +0,0 @@
import { Box, Card, Chip, Divider, LinearProgress, Stack, TextField, Typography, Alert } from '@mui/material'
import { MapPin, Ruler, Wallet, Clock, CheckSquare, ShieldAlert } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
interface Props {
criteria: ParsedNeedCriteria
weights: Record<WeightingKey, number>
confidenceByField: Record<string, number>
missingFields: string[]
needTitle: string
onNeedTitleChange: (v: string) => void
}
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
}
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
export function NeedCardPreview({ criteria: c, weights, confidenceByField, missingFields, needTitle, onNeedTitleChange }: Props) {
const maxWeight = Math.max(...WEIGHTING_KEYS.map(k => weights[k] ?? 0), 0.01)
const fieldEntries = Object.entries(confidenceByField)
const overallConfidence = fieldEntries.length > 0
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
: 0
const lowConfidenceFields = fieldEntries.filter(([, v]) => v < 0.6).map(([k]) => k)
const criticalMissing = missingFields.filter(f => CRITICAL_FIELDS.some(cf => f.toLowerCase().includes(cf.toLowerCase())))
const isLowConfidence = overallConfidence < 0.6
return (
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Vorschau Neuer Bedarf
</Typography>
{/* Need Title */}
<TextField
fullWidth
size="small"
label="Bedarf Bezeichnung"
placeholder="z.B. Bürofläche Zürich Q4/2025"
value={needTitle}
onChange={e => onNeedTitleChange(e.target.value)}
sx={{ mb: 2 }}
/>
{/* Low-confidence warning */}
{isLowConfidence && (
<Alert severity="warning" icon={<ShieldAlert size={18} />} sx={{ mb: 2 }}>
Gesamtkonfidenz niedrig ({Math.round(overallConfidence * 100)}%) Bedarf wird als Entwurf gespeichert und muss manuell geprüft werden.
</Alert>
)}
{/* Critical missing fields */}
{criticalMissing.length > 0 && (
<Alert severity="error" sx={{ mb: 2 }}>
Fehlende Pflichtfelder: {criticalMissing.join(', ')}. Bitte in den Kriterien ergänzen.
</Alert>
)}
<Card sx={{ p: 3, mb: 2 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{c.assetType && (
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white' }} />
)}
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mb: 2 }}>
{c.preferredLocations && c.preferredLocations.length > 0 && (
<Box sx={{ display: 'flex', gap: 1 }}>
<MapPin size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Standort</Typography>
<Typography variant="body2">{c.preferredLocations.join(', ')}</Typography>
</Box>
</Box>
)}
{c.areaRange && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Ruler size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Fläche</Typography>
<Typography variant="body2">{c.areaRange.min}{c.areaRange.max} m²</Typography>
</Box>
</Box>
)}
{c.budgetRange && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Wallet size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Budget</Typography>
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²</Typography>
</Box>
</Box>
)}
{c.timing && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Clock size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Verfügbarkeit</Typography>
<Typography variant="body2">ab {c.timing.earliestMoveIn}</Typography>
</Box>
</Box>
)}
</Box>
{c.mustHaveCriteria && c.mustHaveCriteria.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 0.5 }}>
<CheckSquare size={16} color="#64748b" />
<Typography variant="caption" color="text.secondary">Must-haves</Typography>
</Box>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{c.mustHaveCriteria.map(m => (
<Chip key={m} label={m} size="small" variant="outlined" />
))}
</Stack>
</Box>
)}
<Divider sx={{ my: 2 }} />
{/* Confidence Summary */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }} color="text.secondary">
Gesamtkonfidenz
</Typography>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b' }}
>
{Math.round(overallConfidence * 100)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={overallConfidence * 100}
sx={{
height: 6, borderRadius: 3, bgcolor: '#e2e8f0',
'& .MuiLinearProgress-bar': {
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
},
}}
/>
{lowConfidenceFields.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Unsichere Felder: {lowConfidenceFields.join(', ')}
</Typography>
)}
</Box>
<Divider sx={{ my: 2 }} />
{/* Weights */}
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
Gewichtungsprofil
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{WEIGHTING_KEYS.map(k => {
const pct = Math.round((weights[k] ?? 0) * 100)
return (
<Box key={k} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" sx={{ width: 130, flexShrink: 0 }}>{WEIGHTING_LABELS[k]}</Typography>
<LinearProgress
variant="determinate"
value={((weights[k] ?? 0) / maxWeight) * 100}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
/>
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
</Box>
)
})}
</Box>
</Card>
</Box>
)
}
@@ -1,155 +0,0 @@
import { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
interface Props {
criteria: ParsedNeedCriteria
onCriteriaChange: (c: ParsedNeedCriteria) => void
}
const ASSET_OPTIONS = [
{ label: 'Büro', value: AssetType.OFFICE },
{ label: 'Retail', value: AssetType.RETAIL },
{ label: 'Logistik', value: AssetType.LOGISTICS },
{ label: 'Produktion', value: AssetType.PRODUCTION },
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gemischt', value: AssetType.MIXED },
]
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
{children}
</Typography>
)
}
export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
const [locationDraft, setLocationDraft] = useState('')
const [mustHaveDraft, setMustHaveDraft] = useState('')
function addLocations(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, preferredLocations: [...new Set([...(c.preferredLocations ?? []), ...tokens])] })
setLocationDraft('')
}
function addMustHaves(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return
set({ ...c, mustHaveCriteria: [...new Set([...(c.mustHaveCriteria ?? []), ...tokens])] })
setMustHaveDraft('')
}
return (
<Card elevation={0} sx={{ p: 3, border: '1px solid #e2e8f0', height: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>Kriterien verfeinern</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2.5 }}>
Ergänzen oder korrigieren Sie die extrahierten Felder.
</Typography>
{/* Asset Type */}
<FieldLabel>Nutzungstyp</FieldLabel>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{ASSET_OPTIONS.map(opt => (
<Chip
key={opt.value}
label={opt.label}
size="small"
variant={c.assetType === opt.value ? 'filled' : 'outlined'}
clickable
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === opt.value
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
: {}}
/>
))}
</Stack>
{/* Area */}
<FieldLabel>Fläche (m²)</FieldLabel>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2.5 }}>
<TextField
size="small" type="number" placeholder="Min"
value={c.areaRange?.min || ''}
onChange={e => set({ ...c, areaRange: { min: parseInt(e.target.value) || 0, max: c.areaRange?.max ?? 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="body2" color="text.secondary"></Typography>
<TextField
size="small" type="number" placeholder="Max"
value={c.areaRange?.max || ''}
onChange={e => set({ ...c, areaRange: { min: c.areaRange?.min ?? 0, max: parseInt(e.target.value) || 0 } })}
sx={{ width: 100 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
<Typography variant="caption" color="text.secondary">m²</Typography>
</Box>
{/* Location */}
<FieldLabel>Standort</FieldLabel>
<TextField
size="small" fullWidth
placeholder="Stadt oder Region — Enter zum Hinzufügen"
value={locationDraft}
onChange={e => setLocationDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && locationDraft.trim()) addLocations(locationDraft) }}
onBlur={() => { if (locationDraft.trim()) addLocations(locationDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.preferredLocations?.length ?? 0) > 0 ? (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}>
{c.preferredLocations!.map(loc => (
<Chip key={loc} label={loc} size="small"
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
/>
))}
</Stack>
) : <Box sx={{ mb: 2.5 }} />}
{/* Budget */}
<FieldLabel>Budget (max CHF/m²)</FieldLabel>
<TextField
size="small" type="number" placeholder="z.B. 45"
value={c.budgetRange?.maxPerSqm || ''}
onChange={e => set({ ...c, budgetRange: { maxPerSqm: parseInt(e.target.value) || 0, currency: 'CHF' } })}
sx={{ width: 160, mb: 2.5 }}
slotProps={{ htmlInput: { min: 0 } }}
/>
{/* Timing */}
<FieldLabel>Verfügbar ab</FieldLabel>
<TextField
size="small"
placeholder="z.B. Q3 2025 oder 01.09.2025"
value={c.timing?.earliestMoveIn ?? ''}
onChange={e => set({ ...c, timing: { earliestMoveIn: e.target.value, latestMoveIn: e.target.value, flexibleTiming: true } })}
sx={{ width: 220, mb: 2.5 }}
/>
{/* Must-haves */}
<FieldLabel>Must-haves</FieldLabel>
<TextField
size="small" fullWidth
placeholder="z.B. ÖV-Anbindung, Parkplätze — Enter zum Hinzufügen"
value={mustHaveDraft}
onChange={e => setMustHaveDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
onBlur={() => { if (mustHaveDraft.trim()) addMustHaves(mustHaveDraft) }}
sx={{ mb: 0.75 }}
/>
{(c.mustHaveCriteria?.length ?? 0) > 0 && (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{c.mustHaveCriteria!.map(item => (
<Chip key={item} label={item} size="small"
onDelete={() => set({ ...c, mustHaveCriteria: c.mustHaveCriteria!.filter(m => m !== item) })}
/>
))}
</Stack>
)}
</Card>
)
}
@@ -1,202 +0,0 @@
import { useRef, useState } from 'react'
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import { Mic, MicOff, Sparkles, X } from 'lucide-react'
interface Props {
text: string
onTextChange: (s: string) => void
onAiSubmit: () => void
isAnalyzing: boolean
isAutoGen: boolean
}
const EXAMPLES = [
'Büro 8001000 m² Zürich-West, ab Sept. 2025, max. CHF 45/m², ÖV-Anbindung',
'Retail-Fläche 200400 m² Bern Innenstadt, Erdgeschoss, max. CHF 150/m², sofort',
'Lagerhalle 20003000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
]
const isSpeechSupported = typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
const [isRecording, setIsRecording] = useState(false)
const [interimText, setInterimText] = useState('')
const recognitionRef = useRef<any>(null)
const accumulatedRef = useRef('')
function startRecording() {
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition
if (!SpeechAPI) return
accumulatedRef.current = text
const rec = new SpeechAPI()
rec.lang = 'de-DE'
rec.continuous = true
rec.interimResults = true
rec.onresult = (e: any) => {
let finalPart = ''
let interimPart = ''
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript
if (e.results[i].isFinal) finalPart += t
else interimPart += t
}
if (finalPart) {
accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim()
onTextChange(accumulatedRef.current)
}
setInterimText(interimPart)
}
rec.onend = () => {
setIsRecording(false)
setInterimText('')
if (accumulatedRef.current.length >= 15) onAiSubmit()
}
rec.onerror = () => { setIsRecording(false); setInterimText('') }
rec.start()
recognitionRef.current = rec
setIsRecording(true)
}
function stopRecording() {
recognitionRef.current?.stop()
}
// Show interim text inside the field while recording
const displayValue = isRecording && interimText
? (text + (text ? ' ' : '') + interimText)
: text
return (
<Card
elevation={0}
sx={{
p: 3,
border: '1px solid',
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
transition: 'border-color 0.2s',
bgcolor: isRecording ? '#fff5f5' : 'white',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
Bedarf beschreiben
</Typography>
<Typography variant="caption" color="text.secondary">
Schreiben oder sprechen die KI extrahiert alle Kriterien automatisch
</Typography>
</Box>
{isRecording ? (
<Chip
size="small"
label="🎤 Aufnahme läuft…"
onClick={stopRecording}
sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
/>
) : isAnalyzing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert</Typography>
</Box>
) : isAutoGen && text ? (
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}> auto-synchronisiert</Typography>
) : null}
</Box>
{/* Textarea + mic */}
<Box sx={{ position: 'relative' }}>
<TextField
multiline
rows={4}
fullWidth
placeholder="Wir suchen 8001.000 m² Bürofläche in Zürich-West, verfügbar ab September 2025, Budget max. 45 CHF/m². Wichtig: gute ÖV-Anbindung, moderne Infrastruktur."
value={displayValue}
onChange={e => {
setInterimText('')
onTextChange(e.target.value)
}}
disabled={isRecording || isAnalyzing}
slotProps={{ htmlInput: { maxLength: 2000 } }}
sx={{
'& .MuiOutlinedInput-root': {
pr: '52px',
bgcolor: isAutoGen && !isRecording ? '#f0f7ff' : 'transparent',
transition: 'background-color 0.2s',
'& textarea': { color: isRecording && interimText ? '#64748b' : 'inherit' },
},
}}
/>
<Box sx={{ position: 'absolute', bottom: 10, right: 10 }}>
{isRecording ? (
<IconButton
onClick={stopRecording}
size="small"
sx={{
bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' },
animation: 'micPulse 1.2s ease-in-out infinite',
'@keyframes micPulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' },
'50%': { boxShadow: '0 0 0 6px rgba(220,38,38,0)' },
},
}}
>
<MicOff size={16} />
</IconButton>
) : (
<IconButton
onClick={isSpeechSupported ? startRecording : undefined}
size="small"
disabled={isAnalyzing || !isSpeechSupported}
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
>
<Mic size={16} />
</IconButton>
)}
</Box>
</Box>
{/* Actions row */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5 }}>
{/* Example prompts */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{EXAMPLES.map((ex, i) => (
<Chip
key={i}
label={ex.slice(0, 32) + '…'}
size="small"
variant="outlined"
clickable
onClick={() => onTextChange(ex)}
sx={{ fontSize: 10, height: 20 }}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
{text && !isRecording && (
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}>
<X size={14} />
</IconButton>
)}
<Button
size="small"
variant="outlined"
disabled={!text.trim() || isAnalyzing || isRecording}
onClick={onAiSubmit}
endIcon={<Sparkles size={13} />}
sx={{ fontSize: 12, whiteSpace: 'nowrap' }}
>
KI Auto-fill
</Button>
</Box>
</Box>
</Card>
)
}
@@ -1,98 +0,0 @@
import { useState } from 'react'
import { Box, Button, Card, Slider, Typography } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
import { weightingService } from '../../services/weightingService'
interface Props {
weights: Record<WeightingKey, number>
onChange: (weights: Record<WeightingKey, number>) => void
assetType?: string
}
const IMPORTANCE_LABELS = ['', 'Unwichtig', 'Wenig wichtig', 'Wichtig', 'Sehr wichtig', 'Entscheidend']
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
if (max === 0) return Object.fromEntries(WEIGHTING_KEYS.map(k => [k, 3])) as Record<WeightingKey, number>
return Object.fromEntries(
WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))])
) as Record<WeightingKey, number>
}
function rawToWeights(raw: Record<WeightingKey, number>): Record<WeightingKey, number> {
const total = WEIGHTING_KEYS.reduce((s, k) => s + (raw[k] ?? 1), 0)
return Object.fromEntries(
WEIGHTING_KEYS.map(k => [k, (raw[k] ?? 1) / total])
) as Record<WeightingKey, number>
}
export function WeightingEditor({ weights, onChange, assetType }: Props) {
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
function handleSlider(key: WeightingKey, value: number) {
const updated = { ...raw, [key]: value }
setRaw(updated)
onChange(rawToWeights(updated))
}
function handleReset() {
const defaults = weightingService.getDefaultWeights(assetType)
setRaw(toRaw(defaults))
onChange(defaults)
}
return (
<Box sx={{ maxWidth: 720, mx: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Wichtigkeit der Kriterien
</Typography>
<Typography variant="caption" color="text.secondary">
Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet.
</Typography>
</Box>
<Button
size="small"
variant="outlined"
startIcon={<RotateCcw size={14} />}
onClick={handleReset}
>
Zurücksetzen
</Button>
</Box>
<Card sx={{ p: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{WEIGHTING_KEYS.map(key => {
const importance = raw[key] ?? 3
return (
<Box key={key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{WEIGHTING_LABELS[key]}
</Typography>
<Typography variant="caption" color="text.secondary">
{IMPORTANCE_LABELS[importance]}
</Typography>
</Box>
<Slider
value={importance}
min={1}
max={5}
step={1}
marks
onChange={(_, v) => handleSlider(key, v as number)}
size="small"
sx={{ color: '#1e3a5f' }}
/>
</Box>
)
})}
</Box>
</Card>
</Box>
)
}
@@ -1,11 +0,0 @@
export { ConfidenceFieldBadge } from './ConfidenceFieldBadge'
export { CriteriaReviewPanel } from './CriteriaReviewPanel'
export { ExtractedFieldRow } from './ExtractedFieldRow'
export { FollowUpPanel } from './FollowUpPanel'
export { FollowUpQuestionCard } from './FollowUpQuestionCard'
export { NeedBuilderErrorState } from './NeedBuilderErrorState'
export { NeedBuilderProgress } from './NeedBuilderProgress'
export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor'
@@ -1,53 +0,0 @@
import { Box, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
import type { ConfidenceLevel } from '../../domain/enums'
import { ConfidenceBadge } from '../badges/ConfidenceBadge'
interface FieldWithConfidenceProps {
label: string
value: ReactNode
confidence?: number
confidenceLevel?: ConfidenceLevel
layout?: 'row' | 'column'
sx?: SxProps<Theme>
}
export function FieldWithConfidence({
label,
value,
confidence,
confidenceLevel,
layout = 'column',
sx,
}: FieldWithConfidenceProps) {
const showBadge = confidence !== undefined || confidenceLevel !== undefined
if (layout === 'row') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ...sx }}>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', minWidth: 80 }}>
{label}
</Typography>
<Box sx={{ flex: 1 }}>{value}</Box>
{showBadge && (
<ConfidenceBadge level={confidenceLevel} score={confidence} />
)}
</Box>
)
}
return (
<Box sx={sx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.25 }}>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
{label}
</Typography>
{showBadge && (
<ConfidenceBadge level={confidenceLevel} score={confidence} />
)}
</Box>
<Box sx={{ fontSize: '0.875rem' }}>{value}</Box>
</Box>
)
}
@@ -1,64 +0,0 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
interface ChipOption {
value: string
label: string
color?: string
}
interface PriorityChipGroupProps {
label?: string
options: ChipOption[]
value: string[]
onChange: (value: string[]) => void
exclusive?: boolean
sx?: SxProps<Theme>
}
export function PriorityChipGroup({ label, options, value, onChange, exclusive = false, sx }: PriorityChipGroupProps) {
function toggle(optValue: string) {
if (exclusive) {
onChange(value.includes(optValue) ? [] : [optValue])
} else {
onChange(
value.includes(optValue)
? value.filter(v => v !== optValue)
: [...value, optValue],
)
}
}
return (
<Box sx={sx}>
{label && (
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary', mb: 0.75, fontWeight: 500 }}>
{label}
</Typography>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{options.map(opt => {
const selected = value.includes(opt.value)
const accent = opt.color ?? '#1e3a5f'
return (
<Chip
key={opt.value}
label={opt.label}
size="small"
clickable
onClick={() => toggle(opt.value)}
sx={{
fontSize: '0.75rem',
bgcolor: selected ? accent : 'transparent',
color: selected ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: selected ? accent : 'divider',
'&:hover': { bgcolor: selected ? accent : 'rgba(0,0,0,0.04)' },
}}
/>
)
})}
</Box>
</Box>
)
}
@@ -1,55 +0,0 @@
import { FormControl, FormHelperText, InputLabel, MenuItem, Select } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
interface SelectOption<T extends string = string> {
value: T
label: string
}
interface SelectFieldProps<T extends string = string> {
label: string
value: T | ''
onChange: (value: T) => void
options: SelectOption<T>[]
error?: string
helperText?: string
required?: boolean
disabled?: boolean
size?: 'small' | 'medium'
fullWidth?: boolean
sx?: SxProps<Theme>
}
export function SelectField<T extends string = string>({
label,
value,
onChange,
options,
error,
helperText,
required,
disabled,
size = 'small',
fullWidth = true,
sx,
}: SelectFieldProps<T>) {
const labelId = `select-${label.replace(/\s+/g, '-').toLowerCase()}`
return (
<FormControl fullWidth={fullWidth} size={size} error={!!error} disabled={disabled} required={required} sx={sx}>
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
value={value}
label={label}
onChange={e => onChange(e.target.value as T)}
>
{options.map(opt => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
{(error ?? helperText) && <FormHelperText>{error ?? helperText}</FormHelperText>}
</FormControl>
)
}
@@ -1,52 +0,0 @@
import { TextField } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
interface TextInputProps {
label: string
value: string
onChange: (value: string) => void
error?: string
helperText?: string
placeholder?: string
required?: boolean
disabled?: boolean
multiline?: boolean
rows?: number
size?: 'small' | 'medium'
fullWidth?: boolean
sx?: SxProps<Theme>
}
export function TextInput({
label,
value,
onChange,
error,
helperText,
placeholder,
required,
disabled,
multiline,
rows,
size = 'small',
fullWidth = true,
sx,
}: TextInputProps) {
return (
<TextField
label={label}
value={value}
onChange={e => onChange(e.target.value)}
error={!!error}
helperText={error ?? helperText}
placeholder={placeholder}
required={required}
disabled={disabled}
multiline={multiline}
rows={rows}
size={size}
fullWidth={fullWidth}
sx={sx}
/>
)
}
@@ -1,4 +0,0 @@
export { TextInput } from './TextInput'
export { SelectField } from './SelectField'
export { PriorityChipGroup } from './PriorityChipGroup'
export { FieldWithConfidence } from './FieldWithConfidence'
@@ -1,101 +0,0 @@
import { Box, Chip, LinearProgress, Typography } from '@mui/material'
import { SignalTypeBadge } from './SignalTypeBadge'
import { SensitivityBadge } from './SensitivityBadge'
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import type { FutureSignal } from '../../domain/futureSignal'
const SOURCE_LABELS: Record<string, string> = {
PRESS: 'Presse',
CONSTRUCTION_PERMIT: 'Baubewilligung',
JOB_POSTING: 'Stelleninserat',
COMPANY_REPORT: 'Geschäftsbericht',
MARKET_DATA: 'Marktdaten',
MANUAL: 'Manuell',
}
function probColor(p: number): string {
return p >= 0.7 ? '#1a7a4a' : p >= 0.5 ? '#d97706' : '#c0392b'
}
interface Props {
signal: FutureSignal
isSelected: boolean
onSelect: (signal: FutureSignal) => void
}
export function FutureSignalCard({ signal, isSelected, onSelect }: Props) {
const isConfidential = signal.sensitivityLevel === 'CONFIDENTIAL'
return (
<Box
onClick={() => onSelect(signal)}
sx={{
p: 2,
cursor: 'pointer',
borderBottom: '1px solid #f1f5f9',
borderLeft: isSelected
? '3px solid #1e3a5f'
: isConfidential
? '3px solid #d97706'
: '3px solid transparent',
bgcolor: isSelected ? '#eff6ff' : isConfidential ? '#fffbeb' : 'transparent',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
}}
>
{/* Row 1: type + sensitivity + review status */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
<SignalTypeBadge type={signal.signalType} />
<SensitivityBadge level={signal.sensitivityLevel} />
<SignalReviewStatusBadge status={signal.reviewStatus} />
</Box>
{/* Row 2: title / company / location */}
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.25 }}>
{signal.title ?? signal.companyName ?? signal.locationHint}
</Typography>
{(signal.title || signal.companyName) && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75 }}>
{signal.locationHint}
</Typography>
)}
{/* Row 3: probability */}
<Box sx={{ mb: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">Wahrscheinlichkeit</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: probColor(signal.probability) }}>
{Math.round(signal.probability * 100)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={signal.probability * 100}
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#f1f5f9',
'& .MuiLinearProgress-bar': { bgcolor: probColor(signal.probability) },
}}
/>
</Box>
{/* Row 4: meta chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 1 }}>
<Chip label={`${signal.timeHorizonMonths}M`} size="small" sx={{ fontSize: 10, height: 18 }} />
{signal.areaSqmEstimate && (
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')}`} size="small" sx={{ fontSize: 10, height: 18 }} />
)}
<Chip
label={SOURCE_LABELS[signal.source.type] ?? signal.source.type}
size="small"
variant="outlined"
sx={{ fontSize: 10, height: 18 }}
/>
</Box>
{/* Footer: mini disclaimer */}
<FutureSignalDisclaimer mini />
</Box>
)
}
@@ -1,263 +0,0 @@
import { Box, Button, Chip, CircularProgress, Divider, IconButton, LinearProgress, Paper, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useState } from 'react'
import { SignalTypeBadge } from './SignalTypeBadge'
import { SensitivityBadge } from './SensitivityBadge'
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore'
import { reviewService } from '../../services/reviewService'
import { ReviewStatus } from '../../domain/enums'
import type { FutureSignal } from '../../domain/futureSignal'
const SOURCE_LABELS: Record<string, string> = {
PRESS: 'Pressebericht',
CONSTRUCTION_PERMIT: 'Baubewilligung',
JOB_POSTING: 'Stelleninserat',
COMPANY_REPORT: 'Geschäftsbericht',
MARKET_DATA: 'Marktdaten',
MANUAL: 'Manuell erfasst',
}
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hoch', color: '#1a7a4a' },
MEDIUM: { label: 'Mittel', color: '#d97706' },
LOW: { label: 'Niedrig', color: '#c0392b' },
}
function BarRow({ label, value }: { label: string; value: number }) {
const color = value >= 0.75 ? '#1a7a4a' : value >= 0.55 ? '#d97706' : '#c0392b'
return (
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color }}>{Math.round(value * 100)}%</Typography>
</Box>
<LinearProgress
variant="determinate"
value={value * 100}
sx={{ height: 5, borderRadius: 2, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
/>
</Box>
)
}
interface Props {
signal: FutureSignal
onClose: () => void
}
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
const updateStatus = useUpdateSignalReviewStatus()
const { openAddDialog } = useShortlistStore()
const showToast = useToastStore((s) => s.showToast)
const [reviewTaskSent, setReviewTaskSent] = useState(false)
const reviewStatus = signal.reviewStatus ?? ReviewStatus.UNREVIEWED
const isRejected = reviewStatus === ReviewStatus.REJECTED
const isApproved = reviewStatus === ReviewStatus.APPROVED
const STATUS_TOAST: Record<string, string> = {
IN_REVIEW: 'Signal zur Prüfung markiert.',
APPROVED: 'Signal genehmigt.',
REJECTED: 'Signal abgelehnt.',
FLAGGED: 'Signal markiert.',
}
async function handleStatus(status: typeof ReviewStatus[keyof typeof ReviewStatus]) {
try {
await updateStatus.mutateAsync({ id: signal.id, status })
showToast(STATUS_TOAST[status] ?? 'Status aktualisiert.')
} catch {
showToast('Statusänderung fehlgeschlagen.', 'error')
}
}
async function handleSendReview() {
try {
await reviewService.createReviewTask(signal.id)
setReviewTaskSent(true)
showToast('Prüfungsaufgabe erstellt.')
} catch {
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
}
}
function handleShortlist() {
openAddDialog({
resultId: signal.id,
resultType: 'FUTURE_AVAILABILITY',
title: signal.title ?? signal.companyName ?? signal.locationHint,
matchScore: Math.round(signal.confidenceScore * 100),
confidenceScore: signal.confidenceScore,
sourceLabel: SOURCE_LABELS[signal.source.type] ?? signal.source.type,
addedBy: 'admin@ideal-sharing.ch',
})
}
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? { label: signal.source.credibility, color: '#64748b' }
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ flex: 1, mr: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{signal.title ?? signal.companyName ?? signal.locationHint}
</Typography>
{(signal.title || signal.companyName) && (
<Typography variant="caption" color="text.secondary">{signal.locationHint}</Typography>
)}
</Box>
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8', mt: -0.5 }}>
<X size={16} />
</IconButton>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
<SignalTypeBadge type={signal.signalType} />
<SensitivityBadge level={signal.sensitivityLevel} />
<SignalReviewStatusBadge status={signal.reviewStatus} />
{signal.isVerified && (
<Chip label="Verifiziert" size="small" sx={{ bgcolor: '#1a7a4a', color: 'white', fontSize: 10 }} />
)}
</Box>
</Box>
{/* Body */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
<FutureSignalDisclaimer />
{/* Probability + confidence */}
<BarRow label="Wahrscheinlichkeit" value={signal.probability} />
<BarRow label="Konfidenz" value={signal.confidenceScore} />
<Divider sx={{ my: 1.5 }} />
{/* Meta */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Zeithorizont</Typography>
<Typography variant="caption">~{signal.timeHorizonMonths} Monate</Typography>
</Box>
{signal.areaSqmEstimate && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Geschätzte Fläche</Typography>
<Typography variant="caption">~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
</Box>
)}
{signal.companyName && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Unternehmen</Typography>
<Typography variant="caption">{signal.companyName}</Typography>
</Box>
)}
{signal.riskLevel && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Risikoniveau</Typography>
<Typography variant="caption">{signal.riskLevel}</Typography>
</Box>
)}
</Box>
<Divider sx={{ my: 1.5 }} />
{/* Source */}
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Quelle</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Typ</Typography>
<Typography variant="caption">{SOURCE_LABELS[signal.source.type] ?? signal.source.type}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Glaubwürdigkeit</Typography>
<Chip label={credMeta.label} size="small" sx={{ bgcolor: credMeta.color, color: 'white', fontSize: 10, height: 18 }} />
</Box>
{signal.source.publishedAt && (
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Veröffentlicht</Typography>
<Typography variant="caption">{signal.source.publishedAt}</Typography>
</Box>
)}
</Box>
{/* Evidence */}
{signal.evidence?.summary && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Evidenz</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: '#f8fafc' }}>
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
</Paper>
</>
)}
{/* Market indicator */}
{signal.marketIndicator && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>Marktindikator</Typography>
<Typography variant="body2" color="text.secondary">{signal.marketIndicator}</Typography>
</>
)}
<Divider sx={{ my: 1.5 }} />
{/* Actions */}
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }}>Aktionen</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{reviewStatus === ReviewStatus.UNREVIEWED && (
<Button
fullWidth size="small" variant="outlined"
disabled={updateStatus.isPending}
onClick={() => handleStatus(ReviewStatus.IN_REVIEW)}
sx={{ justifyContent: 'flex-start' }}
>
Verfolgen (In Prüfung setzen)
</Button>
)}
{!isRejected && !reviewTaskSent && (
<Button
fullWidth size="small" variant="outlined"
onClick={handleSendReview}
sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}
>
Zur Prüfung senden
</Button>
)}
{(reviewStatus === ReviewStatus.IN_REVIEW || reviewStatus === ReviewStatus.FLAGGED) && !isApproved && (
<Button
fullWidth size="small" variant="contained"
disabled={updateStatus.isPending}
onClick={() => handleStatus(ReviewStatus.APPROVED)}
sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' }, justifyContent: 'flex-start' }}
endIcon={updateStatus.isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
>
Genehmigen
</Button>
)}
{!isRejected && (
<Button
fullWidth size="small" variant="outlined"
disabled={updateStatus.isPending}
onClick={() => handleStatus(ReviewStatus.REJECTED)}
sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}
>
Ablehnen
</Button>
)}
<Button
fullWidth size="small" variant="outlined"
onClick={handleShortlist}
sx={{ justifyContent: 'flex-start' }}
>
Zu Shortlist hinzufügen
</Button>
</Box>
</Box>
</Box>
)
}
@@ -1,27 +0,0 @@
import { Alert, Typography } from '@mui/material'
interface Props {
mini?: boolean
}
export function FutureSignalDisclaimer({ mini = false }: Props) {
if (mini) {
return (
<Alert severity="warning" sx={{ py: 0.25, px: 1, '& .MuiAlert-message': { py: 0.25 } }}>
<Typography variant="caption">Probabilistisches Signal keine bestätigte Fläche</Typography>
</Alert>
)
}
return (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Hinweis: Probabilistisches Zukunftssignal
</Typography>
<Typography variant="body2">
Dieses Signal basiert auf AI-Analyse öffentlicher Daten. Es handelt sich um keine bestätigte verfügbare Fläche.
Bitte ausschliesslich für strategische Beobachtung und interne Prüfung verwenden keine verbindlichen Aussagen gegenüber Dritten.
</Typography>
</Alert>
)
}
@@ -1,28 +0,0 @@
import { Box, Typography } from '@mui/material'
import { Filter, Radio } from 'lucide-react'
type EmptyContext = 'no-signals' | 'filtered-empty'
const META: Record<EmptyContext, { icon: React.ReactNode; title: string; desc: string }> = {
'no-signals': {
icon: <Radio size={32} color="#94a3b8" />,
title: 'Keine Signale vorhanden',
desc: 'Es wurden noch keine Zukunftssignale erfasst.',
},
'filtered-empty': {
icon: <Filter size={32} color="#94a3b8" />,
title: 'Keine Signale für diese Filter',
desc: 'Passen Sie die Filtereinstellungen an, um Signale anzuzeigen.',
},
}
export function FutureSignalEmptyState({ context }: { context: EmptyContext }) {
const { icon, title, desc } = META[context]
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 8, px: 3 }}>
{icon}
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} color="text.secondary">{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', maxWidth: 280 }}>{desc}</Typography>
</Box>
)
}
@@ -1,143 +0,0 @@
import { Box, Chip, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material'
import { SignalType } from '../../domain/enums'
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
export interface SignalFilterState {
signalType: string
minConfidence: number
sensitivityLevel: string
reviewStatus: string
timeHorizon: string
}
export const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
signalType: '',
minConfidence: 0,
sensitivityLevel: '',
reviewStatus: '',
timeHorizon: '',
}
interface Props {
filters: SignalFilterState
onChange: (f: SignalFilterState) => void
totalCount: number
filteredCount: number
}
const SIGNAL_TYPES = Object.values(SignalType)
export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredCount }: Props) {
const set = (partial: Partial<SignalFilterState>) => onChange({ ...filters, ...partial })
const activeCount = [
filters.signalType !== '',
filters.minConfidence > 0,
filters.sensitivityLevel !== '',
filters.reviewStatus !== '',
filters.timeHorizon !== '',
].filter(Boolean).length
return (
<Box sx={{ px: 3, py: 1.5, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{/* Signal type chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
<Chip
label="Alle"
size="small"
onClick={() => set({ signalType: '' })}
color={filters.signalType === '' ? 'primary' : 'default'}
sx={{ fontWeight: filters.signalType === '' ? 700 : 400 }}
/>
{SIGNAL_TYPES.map(t => (
<Chip
key={t}
label={SIGNAL_TYPE_LABELS[t] ?? t}
size="small"
onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
sx={{
fontWeight: filters.signalType === t ? 700 : 400,
bgcolor: filters.signalType === t ? '#1e3a5f' : undefined,
color: filters.signalType === t ? 'white' : undefined,
}}
/>
))}
</Box>
{/* Selects */}
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Konfidenz</InputLabel>
<Select
label="Konfidenz"
value={filters.minConfidence}
onChange={e => set({ minConfidence: Number(e.target.value) })}
>
<MenuItem value={0}>Alle</MenuItem>
<MenuItem value={0.75}>Hoch 75%</MenuItem>
<MenuItem value={0.55}>Mittel 55%</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Prüfstatus</InputLabel>
<Select
label="Prüfstatus"
value={filters.reviewStatus}
onChange={e => set({ reviewStatus: e.target.value })}
>
<MenuItem value="">Alle</MenuItem>
<MenuItem value="UNREVIEWED">Ungeprüft</MenuItem>
<MenuItem value="IN_REVIEW">In Prüfung</MenuItem>
<MenuItem value="APPROVED">Genehmigt</MenuItem>
<MenuItem value="REJECTED">Abgelehnt</MenuItem>
<MenuItem value="FLAGGED">Markiert</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Zeithorizont</InputLabel>
<Select
label="Zeithorizont"
value={filters.timeHorizon}
onChange={e => set({ timeHorizon: e.target.value })}
>
<MenuItem value="">Alle</MenuItem>
<MenuItem value="short">Kurz 6M</MenuItem>
<MenuItem value="medium">Mittel 712M</MenuItem>
<MenuItem value="long">Lang &gt;12M</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Vertraulichkeit</InputLabel>
<Select
label="Vertraulichkeit"
value={filters.sensitivityLevel}
onChange={e => set({ sensitivityLevel: e.target.value })}
>
<MenuItem value="">Alle</MenuItem>
<MenuItem value="PUBLIC">Öffentlich</MenuItem>
<MenuItem value="INTERNAL">Intern</MenuItem>
<MenuItem value="CONFIDENTIAL">Vertraulich</MenuItem>
</Select>
</FormControl>
</Box>
{/* Result count */}
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
{activeCount > 0 && (
<Chip
label={`${activeCount} Filter aktiv`}
size="small"
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f' }}
/>
)}
<Typography variant="caption" color="text.secondary">
{filteredCount} / {totalCount}
</Typography>
</Box>
</Box>
)
}
@@ -1,19 +0,0 @@
import { Chip } from '@mui/material'
const SENSITIVITY_META: Record<string, { label: string; color: string }> = {
PUBLIC: { label: 'Öffentlich', color: '#64748b' },
INTERNAL: { label: 'Intern', color: '#d97706' },
CONFIDENTIAL: { label: 'Vertraulich', color: '#c0392b' },
RESTRICTED: { label: 'Eingeschränkt', color: '#7c3aed' },
}
export function SensitivityBadge({ level }: { level: string }) {
const meta = SENSITIVITY_META[level] ?? { label: level, color: '#64748b' }
return (
<Chip
label={meta.label}
size="small"
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }}
/>
)
}
@@ -1,22 +0,0 @@
import { Chip } from '@mui/material'
import type { ReviewStatus } from '../../domain/enums'
const STATUS_META: Record<string, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
FLAGGED: { label: 'Markiert', color: '#ea580c' },
}
export function SignalReviewStatusBadge({ status }: { status?: ReviewStatus | null }) {
const key = status ?? 'UNREVIEWED'
const meta = STATUS_META[key] ?? { label: key, color: '#94a3b8' }
return (
<Chip
label={meta.label}
size="small"
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }}
/>
)
}
@@ -1,28 +0,0 @@
import { Chip } from '@mui/material'
import type { SignalType } from '../../domain/enums'
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
interface SignalTypeBadgeProps {
type: SignalType
size?: 'small' | 'medium'
}
const COLOR_MAP: Record<string, { bg: string; color: string }> = {
EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: '#1a7a4a' },
POSSIBLE_MOVE_OUT: { bg: 'rgba(217,119,6,0.12)', color: '#d97706' },
CONSTRUCTION_PROJECT: { bg: 'rgba(37,99,235,0.12)', color: '#1d4ed8' },
RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: '#c2410c' },
PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: '#6d28d9' },
SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: '#475569' },
}
export function SignalTypeBadge({ type, size = 'small' }: SignalTypeBadgeProps) {
const { bg, color } = COLOR_MAP[type] ?? { bg: '#f1f5f9', color: '#475569' }
return (
<Chip
label={SIGNAL_TYPE_LABELS[type] ?? type}
size={size}
sx={{ bgcolor: bg, color, fontWeight: 600, border: 'none' }}
/>
)
}
@@ -1,10 +0,0 @@
export { SignalTypeBadge } from './SignalTypeBadge'
export { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
export { SensitivityBadge } from './SensitivityBadge'
export { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
export { FutureSignalCard } from './FutureSignalCard'
export { FutureSignalFilterBar } from './FutureSignalFilterBar'
export { FutureSignalDetailPanel } from './FutureSignalDetailPanel'
export { FutureSignalEmptyState } from './FutureSignalEmptyState'
export type { SignalFilterState } from './FutureSignalFilterBar'
export { DEFAULT_SIGNAL_FILTERS } from './FutureSignalFilterBar'
@@ -1,553 +0,0 @@
import { useEffect } from 'react'
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
import { useLayoutStore } from '../../stores/layoutStore'
import { useSessionStore } from '../../stores/sessionStore'
import { WorkspaceType } from '../../domain/enums'
import {
Box,
Typography,
Avatar,
IconButton,
Tooltip,
Chip,
Button,
} from '@mui/material'
import {
LayoutDashboard,
Building2,
Target,
TrendingUp,
CheckSquare,
Search,
List,
Columns2,
Bookmark,
ClipboardList,
Activity,
Shield,
ChevronLeft,
ChevronRight,
Sparkles,
Clock,
Radar,
ServerCog,
GitBranch,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { OrganizationContextBadge } from './OrganizationContextBadge'
import { UserMenu } from './UserMenu'
import { NotificationButton } from './NotificationButton'
import { RightContextPanel } from './RightContextPanel'
import { CompareTray } from './CompareTray'
import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant'
import { useAssistantStore } from '../../stores/assistantStore'
import { ToastProvider } from '../ui'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NavItem {
path: string
label: string
icon: LucideIcon
}
interface WorkspaceConfig {
label: string
abbreviation: string
icon: LucideIcon
firstPath: string
navItems: NavItem[]
chipColor: string
}
// ---------------------------------------------------------------------------
// Workspace configuration
// ---------------------------------------------------------------------------
const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
[WorkspaceType.SUPPLY]: {
label: 'Verwaltung',
abbreviation: 'VW',
icon: Building2,
firstPath: '/supply/dashboard',
chipColor: '#1e3a5f',
navItems: [
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
{ path: '/supply/match-center', label: 'Eingehende Bedarfe', icon: Target },
{ path: '/supply/future-availability', label: 'Marktchancen', icon: TrendingUp },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
],
},
[WorkspaceType.DEMAND]: {
label: 'Suche',
abbreviation: 'SU',
icon: Search,
firstPath: '/demand/ai-search',
chipColor: '#1a7a4a',
navItems: [
{ path: '/demand/ai-search', label: 'Flächensuche', icon: Search },
{ path: '/demand/results', label: 'Ergebnisse', icon: List },
{ path: '/demand/compare', label: 'Vergleich', icon: Columns2 },
{ path: '/demand/shortlists', label: 'Shortlists', icon: Bookmark },
],
},
[WorkspaceType.OPERATIONS]: {
label: 'Administration',
abbreviation: 'ADM',
icon: Shield,
firstPath: '/ops/review-queue',
chipColor: '#7c3aed',
navItems: [
{ path: '/ops/review-queue', label: 'Review Queue', icon: ClipboardList },
{ path: '/ops/ai-monitoring', label: 'AI Monitoring', icon: Activity },
{ path: '/ops/governance', label: 'Governance', icon: Shield },
{ path: '/ops/market-intelligence', label: 'Market Intelligence', icon: Radar },
{ path: '/ops/source-monitoring', label: 'Source Monitoring', icon: ServerCog },
{ path: '/ops/signal-pipeline', label: 'Signal Pipeline', icon: GitBranch },
{ path: '/ops/activity-timeline', label: 'Aktivitäts-Timeline', icon: Clock },
],
},
}
// Ordered list for rendering workspace tabs
const WORKSPACE_ORDER: WorkspaceType[] = [
WorkspaceType.SUPPLY,
WorkspaceType.DEMAND,
WorkspaceType.OPERATIONS,
]
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getWorkspaceFromPath(pathname: string): WorkspaceType | null {
if (pathname.startsWith('/supply')) return WorkspaceType.SUPPLY
if (pathname.startsWith('/demand')) return WorkspaceType.DEMAND
if (pathname.startsWith('/ops')) return WorkspaceType.OPERATIONS
return null
}
function getPageNameFromPath(pathname: string): string {
for (const ws of Object.values(WORKSPACE_CONFIG)) {
for (const item of ws.navItems) {
if (item.path === pathname) return item.label
}
}
// Fallback: last segment, capitalised
const segment = pathname.split('/').filter(Boolean).pop() ?? ''
return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
}
function getUserInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const SIDEBAR_BG = '#0f1923'
const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
const TEXT_MUTED = '#94a3b8'
const TEXT_WHITE = '#ffffff'
const ACTIVE_BG = 'rgba(255,255,255,0.1)'
const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)'
const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
interface SidebarProps {
collapsed: boolean
activeWorkspace: WorkspaceType
allowedWorkspaces: WorkspaceType[]
onWorkspaceClick: (workspace: WorkspaceType) => void
onToggle: () => void
userName: string
orgName: string
}
function Sidebar({
collapsed,
activeWorkspace,
allowedWorkspaces,
onWorkspaceClick,
onToggle,
userName,
orgName,
}: SidebarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace]
const width = collapsed ? 60 : 240
const visibleWorkspaces = WORKSPACE_ORDER.filter((ws) => allowedWorkspaces.includes(ws))
return (
<Box
component="nav"
sx={{
width,
minWidth: width,
flexShrink: 0,
height: '100vh',
backgroundColor: SIDEBAR_BG,
display: 'flex',
flexDirection: 'column',
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
{/* Logo area */}
<Box
sx={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
px: collapsed ? 0 : 2.5,
borderBottom: `1px solid ${DIVIDER_COLOR}`,
flexShrink: 0,
}}
>
{collapsed ? (
<Typography
variant="subtitle1"
sx={{ color: TEXT_WHITE, fontWeight: 700, letterSpacing: 0.5 }}
>
PM
</Typography>
) : (
<Box>
<Typography
variant="subtitle1"
sx={{ color: TEXT_WHITE, fontWeight: 700, lineHeight: 1.2 }}
>
Property
</Typography>
<Typography
variant="caption"
sx={{
color: '#64b5f6',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
fontSize: '0.6rem',
}}
>
Match
</Typography>
</Box>
)}
</Box>
{/* Workspace tabs */}
<Box
sx={{
borderBottom: `1px solid ${DIVIDER_COLOR}`,
py: 0.5,
px: 0.5,
flexShrink: 0,
}}
>
{visibleWorkspaces.map((ws) => {
const wsConfig = WORKSPACE_CONFIG[ws]
const Icon = wsConfig.icon
const isActive = ws === activeWorkspace
const tabContent = (
<Box
onClick={() => onWorkspaceClick(ws)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: collapsed ? 0 : 1.5,
py: 0.75,
borderRadius: 1,
cursor: 'pointer',
justifyContent: collapsed ? 'center' : 'flex-start',
backgroundColor: isActive ? ACTIVE_BG : 'transparent',
'&:hover': {
backgroundColor: isActive ? ACTIVE_BG : NAV_HOVER_BG,
},
transition: 'background-color 0.15s ease',
}}
>
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
{!collapsed && (
<Typography
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 600 : 400,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
}}
>
{wsConfig.label}
</Typography>
)}
</Box>
)
return collapsed ? (
<Tooltip key={ws} title={wsConfig.label} placement="right">
{tabContent}
</Tooltip>
) : (
<Box key={ws}>{tabContent}</Box>
)
})}
</Box>
{/* Nav items */}
<Box sx={{ flex: 1, overflowY: 'auto', py: 0.5 }}>
{config.navItems.map((item) => {
const Icon = item.icon
const navContent = (
<NavLink
to={item.path}
style={{ textDecoration: 'none', display: 'block' }}
>
{({ isActive }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: collapsed ? 0 : 1.5,
py: 0.75,
mx: 0.5,
borderRadius: 1,
justifyContent: collapsed ? 'center' : 'flex-start',
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
'&:hover': {
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
},
transition: 'background-color 0.15s ease',
cursor: 'pointer',
}}
>
<Icon size={16} color={isActive ? TEXT_WHITE : TEXT_MUTED} />
{!collapsed && (
<Typography
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 500 : 400,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
}}
>
{item.label}
</Typography>
)}
</Box>
)}
</NavLink>
)
return collapsed ? (
<Tooltip key={item.path} title={item.label} placement="right">
<Box>{navContent}</Box>
</Tooltip>
) : (
<Box key={item.path}>{navContent}</Box>
)
})}
</Box>
{/* Bottom section */}
<Box
sx={{
borderTop: `1px solid ${DIVIDER_COLOR}`,
px: collapsed ? 0 : 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
justifyContent: collapsed ? 'center' : 'space-between',
flexShrink: 0,
}}
>
{!collapsed && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Avatar
sx={{
width: 32,
height: 32,
fontSize: '0.75rem',
bgcolor: '#1e3a5f',
flexShrink: 0,
}}
>
{getUserInitials(userName)}
</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="body2"
sx={{
color: TEXT_WHITE,
fontWeight: 500,
fontSize: '0.8125rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{userName}
</Typography>
<Typography
variant="caption"
sx={{
color: '#64748b',
fontSize: '0.7rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
}}
>
{orgName}
</Typography>
</Box>
</Box>
)}
<IconButton
onClick={onToggle}
size="small"
sx={{ color: '#64748b', flexShrink: 0 }}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</IconButton>
</Box>
</Box>
)
}
interface TopBarProps {
activeWorkspace: WorkspaceType
pathname: string
}
function TopBar({ activeWorkspace, pathname }: TopBarProps) {
const config = WORKSPACE_CONFIG[activeWorkspace]
const pageName = getPageNameFromPath(pathname)
const openAssistant = useAssistantStore(s => s.open)
return (
<Box
component="header"
sx={{
height: 56,
flexShrink: 0,
backgroundColor: '#ffffff',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
}}
>
{/* Left side */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Chip
label={config.label}
size="small"
sx={{
backgroundColor: config.chipColor,
color: '#ffffff',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Typography
variant="body1"
sx={{ fontWeight: 500, color: '#1e293b', fontSize: '0.9375rem' }}
>
{pageName}
</Typography>
</Box>
{/* Right side */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<OrganizationContextBadge />
<Button
variant="outlined"
size="small"
startIcon={<Sparkles size={14} />}
onClick={openAssistant}
sx={{ textTransform: 'none', fontSize: '0.8125rem' }}
>
AI Assistent
</Button>
<NotificationButton />
<UserMenu />
</Box>
</Box>
)
}
// ---------------------------------------------------------------------------
// AppShell
// ---------------------------------------------------------------------------
export function AppShell() {
const { activeWorkspace, sidebarCollapsed, setActiveWorkspace, toggleSidebar } =
useLayoutStore()
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
// Sync active workspace with URL
useEffect(() => {
const detected = getWorkspaceFromPath(location.pathname)
if (detected && detected !== activeWorkspace) {
setActiveWorkspace(detected)
}
}, [location.pathname, activeWorkspace, setActiveWorkspace])
const handleWorkspaceClick = (workspace: WorkspaceType) => {
setActiveWorkspace(workspace)
navigate(WORKSPACE_CONFIG[workspace].firstPath)
}
const userName = currentUser?.name ?? 'User'
const orgName = currentUser?.organizationName ?? ''
const allowedWorkspaces = currentUser?.allowedWorkspaces ?? WORKSPACE_ORDER
return (
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Sidebar
collapsed={sidebarCollapsed}
activeWorkspace={activeWorkspace}
allowedWorkspaces={allowedWorkspaces}
onWorkspaceClick={handleWorkspaceClick}
onToggle={toggleSidebar}
userName={userName}
orgName={orgName}
/>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<TopBar activeWorkspace={activeWorkspace} pathname={location.pathname} />
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
<Outlet />
</Box>
<RightContextPanel />
</Box>
<CompareTray />
<GlobalAIAssistantDrawer />
<GlobalAIAssistantButton />
<ToastProvider />
</Box>
)
}
@@ -1,108 +0,0 @@
import { useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router'
import { Box, Button, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
import { useLayoutStore } from '../../stores/layoutStore'
const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706',
FUTURE_AVAILABILITY: '#7c3aed',
}
export function CompareTray() {
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
const { setCompareTrayVisible } = useLayoutStore()
const navigate = useNavigate()
const location = useLocation()
const isDemand = location.pathname.startsWith('/demand')
useEffect(() => {
setCompareTrayVisible(compareItems.length > 0 && isDemand)
}, [compareItems.length, setCompareTrayVisible, isDemand])
if (!isDemand) return null
const getTitle = (item: (typeof compareItems)[number]) => {
if (item.resultType === 'FUTURE_AVAILABILITY') {
return (item as any).signal?.companyName ?? (item as any).signal?.locationHint ?? 'Signal'
}
return (item as any).property?.title ?? `Score ${item.matchScore}`
}
return (
<Box
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1300,
height: 56,
bgcolor: '#0f1923',
display: 'flex',
alignItems: 'center',
px: 3,
gap: 2,
transform: compareItems.length > 0 ? 'translateY(0)' : 'translateY(100%)',
transition: 'transform 0.25s ease',
}}
>
<Typography variant="caption" sx={{ color: '#fff', flexShrink: 0 }}>
Vergleich ({compareItems.length}/4)
</Typography>
<Box sx={{ flex: 1, display: 'flex', gap: 1, overflow: 'hidden' }}>
{compareItems.map((item) => (
<Box
key={item.matchId}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
bgcolor: 'rgba(255,255,255,0.1)',
borderRadius: 1,
px: 1,
py: 0.25,
flexShrink: 0,
}}
>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: TYPE_DOT[item.resultType] ?? '#64748b', flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#fff', maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{getTitle(item)}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', flexShrink: 0 }}>
{item.matchScore}
</Typography>
<IconButton
size="small"
onClick={() => removeFromCompare(item.matchId)}
sx={{ p: 0.25, color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#fff' } }}
>
<X size={12} />
</IconButton>
</Box>
))}
</Box>
<Button
variant="text"
size="small"
onClick={clearCompare}
sx={{ color: 'rgba(255,255,255,0.5)', textTransform: 'none', flexShrink: 0 }}
>
Alle entfernen
</Button>
<Button
variant="contained"
size="small"
onClick={() => navigate('/demand/compare')}
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
>
Vergleich starten
</Button>
</Box>
)
}
@@ -1,39 +0,0 @@
import { useState } from 'react'
import { Badge, IconButton, Popover, Typography } from '@mui/material'
import { Bell } from 'lucide-react'
export function NotificationButton() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
}
function handleClose() {
setAnchorEl(null)
}
return (
<>
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
<Badge badgeContent={0} color="error">
<Bell size={20} />
</Badge>
</IconButton>
<Popover
open={!!anchorEl}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine neuen Benachrichtigungen
</Typography>
</Popover>
</>
)
}
@@ -1,19 +0,0 @@
import { Chip } from '@mui/material'
import { Building2 } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
export function OrganizationContextBadge() {
const { currentUser } = useSessionStore()
if (!currentUser?.organizationName) return null
return (
<Chip
size="small"
variant="outlined"
icon={<Building2 size={12} color="#64748b" />}
label={currentUser.organizationName}
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e2e8f0' }}
/>
)
}
@@ -1,76 +0,0 @@
import { Box, Breadcrumbs, Typography } from '@mui/material'
import type { SxProps, Theme } from '@mui/material'
import type { ReactNode } from 'react'
import { NavLink } from 'react-router'
interface BreadcrumbItem {
label: string
href?: string
}
interface PageHeaderProps {
title: string
subtitle?: string
breadcrumbs?: BreadcrumbItem[]
primaryAction?: ReactNode
secondaryActions?: ReactNode
badge?: ReactNode
sx?: SxProps<Theme>
}
export function PageHeader({
title,
subtitle,
breadcrumbs,
primaryAction,
secondaryActions,
badge,
sx,
}: PageHeaderProps) {
return (
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', ...sx }}>
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
{breadcrumbs.map((crumb) =>
crumb.href ? (
<NavLink
key={crumb.label}
to={crumb.href}
style={{ textDecoration: 'none', color: '#64748b', fontSize: '0.75rem' }}
>
{crumb.label}
</NavLink>
) : (
<Typography key={crumb.label} sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
{crumb.label}
</Typography>
),
)}
</Breadcrumbs>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
{title}
</Typography>
{badge}
</Box>
{subtitle && (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.25 }}>
{subtitle}
</Typography>
)}
</Box>
{(primaryAction ?? secondaryActions) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0, ml: 2 }}>
{secondaryActions}
{primaryAction}
</Box>
)}
</Box>
</Box>
)
}
@@ -1,70 +0,0 @@
import { Box, Divider, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useLayoutStore } from '../../stores/layoutStore'
import type { RightPanelContentType } from '../../stores/layoutStore'
const PANEL_TITLES: Record<RightPanelContentType, string> = {
ai_context: 'KI Kontext',
detail_preview: 'Detail Vorschau',
compare_preview: 'Vergleich Vorschau',
activity_feed: 'Aktivitäts-Feed',
}
const PANEL_PLACEHOLDERS: Record<RightPanelContentType, string> = {
ai_context: 'KI-Kontext wird geladen...',
detail_preview: 'Kein Objekt ausgewählt.',
compare_preview: 'Vergleichsvorschau nicht verfügbar.',
activity_feed: 'Keine Aktivitäten vorhanden.',
}
export function RightContextPanel() {
const { isRightPanelOpen, rightPanelContentType, closeRightPanel } = useLayoutStore()
const title = rightPanelContentType ? PANEL_TITLES[rightPanelContentType] : ''
const placeholder = rightPanelContentType ? PANEL_PLACEHOLDERS[rightPanelContentType] : ''
return (
<Box
sx={{
position: 'fixed',
right: 0,
top: 56,
height: 'calc(100vh - 56px)',
width: 320,
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
transition: 'transform 0.25s ease',
bgcolor: '#fff',
borderLeft: '1px solid #e2e8f0',
boxShadow: '-4px 0 16px rgba(0,0,0,0.08)',
zIndex: 1200,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
height: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{title}</Typography>
<IconButton size="small" onClick={closeRightPanel} sx={{ color: '#64748b' }}>
<X size={16} />
</IconButton>
</Box>
<Divider />
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{placeholder}
</Typography>
</Box>
</Box>
)
}
@@ -1,105 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
import { LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
ORGANIZATION_ADMIN: 'Org Admin',
PROPERTY_MANAGER: 'Property Manager',
REVIEWER: 'Reviewer',
OWNER_VIEWER: 'Owner Viewer',
DEMAND_USER: 'Demand User',
}
function getUserInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
export function UserMenu() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { currentUser, logout } = useSessionStore()
const navigate = useNavigate()
const showToast = useToastStore((s) => s.showToast)
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
}
function handleClose() {
setAnchorEl(null)
}
function handleLogout() {
handleClose()
logout()
navigate('/auth/login')
}
const initials = getUserInitials(currentUser?.name ?? 'U')
return (
<>
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
{initials}
</Avatar>
</IconButton>
<Menu
anchorEl={anchorEl}
open={!!anchorEl}
onClose={handleClose}
slotProps={{ paper: { sx: { width: 300, mt: 1 } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
{/* User info header */}
{currentUser && (
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{currentUser.name}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{ROLE_LABELS[currentUser.role] ?? currentUser.role}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
{currentUser.organizationName}
</Typography>
</Box>
)}
<Divider />
<MenuItem onClick={() => { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}>
<ListItemIcon><User size={16} /></ListItemIcon>
Profil
</MenuItem>
<MenuItem onClick={() => { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
<ListItemIcon><Settings size={16} /></ListItemIcon>
Einstellungen
</MenuItem>
<Divider />
{/* Demo role switcher */}
<Box sx={{ px: 1, py: 1 }}>
<DemoRoleSwitcher />
</Box>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon><LogOut size={16} /></ListItemIcon>
Abmelden
</MenuItem>
</Menu>
</>
)
}
@@ -1,7 +0,0 @@
export { AppShell } from './AppShell'
export { PageHeader } from './PageHeader'
export { RightContextPanel } from './RightContextPanel'
export { CompareTray } from './CompareTray'
export { UserMenu } from './UserMenu'
export { NotificationButton } from './NotificationButton'
export { OrganizationContextBadge } from './OrganizationContextBadge'

Some files were not shown because too many files have changed in this diff Show More