72 Commits

Author SHA1 Message Date
Benjamin Sutter 362514681a feat(demand): Ergebnis-Grid — Spalten-Umschalter, sauberere Karten, mehr Abstand
- 3/5/10-Spalten-Umschalter im Ergebnis-Header (nur Grid-Ansicht, in localStorage gemerkt) analog zu "Meine Objekte"
- Karten-Bild auf aspectRatio 15/8 statt fester Höhe → keine Verzerrung bei beliebiger Spaltenzahl
- Orts-Overlay vom Bild entfernt → Match-Score überdeckt das Ort-Label nicht mehr
- Sidebar bleibt bis < lg (1200px) ausgeklappt → Navigation + Account auf Laptops sichtbar
- Größere vertikale Abstände im Ergebnis-Feed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:28:42 +02:00
300 changed files with 12469 additions and 3915 deletions
+19 -1
View File
@@ -11,7 +11,25 @@
"Bash(start http://localhost:5173)",
"Bash(npm install *)",
"Bash(git pull *)",
"Bash(node scripts/check-tokens.js)"
"Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")",
"Bash(Get-ChildItem \"c:\\\\Users\\\\beni_\\\\AppData\\\\Local\\\\Temp\\\\docx-gen\\\\node_modules\\\\docx\" -Recurse -Name)",
"Bash(start \"C:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\Aenderungsprotokoll_Meeting_26052026.docx\")",
"Bash(start http://localhost:5176)",
"Bash(start http://localhost:5176/demand/ai-search)",
"Bash(node -e ' *)",
"Skill(run)",
"Skill(run:*)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173)",
"Bash(powershell.exe -Command \"Start-Process 'http://localhost:5173'\")",
"Bash(findstr \"LISTENING\")",
"Bash(findstr \":517\")",
"Bash(findstr \"LISTEN\")",
"Bash(findstr \":5\")",
"Bash(echo \"Exit: $?\")",
"Bash(npx vercel *)",
"Bash(Start-Process \"http://localhost:5180\")"
]
}
}
+20 -1
View File
@@ -2,7 +2,26 @@
"permissions": {
"allow": [
"Bash(git commit -m ' *)",
"Bash(git commit *)"
"Bash(git commit *)",
"Bash(node -e ' *)",
"Bash(git stash *)",
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)",
"Bash(pandoc --version)",
"Bash(npm list *)",
"Read(//c/Program Files/LibreOffice/program/**)",
"Read(//c/Program Files/Microsoft Office/**)",
"Read(//c/Program Files \\(x86\\)/**)",
"Bash(mkdir -p /tmp/docx-gen)",
"Read(//tmp/**)",
"Bash(npm init *)",
"Bash(node generate.js)",
"Bash(timeout 8 bash -c \"until curl -s http://localhost:5173 > /dev/null; do sleep 1; done\")",
"Bash(dir \"c:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\src\" -Depth 2)",
"Bash(start \"\" \"http://localhost:5178/demand/results\")",
"Bash(start \"\" \"http://localhost:5178/supply/properties\")"
],
"additionalDirectories": [
"c:\\Users\\beni_\\AppData\\Local\\Temp\\docx-gen"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
> Why do I have a folder named ".vercel" in my project?
The ".vercel" folder is created when you link a directory to a Vercel project.
> What does the "project.json" file contain?
The "project.json" file contains:
- The ID of the Vercel project that you linked ("projectId")
- The ID of the user or team your Vercel project is owned by ("orgId")
> Should I commit the ".vercel" folder?
No, you should not share the ".vercel" folder with anyone.
Upon creation, it will be automatically added to your ".gitignore" file.
+1
View File
@@ -0,0 +1 @@
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
Binary file not shown.
+48
View File
@@ -41,6 +41,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
@@ -4330,6 +4331,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+1
View File
@@ -47,6 +47,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
+1 -3
View File
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings'))
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
function App() {
return (
@@ -62,7 +61,6 @@ function App() {
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/market-leads" element={<MarketLeads />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} />
@@ -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>
@@ -0,0 +1,146 @@
import { Box, Button, Checkbox, Paper, Typography } from '@mui/material'
import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils'
interface EmbeddedPropertyCardProps {
property: Property
matchScore: number
selected: boolean
onToggle: () => void
reason: string
onQuickOffer: () => void
disabled?: boolean
}
export function EmbeddedPropertyCard({
property,
matchScore,
selected,
onToggle,
reason,
onQuickOffer,
disabled,
}: EmbeddedPropertyCardProps) {
const tier = getScoreTier(matchScore)
const theme = SCORE_THEME[tier]
const image = property.images?.[0]
return (
<Paper
elevation={0}
sx={{
borderRadius: 2,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f8fafc' : 'white',
overflow: 'hidden',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 8px rgba(15,23,42,0.07)' },
}}
>
{/* Info row */}
<Box sx={{ display: 'flex', gap: 2, p: 2 }}>
<Box
sx={{
width: 72,
height: 72,
flexShrink: 0,
borderRadius: 1.5,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!image && <Building2 size={24} color="#94a3b8" />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
{property.title}
</Typography>
<Box
sx={{
background: theme.gradient,
color: theme.text,
px: 1.25,
py: 0.375,
borderRadius: 1,
fontSize: '0.9rem',
fontWeight: 700,
flexShrink: 0,
border: `1px solid ${theme.border}`,
}}
>
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
{reason}
</Typography>
</Box>
</Box>
{/* Action row */}
<Box
sx={{
borderTop: '1px solid #f1f5f9',
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#fafafa',
}}
>
<Box
onClick={!disabled ? onToggle : undefined}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: disabled ? 'default' : 'pointer' }}
>
<Checkbox
checked={selected}
onChange={onToggle}
onClick={e => e.stopPropagation()}
size="small"
disabled={disabled}
sx={{ p: 0.5 }}
/>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
Für Angebot auswählen
</Typography>
</Box>
<Button
size="small"
variant="outlined"
onClick={onQuickOffer}
disabled={disabled}
sx={{
textTransform: 'none',
fontSize: '0.775rem',
fontWeight: 600,
borderColor: '#152642',
color: '#152642',
py: 0.5,
px: 1.5,
minWidth: 0,
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
}}
>
Angebot
</Button>
</Box>
</Paper>
)
}
+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 && (
@@ -123,7 +146,6 @@ export function InquiryReplyComposer({
display: 'flex',
alignItems: 'center',
gap: 0.75,
bgcolor: 'white',
border: '1px solid',
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
borderRadius: 1,
@@ -150,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"
@@ -167,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>
@@ -5,7 +5,6 @@ import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
import { EmptyState } from '../ui'
import { PublicNeedList } from './PublicNeedList'
import { PublicNeedDetail } from './PublicNeedDetail'
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
type MobileView = 'list' | 'detail' | 'properties'
@@ -47,7 +46,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>
@@ -112,7 +111,6 @@ export function LatentInquiriesTab() {
</Box>
)}
</Box>
{selectedNeed && <OwnPropertyMatchList need={selectedNeed} />}
</Box>
)
}
@@ -1,18 +1,86 @@
import { Box, Typography } from '@mui/material'
import { FileText } from 'lucide-react'
import { Box, Divider, Typography } from '@mui/material'
import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { mockProperties } from '../../mock-data/properties'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
interface MockPdfPreviewProps {
fields: Record<string, string>
fallbackFields: Record<string, string>
fieldSelections: ReportObjectFieldSelection[]
}
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Asset Type',
floor: 'Stockwerk',
parking: 'Parkplätze',
fitOut: 'Ausbaugrad',
isBarrierFree: 'Barrierefrei',
ceilingHeightM: 'Raumhöhe',
floorLoad: 'Bodenlast',
loadingDocksCount: 'Anlieferung',
goodsLift: 'Warenaufzug',
passengerLift: 'Personenaufzug',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
internet: 'Internet',
deliveryAccess: 'Zufahrt',
publicTransportScore: 'ÖV-Anbindung',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
footfallScore: 'Passantenfrequenz',
commuterAccessScore: 'Pendlererreichbarkeit',
talentAccessScore: 'Talent Access',
esgScore: 'ESG',
flexibilityScore: 'Flexibilität',
expansionPotentialScore: 'Expansionspotenzial',
taxEnvironmentScore: 'Steuerumfeld',
microLocation: 'Mikrostandort',
competitionEnvironment: 'Konkurrenzumfeld',
infrastructure: 'Infrastruktur',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
const needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
fontFamily: '"Georgia", "Times New Roman", serif',
}}
>
{/* ── Page 1 — Offer letter ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{v('closing')}
</Typography>
{/* ── Page 2 — Property details side by side ── */}
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
gap: 1.5,
}}
>
{properties.map(p => {
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = p.images?.[0]
return (
<Box
key={p.id}
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Photo */}
{image ? (
<Box
component="img"
src={image}
alt={p.title}
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={22} color="#94a3b8" />
</Box>
)}
{/* Info */}
<Box sx={{ p: 1.25, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
{p.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
<MapPin size={10} color="#94a3b8" />
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
</Box>
{/* Selected fields */}
{optionalFields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{optionalFields.map(key => {
const val = getFieldValue(p, key)
if (!val) return null
return (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
</Box>
</>
)}
</Box>
)
}
@@ -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' },
}}
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
@@ -187,7 +187,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
size="small"
value={a.date}
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
InputLabelProps={{ shrink: true }}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: 160 }}
/>
<TextField
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
progress={progress}
ready={ready}
draft={draft}
property={property}
property={property ?? undefined}
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
onAttach={() => {
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
@@ -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>
@@ -0,0 +1,87 @@
import { useEffect } from 'react'
import { Box, Button, Stack, Typography } from '@mui/material'
import { ArrowLeft } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useProperties } from '../../hooks/useProperties'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function OfferFieldSelectionStep() {
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
const setStep = useOfferWizardStore(s => s.setStep)
const { data: allProperties = [] } = useProperties()
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
// Initialise selections when properties are loaded or change
useEffect(() => {
if (selectedProperties.length === 0) return
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
const missing = selectedProperties.filter(p => !existing.has(p.id))
if (missing.length > 0) {
setFieldSelections([
...fieldSelections,
...missing.map(p => ({
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
})),
])
}
// run only when property list or selections change length
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProperties.length, fieldSelections.length])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
</Typography>
<Stack spacing={2}>
{selectedProperties.map(p => {
const selection: ReportObjectFieldSelection =
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
}
return (
<ReportObjectFieldSelector
key={p.id}
propertyTitle={p.title}
value={selection}
onChange={v => updateFieldSelection(p.id, v)}
/>
)
})}
</Stack>
</Box>
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button
startIcon={<ArrowLeft size={14} />}
onClick={() => setStep('select_properties')}
sx={{ textTransform: 'none' }}
>
Zurück
</Button>
<Button
variant="contained"
onClick={() => setStep('pdf_review')}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
}
}
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const needTitle = useOfferWizardStore(s => s.needTitle)
const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
</Typography>
</Box>
) : (
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
)}
</Box>
@@ -177,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>
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
return
}
setOfferDraftId(res.data.id)
setStep('pdf_review')
setStep('select_fields')
}
if (!need) {
@@ -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>
+11 -5
View File
@@ -12,22 +12,25 @@ import {
import { X } from 'lucide-react'
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer'
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
// 'checked' is internal — merged into pdf_review step; 4 visible steps
const STEPS: { key: OfferStep; label: string }[] = [
{ key: 'select_properties', label: 'Objekte wählen' },
{ key: 'select_fields', label: 'Felder wählen' },
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
{ key: 'send', label: 'Senden' },
]
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
select_properties: 0,
pdf_review: 1,
checked: 1, // same visual step as pdf_review
send: 2,
select_fields: 1,
pdf_review: 2,
checked: 2, // same visual step as pdf_review
send: 3,
}
export function OfferWizard() {
@@ -48,13 +51,15 @@ export function OfferWizard() {
fullScreen={fullScreen}
maxWidth="lg"
fullWidth
PaperProps={{
slotProps={{
paper: {
sx: {
height: fullScreen ? '100vh' : '85vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
},
}}
>
{/* Header */}
@@ -112,6 +117,7 @@ export function OfferWizard() {
{/* Step content */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
{currentStep === 'checked' && <OfferCheckedAction />}
{currentStep === 'send' && <OfferChatComposer />}
@@ -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',
@@ -6,7 +6,6 @@ import {
} from '@mui/material'
import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
import { useToastStore } from '../../stores/toastStore'
@@ -40,10 +39,6 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const selectedProperties = draft?.selectedPropertyIds
.map(id => allProperties.find(p => p.id === id))
.filter((p): p is Property => !!p) ?? []
const toggleProperty = (id: string) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
@@ -164,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 }}>
@@ -239,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>
@@ -276,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>
@@ -287,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>
@@ -1,5 +1,5 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin, Ruler } from 'lucide-react'
import { MapPin } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
@@ -9,6 +9,12 @@ interface PublicNeedCardProps {
onClick: () => void
}
function opportunityColor(status: 'public' | 'paused' | 'expired'): string {
if (status === 'public') return '#16a34a'
if (status === 'paused') return '#d97706'
return '#94a3b8'
}
export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {
const statusCfg = latentStatusBadge(need.status)
return (
@@ -19,12 +25,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',
@@ -36,18 +42,15 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
{need.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: opportunityColor(need.status) }} />
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.65rem',
height: 20,
}}
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
/>
</Box>
</Box>
{need.tenantCompany && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
@@ -74,12 +77,6 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
{need.desiredLocation}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
<Ruler size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
</Paper>
)
}
+154 -158
View File
@@ -1,206 +1,159 @@
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
import { CheckCircle2, Calendar, MapPin, Wallet, Info, Ruler, Sparkles } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
Accordion, AccordionDetails, AccordionSummary,
Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Typography,
} from '@mui/material'
import { Calendar, CheckCircle2, ChevronDown, Info, MapPin, Ruler, Sparkles, Target, Wallet } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { ResultType, UserRole } from '../../domain/enums'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSessionStore } from '../../stores/sessionStore'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
import { UserRole } from '../../domain/enums'
import { useProperties } from '../../hooks/useProperties'
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
interface PublicNeedDetailProps {
need: LatentNeed
}
function buildReason(propType: string, needType: string, city: string, location: string): string {
if (propType === needType) {
const match = location.toLowerCase().includes(city.toLowerCase()) || city.toLowerCase().includes(location.toLowerCase())
return match ? 'Nutzungstyp und Standort passen sehr gut' : 'Passender Nutzungstyp, alternative Lage'
}
return 'Alternatives Profil — Detailprüfung empfohlen'
}
export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
const openWizard = useOfferWizardStore(s => s.open)
const setSelectedProperties = useOfferWizardStore(s => s.setSelectedProperties)
const selectedIds = useOfferWizardStore(s => s.selectedPropertyIds)
const toggleProperty = useOfferWizardStore(s => s.toggleProperty)
const currentUser = useSessionStore(s => s.currentUser)
const statusCfg = latentStatusBadge(need.status)
const [showAll, setShowAll] = useState(false)
const { data: properties = [], isLoading: propertiesLoading } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const disabled = currentUser?.role === UserRole.OWNER_VIEWER || need.status !== 'public'
const scored = useMemo(
() =>
properties
.map(p => ({
property: p,
score: deterministicMatchScore(p.id, need.id),
reason: buildReason(p.assetType, need.assetType, p.location.city, need.desiredLocation),
}))
.sort((a, b) => b.score - a.score),
[properties, need],
)
const visible = showAll ? scored : scored.slice(0, 3)
const remaining = scored.length - 3
function handleQuickOffer(propertyId: string) {
setSelectedProperties([propertyId])
openWizard(need.id, need.title)
}
return (
<Box
sx={{
flex: 1,
overflowY: 'auto',
bgcolor: '#f8fafc',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
{/* Scrollable content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Header */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip
label={assetTypeLabel(need.assetType)}
size="small"
sx={{
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Chip
label={statusCfg.label}
size="small"
sx={{
bgcolor: statusCfg.bg,
color: statusCfg.fg,
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
}}
/>
<Chip label={assetTypeLabel(need.assetType)} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
{need.title}
</Typography>
{need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}>
{need.tenantCompany}
</Typography>
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
)}
</Box>
{/* AI Summary */}
{/* KI-Entscheidungsbrief */}
{need.aiSummary && (
<Alert
icon={<Sparkles size={16} />}
severity="info"
sx={{
bgcolor: '#eff6ff',
borderRadius: 1.5,
border: '1px solid #bfdbfe',
'& .MuiAlert-icon': { color: '#1d4ed8' },
'& .MuiAlert-message': { color: '#1e3a8a', fontSize: '0.875rem' },
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1d4ed8', display: 'block', mb: 0.5 }}>
KI-Analyse
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Sparkles size={16} color="#1d4ed8" />
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
KI-Entscheidungsbrief
</Typography>
</Box>
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
{need.aiSummary}
</Alert>
</Typography>
</Paper>
)}
{/* Suchprofil grid */}
{/* Suchprofil */}
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Suchprofil
</Typography>
<Box
sx={{
mt: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 1.5,
}}
>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
<ProfileBox icon={<MapPin size={14} />} label="Standort" value={need.desiredLocation} />
<ProfileBox
icon={<Ruler size={14} />}
label="Fläche"
value={`${need.sizeRange.min}${need.sizeRange.max}`}
/>
<ProfileBox icon={<Ruler size={14} />} label="Fläche" value={`${need.sizeRange.min}${need.sizeRange.max}`} />
<ProfileBox
icon={<Wallet size={14} />}
label="Budget"
value={
need.budgetRange
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${
need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'
} /m²`
: 'Flexibel'
}
value={need.budgetRange
? `${need.budgetRange.min ? `CHF ${need.budgetRange.min}` : 'flex'}${need.budgetRange.max ? `CHF ${need.budgetRange.max}` : 'flex'} /m²`
: 'Flexibel'}
/>
<ProfileBox icon={<Calendar size={14} />} label="Timing" value={need.timing} />
</Box>
</Box>
{/* Must-have Kriterien */}
{/* Must-haves */}
{need.mustHaveCriteria.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Must-have Kriterien
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>
{c}
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Gewichtete Präferenzen */}
{/* Gewichtete Präferenzen — Accordion */}
{need.weightedPreferences.length > 0 && (
<Box>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}
>
<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>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100)
return (
<Box
key={idx}
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{p.criterion}
</Typography>
<Typography
variant="caption"
sx={{
fontWeight: 700,
color: '#1e3a5f',
fontSize: '0.75rem',
bgcolor: '#e0e7ff',
px: 1,
py: 0.125,
borderRadius: 1,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box
sx={{
height: 6,
borderRadius: 3,
bgcolor: '#e2e8f0',
overflow: 'hidden',
}}
>
<Box
sx={{
width: `${pct}%`,
height: '100%',
bgcolor: '#1e3a5f',
transition: 'width 0.3s',
}}
/>
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
</Box>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
@@ -211,57 +164,100 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
)
})}
</Box>
</Box>
</AccordionDetails>
</Accordion>
)}
{/* Top Empfehlungen */}
<Box>
<Divider sx={{ mb: 2.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Target size={14} color="#152642" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Ihre Top Empfehlungen
</Typography>
</Box>
{propertiesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
) : scored.length === 0 ? (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
Keine Portfolio-Objekte vorhanden
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{visible.map(({ property, score, reason }) => (
<EmbeddedPropertyCard
key={property.id}
property={property}
matchScore={score}
selected={selectedIds.includes(property.id)}
onToggle={() => toggleProperty(property.id)}
reason={reason}
onQuickOffer={() => handleQuickOffer(property.id)}
disabled={disabled}
/>
))}
{remaining > 0 && (
<Box
onClick={() => setShowAll(s => !s)}
sx={{
py: 1.25,
textAlign: 'center',
cursor: 'pointer',
color: '#64748b',
fontSize: '0.8rem',
fontWeight: 500,
border: '1px dashed #e2e8f0',
borderRadius: 1.5,
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
transition: 'all 0.15s',
}}
>
{showAll ? '▲ Weniger anzeigen' : `▼ Alle ${scored.length} Objekte anzeigen (+${remaining} weitere)`}
</Box>
)}
</Box>
)}
</Box>
</Box>
{/* Sticky footer CTA */}
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white' }}>
{disabled && need.status !== 'public' && (
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem' }}>
<Alert severity="warning" icon={<Info size={16} />} sx={{ fontSize: '0.8125rem', mb: 1.5 }}>
Dieser Bedarf ist aktuell {statusCfg.label.toLowerCase()} kein Angebot möglich.
</Alert>
)}
<Box sx={{ mt: 'auto', pt: 1 }}>
{selectedIds.length > 0 && !disabled && (
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
</Typography>
)}
<Button
variant="contained"
size="large"
fullWidth
onClick={() => openWizard(need.id, need.title)}
disabled={disabled}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
>
Angebot erstellen
</Button>
</Box>
</Box>
</Box>
)
}
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box
sx={{
bgcolor: 'white',
border: '1px solid #e2e8f0',
borderRadius: 1.5,
px: 1.5,
py: 1,
}}
>
<Box sx={{ 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>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
@@ -16,8 +16,8 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
return (
<Box
sx={{
width: fullWidth ? '100%' : 280,
minWidth: fullWidth ? 'unset' : 280,
width: fullWidth ? '100%' : 260,
minWidth: fullWidth ? 'unset' : 260,
flexShrink: 0,
borderRight: fullWidth ? 'none' : '1px solid #e2e8f0',
bgcolor: 'white',
@@ -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>
)
}
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
},
]
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
const isSelected = (key: ReportObjectFieldKey) =>
value.selectedOptionalFields.includes(key)
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
{FIELD_GROUPS.map(group => {
const groupKeys = group.fields.map(f => f.key)
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
return (
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
@@ -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',
+1
View File
@@ -15,6 +15,7 @@ export { PublicNeedCard } from './PublicNeedCard'
export { PublicNeedDetail } from './PublicNeedDetail'
export { OwnPropertyMatchList } from './OwnPropertyMatchList'
export { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
export { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
export { OfferCreationPanel } from './OfferCreationPanel'
export { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
export { OfferPdfReviewStep } from './OfferPdfReviewStep'
@@ -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 -2
View File
@@ -1,5 +1,5 @@
import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants'
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe,
MAISON_WORK: Building2,
FUTURE_AVAILABILITY: Sparkles,
}
+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 -2
View File
@@ -9,8 +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 },
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
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>
@@ -0,0 +1,24 @@
import { memo } from 'react'
import type { ReactNode } from 'react'
import { TableCell, TableRow } from '@mui/material'
import { LABEL_SX, DATA_SX } from './compareUtils'
// ── Props ─────────────────────────────────────────────────────────────────────
export interface CompareMetricRowProps {
label: string
cells: ReactNode[]
}
// ── Component ─────────────────────────────────────────────────────────────────
export const CompareMetricRow = memo(function CompareMetricRow({ label, cells }: CompareMetricRowProps) {
return (
<TableRow hover>
<TableCell sx={LABEL_SX}>{label}</TableCell>
{cells.map((cell, i) => (
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
))}
</TableRow>
)
})
+47 -6
View File
@@ -20,6 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
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 ──────────────────────────────────────────────────────────────
@@ -148,18 +150,57 @@ export function CompareTableBody({
: <MissingDataCell />
}))}
{/* 9. Rent / Budget Fit */}
{row('9. Miete / Budget', compareItems.map(item => {
{/* 9. Rent / Budget Fit — full cost breakdown incl. amortised fit-out */}
{row('9. Kosten / Budget', compareItems.map(item => {
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
const monthlyNebenkosten = prop.ancillaryCosts != null
? Math.round(prop.ancillaryCosts * prop.areaSqm)
: null
const fitOut = prop.hardFacts?.fitOut
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// 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)
+ fitOutMonthly
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
CHF {prop.rentPricePerSqm}/m²/Jahr
</Typography>
{prop.totalRentMonthly && (
<Typography variant="caption" color="text.secondary">
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
{monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete)
</Typography>
{monthlyNebenkosten != null && (
<Typography variant="caption" color="text.secondary">
+ {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK)
</Typography>
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography>
)}
<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}{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,
+41 -3
View File
@@ -1,4 +1,4 @@
import { Alert, Box, Button, CircularProgress } from '@mui/material'
import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, Typography } from '@mui/material'
import { Save } from 'lucide-react'
import { NeedCardPreview } from './NeedCardPreview'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
@@ -10,7 +10,9 @@ interface Props {
needTitle: string
overallConfidence: number
isSaving: boolean
isAnonymous: boolean
onNeedTitleChange: (t: string) => void
onAnonymousChange: (v: boolean) => void
onBack: () => void
onSave: () => void
}
@@ -22,7 +24,9 @@ export function AISearchSavePreview({
needTitle,
overallConfidence,
isSaving,
isAnonymous,
onNeedTitleChange,
onAnonymousChange,
onBack,
onSave,
}: Props) {
@@ -39,7 +43,41 @@ export function AISearchSavePreview({
needTitle={needTitle}
onNeedTitleChange={onNeedTitleChange}
/>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
{/* Anonymity option */}
<Box sx={{
maxWidth: 720, mx: 'auto', mt: 2,
p: 1.5, borderRadius: 1.5, border: '1px solid',
borderColor: isAnonymous ? '#7c3aed' : '#e8e7e4',
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
transition: 'all 0.15s',
}}>
<FormControlLabel
control={
<Switch
size="small"
checked={isAnonymous}
onChange={e => onAnonymousChange(e.target.checked)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' },
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: isAnonymous ? '#6d28d9' : 'text.primary' }}>
Anonymes Suchprofil
</Typography>
<Typography variant="caption" sx={{ color: isAnonymous ? '#7c3aed' : 'text.secondary' }}>
Firmenname wird nicht an Vermieter übermittelt Verwalter sehen nur Branche und Flächenbedarf
</Typography>
</Box>
}
/>
</Box>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
Zurück
</Button>
@@ -48,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…'
+24 -5
View File
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
inq: Inquiry
isSelected: boolean
hasPipeline: boolean
perspective?: 'demand' | 'supply'
onSelect: (id: string) => void
}
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger
const primaryTitle = perspective === 'demand'
? (inq.propertyAddress ?? inq.subject)
: inq.tenantName
const secondaryLine = perspective === 'demand'
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
: (inq.tenantCompany ?? '')
const lastMsgPrefix = (() => {
if (!lastMsg) return ''
if (perspective === 'demand') {
return lastMsg.senderType === 'tenant'
? 'Sie: '
: `${inq.propertyManagerName ?? 'Verwalter'}: `
}
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
})()
return (
<Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5,
@@ -31,7 +50,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName}
{primaryTitle}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
@@ -43,9 +62,9 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box>
</Box>
{inq.tenantCompany && (
{secondaryLine && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{inq.tenantCompany}
{secondaryLine}
</Typography>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
@@ -53,7 +72,7 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
</Typography>
{lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
{lastMsgPrefix}
{lastMsg.body.split('\n')[0]}
</Typography>
)}
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
const isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time}
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
</Typography>
</Box>
<Box
@@ -10,7 +10,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
}
+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>
+4 -4
View File
@@ -15,7 +15,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
}
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
@@ -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>
@@ -1,4 +1,4 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
@@ -73,7 +73,48 @@ 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
size="small" type="number" fullWidth placeholder="z.B. 200"
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
/>
</Box>
)}
</Box>
{/* Divisibility */}
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid #f1f5f9' }}>
<FormControlLabel
control={
<Switch
size="small"
checked={c.requiresDivisibility ?? false}
onChange={e => set({ ...c, requiresDivisibility: e.target.checked || undefined })}
/>
}
label={<Typography variant="caption" sx={{ fontWeight: 600 }}>Fläche muss teilbar sein</Typography>}
sx={{ mb: c.requiresDivisibility ? 1 : 0 }}
/>
{c.requiresDivisibility && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindesteinheit (m²)</Typography>
<TextField
size="small" type="number"
placeholder="z.B. 150"
value={c.minDivisibleUnit ?? ''}
onChange={e => set({ ...c, minDivisibleUnit: parseInt(e.target.value) || undefined })}
sx={{ width: 160 }}
slotProps={{ htmlInput: { min: 10, max: 5000, step: 10 } }}
/>
</Box>
)}
</Box>
</AccordionDetails>
</Accordion>
)
+29 -7
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Box, Card, Chip, Stack, 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'
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
{ label: 'Retail', value: AssetType.RETAIL },
{ label: 'Logistik', value: AssetType.LOGISTICS },
{ label: 'Produktion', value: AssetType.PRODUCTION },
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gemischt', value: AssetType.MIXED },
]
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
const RADIUS_MARKS = [5, 10, 20, 30, 50, 100].map(v => ({ value: v, label: v === 5 || v === 100 ? `${v} km` : `${v}` }))
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
@@ -79,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' } }
: {}}
/>
))}
@@ -137,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' }
}
/>
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
))}
</Stack>
)}
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
{(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) === 0 && <Box sx={{ mb: 1.5 }} />}
{/* Radius */}
<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: '#152642' }}>
{c.searchRadius ?? 30} km
</Typography>
</Box>
<Slider
value={c.searchRadius ?? 30}
min={1}
max={100}
marks={RADIUS_MARKS}
step={1}
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
size="small"
sx={{ color: '#152642', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
/>
</Box>
{/* Budget */}
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
@@ -175,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' }
}
/>
@@ -220,7 +242,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack>
)}
<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('lg'))
const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [location.pathname])
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
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 -3
View File
@@ -4,8 +4,7 @@ import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706',
VERIFIED_PORTFOLIO: '#152642',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed',
}
@@ -93,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>
+15 -7
View File
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
import {
LayoutDashboard,
Building2,
Target,
CheckSquare,
Search,
List,
@@ -13,7 +12,7 @@ import {
MessageSquare,
Kanban,
BellRing,
Plus,
Settings,
} from 'lucide-react'
// ---------------------------------------------------------------------------
@@ -45,17 +44,15 @@ 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 },
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
],
},
[WorkspaceType.DEMAND]: {
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
],
},
[WorkspaceType.OPERATIONS]: {
label: 'Operations',
abbreviation: 'OP',
icon: Settings,
firstPath: '/ops/review-queue',
chipColor: '#6b21a8',
navItems: [
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
],
},
}
// Ordered list for rendering workspace tabs
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { Box, Button, Divider, Typography } from '@mui/material'
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
import { useNavigate } from 'react-router'
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
// ── Future Availability Card ──────────────────────────────────────────────────
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
const isControlled = vm.signalIsControlled ?? false
const navigate = useNavigate()
@@ -31,7 +32,6 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
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)',
@@ -243,4 +243,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
</Box>
</Box>
)
}
})
@@ -1,18 +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
@@ -25,53 +22,82 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
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} />
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} aspectRatio="15/8" />
{/* 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" />
@@ -79,24 +105,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
</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.`
@@ -104,43 +130,64 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
</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', 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"
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: vm.title,
location: vm.locationLabel ?? '',
matchScore: vm.matchScore,
matchId: vm.id,
propertyId: vm.propertyId,
})
}}
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}
@@ -148,9 +195,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
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' } }
: {}
+173 -90
View File
@@ -1,119 +1,202 @@
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import { MatchCardHeader } from './MatchCardHeader'
import { MatchReasonList } from './MatchReasonList'
import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import { memo } from '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, DS_TEXT, DS_BORDER } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
vm: MatchCardViewModel
imageUrl?: string
}
export function MatchCardCompact({ vm }: Props) {
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState />
const borderColor = vm.isCompareSelected
? '#7c3aed'
: vm.isSelected
? '#1e3a5f'
: 'transparent'
const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const tier = getScoreTier(vm.matchScore)
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 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 (
<Card
sx={{
p: 2.5,
mb: 1.5,
border: `2px solid ${borderColor}`,
<Box sx={{
display: 'flex',
borderRadius: 2,
overflow: 'hidden',
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: 'border-color 0.15s',
}}
>
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
{vm.disclaimer}
</Alert>
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 — 180px, Ginesta proportion ── */}
<Box sx={{ width: 180, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
{imageUrl ? (
<img
src={imageUrl}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{
width: '100%', height: '100%', minHeight: 140,
display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: '#f4f3f0',
}}>
<MapPin size={24} color="#c4c4c4" />
</Box>
)}
{vm.isReviewRequired && (
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
Manuelle Überprüfung erforderlich
</Alert>
)}
{/* Header: score → type → confidence → availability → risk */}
<MatchCardHeader vm={vm} compact />
{/* Title + location (max 2 lines) */}
<Box sx={{ mt: 1.25, mb: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{vm.title}
{/* Score badge — top left */}
<Box sx={{
position: 'absolute', top: 8, left: 8,
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',
}}>
{vm.matchScore}%
</Typography>
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<MapPin size={13} color="#64748b" />
<Typography variant="body2" color="text.secondary">
</Box>
{/* Location pill — Ginesta-style, top right */}
<Box sx={{
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)',
}}>
<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, px: 2, py: 2.5, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
)}
{/* 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 }} />
<HeatBadge propertyId={vm.propertyId} size="sm" />
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', 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 }} />
)}
</Box>
{/* 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: DS_TEXT.secondary, fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
{/* 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"
color="text.secondary"
sx={{
mt: 0.5,
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>
)}
</Box>
<Divider sx={{ mb: 1.25 }} />
{/* Score formula — always visible */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 1.25 }}>
<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>
)}
{/* Top reason only */}
{vm.reasons.length > 0 && (
<Box sx={{ mb: 1 }}>
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
</Box>
{/* 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,
location: vm.locationLabel ?? '',
matchScore: vm.matchScore,
matchId: vm.id,
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
>
Anfrage
</Button>
{detailAction && (
<Button
size="small"
variant="outlined"
onClick={detailAction.onClick}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
>
Details
</Button>
)}
{/* Top tradeoff only (compact) */}
{vm.tradeoffs.length > 0 && (
<Box sx={{ mb: 1 }}>
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
</Box>
)}
{/* Data quality — only critical warning in compact mode */}
<MatchDataQualitySummary
dataQualityScore={vm.dataQualityScore}
missingData={vm.missingData}
compact
/>
<Box sx={{ mt: 1.25 }}>
<MatchActionToolbar actions={vm.actions} compact />
</Box>
</Card>
</Box>
)
}
})
@@ -4,8 +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' },
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
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 && (
@@ -1,6 +1,7 @@
import type { ResultType } from '../../domain/enums'
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
import type { PropertyUnit } from '../../domain/property'
import type { FitOutInvestment } from '../../lib/fitOutUtils'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
signalIsControlled?: boolean
signalAreaSqmEstimate?: number
// Divisibility (Teilbarkeit)
isDivisible?: boolean
minDivisibleUnitSqm?: number
// Fit-out signal
fitOutLabel?: string // e.g. "FULL — bezugsfertig", "BASIC + CHF 200 MAB"
fitOutViable?: boolean // true when budget bridges the gap
fitOutInvestment?: FitOutInvestment
// Unit-first display (primary)
propertySubtitle?: string // building name shown below the unit title
@@ -76,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}
+1
View File
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
export { MatchActionToolbar } from './MatchActionToolbar'
export { MatchCardSkeleton } from './MatchCardSkeleton'
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
export type {
MatchCardViewModel,
MatchCardVariant,
@@ -70,9 +70,12 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{need && (
<Chip
label={need.companyName}
label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
size="small"
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }}
sx={need.isAnonymous
? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' }
: { bgcolor: '#eff6ff', color: '#152642', fontSize: 11, height: 20, fontWeight: 500 }
}
/>
)}
<Chip
@@ -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 }) {
@@ -32,9 +32,14 @@ export function NeedSelectionPanel({ matches }: Props) {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3, flex: 1, mr: 0.5 }}>
{need.companyName}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, mr: 0.5, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }} noWrap>
{need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
</Typography>
{need.isAnonymous && (
<Chip label="Anonym" size="small" sx={{ bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 9, height: 16, fontWeight: 700, flexShrink: 0, border: '1px solid #ddd6fe' }} />
)}
</Box>
{pendingCount > 0 && (
<Chip label={pendingCount} size="small"
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} />
@@ -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>
)
}
@@ -0,0 +1,114 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
fitOutByLandlord?: boolean
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
return (
<Box sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
py: 0.875, borderBottom: isTotal ? 'none' : `1px solid ${DS_BORDER.default}`,
bgcolor: isTotal ? (isWarning ? DS_SURFACE.warning.bg : DS_SURFACE.success.bg) : 'transparent',
px: isTotal ? 1.5 : 0, mx: isTotal ? -1.5 : 0, borderRadius: isTotal ? 1 : 0,
}}>
<Box>
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 400 }}>{label}</Typography>
{sub && <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>{sub}</Typography>}
</Box>
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 500, color: isTotal ? DS_TEXT.primary : DS_TEXT.secondary, flexShrink: 0, ml: 2 }}>
{value}
</Typography>
</Box>
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
const rentPerYear = rentPricePerSqm * areaSqm
const fitOutPerYear = fitOutPerSqm * areaSqm
const totalPerYear = rentPerYear + fitOutPerYear
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
// 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>
{hasSurcharge && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, fontSize: 10, height: 20, border: `1px solid ${DS_SURFACE.blue.border}` }}
/>
)}
</Box>
<Box sx={{ px: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden' }}>
<Row
label="Jahresmiete"
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{fitOutByLandlord ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
/>
) : !hasSurcharge ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : (
<Row
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
value={chf(fitOutPerYear)}
sub={
`${fitOutLabel}` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`
}
/>
)}
<Row
label="Total/Jahr"
value={chf(totalPerYear)}
isTotal
isWarning={isWarning}
/>
</Box>
{disclaimer && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
{disclaimer}
</Typography>
)}
</Paper>
)
}
@@ -0,0 +1,51 @@
import { Box, Paper, Typography } from '@mui/material'
import { FileImage } from 'lucide-react'
import { DS_TEXT } from '../../lib/ds'
interface FloorPlan {
url: string
label?: string
}
interface Props {
plans: FloorPlan[]
mb?: number | string
}
export function FloorPlanSection({ plans, mb }: Props) {
if (plans.length === 0) return null
return (
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<FileImage size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{plans.map((p, i) => (
<Box key={i}>
{p.label && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
{p.label}
</Typography>
)}
<Box
component="img"
src={p.url}
alt={p.label ?? 'Grundriss'}
sx={{
width: '100%',
borderRadius: 1,
border: '1px solid rgba(0,0,0,0.08)',
display: 'block',
objectFit: 'contain',
maxHeight: 480,
bgcolor: '#f8fafc',
}}
/>
</Box>
))}
</Box>
</Paper>
)
}

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