42 Commits

Author SHA1 Message Date
Benjamin Sutter be5523bcee fix(supply): Anfragencenter parse error — stray quote in JSX attribute string
The "Gesendet" empty-state description embedded an ASCII double-quote inside a German „…" pair, which Vite/oxc parsed as the end of the attribute string (PARSE_ERROR). Reworded without embedded quotes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:26:30 +02:00
211 changed files with 4992 additions and 2230 deletions
+17 -1
View File
@@ -13,7 +13,23 @@
"Bash(git pull *)",
"Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")"
"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\")"
]
}
}
+17 -1
View File
@@ -5,7 +5,23 @@
"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/**)"
"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"
]
}
}
@@ -23,7 +23,7 @@ const TYPE_LABELS: Record<AIOutputType, string> = {
}
const TYPE_COLORS: Record<AIOutputType, string> = {
NEED_PARSE: '#1e3a5f',
NEED_PARSE: '#152642',
FOLLOW_UP_QUESTIONS: '#0891b2',
MATCH_EXPLANATION: '#4f46e5',
COMPARE_SUMMARY: '#1a7a4a',
@@ -61,7 +61,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
variant="outlined"
onClick={onCopyJson}
startIcon={<Copy size={13} />}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e8e7e4', '&:hover': { bgcolor: '#f8fafc' } }}
>
JSON
</Button>
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries'
import type { InquiryFilters } from '../../provider/IInquiryProvider'
import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid'
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
return stored === 'grid' ? 'grid' : 'list'
}
export function ActiveInquiriesTab() {
const { data: inquiries = [], isLoading } = useActiveInquiries()
interface Props {
filters?: InquiryFilters
emptyTitle?: string
emptyDescription?: string
}
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme()
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
return (
<EmptyState
icon={<Inbox size={40} />}
title="Keine aktiven Anfragen"
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
title={emptyTitle ?? 'Keine aktiven Anfragen'}
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
/>
)
}
@@ -93,18 +100,18 @@ export function ActiveInquiriesTab() {
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Anfragen
</Typography>
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#1e3a5f', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{inquiries.length}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Listenansicht">
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#1e3a5f' : '#64748b' }}>
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}>
<ListIcon size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Kartenansicht">
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#1e3a5f' : '#64748b' }}>
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}>
<LayoutGrid size={16} />
</IconButton>
</Tooltip>
+5 -15
View File
@@ -20,12 +20,12 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
p: 2,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
@@ -96,19 +96,9 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
{formatInquiryDate(inquiry.createdAt)}
</Typography>
{inquiry.matchScore !== undefined && (
<Box
sx={{
px: 1,
py: 0.25,
borderRadius: 1,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
Match {inquiry.matchScore}%
</Box>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
{inquiry.matchScore}%
</Typography>
)}
</Box>
</Paper>
@@ -42,7 +42,7 @@ export function InquiryChat({
fontSize: '0.75rem',
borderColor: '#cbd5e1',
color: '#475569',
'&:hover': { borderColor: '#1e3a5f', color: '#1e3a5f', bgcolor: '#f0f4f8' },
'&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' },
}}
>
Angebot erstellen
@@ -56,6 +56,7 @@ export function InquiryChat({
<InquiryReplyComposer
inquiryId={inquiry.id}
defaultSubject={inquiry.subject}
tenantName={inquiry.tenantName}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
/>
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ClipboardList } from 'lucide-react'
import { ArrowRight, Building2, ClipboardList, MapPin, Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { usePropertyById } from '../../hooks/useProperties'
import { InquiryChat } from './InquiryChat'
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
import type { Attachment } from '../../domain/inquiry'
@@ -12,7 +14,9 @@ interface InquiryDetailPanelProps {
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
const markRead = useMarkThreadAsRead()
const navigate = useNavigate()
const [preparationOpen, setPreparationOpen] = useState(false)
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
@@ -43,10 +47,13 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
}
const isLatentInquiry = !!inquiry.needId
const propertyImage = property?.images?.[0]
return (
<>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header: tenant + subject + action */}
<Box
sx={{
px: 2.5,
@@ -85,28 +92,94 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
variant="outlined"
startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#1e3a5f', color: '#1e3a5f', '&:hover': { bgcolor: '#f0f4f8' } }}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#152642', color: '#152642', '&:hover': { bgcolor: '#f0f4f8' } }}
>
Vorbereitung starten
</Button>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
{/* Property context bar */}
<Box
sx={{
px: 2,
py: 1,
borderBottom: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexShrink: 0,
}}
>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 1,
overflow: 'hidden',
flexShrink: 0,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{propertyImage ? (
<Box
component="img"
src={propertyImage}
alt={property?.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Building2 size={18} color="#94a3b8" />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
>
{property?.title ?? '—'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
<MapPin size={11} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{property?.location.city}
{property?.areaSqm ? ` · ${property.areaSqm.toLocaleString('de-CH')}` : ''}
{property?.rentPricePerSqm ? ` · CHF ${property.rentPricePerSqm}/m²/J.` : ''}
</Typography>
</Box>
</Box>
<Button
size="small"
endIcon={<ArrowRight size={12} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
>
Objekt ansehen
</Button>
</Box>
{/* Chat (flex: 1) */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<InquiryChat
inquiry={inquiry}
onCreateOffer={() => setOfferWizardOpen(true)}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
/>
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
</Box>
{/* Bottom: weitere passende Objekte */}
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
<RelatedPropertyCardPanel
propertyId={inquiry.propertyId}
inquiryId={inquiry.id}
compact
/>
</Box>
</Box>
</Box>
{preparationOpen && (
<PreparationWizardLazy
@@ -17,11 +17,10 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
onClick={onClick}
sx={{
px: 2,
py: 1.5,
py: 1,
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid',
borderLeftColor: selected ? '#1e3a5f' : hasUnread ? '#2563eb' : 'transparent',
bgcolor: selected ? '#f1f5f9' : 'white',
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s',
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
@@ -30,7 +29,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography
variant="body2"
sx={{ fontWeight: hasUnread ? 700 : 600, color: '#0f172a', fontSize: '0.85rem' }}
sx={{ fontWeight: selected || hasUnread ? 700 : 600, color: selected ? '#1d4ed8' : '#0f172a', fontSize: '0.85rem' }}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
@@ -76,20 +75,9 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
{propertyLabelFromId(inquiry.propertyId)}
</Typography>
{inquiry.matchScore !== undefined && (
<Box
sx={{
ml: 'auto',
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
{inquiry.matchScore}%
</Box>
</Typography>
)}
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
@@ -38,7 +38,7 @@ export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
<Box
sx={{
maxWidth: '75%',
bgcolor: isTenant ? '#f1f5f9' : '#1e3a5f',
bgcolor: isTenant ? '#f1f5f9' : '#152642',
color: isTenant ? '#0f172a' : 'white',
px: 1.75,
py: 1.25,
@@ -7,7 +7,7 @@ import {
TextField,
Typography,
} from '@mui/material'
import { Send, Paperclip, X } from 'lucide-react'
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
import { useSendInquiryReply } from '../../hooks/useInquiries'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
@@ -16,6 +16,7 @@ import { formatFileSize } from './inquiryUtils'
interface InquiryReplyComposerProps {
inquiryId: string
defaultSubject: string
tenantName?: string
onSent?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
@@ -24,6 +25,7 @@ interface InquiryReplyComposerProps {
export function InquiryReplyComposer({
inquiryId,
defaultSubject,
tenantName,
onSent,
pendingAttachment,
onPendingAttachmentConsumed,
@@ -33,6 +35,7 @@ export function InquiryReplyComposer({
)
const [body, setBody] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [isAIDraft, setIsAIDraft] = useState(false)
const sendReply = useSendInquiryReply()
const showToast = useToastStore(s => s.showToast)
@@ -49,6 +52,14 @@ export function InquiryReplyComposer({
const sending = sendReply.isPending
function handleAIDraft() {
const salutation = tenantName ? `Sehr geehrte/r ${tenantName}` : 'Sehr geehrte Damen und Herren'
setBody(
`${salutation},\n\nVielen Dank für Ihre Anfrage. Gerne bestätigen wir, dass das Objekt zum gewünschten Zeitpunkt verfügbar ist.\n\nWir würden Ihnen gerne einen Besichtigungstermin vorschlagen — bitte teilen Sie uns Ihre Verfügbarkeit mit, damit wir einen passenden Termin finden können.\n\nFür Rückfragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüssen`
)
setIsAIDraft(true)
}
const handleAddMockAttachment = () => {
const name = `Anhang_${attachments.length + 1}.pdf`
setAttachments(prev => [
@@ -82,6 +93,7 @@ export function InquiryReplyComposer({
showToast('Antwort gesendet', 'success')
setBody('')
setAttachments([])
setIsAIDraft(false)
onSent?.()
}
@@ -103,15 +115,26 @@ export function InquiryReplyComposer({
onChange={e => setSubject(e.target.value)}
fullWidth
/>
{isAIDraft && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Sparkles size={12} color="#7c3aed" />
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
KI-Entwurf bitte prüfen und anpassen
</Typography>
</Box>
)}
<TextField
size="small"
label="Nachricht"
value={body}
onChange={e => setBody(e.target.value)}
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
multiline
rows={4}
rows={7}
placeholder="Antwort verfassen..."
fullWidth
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
/>
{attachments.length > 0 && (
@@ -149,15 +172,32 @@ export function InquiryReplyComposer({
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 0.75 }}>
<Button
size="small"
startIcon={<Paperclip size={14} />}
startIcon={<Sparkles size={13} />}
onClick={handleAIDraft}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
color: '#7c3aed',
borderColor: '#ddd6fe',
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' },
}}
variant="outlined"
>
KI-Entwurf
</Button>
<Button
size="small"
startIcon={<Paperclip size={13} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none' }}
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
>
Anhang
</Button>
</Box>
<Button
variant="contained"
size="small"
@@ -166,7 +206,7 @@ export function InquiryReplyComposer({
}
onClick={handleSend}
disabled={sending || !body.trim()}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Antwort senden
</Button>
@@ -47,7 +47,7 @@ export function LatentInquiriesTab() {
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box
onClick={() => setMobileView('properties')}
sx={{ p: 1.25, bgcolor: '#1e3a5f', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
sx={{ p: 1.25, bgcolor: '#152642', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
>
Eigene Objekte anzeigen
</Box>
@@ -164,11 +164,11 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
</Box>
<Box
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSendOffer } from '../../hooks/useOffers'
import { useCreateOffer } from '../../hooks/useInquiries'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties'
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
const reset = useOfferWizardStore(s => s.reset)
const sendOffer = useSendOffer()
const createOffer = useCreateOffer()
const { data: latentNeed } = useLatentNeedById(needId)
const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => {
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
showToast(`Fehler: ${res.error}`, 'error')
return
}
// Angebot als Konversation im Nachfrager-Postfach materialisieren
await createOffer.mutateAsync({
organizationId: currentUser?.organizationId ?? 'org-wincasa',
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
needId: needId ?? '',
propertyId: selectedPropertyIds[0] ?? '',
offeredPropertyIds: selectedPropertyIds,
subject,
message: body,
senderName: currentUser?.organizationName ?? 'Wincasa AG',
recipientName: latentNeed?.tenantCompany,
attachments,
})
showToast('Angebot erfolgreich gesendet', 'success')
reset()
}
@@ -116,7 +135,7 @@ export function OfferChatComposer() {
py: 0.75,
}}
>
<FileText size={14} color="#1e3a5f" />
<FileText size={14} color="#152642" />
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
{a.fileName}
</Typography>
@@ -35,7 +35,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
onClick={() => open(needId, needTitle)}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
@@ -255,7 +255,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
variant="contained"
disabled={loading}
onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -264,7 +264,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button
variant="contained"
onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -273,7 +273,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button
variant="contained"
onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
PDF erstellen
</Button>
@@ -77,7 +77,7 @@ export function OfferFieldSelectionStep() {
<Button
variant="contained"
onClick={() => setStep('pdf_review')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -179,7 +179,7 @@ export function OfferPdfReviewStep() {
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleNext}
disabled={!pdfReady || markChecked.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
</Button>
@@ -141,7 +141,7 @@ export function OfferPropertySelectionStep() {
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -18,8 +18,8 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
PDF wird generiert
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
@@ -44,7 +44,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
overflow: 'hidden',
}}
>
<Box sx={{ bgcolor: '#1e3a5f', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ bgcolor: '#152642', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
@@ -53,7 +53,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
</Typography>
</Box>
<Box sx={{ flex: 1, p: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', mb: 1 }}>
Angebotsschreiben
</Typography>
{draft?.editableFields.slice(0, 3).map(f => (
@@ -83,7 +83,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
variant="contained"
startIcon={<Download size={16} />}
onClick={onDownload}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
@@ -62,7 +62,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
flexShrink: 0,
}}
>
<Target size={14} color="#1e3a5f" />
<Target size={14} color="#152642" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Eigene Objekte
</Typography>
@@ -72,7 +72,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#1e3a5f',
bgcolor: '#152642',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
@@ -159,8 +159,8 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
Bericht wird erstellt
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
@@ -234,7 +234,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
fullWidth
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
@@ -271,7 +271,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
variant="contained"
disabled={selectedIds.length === 0}
onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -282,7 +282,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
disabled={finalizing}
onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Finalisieren
</Button>
@@ -19,12 +19,12 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
@@ -174,7 +174,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
variant="caption"
sx={{
fontWeight: 700,
color: '#1e3a5f',
color: '#152642',
fontSize: '0.75rem',
bgcolor: '#e0e7ff',
px: 1,
@@ -189,7 +189,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
sx={{
height: 6,
borderRadius: 3,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
overflow: 'hidden',
}}
>
@@ -197,7 +197,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
sx={{
width: `${pct}%`,
height: '100%',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
transition: 'width 0.3s',
}}
/>
@@ -229,7 +229,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
disabled={disabled}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
@@ -47,7 +47,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#1e3a5f',
bgcolor: '#152642',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
@@ -1,5 +1,5 @@
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
@@ -8,9 +8,10 @@ import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
propertyId: string
inquiryId: string
compact?: boolean
}
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const {
@@ -18,7 +19,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
isLoading: loadingMatches,
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
if (isLoading) {
if (isLoading && !compact) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
<CircularProgress size={20} />
@@ -26,6 +27,44 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
)
}
if (compact) {
return (
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
>
Weitere passende Objekte
</Typography>
{loadingMatches ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={16} />
</Box>
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
Keine weiteren Matches
</Typography>
) : (
<Box
sx={{
display: 'flex',
gap: 1.25,
overflowX: 'auto',
pb: 0.5,
'&::-webkit-scrollbar': { height: 3 },
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
}}
>
{additionalMatches.map(m => (
<MatchSliderCard key={m.propertyId} match={m} />
))}
</Box>
)}
</Box>
)
}
if (!property) {
return (
<Box sx={{ p: 2 }}>
@@ -51,25 +90,22 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
overflowY: 'auto',
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Bezogenes Objekt
</Typography>
<Box
sx={{
height: 140,
aspectRatio: '3/2',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: '#e2e8f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
bgcolor: '#f4f3f0',
}}
>
{!image && <Building2 size={32} color="#94a3b8" />}
{image ? (
<Box component="img" src={image} alt={property.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={32} color="#94a3b8" />
</Box>
)}
</Box>
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
@@ -77,20 +113,20 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<MapPin size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.location.city}
{property.location.district ? `, ${property.location.district}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Ruler size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Calendar size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
@@ -114,14 +150,10 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
Objekt ansehen
</Button>
{/* Weitere Matches */}
<Divider sx={{ my: 0.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Trophy size={14} color="#d97706" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
</Box>
{loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
@@ -140,74 +172,117 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
)
}
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
onClick={() => navigate('/supply/properties')}
sx={{
border: '1px solid #e2e8f0',
flexShrink: 0,
width: 168,
border: '1px solid #e8e7e4',
borderRadius: 1.5,
overflow: 'hidden',
cursor: 'pointer',
bgcolor: 'white',
transition: 'border-color 0.15s, box-shadow 0.15s',
'&:hover': {
borderColor: '#b0aead',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
},
}}
>
{match.imageUrl && (
{/* Image — 16:9 crop, compact supplementary card */}
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
{match.imageUrl ? (
<Box
sx={{
height: 70,
backgroundImage: `url(${match.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
component="img"
src={match.imageUrl}
alt={match.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={16} color="#94a3b8" />
</Box>
)}
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
{match.title}
</Typography>
{/* Score badge overlay */}
<Box
sx={{
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
position: 'absolute',
top: 6,
right: 6,
bgcolor: 'rgba(15,23,42,0.72)',
color: 'white',
fontSize: '0.7rem',
fontWeight: 700,
fontSize: '0.65rem',
flexShrink: 0,
ml: 0.5,
px: 0.75,
py: 0.25,
borderRadius: '4px',
lineHeight: 1.4,
}}
>
{match.matchScore}%
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²/Jahr
</Typography>
{match.reasons.length > 0 && (
<Box sx={{ mb: 0.5 }}>
{match.reasons.map((r, i) => (
<Typography key={i} variant="caption" sx={{ color: '#15803d', fontSize: '0.68rem', display: 'block', lineHeight: 1.4 }}>
+ {r}
</Typography>
))}
</Box>
)}
{match.topUncertainty && (
<Typography variant="caption" sx={{ color: '#92400e', fontSize: '0.68rem', display: 'block', lineHeight: 1.4, mb: 0.25 }}>
{match.topUncertainty}
</Typography>
)}
<Button
size="small"
endIcon={<ArrowRight size={11} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#1e3a5f' }}
<Box sx={{ p: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
>
Ansehen
</Button>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
{match.matchScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{match.location}
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
+ {match.reasons[0]}
</Typography>
)}
</Box>
</Box>
)
}
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
sx={{
borderTop: '1px solid #f1f5f9',
pt: 1,
'&:first-of-type': { borderTop: 'none', pt: 0 },
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
{match.matchScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
+ {match.reasons[0]}
</Typography>
)}
<Button
size="small"
endIcon={<ArrowRight size={10} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: '#152642' }}
>
Ansehen
</Button>
</Box>
)
}
@@ -31,12 +31,12 @@ export function SelectablePropertyMatchCard({
p: 1.25,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
borderColor: selected ? '#152642' : '#94a3b8',
},
display: 'flex',
gap: 1.25,
@@ -57,7 +57,7 @@ export function SelectablePropertyMatchCard({
height: 40,
flexShrink: 0,
borderRadius: 1,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -21,7 +21,7 @@ export function latentStatusBadge(status: 'public' | 'paused' | 'expired'): {
} {
if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' }
return { label: 'Abgelaufen', bg: '#e2e8f0', fg: '#475569' }
return { label: 'Abgelaufen', bg: '#e8e7e4', fg: '#475569' }
}
// Deterministic pseudo-random match score from string IDs (range 6096)
@@ -9,7 +9,7 @@ interface Props {
}
const ACTION_COLORS: Record<string, string> = {
NAVIGATE: '#1e3a5f',
NAVIGATE: '#152642',
OPEN_REVIEW: '#7c3aed',
ADD_TO_SHORTLIST: '#1a7a4a',
REQUEST_DATA: '#d97706',
@@ -25,7 +25,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
px: 1.5,
py: 1,
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
bgcolor: isUser ? '#152642' : '#f1f5f9',
color: isUser ? 'white' : '#1e293b',
}}
>
@@ -237,7 +237,7 @@ export function GlobalAIAssistantDrawer() {
color: 'white',
flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' },
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
'&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' },
}}
>
<Send size={16} />
+2 -2
View File
@@ -37,10 +37,10 @@ export function DemoRoleSwitcher() {
sx={{
fontSize: '0.7rem',
height: 22,
bgcolor: active ? '#1e3a5f' : 'transparent',
bgcolor: active ? '#152642' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
borderColor: active ? '#152642' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
+1 -1
View File
@@ -51,7 +51,7 @@ export function SessionExpired() {
<Button
variant="contained"
onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }}
>
Erneut anmelden
</Button>
+1 -1
View File
@@ -17,7 +17,7 @@ interface MetricCardProps {
sx?: SxProps<Theme>
}
export function MetricCard({ label, value, delta, icon, color = '#1e3a5f', sx }: MetricCardProps) {
export function MetricCard({ label, value, delta, icon, color = '#152642', sx }: MetricCardProps) {
return (
<Card sx={{ p: 2.5, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
+1 -1
View File
@@ -9,7 +9,7 @@ interface SourceTypeBadgeProps {
}
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#152642', Icon: ShieldCheck },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
}
@@ -67,7 +67,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
sx={{
fontWeight: 700, lineHeight: 1.3, mb: 0.25,
cursor: 'pointer',
'&:hover': { color: '#1e3a5f' },
'&:hover': { color: '#152642' },
}}
onClick={() => navigate(`/demand/results/${item.matchId}`)}
>
@@ -38,7 +38,7 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
{compareItems.map(item => (
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }} noWrap>
{getTitle(item)}
</Typography>
</TableCell>
+1 -1
View File
@@ -15,7 +15,7 @@ export function CompareEmptyState() {
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' } }}>
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}>
Zu den Suchergebnissen
</Button>
</Box>
+12 -25
View File
@@ -20,8 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -155,11 +155,7 @@ export function CompareTableBody({
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
@@ -170,21 +166,12 @@ export function CompareTableBody({
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
// Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
})
const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
@@ -205,15 +192,15 @@ export function CompareTableBody({
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
= {totalMonthly.toLocaleString('de-CH')} CHF/Monat
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography>
)}
</Box>
@@ -81,7 +81,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
Datenaktualisierung anfragen
</Button>
{q.missingCriticalFields.length > 0 && (
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#1e3a5f' }}>
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#152642' }}>
Fehlende Felder ergänzen
</Button>
)}
@@ -76,7 +76,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
@@ -110,7 +110,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
@@ -79,7 +79,7 @@ export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: color },
}}
/>
+1 -1
View File
@@ -23,7 +23,7 @@ export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, on
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
bgcolor: '#152642',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
@@ -48,7 +48,7 @@ export function AISearchSavePreview({
<Box sx={{
maxWidth: 720, mx: 'auto', mt: 2,
p: 1.5, borderRadius: 1.5, border: '1px solid',
borderColor: isAnonymous ? '#7c3aed' : '#e2e8f0',
borderColor: isAnonymous ? '#7c3aed' : '#e8e7e4',
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
transition: 'all 0.15s',
}}>
@@ -86,7 +86,7 @@ export function AISearchSavePreview({
onClick={onSave}
disabled={isSaving}
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{isSaving
? 'Wird gespeichert…'
+1 -1
View File
@@ -66,7 +66,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onRepa
disabled={requiredUnanswered.length > 0}
onClick={onContinue}
endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Weiter zur Gewichtung
</Button>
@@ -22,7 +22,7 @@ export function NeedBuilderErrorState({ message, onRetry }: Props) {
variant="contained"
startIcon={<RotateCcw size={16} />}
onClick={onRetry}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Erneut versuchen
</Button>
+3 -3
View File
@@ -66,7 +66,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
{/* 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={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#152642', color: 'white' }} />
)}
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
</Box>
@@ -143,7 +143,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
variant="determinate"
value={overallConfidence * 100}
sx={{
height: 6, borderRadius: 3, bgcolor: '#e2e8f0',
height: 6, borderRadius: 3, bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': {
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
},
@@ -171,7 +171,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
<LinearProgress
variant="determinate"
value={((weights[k] ?? 0) / maxWeight) * 100}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e8e7e4', '& .MuiLinearProgress-bar': { bgcolor: '#152642' } }}
/>
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
</Box>
@@ -73,6 +73,7 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
{c.requiredFitOut && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
@@ -80,9 +81,10 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
/>
</Box>
)}
</Box>
{/* Divisibility */}
+7 -31
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
import { Box, Card, Chip, Slider, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
@@ -81,7 +81,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
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' } }
? { bgcolor: '#152642', color: 'white', '& .MuiChip-label': { color: 'white' } }
: {}}
/>
))}
@@ -139,7 +139,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
})
}}
sx={c.preferredLocations?.includes(loc)
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
}
/>
@@ -169,7 +169,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
<Box sx={{ mb: 2.5, px: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>Suchradius</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
{c.searchRadius ?? 30} km
</Typography>
</Box>
@@ -181,7 +181,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
step={1}
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
size="small"
sx={{ color: '#1e3a5f', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
sx={{ color: '#152642', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
/>
</Box>
@@ -197,7 +197,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
sx={c.budgetRange?.maxPerSqm === p.value
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
}
/>
@@ -242,31 +242,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack>
)}
{/* Anonymous search */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e2e8f0'}` }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
Anonyme Suche
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
Firmenname wird nicht an Vermieter übermittelt
</Typography>
</Box>
<FormControlLabel
control={
<Switch
size="small"
checked={c.isAnonymous ?? false}
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
/>
}
label=""
sx={{ m: 0 }}
/>
</Box>
<NeedExtendedRequirements criteria={c} onChange={set} />
<NeedExtendedRequirements criteria={c} onChange={set} />
</Card>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { memo } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { ROUTES } from '../../lib/constants'
import type { Property } from '../../domain/property'
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
import type { Match } from '../../domain/match'
import { MatchStrength, ResultType } from '../../domain/enums'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useCompareStore } from '../../stores/compareStore'
interface Props {
property: Property
score: number
needId: string
}
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
const syntheticMatch = {
id: matchId,
needId,
propertyId: property.id,
matchScore: score,
matchStrength: strength,
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
confidenceLevel: score / 100,
} as unknown as Match
return {
matchId,
needId,
matchScore: score,
resultType: ResultType.VERIFIED_PORTFOLIO,
property,
match: syntheticMatch,
}
}
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
const navigate = useNavigate()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
// Match IDs in the store use double-underscore format: m__propId__prop__needId
const matchId = `m__${property.id}__prop__${needId}`
const inCompare = isInCompare(matchId)
function handleInquire() {
openInquiryDialog({
propertyTitle: property.title,
location: property.location.city,
matchScore: score,
matchId,
propertyId: property.id,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleShortlist() {
openSavedDialog({
resultId: matchId,
resultType: 'VERIFIED_PORTFOLIO',
title: property.title,
matchScore: score,
location: property.location.city,
propertyId: property.id,
propertyAddress: `${property.title}, ${property.location.city}`,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleCompare() {
if (inCompare) {
removeFromCompare(matchId)
} else if (!isFull()) {
addToCompare(buildSyntheticResult(property, needId, score, matchId))
}
}
function handleDetails() {
navigate(`/demand/results/${matchId}`)
}
function handleUnit() {
navigate(`/demand/property/${property.id}`)
}
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
{property.images?.[0] ? (
<Box component="img" src={property.images[0]} alt=""
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
) : (
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Ruler size={18} color="#3730a3" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
{property.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{property.location.city} · {property.areaSqm} m²
</Typography>
</Box>
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
{score}%
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
</ActionButton>
<ActionButton onClick={handleDetails}>Details </ActionButton>
<ActionButton onClick={handleUnit}>Zur Einheit </ActionButton>
</Box>
</Box>
)
})
function ActionButton({ children, onClick, primary, disabled }: {
children: React.ReactNode
onClick: () => void
primary?: boolean
disabled?: boolean
}) {
return (
<Button
size="small"
variant={primary ? 'contained' : 'outlined'}
onClick={onClick}
disabled={disabled}
sx={{
textTransform: 'none',
fontWeight: 600,
fontSize: '0.75rem',
py: 0.25,
px: 1,
minWidth: 0,
...(primary
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
}}
>
{children}
</Button>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { memo } from 'react'
import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import type { Need } from '../../domain/need'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
interface Props {
need: Need
selected: boolean
matchCount: number
topScore: number
onClick: () => void
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, matchCount, topScore, onClick }: Props) {
const statusCfg = statusConfig(need.status)
const locationText = need.preferredLocations.slice(0, 2).join(', ')
const scoreColor = topScore >= 80 ? '#16a34a' : topScore >= 65 ? '#d97706' : '#dc2626'
return (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 6px rgba(15,23,42,0.06)' },
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
</Box>
{matchCount > 0 && (
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
{matchCount} Treffer
</Box>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
{need.companyName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
</Box>
{topScore > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
</Box>
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
{topScore}%
</Typography>
</Box>
)}
</Paper>
)
})
+326
View File
@@ -0,0 +1,326 @@
import { memo, useMemo, useState } from 'react'
import {
Accordion, AccordionDetails, AccordionSummary,
Box, Button, Chip, Divider, LinearProgress, Slider, Switch, TextField, Typography,
} from '@mui/material'
import { Bell, Calendar, CheckCircle2, ChevronDown, MapPin, Ruler, Target, Wallet } from 'lucide-react'
import type { Need, NotificationConfig, UpdateNeedInput } from '../../domain/need'
import type { Property } from '../../domain/property'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { confidenceHex } from '../../lib/utils'
import { useMatchesByNeed } from '../../hooks/useMatches'
import { PropertyMatchRow } from './PropertyMatchRow'
const WEIGHT_LABELS: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansionspotenzial',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Publikumsverkehr',
talentAccess: 'Talentzugang', esg: 'ESG', taxEnvironment: 'Steuerumgebung',
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
interface Props {
need: Need
properties: Property[]
onStart: () => void
onEdit: () => void
onArchive: () => void
onUpdate: (data: UpdateNeedInput) => void
}
export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, onStart, onEdit, onArchive, onUpdate }: Props) {
const statusCfg = statusConfig(need.status)
const conf = need.confidenceInCriteria ?? 0
const confColor = confidenceHex(conf)
const [notif, setNotif] = useState<NotificationConfig>(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
const [notifDirty, setNotifDirty] = useState(false)
function handleNotifChange(patch: Partial<NotificationConfig>) {
setNotif(prev => ({ ...prev, ...patch }))
setNotifDirty(true)
}
function handleNotifSave() {
onUpdate({ notificationConfig: notif })
setNotifDirty(false)
}
const { data: matches = [] } = useMatchesByNeed(need.id)
const notifThreshold = need.notificationConfig?.minScore ?? 80
const matchCount = matches.filter(m => m.matchScore >= notifThreshold).length
const scoredProperties = useMemo(() => {
const seen = new Set<string>()
return matches
.map(m => {
const property = properties.find(p => p.id === m.propertyId)
return property ? { property, score: Math.round(m.matchScore) } : null
})
.filter((x): x is { property: Property; score: number } => x !== null)
.sort((a, b) => b.score - a.score)
.filter(x => {
if (seen.has(x.property.id)) return false
seen.add(x.property.id)
return true
})
}, [matches, properties])
const topScore = scoredProperties.length > 0 ? scoredProperties[0].score : 0
const topProperties = scoredProperties.slice(0, 3)
const topWeights = useMemo(
() => Object.entries(need.weightingProfile).filter(([, v]) => v > 0.03).sort(([, a], [, b]) => b - a).slice(0, 6),
[need.weightingProfile],
)
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
{need.companyName}
</Typography>
{need.contactName && (
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
)}
</Box>
<Box>
<SectionLabel>Suchkriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1.25 }}>
<CriteriaBox icon={<MapPin size={13} />} label="Standort" value={need.preferredLocations.join(', ') || '—'} />
<CriteriaBox icon={<Ruler size={13} />} label="Fläche" value={`${need.requiredArea.min}${need.requiredArea.max}`} />
<CriteriaBox icon={<Wallet size={13} />} label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
<CriteriaBox icon={<Calendar size={13} />} label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<SectionLabel>Konfidenz der Kriterien</SectionLabel>
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor, fontSize: '0.8rem' }}>
{Math.round(conf * 100)}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={conf * 100} sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
}} />
</Box>
{(need.mustCriteriaText?.length ?? 0) > 0 && (
<Box>
<SectionLabel>Must-have Kriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustCriteriaText!.map((c, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
</Box>
))}
</Box>
</Box>
)}
{topWeights.length > 0 && (
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topWeights.map(([key, val]) => {
const pct = Math.round(val * 100)
return (
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{WEIGHT_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
</Box>
</Box>
)
})}
</Box>
</AccordionDetails>
</Accordion>
)}
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Benachrichtigungen
</Typography>
{notif.enabled && (
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
ab {notif.minScore}%
</Box>
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
Neue Treffer oberhalb der Schwelle melden
</Typography>
</Box>
<Switch
size="small"
checked={notif.enabled}
onChange={e => handleNotifChange({ enabled: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
/>
</Box>
{notif.enabled && (
<>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
{notif.minScore}%
</Typography>
</Box>
<Slider
value={notif.minScore}
min={60} max={95} step={5}
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
</Box>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', mb: 0.5 }}>
E-Mail (optional)
</Typography>
<TextField
size="small"
fullWidth
placeholder="name@firma.ch"
value={notif.emailAddress ?? ''}
onChange={e => handleNotifChange({ emailAddress: e.target.value || undefined })}
inputProps={{ type: 'email' }}
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
helperText="Im Produktivsystem wird bei neuen Treffern eine E-Mail ausgelöst"
/>
</Box>
</>
)}
{notifDirty && (
<Button
variant="contained"
size="small"
onClick={handleNotifSave}
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
>
Speichern
</Button>
)}
</Box>
</AccordionDetails>
</Accordion>
{scoredProperties.length > 0 && (
<>
<Divider />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Target size={14} color="#15803d" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Passende Objekte
</Typography>
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
{matchCount} Treffer
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topProperties.map(({ property, score }) => (
<PropertyMatchRow key={property.id} property={property} score={score} needId={need.id} />
))}
</Box>
{matchCount > 3 && (
<Box
onClick={onStart}
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
>
Alle {matchCount} Ergebnisse anzeigen
</Box>
)}
</Box>
</>
)}
</Box>
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
<Button variant="outlined" size="large" onClick={onEdit}
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
Bearbeiten
</Button>
<Button variant="contained" size="large" onClick={onStart}
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
Suche starten
</Button>
</Box>
</Box>
)
})
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{children}
</Typography>
)
}
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useState } from 'react'
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Plus, Search } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles, useArchiveNeed, useUpdateNeed } from '../../hooks/useNeeds'
import { useProperties } from '../../hooks/useProperties'
import { useMatches } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import { ROUTES } from '../../lib/constants'
import { EmptyState } from '../ui'
import { SavedNeedCard } from './SavedNeedCard'
import { SavedNeedDetail } from './SavedNeedDetail'
import { needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
interface Props {
onNewSearch: (prefillNeedId?: string) => void
}
export function SavedProfilesTab({ onNewSearch }: Props) {
const navigate = useNavigate()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const { data: needs = [], isLoading } = useNeedProfiles()
const { data: properties = [] } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const { data: allMatches = [] } = useMatches()
const archiveMutation = useArchiveNeed()
const updateMutation = useUpdateNeed()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
const visible = useMemo(
() =>
needs
.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT')
.sort((a, b) => {
const aActive = !a.status || a.status === 'ACTIVE'
const bActive = !b.status || b.status === 'ACTIVE'
if (aActive !== bActive) return aActive ? -1 : 1
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}),
[needs],
)
const { matchCounts, topScores } = useMemo(() => {
const counts: Record<string, number> = {}
const tops: Record<string, number> = {}
for (const n of visible) {
const needMatches = allMatches.filter(m => m.needId === n.id)
const threshold = n.notificationConfig?.minScore ?? 80
counts[n.id] = needMatches.filter(m => m.matchScore >= threshold).length
tops[n.id] = needMatches.length > 0 ? Math.round(Math.max(...needMatches.map(m => m.matchScore))) : 0
}
return { matchCounts: counts, topScores: tops }
}, [visible, allMatches])
useEffect(() => {
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
}, [visible, selectedId, isMobile])
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
function handleSelect(id: string) {
setSelectedId(id)
if (isMobile) setMobileView('detail')
}
function handleStart(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
}
function handleEdit(needId: string) {
onNewSearch(needId)
}
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}><CircularProgress size={24} /></Box>
}
if (visible.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 8, gap: 2 }}>
<EmptyState title="Keine Suchprofile" description="Sie haben noch keine Suchprofile gespeichert." />
<Button variant="outlined" startIcon={<Plus size={16} />} onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
Neue Suche erstellen
</Button>
</Box>
)
}
const list = (
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Search size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{visible.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
{visible.map(n => (
<SavedNeedCard
key={n.id}
need={n}
selected={n.id === selectedId}
matchCount={matchCounts[n.id] ?? 0}
topScore={topScores[n.id] ?? 0}
onClick={() => handleSelect(n.id)}
/>
))}
</Box>
<Box sx={{ flexShrink: 0, p: 1.5, borderTop: '1px solid #e2e8f0' }}>
<Button fullWidth variant="outlined" size="small" startIcon={<Plus size={14} />} onClick={() => onNewSearch()}
sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
Neue Suche
</Button>
</Box>
</Box>
)
if (isMobile) {
if (mobileView === 'detail' && selectedNeed) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => setMobileView('list')}><ArrowLeft size={18} /></IconButton>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>{selectedNeed.companyName}</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
</Box>
</Box>
)
}
return <Box sx={{ height: '100%', overflow: 'hidden' }}>{list}</Box>
}
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{list}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{selectedNeed ? (
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<EmptyState icon={<Search size={40} />} title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
</Box>
)}
</Box>
</Box>
)
}
+5 -5
View File
@@ -77,7 +77,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
sx={{
p: 3,
border: '1px solid',
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
borderColor: isRecording ? '#dc2626' : '#e8e7e4',
transition: 'border-color 0.2s',
bgcolor: isRecording ? '#fff5f5' : 'white',
}}
@@ -101,11 +101,11 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
/>
) : 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>
<CircularProgress size={14} sx={{ color: '#152642' }} />
<Typography variant="caption" sx={{ color: '#152642', fontWeight: 600 }}>Analysiert</Typography>
</Box>
) : isAutoGen && text ? (
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}> auto-synchronisiert</Typography>
<Typography variant="caption" sx={{ color: '#152642', fontSize: 11 }}> auto-synchronisiert</Typography>
) : null}
</Box>
@@ -154,7 +154,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
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 } }}
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e8e7e4' }, '&:disabled': { opacity: 0.35 } }}
>
<Mic size={16} />
</IconButton>
+1 -1
View File
@@ -109,7 +109,7 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
</Box>
<Card sx={{ p: 3 }}>
<SliderGroup label="Harte Kriterien" keys={HARD_KEYS} raw={raw} onSlider={handleSlider} color="#1e3a5f" />
<SliderGroup label="Harte Kriterien" keys={HARD_KEYS} raw={raw} onSlider={handleSlider} color="#152642" />
<Divider sx={{ my: 2.5 }} />
<SliderGroup label="Weiche Kriterien" keys={SOFT_KEYS} raw={raw} onSlider={handleSlider} color="#7c3aed" />
</Card>
+4
View File
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor'
export { PropertyMatchRow } from './PropertyMatchRow'
export { SavedNeedCard } from './SavedNeedCard'
export { SavedNeedDetail } from './SavedNeedDetail'
export { SavedProfilesTab } from './SavedProfilesTab'
+1 -1
View File
@@ -39,7 +39,7 @@ export function PriorityChipGroup({ label, options, value, onChange, exclusive =
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{options.map(opt => {
const selected = value.includes(opt.value)
const accent = opt.color ?? '#1e3a5f'
const accent = opt.color ?? '#152642'
return (
<Chip
key={opt.value}
@@ -57,7 +57,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
sx={{
fontWeight: filters.signalType === t ? 700 : 400,
bgcolor: filters.signalType === t ? '#1e3a5f' : undefined,
bgcolor: filters.signalType === t ? '#152642' : undefined,
color: filters.signalType === t ? 'white' : undefined,
}}
/>
@@ -131,7 +131,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
label={`${activeCount} Filter aktiv`}
size="small"
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f' }}
sx={{ bgcolor: '#eff6ff', color: '#152642' }}
/>
)}
<Typography variant="caption" color="text.secondary">
+6 -1
View File
@@ -21,16 +21,21 @@ export function AppShell() {
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const isCompact = useMediaQuery(theme.breakpoints.down('xl'))
const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [location.pathname])
// Auto-collapse sidebar on compact screens (laptops < 1536px)
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
// Sync active workspace with URL
useEffect(() => {
const detected = getWorkspaceFromPath(location.pathname)
+12 -10
View File
@@ -13,7 +13,8 @@ export const SIDEBAR_BG = '#0f1923'
export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
export const TEXT_MUTED = '#94a3b8'
export const TEXT_WHITE = '#ffffff'
export const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)'
export const NAV_ACTIVE_BG = 'transparent'
export const NAV_ACTIVE_ACCENT = '#b8975a'
export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
// ---------------------------------------------------------------------------
@@ -188,16 +189,16 @@ export function Sidebar({
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: collapsed ? 0 : 1.5,
pl: collapsed ? 0 : isActive ? 1.25 : 1.5,
pr: 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',
borderLeft: collapsed ? 'none' : `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
backgroundColor: 'transparent',
'&:hover': { backgroundColor: NAV_HOVER_BG },
transition: 'border-color 0.15s ease',
cursor: 'pointer',
}}
>
@@ -208,8 +209,9 @@ export function Sidebar({
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 500 : 400,
fontSize: '0.8125rem',
fontWeight: isActive ? 600 : 400,
fontSize: '0.875rem',
letterSpacing: '-0.01em',
whiteSpace: 'nowrap',
}}
>
@@ -263,7 +265,7 @@ export function Sidebar({
width: 32,
height: 32,
fontSize: '0.75rem',
bgcolor: '#1e3a5f',
bgcolor: '#b8975a',
flexShrink: 0,
}}
>
+5 -4
View File
@@ -31,10 +31,10 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Box
component="header"
sx={{
height: 56,
height: 64,
flexShrink: 0,
backgroundColor: '#ffffff',
borderBottom: '1px solid #e2e8f0',
borderBottom: '1px solid #e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
@@ -51,12 +51,13 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Chip
label={config.label}
size="small"
variant="outlined"
sx={{
backgroundColor: config.chipColor,
color: '#ffffff',
color: '#152642',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
border: '1px solid #e8e7e4',
}}
/>
<Typography
+2 -2
View File
@@ -4,7 +4,7 @@ import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
VERIFIED_PORTFOLIO: '#152642',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed',
}
@@ -92,7 +92,7 @@ export function CompareTray() {
variant="contained"
size="small"
onClick={() => navigate('/demand/compare')}
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
>
Vergleich starten
</Button>
+99 -9
View File
@@ -1,22 +1,74 @@
import { useState } from 'react'
import { Badge, IconButton, Popover, Typography } from '@mui/material'
import { useMemo, useState } from 'react'
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
import { Bell } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles } from '../../hooks/useNeeds'
import { useMatches } from '../../hooks/useMatches'
import { ROUTES } from '../../lib/constants'
function loadSeenCounts(): Record<string, number> {
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
catch { return {} }
}
function saveSeenCounts(counts: Record<string, number>) {
localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
}
export function NotificationButton() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [seenCounts, setSeenCounts] = useState<Record<string, number>>(loadSeenCounts)
const navigate = useNavigate()
const { data: needs = [] } = useNeedProfiles()
const { data: allMatches = [] } = useMatches()
const profilesWithMatches = useMemo(() => {
const active = needs.filter(n =>
(!n.status || n.status === 'ACTIVE' || n.status === 'DRAFT') &&
(n.notificationConfig?.enabled !== false),
)
return active
.map(n => {
const minScore = n.notificationConfig?.minScore ?? 80
return {
need: n,
count: allMatches.filter(m => m.needId === n.id && m.matchScore >= minScore).length,
minScore,
}
})
.filter(x => x.count > 0)
}, [needs, allMatches])
// Badge only lights up for profiles with matches the user hasn't seen yet
const newProfiles = useMemo(
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
[profilesWithMatches, seenCounts],
)
const badgeCount = newProfiles.length
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
// Mark all current matches as seen
const updated = { ...seenCounts }
for (const { need, count } of profilesWithMatches) {
updated[need.id] = count
}
setSeenCounts(updated)
saveSeenCounts(updated)
}
function handleClose() {
setAnchorEl(null)
function handleClose() { setAnchorEl(null) }
function goToResults(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
handleClose()
}
return (
<>
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
<Badge badgeContent={0} color="error">
<Badge badgeContent={badgeCount || null} color="error">
<Bell size={20} />
</Badge>
</IconButton>
@@ -27,12 +79,50 @@ export function NotificationButton() {
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine neuen Benachrichtigungen
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
Neue Treffer
</Typography>
{profilesWithMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine aktiven Benachrichtigungen
</Typography>
) : (
<>
{profilesWithMatches.map(({ need, count, minScore }) => (
<Box
key={need.id}
onClick={() => goToResults(need.id)}
sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
{need.companyName}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
ab {minScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
{count} Treffer
</Typography>
</Box>
))}
<Divider sx={{ my: 1.25 }} />
<Box
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
>
Alle Suchprofile anzeigen
</Box>
</>
)}
</Popover>
</>
)
@@ -13,7 +13,7 @@ export function OrganizationContextBadge() {
variant="outlined"
icon={<Building2 size={12} color="#64748b" />}
label={currentUser.organizationName}
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e2e8f0' }}
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e8e7e4' }}
/>
)
}
+2 -2
View File
@@ -28,7 +28,7 @@ export function PageHeader({
sx,
}: PageHeaderProps) {
return (
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', ...sx }}>
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e8e7e4', bgcolor: 'white', ...sx }}>
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
{breadcrumbs.map((crumb) =>
@@ -52,7 +52,7 @@ export function PageHeader({
<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 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
{title}
</Typography>
{badge}
+2 -2
View File
@@ -32,8 +32,8 @@ export function RightContextPanel() {
right: 0,
top: 56,
height: 'calc(100vh - 56px)',
width: 320,
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
width: { xs: '85vw', sm: 300, lg: 320 },
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(110%)',
transition: 'transform 0.25s ease',
bgcolor: '#fff',
borderLeft: '1px solid #e2e8f0',
+1 -1
View File
@@ -45,7 +45,7 @@ export function UserMenu() {
return (
<>
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#152642' }}>
{initials}
</Avatar>
</IconButton>
+1 -1
View File
@@ -44,7 +44,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
abbreviation: 'VW',
icon: Building2,
firstPath: '/supply/dashboard',
chipColor: '#1e3a5f',
chipColor: '#152642',
navItems: [
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
@@ -32,7 +32,6 @@ export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${borderColor}`,
borderLeft: `3px solid ${accentColor}`,
background: 'white',
display: 'flex', flexDirection: 'column',
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
@@ -1,19 +1,15 @@
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import {
Building2,
CheckCircle2,
} from 'lucide-react'
import { Box, Button, Chip, Typography } from '@mui/material'
import { Building2, CheckCircle2, MapPin } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import type { MatchCardViewModel } from './MatchCardViewModel'
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
vm: MatchCardViewModel
imageUrl?: string
@@ -30,52 +26,80 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' }
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
const confPct = Math.round(vm.confidenceScore * 100)
// Future availability cards use the new design
if (isFuture) {
return <FutureAvailabilityCard vm={vm} />
}
// secondary chips — max 1 visible (availability only), no overflow chip
const secondaryChip = vm.availabilityLabel ?? vm.fitOutLabel ?? null
return (
<Box sx={{
borderRadius: 2,
overflow: 'hidden',
boxShadow: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${theme.cardBorder}`,
background: theme.cardBg,
border: `1px solid ${DS_BORDER.default}`,
bgcolor: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
display: 'flex',
flexDirection: 'column',
transition: 'box-shadow 0.15s, transform 0.1s',
position: 'relative',
zIndex: 0,
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
'&:hover': {
boxShadow: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
transform: 'translateY(-1px)',
borderColor: '#b0aead',
transform: 'scale(1.025)',
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
zIndex: 1,
},
}}>
{/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={200} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
{/* Score badge — bottom-left editorial */}
<Box sx={{
position: 'absolute', top: 10, left: 10,
background: theme.gradient, borderRadius: '10px',
px: 1.5, py: 0.5,
boxShadow: `0 4px 16px ${theme.glow}`,
position: 'absolute', bottom: 10, left: 12,
bgcolor: theme.gradient, borderRadius: '8px',
px: 1.25, py: 0.375,
border: `1px solid ${theme.border}`,
minWidth: 52, textAlign: 'center',
}}>
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
<Typography sx={{
fontWeight: 700, fontSize: '1.625rem',
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.02em',
}}>
{vm.matchScore}%
</Typography>
</Box>
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
{/* Location pill — Ginesta-style, top-left */}
<Box sx={{
position: 'absolute', top: 10, left: 10,
bgcolor: 'rgba(255,255,255,0.92)',
borderRadius: '20px',
px: 1, py: 0.35,
display: 'flex', alignItems: 'center', gap: 0.4,
backdropFilter: 'blur(4px)',
}}>
<MapPin size={9} color="#64748b" />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
{vm.locationLabel}
</Typography>
</Box>
{/* Top-right badge strip — result type + owner + heat */}
<Box sx={{ position: 'absolute', top: 10, right: 10, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip
icon={<Building2 size={9} color="#1e3a5f" />}
icon={<Building2 size={9} color="#152642" />}
label="Ihr Objekt"
size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
sx={{ bgcolor: 'rgba(21,38,66,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
/>
)}
<HeatBadge propertyId={vm.propertyId} size="sm" />
@@ -83,24 +107,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
</Box>
{/* ── Card body ── */}
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Unit-first title (floor + street) */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
<Box sx={{ p: 2.25, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Title */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{vm.title}
</Typography>
{/* Building name as subtitle when a unit or floor range is in the title */}
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: '#475569', fontWeight: 500 }} noWrap>
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: DS_TEXT.secondary, fontWeight: 500 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25 }}>
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
</Typography>
{/* Unit area + rent when a specific unit is matched */}
{/* Unit details */}
{vm.preMarketUnit && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', mt: 0.25 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', mt: 0.25 }}>
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
{vm.preMarketUnit.rentPricePerSqm
? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
@@ -108,43 +132,47 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
</Typography>
)}
{/* Regular explainability summary */}
{/* Confidence + one secondary chip max */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{secondaryChip && (
<Chip label={secondaryChip} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
)}
</Box>
{/* Explainability */}
{vm.explainabilitySummary && (
<>
<Divider sx={{ my: 1.25 }} />
<Typography variant="body2" sx={{
fontWeight: 500, color: '#1e293b', lineHeight: 1.5, flex: 1,
fontWeight: 500, color: DS_TEXT.secondary, lineHeight: 1.5, flex: 1, mt: 1,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
}}>
{vm.explainabilitySummary}
</Typography>
</>
)}
{vm.reasons.length > 0 && (
<>
<Divider sx={{ my: 1.25 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
{vm.reasons.slice(0, 2).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: DS_TEXT.primary, lineHeight: 1.3 }}>
{r.label}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: '#64748b', lineHeight: 1.3 }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.muted, lineHeight: 1.3 }}>
{r.explanation}
</Typography>
</Box>
</Box>
))}
</Box>
</>
)}
{/* Actions */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<Button
size="small"
variant="contained"
@@ -158,7 +186,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
>
Anfrage
</Button>
@@ -169,9 +197,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick}
disabled={a.disabled}
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
>
{a.label}
</Button>
@@ -26,7 +26,7 @@ export function MatchActionToolbar({ actions }: Props) {
onClick={action.onClick}
sx={
action.variant === 'primary'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }
? { bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }
: action.variant === 'danger'
? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } }
: {}
+99 -142
View File
@@ -1,23 +1,21 @@
import { memo } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
import { MapPin, AlertTriangle } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
vm: MatchCardViewModel
imageUrl?: string
overlayTypeLabel?: string
}
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState />
const { currentUser } = useSessionStore()
@@ -25,29 +23,47 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const scoreBadgeBg = SCORE_THEME[tier].gradient
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' }
const confPct = Math.round(vm.confidenceScore * 100)
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
const leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
// Compact metric line — area · rent · availability
const metricParts: string[] = []
if (vm.areaSqm) metricParts.push(`${vm.areaSqm.toLocaleString('de-CH')}`)
if (vm.rentPerSqm) metricParts.push(`CHF ${vm.rentPerSqm}/m²/J`)
if (vm.availabilityLabel) metricParts.push(vm.availabilityLabel)
const metricLine = metricParts.join(' · ')
// Detail action — prefer the "Details →" action if available, fallback to first action
const detailAction = vm.actions.find(a => a.actionType === 'OPEN_DETAIL' && a.label.includes('Detail'))
?? vm.actions.find(a => a.actionType === 'OPEN_DETAIL')
return (
<Box sx={{
display: 'flex',
mb: 1.5,
borderRadius: 2,
overflow: 'hidden',
border: `2px solid ${borderColor}`,
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
background: theme.cardBg,
border: `1px solid ${DS_BORDER.default}`,
borderLeft: `3px solid ${leftAccent}`,
bgcolor: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'box-shadow 0.15s',
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
transition: 'border-color 0.15s, transform 0.18s ease, box-shadow 0.18s ease',
position: 'relative',
zIndex: 0,
'&:hover': {
borderColor: '#b0aead',
transform: 'scale(1.012)',
boxShadow: '0 6px 20px rgba(0,0,0,0.10)',
zIndex: 1,
},
}}>
{/* ── Image strip ── */}
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
{/* ── Image strip — 180px, Ginesta proportion ── */}
<Box sx={{ width: 180, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
{imageUrl ? (
<img
src={imageUrl}
@@ -56,169 +72,106 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
/>
) : (
<Box sx={{
width: '100%', height: '100%', minHeight: 120,
width: '100%', height: '100%', minHeight: 140,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
bgcolor: '#f4f3f0',
}}>
<MapPin size={24} color="#7ba3bf" />
<MapPin size={24} color="#c4c4c4" />
</Box>
)}
{/* Score badge */}
{/* Score badge — top left */}
<Box sx={{
position: 'absolute', top: 8, left: 8,
background: theme.gradient, borderRadius: '8px',
px: 1, py: 0.25,
border: `1px solid ${theme.border}`,
boxShadow: `0 2px 8px ${theme.glow}`,
bgcolor: scoreBadgeBg, borderRadius: '6px',
px: 0.875, py: 0.25,
border: `1px solid ${SCORE_THEME[tier].border}`,
}}>
<Typography sx={{
fontWeight: 700, fontSize: '1.125rem',
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.01em',
}}>
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
{vm.matchScore}%
</Typography>
</Box>
{/* Asset type label */}
{overlayTypeLabel && (
{/* Location pill — Ginesta-style, top right */}
<Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0,
px: 0.75, py: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
position: 'absolute', top: 8, right: 8,
bgcolor: 'rgba(255,255,255,0.92)',
borderRadius: '20px',
px: 1, py: 0.35,
display: 'flex', alignItems: 'center', gap: 0.4,
backdropFilter: 'blur(4px)',
}}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
{overlayTypeLabel}
<MapPin size={9} color="#64748b" />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
{vm.locationLabel}
</Typography>
</Box>
)}
</Box>
{/* ── Content ── */}
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Box sx={{ flex: 1, px: 2, py: 2.5, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Alerts */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
)}
{/* Badges row */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
<HeatBadge propertyId={vm.propertyId} size="sm" />
{/* Chips — result type + heat + confidence */}
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', mb: 0.875 }}>
<Chip label={rt.label} size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
)}
<HeatBadge propertyId={vm.propertyId} size="sm" />
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18 }} />
)}
{vm.fitOutLabel && (
<Chip
label={vm.fitOutLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutViable ? '#059669' : '#94a3b8',
color: vm.fitOutViable ? '#059669' : '#64748b',
}}
/>
)}
{vm.fitOutInvestment && !vm.fitOutInvestment.isFullyCovered && (
<Chip
label={vm.fitOutInvestment.shortLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutInvestment.netTotal.max > 100000 ? '#d97706' : '#94a3b8',
color: vm.fitOutInvestment.netTotal.max > 100000 ? '#92400e' : '#64748b',
}}
/>
)}
{vm.isDivisible && vm.minDivisibleUnitSqm && (
<Chip
label={`Teilbar ab ${vm.minDivisibleUnitSqm}`}
size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: '#7c3aed', color: '#7c3aed' }}
/>
)}
{topRisk && (
<Chip
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
size="small" color="error" variant="outlined"
sx={{ fontSize: 10, height: 20 }}
/>
)}
</Box>
</Box>
{/* Title + location */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
{/* Title */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{vm.title}
</Typography>
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
<MapPin size={11} color="#64748b" />
<Typography variant="caption" color="text.secondary">
{vm.locationLabel}
</Typography>
</Box>
{/* Explainability summary */}
{/* Metric line — Ginesta-style */}
{metricLine && (
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8', mt: 0.5, letterSpacing: 0.1 }}>
{metricLine}
</Typography>
)}
{/* Explainability summary — 1 line */}
{vm.explainabilitySummary && (
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
<Typography variant="body2" sx={{
color: DS_TEXT.secondary, fontWeight: 500, mt: 0.875, lineHeight: 1.5,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical',
}}>
{vm.explainabilitySummary}
</Typography>
)}
<Divider sx={{ my: 1 }} />
{/* Score breakdown */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 1 }}>
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
</Box>
{/* Critical risk only */}
{topRisk && topRisk.level === 'CRITICAL' && (
<Alert icon={<AlertTriangle size={13} />} severity="error"
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
{topRisk.description}
</Alert>
)}
{/* Reasons — all 3, same style as grid card */}
{vm.reasons.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
{r.label}
</Typography>
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
{r.explanation}
</Typography>
</Box>
</Box>
))}
</Box>
)}
{/* Top tradeoff */}
{vm.tradeoffs.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
{vm.tradeoffs[0].concern}
</Typography>
</Box>
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Button size="small" variant="contained" onClick={e => {
{/* 2 Buttons — Anfrage + Details */}
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1.25 }}>
<Button
size="small"
variant="contained"
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: vm.title,
@@ -228,16 +181,20 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
>
Anfrage
</Button>
{vm.actions.map(a => (
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick} disabled={a.disabled}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
{a.label}
{detailAction && (
<Button
size="small"
variant="outlined"
onClick={detailAction.onClick}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
>
Details
</Button>
))}
)}
</Box>
</Box>
</Box>
@@ -4,7 +4,7 @@ import { MatchScoreDisplay } from './MatchScoreDisplay'
import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#152642' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
}
@@ -38,10 +38,10 @@ export function MatchCardHeader({ vm, compact }: Props) {
/>
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip
icon={<Building2 size={10} color="#1e3a5f" />}
icon={<Building2 size={10} color="#152642" />}
label="Ihr Objekt"
size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#1e3a5f', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }}
sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#152642', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }}
/>
)}
{vm.assetType && (
@@ -86,6 +86,10 @@ export interface MatchCardViewModel {
preMarketUnit?: PropertyUnit // specific unit being released pre-market
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
// Key metrics for compact display (Ginesta-style metric line)
areaSqm?: number
rentPerSqm?: number
// Score transparency
scoreBreakdown?: {
hardMatchScore: number
@@ -25,7 +25,7 @@ const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#152642' }
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
@@ -93,15 +93,15 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact =
return (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Hart-Kriterien
</Typography>
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.6, borderRadius: 1, mb: softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#152642' }}>
{sb.hardMatchScore}/100 × 60%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{hardContrib} Pkt</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#152642' }}>{hardContrib} Pkt</Typography>
</Box>
{softFactors.length > 0 && (
@@ -10,7 +10,7 @@ export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'L
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{c.dots.map((filled, i) => (
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e8e7e4' }} />
))}
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
{c.label}
@@ -74,7 +74,7 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
size="small"
sx={need.isAnonymous
? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' }
: { bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }
: { bgcolor: '#eff6ff', color: '#152642', fontSize: 11, height: 20, fontWeight: 500 }
}
/>
)}
@@ -5,7 +5,7 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
SHORTLISTED: { label: 'Shortlist', color: '#1e3a5f' },
SHORTLISTED: { label: 'Shortlist', color: '#152642' },
}
export function MatchStatusBadge({ status }: { status?: MatchStatus }) {
@@ -13,7 +13,7 @@ export function ExecutiveSummaryPanel({ match }: Props) {
const rows: { icon: React.ReactNode; label: string; text: string }[] = [
{
icon: <Target size={15} color="#1e3a5f" />,
icon: <Target size={15} color="#152642" />,
label: 'Gesamteignung',
text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`,
},
@@ -0,0 +1,82 @@
import { Box, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { Lightbulb, MessageSquareQuote } from 'lucide-react'
import { useFitOutAdvice } from '../../hooks/useAI'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const RECOMMENDATION_META: Record<string, { label: string; bg: string; border: string; fg: string }> = {
MIETERAUSBAU: { label: 'Mieterausbau', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
BKZ: { label: 'Baukostenzuschuss (BKZ)', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
MAB_AMORTISATION: { label: 'MAB-Amortisation', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
}
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
}
export function FitOutAdvicePanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, requiredFitOut, tenantBudgetPerSqm }: Props) {
const { data, isLoading, isError } = useFitOutAdvice({
fitOut,
areaSqm,
mabPerSqm,
requiredFitOut,
tenantBudgetPerSqm,
monthlyRentPerSqm: Math.round(rentPricePerSqm / 12),
})
// Fallback: KI-Ausfall blockiert nie — Panel wird einfach nicht gezeigt
if (isError) return null
if (isLoading || !data) {
return (
<Paper sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>KI-Ausbauempfehlung wird erstellt</Typography>
</Paper>
)
}
const advice = data.data
const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Lightbulb size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>KI-Ausbauempfehlung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.75 }}>{advice.headline}</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 1.5, lineHeight: 1.55 }}>
{advice.explanation}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.5, bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Geschätzte Nettoinvestition</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{advice.estimatedNetInvestment}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<MessageSquareQuote size={14} color={DS_TEXT.muted} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, lineHeight: 1.5 }}>
<strong>Verhandlungstipp:</strong> {advice.negotiationTip}
</Typography>
</Box>
{data.provenance?.fallbackUsed && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
Hinweis: KI nicht verfügbar Richtwert-basierte Empfehlung.
</Typography>
)}
</Paper>
)
}
+27 -46
View File
@@ -1,33 +1,23 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
fitOutByLandlord?: boolean
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
function chfRange(min: number, max: number): string {
if (Math.round(min) === Math.round(max)) return chf(min)
return `${chf(min)} ${chf(max)}`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
@@ -49,35 +39,26 @@ function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutPerYear = fitOutPerSqm * areaSqm
const totalPerYear = rentPerYear + fitOutPerYear
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
// Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
const hasSurcharge = fitOutPerYear > 0
const isWarning = totalPerYear > rentPerYear * 1.3
const disclaimer = hasSurcharge
? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
: null
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && (
{hasSurcharge && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{isReadyToMoveIn ? (
{fitOutByLandlord ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
/>
) : !hasSurcharge ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : (
<Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
value={chf(fitOutPerYear)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
`${fitOutLabel}` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} ÷ ${AMORTIZATION_YEARS} J.`
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
value={chf(totalPerYear)}
isTotal
isWarning={isWarning}
/>
@@ -6,7 +6,9 @@ import {
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { useCreateInquiry } from '../../hooks/useInquiries'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const currentUser = useSessionStore(s => s.currentUser)
const createInquiry = useCreateInquiry()
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
createInquiry.mutate({
organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa)
tenantOrgId: currentUser?.organizationId ?? 'org-mobimo',
propertyId: pendingInquiry.propertyId ?? 'unknown',
tenantName: currentUser?.name ?? 'Demand User',
tenantCompany: currentUser?.organizationName,
tenantEmail: currentUser?.email,
propertyAddress: pendingInquiry.propertyTitle,
subject: `Anfrage: ${pendingInquiry.propertyTitle}`,
message,
matchScore: pendingInquiry.matchScore,
})
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}
@@ -82,7 +96,7 @@ export function InquiryQuickDialog() {
size="small"
disabled={!message.trim()}
startIcon={<Send size={14} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
@@ -0,0 +1,70 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { TrendingUp } from 'lucide-react'
import { estimateMarketRent, type RentVerdict } from '../../lib/rentEstimate'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const VERDICT_META: Record<RentVerdict, { label: string; bg: string; border: string; fg: string }> = {
BELOW: { label: 'Unter Markt', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
AT: { label: 'Marktkonform', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
ABOVE: { label: 'Über Markt', bg: DS_SURFACE.warning.bg, border: DS_SURFACE.warning.border, fg: DS_TEXT.warning },
}
interface Props {
city: string
assetType: string
askingRentPerSqm: number
futureRentPerSqm?: number // Pre-Market: erwarteter künftiger Preis
}
function chf(v: number): string {
return `CHF ${Math.round(v).toLocaleString('de-CH')}/m²`
}
export function MarketPricePanel({ city, assetType, askingRentPerSqm, futureRentPerSqm }: Props) {
const est = estimateMarketRent(city, assetType, askingRentPerSqm)
if (!est) return null
const meta = VERDICT_META[est.verdict]
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Marktpreis-Einschätzung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 1.25 }}>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Angebotsmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>{chf(est.askingRentPerSqm)}</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Faire Marktmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{chf(est.fairRentPerSqm)}
<Box component="span" sx={{ ml: 0.75, fontSize: 12, fontWeight: 600, color: meta.fg }}>
{est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`}
</Box>
</Typography>
</Box>
</Box>
{futureRentPerSqm != null && futureRentPerSqm > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.25, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Erwartet (Pre-Market)</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{chf(est.askingRentPerSqm)} <Box component="span" sx={{ color: DS_TEXT.muted }}></Box> {chf(futureRentPerSqm)}
</Typography>
</Box>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
{est.rationale}
</Typography>
</Paper>
)
}
@@ -6,7 +6,7 @@ import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#152642' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
@@ -101,7 +101,7 @@ export function MatchDetailHeader({ match, property, signal, onBack, onCompare,
size="small"
startIcon={<Columns2 size={14} />}
onClick={onCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Vergleichen
</Button>
@@ -46,7 +46,7 @@ export function MatchDetailHero({
{/* Hero: image first, map fallback */}
{!isFuture && (
property?.images?.[0] ? (
<Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e2e8f0', flexShrink: 0 }}>
<Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e8e7e4', flexShrink: 0 }}>
<img
src={property.images[0]}
alt={property.title}
@@ -105,7 +105,7 @@ export function MatchDetailHero({
size="small"
startIcon={<Columns2 size={14} />}
onClick={onCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
Vergleichen
</Button>
@@ -28,7 +28,7 @@ export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Pr
variant="contained"
startIcon={<MessageSquare size={14} />}
onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ justifyContent: 'flex-start', bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
+2
View File
@@ -13,4 +13,6 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { MarketPricePanel } from './MarketPricePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -20,7 +20,7 @@ export function factorLabel(criterion: string): string {
// Convert normalised weight to 1-5 importance level relative to other factors in the same set
export function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#152642' }
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
@@ -1,11 +1,16 @@
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC'])
interface Props {
floor: string
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
fitOutByLandlord: boolean | undefined
onFitOutByLandlordChange: (v: boolean) => void
parking: string
onParkingChange: (v: string) => void
ceilingHeight: string
@@ -21,12 +26,14 @@ interface Props {
export function TechnicalDetailsSection({
floor, onFloorChange,
fitOut, onFitOutChange,
fitOutByLandlord, onFitOutByLandlordChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) {
const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
@@ -44,14 +51,6 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField
label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)}
@@ -64,6 +63,46 @@ export function TechnicalDetailsSection({
/>
</Box>
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
{showFitOutResponsibility && (
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive
size="small"
value={fitOutByLandlord === undefined ? null : fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{fitOutByLandlord === undefined ? (
<Typography variant="caption" sx={{ flex: 1, minWidth: 220, mt: 0.5, color: '#b45309' }}>
Pflichtangabe bei Rohbau/Edelrohbau bitte wählen, wer den Ausbau trägt.
</Typography>
) : fitOutByLandlord ? (
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
Ausbau im Mietzins enthalten bitte die <strong>bezugsfertige</strong> Miete eintragen.
Es wird kein Aufschlag berechnet.
</Typography>
) : (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" sx={{ width: 240 }}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters — optional, leer = kein Beitrag"
/>
)}
</Box>
</Box>
)}
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
+2 -2
View File
@@ -41,7 +41,7 @@ function Slide0() {
return (
<Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4">
<Building2 size={48} color="#1e3a5f" />
<Building2 size={48} color="#152642" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Willkommen bei Property Match
@@ -74,7 +74,7 @@ function Slide1() {
return (
<Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4">
<Search size={48} color="#1e3a5f" />
<Search size={48} color="#152642" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Bedarf beschreiben KI findet Matches
+1 -1
View File
@@ -18,7 +18,7 @@ export function DataCategoryBadgeList({ categories, max }: DataCategoryBadgeList
label={cat}
sx={{
bgcolor: 'rgba(30,58,95,0.07)',
color: '#1e3a5f',
color: '#152642',
border: 'none',
fontSize: '0.68rem',
}}
@@ -156,7 +156,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps
label={entity.value}
sx={{
bgcolor: 'rgba(30,58,95,0.08)',
color: '#1e3a5f',
color: '#152642',
border: 'none',
fontSize: '0.75rem',
cursor: 'default',
+2 -2
View File
@@ -48,10 +48,10 @@ export function MarketSignalFilterBar({ filters, onChange }: MarketSignalFilterB
sx={{
fontSize: '0.65rem',
height: 20,
bgcolor: active ? '#1e3a5f' : 'transparent',
bgcolor: active ? '#152642' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
borderColor: active ? '#152642' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
+1 -1
View File
@@ -50,7 +50,7 @@ export function ReviewGatePanel({ gate }: ReviewGatePanelProps) {
{reviewerNotes.length > 0 && (
<Box sx={{ mt: 1, p: 1, bgcolor: 'rgba(30,58,95,0.04)', borderRadius: 1, border: '1px solid rgba(30,58,95,0.1)' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f', display: 'block', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#152642', display: 'block', mb: 0.5 }}>
Reviewer-Informationen
</Typography>
{reviewerNotes.map((note, i) => (
+1 -1
View File
@@ -50,7 +50,7 @@ export function SignalConversionPanel({ signal }: SignalConversionPanelProps) {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<ArrowRight size={16} color="#1e3a5f" />
<ArrowRight size={16} color="#152642" />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
In Future Availability überführen
</Typography>
+1 -1
View File
@@ -43,7 +43,7 @@ export function SignalInbox({
<Chip
label={isLoading ? '…' : signals.length}
size="small"
sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }}
sx={{ bgcolor: '#152642', color: '#fff', fontSize: '0.7rem', height: 20 }}
/>
</Box>
+4 -4
View File
@@ -16,14 +16,14 @@ export function SignalPipelineStepper({ pipelineState }: SignalPipelineStepperPr
sx={{
mb: 1,
'& .MuiStepIcon-root': { fontSize: '1.6rem', color: '#cbd5e1' },
'& .MuiStepIcon-root.Mui-active': { color: '#1e3a5f' },
'& .MuiStepIcon-root.Mui-active': { color: '#152642' },
'& .MuiStepIcon-root.Mui-completed': { color: '#15803d' },
'& .MuiStepLabel-label': { fontSize: '0.68rem', mt: 0.5, color: '#64748b' },
'& .MuiStepLabel-label.Mui-active': { color: '#1e3a5f', fontWeight: 700 },
'& .MuiStepLabel-label.Mui-active': { color: '#152642', fontWeight: 700 },
'& .MuiStepLabel-label.Mui-completed': { color: '#15803d' },
'& .MuiStepConnector-line': { borderTopWidth: 2, borderColor: '#e2e8f0' },
'& .MuiStepConnector-line': { borderTopWidth: 2, borderColor: '#e8e7e4' },
'& .MuiStepConnector-root.Mui-completed .MuiStepConnector-line': { borderColor: '#15803d' },
'& .MuiStepConnector-root.Mui-active .MuiStepConnector-line': { borderColor: '#1e3a5f' },
'& .MuiStepConnector-root.Mui-active .MuiStepConnector-line': { borderColor: '#152642' },
}}
>
{PIPELINE_STAGE_ORDER.map((stage) => (
+1 -1
View File
@@ -123,7 +123,7 @@ export function SignalPipelineView({ signal }: SignalPipelineViewProps) {
sx={{
textTransform: 'none',
fontSize: '0.8rem',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
'&:hover': { bgcolor: '#162d4a' },
}}
>
@@ -10,7 +10,7 @@ function getStageColor(entry: AuditTrailEntry): string {
const idx = PIPELINE_STAGE_ORDER.indexOf(entry.stage)
if (idx <= 0) return '#94a3b8'
if (idx >= 5) return '#15803d'
if (idx >= 3) return '#1e3a5f'
if (idx >= 3) return '#152642'
if (idx >= 2) return '#4f46e5'
return '#2563eb'
}

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