182 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
Benjamin Sutter e169f8e310 feat(matching): annuity-based fit-out cost in score + fix unapplied DQ/confidence modifiers
Mieterausbau / fit-out economics — surface true total cost of occupancy:
- Annuity calc (annuityFactor + effectiveAnnualBurdenPerSqm) replaces straight-line ÷5; FITOUT_ANNUITY_RATE=5%
- New hardFacts.fitOutByLandlord: "Wer baut aus?" toggle in NewListing — landlord-borne fit-out is priced into rent (no surcharge), tenant-borne SHELL/BASIC adds annuitized cost minus MAB
- scoreBudget now compares effective annual burden (rent + fit-out annuity) vs budget instead of cold rent only; FULL/PREMIUM and landlord-borne unchanged
- FitOutCostPanel + CompareTableBody compute annuitized, tenant-aware burden
- Central FIT_OUT_LABELS with industry/international vocabulary (Rohbau·Core&Shell, Edelrohbau·CAT A, etc.)
- Activate existing generateFitOutAdvice via useFitOutAdvice hook + new FitOutAdvicePanel (MIETERAUSBAU/BKZ/MAB-Amortisation + negotiation tip), shown for tenant-borne SHELL/BASIC
- MAB field only asked when tenant builds out (optional) — one new toggle, no extra data burden for property managers

