164 Commits

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

After

Width:  |  Height:  |  Size: 9.3 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

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

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