Fix: DQ/confidence modifiers were computed but never applied to finalScore (hardcoded 0 in output) — now folded into rawFinal and exposed. Trust-first: weak data quality lowers the score. Resolves 3 pre-existing red tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:19:58 +02:00
Benjamin Sutter 7a0909e36a feat: saved profiles — per-profile notifications, real match scores, action buttons
- Add per-profile notification config (toggle, score threshold, email) with Accordion UI in SavedNeedDetail
- NotificationButton: badge only lights up for unseen matches, dismisses on open (localStorage), uses per-profile minScore
- SavedNeedDetail: replace deterministicMatchScore with real match data (useMatchesByNeed), sort by score desc, deduplicate by property
- SavedProfilesTab & SavedNeedCard: top score bar now shows best real match score instead of criteria confidence
- "Treffer" count uses per-profile notificationConfig.minScore (≥80 default) consistently across card, panel, and notification badge
- Extract PropertyMatchRow to own file with Anfrage/Merken/Vergleichen/Details→/Zur Einheit→ action buttons
- Details → navigates to /demand/results/:matchId using real match ID format (m__propId__prop__needId)
- Add InquiryQuickDialog + AddToPipelineDialog to AISearch.tsx so dialogs work from SavedProfilesTab
- Org-isolation: useNeeds filters by organizationId, org-mobimo gets 4 own search profiles
- Mock data: all 18 org-wincasa need names changed to descriptive profile names; 4 new org-mobimo needs added
- useUpdateNeed mutation hook for updating need fields (notificationConfig etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 20:53:49 +02:00
Benjamin Sutter 6aa4f96bd2 feat: responsive layout + Reminder Manager redesign
- Auto-collapse sidebar at <1536px (all laptops), expand at ≥1536px
- Responsive drawer/panel widths across Pipeline, Properties, MyListings, Anfragen, MatchDetail, AISearch
- Reminder Manager: dot+label priority badge, Fläche column removed, status only for non-active rows
- ReminderKpiBar: focal Überfällig card, three secondary KPIs
- ReminderDetailDrawer: Task Panel redesign — Finanzen removed, Notiz promoted, Pre-Market as inline badge, full create form
- useCreateReminder hook wired to reminderService.create with cache invalidation
- Mock data: contract-derived reminders (LEASE_EXPIRY, BREAK_OPTION, RENT_REVIEW, INSURANCE_RENEWAL, SCHATTENMARKT_RELEASE) now show auto-creation note in Verlauf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:30:06 +02:00
Benjamin Sutter 5259579f85 refactor(PropertyIntelligenceCard): smaller image, larger text, more spacing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:02:03 +02:00
Benjamin Sutter beecf754d5 refactor: premium design polish — cards, results, grid view
- PropertyIntelligenceCard: aspect-ratio image, hover animation, compact
  data-completeness row, tighter body padding
- Results: replace verbose Card with compact active-search strip, remove
  DecisionContextPanel
- Properties: add 3/5/10 column toggle in grid view, persist to localStorage
- NeedInput, UnifiedResultCard/Feed, MatchCardViewModel: cleanup from
  earlier session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:06:23 +02:00
Benjamin Sutter 6e67c698ce fix: standardize subtitle typography in all page headers
All page header subtitles now use variant="body2" color="text.secondary"
(0.875rem gray). Fixed caption→body2 in DataQuality and MarketIntelligence,
and aligned DashboardHeader title/subtitle to the global standard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:05:30 +02:00
Benjamin Sutter 3dcac2dcb6 fix: MyListings — sticky header bar matches standard page layout
Restructured from scrollable content-box to flex-column with a
fixed header bar (borderBottom #e8e7e4, py:2.5) and a scrollable
content area below — identical pattern to all other pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:02:40 +02:00
Benjamin Sutter 5035445d2e refactor: standardize page title typography across all workspaces
All page-level headers now use fontSize:1.125rem, fontWeight:700,
color:#0f172a, container py:2.5, borderBottom:#e8e7e4 — matching
the standard established in PageHeader. Removes variant="h5"/h6"
inconsistencies across Supply, Demand, and shared components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:01:15 +02:00
Benjamin Sutter 017835b083 fix(typography): remove DM Serif Display — all UI text now uses Inter
PageHeader titles, MatchCardCompact score badge, and IntelligenceMatchCard
score badge were the only 3 places using the serif override. All now use
the theme-defined Inter stack (fontWeight 600/700, letterSpacing tight).
PDF/print previews (BerichtDialog, LatentInquiryReportPreview) intentionally
keep their serif fonts — those are document renderers, not UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:53:15 +02:00
Benjamin Sutter d945fb1ab6 refine(anfragencenter): compact match cards 168px, reply field 7 rows
Match cards reduced to 168px wide, 60px image height — supplementary context
should not visually dominate the primary chat content. Reply textarea grows
to 7 rows so the composer takes more of the available space.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:48:35 +02:00
Benjamin Sutter f783cf6881 fix(anfragencenter): use aspectRatio 3/2 for match card images like PropertyIntelligenceCard
Replaces fixed height: 90px with aspectRatio: '3/2' — the same ratio used in
supply property cards. Ensures consistent, undistorted image crops across all
card surfaces. objectFit: cover guaranteed via <img> element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:47:11 +02:00
Benjamin Sutter cf9089b5a2 fix(anfragencenter): match cards fixed width 240px, horizontal scroll rail
Replace flex-stretch layout (cards filling full width = distorted images)
with fixed 240px cards in a horizontal overflow-auto rail. Shows as many
cards as fit the screen naturally; image height increased to 90px for a
cleaner 240×90 (≈ 8:3) proportion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:46:04 +02:00
Benjamin Sutter b0f675c319 refine(anfragencenter): property bar top, matches-only bottom, AI draft button
Layout: property context bar (44x44 thumbnail + title + key facts + Ansehen)
sits between inquiry header and chat. Bottom strip now shows only the
2-card matches slider — no duplicate property info. Images use objectFit:cover
via <img> for consistent non-distorted rendering. Match score shown as badge
overlay on image. Reply composer gains KI-Entwurf button (Sparkles, purple)
that fills a salutation-prefixed template; an inline label marks it as
AI-generated until the user edits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:43:29 +02:00
Benjamin Sutter da3b5b55cc refine(anfragencenter): clearer selection, card-style property strip, 2-item slider
- InquiryListRow: selected state now uses bright blue (4px #2563eb border,
  #eff6ff bg, blue tenant name) — unmistakable at a glance
- RelatedPropertyCardPanel compact: left panel is a mini property card matching
  supply card style (border, borderRadius 2, hover shadow, 78px image);
  right panel shows exactly 2 match slider cards with ChevronLeft/Right nav
  when more than 2 exist; match cards mirror property card visual language
- Strip maxHeight raised to 260px; reply composer rows 4→5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:34:50 +02:00
Benjamin Sutter 76e52dae1d refine(anfragencenter): property strip with image thumbnail + horizontal match rail
Compact strip redesign: left side shows 72x80px property thumbnail + key facts
+ "Ansehen" link. Right side shows a horizontal scroll rail (148px cards) with
placeholder/actual image, title, score, and top match reason for each
additional property. Uses imageUrl from AdditionalPropertyMatch domain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:29:29 +02:00
Benjamin Sutter 4c79d9f969 feat(anfragencenter): 2-column layout — chat top, property strip bottom
Replace 3-panel side-by-side layout with a vertical stack on the right:
InquiryChat takes full height (flex:1), RelatedPropertyCardPanel becomes a
compact 200px bottom strip with a 2-column layout (property info left,
further matches right). Active row selection background deepened to #eef2f8
for clearer focus indication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:25:43 +02:00
Benjamin Sutter ec5d567dae refine: Reminder Manager — align to premium design
Replace SaaS-generic KPI icon boxes with flat inline layout.
Replace MUI color-prop status chips with design-system colors
matching the rest of the supply workspace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:05:08 +02:00
Benjamin Sutter e64ae15d17 feat: anonymous pre-market release per unit
Add anonymous flag to schattenmarktRelease on PropertyUnit and Property.
When a unit is enabled in the pre-market panel, an Anonym toggle appears
inline — hides address and property name in the matching feed, showing
only area, type and region to prospective tenants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:58:02 +02:00
Benjamin Sutter ee229f8f4f refactor: unit-centric lease view — Einheiten & Mietverträge
Replace building-level Miet- & Mieterinformationen section with
unit-level lease display. Each unit row now shows tenant name and
lease end date directly; expand reveals full contract details
(Mietbeginn, Mietende, Laufzeit, Breakout, source system, doc link).

Floor grouping groups consecutive same-floor units under one label.
Domain model extended with Lease and Tenant types; deprecated
property-level tenant fields preserved via @deprecated for compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:52:18 +02:00
Benjamin Sutter d771029633 fix: correct ScoreInlineBreakdown prop names in MatchCardCompact
scoreFactors/score were stale prop names; the component expects
scoreBreakdown and allFactors. Added null-guard for scoreBreakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:00:20 +02:00
Benjamin Sutter 20dcc6b245 fix: remove FUTURE_SIGNAL from supply availability filter
FUTURE_SIGNAL is a demand/AI market intelligence concept — it applies
to properties not in the manager's portfolio. Own properties can only
be AVAILABLE_NOW, AVAILABLE_SOON, or OCCUPIED.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:52:19 +02:00
Benjamin Sutter bd9fd98d78 refine: sharper corners, minimal status indicator, no metric boxes
- theme: borderRadius 8→4 (cascades to all MUI components)
- PropertyIntelligenceCard: status pill → plain dot+text (no bg/border)
- OCCUPIED status: green → neutral gray (rented = expected, no signal needed)
- metric cells: remove box decoration, whitespace-only separation
- metric labels: lowercase, no uppercase/letter-spacing
- status placed inline next to location, saving vertical space

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:49:18 +02:00
Benjamin Sutter bae0cd31d5 refactor: remove ÖV chip from supply card — tenant metric, not manager metric
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:40:29 +02:00
Benjamin Sutter 5c886193d5 refactor: PropertyIntelligenceCard — property manager perspective
Primary element: occupancy status pill (Vermietet/Bald frei/Verfügbar)
with semantic color — this is what drives every manager action.

Metrics: Fläche · Miete CHF/m²/J · Anfragen (not annual total)
- CHF/m²/J is the professional pricing metric, not the portal total
- Removed price overlay from image — clean photo, no text on top
- Image reduced to 150px (photo as context, not hero)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:39:25 +02:00
Benjamin Sutter e6c20958a5 feat: supply card metrics — replace Prestige with Anfragen count
Senior property manager perspective: the three key metrics are
Fläche (size), Frei ab (availability date), and Anfragen (active
inquiry count). Anfragen box highlights amber when > 0. Rename
Datenkonfidenz → Datenvollständigkeit (clearer for the manager).
Remove ÖV chip, keep fit-out and contract chips as property specs.
Wire inquiryCount from useActiveInquiries in Properties page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:36:45 +02:00
Benjamin Sutter 884bb468b8 refactor: PropertyIntelligenceCard — keep all info, premium visual treatment
Preserve metric boxes, ÖV/fit-out/contract chips and confidence bar.
Apply premium styling: DM Serif CHF on image, neutral dark asset-type
badge, warm gray metric boxes (#f9f8f6), muted border chips, navy
confidence bar with gold % when high.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:32:39 +02:00
Benjamin Sutter 29ee98a300 fix: Properties grid — wider min card width, more breathing room
Change minmax from 240px to 320px and gap from 1.5 to 2.5 so the
grid shows 3-4 columns instead of 9-10 on a wide screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:30:12 +02:00
Benjamin Sutter 161b2d609a refactor: PropertyIntelligenceCard — E&V-style minimal layout
Remove all image overlays, colored badges, metric boxes and confidence
bar. New structure: image → location (gray) → title → price large
(DM Serif) → single facts line. Single neutral dark tag for asset type.
Matches Engel & Völkers card pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:28:56 +02:00
Benjamin Sutter da50f3b5ea feat: premium redesign — DM Serif, refined palette, flat score badges
- Design tokens (ds.ts, theme.ts, scoreTheme.ts): new warm palette
  (#152642 navy, #f9f8f6 warm white, #e8e7e4 borders, #b8975a gold accent),
  flat score tier badges replacing CSS gradients, Inter + DM Serif Display typography
- Card components: white-background cards, DM Serif score numbers, max-2 badge
  chips with +N overflow tooltip, editorial score badge positioning
- Layout shell: gold left-accent nav active state, 64px top bar, outlined
  workspace chip, DM Serif page titles
- Shared atoms: GenericBadge (outlined/solid variants), ResultFilterBar
  (simplified chip styles), DecisionContextPanel (dot metrics, no left accent)
- Global replacement (118 files): #1e3a5f→#152642, #e2e8f0→#e8e7e4,
  #f4f6f9→#f9f8f6 — all handled via Node.js for proper UTF-8 safety

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:26:30 +02:00
Benjamin Sutter 96a2507979 fix: carry all unit data into + Inserat prefill
When clicking + Inserat in UnitStructurePanel, the new listing form
now pre-populates: floor label, fit-out standard, parking, ceiling
height, Mieterausbaubeitrag, Teilbar toggle, min lettable sqm, and
availability date from the unit and its parent property.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:32:54 +02:00
Benjamin Sutter 87ea4c4dfc fix: clear stale search criteria on text edit, fix Zürich false-positive, improve cost breakdown and fit-out labels
- AISearch: clear parsed criteria when user manually edits text input so stale locations (e.g. Zürich) no longer persist after retyping
- needParser: fix ZURICH_SIGNALS substring bug — "kreis N" now uses word-boundary regex so "umkreis 22" no longer matches "kreis 2"
- CompareTableBody: row 9 shows full cost breakdown (Miete + NK + amortised fit-out) using FITOUT_AMORTIZATION_YEARS from constants
- FitOutCostPanel: replace local AMORTIZATION_YEARS with FITOUT_AMORTIZATION_YEARS from constants.ts (single source of truth)
- constants.ts: add FITOUT_AMORTIZATION_YEARS = 5 — change here to affect all cost calculations
- PropertyIntelligenceCard: translate raw fitOut enum to German labels (Rohbau/Grundausbau/Vollausbau/Premium-Ausbau) with explanatory tooltips; add tooltip on contract duration chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:32:25 +02:00
Benjamin Sutter c6d3071010 docs: Änderungsprotokoll aktualisiert — Change 2 vollständig, Change 5 + 6 ergänzt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:24:17 +02:00
Benjamin Sutter 5a1f412ce3 feat: anonymes Suchprofil — Toggle im Speichern-Schritt, supply-side Masking
- Toggle 'Anonymes Suchprofil' im AISearchSavePreview (nicht in Suchmaske)
- Box wechselt zu lila wenn aktiv, Text erklärt was anonymisiert wird
- isAnonymous wird via buildNeedInput in gespeichertes Need geschrieben
- MatchListCard + NeedSelectionPanel: zeigen 'Anonyme Anfrage' (lila) statt Firmenname

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:04:17 +02:00
Benjamin Sutter b2530f9e20 feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6):
- FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte
- FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J.
- Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k)
- FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts
- BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface)

Must-have Kriterien (Nicht prüfbar Fix):
- ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft
- Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft
- Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM

fitOut-Datenpflege:
- fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL)
- MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:09:55 +02:00
Benjamin Sutter a825672197 feat: collapsible filter bar, compact property cards, offer wizard field selection
- Properties page: collapsible decision/filter strip (localStorage state), slim
  single-row Datenpflege status bar (chips + button), 4-column card grid
- PropertyIntelligenceCard: reduced image height (160→120px), tighter body padding
- OfferWizard (latente Anfragen): new "Felder wählen" step between property
  selection and PDF preview; uses ReportObjectFieldSelector per property
- MockPdfPreview: adds second PDF page showing selected properties side by side
  with photo, title, location and all chosen hard/soft fact fields
- offerWizardStore: adds select_fields step type and fieldSelections state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:36:32 +02:00
Benjamin Sutter 36570c5bdc fix: resolve all TypeScript errors and refactor oversized components
- Fix MUI v9 API: PaperProps/InputLabelProps/inputProps → slotProps in 6 components
- Add ExternalMarketResult alias, PropertyUnit import, OPERATIONS workspace config
- Fix TradeOff.description → .concern, ScoreFactor.label → .criterion
- Make schattenmarktRelease.leadTimeMonths optional, fix mock-data enum values
- Fix useMatchDetailData query typing, weightingService missing WeightProfile keys
- Split Pipeline/Compare/MatchDetail/IntelligenceMatchCard into sub-components
- Fix all test fixtures (CreateNeedInput, CreatePropertyInput, TradeOffInput, etc.)
- Add vercel.json for deployment, zero tsc errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:08:35 +02:00
Benjamin Sutter 98d2770054 feat: Datenpflege — actionable work queue with inline editing
- KPI cards now clickable filters (Pflichtfelder fehlen → filters list, etc.)
- Quality distribution bars also clickable filters
- Table rows sorted by urgency: critical+stale first, then critical, then stale
- Missing field names shown as red chips (not tooltip count): Mietpreis, Fläche, etc.
- Each row has Edit2 icon button + full row click → opens PropertyDetailView drawer
- Empfehlung column: top recommended action with priority icon (AlertTriangle/Clock)
- Active filter chip with × to clear
- DataFreshness stale highlighting in row background

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:57:12 +02:00
Benjamin Sutter 6fb2fb027c refactor: rename tab to KI-Signale, diversify DEMAND signal narratives
- "Digitale Signale" → "KI-Signale" tab label
- signal-001: Alpbach Advisors — new Zürich branch announced via press release
- signal-007: EBH Treuhand — Handelsregister merger of two Winterthur firms
- signal-010: Centra Schweiz — suburban→city relocation for talent attraction
- signal-013: Rheintaler Logistik Solutions — spin-off needs its own premises
- signal-015: BernTech — CEO publicly confirmed new office in VC press release
- signal-004 improved: lab expansion driven by published strategic plan, not headcount
- Each signal uses a different trigger: press/Handelsregister/annual report/VC round

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:53:30 +02:00
Benjamin Sutter 7f6b7766e6 feat: Marktchancen — Netzwerk-Hinweise tab (human-sourced market intel)
- MarktHinweis domain type: SUCHE/VERFUEGBARKEIT direction, INTERN/PLATTFORM
  visibility, anonymization flag, source tracking
- Full data layer: IMarktHinweisProvider, MockupMarktHinweisProvider, service, hooks
- 7 seed entries: mix of intern/shared, demand/supply, own/partner Verwaltungen
- Marktchancen gets two-tab layout: Digitale Signale (crawler) + Netzwerk (human)
- HinweisListItem: direction icon, visibility badge (Intern/Geteilt/partner name)
- HinweisDetail: note card, portfolio match (SUCHE) or availability info (VERFUEGBARKEIT)
- HinweisErfassenDialog: direction toggle, asset type, location, area range,
  company + anonymize switch, source, note, card-based visibility picker
- HinweisPropertyCard: same Anschreiben composer pattern as crawler side

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:47:50 +02:00
Benjamin Sutter 63909c8caf feat: Marktchancen — source links, KI-Kontaktdaten, portfolio match, Anschreiben
- ExtractedContact domain type (ContactType, ContactConfidence) on FutureSignal
- Mock extractedContacts for all 6 DEMAND signals (website, email, phone/linkedin)
- Source URL row with external link button
- KI-Kontaktdaten section: clickable links, confidence dots, purple accent
- PropertyMatchCard: area fit % chip (green/yellow/red), inline Anschreiben toggle
- buildAnschreiben() pre-filled German outreach template, editable + copy button
- Portfolio match section groups properties by fit quality

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:32:42 +02:00
Benjamin Sutter d8ff380d6e feat: merge Markt-Leads + Markt Intelligence → Marktchancen
Two-pane UX (from Market Intelligence) with supply-relevant data
(FutureSignal + portfolio property matches from Markt-Leads):
- Left pane: filterable signal list with probability badge + portfolio match count
- Right pane: company overview, KI-Analyse, market indicators, Faktencheck,
  portfolio matches (click → Meine Objekte), Reminder erstellen action
- No OPS-level actions (approve/reject/review stripped out)
- Nav: remove Markt-Leads entry, rename to Marktchancen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:22:18 +02:00
Benjamin Sutter 81d66d24a9 feat: hide Marktsignale tab on Inserate detail drawer — direct listings have no market signal data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:14:29 +02:00
Benjamin Sutter e597e81ff5 feat: hide Matchability tab on Inserate detail drawer
PropertyDetailView gets hideTabs prop — MyListings passes ['Matchability']
since the Suchabo chip already covers the match/contact use case inline.
Tab content now keys by name not index, so removal doesn't shift panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:13:50 +02:00
Benjamin Sutter d2af8664f4 fix: KPI card counts match feed — exclusive buckets + filter reset on click
- reminderService: dueThisWeek/dueThisMonth use exclusive day ranges (1-7,
  8-30) matching getHorizon() buckets; schattenmarktReadyCount counts by
  type=SCHATTENMARKT_RELEASE (same criteria as the feed filter)
- ReminderKpiBar: clicking a horizon card resets filterType to ALL;
  clicking Pre-Market card resets filterHorizon to ALL — ensures the number
  shown on the card always equals the number of rows displayed below

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:12:08 +02:00
Benjamin Sutter cd220ed282 feat: Suchabo inline contact flow — chip opens need detail + OfferWizard
- Add >80% STRONG matches for all 6 direct listings (match-072–075)
- Add 4 new Needs (need-012–015) tailored to each previously unmatched listing
- Add 8 new LatentNeeds (lneed-007–014) for all needs in direct listing matches
- Add need→latentNeed mapping (src/lib/needToLatentNeedMap.ts)
- MyListings: clicking Suchabo chip opens dialog with company name, score,
  PublicNeedDetail view and OfferWizard — no navigation to Anfragencenter needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:08:41 +02:00
Benjamin Sutter a9ee53f185 feat: Inserate — edit drawer + Suchabo match count per listing
Each listing row now shows a color-coded Suchabo chip (green ≥80 / amber
≥60 / red) with a tooltip listing individual scores. Clicking a row opens
a right-side PropertyDetailView drawer with full edit + Matchability tab
(which shows the matching Suchabos). Added 7 mock matches across the 6
direct listings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:54:06 +02:00
Benjamin Sutter fe8444c1d3 feat: add 6 mock direct listings (sourceType DIRECT) for Inserate page
4 active + 2 inactive — Office/Retail/Light-Industrial/Logistics across
Zürich, Bern, Winterthur, Pratteln, Muttenz, Luzern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:47:12 +02:00
Benjamin Sutter 453f2b1982 refactor: merge nav — rename to Inserate, remove Neues Inserat sidebar entry
New listings are created via the button on the Inserate page, not as a
first-class nav destination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:45:14 +02:00
Benjamin Sutter d804d1c923 feat: Reminder Manager UX — timeline-first grouped layout
Replace 3-row filter panel with a grouped feed (Überfällig / Diese Woche /
Dieser Monat / Später). KPI cards are now clickable shortcuts that filter to
their time horizon or type (Pre-Market). Filter bar condenses to one row:
search + type dropdown + archive toggle. Rows get a left priority-color
border (red/orange/yellow). Default status filter is ACTIVE+SNOOZED.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:42:37 +02:00
Benjamin Sutter a9e038a64f feat: Marktsignale KPI panel with adjustable radius (5/10/15/20 km)
Adds MarketKpis domain type with formula-based radius scaling, a MarketKpiPanel
component (vacancy rate, platform searches, avg vacancy duration, demand/supply
ratio — color-coded with interpretation badges), and integrates it at the top of
PropertyMarketSignalsTab. Demand/supply section labels now update with the
selected radius. Mock data covers all 5 existing market reports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:28:04 +02:00
Benjamin Sutter 61a26d00a7 feat: Mietvertrag-Mock für 50 % der Objekte (URL + Bezeichnung)
Jedes zweite Objekt bekommt per Post-Processing-Loop eine
leaseContractUrl (docs.wincasa.ch/…) + leaseContractName
(„Mietvertrag YYYY–YYYY") — ohne echten Inhalt, nur für die UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:19:37 +02:00
Benjamin Sutter f2edc3b2ef feat: Mietvertrag hinterlegen pro Objekt (Link + Bezeichnung)
- Property.leaseContractUrl? + leaseContractName? im Domain
- PropertyDetailOverview: Mietvertrag-Block in "Miet- & Mieterinformationen"
  — View-Modus: Öffnen-Button oder "Noch kein Vertrag"-Hinweis
  — Edit-Modus: URL-Feld + Bezeichnungsfeld (gespeichert via updateProperty)
- ReminderDetailDrawer: Mietvertrag-Link im "Objekt"-Abschnitt
  (lädt Property über usePropertyById)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:18:02 +02:00
Benjamin Sutter e95490eb72 feat: Grundriss-Feature, Listenansicht mit Bildern, Gewerbe-Label, Image-Pool
- Grundriss (FloorPlanSection): PropertyUnit.floorPlanUrl?, Property.floorPlanUrl?;
  FloorPlanSection in MatchDetail + PropertyDetail; FloorPlanUrlSection in NewListing-Formular
- Listenansicht (MatchCardCompact): horizontales Layout mit 120px Bildstreifen,
  Score-Badge, Asset-Label-Overlay, alle 3 grünen Punkte, Anfrage-Button
- Light Industrial → "Gewerbe" überall (NeedInput, NeedCardPreview, CriteriaReviewPanel,
  newListingConstants, MyListings, propertyHelpers)
- "Zum Originalinserat"-Button nur bei Maison-Work-Objekten
- Image-Pool: propertyImageResolver mit sequentiellem Pool-Index (keine doppelten Bilder),
  nur Innenaufnahmen, rotate()-Trick für Sub-Pools
- Overlay-Labels (Objekttyp + Stadtteil) in LocationPreview + IntelligenceMatchCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:12:43 +02:00
Benjamin Sutter 3ab6eeccf2 fix: demand Anfragen page shows own sent inquiries (demand perspective)
Replace supply-side mock data with demand-side conversations where the
logged-in user is the sender (right/dark) and verwalter replies are left.
List shows property name instead of tenant name.
New domain fields: propertyManagerName, propertyManagerCompany, propertyAddress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:07:59 +02:00
Benjamin Sutter 128d28af8d feat: in-memory inquiry flow — send from card, pipeline, and detail
New inquiryStore (Zustand) holds sent inquiries for the session.
InquiryQuickDialog reads from the store (no props), renders wherever needed.
Sent inquiries appear immediately at the top of the Anfragen page.

Entry points:
- Match cards: "Anfrage" button on every card in the results feed
- MatchDetail: primary action in NextActionsPanel
- Pipeline: chat icon on every DraggableCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:00:46 +02:00
Benjamin Sutter 9df5d285f5 fix: remove hidden score modifiers + fix Zürich auto-inference false positive
Score formula simplified to: finalScore = round(hard×60% + soft×40%)
Removes dataQualityModifier, confidenceModifier, mustHaveEval.scoreImpact —
the breakdown panel now always matches the displayed score.

Also removes 'industrie groove' from Zürich-only location signals — the phrase
describes an aesthetic preference, not a district reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:33:55 +02:00
Benjamin Sutter 609a3634bd feat: remove EXTERNAL_MARKET result type entirely
All external market properties now surface as VERIFIED_PORTFOLIO.
Removes the type from enums, domain types, mock data, matching engine,
components, hooks, and tests — zero user-visible distinction remains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:28:42 +02:00
Benjamin Sutter 11de05025c fix: merge EXTERNAL_MARKET into Verifiziertes Objekt
Only 3 result types shown to users: Verifiziertes Objekt, Maison Work,
Zukunftssignal. EXTERNAL_MARKET label and color now identical to
VERIFIED_PORTFOLIO — no visible distinction for end users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:18:33 +02:00
Benjamin Sutter 99c26b5464 feat: Decision Intelligence simplification pass
Phase 1 — MatchDetail: collapse from 11 panels to 3 visible sections
(Warum dieser Match? / Nächste Aktion / toggle). All analysis panels
(ScoreBreakdown, Risk, MissingData, NeedAlignment, Location, Tradeoffs,
FutureAvailabilityContext) hidden behind "Vollständige Analyse anzeigen".
Removes 2-column sidebar layout for cleaner single-column reading flow.

Phase 2 — Supply Dashboard: replace data quality KPIs with demand
intelligence. Hero now shows "Starke Match-Anfragen", active properties,
and Zukunftssignale count. Top 4 matches listed with score + reason +
next action. Data quality demoted to secondary collapsed notice.

Phase 3 — Future Availability: add informational banner above the results
feed when Zukunftssignale are present. Framed as professional market
intelligence (contract expiries, construction signals) not as risk.
Removed Zukunftssignal from DecisionContextPanel risks array.

Phase 4 — Naming: standardise RESULT_TYPE_META to "Verifiziertes Objekt" /
"Externes Angebot" / "Maison Work" / "Zukunftssignal" across all screens.
EXTERNAL_MARKET gets distinct amber color (#d97706). Remove Gold/Silver/
Bronze tier label from match score badge — only the % number is shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:09:12 +02:00
Benjamin Sutter 58a0a05eff test: integration coverage for stores, services, MockAIService, and hooks
- Stores (29 tests): compareStore max-4 enforcement, toastStore unique IDs,
  shortlistStore dialog state, assistantStore context merge + clearConversation
- Services (37 tests): needService CRUD, propertyService dashboard summary
  active-status filter, matchService computeMatchesForNeed + getStrongMatches,
  futureSignalService summary math (distribution totals, highConfidence count)
- MockAIService (18 tests): follow-up question priority ordering, area ambiguity
  detection (ratio >8 and zero values), max-3 cap, trade-off risk logic
  (LOW/MEDIUM/HIGH thresholds), match explanation headline tiers, provenance shape
- Hooks (20 tests): useNeeds/useProperties/useMatches envelope unwrap, disabled-
  when-empty guards, useCreateNeed/useCreateProperty mutations, useNeed/useMatchDetail

All 257 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:55:02 +02:00
Benjamin Sutter e1f4beb898 refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening
- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble)
  fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy
- Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds()
  with optional refetchOnMount/gcTime overrides
- NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under
  src/components/new-listing/; page shell reduced to 121 lines
- AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/
  fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns,
  MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection,
  prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision)
- Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:10:39 +02:00
Benjamin Sutter e36c5bc979 refactor(arch): eliminate direct service calls in components — route all through hooks
New hook files:
- useAuth.ts: useLogin, useSwitchDemoRole, useSwitchOrganization
- useAI.ts: useParseNeed, useGenerateOfferEmail, useGenerateDecisionBrief, useParseListingText
- useAssistant.ts: useAssistantSuggestions (useQuery), useAssistantAnswer (useMutation)
- useOfferReport.ts: useOfferReportByInquiry, useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf
- useInquiryReport.ts: useInquiryReportByInquiry, useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport
- useMarketReport.ts: useMarketReport
- useWeighting.ts: useDefaultWeights (synchronous wrapper)
- useUnitMatches.ts: useUnitMatchesMap, useUnitBundle, useBundleMatches (useMemo wrappers)

Extended hooks: useProperties (add update/create/remove mutations),
useNeeds (add useCreateNeed), useMatches (add useNeedMatchesForProperty,
useAdditionalMatchesForInquiry), useReviewQueue (add useCreateReviewTask)

Updated 21 components/pages: all direct service imports replaced with hooks.
Deliberate exception: getRecommendedActions in DataQuality.tsx (pure sync utility, no provider access).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:56:32 +02:00
Benjamin Sutter f487435a94 fix: correct rent unit labels and monthly/annual calculation consistency
- NeedAlignmentPanel: display budget row in monthly (CHF/m²/Mt.) to match
  the Preis section, use exact division (no Math.round) to avoid 13×12≠152
- MatchDetailPropertySections + PropertyDetailPublicSections: replace
  Math.round(rentPricePerSqm/12) with exact division; show 2 decimal places
  when monthly is not a whole number (e.g. CHF 12.67 instead of CHF 13)
- Add /Jahr suffix to all 15 displays showing rentPricePerSqm or maxPerSqm
  without a time unit across results, compare, match-detail, supply, and
  anfragencenter components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:35:20 +02:00
Benjamin Sutter d4171fe9b5 feat(ai): observability tracing + improved prompt templates
- Add AITrace type, AITraceStore (circular buffer, localStorage in DEV,
  window.__aiTraces for DevTools), provenanceToStatus() helper
- Instrument OpenRouterAIService withFallback with latency tracking and
  trace recording across all three paths (no-key, success, error)
- Wrap all MockAIService methods with traceMock for consistent in-memory
  tracing including method name, latency, and validation status
- Improve all 6 prompt templates with ROLLE/AUFGABE/VERBOTE/BEISPIEL
  structure; marketSignalPrompt carries hard prohibition against claiming
  confirmed availability from unconfirmed signals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:18:42 +02:00
Benjamin Sutter 7934da7669 feat: design token system — DS_TEXT/DS_SURFACE/DS_BORDER + 237 hex migrations
Token infrastructure (src/lib/ds.ts):
- DS_TEXT: 16 semantic text color tokens (primary/secondary/muted/success/
  warning/error/info/brand/signal + dark variants successDark/warningDark/
  signalDark/infoDark for text on tinted surfaces)
- DS_BG: page/surface/subtle/muted background tokens
- DS_BORDER: default/muted/strong border tokens
- DS_SURFACE: 10 bg+border surface pairs (success/warning/error/info/indigo/
  purple/orange/blue/neutral/slate)
- DS_MATCH_TIER: score-tier surface aliases keyed by strong/moderate/weak
- DS_PRE_MARKET / DS_MARKET_SIGNAL: named aliases for futureCard tokens
- DS_SHADOW: card/panel/dialog elevation tokens
- BADGE_COLORS: 14 semantic presets for GenericBadge (confidenceHigh,
  riskMedium, preMarket, marketSignal, verified, gold, silver, bronze…)

GenericBadge (src/components/shared/GenericBadge.tsx):
- New semanticVariant prop (keyof BADGE_COLORS) — preferred over raw hex
- color prop becomes optional (fallback), type-documented as escape hatch

Priority file migrations — 237 hex literals replaced across 10 files:
  ScoreBreakdownPanel.tsx    −22  PipelineDetailPanel.tsx    −22
  FutureAvailabilityCard.tsx −26  AICompareSummary.tsx       −27
  LocationIntelligencePanel  −39  AssistantPromptSuggestions −18
  UnitStructurePanel.tsx     −25  BerichtDialog.tsx          −24
  PreMarketPanel.tsx         −24  PropertyActivityLogPanel   −10

ESLint (eslint.config.js):
- Updated rule message to reference new tokens (DS_TEXT, DS_SURFACE, etc.)
- Added 'stroke' to monitored property names
- Remains 'warn' for gradual migration; use check:tokens for CI gate

CI script (scripts/check-tokens.js + npm run check:tokens):
- Counts hex patterns in components/ + pages/
- Fails if count > THRESHOLD (ratchet: 1958 baseline, lower per sprint)
- Reports top 15 offenders for prioritizing next migration batch

Results: ESLint targeted sx-prop violations: 1752 → 1030 (−41%)
0 TypeScript errors, 154 tests green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:05:52 +02:00
Benjamin Sutter e62391af66 feat: Zod AI validation, AIProvenance governance, fix tests (154 green)
- Add AIProvenance + AIResponse<T> to IAIService — all 11 methods now
  return structured provenance (provider, model, source, fallbackUsed,
  validationPassed) instead of bare ItemResponse<T>
- Add schemas.ts with Zod schemas for all 8 AI response types;
  validateAIResponse() utility returns null on failure, never throws
- Rewrite OpenRouterAIService: every method validates AI JSON against
  its Zod schema; failed validation triggers MockAIService fallback
  with fallbackUsed:true — no invalid data can reach the UI
- Fix MockAIService.generateFollowUpQuestions: replace broken
  mockParseNeed(JSON.stringify(criteria)) with direct ParsedNeedCriteria
  field inspection; returns max 3 prioritised FollowUpQuestion objects
- Add provenance: mockProvenance() to all MockAIService responses
- Improve decisionBriefPrompt: structured JSON schema example,
  confidence vocabulary, availability disclaimer
- Improve matchExplanationPrompt: score-tier vocabulary, isFutureSignal
  flag forbids confirmed-availability language for future signals
- Add 102 new tests: mustHaveScorer (16), softFactorEnrichment (38),
  aiSchemas (52) — 154 total, all passing; 0 TypeScript errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:44:46 +02:00
Benjamin Sutter 8f1db31683 refactor: GenericBadge + Provider-Isolation
Teil A — GenericBadge:
- Neue src/components/shared/GenericBadge.tsx mit zwei Varianten:
  transparent (${color}18 Hintergrund, farbiger Text, opt. Border)
  solid (gefüllte Farbe, weisser Text)
- Props: label, color, variant, showBorder, bold, icon, size, tooltip, ariaLabel
- 7 Badges auf GenericBadge refactored (Config-Objekt + 1-Zeiler):
  ReviewStatusBadge, ReviewPriorityBadge, ReviewEntityTypeBadge,
  AIOutputStatusBadge, AIErrorBadge, MatchStatusBadge, ShortlistStatusBadge
- Barrel-Export in src/components/shared/index.ts

Teil B — Provider-Isolation:
- src/mock-data/propertyStore.ts als neutrales Daten-Modul erstellt
- MockupPropertyProvider: importiert aus mock-data statt selbst zu definieren
- MockupUnitProvider: importiert aus mock-data statt aus MockupPropertyProvider
- matchSyncService: importiert aus mock-data statt aus MockupPropertyProvider
- Kein Provider importiert mehr einen anderen Provider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:13:34 +02:00
Benjamin Sutter 7f090a48ff refactor: replace hardcoded hex colors with design tokens across priority components
- Add DS_COLORS.heat, DS_COLORS.futureCard, INQUIRY_STATUS_META tokens to ds.ts
- Eliminate 4 duplicate RESULT_TYPE_META / TYPE_META constants (now all use ds.ts)
- Eliminate 3 duplicate scoreColor / SCORE_COLOR functions (now all use matchScoreHex)
- HeatBadge: use DS_COLORS.heat.VERY_HOT / HOT tokens
- FutureAvailabilityCard: use DS_COLORS.futureCard.controlled / signal tokens
- IntelligenceMatchCard: import RESULT_TYPE_META from ds.ts, remove primary override
- MatchScoreDisplay: replace local scoreColor() with matchScoreHex()
- CompareColumnHeader: remove local TYPE_META + SCORE_COLOR, use ds.ts + utils.ts
- CompareTableBody: import RESULT_TYPE_META + matchScoreHex, fix #7c3aed → token
- compareUtils: remove TYPE_META + SCORE_COLOR, use dataQualityHex in scoreBar
- Anfragen + AnfragenInquiryItem: deduplicate STATUS_CONFIG → INQUIRY_STATUS_META
- Anfragen: #1e3a5f → 'primary.main', KI alert → futureCard.controlled tokens
- AnfragenMessageBubble: own-message bubble → 'primary.main', AI bubble → tokens
- ScoreBreakdownPanel: #1e3a5f → 'primary.main', #6d28d9 → futureCard.controlled
- ESLint: add warn rule against new hex colors in component sx props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:02:01 +02:00
Benjamin Sutter c972392b78 feat: OpenRouter-ready AI service — all 11 methods implemented
IAIService:
+ generateMatchExplanation, summarizeTradeOffs, generateDataQualitySummary,
  classifyMarketSignal (4 new methods covering all documented AI output types)

OpenRouterAIService:
- All 11 methods now make real API calls via withFallback() pattern
- Every fallback is explicit (console.warn/error) — no silent mock bleed-through
- Proper JSON extraction with type-safe parsers, no any casts
- parseNeed: AI JSON → ParseNeedResult mapping (no TODO stubs)
- generateFollowUpQuestions, generateMatchExplanation, summarizeTradeOffs,
  generateDataQualitySummary, classifyMarketSignal, generateOfferEmail,
  extractCriteria, generateFollowUp: fully implemented

Prompts: +followUpQuestionsPrompt, +tradeOffPrompt, +dataQualityPrompt, +marketSignalPrompt

Factory (index.ts):
- VITE_AI_PROVIDER=mock|openrouter (new, takes priority)
- VITE_USE_REAL_AI=true still supported (legacy compat)
- Missing API key → explicit console.warn + MockAIService fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:43:51 +02:00
Benjamin Sutter 723f553939 refactor: move PipelineItems from Zustand to Provider→Service→React Query
PipelineItems are domain data and must not live in Zustand. Moves the
full stack to the correct layer: MockupPipelineProvider (localStorage
persistence + seed fallback) → pipelineService → usePipeline hooks
(useQuery for reads, useMutation for writes with cache invalidation).

pipelineStore is now UI-only: dialogOpen, pendingItem, openSavedDialog,
closeSavedDialog. All consumers updated to use the new hooks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:32:57 +02:00
Benjamin Sutter 0582031930 test: Vitest test infrastructure + 52 unit tests for matching engine
Install vitest, @vitest/coverage-v8, jsdom, @testing-library/react/jest-dom.
Add test / test:watch / test:coverage scripts to package.json.

Three test suites covering the business-critical scoring pipeline:

scoreCalculator.test.ts (28 tests)
- calcDataQualityModifier: all 5 boundary thresholds (+5 / 0 / -5 / -10 / -15)
- calcConfidenceModifier: verified/external/maison-work/future + low-conf stacking
- applyHardFilters: pass, wrong asset type, area tolerance, budget exclusion, OCCUPIED penalty, excluded city
- calculateScore: strong match ≥85, weak match <50, excluded=0, determinism,
  formula verification, DQ+confidence direction, occupied 25-point penalty,
  positive factors, allHardFactors completeness

rankingEngine.test.ts (10 tests)
- matchStrengthFromScore: STRONG/MODERATE/WEAK boundaries (78/52)
- rankMatches: score sort, type tiebreak (VERIFIED > EXTERNAL), confidence tiebreak
- buildFullMatch: all explainability fields, SHORTLIST+CONTACT for strong match,
  SCHEDULE for future signals, uncertainty indicators

matchCardAdapter.test.ts (14 tests)
- VERIFIED_PORTFOLIO: resultType, title, locationLabel, matchScore, scoreBreakdown,
  no disclaimer, reasons from positiveFactors, actions passthrough
- FUTURE_AVAILABILITY: resultType, disclaimer always present, signalProbability,
  signalQuality HIGH for probability >= 0.70

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:33:12 +02:00
Benjamin Sutter 713ef3ec08 docs: engineering governance — CLAUDE.md, ARCHITECTURE.md, CODE_REVIEW_CHECKLIST.md
Complete rewrite of CLAUDE.md with full binding rules (vision, architecture layers,
component limits, state management, design system, AI integration, performance,
security). New ARCHITECTURE.md covers folder structure, all data flows with ASCII
diagrams, scoring pipeline, AI flow, unified result feed, and workspace routing.
New CODE_REVIEW_CHECKLIST.md provides a 10-section review guide and pre-commit
quick checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:10:30 +02:00
Benjamin Sutter ad84fe8289 fix: rename compareUtils.ts → compareUtils.tsx (contains JSX)
.ts files cannot contain JSX — Vite/oxc parse error at the <Box> on line 83.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:54:46 +02:00
Benjamin Sutter ce67da73b3 perf: memoize expensive list computations + memo on grid/list items
Results.tsx:
- useMemo: filter + sort in one pass (was 7 separate array iterations per render)
- useMemo: platform/maison/future/missingData counts in single for-loop
- Fix: move queryClient.invalidateQueries from render body into useEffect

Properties.tsx:
- useMemo: wrap applyFilters() call (was full copy+sort on every render)
- useMemo: compute matchReady/criticalGaps/lowConfidence/staleOrOutdated/
  allMissingFields in a single for-loop (was 5 separate filter passes)

ReminderFeed.tsx:
- useMemo: wrap applyFilters() call
- useCallback: resetFilters (passed to ReminderEmptyState)

PropertyIntelligenceCard, ReminderListRow:
- React.memo: grid/list items no longer re-render when unrelated parent
  state changes (e.g. selectedId, filter UI state)

tsc --noEmit passes with zero errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:53:03 +02:00
Benjamin Sutter 86bdf142b4 refactor: consolidate duplicated UI score/color/badge logic into lib/
- Add matchScoreHex(), criterionScoreColor(), criterionScoreTextColor() to lib/utils.ts
- Add RESULT_TYPE_META (labels + colors) to lib/ds.ts as single source of truth
- Remove 5 local scoreColor() functions: StrongMatchMiniCard, MatchListCard,
  pipelineUtils (re-exported as matchScoreHex), CriterionRow, ScoreBreakdownPanel,
  ScoreInlineBreakdown
- Remove local RESULT_TYPE_META/TYPE_META from MatchCardHeader, ResultTypeBadge;
  remove RESULT_TYPE_LABEL/COLOR from pipelineConstants — all now use lib/ds.ts
- Replace local confidenceColor() in MatchCardHeader, ConfidenceFieldBadge,
  ReliabilityScorePanel with confidenceHex() from lib/utils.ts
- Replace local qualityColor() in propertyHelpers, DataQualityWidget with
  dataQualityColor() from lib/utils.ts
- Fix FUTURE_AVAILABILITY label inconsistency ('Future'/'Zukunft' → 'Zukunftssignal')
- Fix EXTERNAL_MARKET label inconsistency ('Direktinserat' → 'Plattform')
- tsc --noEmit passes with zero errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:47:08 +02:00
Benjamin Sutter 5d53f35dea refactor(state): clean layoutStore, add selectors, centralise STALE constants
- layoutStore: remove 4 dead field groups (pinnedPanels, selectedResultId,
  compareTrayVisible, notificationsOpen) — none were read outside the store
- CompareTray: drop dead useLayoutStore side-effect (state was write-only)
- 8 components: replace bare useStore() with explicit selectors / useShallow
  to prevent unnecessary re-renders on unrelated state mutations
- lib/constants: add STALE_MARKET_SIGNALS + STALE_REVIEW_QUEUE (30s each)
- useMarketSignals, useReviewQueue: use global constants instead of
  hook-local magic numbers
- Add STATE_MANAGEMENT.md: decision tree + rules for RQ/Zustand/local/derived

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:30:44 +02:00
Benjamin Sutter eedee83b49 refactor: extract SignalSourcesSection from FutureAvailabilityContextPanel (314 → 227 lines)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:18:49 +02:00
Benjamin Sutter 01c0f8b06f refactor: extract helpers from 3 match-detail/match-card components
- LocationIntelligencePanel (333→275): KpiTile + NEW_PROJECTS → own files
- FutureAvailabilityContextPanel (351→315): constants + utils → own files
- FutureAvailabilityCard (310→244): helpers + SignalQualityDots → own files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:16:32 +02:00
Benjamin Sutter c6055f0611 refactor: extract CompareTableBody from Compare.tsx (441 → 147 lines)
Move 18 inline table row definitions into CompareTableBody component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:13:59 +02:00
Benjamin Sutter a3fc213916 refactor: extract helpers from ReminderDetailDrawer + OfferCreationWizard
- ReminderDetailDrawer (340→257 lines): constants + SectionTitle/DateRow/ActivityEntry → reminderDetailHelpers.tsx
- OfferCreationWizard (350→276 lines): PDF step JSX → OfferWizardPdfStep.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:10:31 +02:00
Benjamin Sutter ee71ecc881 refactor: extract sub-components from PropertyMarketSignalsTab + NegotiationInsightsPanel
- PropertyMarketSignalsTab (416→100 lines): SignalCard, BerichtDialog → own files
- NegotiationInsightsPanel (345→260 lines): pure logic → negotiationInsightsUtils.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:08:45 +02:00
Benjamin Sutter f0988b7250 refactor: extract constants + mapper from NewListing.tsx (526 → 442 lines)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:05:57 +02:00
Benjamin Sutter 0bacd188d6 refactor: split AppShell, match-detail panels, extract useCompareData
AppShell.tsx: 627→116 lines
- appShellConfig.ts: NavItem/WorkspaceConfig types, WORKSPACE_CONFIG, nav helpers
- AppShellSidebar.tsx: Sidebar component with visual constants
- AppShellTopBar.tsx: TopBar component

Match-detail panels:
- ScoreBreakdownPanel: 377→303 lines (scoreBreakdownConstants.ts + CriterionRow.tsx extracted)
- LocationIntelligencePanel: 383→333 lines (SoftFactorBar.tsx extracted)
- FutureAvailabilityContextPanel: 386→351 lines (futureAvailabilityConstants.tsx extracted)

Compare.tsx: 485→441 lines
- useCompareData hook: all queries and derived state extracted to hooks/useCompareData.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:00:38 +02:00
Benjamin Sutter ae82d0e6a0 refactor: split PropertyDetail, Anfragen, AISearch god components
PropertyDetail.tsx: 565→118 lines
- Removed duplicate constants (import from MatchDetailPropertyDetails)
- PropertyDetailPublicSections: Preis/Hauptangaben/Eigenschaften/Wegzeit/Einheiten/Beschreibung/Quelle sections
- PropertyContactForm: Verwaltung kontaktieren form

Anfragen.tsx: 481→320 lines
- anfragenKiDetection.ts: STAGE_ORDER, STAGE_LABELS, KI_RULES, detectKiStage
- AnfragenMessageBubble: chat message bubble component
- AnfragenInquiryItem: inquiry list row component

AISearch.tsx: 398→342 lines
- needSearchMapper.ts: generateSummary + buildNeedInput pure functions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:55:55 +02:00
Benjamin Sutter 4d4ea6d2ff refactor: split PropertyDetailView (998→192 lines) and MatchDetail (464→236 lines)
PropertyDetailView.tsx extracted into 5 focused components:
- PropertyDetailHelpers: Field, FieldGrid, SectionTitle, floorLabel
- UnitStructurePanel: floor structure with unit matching
- PreMarketPanel: schattenmarkt release controls
- PropertyDetailOverview: overview tab content
- MatchabilityTabPanel: need matches tab

MatchDetail.tsx extracted into 2 focused components:
- MatchDetailHero: image/map, header, key facts strip
- MatchDetailPropertySections: Preis/Hauptangaben/Eigenschaften/etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:48:13 +02:00
Benjamin Sutter 6515acb7f0 feat: unified error handling + AI service modularisation
Error handling (Prompt 2):
- src/services/errors.ts: AppError class, normalizeError(), throwServiceError() helper
- 6 services wrapped with try/catch (property, match, need, shortlist, futureSignal, inquiry)
- inquiryService aligned from custom ServiceResult<T> to standard ServiceResponse types
- Results, MatchCenter, FutureAvailability pages show <ErrorState onRetry> on query failure

AI modularisation (Prompt 3):
- src/services/aiService.ts reduced from 755 → 19 lines (barrel re-export)
- src/services/ai/IAIService.ts: typed interface + all response types
- src/services/ai/mock/: needParser, compareBuilder, decisionBrief, listingParser, MockAIService
- src/services/ai/openrouter/OpenRouterAIService.ts: model-agnostic skeleton
- src/services/ai/prompts/: 4 prompt template files (needParsing, matchExplanation, compareSummary, decisionBrief)
- src/services/ai/index.ts: factory selects Mock or OpenRouter via VITE_USE_REAL_AI flag
- All existing import paths unchanged — zero call-site modifications

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:32:01 +02:00
Benjamin Sutter efc72b720e fix: P0 stabilisation — score modifiers, logout cache clear, provider isolation, mutation error feedback
- scoreCalculator: apply dataQuality/confidence modifiers to finalScore (were computed but hardcoded to 0)
- authService: call queryClient.clear() on logout to prevent cross-session data leakage
- queryClient: extract to src/lib/queryClient.ts singleton so services can access it without circular imports
- matchSyncService: new service layer owns match-generation logic; MockupNeedProvider no longer imports other providers directly
- hooks (11 files): add onError + German toast feedback to every useMutation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 00:13:14 +02:00
Benjamin Sutter 22c195b4a5 refactor: split large page components + taxonomy/HeatBadge/FutureAvailability improvements
- Taxonomy: merge VERIFIED_PORTFOLIO + EXTERNAL_MARKET display → 'Plattform' (dark blue) across all surfaces
- HeatBadge: new flame indicator for hot properties (grid, list, pipeline views)
- FutureAvailabilityContextPanel: richer detail page with AI summary, strategic assessment, sources
- Refactor Pipeline.tsx (630→152 lines) → pipeline/PipelineCard, PipelineColumn, PipelineDetailPanel, pipelineConstants, pipelineUtils
- Refactor IntelligenceMatchCard.tsx (483→179 lines) → FutureAvailabilityCard extracted
- Refactor MatchDetail.tsx (559→464 lines) → useMatchDetailData hook, MatchDetailPropertyDetails
- Refactor Compare.tsx (638→485 lines) → compareUtils, CompareCriteriaCard extracted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 23:36:03 +02:00
Benjamin Sutter 72e4f08900 fix: Compare→Pipeline flow, pipeline card navigation, remove floating AI button
- Remove CompareTray fixed bottom bar; replace with compare count badge on Vergleich nav item
- Pipeline cards redesigned to match search result card visual style (MatchScoreDisplay, type chip, stage chip, MapPin layout)
- CompareColumnHeader: redesigned with proper Details/Merken action buttons, clickable title
- AddToPipelineDialog now mounted on Compare page (was missing — bookmark had no effect)
- Pipeline card ExternalLink icon navigates to property detail page; detailPath prefers stable propertyId over volatile session matchId
- Add matchId field to PipelineItem domain; pipelineStore stores it on save
- All 8 mock pipeline items now have propertyId for reliable cross-session navigation
- Remove GlobalAIAssistantButton floating overlay (was blocking form submissions and clicks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:11:29 +02:00
Benjamin Sutter 6a6ff7f2e2 feat: Pipeline↔Anfragen integration + Compare→Pipeline + KI stage detection
Navigation: Deal Pipeline moved after Vergleich (before Anfragen)

Compare → Pipeline:
- Bookmark icon per column header; BookmarkCheck when already in pipeline
- Passes propertyId, propertyAddress, area/rent labels on save

Pipeline cards now unit-level:
- propertyAddress shown with MapPin on every card
- Chat icon (MessageSquare) on cards with linked inquiry → navigates to /demand/anfragen?inquiry=xxx
- Detail panel: Chat chip links to specific inquiry thread, propertyAddress displayed

Anfragen → Pipeline KI detection:
- Keyword scan on every sent message (besichtigung → VISITED, mietvertrag → NEGOTIATION, unterschrieben → CLOSED_WON)
- Only advances stage, never goes back
- Purple KI alert banner with direct Pipeline link, auto-dismisses after 6s
- Pipeline badge in inquiry list + stage chip in chat header with nav link
- URL param ?inquiry=xxx pre-selects inquiry (used from Pipeline chat button)

Domain: PipelineItem gains propertyId, unitId, propertyAddress, inquiryId
Mock data: pl-001/pl-002/pl-004 linked to inq-001/inq-005/inq-004

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:42:21 +02:00
Benjamin Sutter a002597f5b feat: drag & drop for Pipeline Kanban columns (@dnd-kit)
Cards can be dragged between all 7 stages. Drop target highlights with
a dashed border + tinted background. A floating card overlay follows
the cursor during drag. Click-to-select still works (fires only when
no drag occurred, guarded by activeId check).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:34:00 +02:00
Benjamin Sutter de00f758e9 feat: merge Merkliste into Pipeline (SAVED stage) + Anfragen chat page
- Remove standalone Merkliste/Shortlists nav item; pipeline now covers the full funnel
- Add SAVED as first PipelineStage — 'Merken' on result cards lands here
- pipelineStore (Zustand) holds shared items; AddToPipelineDialog replaces AddToShortlistDialog in demand workspace
- New /demand/anfragen split-panel: searchable inquiry list + chat thread with compose
- Pipeline detail panel: KI insight, stage actions, editable notes, mock documents
- ShortlistItemCard: cards clickable → property detail, Anfrage button with inline dialog
- Supply workspace (FutureAvailability, Anfragencenter) unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:31:30 +02:00
Benjamin Sutter 76f9927c7c fix: UX polish — score formula, role badges, compare view, page titles
- Score: remove hidden dataQuality/confidence modifiers from finalScore;
  formula now correctly shows hard×60% + soft×40% = displayed total
- "Ihr Objekt" badge: only shown for PROPERTY_MANAGER/ORGANIZATION_ADMIN
  in both IntelligenceMatchCard and MatchCardHeader (DEMAND_USER sees
  Verified Portfolio as a normal listing without ownership indicator)
- Compare: fix factor lookup to use allFactors (includes mid-range 45–70
  scores) so Prestige, Erreichbarkeit etc. no longer show "Nicht verfügbar"
- Compare: richer AI summary with overallAssessment, perPropertyAssessment
  (strengths/weaknesses/bestFor/keyRisk per property), and recommendation
- Compare/Results: pb:10 so CompareTray never overlaps last row of content
- AppShell: dynamic route names for /demand/results/:id → "Match Detail"
  and /demand/property/:id → "Objekt Detail" (no more UUID in header)
- MatchDetail: remove "Match EF9" debug chip from sticky nav
- LocationIntelligencePanel: comparable listings are now clickable links
  navigating to /demand/property/:id
- Comparable links: blue text + hover highlight

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:05:36 +02:00
Benjamin Sutter 9f7062d137 feat: UC-A/B/C demo — 50 mock properties, district scoring, gold/silver cards visible
- Add 50 mock properties (prop-050–099) tuned for UC-A (Fahrradhändler RETAIL),
  UC-B (Umzugsfirma OFFICE Zürich-West) and UC-C (Vermögensverwalter PREMIUM)
- District-aware scoreLocation: district match→100, city-only→70 when need
  specifies districts, canton→60, no match→35; fixes UC-C prop-001 over-scoring
- mustHaveScorer: add klimatisierung keyword; parking minimum count logic
- Soft factor scale fix: integer 0–100 values no longer multiplied ×100
- footfall: map passerbyFrequency string (HIGH→85, MEDIUM_HIGH→68…) before
  enrichment fallback so RETAIL properties score correctly
- Parser: Kreis list extraction ("Kreis 3, 4, 5, und 8" → 4 district entries),
  neighbourhood→district map (Seefeld, Bahnhofstrasse), prestige signals
- Results: remove VERIFIED_PORTFOLIO role gate — all users see portfolio cards,
  enabling gold (85+) and silver (70–84) cards for every demo use case
- Fix flash of wrong cards on NeedBuilder nav (effectiveNeedId not activeNeed?.id)
- needs.ts: UC-A preferredLocations now includes Kreis 3/4/5/8 entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:31:30 +02:00
Benjamin Sutter 98b4a1146b feat: soft factor enrichment from Swiss location intelligence
When property has no soft factor data, estimate from location:
- 18 location rules covering CH cities/districts (Zürich quartiers,
  Zug tax haven, Bern, Basel, Genf, Lausanne, Luzern, Winterthur,
  logistics hubs) + generic CH fallback
- Estimated factors marked with 'estimated: true' on ScoreFactor
- ScoreBreakdownPanel shows purple 'Schätzung' badge on estimated rows
- Scoring engine: neutral 50 only if no location match found

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:04:51 +02:00
Benjamin Sutter f82194ea7f fix: always show all 9 soft factors in score breakdown
Previously factors with weight=0 (e.g. Steuerlast for Innovatech)
were invisible. Now all 9 factors appear in allSoftFactors; only
weighted ones contribute to the score calculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:21:35 +02:00
Benjamin Sutter 030d861ed5 fix: restore WEIGHTING_KEYS import in WeightingEditor 2026-05-22 10:16:36 +02:00
Benjamin Sutter 0de48a14d7 feat: split WeightingEditor into Harte / Weiche Kriterien sections
Hard criteria (Fläche, Standort, Budget, Verfügbarkeit) in dark blue,
soft criteria (Prestige, Erreichbarkeit, …) in purple — separated by
a divider with group labels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:13:46 +02:00
Benjamin Sutter 9f877614c1 fix: soft factor scores use 0-1 scale matching the scoring engine
LOW=0.30 / MEDIUM=0.55 / HIGH=0.85 — previously 3/5/8 which clamped
every soft factor to 100 regardless of level chosen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 10:11:18 +02:00
Benjamin Sutter 955da564a0 fix: invalidate properties cache after listing creation
Without this, navigating to Meine Inserate within 5 min of visiting
it previously would show a stale empty list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:22:34 +02:00
Benjamin Sutter c9324e9cc8 feat: expand NewListing with AI, soft factors, hard facts, images + MyListings manager
- KI-Hilfe: text description → auto-fills all fields (parseListingText mock parser)
- Lage & Ausstrahlung: 9 soft factor selects (Tief/Mittel/Hoch) mapped to scoring engine
- Technische Details: floor, fit-out, parking, ceiling height
- Bilder: URL list with add/remove
- New /supply/my-listings page: list, status toggle, delete for DIRECT listings
- Added sourceType filter to PropertyFilters + MockupPropertyProvider
- Nav entry "Meine Inserate" added to supply sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:19:45 +02:00
Benjamin Sutter 0dac528f98 fix: remove Quelle & Herkunft panel from demand MatchDetail
Internal data provenance is irrelevant for property seekers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:12:33 +02:00
Benjamin Sutter 67d3d06a53 feat: Inserat erstellen — direct listing flow for units and standalone
- New /supply/new-listing page (Path A: pre-filled from portfolio unit,
  Path B: standalone blank form); creates EXTERNAL_MARKET + sourceType DIRECT
- "+ Inserat" button on available units in PropertyDetailView navigates
  with prefilled address/area/rent
- Nav entry "Neues Inserat" added to supply sidebar
- SourceProvenancePanel hidden for DIRECT source listings (no external source)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:10:41 +02:00
Benjamin Sutter 531c88cfb6 fix: weighting sliders in 2-column grid layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:59:09 +02:00
Benjamin Sutter ba910806d6 feat: expose all 13 scoring criteria as weighting sliders
Previously only 8 of the 13 engine criteria had sliders — flexibility,
visibility, footfall, talentAccess, esg were scored using fixed defaults
with no user control. Now all 13 criteria appear in the WeightingEditor.

- needBuilder.ts: WEIGHTING_KEYS and WEIGHTING_LABELS extended to all 13
- scoreCalculator.ts: CORE array in resolveProfile now includes all 13
- need.ts: WeightingProfile interface explicit for all 13 fields
- needs.ts: all 11 mock need profiles updated with asset-type-appropriate
  weights (RETAIL needs high footfall/visibility; LOGISTICS low; OFFICE
  high talentAccess; TechStart high taxEnvironment for Zug, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:58:01 +02:00
Benjamin Sutter 113274cfc7 fix: remove need selector chip strip from results page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:51:59 +02:00
Benjamin Sutter d8cce96bd8 fix: hide 'Neue Suche' entries from need selector chips
Only named needs (company name != 'Neue Suche') appear in the chip strip.
Default active need is also the first named need, not the most-recently-created
test search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:50:22 +02:00
Benjamin Sutter 11ecd5d900 fix: need selector on results page + retail budget parsing
- Results page: chip strip shows all saved searches — click to switch without
  creating a new search. Defaults to first need instead of most-recently-created
  so pre-existing mock needs load immediately on navigation.
- AI parser: asset-type-aware monthly budget threshold (RETAIL: 500, others: 150)
  so "150 CHF/m²" for retail is correctly converted to 1800 CHF/m²/year instead
  of being stored as an impossibly low annual value that excludes all properties.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:47:14 +02:00
Benjamin Sutter 27c53f3af1 feat: score transparency on all cards + budget parser fix
- Single scoring system: MockupNeedProvider rewrites matches via calculateScore
  exclusively — allFactors always populated, no more dual-system (computeScore
  removed), weights from weightingProfile reflected in every breakdown
- ScoreInlineBreakdown: new component shows hard/soft criteria with importance
  labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards
- MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel
- MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability'
- aiService budget parser: values < 100 treated as monthly and multiplied by 12
  to produce correct annual CHF/m²/year value — fixes 0-result searches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:41:46 +02:00
Benjamin Sutter 34a4dcfb29 feat: unit-first architecture — floor + street as primary card title
Every VERIFIED_PORTFOLIO property now has explicit units in mock data
(added to prop-002, prop-008, prop-009, prop-010, prop-011, prop-014).

PropertyUnit extracted to domain/unit.ts with formatFloorLabel,
formatUnitTitle, formatMultiUnitFloors helpers; re-exported from
property.ts so all existing imports are unaffected.

Cards now show floor + street as the primary title:
- Specific unit match: "1.OG Nord · Zollstrasse 12"
- Multi-unit (whole building): "1.OG–3.OG · Zollstrasse 12"
- EG retail: "EG Verkauf · Löwenplatz 3"
Building name (e.g. "Bürofläche Zollstrasse 12") appears as subtitle.
Both IntelligenceMatchCard (grid) and MatchCardCompact (list) updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:19:13 +02:00
Benjamin Sutter a39c6ed200 fix: property-level scoring for multi-unit properties + multi-unit chip
For properties with explicit sub-units (e.g. prop-001: 280/310/260 m²),
synthetic matching now generates ONE property-level match using the aggregate
areaSqm (850 m²) instead of per-unit matches — ensuring a need for 800-1000 m²
correctly scores the whole floor as +20 (STRONG) rather than -10 each unit.

Pre-market unit-level matches are still generated per released unit so tenants
seeking smaller spaces still see the specific unit signals.

Card shows "X Einheiten · total Y m²" chip when a multi-unit property is matched
at property level (no specific unit assigned).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:30:03 +02:00
Benjamin Sutter e2ec1b1dc9 feat: add 'Zur Einheit →' CTA on all VERIFIED_PORTFOLIO cards
Every portfolio match card now shows a primary 'Zur Einheit →' button
that navigates to /demand/property/:propertyId?unit=:unitId, where the
user can see the unit table and contact management via the inquiry form.

'Details →' is demoted to secondary for portfolio cards (match analysis
still accessible, but unit/contact page is the primary action).
EXTERNAL_MARKET and MAISON_WORK cards are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:17:20 +02:00
Benjamin Sutter 69b96f293a refactor: remove Schattenmarkt from all user-visible UI strings
- Pipeline.tsx: FUTURE_AVAILABILITY label → 'Future Availability'
- ReminderTypeBadge: SCHATTENMARKT_RELEASE label → 'Pre-Market'
- ReminderFilterBar: SCHATTENMARKT_RELEASE label → 'Pre-Market'
- ReminderKpiBar: 'Schattenmarkt-Risiko' → 'Pre-Market Risiko'
- ReminderDetailDrawer: section title + switch label → 'Pre-Market'
- mock-data/matches: explainabilitySummary → 'Pre-Market freigegeben'
- mock-data/reminders: notes → 'Pre-Market Freigabe'

Internal identifiers (schattenmarktRelease field, useSchattenmarktSignals hook,
signal ID prefix schattenmarkt-*) are unchanged — renaming them touches too many
call sites for no user-visible gain.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:11:35 +02:00
Benjamin Sutter 99e99d66c5 feat: unit-level matching architecture — match against Einheiten, not Objekte
- domain/property: add getEffectiveUnits() — returns explicit units or synthesises
  one from property-level data so all properties work uniformly at unit level
- domain/match: add unitId? field — every Match references a specific unit
- domain/unifiedResult: add unit? to VerifiedPortfolioResult + ExternalMarketResult
- MockupNeedProvider: generateSyntheticMatches iterates getEffectiveUnits(prop);
  computeScore uses unit-level areaSqm + rentPricePerSqm; resultId for PRE-MARKET
  signals matches useSchattenmarktSignals signal ID format
- useUnifiedResults: resolve unit from match.unitId for VERIFIED_PORTFOLIO/EXTERNAL_MARKET
- matchCardAdapter: unit now extracted for all result types; unitId from match.unitId
- IntelligenceMatchCard: regular cards show unit chip (floor + label + m²) when a
  named unit is known — only adds context, never shows for synthetic/whole-property units

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:03:54 +02:00
Benjamin Sutter d902feb6c0 feat: Objekt→Einheit architecture — unit-level pre-market, property detail page, click-through from cards
- Domain: PropertyUnit extended with propertyId + schattenmarktRelease per unit
- Domain: FutureAvailabilityResult carries resolved property + unit
- useSchattenmarktSignals: generates unit-level signals (schattenmarkt-{propId}-{unitId})
- useUnifiedResults: resolves backing property + unit on FUTURE_AVAILABILITY fast path
- IUnitProvider + MockupUnitProvider: first-class unit access and mutation
- matchCardAdapter: maps preMarketUnit, preMarketAllUnits, propertyId, unitId to ViewModel
- IntelligenceMatchCard: PRE-MARKET VERIFIED shows unit info strip + "Zur Einheit →" button
- PropertyDetailView: unit-level toggles + date pickers inside PreMarketPanel
- New page: /demand/property/:propertyId with unit table, status chips, inquiry form
- App.tsx: demand route /demand/property/:propertyId registered
- Mock data: prop-001/007/037 units updated with correct lease dates + unit-level schattenmarktRelease

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:47:59 +02:00
Benjamin Sutter 66aabde1dc fix: synthetic scoring — budget unit mismatch + PRE-MARKET VERIFIED in demand feed
Two root causes prevented gold and PRE-MARKET VERIFIED cards from showing
in wizard-created searches:

1. computeScore compared annual property rates (e.g. 1080 CHF/m²/year)
   against monthly AI-extracted budget (e.g. 150 CHF/m²/month), causing
   a constant -8 penalty and capping Bern retail at ~79%. Fix: divide
   rentPricePerSqm by 12 before comparing.

2. generateSyntheticMatches set resultType: VERIFIED_PORTFOLIO for
   schattenmarktRelease-enabled properties, which Results.tsx filters
   out for demand users. Fix: set resultType: FUTURE_AVAILABILITY for
   these properties so the schattenmarkt signal is resolved and the
   PRE-MARKET VERIFIED card is rendered.

Also adds a score discount (×0.82 for probabilistic FUTURE_AVAILABILITY,
×0.92 for PRE-MARKET) to keep tier distribution realistic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 19:29:06 +02:00
Benjamin Sutter dd2eb9be3f feat: add prop-037 + match-063/064 — PRE-MARKET VERIFIED for Bern Retail search
Adds VERIFIED_PORTFOLIO retail property (Spitalgasse 18, Bern Innenstadt)
with schattenmarktRelease enabled, completing card-type coverage for
need-011 (Stadtladen Bern GmbH): now has VERIFIED_PORTFOLIO, PRE-MARKET
VERIFIED, EXTERNAL_MARKET, MAISON_WORK, and MARKET SIGNAL cards across
gold/silver/bronze score tiers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:18:12 +02:00
Benjamin Sutter d2d2f5f1bb feat: enable Pre-Market Matching on 7 more portfolio properties — 10 active signals total
Activated schattenmarktRelease on prop-008 through prop-014 with realistic
future lease end dates (2026–2027) and appropriate lead times so all
trigger dates fall before MOCK_TODAY (2026-05-20).

Active PRE-MARKET VERIFIED signals: prop-001, 002, 007, 008, 009, 010, 011, 012, 013, 014

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:59:07 +02:00
Benjamin Sutter 8f7372157c feat: Pre-Market Intelligence UX rework — institutional positioning
Redesign the Future Availability card system to clearly distinguish two classes:

PRE-MARKET VERIFIED (Verwaltung-controlled):
- Soft purple (#7c3aed) accent, institutional premium feel
- Badge: "PRE-MARKET VERIFIED" with shield icon
- "Direkte Verwaltungsquelle" attribution chip in header
- Shows confirmed ERP facts with green checkmarks

MARKET SIGNAL (crawler/probabilistic):
- Slate blue (#1d4ed8) accent, analytical feel
- Badge: "MARKET SIGNAL"
- Amber notice box: "Probabilistischer Marktindikator — kein bestätigtes Objekt"
- Shows market indicators and explicit probability %
- Source attribution (press, job posting, etc.)

Both cards answer: What? How reliable? Where from? Why relevant? Risks? Action?

PropertyDetailView: rename SchattenmarktReleasePanel → PreMarketPanel, update
toggle label to "Pre-Market Matching aktivieren", add Matching Demand Intelligence
block showing active search profile counts and early matching opportunity.

FutureAvailabilityContextPanel: update LEASE_EXPIRY signal type label and
relevance bridge text to remove "Schattenmarkt" references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:50:37 +02:00
Benjamin Sutter 67c0701bfe fix: remove EXPANSION fake properties and their matches from mock data
Deleted prop-005, prop-026, prop-028, prop-030 (resultType FUTURE_AVAILABILITY
but representing demand-side Expansion signals) and the 6 associated matches
(match-007, match-018, match-029, match-047, match-054, match-055).

These fake properties bypassed the signalDirection filter in useUnifiedResults,
causing EXPANSION signals to appear in the demand search feed. Future
Availability in the demand feed now contains only genuine supply-side signals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:10:23 +02:00
Benjamin Sutter 0dc04a14c5 fix: MarketLeads zeigt 0 Leads — Provider liest live aus Mock-Daten
MockupFutureSignalProvider kopierte mockFutureSignals einmalig in einen
store-Array beim Modulload. Vite HMR und React Query Cache (staleTime 5min)
führten dazu, dass signalDirection-Feld in gecachten Resultaten fehlte.

Lösung: Provider liest jetzt immer direkt aus dem lebenden mockFutureSignals-
Import via resolve()-Helper. Mutationen (verify, updateStatus) werden in einer
Map gespeichert und beim Lesen überlagert. Damit sind HMR-Änderungen an
Mock-Daten sofort sichtbar ohne Browser-Neustart.

Ausserdem: irreführenden CircularProgress aus Empty-State entfernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:02:32 +02:00
Benjamin Sutter b0ad6643ff feat: Future Availability — Logiktrennung Supply/Demand + Markt-Leads
EXPANSION-Signale (Unternehmen sucht Fläche) sind Nachfragesignale und
gehören nicht in den Demand-Feed. Neue Architektur:

- signalDirection: 'SUPPLY' | 'DEMAND' auf FutureSignal-Domain
- SUPPLY-Signale: POSSIBLE_MOVE_OUT / CONSTRUCTION_PROJECT (Crawler)
  und LEASE_EXPIRY+isVerified (Verwaltung ERP) → Demand-Feed wie bisher
- DEMAND-Signale: EXPANSION → aus Demand-Feed gefiltert
- Neues useMarketLeads-Hook + /supply/market-leads Seite für Verwaltung:
  zeigt EXPANSION-Signale als potenzielle Mieter mit Portfolio-Matching
- Alle 15 Signale in futureSignals.ts inhaltlich bereinigt und
  mit korrekter signalDirection versehen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:56:29 +02:00
Benjamin Sutter 5fe15bac62 feat: Future Availability Intelligence — rebrand & card redesign
Replaces "Schattenmarkt" branding with professional Future Availability
Intelligence throughout the UI. Two distinct card types: Pre-Market
(LEASE_EXPIRY + verified, green accent) and Market Signal (probabilistic,
blue accent). Professioneller Data-Header replaces dark purple gradient.
New detail panel sections: Erkannte Marktindikatoren, Strategische
Interpretation, Bestätigt/Nicht bestätigt. All 15 mock signals enriched
with concrete Swiss CRE market indicators and strategic interpretations.
Signal quality shown as 4-dot indicator (Hoch/Mittel/Niedrig).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:06:16 +02:00
Benjamin Sutter d5a4862a28 feat: redesign Schattenmarkt card & detail view for explainability
- Remove abstract Konfidenz/DQ badges from match detail header
- Replace ScoreBreakdownPanel with 3-step Bewertungsherleitung for
  FUTURE_AVAILABILITY: Kriterien-Match per factor with bars, Signal-
  Abschlag (verified ERP = kein Abschlag / probabilistic = -N Punkte),
  Gesamt-Score
- Restructure FutureAvailabilityContextPanel: source section first,
  verified ERP box for LEASE_CONTRACT, remove Konfidenz row, disclaimer
  as grey footnote instead of yellow alert
- Add LEASE_EXPIRY to signal type/relevance bridge labels
- Show up to 3 match reasons with explanation on cards (was 1)
- matchCardAdapter: return top-3 positive factors as reasons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:39:12 +02:00
Benjamin Sutter 9f391d17cb feat: Reminder Manager + Schattenmarkt-Freigabe + mock data overhaul
- Add Reminder Manager page (/supply/reminder-manager) with KPI bar,
  filter bar, list/card feed, and detail drawer (7 sections incl.
  activity log, snooze, complete, dismiss actions)
- Add Schattenmarkt-Freigabe toggle on PropertyDetailView: Verwaltung
  can opt-in properties for early market exposure before contract expiry
- Auto-generate FutureSignal cards via useSchattenmarktSignals hook
  when leaseEndDate - leadTimeMonths <= MOCK_TODAY
- Fix useUnifiedResults: use property.resultType as fallback (was
  defaulting everything to VERIFIED_PORTFOLIO)
- Fix demand Results: hide VERIFIED_PORTFOLIO from non-manager users
  even when showOwnProperties toggle was previously enabled
- Fix AppShell: redirect to allowed workspace on user role switch
- Fix 3 wrong match scores (match-002: 93→62, match-009: 86→55,
  match-017: 87→52)
- Add 7 new match records (match-050–056) for need-001, need-002,
  need-011
- Add need-011 (Retail Bern Innenstadt)
- Add prop-031–036 (EXTERNAL_MARKET / MAISON_WORK / FUTURE_AVAILABILITY)
- Fix duplicate image URLs across all properties
- Add signal-011 (Bern Altstadt, Mode Boutique) + propertyId to
  signal-001

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:52:24 +02:00
Benjamin Sutter 0663757cde fix: Schattenmarkt bypass source filter + bump external/maison match scores for all tiers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:10:41 +02:00
Benjamin Sutter 3756675b6f feat: add Maison Work source type, rename ExternalMarket to Direktinserat, hide own portfolio from demand search by default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:02:05 +02:00
Benjamin Sutter a521155bb2 feat: mix Schattenmarkt into result tiers, add filter toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:36:27 +02:00
Benjamin Sutter b6f65f4763 feat: hide workspace switcher when user has only 1 workspace
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:41:40 +02:00
Benjamin Sutter 86051c6f01 feat: workspace tabs as side-by-side segmented control in sidebar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:40:12 +02:00
Benjamin Sutter 315733a423 feat: reduce demo role switcher to Verwaltung + Bürosuche only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:37:20 +02:00
Benjamin Sutter a36a827564 feat: reduce demo login to Verwaltung + Bürosuche only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:35:55 +02:00
Benjamin Sutter d15a13e485 feat: remove Administration workspace — keep only Verwaltung + Suche
- Delete all ops page components (ReviewQueue, AIMonitoring, Governance,
  SourceMonitoring, ActivityTimeline, SignalPipeline)
- Remove OPERATIONS workspace from AppShell config, nav order, path detection
- Remove all /ops/* routes from App.tsx
- Remove WorkspaceType.OPERATIONS from allowedWorkspaces in authService,
  sessionStore, permissions
- Keep MarketIntelligence page (already moved to /supply/market-intelligence)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:32:41 +02:00
Benjamin Sutter d22e72f945 feat: move Markt Intelligence from Administration to Verwaltung (under Datenpflege) 2026-05-19 20:28:36 +02:00
Benjamin Sutter f0e58f5f7a feat: unit-level matching — each floor lettable independently or as bundle
- PropertyUnit: add isFlexible, minLettableSqm, offeredSqm fields
- New UnitBundle + UnitNeedMatch domain types
- unitMatchService: match individual units or bundles against LatentNeeds
  (exact, partial/Teilfläche, bundle match types with area scoring)
- Mock units updated: prop-001/007/012/013 free units flagged as flexible
  with minimum lettable areas
- UnitStructurePanel replaces old Stockwerkstruktur table:
  - Checkboxes on free units (when ≥2 exist) for bundle selection
  - Inline match pills per free unit (top need + score)
  - Expand row to see all matching needs with area details
  - 'Teilfläche möglich' chip for flexible units with min sqm
  - Bundle panel appears when 2+ units selected: shows combined sqm,
    bundle matches, and note about remaining area after contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:24:17 +02:00
Benjamin Sutter 7cda66f98a fix: convert strings with Swiss apostrophes (3'000, 2'500, 1'500) to template literals 2026-05-19 20:09:54 +02:00
Benjamin Sutter 18a7f146b0 fix: replace smart quotes with ASCII quotes in mock data files 2026-05-19 20:01:29 +02:00
Benjamin Sutter 13553f60a0 fix: close spec gaps — delete InquiryStatusBadge, extend match reasons, full field keys
- Delete InquiryStatusBadge.tsx (dead code after Part A read/unread migration)
- AdditionalPropertyMatch: add reasons[] + topUncertainty from positiveFactors/tradeoffs
- AdditionalMatchCard: render green reasons and amber uncertainty below match score
- ReportObjectFieldKey: add breakoutOptionDate, roomHeight, floorLoad, deliveryAccess,
  goodsLift, passengerLift, internet, microLocation, competitionEnvironment,
  infrastructure, marketSignals, negotiationHints, missingData (now 45 keys)
- ReportObjectFieldSelector: regroup into 5 spec-aligned groups (Basisdaten,
  Miet-/Vertragsdaten, Technische Hard Facts, Soft Factors, Marktinformationen)
- LatentInquiryReportPreview: add labels for all new field keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:57:16 +02:00
Benjamin Sutter b4d398270f feat: Anfragencenter Parts A–E — read/unread, prep wizard, offer flow, report preview
- Part A: Replace InquiryStatus badges with WhatsApp-style unread indicators
  (isRead, unreadCount, lastReadAt on Inquiry; markThreadAsRead in service + hook)
- Part B: RelatedPropertyCardPanel — reposition "Objekt ansehen", add "Weitere
  Matches" section via matchService.getAdditionalMatchesForInquiry
- Part C: PreparationWizard 4-step dialog — property selection, report generation
  progress, field editing + ReportObjectFieldSelector, PDF preview + finalize
- Part D: OfferCreationWizard 4-step dialog triggered from first tenant message —
  data capture, field editing, viewing appointments, PDF generation + attach to reply
- Part E: LatentInquiryReportPreview (2-page A4 doc), ReportObjectFieldSelector
  (5 accordion groups, 35+ optional fields), mapImageUrl on Property domain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:20:13 +02:00
Benjamin Sutter 279b55d70e feat: Bericht erstellen shows full PDF preview before download
Dialog now opens at md size (88vh), shows a progress bar while
generating, then reveals the full report document: header, all 4
signal sections with colour-coded separators, confidence scores,
sources, dates. Document is blurred during generation and sharpens
when ready. Herunterladen button stays disabled until preview is ready.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:17:00 +02:00
Benjamin Sutter 7800d127fe fix: BerichtDialog, images, and add property number + floor/unit structure
- BerichtDialog: conditionally mount instead of always-mounted with
  open prop; starts in 'generating' state immediately on open
- Images: fix all 6 wrong VERIFIED_PORTFOLIO and EXTERNAL_MARKET photos
  to match spec per assetType (OFFICE/RETAIL/LOGISTICS/PRODUCTION/MIXED)
- Domain: add PropertyUnit interface + propertyNumber field to Property
- Mock data: propertyNumber + units[] for prop-001/007/012/013
- UI: Stockwerkstruktur section in Übersicht tab (floor/unit table with
  availability, tenant, area); Objekt-Nr. shown below key metrics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:14:22 +02:00
Benjamin Sutter 0651333030 feat: clickable NeedMatchCard in Matchability tab navigates to Anfragencenter
Clicking a need card in the Matchability tab pre-selects the property
and navigates to Anfragencenter → Latente Anfragen tab. Angebot creation
lives here (demand-side), not in Marktsignale. NeedMatchCard gains
onClick prop with navy hover + shadow treatment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:05:18 +02:00
Benjamin Sutter 18f4b2b37a feat: navigate from Marktsignale tab to Anfragencenter latente Anfragen
'Angebot erstellen' button in Marktsignale tab pre-selects the property
in the offer store and navigates to /supply/anfragen on the Latente
Anfragen tab. Also fixes offerWizardStore.open() to preserve
selectedPropertyIds so pre-selection survives the wizard open call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:00:00 +02:00
Benjamin Sutter c0f50344e0 fix: replace toast with mock PDF generation dialog in Marktsignale tab
BerichtDialog now animates a progress bar (idle → generating → ready),
then triggers a blob download of Marktsignal-Bericht_{id}.pdf on confirm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:53:15 +02:00
Benjamin Sutter 920ce8eb09 feat: Verwaltungszugang refactor — lease data, 3-tab detail, market signals
- Dashboard: remove QuickActionPanel, StrongMatchOverview, FutureSignalWidget,
  DataQualityWidget, header buttons, Marktchancen nav; KpiGrid → 4 cards
- Property domain: 9 new fields (leaseTerm, leaseStartDate/End, breakoutOption,
  currentTenant, importedFrom, importedAt, lastUpdatedAt)
- Mock data: annual CHF/m²/Jahr prices (×12), Swiss images, tenant/lease data
  for all 10 VERIFIED_PORTFOLIO properties; need budgets updated to annual
- PropertyTable: swap columns — add Aktueller Mieter, Mietlaufzeit,
  Breakoutoption, Breakoutoption Zeitpunkt; remove Verfügbarkeit, Konfidenz, Quelle
- PropertyDetailView: 3 tabs (Übersicht, Matchability, Marktsignale) with
  inline edit mode, NeedMatchCard list, PropertyMarketSignalsTab
- New: marketReport domain + mock data + service, NeedMatchCard,
  PropertyMarketSignalsTab with 4 sections + PDF button
- matchService: getNeedMatchesForProperty(propertyId, {minScore})

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:43:52 +02:00
Benjamin Sutter 52a2693cad fix: move map from header into Übersicht tab content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:18:39 +02:00
Benjamin Sutter 73ff758f03 feat: add 'Produkttour starten' to user menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:14:06 +02:00
Benjamin Sutter 14badd1ca8 feat: all 4 UX priorities — wizard, examples, onboarding, pipeline
- Offer wizard: 4→3 steps by merging 'Prüfen' into PDF review step
- Search mask: quick-select chips for area ranges, budget, and city presets
- Onboarding: WelcomeDialog (3 slides, localStorage) on first visit
- Deal Pipeline: Kanban board (6 stages, 8 mock items, advance-to-next-stage)
  added to demand workspace navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:09:06 +02:00
Benjamin Sutter 9cf3e72033 feat: Schattenmarkt UX — separate section, relevance bridge, source links
Signals are now in their own feed section with an explanation banner
so users understand they are not confirmed properties. Each signal card
shows WHY it appears in their search (e.g. 'Fläche könnte frei werden').
Source pills are now clickable links. FutureAvailabilityContextPanel
redesigned with full AI summary, source button, and relevance bridge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:59:08 +02:00
Benjamin Sutter b5cdb0353b feat: Schattenmarkt cards — source pills, probability bar, AI summary
Complete IntelligenceMatchCard redesign for FUTURE_AVAILABILITY signals:
purple gradient hero, source type pills, credibility indicators,
probability LinearProgress bar, market indicator badge, AI-generated
signal summary block. Added aiSummary field to FutureSignal domain
and rich German AI summaries to mock signals 001–005.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:49:27 +02:00
Benjamin Sutter 5391ad0ae5 fix: image above map stacked vertically in PropertyDetailView (160px + 120px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:35:36 +02:00
Benjamin Sutter a75818b983 feat: full responsive layout — mobile sidebar, stacked panels, fullscreen drawers
- AppShell: permanent sidebar on desktop, temporary drawer + hamburger on mobile (<md)
- TopBar: hamburger icon on mobile, AI-Assistent als Icon-Button auf kleinen Screens
- MatchDetail: Sidebar stacks below content on mobile, key facts wrap, px responsive
- Properties: Drawer fullscreen (100vw) on mobile, 650px on desktop
- ActiveInquiriesTab: mobile shows list → tap opens detail with back button
- LatentInquiriesTab: mobile sequential navigation (list → detail → own properties), tablet 2-col, desktop 3-col
- PublicNeedList: fullWidth prop for mobile layout
- InquiryDetailPanel: property card stacks below chat on mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:24:00 +02:00
Benjamin Sutter 259b4e3c68 fix: compact side-by-side image+map in PropertyDetailView drawer (140px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:11:38 +02:00
Benjamin Sutter 732231ce13 fix: always show tab scroll buttons in PropertyDetailView — Aktivitätslog sichtbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:26:20 +02:00
Benjamin Sutter fdf4de37db feat: Aktivitätslog tab in PropertyDetailView — per-property timeline
Adds a new "Aktivitätslog" tab (9th tab) to the supply-side property
drawer. Shows match approvals/rejections, need registrations, data
quality flags, and governance actions linked to this specific property.
Only visible to Verwaltung users in their own property view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:12:50 +02:00
Benjamin Sutter c4c29ef7d3 feat: F030 Anfragencenter — Aktive & Latente Anfragen + Angebot-Wizard
Neuer Menüpunkt «Anfragencenter» (ehemals «Eingehende Bedarfe»):

Aktive Anfragen:
- Split-View: Anfrageliste (Liste/Grid-Toggle) + Chat-Detailansicht
- Chat-Verlauf mit Sender-Styling (Mieter links, Supply rechts)
- Antwortformular mit Betreff, Nachricht, Anhänge, Statuswechsel
- Zugehörige Property Card neben dem Chat

Latente Anfragen:
- Drei-Spalten-Layout: Need-Liste | Need-Detail | Eigene Objekte
- Need Cards mit AI-Zusammenfassung, Must-Haves, Präferenzen
- Eigene Portfolio-Objekte sortiert nach deterministischem Match-Score
- Checkbox-Selektion für Angebotsauswahl

Angebot-Wizard (4 Schritte):
- Schritt 1: Objekte auswählen (mit Score-Vorschau)
- Schritt 2: PDF-Vorschau + editierbare Textfelder
- Schritt 3: Angebot prüfen & bestätigen
- Schritt 4: Nachricht an Suchenden mit KI-generierter Mail

Architektur:
- Domain: Inquiry, LatentNeed, OfferDraft Types
- Provider: IInquiryProvider, ILatentNeedProvider, IOfferProvider + Mockups
- Services: inquiryService, latentNeedService, offerService
- Hooks: useInquiries, useLatentNeeds, useOffers (TanStack Query)
- Store: offerWizardStore (Zustand, lokaler Wizard-State)
- 27 neue Komponenten, alle Loading/Empty/Error States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:03:05 +02:00
Benjamin Sutter 05ac0083b2 feat: Steuerlast-Kriterium, ImmoScout-Layout, Gold/Silver/Bronze-Trennung
- Flächensuche: Flexibilität durch Steuerlast (taxEnvironment) ersetzt
- MatchDetail: Bild als Hero-Banner oben, Karte eingebettet im Inhalt darunter
- Ergebnisliste: Gold/Silber/Bronze-Abschnitte mit farbigen Trennlinien
- ScoreBreakdown: KI-Steuerlast-Link zur kantonalen Steuerrechner-Seite
- Beispieldaten: alle Objekte mit passenden Bildern versehen
- locationIntelligence: taxCalculatorUrl pro Kanton ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:14:10 +02:00
Benjamin Sutter 5b917bd619 feat: embedded interactive map in property + match detail views
Integrates react-leaflet with OpenStreetMap tiles. PropertyDetailView
and MatchDetail now show property photo + zoomable/pannable map above
the tabs/content — always visible on card click, no redirect. Map
renders at correct lat/lng with a location marker and popup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:34:57 +02:00
Benjamin Sutter c75d3c8121 feat: F029 intelligence card system — premium AI-native CRE cards
Gold/Silver/Bronze FIFA-style score badges overlaid on location imagery.
List/grid view toggle on Results and Properties pages (persisted in
localStorage). IntelligenceMatchCard for match results grid view;
PropertyIntelligenceCard for property grid view with warm brown theme.
LocationPreview component with Google Maps link. 10 mock properties
enriched with Unsplash building images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:25:27 +02:00
593 changed files with 61086 additions and 6400 deletions
+35
View File
@@ -0,0 +1,35 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(git add *)",
"Bash(git push *)",
"Bash(npx tsc *)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
"Bash(find c:\\\\\\\\Users\\\\\\\\beni_\\\\\\\\OneDrive\\\\\\\\Desktop\\\\\\\\property-match -name \"Dashboard.tsx\" -type f)",
"Bash(awk '{print $NF}')",
"Bash(start http://localhost:5173)",
"Bash(npm install *)",
"Bash(git pull *)",
"Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")",
"Bash(Get-ChildItem \"c:\\\\Users\\\\beni_\\\\AppData\\\\Local\\\\Temp\\\\docx-gen\\\\node_modules\\\\docx\" -Recurse -Name)",
"Bash(start \"C:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\Aenderungsprotokoll_Meeting_26052026.docx\")",
"Bash(start http://localhost:5176)",
"Bash(start http://localhost:5176/demand/ai-search)",
"Bash(node -e ' *)",
"Skill(run)",
"Skill(run:*)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173)",
"Bash(powershell.exe -Command \"Start-Process 'http://localhost:5173'\")",
"Bash(findstr \"LISTENING\")",
"Bash(findstr \":517\")",
"Bash(findstr \"LISTEN\")",
"Bash(findstr \":5\")",
"Bash(echo \"Exit: $?\")",
"Bash(npx vercel *)",
"Bash(Start-Process \"http://localhost:5180\")"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"Bash(git commit -m ' *)",
"Bash(git commit *)",
"Bash(node -e ' *)",
"Bash(git stash *)",
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)",
"Bash(pandoc --version)",
"Bash(npm list *)",
"Read(//c/Program Files/LibreOffice/program/**)",
"Read(//c/Program Files/Microsoft Office/**)",
"Read(//c/Program Files \\(x86\\)/**)",
"Bash(mkdir -p /tmp/docx-gen)",
"Read(//tmp/**)",
"Bash(npm init *)",
"Bash(node generate.js)",
"Bash(timeout 8 bash -c \"until curl -s http://localhost:5173 > /dev/null; do sleep 1; done\")",
"Bash(dir \"c:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\src\" -Depth 2)",
"Bash(start \"\" \"http://localhost:5178/demand/results\")",
"Bash(start \"\" \"http://localhost:5178/supply/properties\")"
],
"additionalDirectories": [
"c:\\Users\\beni_\\AppData\\Local\\Temp\\docx-gen"
]
}
}
+3
View File
@@ -16,6 +16,9 @@ dist-ssr
desktop.ini
Thumbs.db
# Claude Code agent worktrees (transient copies of src/ — never commit)
.claude/worktrees/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+11
View File
@@ -0,0 +1,11 @@
> Why do I have a folder named ".vercel" in my project?
The ".vercel" folder is created when you link a directory to a Vercel project.
> What does the "project.json" file contain?
The "project.json" file contains:
- The ID of the Vercel project that you linked ("projectId")
- The ID of the user or team your Vercel project is owned by ("orgId")
> Should I commit the ".vercel" folder?
No, you should not share the ".vercel" folder with anyone.
Upon creation, it will be automatically added to your ".gitignore" file.
+1
View File
@@ -0,0 +1 @@
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
+476
View File
@@ -0,0 +1,476 @@
# Property Match — Architecture Reference
> This document describes the architecture of the Property Match platform.
> For coding rules see [CLAUDE.md](./CLAUDE.md).
> For state management patterns see [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
---
## 1. Folder Structure
```
src/
├── assets/ Static images, icons
├── domain/ TypeScript types & enums (single source of truth)
│ ├── enums.ts AssetType, ResultType, MatchStatus, UserRole, WorkspaceType
│ ├── match.ts Match, ScoreBreakdown, ScoreFactor, TradeOff, Risk
│ ├── property.ts Property, Location, ContactPerson
│ ├── need.ts Need, AreaRange, BudgetRange, WeightingProfile
│ ├── unifiedResult.ts UnifiedMatchResult (discriminated union)
│ ├── scoring.ts ScoringWeightProfile, MatchEngineOutput
│ ├── aiOutput.ts AI scoring, extraction, monitoring types
│ ├── futureSignal.ts Future availability signals
│ ├── pipeline.ts Pipeline stages & items
│ ├── inquiry.ts Demand-side inquiry records
│ ├── reminder.ts Reminder & DueDate
│ └── index.ts Barrel re-exports
├── features/ Business logic algorithms (pure TypeScript)
│ └── matching/
│ ├── scoreCalculator.ts Hard/soft scoring, confidence & DQ modifiers
│ ├── rankingEngine.ts Match ranking, unified result assembly
│ ├── mustHaveScorer.ts Must-have criteria evaluation
│ ├── tradeOffAnalyzer.ts Tradeoff, risk, and missing-data analysis
│ └── matchCardAdapter.ts Match → MatchCardViewModel adapter
├── provider/ Data access layer (interface + mock per entity)
│ ├── IPropertyProvider.ts
│ ├── MockupPropertyProvider.ts
│ ├── INeedProvider.ts
│ ├── MockupNeedProvider.ts
│ └── ... (one pair per entity)
├── services/ Business logic services
│ ├── ai/
│ │ ├── IAIService.ts AI capability interface
│ │ ├── MockAIService.ts Deterministic mock (dev/CI)
│ │ ├── OpenRouterAIService.ts Real LLM calls (production)
│ │ └── prompts/ Structured prompt templates
│ ├── matchService.ts
│ ├── needService.ts
│ ├── propertyService.ts
│ ├── aiMonitoringService.ts
│ ├── governanceService.ts
│ ├── types.ts ListResponse<T>, ItemResponse<T>
│ └── errors.ts throwServiceError()
├── hooks/ React Query wrappers
│ ├── useProperties.ts
│ ├── useMatches.ts
│ ├── useUnifiedResults.ts Aggregates all result types for demand feed
│ └── ... (one file per entity)
├── stores/ Zustand UI stores (global UI state only)
│ ├── sessionStore.ts Auth, currentUser, workspace
│ ├── compareStore.ts Compare tray items (max 4)
│ ├── pipelineStore.ts Pipeline items + add-dialog
│ ├── assistantStore.ts AI assistant drawer conversation
│ ├── offerWizardStore.ts Multi-step offer wizard
│ ├── layoutStore.ts Sidebar, right panel, active workspace
│ ├── matchCenterStore.ts Supply match center selection
│ ├── reminderStore.ts Reminder filters + drawer
│ ├── shortlistStore.ts Shortlist selection + add-dialog
│ └── toastStore.ts Toast notification queue
├── lib/ Shared utilities & design system
│ ├── constants.ts All thresholds, labels, stale times, routes
│ ├── ds.ts DS_COLORS, RESULT_TYPE_META, score → level converters
│ ├── utils.ts matchScoreHex, confidenceHex, dataQualityColor, formatters
│ ├── theme.ts MUI theme
│ ├── queryClient.ts TanStack Query client config
│ ├── permissions.ts Role-based access helpers
│ ├── locationIntelligence.ts Location scoring
│ └── propertyHeat.ts Market heat analysis
├── components/ UI components (organized by feature)
│ ├── layout/ AppShell, PageHeader, RightContextPanel
│ ├── ui/ ErrorBoundary, EmptyState, Toast, DecisionContextPanel
│ ├── shared/ LocationPreview, ViewToggle (cross-feature)
│ ├── match-card/ MatchCard variants (compact/expanded/review/compare-mini)
│ ├── match-detail/ MatchDetailHero, ScoreBreakdownPanel, CriterionRow
│ ├── compare/ CompareMetricRow, CompareColumn, compareUtils
│ ├── pipeline/ PipelineCard, PipelineColumn, pipelineConstants, pipelineUtils
│ ├── results/ UnifiedResultFeed, ResultFilterBar, ResultTypeBadge
│ ├── supply/ PropertyIntelligenceCard, ReminderFeed, ReminderListRow, ...
│ ├── demand/ NeedInput, WeightingEditor, CriteriaReviewPanel, ...
│ ├── future-signals/ SignalCard, SignalFilterBar, FutureAvailabilityPanel, ...
│ ├── data-quality/ DataQualityPanel, FreshnessIndicator, ProvenancePanel, ...
│ ├── badges/ ConfidenceBadge, RiskBadge, FreshnessBadge, ...
│ ├── ai-monitoring/ AI audit log UI
│ └── ...
├── pages/ Route-level page components
│ ├── auth/LoginScreen.tsx
│ ├── supply/ SupplyDashboard, Properties, MatchCenter, ...
│ ├── demand/ AISearch, Results, MatchDetail, Compare, Pipeline, ...
│ └── ops/ MarketIntelligence
├── mock-data/ Seed data for mock providers
├── App.tsx React Router setup (all routes, lazy-loaded)
└── index.css Tailwind layer imports (no project styles here)
```
---
## 2. Data Flow Overview
The core data flow is **unidirectional** across four layers.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Component / Page │
│ │
│ Reads: useQuery hook Writes: useMutation hook │
│ UI state: Zustand selector Side effects: useEffect │
└────────────────┬──────────────────────────┬────────────────────────────┘
│ calls │ calls
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ Hook Layer │
│ src/hooks/*.ts │
│ │
│ useQuery({ queryFn: () => service.getAll() }) │
│ useMutation({ mutationFn: (data) => service.create(data) }) │
└────────────────────────────────┬───────────────────────────────────────┘
│ calls
┌────────────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ src/services/*.ts │
│ │
│ Business logic, error handling, response standardization │
│ Reads session via sessionStore.getState() │
│ Calls scoring engine (features/matching/) as needed │
└────────────────────────────────┬───────────────────────────────────────┘
│ calls
┌────────────────────────────────────────────────────────────────────────┐
│ Provider Layer │
│ src/provider/*.ts │
│ │
│ IPropertyProvider (interface) │
│ MockupPropertyProvider (in-memory, dev) ← swap → RestPropertyProvider (prod)
└────────────────────────────────────────────────────────────────────────┘
```
---
## 3. Provider Swap Flow
Swapping from mock to real requires **one file change** — the service import. No hooks, components, or tests change.
```
Dev / CI Production
───────────────────────────────────── ─────────────────────────────────────
propertyService.ts propertyService.ts
import provider from import provider from
'./MockupPropertyProvider' './RestPropertyProvider'
One line change — nothing else
```
Provider interfaces enforce the contract. All methods are `async`. Mock providers hold in-memory seed data from `src/mock-data/`.
---
## 4. State Architecture
```
┌──────────────────────────────────────────┐
│ React Query Cache │
│ │
│ ['properties'] ── 5 min stale │
│ ['matches', needId] ── 2 min stale │
│ ['signals'] ── 5 min stale │
│ ['review-queue'] ── 30 s stale │
│ │
│ ← server data, cached, invalidated │
│ on mutation success → │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Zustand Stores │
│ │
│ sessionStore ← auth, currentUser │
│ layoutStore ← sidebar, workspace │
│ compareStore ← compare tray items │
│ pipelineStore ← pipeline + dialog │
│ assistantStore ← AI chat conversation │
│ offerWizardStore← multi-step wizard │
│ reminderStore ← filter + drawer │
│ toastStore ← notification queue │
│ │
│ ← UI state only, no server data → │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Component Local State │
│ │
│ useState ← form inputs, toggles │
│ useMemo ← derived / filtered lists │
│ useCallback ← stable callbacks │
└──────────────────────────────────────────┘
```
---
## 5. Matching & Scoring Flow
The matching engine (`src/features/matching/`) produces all scores. Components never compute scores — they display them.
```
Need (criteria) + Property (attributes)
│ │
└───────────┬────────────┘
┌─────────────────────┐
│ scoreCalculator │
│ │
│ 1. Hard filters │ ← Exclusions (wrong type, out of area, etc.)
│ 2. Hard scoring │ ← Area, location, budget, timing, asset type (0100 each)
│ 3. Soft scoring │ ← Prestige, accessibility, ESG, footfall, etc. (0100 each)
│ 4. Modifiers │ ← confidenceModifier (-20→+5) + dataQualityModifier (-15→0)
│ 5. totalScore │ ← Weighted average + modifiers (0100)
└──────────┬──────────┘
┌─────────────────────┐
│ tradeOffAnalyzer │ ← TradeOff[], Risk[], MissingDataItem[]
└──────────┬──────────┘
┌─────────────────────┐
│ rankingEngine │ ← Builds Match[], ranks, adds NextBestAction[]
└──────────┬──────────┘
┌─────────────────────┐
│ matchCardAdapter │ ← Match → MatchCardViewModel (UI props)
└──────────┬──────────┘
MatchCard / MatchDetail / Compare
```
**ScoreBreakdown shape:**
```ts
{
hardMatchScore: number // 0100: area, location, budget, timing, usage
softFactorScore: number // 0100: prestige, accessibility, ESG, etc.
confidenceModifier: number // 20 → +5
dataQualityModifier: number // 15 → 0
totalScore: number // 0100 final score
positiveFactors: ScoreFactor[] // top reasons this is a good match
negativeFactors: ScoreFactor[] // top concerns
tradeOffs: TradeOff[] // explicit trade-off explanations
risks: Risk[] // risk categories
missingData: MissingDataItem[] // fields that would improve the score
}
```
---
## 6. AI Flow
```
User input (text / voice)
NeedInput component
│ calls
aiService.parseNeed(text)
│ routes to
├── MockAIService (dev) ──────────────► deterministic ParsedNeed
└── OpenRouterAIService (prod) ────────► LLM API call
│ validates with Zod
ParseNeedResult
{ need: Partial<Need>, confidence: number, followUpQuestions: string[] }
CriteriaReviewPanel
(user confirms / overrides AI-extracted criteria)
Need saved → Results feed runs
```
**AI Governance loop:**
```
Every AI call
├─► aiMonitoringService.log({ modelId, promptHash, responseDigest, userId, ... })
└─► Visible in /ops/ai-monitoring (review queue for ops team)
```
**AI never speaks directly to the UI.** All AI outputs go through:
1. `IAIService` interface method
2. Zod validation
3. Typed domain object
4. Component that renders it with explainability metadata
---
## 7. Unified Result Feed Flow (Demand)
The demand workspace shows a unified feed that merges three result types.
```
useUnifiedResults(needId)
├─► matchService.getForNeed(needId)
│ └─► VERIFIED_PORTFOLIO matches (own properties)
│ └─► EXTERNAL_MARKET matches (partner/scraped)
├─► aiService.getMaisonWorkResults(needId)
│ └─► MAISON_WORK matches (AI-suggested)
└─► futureSignalService.getForNeed(needId)
└─► FUTURE_AVAILABILITY signals (AI-predicted)
├── probabilityScore (01)
├── signalBasis (string)
└── expectedAvailabilityDate
All merged into UnifiedMatchResult[]
Sorted by matchScore desc (default)
UnifiedResultFeed
├── MatchCard (VERIFIED_PORTFOLIO, EXTERNAL_MARKET, MAISON_WORK)
└── FutureAvailabilityCard (FUTURE_AVAILABILITY — different visual treatment)
```
**Future Availability signals are probabilistic** — they must always show `probabilityScore` and a basis explanation. They are never presented as confirmed availability.
---
## 8. Three-Workspace Architecture
```
App.tsx
├── /auth/login → LoginScreen
├── /supply/* ──────────────────────────────────► SUPPLY workspace
│ Protected: WorkspaceType.SUPPLY (PropertyManager, OrgAdmin)
│ Pages: SupplyDashboard, Properties,
│ MatchCenter, Anfragencenter,
│ FutureAvailability, DataQuality,
│ ReminderManager, MarketLeads,
│ NewListing, MyListings
├── /demand/* ──────────────────────────────────► DEMAND workspace
│ Protected: WorkspaceType.DEMAND (Tenant, CompanyAdmin)
│ Pages: AISearch, Results, MatchDetail,
│ Compare, Pipeline, Anfragen,
│ PropertyDetail, Shortlists
└── /ops/* ────────────────────────────────────► OPERATIONS workspace
Protected: WorkspaceType.OPERATIONS (Internal staff)
Pages: MarketIntelligence
```
Route protection enforced by `<ProtectedRoute workspace={...} />`. Each workspace has distinct data flows and does not share components with other workspaces unless through `src/components/shared/` or `src/components/ui/`.
---
## 9. Decision Screen Design Pattern
Every page is built around **one user decision**. The `DecisionContextPanel` component surfaces this explicitly at the top of each page.
```
┌─────────────────────────────────────────────────────────────┐
│ PageHeader — title, subtitle, secondary actions (ViewToggle) │
├─────────────────────────────────────────────────────────────┤
│ DecisionContextPanel │
│ decision: "Which matches are ready to shortlist?" │
│ context: "Search: OFFICE · 200500 m² · Zürich" │
│ metrics: [strongMatches: 3 ✓] [withGaps: 2 ⚠] │
│ risks: ["2 results have missing floor plan data"] │
│ actions: [Open Compare] [Refine Search] │
├─────────────────────────────────────────────────────────────┤
│ FilterBar / Controls │
├─────────────────────────────────────────────────────────────┤
│ Content (list / grid / table / kanban) │
└─────────────────────────────────────────────────────────────┘
```
If a screen cannot answer "what decision does this help the user make?", the design is wrong.
---
## 10. Error Boundary Architecture
```
App.tsx
└── AppErrorBoundary (top-level catch-all)
└── Route components
└── Feature-level try/catch in services
└── Error state passed via useQuery error prop
└── <ErrorState message={...} /> or <UnauthorizedState />
```
Every page handles three states: loading (`<FeedSkeleton />`), error (`<ErrorState />`), and empty (`<FeedEmptyState />`). These are never omitted.
---
## 11. Component Dependency Rules
```
pages/ can import from components/, hooks/, stores/, lib/, domain/
components/ can import from components/, hooks/, stores/, lib/, domain/
hooks/ can import from services/, lib/, domain/
services/ can import from provider/, features/, lib/, domain/
features/ can import from lib/, domain/
provider/ can import from domain/, mock-data/
lib/ can import from (nothing in src — pure utilities)
domain/ can import from (nothing — types only)
stores/ can import from domain/, lib/
```
**Forbidden imports:**
- `services/` must not import React hooks
- `domain/` must not import anything from `src/`
- `lib/constants.ts` must not import from components or services
- Circular imports of any kind
---
## 12. Key Design System Tokens
```
Scores (0100 scale)
≥ 80 → STRONG → DS_COLORS.confidence.high (#1a7a4a)
≥ 60 → MODERATE → DS_COLORS.confidence.medium (#d97706)
< 60 → WEAK → DS_COLORS.confidence.low (#c0392b)
Confidence (01 scale)
≥ 0.85 → HIGH → DS_COLORS.confidence.high
≥ 0.65 → MEDIUM → DS_COLORS.confidence.medium
< 0.65 → LOW → DS_COLORS.confidence.low
Data Quality (01 scale)
≥ 0.80 → HIGH
≥ 0.60 → MEDIUM
< 0.60 → LOW
Result Types
VERIFIED_PORTFOLIO → label 'Plattform' color #1e3a5f
EXTERNAL_MARKET → label 'Plattform' color #1e3a5f
MAISON_WORK → label 'Maison Work' color #0369a1
FUTURE_AVAILABILITY → label 'Zukunftssignal' color #7c3aed
Freshness
FRESH → DS_COLORS.freshness.fresh (#1a7a4a)
STALE → DS_COLORS.freshness.stale (#d97706)
OUTDATED → DS_COLORS.freshness.outdated (#dc2626)
```
All tokens from `src/lib/ds.ts`. All thresholds from `src/lib/constants.ts`.
Binary file not shown.
+554 -51
View File
@@ -1,40 +1,99 @@
# property-match — Development Guidelines
# property-match — Engineering Guidelines
## Stack
> **This file is read by Claude Code on every session.** All rules here are binding.
> For architecture diagrams and data flows see [ARCHITECTURE.md](./ARCHITECTURE.md).
> For the pre-commit review checklist see [CODE_REVIEW_CHECKLIST.md](./CODE_REVIEW_CHECKLIST.md).
> For state management patterns see [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
- **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
## 1. Project Vision
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.
Property Match is a **Decision Intelligence Platform** — not a real estate portal.
## Styling
The distinction matters for every design decision:
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.
| Classical Portal | Decision Intelligence |
|---|---|
| Show listings, let the user decide | Surface scored matches with explainability |
| Generic SaaS listing UI | Every screen is designed around one user decision |
| More filters = better | Fewer, smarter signals = better |
| Trust by volume | Trust by transparency |
**Three design principles that override everything else:**
## Providers
1. **Explainability-first.** Every score, badge, and recommendation must be traceable. If the user cannot understand *why* a match is strong, the feature is not done. Score breakdowns, trade-off panels, risk indicators, and data provenance are first-class UI elements — not tooltips added at the end.
All data access and data actions live in `src/provider/`.
2. **Trust-first.** Confidence levels, data freshness, and missing-data warnings are shown proactively. We never surface a result that looks more certain than the underlying data supports. When data is stale or incomplete, we say so.
### Naming
3. **Better-than-Google.** A property search result that just shows "here are things that match your query" fails the user. Property Match should rank, explain trade-offs, surface future signals, and give the user a clear recommended next action — like a trusted advisor, not a search engine.
| 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
## 2. Stack
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.
| Technology | Version | Role |
|---|---|---|
| Vite | 8 | Build tool & dev server |
| React | 19 | UI framework |
| TypeScript | 6 | Type safety |
| MUI | v9 (`@mui/material`) | Component library |
| Tailwind CSS | v4 | Utility classes (via `@tailwindcss/vite`) |
| React Router | v7 | Routing — import from `react-router`, not `react-router-dom` |
| TanStack Query | v5 | Server state & caching |
| Zustand | v5 | Global UI state |
| @dnd-kit | current | Drag-and-drop (Pipeline board) |
| Zod | current | Schema validation |
| Lucide React | current | Icon set (supplementary to MUI icons) |
**There is no `tailwind.config.js`.** Tailwind v4 uses `@tailwindcss/vite` — config is inline.
---
## 3. Three-Workspace Architecture
The app is organized into three protected workspaces. Every route lives in exactly one workspace.
| Workspace | Route prefix | Primary user | Core job-to-be-done |
|---|---|---|---|
| **SUPPLY** | `/supply/*` | Property manager / owner | Manage inventory, respond to demand, monitor market |
| **DEMAND** | `/demand/*` | Tenant / company | Find, compare, and pipeline commercial space |
| **OPERATIONS** | `/ops/*` | Internal staff | AI governance, market intelligence, audit logs |
Route protection is enforced at the router level via `<ProtectedRoute workspace={WorkspaceType.X} />`. Never add workspace-specific logic inside shared components — pass `isStaff`, `isOwner`, etc. as props.
---
## 4. Layered Architecture
Data and logic flow through four explicit layers. **Never skip a layer.**
```
Provider → Service → Hook (React Query) → Component
```
### 4.1 Provider Layer (`src/provider/`)
Providers are the **only** code that touches data storage. Everything else goes through them.
**Rules:**
- Every entity has an interface: `I[Entity]Provider` (file: `I[Entity]Provider.ts`)
- Every interface is implemented by a mockup: `Mockup[Entity]Provider` (file: `Mockup[Entity]Provider.ts`)
- Every method is `async` and returns a `Promise` — even in the mockup
- Mockups hold in-memory seed data from `src/mock-data/`
- Real implementations swap in without changing any call sites
**Naming:**
| Pattern | Example |
|---|---|
| Interface | `IPropertyProvider` |
| Mock implementation | `MockupPropertyProvider` |
| Real implementation (future) | `RestPropertyProvider`, `SupabasePropertyProvider` |
```ts
// src/provider/IPropertyProvider.ts
export interface PropertyProvider {
export interface IPropertyProvider {
getAll(): Promise<Property[]>
getById(id: string): Promise<Property | null>
create(data: CreatePropertyInput): Promise<Property>
@@ -43,38 +102,482 @@ export interface PropertyProvider {
}
```
### 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)
},
export const MockupPropertyProvider: IPropertyProvider = {
async getAll() { return [...properties] },
async getById(id) { return properties.find(p => p.id === id) ?? null },
// ...
}
```
Swap to a real implementation by replacing `MockupPropertyProvider` with a provider that calls an API — no other code changes required.
**Anti-patterns:**
- Never call a provider directly from a component — use a service
- Never mutate provider state from outside the provider
- Never share state across providers (cross-provider coupling creates hidden dependencies)
### 4.2 Service Layer (`src/services/`)
Services wrap providers with business logic, error handling, and response standardization.
**Rules:**
- Named `[entity]Service` (e.g., `matchService`, `needService`)
- Return standardized types: `ListResponse<T>` or `ItemResponse<T>` from `services/types.ts`
- Errors are thrown via `throwServiceError()` from `services/errors.ts`
- Services never import React hooks — they are plain TypeScript
- Services access Zustand stores via `getState()`, never via `useStore()` hooks
```ts
// ✅ Service accessing auth — no hook
const user = useSessionStore.getState().currentUser
```
### 4.3 Hook Layer (`src/hooks/`)
Hooks wrap React Query around services.
**Rules:**
- One hook file per entity: `useProperties.ts`, `useMatches.ts`, etc.
- `useQuery` for reads, `useMutation` for writes
- Query keys follow the convention in STATE_MANAGEMENT.md
- Stale times are imported from `src/lib/constants.ts` — never defined locally in hooks
- Mutations call `queryClient.invalidateQueries` on success
```ts
// ✅ Stale time from constants — never local
import { STALE_PROPERTIES } from '../lib/constants'
export function useProperties() {
return useQuery({ queryKey: ['properties'], queryFn: () => propertyService.getAll(), staleTime: STALE_PROPERTIES })
}
```
### 4.4 Component Layer (`src/components/` + `src/pages/`)
Components are **pure UI**. They render data, dispatch mutations, and show state. They do not contain business logic.
---
## 5. Component Rules
### 5.1 Size Limits
| File type | Hard limit | Soft target |
|---|---|---|
| Page component (`src/pages/`) | 300 lines | 150200 lines |
| Feature component | 250 lines | 100150 lines |
| Atom/shared component | 150 lines | 50100 lines |
When a component exceeds its soft target, split it. Extract sub-components, move constants to a `*Constants.ts` file, move pure helpers to a `*Utils.ts` file (`.tsx` if it returns JSX).
### 5.2 No Business Logic in JSX
The render body is for presentation only. All logic belongs upstream.
```tsx
// ❌ Business logic in JSX
{matches.filter(m => m.score > 70 && m.status !== 'DISMISSED').map(...)}
// ✅ Logic in useMemo, computed before the return
const visibleMatches = useMemo(
() => matches.filter(m => m.score > 70 && m.status !== 'DISMISSED'),
[matches]
)
// then:
{visibleMatches.map(...)}
```
### 5.3 No Magic Strings
Every label, status, color, and route string has a named constant.
```ts
// ❌
if (user.role === 'PROPERTY_MANAGER') { ... }
navigate('/supply/properties')
// ✅
import { UserRole } from '../domain/enums'
import { ROUTES } from '../lib/constants'
if (user.role === UserRole.PROPERTY_MANAGER) { ... }
navigate(ROUTES.SUPPLY.PROPERTIES)
```
All German UI labels for enums live in `src/lib/constants.ts` (e.g., `ASSET_TYPE_LABELS`, `AVAILABILITY_LABELS`).
### 5.4 No `any` Casts
`any` disables TypeScript. Use `unknown` with type guards, or fix the type properly.
```ts
// ❌
const result = response as any
const { data } = result
// ✅
const result: ItemResponse<Property> = response
const { data } = result
```
The one allowed exception: third-party library types that don't ship proper types. Wrap the cast in a type-safe adapter so the `any` is isolated.
### 5.5 No Duplicated State
If a value can be computed from existing state, compute it. Do not store it.
```ts
// ❌ Duplicated — sync bugs guaranteed
const [overdueCount, setOverdueCount] = useState(0)
useEffect(() => setOverdueCount(reminders.filter(r => isPast(r.dueDate)).length), [reminders])
// ✅ Derived inline
const overdueCount = reminders.filter(r => isPast(r.dueDate)).length
```
See STATE_MANAGEMENT.md for the full decision tree.
### 5.6 Component Library First
Before writing a custom component, check the [MUI component list](https://mui.com/material-ui/all-components/). Only build custom when:
- MUI has no equivalent, or
- The required behavior diverges significantly from what MUI provides
### 5.7 Styling
Use Tailwind utility classes for all layout and spacing. Use MUI's `sx` prop for component-specific overrides and theme values. **Never add styles to `.css` files.** The only CSS file is `src/index.css` (Tailwind layer imports) — do not add project styles there.
---
## 6. State Management
Full rules in [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md). Summary:
| Data type | Where it lives |
|---|---|
| Server data (fetched from provider) | React Query (`useQuery`) |
| Write operations | React Query (`useMutation`) |
| Global UI state (dialog open/close, compare tray, wizard steps) | Zustand store |
| Auth session | `sessionStore` (Zustand, also read by services via `getState()`) |
| Component-local state (form inputs, hover, toggle) | `useState` |
| Values derived from existing state | Computed inline — never stored |
**Critical rules:**
- Never copy React Query data into a Zustand store
- Never call `useStore()` without a selector (use `s => s.field`)
- Services read Zustand via `getState()`, never via hooks
- Side effects (cache invalidation, analytics) go in `useEffect`, never in the render body
---
## 7. Domain & Types
### 7.1 Single Source of Truth
All domain types live in `src/domain/`. Never duplicate a type or interface.
| File | Contents |
|---|---|
| `enums.ts` | All enums: `AssetType`, `ResultType`, `MatchStatus`, `UserRole`, `WorkspaceType`, etc. |
| `property.ts` | `Property`, `Location`, `ContactPerson` |
| `match.ts` | `Match`, `ScoreBreakdown`, `ScoreFactor`, `TradeOff`, `Risk` |
| `need.ts` | `Need`, `AreaRange`, `BudgetRange`, `WeightingProfile` |
| `unifiedResult.ts` | `UnifiedMatchResult` (the discriminated union for result feeds) |
| `scoring.ts` | `ScoringWeightProfile`, `HardFilterResult`, `MatchEngineOutput` |
| `aiOutput.ts` | AI scoring, extraction, monitoring types |
| ... | See `/domain/index.ts` for full list |
### 7.2 No Inline Type Duplication
```ts
// ❌ Redefining a type locally
interface LocalProperty { id: string; title: string; ... }
// ✅ Import from domain
import type { Property } from '../../domain/property'
```
### 7.3 Response Types
All service responses use the standardized wrapper from `src/services/types.ts`:
```ts
type ListResponse<T> = { data: T[]; total: number }
type ItemResponse<T> = { data: T }
```
---
## 8. Design System
All visual constants are centralized. **Never hardcode a color, spacing multiplier, or border-radius in a component.**
### 8.1 Color Tokens (`src/lib/ds.ts`)
```ts
DS_COLORS.confidence.high // '#1a7a4a'
DS_COLORS.confidence.medium // '#d97706'
DS_COLORS.confidence.low // '#c0392b'
DS_COLORS.risk.critical // '#dc2626'
DS_COLORS.risk.warning // '#d97706'
DS_COLORS.freshness.stale // '#d97706'
DS_COLORS.freshness.outdated// '#dc2626'
```
### 8.2 Score → Color Helpers (`src/lib/utils.ts`)
| Function | Input | Output | Use for |
|---|---|---|---|
| `matchScoreHex(score)` | 0100 | hex string | Overall match score color |
| `confidenceHex(score)` | 01 | hex string | Confidence score color |
| `dataQualityColor(score)` | 01 | hex string | Data quality color |
| `criterionScoreColor(score)` | 0100 | MUI color token | Individual criterion badge |
| `criterionScoreTextColor(score)` | 0100 | hex string | Criterion text color |
### 8.3 Result Type Metadata (`src/lib/ds.ts`)
Use `RESULT_TYPE_META[resultType]` for label, color, and background. Never define these inline.
```ts
// ❌
label="Verified Portfolio"
bgcolor="#1e3a5f"
// ✅
import { RESULT_TYPE_META } from '../../lib/ds'
label={RESULT_TYPE_META[result.resultType]?.label}
bgcolor={RESULT_TYPE_META[result.resultType]?.color}
```
### 8.4 Thresholds (`src/lib/constants.ts`)
```ts
SCORE_STRONG = 80 // match score considered "strong"
SCORE_MODERATE = 60 // match score considered "moderate"
CONF_HIGH = 0.85 // confidence considered "high"
CONF_MEDIUM = 0.65 // confidence considered "medium"
DQ_HIGH = 0.8 // data quality considered "high"
DQ_MEDIUM = 0.6 // data quality considered "medium"
```
These thresholds are used by `scoreToConfidenceLevel()`, `scoreToDataQualityLevel()`, and all badge logic. Never re-define them locally.
---
## 9. AI Integration Rules
Property Match integrates AI as a **service**, not as a feature bolted onto the UI.
### 9.1 Model-Agnostic Interface
AI is accessed exclusively through `IAIService` (`src/services/ai/IAIService.ts`). The interface defines capabilities (parseNeed, compareProperties, generateDecisionBrief, etc.). No component imports an LLM client directly.
```ts
// ❌ Never in a component or hook
import OpenAI from 'openai'
const client = new OpenAI(...)
// ✅ Always via service
import { aiService } from '../services/aiService'
const result = await aiService.parseNeed(text)
```
### 9.2 Implementations
| Implementation | File | Used when |
|---|---|---|
| `MockAIService` | `src/services/ai/MockAIService.ts` | Dev / CI — deterministic, no API calls |
| `OpenRouterAIService` | `src/services/ai/OpenRouterAIService.ts` | Production — OpenRouter API |
Swap implementations by changing the export in `src/services/aiService.ts`. No other file changes required.
### 9.3 Structured Responses
AI calls must return typed, structured objects — never raw strings passed into JSX.
```ts
// ❌ Raw LLM output in JSX
<Typography>{llmResponse}</Typography>
// ✅ Parsed, validated, typed output
const result: ParsedNeed = await aiService.parseNeed(text)
<NeedCardPreview need={result.need} confidence={result.confidence} />
```
All AI response schemas are validated with Zod before use.
### 9.4 Explainable Outputs
Every AI output that influences a user decision must be accompanied by an explanation.
- Match scores include `ScoreFactor[]` (positive & negative)
- Trade-off analysis includes `TradeOff[]` with severity
- Future availability signals include `probabilityScore` + `signalBasis`
- AI-parsed needs include `confidence` + which fields were inferred vs. stated
### 9.5 Human-in-the-Loop
AI suggestions are always proposals — the user confirms or overrides.
- Need parsing: user reviews extracted criteria before search runs
- AI recommendations: presented with confidence, not as commands
- Review queue (`/ops`): all AI-generated content can be reviewed and corrected
- Governance: `aiMonitoringService` logs every AI output with model version, prompt hash, and response
### 9.6 Fallback Handling
Every AI call has a defined fallback. If the AI service fails or returns a low-confidence result:
- The UI shows a manual entry path
- Confidence badges reflect uncertainty
- No AI failure should block a user workflow
---
## 10. Performance Rules
### 10.1 No Expensive Calculations in Render
Any computation over a list, aggregation, or filter runs in `useMemo`, not in the render body.
```tsx
// ❌ Runs on every render — expensive if list is large
return (
<div>
{properties
.filter(p => p.confidenceScore >= 0.7)
.sort((a, b) => b.areaSqm - a.areaSqm)
.map(p => <PropertyCard key={p.id} property={p} />)}
</div>
)
// ✅ Runs only when dependencies change
const visible = useMemo(
() => properties.filter(p => p.confidenceScore >= 0.7).sort((a, b) => b.areaSqm - a.areaSqm),
[properties]
)
return <div>{visible.map(p => <PropertyCard key={p.id} property={p} />)}</div>
```
### 10.2 Memoize List Items
Components rendered in lists (feeds, tables, grids) must be wrapped in `React.memo` to prevent cascade re-renders when parent state changes (e.g., a filter toggle or `selectedId` update).
```ts
// ✅ List item — always memo
export const PropertyCard = memo(function PropertyCard({ property, onSelect }: Props) { ... })
export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props) { ... })
```
### 10.3 Stable Callbacks for Memoized Children
When passing callbacks as props to memoized children, wrap them in `useCallback`. An unstable function reference breaks `React.memo`.
```ts
// ✅ Stable reference — memo children won't re-render
const handleSelect = useCallback((id: string) => setSelectedId(id), [])
```
### 10.4 Single-Pass Aggregations
When computing multiple aggregates over the same list (e.g., count of matchReady + criticalGaps + lowConfidence), use a single `for` loop in one `useMemo` — not three separate `.filter()` calls.
### 10.5 Virtualization
The current page size (`DEFAULT_PAGE_SIZE = 25`) makes React virtualization unnecessary today. If paginated lists are removed or page size exceeds ~200 items, evaluate `@tanstack/virtual` for the result feed and property table.
### 10.6 Bundle Splitting
All routes are already lazy-loaded via `React.lazy()` in `App.tsx`. Keep it that way — do not import page components directly.
---
## 11. Security & Governance
### 11.1 Auth & Session
- Auth state lives in `sessionStore` (Zustand)
- On logout, **clear the React Query cache**: `queryClient.clear()`
- Role checks use `src/lib/permissions.ts` — never inline `role === 'PROPERTY_MANAGER'`
- Workspace access is enforced by `<ProtectedRoute workspace={...} />` — never guard with `if` inside a component
### 11.2 Role-Based Rendering
```ts
// ❌ Magic string role check
if (user.role === 'PROPERTY_MANAGER') { ... }
// ✅ Permission helper
import { canViewMatchCenter } from '../../lib/permissions'
if (canViewMatchCenter(user)) { ... }
```
### 11.3 No Sensitive Data in Client State
- Do not store tokens, passwords, or PII in Zustand stores beyond what is strictly needed for session identity
- Do not log sensitive fields in console statements
- Do not pass sensitive data as URL params (use navigation state instead)
### 11.4 AI Audit Logging
Every AI output that influences a business decision is logged via `aiMonitoringService`. Logs include:
- Timestamp
- Model ID and version
- Prompt hash (not the full prompt — for privacy)
- Response summary
- User ID and workspace
### 11.5 Data Lineage
Every property record carries `DataSource` metadata (origin, freshness, last verified). Surface this data proactively — especially when `freshness === 'STALE'` or `freshness === 'OUTDATED'`.
---
## 12. Adding a New Feature — Checklist
Before writing any code, answer these questions:
1. **Which workspace does this belong to?** (SUPPLY / DEMAND / OPS)
2. **What decision does this help the user make?** (If you can't answer this, reconsider the feature)
3. **Does a provider already exist for this entity?** If not, create `I[Entity]Provider` + `Mockup[Entity]Provider` first
4. **Does a service already exist?** If not, create `[entity]Service` that wraps the provider
5. **Does a hook already exist?** If not, create `use[Entity]` in `src/hooks/`
6. **Is this server state or UI state?** (see STATE_MANAGEMENT.md)
7. **Does this show a score or confidence value?** Use `DS_COLORS` + helper functions from `lib/utils.ts`
8. **Does this involve AI?** Go through `IAIService` — never call LLM APIs directly
9. **Does this need explainability?** Add score factors, provenance, or reasoning before calling it done
**Do not** add business logic to components. **Do not** duplicate types from `/domain/`. **Do not** hardcode colors, labels, or thresholds.
---
## 13. File Naming & Organization
| Location | What goes there |
|---|---|
| `src/pages/[workspace]/` | Route-level page components |
| `src/components/[feature]/` | Feature-specific components |
| `src/components/shared/` | Components used across features |
| `src/components/ui/` | Atomic UI: ErrorBoundary, EmptyState, Toast, DecisionContextPanel |
| `src/components/layout/` | AppShell, PageHeader, RightContextPanel |
| `src/hooks/` | React Query hooks |
| `src/services/` | Business logic services |
| `src/services/ai/` | AI service interface + implementations + prompts |
| `src/provider/` | Data access interfaces + mock implementations |
| `src/domain/` | TypeScript types and enums |
| `src/features/matching/` | Scoring and ranking algorithms |
| `src/stores/` | Zustand stores |
| `src/lib/` | Shared utilities, theme, constants, design system |
| `src/mock-data/` | Seed data for mock providers |
Each component folder has an `index.ts` barrel export. New components must be added to the barrel before use.
**File extension rule:** Any file that contains JSX must use `.tsx`. Plain TypeScript (no JSX) uses `.ts`. Violating this causes Vite parse errors.
---
## 14. TypeScript Rules
- `strict: true` is enforced — do not disable strict mode or individual checks
- Prefer `interface` for object shapes (can be extended); use `type` for unions and aliases
- Props interfaces are named `Props` (local) or `[ComponentName]Props` (exported)
- Never use `@ts-ignore` — fix the type instead
- `as unknown as T` is acceptable only when bridging genuinely untyped third-party code; wrap it in a named adapter function
- Enums from `src/domain/enums.ts` are the source of truth — never redefine values locally
+226
View File
@@ -0,0 +1,226 @@
# Code Review Checklist — Property Match
> Use this checklist before every PR review and before considering any implementation "done".
> For architecture context see [ARCHITECTURE.md](./ARCHITECTURE.md).
> For coding rules see [CLAUDE.md](./CLAUDE.md).
---
## A. Component Size & Structure
- [ ] **Is the component too large?**
- Page component: hard limit 300 lines, target 150200
- Feature component: hard limit 250 lines, target 100150
- Atom/shared component: hard limit 150 lines
- If over limit: extract sub-components, move constants to `*Constants.ts`, move helpers to `*Utils.ts` (`.tsx` if JSX)
- [ ] **Is business logic inside JSX?**
- `.filter()`, `.sort()`, `.reduce()`, conditional rendering based on computed values — these belong in `useMemo`, not inline in the return
- The render body should read computed values, not compute them
- [ ] **Are hardcoded strings present?**
- Status labels (`'ACTIVE'`, `'Aktiv'`, `'PROPERTY_MANAGER'`) must come from `enums.ts` or `constants.ts`
- Route paths must come from `ROUTES` constants, not string literals
- German UI labels for enums live in `constants.ts` (e.g., `ASSET_TYPE_LABELS`)
- [ ] **Are hardcoded colors or thresholds present?**
- Colors like `'#1a7a4a'`, `'#dc2626'` directly in `sx` props → use `DS_COLORS` from `lib/ds.ts`
- Score thresholds like `score >= 80` → use `SCORE_STRONG` from `constants.ts`
- Confidence thresholds like `conf >= 0.75` → use `CONF_HIGH` from `constants.ts`
- [ ] **Are duplicate result type labels/colors defined locally?**
- `RESULT_TYPE_META` in `lib/ds.ts` is the single source of truth
- `RESULT_TYPE_LABEL` / `RESULT_TYPE_COLOR` inline objects anywhere else → delete them
---
## B. State & Data
- [ ] **Is server data stored in Zustand?**
- Any `useEffect(() => setFoo(data), [data])` pattern that mirrors React Query data into local/Zustand state → remove it, read directly from the query
- Server data lives exclusively in the React Query cache
- [ ] **Is derived state stored in state?**
- `const [count, setCount] = useState(0)` + `useEffect` to keep it in sync → compute inline or use `useMemo`
- Exception: expensive computations may use `useMemo`
- [ ] **Is the full Zustand store subscribed to without a selector?**
- `const { a, b, c } = useMyStore()` — re-renders on every store mutation
- Use `useMyStore(s => s.a)` for single fields, `useShallow` for multiple fields
- [ ] **Is there a side effect in the render body?**
- `queryClient.invalidateQueries(...)` called during render → must be in `useEffect`
- Any `console.log`, analytics call, or mutation triggered directly in render body → `useEffect`
- [ ] **Are all hooks called before conditional early returns?**
- React rules of hooks: all `useState`, `useMemo`, `useCallback`, `useQuery` calls must appear before any `if (isLoading) return ...`
---
## C. Type Safety
- [ ] **Are there `any` casts?**
- `as any`, `: any`, `// @ts-ignore` — fix the type or use `unknown` with a type guard
- One exception: bridging untyped third-party code — must be isolated in a named adapter
- [ ] **Are domain types re-defined locally?**
- Any interface or type that duplicates something in `src/domain/` → import from domain instead
- [ ] **Are response types used?**
- Service functions should return `ListResponse<T>` or `ItemResponse<T>` from `services/types.ts`
- [ ] **Do `.tsx` files contain JSX and `.ts` files not?**
- Any file with JSX (`<Component />`, `<Box>`, `<Typography>`) must have `.tsx` extension
- Plain TypeScript utilities use `.ts`
---
## D. Layering & Architecture
- [ ] **Is business logic in a component?**
- Scoring calculations, ranking, filtering rules, AI calls, data transformation → move to service or `features/` layer
- Components only render and dispatch
- [ ] **Is a provider called directly from a component or hook?**
- `MockupPropertyProvider.getAll()` called in a hook → must go through `propertyService`
- All provider access is mediated by a service
- [ ] **Is an LLM API called directly?**
- Any `fetch` to an AI API, or direct `OpenAI` / `openrouter` SDK calls outside `src/services/ai/` → move behind `IAIService`
- [ ] **Are there cross-workspace component imports?**
- A supply component importing from `components/demand/` or vice versa → use `components/shared/` or `components/ui/` instead
- [ ] **Are the import direction rules respected?**
- `services/` must not import React hooks
- `domain/` must not import anything from `src/`
- `lib/constants.ts` must not import from components or services
- No circular imports
---
## E. Loading, Error & Empty States
- [ ] **Does every data-dependent view handle loading?**
- Every `useQuery` that drives a list or table must show a skeleton or spinner while `isLoading`
- Use the appropriate skeleton component (`FeedSkeleton`, `ReminderSkeleton`, etc.)
- [ ] **Does every data-dependent view handle error?**
- `isError` from `useQuery` must show `<ErrorState message={error.message} />`
- Never leave an error state blank or silently swallowed
- [ ] **Does every list handle the empty state?**
- Empty results after filtering → `<FeedEmptyState filtered={true} />` with a reset action
- Empty because no data exists → `<FeedEmptyState filtered={false} />`
---
## F. Performance
- [ ] **Are list items wrapped in `React.memo`?**
- Components rendered inside `.map()` in feeds, tables, or grids → should be `memo()`
- Without memo, every parent state change (e.g., filter toggle, `selectedId`) re-renders the entire list
- [ ] **Are callbacks to memoized children wrapped in `useCallback`?**
- Passing `() => setSelectedId(id)` inline as a prop to a `memo()`-wrapped child → the memo is broken
- Wrap with `useCallback` and explicit dependency array
- [ ] **Are multiple aggregations over the same list collapsed into one pass?**
- Three separate `useMemo` calls that each `.filter()` the same array → collapse into one `useMemo` with a `for` loop
- [ ] **Is `useMemo` used for expensive filters and sorts?**
- `.filter()` + `.sort()` over 25+ items in the render body → `useMemo`
- Decision aggregates (matchReady count, criticalGaps count, etc.) in `Properties.tsx` → already in `useMemo`
- [ ] **Are routes lazy-loaded?**
- All page imports in `App.tsx` must use `React.lazy()` — never static imports for route components
---
## G. Explainability & Trust
- [ ] **Does every score shown have a breakdown path?**
- A match score badge with no way to see *why* it's that score → add `ScoreBreakdownPanel` or link to MatchDetail
- [ ] **Are confidence levels shown proactively?**
- Data with `confidenceScore < CONF_MEDIUM` or `freshness === 'STALE'` → must be visually flagged
- `ConfidenceBadge`, `FreshnessIndicator`, `DataQualityBar` exist for this purpose
- [ ] **Are Future Availability signals clearly labeled as probabilistic?**
- `FUTURE_AVAILABILITY` results must always show `probabilityScore` and `signalBasis`
- They must never look identical to confirmed availability listings
- [ ] **Does AI output show its confidence?**
- AI-extracted need criteria → shown with confidence indicator
- AI match suggestions → shown with `MAISON_WORK` badge and probability
---
## H. Security
- [ ] **Is the React Query cache cleared on logout?**
- Logout flow must call `queryClient.clear()` — otherwise stale data from previous user persists
- [ ] **Are role checks using `permissions.ts` helpers?**
- `user.role === 'PROPERTY_MANAGER'` inline → use `canViewMatchCenter(user)` from `lib/permissions.ts`
- [ ] **Are workspace route guards in place?**
- Every supply/demand/ops route wrapped in `<ProtectedRoute workspace={...} />`
- Never guard with `if (user.workspace !== ...)` inside a component body
- [ ] **Is sensitive data stored in Zustand?**
- PII, tokens, and sensitive fields beyond current-user identity should not persist in client state
---
## I. Backend-Readiness
- [ ] **Is the provider interface complete?**
- Any new entity that creates a mock shortcut (array mutation inline, no interface) → add `I[Entity]Provider` and `Mockup[Entity]Provider`
- [ ] **Are async patterns consistent?**
- All provider methods are `async` — if a new provider method is synchronous, fix it before it gets called from a service
- [ ] **Are query keys stable and specific?**
- Query key `['properties']` is fine for list; `['properties', id]` for single item
- Keys that include derived or computed objects (not primitives) will break cache lookup
- [ ] **Are stale times centralized?**
- No `staleTime: 60_000` or `staleTime: 5 * 60 * 1000` inline in hook files
- Use `STALE_PROPERTIES`, `STALE_MATCHES`, etc. from `src/lib/constants.ts`
---
## J. AI & Model Governance
- [ ] **Is the AI call going through `IAIService`?**
- Direct fetch to AI API anywhere outside `src/services/ai/` → move it
- [ ] **Is the AI response validated with Zod before use?**
- Raw LLM string parsed inline with `JSON.parse()` without a schema → add Zod validation
- [ ] **Is the AI output logged via `aiMonitoringService`?**
- Any AI call that influences a business decision (need parsing, match recommendation, market signal) → log it
- [ ] **Does the AI flow have a fallback?**
- If `aiService` throws or returns `confidence < threshold`, is there a manual entry path?
- AI failures must never block the user workflow
---
## Quick Checklist — Pre-Commit
Before every commit, verify:
```
[ ] npx tsc --noEmit → zero errors
[ ] No new hardcoded colors, routes, or role strings
[ ] No business logic added to a JSX return block
[ ] No React Query data mirrored into Zustand or useState
[ ] All hooks called before early returns
[ ] New list-item components wrapped in React.memo
[ ] New provider methods are async and match the interface
[ ] New feature goes through Provider → Service → Hook → Component
[ ] Loading, error, and empty states handled for any new data-fetching view
[ ] File with JSX has .tsx extension
```
+120 -64
View File
@@ -1,73 +1,129 @@
# React + TypeScript + Vite
# Property Match
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Decision-Intelligence-Plattform für kommerziell genutzte Immobilien — kein Portal, sondern
eine Oberfläche, die Treffer bewertet, Trade-offs erklärt und eine nächste Handlung empfiehlt.
Currently, two official plugins are available:
Verbindliche Entwicklungsregeln stehen in [CLAUDE.md](./CLAUDE.md), Architekturdiagramme in
[ARCHITECTURE.md](./ARCHITECTURE.md), Zustandsregeln in [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
- [@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/)
## Starten
## 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...
},
},
])
```bash
npm install
npm run dev # Entwicklungsserver auf http://localhost:5173
```
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:
Es gibt kein Backend und keine Datenbank. Sämtliche Daten stammen aus TypeScript-Mockdaten
unter `src/mock-data/` und werden über Mockup-Provider bereitgestellt.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
| Skript | Zweck |
|---|---|
| `npm run dev` | Entwicklungsserver mit HMR |
| `npm run build` | Typecheck (`tsc -b`) und Produktionsbuild |
| `npm run lint` | ESLint über das gesamte Projekt |
| `npm test` | Vitest einmalig ausführen |
| `npm run test:watch` | Vitest im Watch-Modus |
| `npm run check:tokens` | Zählt rohe Hex-Farbwerte gegen einen Schwellwert |
## Architektur in vier Schichten
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...
},
},
])
```
Provider → Service → Hook (React Query) → Component
```
- **Provider** (`src/provider/`) sind der einzige Ort, der Datenhaltung berührt.
- **Services** (`src/services/`) tragen die Fachlogik und liefern `ListResponse<T>` / `ItemResponse<T>`.
- **Hooks** (`src/hooks/`) kapseln React Query; Stale-Zeiten kommen aus `src/lib/constants.ts`.
- **Komponenten** (`src/components/`, `src/pages/`) sind reine Darstellung.
Drei geschützte Arbeitsbereiche: `/supply/*` (Bewirtschaftung), `/demand/*` (Suche),
`/ops/*` (interner Betrieb).
---
## Property On — «Teamübersicht»
Property On ist als Funktionsbereich in Property Match eingebettet, nicht als eigene
Anwendung. Es gibt keine zweite App-Shell, keine zweite Sidebar und kein eigenes Branding.
Erreichbar über den Hauptreiter **Teamübersicht** in der Bewirtschaftungs-Navigation, mit den
Subreitern **Personalverwaltung**, **Bearbeitungsverlauf** und **Kanäle & Systeme**.
Bedienprinzip: **human-led, agent-operated.** Sieben digitale Mitarbeiter führen Arbeiten aus,
der Mensch gibt frei, passt an oder weist zurück. Jede Entscheidung erzeugt einen
Protokolleintrag, aktualisiert die Kennzahlen und meldet sich mit einer Rückmeldung.
### Routen
```
/supply/team Teamübersicht (Startseite)
/supply/team/personalverwaltung Agentenliste + Dossier
/supply/team/personalverwaltung/:agentId Personalblatt
/supply/team/personalverwaltung/:agentId/:tab Dossier-Reiter, deep-linkbar
tab: aufgaben | kanaele | systeme
einstellungen | protokoll
/supply/team/bearbeitungsverlauf Vorgänge
/supply/team/bearbeitungsverlauf/:tab tab: pendente-anfragen | erledigte-auftraege
/supply/team/kanaele-systeme Verbindungen der Organisation
```
### Architekturentscheidungen
**Der Entitätstyp heisst `TeamAgent`, nicht `Agent`.** Im Repo existiert bereits `PowerOn` als
Name des KI-Backend-Proxys, und Claude Code legt Arbeitskopien unter `agent-*` ab. Ein nackter
Typ `Agent` wäre in Suchen praktisch nicht auffindbar. Alle Satellitentypen tragen das Präfix
`Agent*``AgentStatus`, `AgentChannelType`, `AgentProtocolEntry` —, weil `src/domain/index.ts`
per `export *` bündelt und generische Namen dort kollidieren würden.
**Eigener Protokolltyp statt `ActivityEvent`.** Den Namen gibt es im Repo dreifach und
gegenseitig inkompatibel: in `domain/activityEvent.ts`, lokal in `services/governanceService.ts`
und noch einmal in `components/supply/PropertyActivityLogPanel.tsx`. Ein Anschluss an einen
dieser Typen hätte den Konflikt zementiert; `AgentProtocolEntry` steht bewusst daneben.
**Verbindungen sind von `DataSource` getrennt.** `DataSource`/`ConnectorRun` modelliert
Crawler-Quellen mit AGB-Status und Crawl-Läufen. «Kanäle & Systeme» beschreibt angebundene
Kommunikationskanäle und Fachsysteme — fachlich etwas anderes. Die Ops-Komponenten dienten als
visuelles Vorbild, das Datenmodell ist eigenständig.
**Eine Demo-Uhr statt der Systemzeit** (`src/lib/teamClock.ts`). Die Mockdaten sind fachlich
auf den 20.05.2026 verankert. Gegen die echte Systemzeit gerechnet wären die Zeitraumfilter
«heute / diese Woche / dieser Monat» an jedem anderen Kalendertag leer, und die Auswertung
sähe kaputt aus, obwohl sie korrekt arbeitet. Die Demo-Uhr startet am Anker und läuft ab
Anwendungsstart in Echtzeit weiter — jede Freigabe während einer Vorführung landet damit
verlässlich im Bucket «heute». Sie gilt ausschliesslich für Property On.
**Persistenz liegt in der Providerschicht.** Geänderte Einstellungen, Aufgabenschalter,
erledigte Vorgänge, Verbindungsstatus und pausierte Mitarbeiter überleben einen Reload über
den Local Storage. Die Bestände tragen eine Schemaversion (`TEAM_SCHEMA_VERSION`): passt sie
nicht, greift wieder der Seed aus `src/mock-data/` — sonst überlagerte ein alter Eintrag
stillschweigend geänderte Mockdaten. «Demo zurücksetzen» löscht genau diese Schlüssel.
**Subreiter sind echte Routen, keine lokalen Tabs.** Die Anwendung kannte bis dahin keine
Feature-Route mit eigenem `<Outlet/>`, und Tabs werden sonst über lokalen `useState` gebaut.
Hier war das nicht tragfähig: die drei Subreiter müssen in der Sidebar sichtbar sein und
deep-linkbar bleiben. Das `NavItem`-Modell in `appShellConfig.ts` wurde deshalb um `children`
erweitert; die Routen sind flache Geschwister, kein Outlet-Baum.
**Kein neuer Stack.** Der ursprüngliche Auftrag nannte shadcn/ui, React Hook Form, date-fns und
Playwright. Nichts davon ist installiert, und die Vorgabe, die bestehenden Property-Match-Muster
zu übernehmen, wiegt schwerer. Umgesetzt mit MUI v9, TanStack Query, Zustand und Zod;
Datumsformatierung über `Intl` in `src/lib/utils.ts` und `src/lib/teamClock.ts`. Besonders
relevant: Tailwind-Preflight ist bewusst nicht importiert — shadcn erwartet es und würde den
`CssBaseline`-Reset von MUI überschreiben.
### Simulation
Alles ist Frontend-Simulation. Es gibt keine echten Systemverbindungen, keine echten
Zugangsdaten und keine echten Modellaufrufe. Simulierte Ladezeiten liegen zwischen 300 und
800 ms. Der Test einer Verbindung ist **deterministisch** und nicht zufällig: verbundene
Kanäle gelingen, nicht verbundene und geplante scheitern reproduzierbar — ein Zufallsfehler
würde eine Kundendemo unvorhersehbar machen.
### Bekannte Altlasten
- `npm run check:tokens` liegt bei 2249 rohen Hex-Werten über dem Schwellwert von 1958. Der
Wert stammt vollständig aus dem Bestand; Property On hat null hinzugefügt. Der Schwellwert
darf laut Skript nicht angehoben werden.
- `npm run lint` meldet im Bestand weiterhin Fehler und Warnungen. Der Property-On-Code ist
frei davon: `npx eslint src/components/team src/pages/supply/Teamuebersicht.tsx …` ist grün.
- In `tsconfig` ist `strict` entgegen CLAUDE.md §14 nicht gesetzt; `strictNullChecks` fehlt
damit projektweit.
+223
View File
@@ -0,0 +1,223 @@
# State Management — Property Match
## Decision Tree
```
Is it server data (fetched from an API or provider)?
→ React Query (useQuery / useMutation)
Is it global UI state shared across unrelated components?
→ Zustand store
Is it local to a single component or parent-child chain?
→ useState / useReducer (local state)
Can it be computed from existing state/data?
→ Derived state (compute inline — no separate store field)
Is it cross-cutting auth / session context accessed in non-React code?
→ Zustand store read via getState() (not useStore hook)
```
---
## React Query — Server State
**Rule:** React Query owns all data that comes from a provider (mock or real).
Never copy React Query data into a Zustand store.
### When to use
- Fetching lists or detail records (`useQuery`)
- Creating, updating, deleting records (`useMutation`)
- Anything that needs cache invalidation or background refetch
### Patterns
```ts
// ✅ Correct — server data in React Query
const { data: properties } = useProperties()
// ✅ Correct — mutation with cache invalidation
const createProp = useCreateProperty()
createProp.mutate(input, {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['properties'] })
})
// ❌ Wrong — copying server data into a Zustand store
const [properties, setProperties] = useState([])
useEffect(() => { fetchProperties().then(setProperties) }, [])
```
### staleTime constants
All stale times live in `src/lib/constants.ts` — never define them locally in hooks.
| Constant | Value | Used for |
|----------|-------|---------|
| `STALE_PROPERTIES` | 5 min | Property lists and details |
| `STALE_MATCHES` | 2 min | Match results (change with need edits) |
| `STALE_SIGNALS` | 5 min | Future availability signals |
| `STALE_MARKET_SIGNALS` | 30 s | Market intelligence (ops team, real-time) |
| `STALE_REVIEW_QUEUE` | 30 s | Review queue tasks (ops team, real-time) |
### Query key conventions
```ts
['entity'] // list: ['properties'], ['matches'], ['reminders']
['entity', id] // single: ['property', id], ['match', id]
['entity', 'scope', id] // scoped: ['matches', 'need', needId]
['entity', filters] // filtered: ['properties', { assetType: 'OFFICE' }]
```
---
## Zustand — UI / Interaction State
**Rule:** Zustand owns UI state only. It must never hold data that belongs in React Query.
### When to use
- Multi-step wizard state (`offerWizardStore`)
- Dialog open/close + pending item (`shortlistStore`, `pipelineStore`)
- Sidebar / layout flags (`layoutStore`)
- Auth session (`sessionStore` — special case, also read by services via `getState()`)
- Toast queue (`toastStore`)
- Active selection in a panel (`matchCenterStore`, `reminderStore`)
### When NOT to use
- Data fetched from a provider → use React Query
- State used only inside one component → use `useState`
- Values derived from existing state → compute inline
### Selector rules
Always use a selector. Subscribing to the full store causes re-renders on every state mutation.
```ts
// ✅ Individual field selector — re-renders only when that field changes
const isOpen = useLayoutStore(s => s.isRightPanelOpen)
const close = useLayoutStore(s => s.closeRightPanel)
// ✅ useShallow for multiple fields from the same store
import { useShallow } from 'zustand/react/shallow'
const { filterType, filterStatus } = useReminderStore(
useShallow(s => ({ filterType: s.filterType, filterStatus: s.filterStatus }))
)
// ❌ No selector — subscribes to all store fields, re-renders on any change
const { isOpen, close } = useLayoutStore()
// ✅ Non-React code (services, mutations) reads store via getState — no subscription
const user = useSessionStore.getState().currentUser
```
### Store inventory
| Store | Responsibility | Key state |
|-------|---------------|-----------|
| `layoutStore` | App shell layout | `activeWorkspace`, `sidebarCollapsed`, `isRightPanelOpen` |
| `sessionStore` | Auth / current user | `currentUser`, `isAuthenticated`, `sessionStatus` |
| `assistantStore` | AI drawer conversation | `isOpen`, `messages`, `context`, `isLoading` |
| `compareStore` | Compare tray items | `compareItems[]` |
| `pipelineStore` | Pipeline items + add-dialog | `items[]`, `dialogOpen`, `pendingItem` |
| `shortlistStore` | Shortlist selection + add-dialog | `selectedShortlistId`, `dialogOpen`, `pendingItem` |
| `offerWizardStore` | Multi-step offer wizard | `isOpen`, `currentStep`, `selectedPropertyIds`, `editableFields` |
| `matchCenterStore` | Supply-side match center selection | `selectedPropertyId`, `selectedNeedId` |
| `reminderStore` | Reminder list filters + drawer | `filterType`, `filterStatus`, `selectedId`, `drawerOpen` |
| `toastStore` | Toast notification queue | `toasts[]` |
### Adding a new store field
Ask these questions first:
1. Is this server data? → React Query instead.
2. Is this only used in one component? → `useState` instead.
3. Is this derived from existing state? → compute it, don't store it.
---
## Local State — Component-Scoped
**Rule:** Default to `useState`. Only escalate to Zustand when state genuinely needs to be shared across unrelated components.
### When to use
- Form input values
- Toggle / accordion open state
- Hover / focus effects
- Step progress inside a self-contained wizard step
- Any state that resets when the component unmounts
```ts
// ✅ Local — form input, no other component needs this
const [name, setName] = useState('')
// ✅ Local — dialog only opened from one place
const [open, setOpen] = useState(false)
// ❌ Should be local — extracted to store unnecessarily
// (e.g. a "confirmDialogOpen" only ever toggled from one parent)
```
---
## Derived State — Compute, Don't Store
**Rule:** Never store a value that can be computed from existing state or query data. Compute it at render time.
```ts
// ✅ Derived — compute from store
const isFull = compareItems.length >= MAX_COMPARE_ITEMS // NOT stored
// ✅ Derived — compute from React Query data
const overdueReminders = reminders.filter(r => isPastDue(r.dueDate)) // NOT stored
// ❌ Stored derived state — causes sync bugs
const [overdueCount, setOverdueCount] = useState(0)
useEffect(() => setOverdueCount(reminders.filter(...).length), [reminders])
```
Exception: expensive computations (e.g. score calculation over thousands of items) may use `useMemo`.
---
## Context — When Neither React Query nor Zustand Fits
Use React Context for:
- Dependency injection (swap provider implementations)
- Tree-scoped state (e.g. a form context for nested inputs)
- Auth abstraction (`AuthProvider` wraps `sessionStore` so components don't import the store directly)
Do NOT use Context as a replacement for React Query or Zustand — it causes cascading re-renders without cache or subscription granularity.
---
## Service / Non-React Code
Services must not import React hooks. They access Zustand state via `getState()`:
```ts
// ✅ In a service — no hook, no subscription
const user = useSessionStore.getState().currentUser
// ✅ In a React Query mutation onError
onError: () => useToastStore.getState().showToast('Fehler aufgetreten', 'error')
// ❌ Services must never call useStore hooks
import { useSessionStore } from '../stores/sessionStore'
const { currentUser } = useSessionStore() // only valid inside a React component
```
---
## Anti-Patterns to Avoid
| Anti-pattern | Why bad | Fix |
|---|---|---|
| `useStore()` without selector | Re-renders on every store mutation | Use `s => s.field` selector or `useShallow` |
| Server data in Zustand | Duplicates cache, causes stale/sync bugs | React Query |
| Derived state stored in state | Sync bugs, extra renders | Compute inline |
| Local dialog state in global store | Bloats store, breaks encapsulation | `useState` |
| Cross-store imports | Tight coupling, circular risk | Keep stores independent |
| Hook-local `STALE_*` constants | Inconsistent cache behaviour | Use `src/lib/constants.ts` |
+28 -1
View File
@@ -6,7 +6,9 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
// `.claude/worktrees/` holds transient agent copies of src/ — linting them
// produces thousands of duplicate parse errors and drowns out real findings.
globalIgnores(['dist', '.claude/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
@@ -19,4 +21,29 @@ export default defineConfig([
globals: globals.browser,
},
},
// ── Design Token Enforcement ────────────────────────────────────────────────
// New hardcoded hex colors in components/pages are blocked (error).
// Existing violations are tracked by: npm run check:tokens
//
// Use instead:
// DS_TEXT.secondary, DS_SURFACE.success.bg (src/lib/ds.ts)
// matchScoreHex(score), criterionScoreTextColor(score) (src/lib/utils.ts)
// RESULT_TYPE_META[type].color (src/lib/ds.ts)
// MUI theme tokens: "primary.main", "text.secondary"
{
files: ['src/components/**/*.{ts,tsx}', 'src/pages/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector: [
'Property[key.name=/^(bgcolor|color|borderColor|background|fill|stroke)$/]',
' > Literal[value=/^#[0-9A-Fa-f]{3,8}$/]',
].join(''),
message:
'No hardcoded hex colors in sx/style props. Use DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG, BADGE_COLORS from src/lib/ds.ts, or helper functions from src/lib/utils.ts.',
},
],
},
},
])
+1452 -20
View File
File diff suppressed because it is too large Load Diff
+18 -2
View File
@@ -7,17 +7,26 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check:tokens": "node scripts/check-tokens.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@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",
"leaflet": "^1.9.4",
"lucide-react": "^0.511.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-leaflet": "^5.0.0",
"react-router": "^7.15.0",
"zod": "^3.25.17",
"zustand": "^5.0.5"
@@ -25,17 +34,24 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
"vite": "^8.0.12",
"vitest": "^4.1.7"
}
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* check-tokens.js
*
* Counts hardcoded hex color literals in sx props / color attributes
* across src/components and src/pages. Fails (exit 1) if count exceeds THRESHOLD.
*
* Usage:
* node scripts/check-tokens.js — report + fail if > threshold
* node scripts/check-tokens.js --report — report only, always exits 0
*
* Run via: npm run check:tokens
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, extname } from 'node:path'
// Hex literals allowed before CI blocks the build.
// This is a ratchet — lower it as migration progresses. Never raise it.
// Baseline after initial token migration (2026-05-24): 1958
const THRESHOLD = 1958
const HEX_PATTERN = /#[0-9A-Fa-f]{3,8}\b/g
const SEARCH_DIRS = ['src/components', 'src/pages']
function walk(dir) {
const files = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) files.push(...walk(full))
else if (entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name))) files.push(full)
}
return files
}
const reportOnly = process.argv.includes('--report')
const cwd = process.cwd()
let totalViolations = 0
const fileViolations = []
for (const searchDir of SEARCH_DIRS) {
const dir = join(cwd, searchDir)
for (const file of walk(dir)) {
const content = readFileSync(file, 'utf8')
const matches = content.match(HEX_PATTERN)
if (matches && matches.length > 0) {
totalViolations += matches.length
fileViolations.push({ file: file.replace(cwd + '\\', '').replace(cwd + '/', ''), count: matches.length })
}
}
}
fileViolations.sort((a, b) => b.count - a.count)
console.log('\n── Token Compliance Check ───────────────────────────────────────────')
console.log(`Total hex violations: ${totalViolations} (threshold: ${THRESHOLD})`)
console.log(`Status: ${totalViolations <= THRESHOLD ? '✅ PASS' : '❌ FAIL'}`)
console.log('\nTop 15 offenders:')
fileViolations.slice(0, 15).forEach(({ file, count }) => {
console.log(` ${count.toString().padStart(3)} ${file}`)
})
console.log('─────────────────────────────────────────────────────────────────────\n')
if (!reportOnly && totalViolations > THRESHOLD) {
console.error(`Error: ${totalViolations} hex violations exceed threshold of ${THRESHOLD}.`)
console.error('Run `node scripts/check-tokens.js --report` to see the full list.')
process.exit(1)
}
+36 -21
View File
@@ -9,11 +9,10 @@ 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 currentUser = useSessionStore(s => s.currentUser)
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
}
@@ -23,22 +22,28 @@ 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 Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings'))
// Property On — «Teamübersicht»
const Teamuebersicht = lazy(() => import('./pages/supply/Teamuebersicht'))
const Personalverwaltung = lazy(() => import('./pages/supply/Personalverwaltung'))
const Bearbeitungsverlauf = lazy(() => import('./pages/supply/Bearbeitungsverlauf'))
const KanaeleSysteme = lazy(() => import('./pages/supply/KanaeleSysteme'))
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 Anfragen = lazy(() => import('./pages/demand/Anfragen'))
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
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 (
@@ -58,8 +63,25 @@ function App() {
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
<Route path="/supply/properties" element={<Properties />} />
<Route path="/supply/match-center" element={<MatchCenter />} />
<Route path="/supply/anfragen" element={<Anfragencenter />} />
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} />
{/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4).
Bewusst flache Geschwisterrouten statt <Outlet/>: die App kennt
keine einzige Feature-Route mit eigenem Outlet, ein Novum hier
würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */}
<Route path="/supply/team" element={<Teamuebersicht />} />
<Route path="/supply/team/personalverwaltung" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId/:tab" element={<Personalverwaltung />} />
<Route path="/supply/team/bearbeitungsverlauf" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/bearbeitungsverlauf/:tab" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/kanaele-systeme" element={<KanaeleSysteme />} />
</Route>
{/* Demand Workspace */}
@@ -68,19 +90,12 @@ function App() {
<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 path="/demand/anfragen" element={<Anfragen />} />
<Route path="/demand/pipeline" element={<Pipeline />} />
<Route path="/demand/property/:propertyId" element={<PropertyDetail />} />
</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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+2 -10
View File
@@ -1,4 +1,4 @@
import { Chip, Tooltip } from '@mui/material'
import { GenericBadge } from '../shared/GenericBadge'
import type { AIOutputError } from '../../domain/aiOutput'
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
@@ -11,13 +11,5 @@ const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
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>
)
return <GenericBadge label={label} color={color} bold tooltip={error.message} />
}
@@ -1,21 +1,15 @@
import { Chip } from '@mui/material'
import { GenericBadge } from '../shared/GenericBadge'
import type { ReviewStatus } from '../../domain/enums'
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
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' },
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' }}
/>
)
return <GenericBadge label={label} color={color} />
}
@@ -23,7 +23,7 @@ const TYPE_LABELS: Record<AIOutputType, string> = {
}
const TYPE_COLORS: Record<AIOutputType, string> = {
NEED_PARSE: '#1e3a5f',
NEED_PARSE: '#152642',
FOLLOW_UP_QUESTIONS: '#0891b2',
MATCH_EXPLANATION: '#4f46e5',
COMPARE_SUMMARY: '#1a7a4a',
@@ -61,7 +61,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
variant="outlined"
onClick={onCopyJson}
startIcon={<Copy size={13} />}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e8e7e4', '&:hover': { bgcolor: '#f8fafc' } }}
>
JSON
</Button>
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries'
import type { InquiryFilters } from '../../provider/IInquiryProvider'
import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid'
import { InquiryDetailPanel } from './InquiryDetailPanel'
type ViewMode = 'list' | 'grid'
const VIEW_STORAGE_KEY = 'view-inquiries'
function loadViewMode(): ViewMode {
if (typeof window === 'undefined') return 'list'
const stored = window.localStorage.getItem(VIEW_STORAGE_KEY)
return stored === 'grid' ? 'grid' : 'list'
}
interface Props {
filters?: InquiryFilters
emptyTitle?: string
emptyDescription?: string
}
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
useEffect(() => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(VIEW_STORAGE_KEY, view)
}
}, [view])
// Auto-select first inquiry on desktop only
useEffect(() => {
if (!isMobile && !selectedId && inquiries.length > 0) {
setSelectedId(inquiries[0].id)
}
}, [inquiries, selectedId, isMobile])
if (!isLoading && inquiries.length === 0) {
return (
<EmptyState
icon={<Inbox size={40} />}
title={emptyTitle ?? 'Keine aktiven Anfragen'}
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
/>
)
}
// Mobile: show detail panel when an inquiry is selected
if (isMobile && selectedId) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
<IconButton size="small" onClick={() => setSelectedId(null)}>
<ArrowLeft size={18} />
</IconButton>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<InquiryDetailPanel inquiryId={selectedId} />
</Box>
</Box>
)
}
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{/* Left column: list/grid */}
<Box
sx={{
width: isMobile ? '100%' : (view === 'list' ? 380 : 720),
minWidth: isMobile ? 'unset' : (view === 'list' ? 380 : 480),
flexShrink: 0,
borderRight: isMobile ? 'none' : '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.25,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Anfragen
</Typography>
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{inquiries.length}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Listenansicht">
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}>
<ListIcon size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Kartenansicht">
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}>
<LayoutGrid size={16} />
</IconButton>
</Tooltip>
</Box>
</Box>
{view === 'list' ? (
<InquiryList inquiries={inquiries} selectedId={selectedId} onSelect={setSelectedId} />
) : (
<InquiryCardGrid inquiries={inquiries} selectedId={selectedId} onSelect={setSelectedId} />
)}
</Box>
{/* Right column: detail (desktop only) */}
{!isMobile && (
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#f8fafc' }}>
{selectedId ? (
<InquiryDetailPanel inquiryId={selectedId} />
) : (
<EmptyState
icon={<Inbox size={40} />}
title="Anfrage auswählen"
description="Wählen Sie eine Anfrage aus der Liste, um Details anzuzeigen und zu antworten."
/>
)}
</Box>
)}
</Box>
)
}
@@ -0,0 +1,57 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { useGenerateOfferEmail } from '../../hooks/useAI'
interface AiOfferEmailButtonProps {
needTitle: string
selectedProperties: string[]
matchScores: number[]
onGenerated: (subject: string, body: string) => void
}
export function AiOfferEmailButton({
needTitle,
selectedProperties,
matchScores,
onGenerated,
}: AiOfferEmailButtonProps) {
const generateOfferEmail = useGenerateOfferEmail()
const handleClick = () => {
generateOfferEmail.mutate(
{ needTitle, properties: selectedProperties, matchScores },
{
onSuccess: (res) => {
onGenerated(res.data.subject, res.data.body)
},
},
)
}
return (
<Box>
<Button
size="small"
startIcon={generateOfferEmail.isPending ? <CircularProgress size={14} /> : <Sparkles size={14} />}
onClick={handleClick}
disabled={generateOfferEmail.isPending}
sx={{
textTransform: 'none',
color: '#7c3aed',
borderColor: '#c4b5fd',
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#7c3aed' },
}}
variant="outlined"
>
KI-Mail generieren
</Button>
{generateOfferEmail.isError && (
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
{generateOfferEmail.error instanceof Error
? generateOfferEmail.error.message
: 'KI-Generierung fehlgeschlagen'}
</Typography>
)}
</Box>
)
}
@@ -0,0 +1,47 @@
import { Box, TextField, Typography } from '@mui/material'
import type { OfferEditableField } from '../../domain/offer'
interface EditableOfferFieldListProps {
fields: OfferEditableField[]
values: Record<string, string>
onChange: (id: string, value: string) => void
}
export function EditableOfferFieldList({ fields, values, onChange }: EditableOfferFieldListProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{fields.map(f => {
const value = values[f.id] !== undefined ? values[f.id] : f.value
return (
<Box key={f.id}>
<Typography
variant="caption"
sx={{
color: '#64748b',
fontWeight: 600,
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
mb: 0.5,
display: 'block',
}}
>
{f.label}
</Typography>
<TextField
fullWidth
size="small"
value={value}
onChange={e => onChange(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : undefined}
sx={{
'& .MuiInputBase-input': { fontSize: '0.85rem' },
}}
/>
</Box>
)
})}
</Box>
)
}
@@ -0,0 +1,146 @@
import { Box, Button, Checkbox, Paper, Typography } from '@mui/material'
import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils'
interface EmbeddedPropertyCardProps {
property: Property
matchScore: number
selected: boolean
onToggle: () => void
reason: string
onQuickOffer: () => void
disabled?: boolean
}
export function EmbeddedPropertyCard({
property,
matchScore,
selected,
onToggle,
reason,
onQuickOffer,
disabled,
}: EmbeddedPropertyCardProps) {
const tier = getScoreTier(matchScore)
const theme = SCORE_THEME[tier]
const image = property.images?.[0]
return (
<Paper
elevation={0}
sx={{
borderRadius: 2,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f8fafc' : 'white',
overflow: 'hidden',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 8px rgba(15,23,42,0.07)' },
}}
>
{/* Info row */}
<Box sx={{ display: 'flex', gap: 2, p: 2 }}>
<Box
sx={{
width: 72,
height: 72,
flexShrink: 0,
borderRadius: 1.5,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!image && <Building2 size={24} color="#94a3b8" />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
{property.title}
</Typography>
<Box
sx={{
background: theme.gradient,
color: theme.text,
px: 1.25,
py: 0.375,
borderRadius: 1,
fontSize: '0.9rem',
fontWeight: 700,
flexShrink: 0,
border: `1px solid ${theme.border}`,
}}
>
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
{reason}
</Typography>
</Box>
</Box>
{/* Action row */}
<Box
sx={{
borderTop: '1px solid #f1f5f9',
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#fafafa',
}}
>
<Box
onClick={!disabled ? onToggle : undefined}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: disabled ? 'default' : 'pointer' }}
>
<Checkbox
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
size="small"
disabled={disabled}
sx={{ p: 0.5 }}
/>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
Für Angebot auswählen
</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={onQuickOffer}
disabled={disabled}
sx={{
textTransform: 'none',
fontSize: '0.775rem',
fontWeight: 600,
borderColor: '#152642',
color: '#152642',
py: 0.5,
px: 1.5,
minWidth: 0,
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
}}
>
Angebot
</Button>
</Box>
</Paper>
)
}
@@ -0,0 +1,106 @@
import { Box, Paper, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
interface InquiryCardProps {
inquiry: Inquiry
selected: boolean
onClick: () => void
}
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 2,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
flexDirection: 'column',
gap: 0.75,
minHeight: 160,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: hasUnread ? 700 : 600,
color: '#0f172a',
fontSize: '0.85rem',
lineHeight: 1.3,
}}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
{hasUnread && (
<Box
sx={{
minWidth: 20,
height: 20,
borderRadius: '50%',
bgcolor: '#2563eb',
color: 'white',
fontSize: '0.65rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
px: inquiry.unreadCount > 9 ? 0.75 : 0,
}}
>
{inquiry.unreadCount}
</Box>
)}
</Box>
<Typography
variant="body2"
sx={{
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
fontSize: '0.85rem',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{inquiry.subject}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b' }}>
<Building2 size={13} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)}
</Typography>
</Box>
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
{formatInquiryDate(inquiry.createdAt)}
</Typography>
{inquiry.matchScore !== undefined && (
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
{inquiry.matchScore}%
</Typography>
)}
</Box>
</Paper>
)
}
@@ -0,0 +1,34 @@
import { Box } from '@mui/material'
import type { Inquiry } from '../../domain/inquiry'
import { InquiryCard } from './InquiryCard'
interface InquiryCardGridProps {
inquiries: Inquiry[]
selectedId: string | null
onSelect: (id: string) => void
}
export function InquiryCardGrid({ inquiries, selectedId, onSelect }: InquiryCardGridProps) {
return (
<Box
sx={{
overflowY: 'auto',
flex: 1,
p: 1.5,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 1.5,
alignContent: 'flex-start',
}}
>
{inquiries.map(inq => (
<InquiryCard
key={inq.id}
inquiry={inq}
selected={inq.id === selectedId}
onClick={() => onSelect(inq.id)}
/>
))}
</Box>
)
}
@@ -0,0 +1,65 @@
import { useEffect, useRef } from 'react'
import { Box, Button } from '@mui/material'
import { FileText } from 'lucide-react'
import type { Inquiry, Attachment } from '../../domain/inquiry'
import { InquiryMessageBubble } from './InquiryMessageBubble'
import { InquiryReplyComposer } from './InquiryReplyComposer'
interface InquiryChatProps {
inquiry: Inquiry
onCreateOffer?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
}
export function InquiryChat({
inquiry,
onCreateOffer,
pendingAttachment,
onPendingAttachmentConsumed,
}: InquiryChatProps) {
const endRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [inquiry.thread.length])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', flex: 1, overflow: 'hidden' }}>
<Box sx={{ overflowY: 'auto', flex: 1, p: 2, bgcolor: 'white' }}>
{inquiry.thread.map((m, idx) => (
<Box key={m.id}>
<InquiryMessageBubble message={m} />
{idx === 0 && m.senderType === 'tenant' && onCreateOffer && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', mb: 1.5, ml: 0.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<FileText size={13} />}
onClick={onCreateOffer}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
borderColor: '#cbd5e1',
color: '#475569',
'&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' },
}}
>
Angebot erstellen
</Button>
</Box>
)}
</Box>
))}
<div ref={endRef} />
</Box>
<InquiryReplyComposer
inquiryId={inquiry.id}
defaultSubject={inquiry.subject}
tenantName={inquiry.tenantName}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
/>
</Box>
)
}
@@ -0,0 +1,244 @@
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight, Building2, ClipboardList, MapPin } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { usePropertyById } from '../../hooks/useProperties'
import { InquiryChat } from './InquiryChat'
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
import type { Attachment } from '../../domain/inquiry'
interface InquiryDetailPanelProps {
inquiryId: string
}
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
const markRead = useMarkThreadAsRead()
const navigate = useNavigate()
const [preparationOpen, setPreparationOpen] = useState(false)
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
useEffect(() => {
if (inquiry && !inquiry.isRead) {
markRead.mutate(inquiry.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inquiry?.id])
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress size={24} />
</Box>
)
}
if (!inquiry) {
return (
<Box sx={{ p: 3 }}>
<Typography variant="body2" color="text.secondary">
Anfrage nicht gefunden
</Typography>
</Box>
)
}
const isLatentInquiry = !!inquiry.needId
const propertyImage = property?.images?.[0]
return (
<>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header: tenant + subject + action */}
<Box
sx={{
px: 2.5,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
alignItems: 'center',
gap: 2,
flexShrink: 0,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
</Box>
{isLatentInquiry && (
<Button
size="small"
variant="outlined"
startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#152642', color: '#152642', '&:hover': { bgcolor: '#f0f4f8' } }}
>
Vorbereitung starten
</Button>
)}
</Box>
{/* Property context bar */}
<Box
sx={{
px: 2,
py: 1,
borderBottom: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexShrink: 0,
}}
>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 1,
overflow: 'hidden',
flexShrink: 0,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{propertyImage ? (
<Box
component="img"
src={propertyImage}
alt={property?.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Building2 size={18} color="#94a3b8" />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
>
{property?.title ?? '—'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
<MapPin size={11} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{property?.location.city}
{property?.areaSqm ? ` · ${property.areaSqm.toLocaleString('de-CH')}` : ''}
{property?.rentPricePerSqm ? ` · CHF ${property.rentPricePerSqm}/m²/J.` : ''}
</Typography>
</Box>
</Box>
<Button
size="small"
endIcon={<ArrowRight size={12} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
>
Objekt ansehen
</Button>
</Box>
{/* Chat (flex: 1) */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<InquiryChat
inquiry={inquiry}
onCreateOffer={() => setOfferWizardOpen(true)}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
/>
</Box>
{/* Bottom: weitere passende Objekte */}
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
<RelatedPropertyCardPanel
propertyId={inquiry.propertyId}
inquiryId={inquiry.id}
compact
/>
</Box>
</Box>
{preparationOpen && (
<PreparationWizardLazy
inquiryId={inquiry.id}
inquiry={inquiry}
onClose={() => setPreparationOpen(false)}
/>
)}
{offerWizardOpen && (
<OfferCreationWizardLazy
inquiryId={inquiry.id}
propertyId={inquiry.propertyId}
tenantName={inquiry.tenantName}
onClose={() => setOfferWizardOpen(false)}
onAttach={(label) => {
setPendingAttachment({
id: crypto.randomUUID(),
fileName: label,
fileType: 'application/pdf',
generated: true,
})
setOfferWizardOpen(false)
}}
/>
)}
</>
)
}
// Lazy-loaded wizard wrappers to avoid circular imports at module load time
import { lazy, Suspense } from 'react'
const PreparationWizardComponent = lazy(() =>
import('./PreparationWizard').then(m => ({ default: m.PreparationWizard }))
)
const OfferCreationWizardComponent = lazy(() =>
import('./OfferCreationWizard').then(m => ({ default: m.OfferCreationWizard }))
)
import type { Inquiry } from '../../domain/inquiry'
function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) {
return (
<Suspense fallback={null}>
<PreparationWizardComponent {...props} />
</Suspense>
)
}
function OfferCreationWizardLazy(props: {
inquiryId: string
propertyId: string
tenantName: string
onClose: () => void
onAttach: (label: string) => void
}) {
return (
<Suspense fallback={null}>
<OfferCreationWizardComponent {...props} />
</Suspense>
)
}
@@ -0,0 +1,24 @@
import { Box } from '@mui/material'
import type { Inquiry } from '../../domain/inquiry'
import { InquiryListRow } from './InquiryListRow'
interface InquiryListProps {
inquiries: Inquiry[]
selectedId: string | null
onSelect: (id: string) => void
}
export function InquiryList({ inquiries, selectedId, onSelect }: InquiryListProps) {
return (
<Box sx={{ overflowY: 'auto', flex: 1 }}>
{inquiries.map(inq => (
<InquiryListRow
key={inq.id}
inquiry={inq}
selected={inq.id === selectedId}
onClick={() => onSelect(inq.id)}
/>
))}
</Box>
)
}
@@ -0,0 +1,88 @@
import { Box, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
interface InquiryListRowProps {
inquiry: Inquiry
selected: boolean
onClick: () => void
}
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
<Box
onClick={onClick}
sx={{
px: 2,
py: 1,
borderBottom: '1px solid #e2e8f0',
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s',
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography
variant="body2"
sx={{ fontWeight: selected || hasUnread ? 700 : 600, color: selected ? '#1d4ed8' : '#0f172a', fontSize: '0.85rem' }}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
{hasUnread && (
<Box
sx={{
minWidth: 18,
height: 18,
borderRadius: '50%',
bgcolor: '#2563eb',
color: 'white',
fontSize: '0.6rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
px: inquiry.unreadCount > 9 ? 0.5 : 0,
}}
>
{inquiry.unreadCount}
</Box>
)}
</Box>
<Typography
variant="body2"
sx={{
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
fontSize: '0.8125rem',
mb: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inquiry.subject}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b', fontSize: '0.75rem' }}>
<Building2 size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)}
</Typography>
{inquiry.matchScore !== undefined && (
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
{inquiry.matchScore}%
</Typography>
)}
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
{formatInquiryDate(inquiry.createdAt)}
</Typography>
</Box>
)
}
@@ -0,0 +1,152 @@
import { Box, Typography } from '@mui/material'
import { Paperclip } from 'lucide-react'
import type { InquiryMessage } from '../../domain/inquiry'
import { formatInquiryDate, formatFileSize } from './inquiryUtils'
interface InquiryMessageBubbleProps {
message: InquiryMessage
}
export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
const isTenant = message.senderType === 'tenant'
const isSupply = message.senderType === 'supply_user'
const isSystemOrAi = message.senderType === 'system' || message.senderType === 'ai'
if (isSystemOrAi) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}>
<Typography
variant="caption"
sx={{
color: '#64748b',
fontStyle: 'italic',
bgcolor: '#f1f5f9',
px: 2,
py: 0.5,
borderRadius: 4,
fontSize: '0.75rem',
}}
>
{message.senderName}: {message.body}
</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', justifyContent: isTenant ? 'flex-start' : 'flex-end', mb: 1.5 }}>
<Box
sx={{
maxWidth: '75%',
bgcolor: isTenant ? '#f1f5f9' : '#152642',
color: isTenant ? '#0f172a' : 'white',
px: 1.75,
py: 1.25,
borderRadius: 2,
boxShadow: '0 1px 2px rgba(15,23,42,0.06)',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.75rem',
color: isTenant ? '#0f172a' : 'white',
}}
>
{message.senderName}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.7rem',
color: isTenant ? '#64748b' : 'rgba(255,255,255,0.7)',
}}
>
{formatInquiryDate(message.createdAt)}
</Typography>
</Box>
{message.subject && (
<Typography
variant="caption"
sx={{
display: 'block',
fontWeight: 600,
fontSize: '0.75rem',
color: isTenant ? '#334155' : 'rgba(255,255,255,0.85)',
mb: 0.5,
}}
>
{message.subject}
</Typography>
)}
<Typography
variant="body2"
sx={{
whiteSpace: 'pre-wrap',
fontSize: '0.85rem',
lineHeight: 1.5,
color: isTenant ? '#0f172a' : 'white',
}}
>
{message.body}
</Typography>
{message.attachments.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{message.attachments.map(att => (
<Box
key={att.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
bgcolor: isTenant ? 'white' : 'rgba(255,255,255,0.12)',
border: '1px solid',
borderColor: isTenant ? '#cbd5e1' : 'rgba(255,255,255,0.25)',
px: 1,
py: 0.5,
borderRadius: 1,
fontSize: '0.75rem',
}}
>
<Paperclip size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem', fontWeight: 500 }}>
{att.fileName}
</Typography>
{att.fileSize && (
<Typography
variant="caption"
sx={{
fontSize: '0.7rem',
color: isTenant ? '#64748b' : 'rgba(255,255,255,0.65)',
ml: 'auto',
}}
>
{formatFileSize(att.fileSize)}
</Typography>
)}
</Box>
))}
</Box>
)}
{isSupply && (
<Typography
variant="caption"
sx={{
display: 'block',
fontSize: '0.65rem',
color: 'rgba(255,255,255,0.6)',
mt: 0.75,
textAlign: 'right',
}}
>
Wincasa AG
</Typography>
)}
</Box>
</Box>
)
}
@@ -0,0 +1,216 @@
import { useEffect, useState } from 'react'
import {
Box,
Button,
CircularProgress,
IconButton,
TextField,
Typography,
} from '@mui/material'
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
import { useSendInquiryReply } from '../../hooks/useInquiries'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
import { formatFileSize } from './inquiryUtils'
interface InquiryReplyComposerProps {
inquiryId: string
defaultSubject: string
tenantName?: string
onSent?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
}
export function InquiryReplyComposer({
inquiryId,
defaultSubject,
tenantName,
onSent,
pendingAttachment,
onPendingAttachmentConsumed,
}: InquiryReplyComposerProps) {
const [subject, setSubject] = useState(
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
)
const [body, setBody] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [isAIDraft, setIsAIDraft] = useState(false)
const sendReply = useSendInquiryReply()
const showToast = useToastStore(s => s.showToast)
useEffect(() => {
if (pendingAttachment) {
setAttachments(prev => {
if (prev.find(a => a.id === pendingAttachment.id)) return prev
return [...prev, pendingAttachment]
})
onPendingAttachmentConsumed?.()
}
}, [pendingAttachment, onPendingAttachmentConsumed])
const sending = sendReply.isPending
function handleAIDraft() {
const salutation = tenantName ? `Sehr geehrte/r ${tenantName}` : 'Sehr geehrte Damen und Herren'
setBody(
`${salutation},\n\nVielen Dank für Ihre Anfrage. Gerne bestätigen wir, dass das Objekt zum gewünschten Zeitpunkt verfügbar ist.\n\nWir würden Ihnen gerne einen Besichtigungstermin vorschlagen — bitte teilen Sie uns Ihre Verfügbarkeit mit, damit wir einen passenden Termin finden können.\n\nFür Rückfragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüssen`
)
setIsAIDraft(true)
}
const handleAddMockAttachment = () => {
const name = `Anhang_${attachments.length + 1}.pdf`
setAttachments(prev => [
...prev,
{
id: crypto.randomUUID(),
fileName: name,
fileType: 'application/pdf',
fileSize: 240_000 + Math.floor(Math.random() * 800_000),
},
])
}
const handleRemoveAttachment = (id: string) => {
setAttachments(prev => prev.filter(a => a.id !== id))
}
const handleSend = async () => {
if (!body.trim()) {
showToast('Bitte geben Sie eine Nachricht ein', 'warning')
return
}
const result = await sendReply.mutateAsync({
inquiryId,
payload: { subject, body, attachments },
})
if (result.error) {
showToast(`Fehler: ${result.error}`, 'error')
return
}
showToast('Antwort gesendet', 'success')
setBody('')
setAttachments([])
setIsAIDraft(false)
onSent?.()
}
return (
<Box
sx={{
borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 1.25,
}}
>
<TextField
size="small"
label="Betreff"
value={subject}
onChange={e => setSubject(e.target.value)}
fullWidth
/>
{isAIDraft && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Sparkles size={12} color="#7c3aed" />
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
KI-Entwurf bitte prüfen und anpassen
</Typography>
</Box>
)}
<TextField
size="small"
label="Nachricht"
value={body}
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
multiline
rows={7}
placeholder="Antwort verfassen..."
fullWidth
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
/>
{attachments.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{attachments.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
border: '1px solid',
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
borderRadius: 1,
px: 1,
py: 0.5,
fontSize: '0.75rem',
bgcolor: a.generated ? '#eff6ff' : 'white',
}}
>
<Paperclip size={12} color={a.generated ? '#2563eb' : undefined} />
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: a.generated ? '#1d4ed8' : undefined }}>
{a.fileName}
</Typography>
{a.fileSize && (
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
{formatFileSize(a.fileSize)}
</Typography>
)}
<IconButton size="small" onClick={() => handleRemoveAttachment(a.id)} sx={{ p: 0.25 }}>
<X size={12} />
</IconButton>
</Box>
))}
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 0.75 }}>
<Button
size="small"
startIcon={<Sparkles size={13} />}
onClick={handleAIDraft}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
color: '#7c3aed',
borderColor: '#ddd6fe',
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' },
}}
variant="outlined"
>
KI-Entwurf
</Button>
<Button
size="small"
startIcon={<Paperclip size={13} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
>
Anhang
</Button>
</Box>
<Button
variant="contained"
size="small"
startIcon={
sending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />
}
onClick={handleSend}
disabled={sending || !body.trim()}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Antwort senden
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react'
import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Sparkles } from 'lucide-react'
import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
import { EmptyState } from '../ui'
import { PublicNeedList } from './PublicNeedList'
import { PublicNeedDetail } from './PublicNeedDetail'
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
type MobileView = 'list' | 'detail' | 'properties'
export function LatentInquiriesTab() {
const { data: needs = [] } = usePublicNeeds()
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(null)
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg'))
const [mobileView, setMobileView] = useState<MobileView>('list')
useEffect(() => {
if (!isMobile && !selectedNeedId && needs.length > 0) {
setSelectedNeedId(needs[0].id)
}
}, [needs, selectedNeedId, isMobile])
const handleSelectNeed = (id: string) => {
setSelectedNeedId(id)
if (isMobile) setMobileView('detail')
}
// Mobile: sequential single-panel navigation
if (isMobile) {
if (mobileView === 'detail' && selectedNeed) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => setMobileView('list')}>
<ArrowLeft size={18} />
</IconButton>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>
{selectedNeed.title}
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<PublicNeedDetail need={selectedNeed} />
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box
onClick={() => setMobileView('properties')}
sx={{ p: 1.25, bgcolor: '#152642', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
>
Eigene Objekte anzeigen
</Box>
</Box>
</Box>
</Box>
)
}
if (mobileView === 'properties' && selectedNeed) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => setMobileView('detail')}>
<ArrowLeft size={18} />
</IconButton>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
Eigene Objekte
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<OwnPropertyMatchList need={selectedNeed} />
</Box>
</Box>
)
}
// list view
return (
<Box sx={{ height: '100%', overflow: 'hidden' }}>
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={handleSelectNeed} fullWidth />
</Box>
)
}
// Tablet: hide own-property column, show it as button in detail
if (isTablet) {
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{selectedNeed ? (
<PublicNeedDetail need={selectedNeed} />
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<EmptyState icon={<Sparkles size={40} />} title="Bedarf auswählen" description="Wählen Sie einen latenten Bedarf, um Details zu sehen." />
</Box>
)}
</Box>
</Box>
)
}
// Desktop: full 3-column layout
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
<PublicNeedList selectedNeedId={selectedNeedId} onSelect={setSelectedNeedId} />
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{selectedNeed ? (
<PublicNeedDetail need={selectedNeed} />
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<EmptyState icon={<Sparkles size={40} />} title="Bedarf auswählen" description="Wählen Sie einen latenten Bedarf, um Details und passende Objekte zu sehen." />
</Box>
)}
</Box>
</Box>
)
}
@@ -0,0 +1,282 @@
import { Box, Chip, Divider, Typography } from '@mui/material'
import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
import type { Inquiry } from '../../domain/inquiry'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props {
draft: InquiryPreparationReportDraft
inquiry: Inquiry
properties: Property[]
}
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
breakoutOptionDate: 'Breakout Zeitpunkt',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Asset Type',
floor: 'Stockwerk',
parking: 'Parkplätze',
fitOut: 'Ausbaugrad',
isBarrierFree: 'Barrierefrei',
ceilingHeightM: 'Raumhöhe',
floorLoad: 'Bodenlast',
loadingDocksCount: 'Anlieferung',
goodsLift: 'Warenaufzug',
passengerLift: 'Personenaufzug',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
internet: 'Internet',
deliveryAccess: 'Zufahrt',
publicTransportScore: 'ÖV-Anbindung',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
footfallScore: 'Passantenfrequenz',
commuterAccessScore: 'Pendlererreichbarkeit',
talentAccessScore: 'Talent Access',
esgScore: 'ESG',
flexibilityScore: 'Flexibilität',
expansionPotentialScore: 'Expansionspotenzial',
taxEnvironmentScore: 'Steuerumfeld',
microLocation: 'Mikrostandort',
competitionEnvironment: 'Konkurrenzumfeld',
infrastructure: 'Infrastruktur',
marketSignals: 'Marktsignale',
negotiationHints: 'Verhandlungshinweise',
missingData: 'Datenlücken',
dataQuality: 'Datenqualität',
description: 'Beschreibung',
units: 'Stockwerkstruktur',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Plätze` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props) {
const editableByField = Object.fromEntries(draft.editableFields.map(f => [f.id, f.value]))
const selectedProps = draft.selectedPropertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is Property => !!p)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0, fontFamily: 'Georgia, serif' }}>
{/* Page 1 — Need Summary */}
<Box
sx={{
bgcolor: 'white',
p: 4,
minHeight: 600,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
Objektvorschlag
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 0.5 }}>
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
</Typography>
</Box>
{editableByField['intro'] && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: DS_TEXT.primary }}>
{editableByField['intro']}
</Typography>
)}
<Box sx={{ bgcolor: DS_BG.page, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Ihre Anfrage
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, mb: 0.5 }}>
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
<strong>Betreff:</strong> {inquiry.subject}
</Typography>
</Box>
{editableByField['highlights'] && (
<>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Warum diese Objekte passen
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary, mb: 3 }}>
{editableByField['highlights']}
</Typography>
</>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{selectedProps.map(p => (
<Chip
key={p.id}
label={p.title}
size="small"
icon={<Building2 size={12} />}
sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
/>
))}
</Box>
</Box>
{/* Page 2+ — One section per property */}
{selectedProps.map((property, idx) => {
const selection = draft.fieldSelections.find(fs => fs.propertyId === property.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = property.images?.[0]
return (
<Box key={property.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Divider sx={{ flex: 1 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
Objekt {idx + 1} von {selectedProps.length}
</Typography>
<Divider sx={{ flex: 1 }} />
</Box>
<Box
sx={{
bgcolor: 'white',
p: 3,
boxShadow: '0 2px 12px rgba(0,0,0,0.1)',
borderRadius: 1,
mb: 2,
}}
>
<Box sx={{ display: 'flex', gap: 2, mb: 2.5 }}>
{/* Map placeholder */}
<Box
sx={{
width: 160,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: DS_BORDER.default,
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
</Box>
{/* Property photo */}
<Box
sx={{
flex: 1,
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: DS_BORDER.default,
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{!image && <Building2 size={28} color={DS_TEXT.disabled} />}
</Box>
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.brand, mb: 0.5, fontFamily: 'inherit' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<MapPin size={13} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<Ruler size={13} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<Calendar size={13} />
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
</Box>
</Box>
{optionalFields.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 0.75,
bgcolor: DS_BG.page,
borderRadius: 1,
p: 1.5,
}}
>
{optionalFields.map(key => {
const val = getFieldValue(property, key)
if (!val) return null
return (
<Box key={key}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', display: 'block' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary, fontSize: '0.78rem' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
{editableByField['next_steps'] && (
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Nächste Schritte
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary }}>
{editableByField['next_steps']}
</Typography>
</Box>
)}
</Box>
)
}
@@ -0,0 +1,251 @@
import { Box, Divider, Typography } from '@mui/material'
import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { mockProperties } from '../../mock-data/properties'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
interface MockPdfPreviewProps {
fields: Record<string, string>
fallbackFields: Record<string, string>
fieldSelections: ReportObjectFieldSelection[]
}
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Asset Type',
floor: 'Stockwerk',
parking: 'Parkplätze',
fitOut: 'Ausbaugrad',
isBarrierFree: 'Barrierefrei',
ceilingHeightM: 'Raumhöhe',
floorLoad: 'Bodenlast',
loadingDocksCount: 'Anlieferung',
goodsLift: 'Warenaufzug',
passengerLift: 'Personenaufzug',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
internet: 'Internet',
deliveryAccess: 'Zufahrt',
publicTransportScore: 'ÖV-Anbindung',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
footfallScore: 'Passantenfrequenz',
commuterAccessScore: 'Pendlererreichbarkeit',
talentAccessScore: 'Talent Access',
esgScore: 'ESG',
flexibilityScore: 'Flexibilität',
expansionPotentialScore: 'Expansionspotenzial',
taxEnvironmentScore: 'Steuerumfeld',
microLocation: 'Mikrostandort',
competitionEnvironment: 'Konkurrenzumfeld',
infrastructure: 'Infrastruktur',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
const needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
return (
<Box
sx={{
bgcolor: 'white',
borderRadius: 1.5,
border: '1px solid #cbd5e1',
boxShadow: '0 4px 16px rgba(15,23,42,0.08)',
p: 4,
height: '100%',
overflowY: 'auto',
fontFamily: '"Georgia", "Times New Roman", serif',
}}
>
{/* ── Page 1 — Offer letter ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
Angebotsvorschau (PDF)
</Typography>
</Box>
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right' }}>
Wincasa AG · Zürich
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right', mb: 4 }}>
{new Date().toLocaleDateString('de-CH')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', mb: 2, fontSize: '1.05rem' }}>
Angebot: {needTitle}
</Typography>
<Typography variant="body2" sx={{ mb: 1.5, fontSize: '0.875rem' }}>
{v('recipient_salutation')},
</Typography>
<Typography variant="body2" sx={{ mb: 2, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{v('offer_intro')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700, fontSize: '0.875rem' }}>
Hervorgehobene Kriterien
</Typography>
<Typography variant="body2" sx={{ mb: 2.5, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{v('highlighted_criteria')}
</Typography>
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700, fontSize: '0.875rem' }}>
Vorgeschlagene Objekte
</Typography>
<Box component="ul" sx={{ pl: 2, mb: 2.5 }}>
{properties.map(p => (
<Typography
key={p.id}
component="li"
variant="body2"
sx={{ fontSize: '0.875rem', mb: 0.5, lineHeight: 1.5 }}
>
<strong>{p.title}</strong> {p.location.city}, {p.areaSqm.toLocaleString('de-CH')} m², CHF {p.rentPricePerSqm}/m²/Jahr
</Typography>
))}
{properties.length === 0 && (
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}>
Keine Objekte ausgewählt.
</Typography>
)}
</Box>
<Typography variant="body2" sx={{ mb: 2.5, fontSize: '0.875rem', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{v('next_steps')}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{v('closing')}
</Typography>
{/* ── Page 2 — Property details side by side ── */}
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
gap: 1.5,
}}
>
{properties.map(p => {
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = p.images?.[0]
return (
<Box
key={p.id}
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Photo */}
{image ? (
<Box
component="img"
src={image}
alt={p.title}
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={22} color="#94a3b8" />
</Box>
)}
{/* Info */}
<Box sx={{ p: 1.25, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
{p.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
<MapPin size={10} color="#94a3b8" />
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
</Box>
{/* Selected fields */}
{optionalFields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{optionalFields.map(key => {
const val = getFieldValue(p, key)
if (!val) return null
return (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
</Box>
</>
)}
</Box>
)
}
@@ -0,0 +1,219 @@
import { Box, Button, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSendOffer } from '../../hooks/useOffers'
import { useCreateOffer } from '../../hooks/useInquiries'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties'
import { deterministicMatchScore } from './latentNeedUtils'
import { formatFileSize } from './inquiryUtils'
export function OfferChatComposer() {
const subject = useOfferWizardStore(s => s.messageSubject)
const body = useOfferWizardStore(s => s.messageDraft)
const setSubject = useOfferWizardStore(s => s.setMessageSubject)
const setBody = useOfferWizardStore(s => s.setMessageDraft)
const attachments = useOfferWizardStore(s => s.attachments)
const addAttachment = useOfferWizardStore(s => s.addAttachment)
const removeAttachment = useOfferWizardStore(s => s.removeAttachment)
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
const selectedPropertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const needId = useOfferWizardStore(s => s.selectedNeedId)
const needTitle = useOfferWizardStore(s => s.needTitle)
const setStep = useOfferWizardStore(s => s.setStep)
const reset = useOfferWizardStore(s => s.reset)
const sendOffer = useSendOffer()
const createOffer = useCreateOffer()
const { data: latentNeed } = useLatentNeedById(needId)
const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => {
const p = mockProperties.find(pp => pp.id === id)
return p?.title ?? id
})
const scores = needId
? selectedPropertyIds.map(id => deterministicMatchScore(id, needId))
: []
const handleAiGenerated = (newSubject: string, newBody: string) => {
setSubject(newSubject)
setBody(newBody)
showToast('KI-Vorschlag eingefügt', 'success')
}
const handleAddAttachment = () => {
addAttachment({
id: crypto.randomUUID(),
fileName: `Anhang_${attachments.length + 1}.pdf`,
fileType: 'application/pdf',
fileSize: 200_000 + Math.floor(Math.random() * 600_000),
})
}
const handleSend = async () => {
if (!offerDraftId) return
if (!body.trim()) {
showToast('Bitte Nachricht eingeben', 'warning')
return
}
const res = await sendOffer.mutateAsync(offerDraftId)
if (res.error) {
showToast(`Fehler: ${res.error}`, 'error')
return
}
// Angebot als Konversation im Nachfrager-Postfach materialisieren
await createOffer.mutateAsync({
organizationId: currentUser?.organizationId ?? 'org-wincasa',
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
needId: needId ?? '',
propertyId: selectedPropertyIds[0] ?? '',
offeredPropertyIds: selectedPropertyIds,
subject,
message: body,
senderName: currentUser?.organizationName ?? 'Wincasa AG',
recipientName: latentNeed?.tenantCompany,
attachments,
})
showToast('Angebot erfolgreich gesendet', 'success')
reset()
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, bgcolor: '#f8fafc' }}>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Empfänger-Kontext
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#1e293b', mt: 0.5 }}>
Bedarf: <strong>{needTitle}</strong> · {selectedPropertyIds.length} Objekt
{selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot
</Typography>
</Box>
<TextField
size="small"
label="Betreff"
fullWidth
value={subject}
onChange={e => setSubject(e.target.value)}
/>
<TextField
size="small"
label="Nachricht"
fullWidth
value={body}
onChange={e => setBody(e.target.value)}
multiline
rows={8}
/>
{attachments.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, mb: 0.75, display: 'block' }}>
Anhänge
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{attachments.map(a => (
<Box
key={a.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
bgcolor: 'white',
border: '1px solid #cbd5e1',
borderRadius: 1,
px: 1,
py: 0.75,
}}
>
<FileText size={14} color="#152642" />
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
{a.fileName}
</Typography>
{a.generated && (
<Box
sx={{
px: 0.75,
py: 0.125,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 700,
fontSize: '0.65rem',
borderRadius: 1,
}}
>
PDF
</Box>
)}
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: '#64748b', ml: 'auto' }}>
{formatFileSize(a.fileSize)}
</Typography>
<IconButton size="small" onClick={() => removeAttachment(a.id)} sx={{ p: 0.25 }}>
<X size={12} />
</IconButton>
</Box>
))}
</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<AiOfferEmailButton
needTitle={needTitle}
selectedProperties={propertyTitles}
matchScores={scores}
onGenerated={handleAiGenerated}
/>
</Box>
</Box>
</Box>
<Box
sx={{
p: 2,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
}}
>
<Button startIcon={<ArrowLeft size={14} />} onClick={() => setStep('checked')} sx={{ textTransform: 'none' }}>
Zurück
</Button>
<Button
variant="contained"
startIcon={sendOffer.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleSend}
disabled={sendOffer.isPending || !body.trim()}
sx={{
textTransform: 'none',
bgcolor: '#16a34a',
fontWeight: 600,
'&:hover': { bgcolor: '#15803d' },
}}
>
Absenden
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,96 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { CheckCircle2 } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useMarkOfferChecked } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore'
export function OfferCheckedAction() {
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
const needTitle = useOfferWizardStore(s => s.needTitle)
const setStep = useOfferWizardStore(s => s.setStep)
const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
const setMessageSubject = useOfferWizardStore(s => s.setMessageSubject)
const markChecked = useMarkOfferChecked()
const showToast = useToastStore(s => s.showToast)
const handleConfirm = async () => {
if (!offerDraftId) return
const res = await markChecked.mutateAsync(offerDraftId)
if (res.error) {
showToast(`Fehler: ${res.error}`, 'error')
return
}
// Add generated PDF as attachment to message
addAttachment({
id: crypto.randomUUID(),
fileName: `Angebot_${needTitle.replace(/\s+/g, '_')}.pdf`,
fileType: 'application/pdf',
fileSize: 320_000,
generated: true,
})
setMessageSubject(`Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`)
setMessageDraft(
`Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot zu Ihrem Bedarf "${needTitle}". Gerne stehen wir für Rückfragen und Besichtigungstermine zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
)
setStep('send')
}
return (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#f8fafc',
p: 4,
textAlign: 'center',
}}
>
<Box
sx={{
width: 96,
height: 96,
borderRadius: '50%',
bgcolor: '#dcfce7',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 3,
}}
>
<CheckCircle2 size={48} color="#16a34a" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
Bereit zur Prüfung
</Typography>
<Typography variant="body2" sx={{ color: '#475569', maxWidth: 480, mb: 4 }}>
Bestätigen Sie das geprüfte Angebot. Das PDF wird automatisch als Anhang für den Chat
vorbereitet, damit Sie es direkt versenden können.
</Typography>
<Button
variant="contained"
size="large"
startIcon={
markChecked.isPending ? <CircularProgress size={18} sx={{ color: 'white' }} /> : <CheckCircle2 size={18} />
}
onClick={handleConfirm}
disabled={markChecked.isPending}
sx={{
textTransform: 'none',
bgcolor: '#16a34a',
fontSize: '1rem',
fontWeight: 700,
px: 4,
py: 1.5,
'&:hover': { bgcolor: '#15803d' },
}}
>
ANGEBOT GEPRÜFT
</Button>
</Box>
)
}
@@ -0,0 +1,47 @@
import { Box, Button, Typography } from '@mui/material'
import { FileText } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
interface OfferCreationPanelProps {
selectedCount: number
needId: string
needTitle: string
}
export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCreationPanelProps) {
const open = useOfferWizardStore(s => s.open)
if (selectedCount === 0) return null
return (
<Box
sx={{
borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
p: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 1,
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a' }}>
{selectedCount} Objekt{selectedCount === 1 ? '' : 'e'} ausgewählt
</Typography>
<Button
fullWidth
variant="contained"
size="small"
startIcon={<FileText size={14} />}
onClick={() => open(needId, needTitle)}
sx={{
textTransform: 'none',
bgcolor: '#152642',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
>
Angebot erstellen
</Button>
</Box>
)
}
@@ -0,0 +1,285 @@
import { useEffect, useRef, useState } from 'react'
import {
Box, Button, CircularProgress, Dialog, DialogContent,
IconButton, Stack, Step, StepLabel, Stepper,
TextField, Typography,
} from '@mui/material'
import { Plus, Trash2, X } from 'lucide-react'
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
import { useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf } from '../../hooks/useOfferReport'
import { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
interface Props {
inquiryId: string
propertyId: string
tenantName: string
onClose: () => void
onAttach: (label: string) => void
}
const STEPS = ['Daten erfassen', 'Bericht prüfen', 'Besichtigungstermine', 'PDF erstellen']
export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose, onAttach }: Props) {
const [step, setStep] = useState(0)
const [draft, setDraft] = useState<OfferReportDraft | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [ready, setReady] = useState(false)
const { data: property } = usePropertyById(propertyId)
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const createOfferReport = useCreateOfferReport()
const updateOfferReport = useUpdateOfferReport()
const generatePdf = useGenerateOfferReportPdf()
useEffect(() => {
createOfferReport.mutate(
{ inquiryId, propertyId, tenantName, propertyTitle: property?.title ?? '' },
{ onSuccess: (d) => { setDraft(d); setLoading(false) } },
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inquiryId, propertyId, tenantName, property?.title])
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
const handleUpdateField = (fieldId: string, value: string) => {
if (!draft) return
setDraft({ ...draft, editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f) })
}
const addAppointment = () => {
if (!draft) return
const slot: ViewingAppointmentOption = {
id: crypto.randomUUID(),
date: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10),
timeSlot: '10:0011:00',
}
setDraft({ ...draft, viewingAppointments: [...draft.viewingAppointments, slot] })
}
const removeAppointment = (id: string) => {
if (!draft) return
setDraft({ ...draft, viewingAppointments: draft.viewingAppointments.filter(a => a.id !== id) })
}
const updateAppointment = (id: string, field: keyof ViewingAppointmentOption, value: string) => {
if (!draft) return
setDraft({
...draft,
viewingAppointments: draft.viewingAppointments.map(a =>
a.id === id ? { ...a, [field]: value } : a,
),
})
}
const handleGeneratePdf = () => {
if (!draft) return
updateOfferReport.mutate(
{ draftId: draft.id, data: { editableFields: draft.editableFields, viewingAppointments: draft.viewingAppointments } },
{
onSuccess: () => {
setStep(3)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
setReady(true)
generatePdf.mutate(draft.id, { onSuccess: setDraft })
} else {
setProgress(Math.min(100, p))
}
}, 200)
},
},
)
}
return (
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
</Box>
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
<Stepper activeStep={step} alternativeLabel>
{STEPS.map(label => (
<Step key={label}><StepLabel>{label}</StepLabel></Step>
))}
</Stepper>
</Box>
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<CircularProgress />
</Box>
)}
{/* Step 0: Data summary */}
{!loading && step === 0 && property && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Objekt</Typography>
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 2 }}>
{property.images?.[0] && (
<Box sx={{ height: 120, backgroundImage: `url(${property.images[0]})`, backgroundSize: 'cover', backgroundPosition: 'center', borderRadius: 1, mb: 1.5 }} />
)}
<Typography variant="body2" sx={{ fontWeight: 700 }}>{property.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>Interessent</Typography>
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 2 }}>
<Typography variant="body2">{tenantName}</Typography>
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: '#64748b', lineHeight: 1.7 }}>
Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt.
Im nächsten Schritt können Sie alle Felder anpassen.
</Typography>
</Box>
</Box>
)}
{/* Step 1: Edit fields */}
{!loading && step === 1 && draft && (
<Stack spacing={2}>
{draft.editableFields.map(f => (
<TextField
key={f.id}
label={f.label}
value={f.value}
onChange={e => handleUpdateField(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : 1}
size="small"
fullWidth
/>
))}
</Stack>
)}
{/* Step 2: Viewing appointments */}
{!loading && step === 2 && draft && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen:
</Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}>
{draft.viewingAppointments.map(a => (
<Box key={a.id} sx={{ display: 'flex', gap: 1.5, alignItems: 'center', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.5 }}>
<TextField
label="Datum"
type="date"
size="small"
value={a.date}
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: 160 }}
/>
<TextField
label="Zeitfenster"
size="small"
value={a.timeSlot}
onChange={e => updateAppointment(a.id, 'timeSlot', e.target.value)}
placeholder="10:0011:00"
sx={{ width: 140 }}
/>
<TextField
label="Kontaktperson"
size="small"
value={a.contactPerson ?? ''}
onChange={e => updateAppointment(a.id, 'contactPerson', e.target.value)}
sx={{ flex: 1 }}
/>
<IconButton size="small" onClick={() => removeAppointment(a.id)} sx={{ color: '#ef4444' }}>
<Trash2 size={16} />
</IconButton>
</Box>
))}
</Stack>
<Button
startIcon={<Plus size={14} />}
onClick={addAppointment}
sx={{ textTransform: 'none' }}
>
Termin hinzufügen
</Button>
</Box>
)}
{/* Step 3: PDF */}
{step === 3 && (
<OfferWizardPdfStep
generating={generating}
progress={progress}
ready={ready}
draft={draft}
property={property ?? undefined}
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
onAttach={() => {
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
onAttach(label)
showToast('Angebot als Anhang hinzugefügt', 'success')
}}
/>
)}
</DialogContent>
{/* Footer */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{step > 0 && step < 3 && (
<Button variant="outlined" onClick={() => setStep(s => s - 1)} sx={{ textTransform: 'none' }}>
Zurück
</Button>
)}
{step === 0 && (
<Button
variant="contained"
disabled={loading}
onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 1 && (
<Button
variant="contained"
onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 2 && (
<Button
variant="contained"
onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
PDF erstellen
</Button>
)}
</Box>
</Box>
</Dialog>
)
}
@@ -0,0 +1,87 @@
import { useEffect } from 'react'
import { Box, Button, Stack, Typography } from '@mui/material'
import { ArrowLeft } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useProperties } from '../../hooks/useProperties'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function OfferFieldSelectionStep() {
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
const setStep = useOfferWizardStore(s => s.setStep)
const { data: allProperties = [] } = useProperties()
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
// Initialise selections when properties are loaded or change
useEffect(() => {
if (selectedProperties.length === 0) return
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
const missing = selectedProperties.filter(p => !existing.has(p.id))
if (missing.length > 0) {
setFieldSelections([
...fieldSelections,
...missing.map(p => ({
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
})),
])
}
// run only when property list or selections change length
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProperties.length, fieldSelections.length])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
</Typography>
<Stack spacing={2}>
{selectedProperties.map(p => {
const selection: ReportObjectFieldSelection =
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
}
return (
<ReportObjectFieldSelector
key={p.id}
propertyTitle={p.title}
value={selection}
onChange={v => updateFieldSelection(p.id, v)}
/>
)
})}
</Stack>
</Box>
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button
startIcon={<ArrowLeft size={14} />}
onClick={() => setStep('select_properties')}
sx={{ textTransform: 'none' }}
>
Zurück
</Button>
<Button
variant="contained"
onClick={() => setStep('pdf_review')}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowLeft, Send } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useUpdateOfferField, useGeneratePdfPreview, useMarkOfferChecked } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore'
import { MockPdfPreview } from './MockPdfPreview'
import { EditableOfferFieldList } from './EditableOfferFieldList'
import type { OfferDraft, OfferEditableField } from '../../domain/offer'
export function OfferPdfReviewStep() {
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
const editableFieldsStore = useOfferWizardStore(s => s.editableFields)
const updateField = useOfferWizardStore(s => s.updateField)
const setStep = useOfferWizardStore(s => s.setStep)
const setPdfReady = useOfferWizardStore(s => s.setPdfReady)
const pdfReady = useOfferWizardStore(s => s.pdfPreviewReady)
const [draft, setDraft] = useState<OfferDraft | null>(null)
const [loadingDraft, setLoadingDraft] = useState(true)
const updateFieldMut = useUpdateOfferField()
const genPreview = useGeneratePdfPreview()
const markChecked = useMarkOfferChecked()
const showToast = useToastStore(s => s.showToast)
useEffect(() => {
let cancelled = false
async function load() {
if (!offerDraftId) return
setLoadingDraft(true)
const draftRes = await import('../../provider/MockupOfferProvider').then(m =>
m.MockupOfferProvider.getById(offerDraftId),
)
if (cancelled) return
setDraft(draftRes)
setLoadingDraft(false)
// Trigger PDF generation once
if (draftRes && !pdfReady) {
const res = await genPreview.mutateAsync(offerDraftId)
if (!cancelled && res.data) setPdfReady()
}
}
void load()
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [offerDraftId])
const fallback: Record<string, string> = useMemo(() => {
const out: Record<string, string> = {}
if (draft) {
for (const f of draft.editableFields) out[f.id] = f.value
}
return out
}, [draft])
const fields: OfferEditableField[] = draft?.editableFields ?? []
const handleFieldChange = async (id: string, value: string) => {
updateField(id, value)
if (offerDraftId) {
await updateFieldMut.mutateAsync({ offerDraftId, fieldId: id, value })
}
}
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const needTitle = useOfferWizardStore(s => s.needTitle)
const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
const setMessageSubject = useOfferWizardStore(s => s.setMessageSubject)
const handleNext = async () => {
if (!offerDraftId) return
if (!pdfReady) {
showToast('PDF-Vorschau wird noch generiert...', 'info')
return
}
const res = await markChecked.mutateAsync(offerDraftId)
if (res.error) {
showToast(`Fehler: ${res.error}`, 'error')
return
}
addAttachment({
id: crypto.randomUUID(),
fileName: `Angebot_${needTitle.replace(/\s+/g, '_')}.pdf`,
fileType: 'application/pdf',
fileSize: 320_000,
generated: true,
})
setMessageSubject(`Passende Gewerbeflächen zu Ihrer Anfrage: ${needTitle}`)
setMessageDraft(
`Sehr geehrte Damen und Herren,\n\nbitte finden Sie anbei unser Angebot zu Ihrem Bedarf "${needTitle}". Gerne stehen wir für Rückfragen und Besichtigungstermine zur Verfügung.\n\nFreundliche Grüsse\nWincasa AG`,
)
setStep('send')
}
if (loadingDraft || !draft) {
return (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden', bgcolor: '#f1f5f9' }}>
<Box sx={{ flex: 1.4, p: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{!pdfReady ? (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
bgcolor: 'white',
borderRadius: 1.5,
border: '1px dashed #cbd5e1',
}}
>
<CircularProgress size={24} />
<Typography variant="body2" color="text.secondary">
PDF-Vorschau wird generiert...
</Typography>
</Box>
) : (
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
)}
</Box>
<Box
sx={{
width: 360,
minWidth: 360,
flexShrink: 0,
p: 2,
bgcolor: 'white',
borderLeft: '1px solid #e2e8f0',
overflowY: 'auto',
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1.5, fontSize: '0.9rem' }}>
Inhalte bearbeiten
</Typography>
<EditableOfferFieldList
fields={fields}
values={editableFieldsStore}
onChange={handleFieldChange}
/>
</Box>
</Box>
<Box
sx={{
p: 2,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
}}
>
<Button
startIcon={<ArrowLeft size={14} />}
onClick={() => setStep('select_properties')}
sx={{ textTransform: 'none' }}
>
Zurück
</Button>
<Button
variant="contained"
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleNext}
disabled={!pdfReady || markChecked.isPending}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,151 @@
import { useEffect, useMemo } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react'
import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useCreateOfferDraft } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore'
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils'
function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
if (propertyAssetType === needAssetType) {
const cityMatch = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
if (cityMatch) return 'Nutzungstyp und Standort passen sehr gut'
return 'Passender Nutzungstyp, alternative Lage'
}
return 'Alternatives Profil — Detailprüfung empfohlen'
}
export function OfferPropertySelectionStep() {
const needId = useOfferWizardStore(s => s.selectedNeedId)
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
const toggle = useOfferWizardStore(s => s.toggleProperty)
const setSelected = useOfferWizardStore(s => s.setSelectedProperties)
const setOfferDraftId = useOfferWizardStore(s => s.setOfferDraftId)
const setStep = useOfferWizardStore(s => s.setStep)
const { data: need } = useLatentNeedById(needId)
const { data: properties = [], isLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const createDraft = useCreateOfferDraft()
const showToast = useToastStore(s => s.showToast)
const scored = useMemo(() => {
if (!need) return []
return properties
.map(p => ({
property: p,
score: deterministicMatchScore(p.id, need.id),
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
}))
.sort((a, b) => b.score - a.score)
}, [properties, need])
// Pre-select top 2 if nothing selected
useEffect(() => {
if (scored.length > 0 && selectedIds.length === 0) {
setSelected(scored.slice(0, 2).map(s => s.property.id))
}
}, [scored, selectedIds.length, setSelected])
const handleNext = async () => {
if (!need) return
if (selectedIds.length === 0) {
showToast('Bitte mindestens ein Objekt auswählen', 'warning')
return
}
const res = await createDraft.mutateAsync({
needId: need.id,
selectedPropertyIds: selectedIds,
needTitle: need.title,
location: need.desiredLocation,
assetType: need.assetType,
sizeRange: need.sizeRange,
})
if (res.error || !res.data) {
showToast(`Fehler: ${res.error}`, 'error')
return
}
setOfferDraftId(res.data.id)
setStep('select_fields')
}
if (!need) {
return (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Need summary header */}
<Box
sx={{
p: 2.5,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Bedarf
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.1rem', mt: 0.25 }}>
{need.title}
</Typography>
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.8rem', mt: 0.5, display: 'block' }}>
{assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1.5 }}>
Wählen Sie passende Objekte aus Ihrem Portfolio
</Typography>
{isLoading && <CircularProgress size={20} />}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{scored.map(({ property, score, reason }) => (
<SelectablePropertyMatchCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggle(property.id)}
reason={reason}
/>
))}
</Box>
</Box>
<Box
sx={{
p: 2,
borderTop: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: '#64748b' }}>
{selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt
</Typography>
<Button
variant="contained"
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,127 @@
import {
Box,
Dialog,
IconButton,
Step,
StepLabel,
Stepper,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material'
import { X } from 'lucide-react'
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer'
// 'checked' is internal — merged into pdf_review step; 4 visible steps
const STEPS: { key: OfferStep; label: string }[] = [
{ key: 'select_properties', label: 'Objekte wählen' },
{ key: 'select_fields', label: 'Felder wählen' },
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
{ key: 'send', label: 'Senden' },
]
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
select_properties: 0,
select_fields: 1,
pdf_review: 2,
checked: 2, // same visual step as pdf_review
send: 3,
}
export function OfferWizard() {
const theme = useTheme()
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
const isOpen = useOfferWizardStore(s => s.isOpen)
const close = useOfferWizardStore(s => s.close)
const currentStep = useOfferWizardStore(s => s.currentStep)
const needTitle = useOfferWizardStore(s => s.needTitle)
const activeIndex = STEP_DISPLAY_INDEX[currentStep] ?? 0
return (
<Dialog
open={isOpen}
onClose={close}
fullScreen={fullScreen}
maxWidth="lg"
fullWidth
slotProps={{
paper: {
sx: {
height: fullScreen ? '100vh' : '85vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
},
}}
>
{/* Header */}
<Box
sx={{
px: 3,
py: 1.5,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', textTransform: 'uppercase', fontWeight: 600, letterSpacing: 0.5 }}>
Angebot erstellen
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 700,
color: '#0f172a',
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{needTitle}
</Typography>
</Box>
<IconButton onClick={close} size="small">
<X size={18} />
</IconButton>
</Box>
{/* Stepper */}
<Box sx={{ px: 3, py: 1.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', flexShrink: 0 }}>
<Stepper activeStep={activeIndex} alternativeLabel>
{STEPS.map(s => (
<Step key={s.key}>
<StepLabel
slotProps={{
label: { sx: { fontSize: '0.8rem', fontWeight: 500 } },
}}
>
{s.label}
</StepLabel>
</Step>
))}
</Stepper>
</Box>
{/* Step content */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
{currentStep === 'checked' && <OfferCheckedAction />}
{currentStep === 'send' && <OfferChatComposer />}
</Box>
</Dialog>
)
}
@@ -0,0 +1,103 @@
import { Box, Button, CircularProgress, LinearProgress, Typography } from '@mui/material'
import { Download, Send } from 'lucide-react'
import type { OfferReportDraft } from '../../domain/offerReport'
import type { Property } from '../../domain/property'
interface OfferWizardPdfStepProps {
generating: boolean
progress: number
ready: boolean
draft: OfferReportDraft | null
property: Property | undefined
onDownload: () => void
onAttach: () => void
}
export function OfferWizardPdfStep({ generating, progress, ready, draft, property, onDownload, onAttach }: OfferWizardPdfStepProps) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
PDF wird generiert
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
</>
) : ready ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, width: '100%' }}>
<Box
sx={{
width: '100%',
maxWidth: 520,
height: 380,
bgcolor: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box sx={{ bgcolor: '#152642', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', ml: 1, fontSize: '0.7rem' }}>
Angebot_{property?.title ?? 'Objekt'}.pdf
</Typography>
</Box>
<Box sx={{ flex: 1, p: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', mb: 1 }}>
Angebotsschreiben
</Typography>
{draft?.editableFields.slice(0, 3).map(f => (
<Box key={f.id} sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', display: 'block' }}>{f.label}</Typography>
<Typography variant="caption" sx={{ color: '#374151', fontSize: '0.75rem', display: 'block' }}>
{f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''}
</Typography>
</Box>
))}
{(draft?.viewingAppointments.length ?? 0) > 0 && (
<Box sx={{ mt: 1.5, p: 1, bgcolor: '#f1f5f9', borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', display: 'block', mb: 0.5 }}>
Besichtigungstermine
</Typography>
{draft!.viewingAppointments.map(a => (
<Typography key={a.id} variant="caption" sx={{ fontSize: '0.7rem', display: 'block', color: '#475569' }}>
{new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot}
</Typography>
))}
</Box>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<Download size={16} />}
onClick={onDownload}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
<Button
variant="outlined"
startIcon={<Send size={16} />}
onClick={onAttach}
sx={{ textTransform: 'none' }}
>
An Nachricht anhängen
</Button>
</Box>
</Box>
) : null}
</Box>
)
}
@@ -0,0 +1,115 @@
import { useMemo } from 'react'
import { Box, CircularProgress, Typography } from '@mui/material'
import { Target } from 'lucide-react'
import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { deterministicMatchScore } from './latentNeedUtils'
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
import { OfferCreationPanel } from './OfferCreationPanel'
import type { LatentNeed } from '../../domain/latentNeed'
interface OwnPropertyMatchListProps {
need: LatentNeed
}
function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
if (propertyAssetType === needAssetType) {
const cityMatch = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
if (cityMatch) return 'Nutzungstyp und Standort passen sehr gut'
return 'Passender Nutzungstyp, alternative Lage'
}
return 'Alternatives Profil — Detailprüfung empfohlen'
}
export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
const { data: properties = [], isLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
const toggle = useOfferWizardStore(s => s.toggleProperty)
const scored = useMemo(() => {
return properties
.map(p => ({
property: p,
score: deterministicMatchScore(p.id, need.id),
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
}))
.sort((a, b) => b.score - a.score)
}, [properties, need])
return (
<Box
sx={{
width: 360,
minWidth: 360,
flexShrink: 0,
borderLeft: '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.25,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Target size={14} color="#152642" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Eigene Objekte
</Typography>
<Box
sx={{
ml: 'auto',
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#152642',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
{scored.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress size={20} />
</Box>
)}
{!isLoading && scored.length === 0 && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem', textAlign: 'center', p: 2 }}>
Keine Portfolio-Objekte vorhanden
</Typography>
)}
{scored.map(({ property, score, reason }) => (
<SelectablePropertyMatchCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggle(property.id)}
reason={reason}
/>
))}
</Box>
<OfferCreationPanel
selectedCount={selectedIds.length}
needId={need.id}
needTitle={need.title}
/>
</Box>
)
}
@@ -0,0 +1,294 @@
import { useEffect, useRef, useState } from 'react'
import {
Box, Button, Checkbox, CircularProgress, Dialog, DialogContent,
FormControlLabel, LinearProgress, Stack, Step, StepLabel, Stepper,
TextField, Typography,
} from '@mui/material'
import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
import { useToastStore } from '../../stores/toastStore'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums'
interface Props {
inquiryId: string
inquiry: Inquiry
onClose: () => void
}
const STEPS = ['Objekte wählen', 'Bericht erstellen', 'Prüfen & bearbeiten', 'Finalisieren']
export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const [step, setStep] = useState(0)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const { data: allProperties = [] } = useProperties()
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const createInquiryReport = useCreateInquiryReport()
const updateInquiryReport = useUpdateInquiryReport()
const finalizeInquiryReport = useFinalizeInquiryReport()
const finalizing = finalizeInquiryReport.isPending
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const toggleProperty = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
)
}
const handleGenerate = () => {
setStep(1)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 20 + 10
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
createInquiryReport.mutate(
{ inquiryId, selectedPropertyIds: selectedIds },
{
onSuccess: (d) => {
setDraft(d)
setStep(2)
},
},
)
} else {
setProgress(Math.min(100, p))
}
}, 200)
}
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
const handleUpdateField = (fieldId: string, value: string) => {
if (!draft) return
setDraft({
...draft,
editableFields: draft.editableFields.map(f => f.id === fieldId ? { ...f, value } : f),
})
}
const handleUpdateFieldSelection = (propertyId: string, sel: ReportObjectFieldSelection) => {
if (!draft) return
setDraft({
...draft,
fieldSelections: draft.fieldSelections.map(fs => fs.propertyId === propertyId ? sel : fs),
})
}
const handleFinalize = () => {
if (!draft) return
updateInquiryReport.mutate(
{ draftId: draft.id, data: { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections } },
{
onSuccess: () => {
finalizeInquiryReport.mutate(draft.id, {
onSuccess: (finalized) => {
setDraft(finalized)
setStep(3)
},
})
},
},
)
}
return (
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
</Box>
<Box sx={{ px: 3, py: 2, flexShrink: 0 }}>
<Stepper activeStep={step} alternativeLabel>
{STEPS.map(label => (
<Step key={label}><StepLabel>{label}</StepLabel></Step>
))}
</Stepper>
</Box>
<DialogContent sx={{ flex: 1, overflow: 'auto', px: 3 }}>
{/* Step 0: Select properties */}
{step === 0 && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten:
</Typography>
<Stack spacing={1}>
{portfolioProps.map(p => (
<FormControlLabel
key={p.id}
control={
<Checkbox
checked={selectedIds.includes(p.id)}
onChange={() => toggleProperty(p.id)}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
}
sx={{ border: '1px solid #e2e8f0', borderRadius: 1, p: 1, m: 0, alignItems: 'flex-start', '& .MuiCheckbox-root': { pt: 0 } }}
/>
))}
</Stack>
</Box>
)}
{/* Step 1: Generating */}
{step === 1 && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
Bericht wird erstellt
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
</>
) : (
<Typography variant="body1">Bericht erstellt. Weiterleitung</Typography>
)}
</Box>
)}
{/* Step 2: Review & Edit */}
{step === 2 && draft && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Berichtsfelder bearbeiten</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
{draft.editableFields.map(f => (
<TextField
key={f.id}
label={f.label}
value={f.value}
onChange={e => handleUpdateField(f.id, e.target.value)}
multiline={f.fieldType === 'textarea'}
rows={f.fieldType === 'textarea' ? 3 : 1}
size="small"
fullWidth
/>
))}
</Stack>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1.5 }}>Felder pro Objekt</Typography>
<Stack spacing={2}>
{draft.fieldSelections.map(fs => {
const prop = allProperties.find(p => p.id === fs.propertyId)
if (!prop) return null
return (
<ReportObjectFieldSelector
key={fs.propertyId}
propertyTitle={prop.title}
value={fs}
onChange={sel => handleUpdateFieldSelection(fs.propertyId, sel)}
/>
)
})}
</Stack>
</Box>
<Box sx={{ width: 380, flexShrink: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 2 }}>Vorschau</Typography>
<Box sx={{ overflow: 'auto', maxHeight: 600 }}>
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
</Box>
</Box>
</Box>
)}
{/* Step 3: Finalized */}
{step === 3 && draft && (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ flex: 1, overflow: 'auto' }}>
<LatentInquiryReportPreview draft={draft} inquiry={inquiry} properties={allProperties} />
</Box>
<Box sx={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}>
<Button
variant="contained"
fullWidth
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
<Button
variant="outlined"
fullWidth
startIcon={<Send size={16} />}
onClick={() => {
showToast('Bericht als Anhang hinzugefügt', 'success')
onClose()
}}
sx={{ textTransform: 'none' }}
>
Als Anhang senden
</Button>
</Box>
</Box>
)}
</DialogContent>
{/* Footer navigation */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
{step === 2 && (
<Button variant="outlined" onClick={() => setStep(0)} sx={{ textTransform: 'none' }}>
Zurück
</Button>
)}
{step === 0 && (
<Button
variant="contained"
disabled={selectedIds.length === 0}
onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
)}
{step === 2 && (
<Button
variant="contained"
disabled={finalizing}
onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Finalisieren
</Button>
)}
</Box>
</Box>
</Dialog>
)
}
@@ -0,0 +1,82 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
interface PublicNeedCardProps {
need: LatentNeed
selected: boolean
onClick: () => void
}
function opportunityColor(status: 'public' | 'paused' | 'expired'): string {
if (status === 'public') return '#16a34a'
if (status === 'paused') return '#d97706'
return '#94a3b8'
}
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
const statusCfg = latentStatusBadge(need.status)
return (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
{need.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
<Chip
label={statusCfg.label}
size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
/>
</Box>
</Box>
{need.tenantCompany && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{need.tenantCompany}
</Typography>
)}
<Chip
label={assetTypeLabel(need.assetType)}
size="small"
sx={{
alignSelf: 'flex-start',
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
height: 20,
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.desiredLocation}
</Typography>
</Box>
</Paper>
)
}
@@ -0,0 +1,263 @@
import { useMemo, useState } from 'react'
import {
Accordion, AccordionDetails, AccordionSummary,
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Typography,
} from '@mui/material'
import { Calendar, CheckCircle2, ChevronDown, Info, MapPin, Ruler, Sparkles, Target, Wallet } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { ResultType, UserRole } from '../../domain/enums'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useProperties } from '../../hooks/useProperties'
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
interface PublicNeedDetailProps {
need: LatentNeed
}
function buildReason(propType: string, needType: string, city: string, location: string): string {
if (propType === needType) {
const match = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
return match ? 'Nutzungstyp und Standort passen sehr gut' : 'Passender Nutzungstyp, alternative Lage'
}
return 'Alternatives Profil — Detailprüfung empfohlen'
}
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
const openWizard = useOfferWizardStore(s => s.open)
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
const toggleProperty = useOfferWizardStore(s => s.toggleProperty)
const currentUser = useSessionStore(s => s.currentUser)
const statusCfg = latentStatusBadge(need.status)
const [showAll, setShowAll] = useState(false)
const { data: properties = [], isLoading: propertiesLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
const scored = useMemo(
() =>
properties
.map(p => ({
property: p,
score: deterministicMatchScore(p.id, need.id),
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
}))
.sort((a, b) => b.score - a.score),
[properties, need],
)
const visible = showAll ? scored : scored.slice(0, 3)
const remaining = scored.length - 3
function handleQuickOffer(propertyId: string) {
setSelectedProperties([propertyId])
openWizard(need.id, need.title)
}
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
{/* Scrollable content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Header */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip label={assetTypeLabel(need.assetType)} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
{need.title}
</Typography>
{need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
)}
</Box>
{/* KI-Entscheidungsbrief */}
{need.aiSummary && (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Sparkles size={16} color="#1d4ed8" />
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
KI-Entscheidungsbrief
</Typography>
</Box>
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
{need.aiSummary}
</Typography>
</Paper>
)}
{/* Suchprofil */}
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Suchprofil
</Typography>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
<ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}${need.sizeRange.max}`} />
<ProfileBox
icon={<Wallet size={14} />}
label="Budget"
value={need.budgetRange
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
: 'Flexibel'}
/>
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
</Box>
</Box>
{/* Must-haves */}
{need.mustHaveCriteria.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Must-have Kriterien
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Gewichtete Präferenzen — Accordion */}
{need.weightedPreferences.length > 0 && (
<Accordion elevation={0} disableGutters
sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100)
return (
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
</Box>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
{p.description}
</Typography>
)}
</Box>
)
})}
</Box>
</AccordionDetails>
</Accordion>
)}
{/* Top Empfehlungen */}
<Box>
<Divider sx={{ mb: 2.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Target size={14} color="#152642" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Ihre Top Empfehlungen
</Typography>
</Box>
{propertiesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
) : scored.length === 0 ? (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
Keine Portfolio-Objekte vorhanden
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{visible.map(({ property, score, reason }) => (
<EmbeddedPropertyCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggleProperty(property.id)}
reason={reason}
onQuickOffer={() => handleQuickOffer(property.id)}
disabled={disabled}
/>
))}
{remaining > 0 && (
<Box
onClick={() => setShowAll(s => !s)}
sx={{
py: 1.25,
textAlign: 'center',
cursor: 'pointer',
color: '#64748b',
fontSize: '0.8rem',
fontWeight: 500,
border: '1px dashed #e2e8f0',
borderRadius: 1.5,
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
transition: 'all 0.15s',
}}
>
{showAll ? '▲ Weniger anzeigen' : `▼ Alle ${scored.length} Objekte anzeigen (+${remaining} weitere)`}
</Box>
)}
</Box>
)}
</Box>
</Box>
{/* Sticky footer CTA */}
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white' }}>
{disabled && need.status !== 'public' && (
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} kein Angebot möglich.
</Alert>
)}
{selectedIds.length > 0 && !disabled && (
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
</Typography>
)}
<Button
variant="contained"
size="large"
fullWidth
onClick={() => openWizard(need.id, need.title)}
disabled={disabled}
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
>
Angebot erstellen
</Button>
</Box>
</Box>
)
}
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
@@ -0,0 +1,88 @@
import { Box, CircularProgress, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { usePublicNeeds } from '../../hooks/useLatentNeeds'
import { PublicNeedCard } from './PublicNeedCard'
import { EmptyState } from '../ui'
interface PublicNeedListProps {
selectedNeedId: string | null
onSelect: (id: string) => void
fullWidth?: boolean
}
export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNeedListProps) {
const { data: needs = [], isLoading, error } = usePublicNeeds()
return (
<Box
sx={{
width: fullWidth ? '100%' : 260,
minWidth: fullWidth ? 'unset' : 260,
flexShrink: 0,
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
bgcolor: 'white',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 2,
py: 1.25,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Sparkles size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Latente Bedarfe
</Typography>
<Box
sx={{
ml: 'auto',
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#152642',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
{needs.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={20} />
</Box>
)}
{error && (
<Typography variant="body2" color="error">
Fehler beim Laden
</Typography>
)}
{!isLoading && needs.length === 0 && (
<EmptyState
title="Keine Bedarfe"
description="Aktuell sind keine öffentlichen Bedarfe verfügbar."
/>
)}
{needs.map(n => (
<PublicNeedCard
key={n.id}
need={n}
selected={n.id === selectedNeedId}
onClick={() => onSelect(n.id)}
/>
))}
</Box>
</Box>
)
}
@@ -0,0 +1,288 @@
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
propertyId: string
inquiryId: string
compact?: boolean
}
export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const {
data: additionalMatches = [],
isLoading: loadingMatches,
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
if (isLoading && !compact) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
<CircularProgress size={20} />
</Box>
)
}
if (compact) {
return (
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
>
Weitere passende Objekte
</Typography>
{loadingMatches ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={16} />
</Box>
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
Keine weiteren Matches
</Typography>
) : (
<Box
sx={{
display: 'flex',
gap: 1.25,
overflowX: 'auto',
pb: 0.5,
'&::-webkit-scrollbar': { height: 3 },
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
}}
>
{additionalMatches.map(m => (
<MatchSliderCard key={m.propertyId} match={m} />
))}
</Box>
)}
</Box>
)
}
if (!property) {
return (
<Box sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
Objekt nicht gefunden
</Typography>
</Box>
)
}
const image = property.images?.[0]
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
p: 2,
borderLeft: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
height: '100%',
overflowY: 'auto',
}}
>
<Box
sx={{
aspectRatio: '3/2',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: '#f4f3f0',
}}
>
{image ? (
<Box component="img" src={image} alt={property.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={32} color="#94a3b8" />
</Box>
)}
</Box>
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<MapPin size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.location.city}
{property.location.district ? `, ${property.location.district}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Ruler size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Calendar size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
</Typography>
</Box>
</Box>
{property.description && (
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.8125rem', lineHeight: 1.5 }}>
{property.description}
</Typography>
)}
<Button
variant="outlined"
size="small"
endIcon={<ArrowRight size={14} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none' }}
>
Objekt ansehen
</Button>
<Divider sx={{ my: 0.5 }} />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
{loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
Keine weiteren Matches
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{additionalMatches.map(m => (
<AdditionalMatchCard key={m.propertyId} match={m} />
))}
</Box>
)}
</Box>
)
}
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
onClick={() => navigate('/supply/properties')}
sx={{
flexShrink: 0,
width: 168,
border: '1px solid #e8e7e4',
borderRadius: 1.5,
overflow: 'hidden',
cursor: 'pointer',
bgcolor: 'white',
transition: 'border-color 0.15s, box-shadow 0.15s',
'&:hover': {
borderColor: '#b0aead',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
},
}}
>
{/* Image — 16:9 crop, compact supplementary card */}
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
{match.imageUrl ? (
<Box
component="img"
src={match.imageUrl}
alt={match.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={16} color="#94a3b8" />
</Box>
)}
{/* Score badge overlay */}
<Box
sx={{
position: 'absolute',
top: 6,
right: 6,
bgcolor: 'rgba(15,23,42,0.72)',
color: 'white',
fontSize: '0.7rem',
fontWeight: 700,
px: 0.75,
py: 0.25,
borderRadius: '4px',
lineHeight: 1.4,
}}
>
{match.matchScore}%
</Box>
</Box>
<Box sx={{ p: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
{match.matchScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{match.location}
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
+ {match.reasons[0]}
</Typography>
)}
</Box>
</Box>
)
}
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
sx={{
borderTop: '1px solid #f1f5f9',
pt: 1,
'&:first-of-type': { borderTop: 'none', pt: 0 },
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
{match.matchScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
+ {match.reasons[0]}
</Typography>
)}
<Button
size="small"
endIcon={<ArrowRight size={10} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: '#152642' }}
>
Ansehen
</Button>
</Box>
)
}
@@ -0,0 +1,158 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
interface Props {
propertyTitle: string
value: ReportObjectFieldSelection
onChange: (v: ReportObjectFieldSelection) => void
}
type FieldGroup = {
label: string
fields: { key: ReportObjectFieldKey; label: string }[]
}
const FIELD_GROUPS: FieldGroup[] = [
{
label: 'Basisdaten',
fields: [
{ key: 'areaSqm', label: 'Fläche' },
{ key: 'rentPricePerSqm', label: 'Mietpreis' },
{ key: 'availabilityDate', label: 'Verfügbar ab' },
{ key: 'propertyNumber', label: 'Objektnummer' },
{ key: 'assetType', label: 'Asset Type' },
{ key: 'description', label: 'Beschreibung' },
],
},
{
label: 'Miet-/Vertragsdaten',
fields: [
{ key: 'leaseTerm', label: 'Mietlaufzeit' },
{ key: 'breakoutOption', label: 'Breakout-Option' },
{ key: 'breakoutOptionDate', label: 'Breakout-Option Zeitpunkt' },
{ key: 'currentTenant', label: 'Aktueller Mieter' },
{ key: 'floor', label: 'Stockwerk' },
{ key: 'parking', label: 'Parkplätze' },
{ key: 'fitOut', label: 'Ausbaugrad' },
{ key: 'isBarrierFree', label: 'Barrierefrei' },
],
},
{
label: 'Technische Hard Facts',
fields: [
{ key: 'ceilingHeightM', label: 'Raumhöhe' },
{ key: 'floorLoad', label: 'Bodenlast' },
{ key: 'loadingDocksCount', label: 'Anlieferung' },
{ key: 'goodsLift', label: 'Warenaufzug' },
{ key: 'passengerLift', label: 'Personenaufzug' },
{ key: 'powerSupplyKva', label: 'Stromanschluss (kVA)' },
{ key: 'hasServerRoom', label: 'Serverraum' },
{ key: 'internet', label: 'Internet / Konnektivität' },
{ key: 'deliveryAccess', label: 'Zufahrt / Logistik' },
],
},
{
label: 'Soft Factors',
fields: [
{ key: 'prestigeScore', label: 'Prestige' },
{ key: 'visibilityScore', label: 'Sichtbarkeit' },
{ key: 'footfallScore', label: 'Passantenfrequenz' },
{ key: 'commuterAccessScore', label: 'Pendlererreichbarkeit' },
{ key: 'talentAccessScore', label: 'Talent Access' },
{ key: 'esgScore', label: 'ESG / Nachhaltigkeit' },
{ key: 'flexibilityScore', label: 'Flexibilität' },
{ key: 'expansionPotentialScore', label: 'Expansionsmöglichkeit' },
{ key: 'taxEnvironmentScore', label: 'Steuerumfeld' },
{ key: 'publicTransportScore', label: 'ÖV-Anbindung' },
{ key: 'microLocation', label: 'Mikrostandort' },
{ key: 'competitionEnvironment', label: 'Konkurrenzumfeld' },
{ key: 'infrastructure', label: 'Infrastruktur' },
],
},
{
label: 'Marktinformationen',
fields: [
{ key: 'marketSignals', label: 'Marktsignale' },
{ key: 'negotiationHints', label: 'Verhandlungshinweise' },
{ key: 'missingData', label: 'Datenlücken / offene Punkte' },
{ key: 'dataQuality', label: 'Datenqualität' },
{ key: 'units', label: 'Stockwerkstruktur' },
],
},
]
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
const isSelected = (key: ReportObjectFieldKey) =>
value.selectedOptionalFields.includes(key)
const toggle = (key: ReportObjectFieldKey) => {
const next = isSelected(key)
? value.selectedOptionalFields.filter(k => k !== key)
: [...value.selectedOptionalFields, key]
onChange({ ...value, selectedOptionalFields: next })
}
const selectAll = (keys: ReportObjectFieldKey[]) => {
const next = Array.from(new Set([...value.selectedOptionalFields, ...keys]))
onChange({ ...value, selectedOptionalFields: next })
}
const deselectAll = (keys: ReportObjectFieldKey[]) => {
onChange({ ...value, selectedOptionalFields: value.selectedOptionalFields.filter(k => !keys.includes(k)) })
}
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1 }}>
Felder für: {propertyTitle}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 1.5 }}>
Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos
</Typography>
{FIELD_GROUPS.map(group => {
const groupKeys = group.fields.map(f => f.key)
return (
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>
{group.label}
</Typography>
<Typography variant="caption" sx={{ ml: 1, color: '#64748b', alignSelf: 'center' }}>
({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length})
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0, pb: 1 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
<Button size="small" onClick={() => selectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
Alle
</Button>
<Button size="small" onClick={() => deselectAll(groupKeys)} sx={{ textTransform: 'none', fontSize: '0.7rem', p: '2px 8px', minWidth: 0 }}>
Keine
</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0 }}>
{group.fields.map(f => (
<FormControlLabel
key={f.key}
control={
<Checkbox
size="small"
checked={isSelected(f.key)}
onChange={() => toggle(f.key)}
sx={{ py: 0.25 }}
/>
}
label={<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{f.label}</Typography>}
sx={{ m: 0 }}
/>
))}
</Box>
</AccordionDetails>
</Accordion>
)
})}
</Box>
)
}
@@ -0,0 +1,118 @@
import { Box, Checkbox, Paper, Typography } from '@mui/material'
import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils'
interface SelectablePropertyMatchCardProps {
property: Property
matchScore: number
selected: boolean
onToggle: () => void
reason: string
}
export function SelectablePropertyMatchCard({
property,
matchScore,
selected,
onToggle,
reason,
}: SelectablePropertyMatchCardProps) {
const tier = getScoreTier(matchScore)
const theme = SCORE_THEME[tier]
const image = property.images?.[0]
return (
<Paper
elevation={0}
onClick={onToggle}
sx={{
p: 1.25,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#152642' : '#94a3b8',
},
display: 'flex',
gap: 1.25,
alignItems: 'flex-start',
}}
>
<Checkbox
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
size="small"
sx={{ p: 0.5, mt: -0.5, ml: -0.5 }}
/>
<Box
sx={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 1,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!image && <Building2 size={18} color="#94a3b8" />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: '#0f172a',
fontSize: '0.8125rem',
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
}}
>
{property.title}
</Typography>
<Box
sx={{
background: theme.gradient,
color: theme.text,
px: 0.75,
py: 0.125,
borderRadius: 1,
fontSize: '0.7rem',
fontWeight: 700,
flexShrink: 0,
border: `1px solid ${theme.border}`,
}}
>
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, color: '#64748b' }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#475569', display: 'block', mt: 0.25 }}>
{reason}
</Typography>
</Box>
</Paper>
)
}
+26
View File
@@ -0,0 +1,26 @@
export { ActiveInquiriesTab } from './ActiveInquiriesTab'
export { LatentInquiriesTab } from './LatentInquiriesTab'
export { OfferWizard } from './OfferWizard'
export { InquiryList } from './InquiryList'
export { InquiryListRow } from './InquiryListRow'
export { InquiryCard } from './InquiryCard'
export { InquiryCardGrid } from './InquiryCardGrid'
export { InquiryChat } from './InquiryChat'
export { InquiryMessageBubble } from './InquiryMessageBubble'
export { InquiryReplyComposer } from './InquiryReplyComposer'
export { InquiryDetailPanel } from './InquiryDetailPanel'
export { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
export { PublicNeedList } from './PublicNeedList'
export { PublicNeedCard } from './PublicNeedCard'
export { PublicNeedDetail } from './PublicNeedDetail'
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
export { OfferCreationPanel } from './OfferCreationPanel'
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
export { OfferCheckedAction } from './OfferCheckedAction'
export { OfferChatComposer } from './OfferChatComposer'
export { AiOfferEmailButton } from './AiOfferEmailButton'
export { EditableOfferFieldList } from './EditableOfferFieldList'
export { MockPdfPreview } from './MockPdfPreview'
@@ -0,0 +1,23 @@
import { mockProperties } from '../../mock-data/properties'
const propertyMap: Record<string, string> = Object.fromEntries(
mockProperties.map(p => [p.id, p.title]),
)
export function propertyLabelFromId(id: string): string {
return propertyMap[id] ?? id
}
export function formatInquiryDate(iso: string): string {
const d = new Date(iso)
const date = d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
const time = d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
return `${date} · ${time}`
}
export function formatFileSize(bytes?: number): string {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
@@ -0,0 +1,35 @@
import type { AssetType } from '../../domain/enums'
export function assetTypeLabel(at: AssetType): string {
const map: Record<string, string> = {
OFFICE: 'Büro',
RETAIL: 'Retail',
LOGISTICS: 'Logistik',
LIGHT_INDUSTRIAL: 'Gewerbe',
PRODUCTION: 'Produktion',
GASTRO: 'Gastro',
MIXED: 'Mischnutzung',
UNKNOWN: 'Unbekannt',
}
return map[at] ?? 'Fläche'
}
export function latentStatusBadge(status: 'public' | 'paused' | 'expired'): {
label: string
bg: string
fg: string
} {
if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' }
return { label: 'Abgelaufen', bg: '#e8e7e4', fg: '#475569' }
}
// Deterministic pseudo-random match score from string IDs (range 6096)
export function deterministicMatchScore(propertyId: string, needId: string): number {
const seed = `${propertyId}::${needId}`
let h = 0
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) >>> 0
}
return 60 + (h % 37)
}
@@ -9,7 +9,7 @@ interface Props {
}
const ACTION_COLORS: Record<string, string> = {
NAVIGATE: '#1e3a5f',
NAVIGATE: '#152642',
OPEN_REVIEW: '#7c3aed',
ADD_TO_SHORTLIST: '#1a7a4a',
REQUEST_DATA: '#d97706',
@@ -25,7 +25,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
px: 1.5,
py: 1,
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
bgcolor: isUser ? '#152642' : '#f1f5f9',
color: isUser ? 'white' : '#1e293b',
}}
>
@@ -1,5 +1,6 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SuggestedQuestion } from '../../domain/assistant'
import { DS_TEXT, DS_BORDER, DS_SURFACE, BADGE_COLORS } from '../../lib/ds'
interface Props {
suggestions: SuggestedQuestion[]
@@ -9,26 +10,26 @@ interface Props {
const CATEGORY_COLORS: Record<string, string> = {
Match: '#4f46e5',
Datenqualität: '#d97706',
Priorisierung: '#1e3a5f',
Empfehlung: '#1a7a4a',
Risiko: '#c0392b',
Datenqualität: DS_TEXT.warning,
Priorisierung: DS_TEXT.brand,
Empfehlung: DS_TEXT.success,
Risiko: DS_TEXT.error,
Tradeoffs: '#ea580c',
Strategie: '#0891b2',
Analyse: '#7c3aed',
Analyse: BADGE_COLORS.preMarket,
Erklärung: '#0891b2',
Evidenz: '#64748b',
Review: '#7c3aed',
Konfidenz: '#d97706',
Fehler: '#c0392b',
Evidenz: DS_TEXT.muted,
Review: BADGE_COLORS.preMarket,
Konfidenz: DS_TEXT.warning,
Fehler: DS_TEXT.error,
Fehleranalyse: '#ea580c',
Eskalation: '#ea580c',
Prozess: '#64748b',
Kosten: '#1a7a4a',
Impact: '#d97706',
Optimierung: '#1a7a4a',
Aktion: '#1e3a5f',
Überblick: '#64748b',
Prozess: DS_TEXT.muted,
Kosten: DS_TEXT.success,
Impact: DS_TEXT.warning,
Optimierung: DS_TEXT.success,
Aktion: DS_TEXT.brand,
Überblick: DS_TEXT.muted,
Ranking: '#4f46e5',
}
@@ -37,12 +38,12 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
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' }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: DS_TEXT.disabled, 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'
const catColor = CATEGORY_COLORS[s.category] ?? DS_TEXT.muted
return (
<Box
key={s.id}
@@ -51,11 +52,11 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
px: 1.25,
py: 0.875,
borderRadius: 1.5,
border: '1px solid #e2e8f0',
border: `1px solid ${DS_BORDER.default}`,
cursor: disabled ? 'default' : 'pointer',
bgcolor: 'white',
opacity: disabled ? 0.5 : 1,
'&:hover': disabled ? {} : { bgcolor: '#f8fafc', borderColor: '#cbd5e1' },
'&:hover': disabled ? {} : { bgcolor: DS_SURFACE.neutral.bg, borderColor: DS_BORDER.strong },
transition: 'all 0.1s ease',
display: 'flex',
alignItems: 'center',
@@ -3,7 +3,8 @@ import { Sparkles } from 'lucide-react'
import { useAssistantStore } from '../../stores/assistantStore'
export function GlobalAIAssistantButton() {
const { isOpen, open } = useAssistantStore()
const isOpen = useAssistantStore(s => s.isOpen)
const open = useAssistantStore(s => s.open)
return (
<Tooltip title="AI Assistent öffnen" placement="left">
@@ -2,49 +2,38 @@ 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 { useShallow } from 'zustand/react/shallow'
import { useAssistantStore } from '../../stores/assistantStore'
import { useSessionStore } from '../../stores/sessionStore'
import { aiAssistantService } from '../../services/aiAssistantService'
import { useAssistantSuggestions, useAssistantAnswer } from '../../hooks/useAssistant'
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 { AssistantContext } 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()
useAssistantStore(useShallow(s => s))
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])
const answerQuestion = useAssistantAnswer()
const { data: suggestions = [] } = useAssistantSuggestions(isOpen ? context : null)
// Refresh suggestions when route changes while open
// Build context from route when drawer opens or route changes while open
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
@@ -54,8 +43,7 @@ export function GlobalAIAssistantDrawer() {
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [location.pathname])
}, [isOpen, location.pathname])
// Auto-scroll on new messages
useEffect(() => {
@@ -64,7 +52,7 @@ export function GlobalAIAssistantDrawer() {
}
}, [messages, isLoading])
const handleQuestion = useCallback(async (question: string) => {
const handleQuestion = useCallback((question: string) => {
if (!question.trim() || isLoading) return
setInputText('')
setError(null)
@@ -78,26 +66,32 @@ export function GlobalAIAssistantDrawer() {
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)
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
answerQuestion.mutate(
{ context: ctx, question },
{
onSuccess: (answer) => {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
setLoading(false)
},
onError: () => {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
setLoading(false)
},
},
)
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError, answerQuestion])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -108,10 +102,6 @@ export function GlobalAIAssistantDrawer() {
const handleClear = () => {
clearConversation()
setSuggestions([])
if (context) {
aiAssistantService.getSuggestions(context).then(setSuggestions)
}
}
const showSuggestions = suggestions.length > 0 && messages.length === 0
@@ -247,7 +237,7 @@ export function GlobalAIAssistantDrawer() {
color: 'white',
flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' },
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
'&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' },
}}
>
<Send size={16} />
+12 -15
View File
@@ -1,22 +1,19 @@
import { Box, Chip, Typography } from '@mui/material'
import { UserRole } from '../../domain/enums'
import { authService } from '../../services/authService'
import { useSwitchDemoRole } from '../../hooks/useAuth'
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',
}
const DEMO_ROLES: { role: UserRole; label: string }[] = [
{ role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung' },
{ role: UserRole.DEMAND_USER, label: 'Bürosuche' },
]
export function DemoRoleSwitcher() {
const { currentUser } = useSessionStore()
const switchDemoRole = useSwitchDemoRole()
async function handleSwitch(role: UserRole) {
await authService.switchDemoRole(role)
function handleSwitch(role: UserRole) {
switchDemoRole.mutate(role)
}
return (
@@ -28,22 +25,22 @@ export function DemoRoleSwitcher() {
Demo-Modus
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{Object.values(UserRole).map((role) => {
{DEMO_ROLES.map(({ role, label }) => {
const active = currentUser?.role === role
return (
<Chip
key={role}
label={ROLE_LABELS[role]}
label={label}
size="small"
clickable
onClick={() => handleSwitch(role)}
sx={{
fontSize: '0.7rem',
height: 22,
bgcolor: active ? '#1e3a5f' : 'transparent',
bgcolor: active ? '#152642' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
borderColor: active ? '#152642' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
+4 -3
View File
@@ -1,6 +1,6 @@
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
import type { SelectChangeEvent } from '@mui/material'
import { authService } from '../../services/authService'
import { useSwitchOrganization } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore'
const MOCK_ORGANIZATIONS = [
@@ -11,9 +11,10 @@ const MOCK_ORGANIZATIONS = [
export function OrganizationSwitcher() {
const { activeOrganizationId } = useSessionStore()
const switchOrganization = useSwitchOrganization()
async function handleChange(e: SelectChangeEvent<string>) {
await authService.switchOrganization(e.target.value)
function handleChange(e: SelectChangeEvent<string>) {
switchOrganization.mutate(e.target.value)
}
return (
-1
View File
@@ -25,7 +25,6 @@ export function ProtectedRoute({ workspace }: ProtectedRouteProps) {
const workspaceLabel: Record<string, string> = {
SUPPLY: 'Supply',
DEMAND: 'Demand',
OPERATIONS: 'Operations',
}
return (
<AccessDenied
+1 -1
View File
@@ -51,7 +51,7 @@ export function SessionExpired() {
<Button
variant="contained"
onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }}
>
Erneut anmelden
</Button>

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