41 Commits

Author SHA1 Message Date
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
259 changed files with 8760 additions and 3037 deletions
+3 -1
View File
@@ -11,7 +11,9 @@
"Bash(start http://localhost:5173)", "Bash(start http://localhost:5173)",
"Bash(npm install *)", "Bash(npm install *)",
"Bash(git pull *)", "Bash(git pull *)",
"Bash(node scripts/check-tokens.js)" "Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")"
] ]
} }
} }
+4 -1
View File
@@ -2,7 +2,10 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(git commit -m ' *)", "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/**)"
] ]
} }
} }
+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.
+1 -3
View File
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability')) const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality')) const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager')) 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 NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings')) 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 Pipeline = lazy(() => import('./pages/demand/Pipeline'))
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail')) const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
function App() { function App() {
return ( return (
@@ -62,7 +61,6 @@ function App() {
<Route path="/supply/future-availability" element={<FutureAvailability />} /> <Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} /> <Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/reminder-manager" element={<ReminderManager />} /> <Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/market-leads" element={<MarketLeads />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} /> <Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} /> <Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} /> <Route path="/supply/my-listings" element={<MyListings />} />
@@ -23,7 +23,7 @@ const TYPE_LABELS: Record<AIOutputType, string> = {
} }
const TYPE_COLORS: Record<AIOutputType, string> = { const TYPE_COLORS: Record<AIOutputType, string> = {
NEED_PARSE: '#1e3a5f', NEED_PARSE: '#152642',
FOLLOW_UP_QUESTIONS: '#0891b2', FOLLOW_UP_QUESTIONS: '#0891b2',
MATCH_EXPLANATION: '#4f46e5', MATCH_EXPLANATION: '#4f46e5',
COMPARE_SUMMARY: '#1a7a4a', COMPARE_SUMMARY: '#1a7a4a',
@@ -61,7 +61,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
variant="outlined" variant="outlined"
onClick={onCopyJson} onClick={onCopyJson}
startIcon={<Copy size={13} />} 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 JSON
</Button> </Button>
@@ -93,18 +93,18 @@ export function ActiveInquiriesTab() {
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}> <Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Anfragen Anfragen
</Typography> </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} {inquiries.length}
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Listenansicht"> <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} /> <ListIcon size={16} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Kartenansicht"> <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} /> <LayoutGrid size={16} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -20,12 +20,12 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
p: 2, p: 2,
borderRadius: 1.5, borderRadius: 1.5,
border: '1px solid', border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0', borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white', bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
'&:hover': { '&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8', borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)', boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
}, },
display: 'flex', display: 'flex',
@@ -42,7 +42,7 @@ export function InquiryChat({
fontSize: '0.75rem', fontSize: '0.75rem',
borderColor: '#cbd5e1', borderColor: '#cbd5e1',
color: '#475569', color: '#475569',
'&:hover': { borderColor: '#1e3a5f', color: '#1e3a5f', bgcolor: '#f0f4f8' }, '&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' },
}} }}
> >
Angebot erstellen Angebot erstellen
@@ -85,7 +85,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
variant="outlined" variant="outlined"
startIcon={<ClipboardList size={14} />} startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)} 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 Vorbereitung starten
</Button> </Button>
@@ -20,7 +20,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
py: 1.5, py: 1.5,
borderBottom: '1px solid #e2e8f0', borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid', borderLeft: '3px solid',
borderLeftColor: selected ? '#1e3a5f' : hasUnread ? '#2563eb' : 'transparent', borderLeftColor: selected ? '#152642' : hasUnread ? '#2563eb' : 'transparent',
bgcolor: selected ? '#f1f5f9' : 'white', bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer', cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s', transition: 'background-color 0.15s, border-color 0.15s',
@@ -38,7 +38,7 @@ export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
<Box <Box
sx={{ sx={{
maxWidth: '75%', maxWidth: '75%',
bgcolor: isTenant ? '#f1f5f9' : '#1e3a5f', bgcolor: isTenant ? '#f1f5f9' : '#152642',
color: isTenant ? '#0f172a' : 'white', color: isTenant ? '#0f172a' : 'white',
px: 1.75, px: 1.75,
py: 1.25, py: 1.25,
@@ -123,7 +123,6 @@ export function InquiryReplyComposer({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.75, gap: 0.75,
bgcolor: 'white',
border: '1px solid', border: '1px solid',
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1', borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
borderRadius: 1, borderRadius: 1,
@@ -167,7 +166,7 @@ export function InquiryReplyComposer({
} }
onClick={handleSend} onClick={handleSend}
disabled={sending || !body.trim()} disabled={sending || !body.trim()}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
Antwort senden Antwort senden
</Button> </Button>
@@ -47,7 +47,7 @@ export function LatentInquiriesTab() {
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}> <Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box <Box
onClick={() => setMobileView('properties')} 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 Eigene Objekte anzeigen
</Box> </Box>
@@ -1,18 +1,86 @@
import { Box, Typography } from '@mui/material' import { Box, Divider, Typography } from '@mui/material'
import { FileText } from 'lucide-react' import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { mockProperties } from '../../mock-data/properties' import { mockProperties } from '../../mock-data/properties'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
interface MockPdfPreviewProps { interface MockPdfPreviewProps {
fields: Record<string, string> fields: Record<string, string>
fallbackFields: 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 needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds) const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const properties = mockProperties.filter(p => propertyIds.includes(p.id)) 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] ?? '' const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
fontFamily: '"Georgia", "Times New Roman", serif', fontFamily: '"Georgia", "Times New Roman", serif',
}} }}
> >
{/* ── Page 1 — Offer letter ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<FileText size={16} /> <FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}> <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 }}> <Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{v('closing')} {v('closing')}
</Typography> </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> </Box>
) )
} }
@@ -116,7 +116,7 @@ export function OfferChatComposer() {
py: 0.75, py: 0.75,
}} }}
> >
<FileText size={14} color="#1e3a5f" /> <FileText size={14} color="#152642" />
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}> <Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
{a.fileName} {a.fileName}
</Typography> </Typography>
@@ -35,7 +35,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
onClick={() => open(needId, needTitle)} onClick={() => open(needId, needTitle)}
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
bgcolor: '#1e3a5f', bgcolor: '#152642',
fontWeight: 600, fontWeight: 600,
'&:hover': { bgcolor: '#16304d' }, '&:hover': { bgcolor: '#16304d' },
}} }}
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
} }
return ( 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 }}> <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> <Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button> <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" size="small"
value={a.date} value={a.date}
onChange={e => updateAppointment(a.id, 'date', e.target.value)} onChange={e => updateAppointment(a.id, 'date', e.target.value)}
InputLabelProps={{ shrink: true }} slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: 160 }} sx={{ width: 160 }}
/> />
<TextField <TextField
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
progress={progress} progress={progress}
ready={ready} ready={ready}
draft={draft} draft={draft}
property={property} property={property ?? undefined}
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')} onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
onAttach={() => { onAttach={() => {
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf` const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
@@ -255,7 +255,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
variant="contained" variant="contained"
disabled={loading} disabled={loading}
onClick={() => setStep(1)} onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
Weiter Weiter
</Button> </Button>
@@ -264,7 +264,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button <Button
variant="contained" variant="contained"
onClick={() => setStep(2)} onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
Weiter Weiter
</Button> </Button>
@@ -273,7 +273,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button <Button
variant="contained" variant="contained"
onClick={handleGeneratePdf} onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
PDF erstellen PDF erstellen
</Button> </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 needTitle = useOfferWizardStore(s => s.needTitle)
const addAttachment = useOfferWizardStore(s => s.addAttachment) const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft) const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
</Typography> </Typography>
</Box> </Box>
) : ( ) : (
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} /> <MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
)} )}
</Box> </Box>
@@ -177,7 +179,7 @@ export function OfferPdfReviewStep() {
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />} endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleNext} onClick={handleNext}
disabled={!pdfReady || markChecked.isPending} 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 →'} {markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
</Button> </Button>
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
return return
} }
setOfferDraftId(res.data.id) setOfferDraftId(res.data.id)
setStep('pdf_review') setStep('select_fields')
} }
if (!need) { if (!need) {
@@ -141,7 +141,7 @@ export function OfferPropertySelectionStep() {
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />} endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext} onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending} disabled={selectedIds.length === 0 || createDraft.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
Weiter Weiter
</Button> </Button>
+11 -5
View File
@@ -12,22 +12,25 @@ import {
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore' import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep' import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep' import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction' import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer' 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 }[] = [ const STEPS: { key: OfferStep; label: string }[] = [
{ key: 'select_properties', label: 'Objekte wählen' }, { key: 'select_properties', label: 'Objekte wählen' },
{ key: 'select_fields', label: 'Felder wählen' },
{ key: 'pdf_review', label: 'Vorschau & Prüfen' }, { key: 'pdf_review', label: 'Vorschau & Prüfen' },
{ key: 'send', label: 'Senden' }, { key: 'send', label: 'Senden' },
] ]
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = { const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
select_properties: 0, select_properties: 0,
pdf_review: 1, select_fields: 1,
checked: 1, // same visual step as pdf_review pdf_review: 2,
send: 2, checked: 2, // same visual step as pdf_review
send: 3,
} }
export function OfferWizard() { export function OfferWizard() {
@@ -48,13 +51,15 @@ export function OfferWizard() {
fullScreen={fullScreen} fullScreen={fullScreen}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
PaperProps={{ slotProps={{
paper: {
sx: { sx: {
height: fullScreen ? '100vh' : '85vh', height: fullScreen ? '100vh' : '85vh',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
}, },
},
}} }}
> >
{/* Header */} {/* Header */}
@@ -112,6 +117,7 @@ export function OfferWizard() {
{/* Step content */} {/* Step content */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> <Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{currentStep === 'select_properties' && <OfferPropertySelectionStep />} {currentStep === 'select_properties' && <OfferPropertySelectionStep />}
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
{currentStep === 'pdf_review' && <OfferPdfReviewStep />} {currentStep === 'pdf_review' && <OfferPdfReviewStep />}
{currentStep === 'checked' && <OfferCheckedAction />} {currentStep === 'checked' && <OfferCheckedAction />}
{currentStep === 'send' && <OfferChatComposer />} {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 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? ( {generating ? (
<> <>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} /> <CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}> <Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
PDF wird generiert PDF wird generiert
</Typography> </Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}> <Box sx={{ width: '100%', maxWidth: 400 }}>
@@ -44,7 +44,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
overflow: 'hidden', 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: '#ef4444' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
@@ -53,7 +53,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
</Typography> </Typography>
</Box> </Box>
<Box sx={{ flex: 1, p: 3 }}> <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 Angebotsschreiben
</Typography> </Typography>
{draft?.editableFields.slice(0, 3).map(f => ( {draft?.editableFields.slice(0, 3).map(f => (
@@ -83,7 +83,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
variant="contained" variant="contained"
startIcon={<Download size={16} />} startIcon={<Download size={16} />}
onClick={onDownload} onClick={onDownload}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
Herunterladen Herunterladen
</Button> </Button>
@@ -62,7 +62,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
flexShrink: 0, flexShrink: 0,
}} }}
> >
<Target size={14} color="#1e3a5f" /> <Target size={14} color="#152642" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}> <Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Eigene Objekte Eigene Objekte
</Typography> </Typography>
@@ -72,7 +72,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
px: 1, px: 1,
py: 0.125, py: 0.125,
borderRadius: 1, borderRadius: 1,
bgcolor: '#1e3a5f', bgcolor: '#152642',
color: 'white', color: 'white',
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
@@ -6,7 +6,6 @@ import {
} from '@mui/material' } from '@mui/material'
import { Download, Send, X } from 'lucide-react' import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry' import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport' import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport' import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
import { useToastStore } from '../../stores/toastStore' 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 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) => { const toggleProperty = (id: string) => {
setSelectedIds(prev => setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
} }
return ( 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 }}> <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> <Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button> <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 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? ( {generating ? (
<> <>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} /> <CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}> <Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
Bericht wird erstellt Bericht wird erstellt
</Typography> </Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}> <Box sx={{ width: '100%', maxWidth: 400 }}>
@@ -239,7 +234,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
fullWidth fullWidth
startIcon={<Download size={16} />} startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')} onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
Herunterladen Herunterladen
</Button> </Button>
@@ -276,7 +271,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
variant="contained" variant="contained"
disabled={selectedIds.length === 0} disabled={selectedIds.length === 0}
onClick={handleGenerate} onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
> >
Weiter Weiter
</Button> </Button>
@@ -287,7 +282,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
disabled={finalizing} disabled={finalizing}
onClick={handleFinalize} onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined} 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 Finalisieren
</Button> </Button>
@@ -19,12 +19,12 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
p: 1.5, p: 1.5,
borderRadius: 1.5, borderRadius: 1.5,
border: '1px solid', border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0', borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white', bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
'&:hover': { '&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8', borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)', boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
}, },
display: 'flex', display: 'flex',
@@ -174,7 +174,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
variant="caption" variant="caption"
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
color: '#1e3a5f', color: '#152642',
fontSize: '0.75rem', fontSize: '0.75rem',
bgcolor: '#e0e7ff', bgcolor: '#e0e7ff',
px: 1, px: 1,
@@ -189,7 +189,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
sx={{ sx={{
height: 6, height: 6,
borderRadius: 3, borderRadius: 3,
bgcolor: '#e2e8f0', bgcolor: '#e8e7e4',
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
@@ -197,7 +197,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
sx={{ sx={{
width: `${pct}%`, width: `${pct}%`,
height: '100%', height: '100%',
bgcolor: '#1e3a5f', bgcolor: '#152642',
transition: 'width 0.3s', transition: 'width 0.3s',
}} }}
/> />
@@ -229,7 +229,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
disabled={disabled} disabled={disabled}
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
bgcolor: '#1e3a5f', bgcolor: '#152642',
fontWeight: 600, fontWeight: 600,
'&:hover': { bgcolor: '#16304d' }, '&:hover': { bgcolor: '#16304d' },
}} }}
@@ -47,7 +47,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
px: 1, px: 1,
py: 0.125, py: 0.125,
borderRadius: 1, borderRadius: 1,
bgcolor: '#1e3a5f', bgcolor: '#152642',
color: 'white', color: 'white',
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
@@ -60,7 +60,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
height: 140, height: 140,
borderRadius: 1.5, borderRadius: 1.5,
overflow: 'hidden', overflow: 'hidden',
bgcolor: '#e2e8f0', bgcolor: '#e8e7e4',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -203,7 +203,7 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
size="small" size="small"
endIcon={<ArrowRight size={11} />} endIcon={<ArrowRight size={11} />}
onClick={() => navigate('/supply/properties')} onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#1e3a5f' }} sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#152642' }}
> >
Ansehen Ansehen
</Button> </Button>
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
}, },
] ]
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) { export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
const isSelected = (key: ReportObjectFieldKey) => const isSelected = (key: ReportObjectFieldKey) =>
value.selectedOptionalFields.includes(key) value.selectedOptionalFields.includes(key)
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
{FIELD_GROUPS.map(group => { {FIELD_GROUPS.map(group => {
const groupKeys = group.fields.map(f => f.key) const groupKeys = group.fields.map(f => f.key)
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
return ( return (
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}> <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 } }}> <AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
@@ -31,12 +31,12 @@ export function SelectablePropertyMatchCard({
p: 1.25, p: 1.25,
borderRadius: 1.5, borderRadius: 1.5,
border: '1px solid', border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0', borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#eff6ff' : 'white', bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
'&:hover': { '&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8', borderColor: selected ? '#152642' : '#94a3b8',
}, },
display: 'flex', display: 'flex',
gap: 1.25, gap: 1.25,
@@ -57,7 +57,7 @@ export function SelectablePropertyMatchCard({
height: 40, height: 40,
flexShrink: 0, flexShrink: 0,
borderRadius: 1, borderRadius: 1,
bgcolor: '#e2e8f0', bgcolor: '#e8e7e4',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -21,7 +21,7 @@ export function latentStatusBadge(status: 'public' | 'paused' | 'expired'): {
} { } {
if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' } if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' } 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) // Deterministic pseudo-random match score from string IDs (range 6096)
@@ -9,7 +9,7 @@ interface Props {
} }
const ACTION_COLORS: Record<string, string> = { const ACTION_COLORS: Record<string, string> = {
NAVIGATE: '#1e3a5f', NAVIGATE: '#152642',
OPEN_REVIEW: '#7c3aed', OPEN_REVIEW: '#7c3aed',
ADD_TO_SHORTLIST: '#1a7a4a', ADD_TO_SHORTLIST: '#1a7a4a',
REQUEST_DATA: '#d97706', REQUEST_DATA: '#d97706',
@@ -25,7 +25,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
px: 1.5, px: 1.5,
py: 1, py: 1,
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px', borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9', bgcolor: isUser ? '#152642' : '#f1f5f9',
color: isUser ? 'white' : '#1e293b', color: isUser ? 'white' : '#1e293b',
}} }}
> >
@@ -237,7 +237,7 @@ export function GlobalAIAssistantDrawer() {
color: 'white', color: 'white',
flexShrink: 0, flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' }, '&:hover': { bgcolor: '#4338ca' },
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' }, '&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' },
}} }}
> >
<Send size={16} /> <Send size={16} />
+2 -2
View File
@@ -37,10 +37,10 @@ export function DemoRoleSwitcher() {
sx={{ sx={{
fontSize: '0.7rem', fontSize: '0.7rem',
height: 22, height: 22,
bgcolor: active ? '#1e3a5f' : 'transparent', bgcolor: active ? '#152642' : 'transparent',
color: active ? '#fff' : 'text.secondary', color: active ? '#fff' : 'text.secondary',
border: '1px solid', border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider', borderColor: active ? '#152642' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' }, '&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}} }}
/> />
+1 -1
View File
@@ -51,7 +51,7 @@ export function SessionExpired() {
<Button <Button
variant="contained" variant="contained"
onClick={handleRelogin} onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }} sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }}
> >
Erneut anmelden Erneut anmelden
</Button> </Button>
+1 -2
View File
@@ -1,5 +1,5 @@
import { Chip } from '@mui/material' 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 type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds' import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants' import { RESULT_TYPE_LABELS } from '../../lib/constants'
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
const ICONS: Record<ResultType, React.ElementType> = { const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck, VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe,
MAISON_WORK: Building2, MAISON_WORK: Building2,
FUTURE_AVAILABILITY: Sparkles, FUTURE_AVAILABILITY: Sparkles,
} }
+1 -1
View File
@@ -17,7 +17,7 @@ interface MetricCardProps {
sx?: SxProps<Theme> 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 ( return (
<Card sx={{ p: 2.5, ...sx }}> <Card sx={{ p: 2.5, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}> <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 }> = { const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck }, VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#152642', Icon: ShieldCheck },
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 }, 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 }, FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
} }
@@ -67,7 +67,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
sx={{ sx={{
fontWeight: 700, lineHeight: 1.3, mb: 0.25, fontWeight: 700, lineHeight: 1.3, mb: 0.25,
cursor: 'pointer', cursor: 'pointer',
'&:hover': { color: '#1e3a5f' }, '&:hover': { color: '#152642' },
}} }}
onClick={() => navigate(`/demand/results/${item.matchId}`)} 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> <TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
{compareItems.map(item => ( {compareItems.map(item => (
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}> <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)} {getTitle(item)}
</Typography> </Typography>
</TableCell> </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. Fügen Sie 24 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
</Typography> </Typography>
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')} <Button variant="contained" size="small" onClick={() => navigate('/demand/results')}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}> sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}>
Zu den Suchergebnissen Zu den Suchergebnissen
</Button> </Button>
</Box> </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>
)
})
+60 -6
View File
@@ -20,6 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index' import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds' import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils' import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ────────────────────────────────────────────────────────────── // ── Local helper ──────────────────────────────────────────────────────────────
@@ -148,18 +150,70 @@ export function CompareTableBody({
: <MissingDataCell /> : <MissingDataCell />
}))} }))}
{/* 9. Rent / Budget Fit */} {/* 9. Rent / Budget Fit — full cost breakdown incl. amortised fit-out */}
{row('9. Miete / Budget', compareItems.map(item => { {row('9. Kosten / Budget', compareItems.map(item => {
const prop = getProp(item) const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" /> if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const 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
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
+ fitOutMonthly
return ( return (
<Box> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
CHF {prop.rentPricePerSqm}/m²/Jahr CHF {prop.rentPricePerSqm}/m²/Jahr
</Typography> </Typography>
{prop.totalRentMonthly && (
<Typography variant="caption" color="text.secondary"> <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 ÷ {FITOUT_AMORTIZATION_YEARS} J.)
</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}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
</Typography> </Typography>
)} )}
</Box> </Box>
@@ -81,7 +81,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
Datenaktualisierung anfragen Datenaktualisierung anfragen
</Button> </Button>
{q.missingCriticalFields.length > 0 && ( {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 Fehlende Felder ergänzen
</Button> </Button>
)} )}
@@ -76,7 +76,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{ sx={{
height: 4, height: 4,
borderRadius: 2, borderRadius: 2,
bgcolor: '#e2e8f0', bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: d.color }, '& .MuiLinearProgress-bar': { bgcolor: d.color },
}} }}
/> />
@@ -110,7 +110,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{ sx={{
height: 5, height: 5,
borderRadius: 3, borderRadius: 3,
bgcolor: '#e2e8f0', bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: d.color }, '& .MuiLinearProgress-bar': { bgcolor: d.color },
}} }}
/> />
@@ -79,7 +79,7 @@ export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
sx={{ sx={{
height: 5, height: 5,
borderRadius: 3, borderRadius: 3,
bgcolor: '#e2e8f0', bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: color }, '& .MuiLinearProgress-bar': { bgcolor: color },
}} }}
/> />
+1 -1
View File
@@ -23,7 +23,7 @@ export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, on
sx={{ sx={{
flex: 1, flex: 1,
py: 1.5, py: 1.5,
bgcolor: '#1e3a5f', bgcolor: '#152642',
'&:hover': { bgcolor: '#162d4a' }, '&:hover': { bgcolor: '#162d4a' },
fontSize: 15, fontSize: 15,
fontWeight: 600, 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 { Save } from 'lucide-react'
import { NeedCardPreview } from './NeedCardPreview' import { NeedCardPreview } from './NeedCardPreview'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
@@ -10,7 +10,9 @@ interface Props {
needTitle: string needTitle: string
overallConfidence: number overallConfidence: number
isSaving: boolean isSaving: boolean
isAnonymous: boolean
onNeedTitleChange: (t: string) => void onNeedTitleChange: (t: string) => void
onAnonymousChange: (v: boolean) => void
onBack: () => void onBack: () => void
onSave: () => void onSave: () => void
} }
@@ -22,7 +24,9 @@ export function AISearchSavePreview({
needTitle, needTitle,
overallConfidence, overallConfidence,
isSaving, isSaving,
isAnonymous,
onNeedTitleChange, onNeedTitleChange,
onAnonymousChange,
onBack, onBack,
onSave, onSave,
}: Props) { }: Props) {
@@ -39,7 +43,41 @@ export function AISearchSavePreview({
needTitle={needTitle} needTitle={needTitle}
onNeedTitleChange={onNeedTitleChange} 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}> <Button variant="outlined" onClick={onBack} disabled={isSaving}>
Zurück Zurück
</Button> </Button>
@@ -48,7 +86,7 @@ export function AISearchSavePreview({
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={isSaving}
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />} 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 {isSaving
? 'Wird gespeichert…' ? 'Wird gespeichert…'
+24 -5
View File
@@ -7,15 +7,34 @@ interface AnfragenInquiryItemProps {
inq: Inquiry inq: Inquiry
isSelected: boolean isSelected: boolean
hasPipeline: boolean hasPipeline: boolean
perspective?: 'demand' | 'supply'
onSelect: (id: string) => void 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 cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1] const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger 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 ( return (
<Box onClick={() => onSelect(inq.id)} sx={{ <Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5, 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 }}> <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 }} />} {!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> <Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName} {primaryTitle}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}> <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> <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box> </Box>
</Box> </Box>
{inq.tenantCompany && ( {secondaryLine && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{inq.tenantCompany} {secondaryLine}
</Typography> </Typography>
)} )}
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap> <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> </Typography>
{lastMsg && ( {lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap> <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]} {lastMsg.body.split('\n')[0]}
</Typography> </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 { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry' 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 isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai' const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) 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 && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />} {!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}> <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time} {isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
</Typography> </Typography>
</Box> </Box>
<Box <Box
@@ -10,7 +10,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = { const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail', 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', MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
} }
+1 -1
View File
@@ -66,7 +66,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onRepa
disabled={requiredUnanswered.length > 0} disabled={requiredUnanswered.length > 0}
onClick={onContinue} onClick={onContinue}
endIcon={<ArrowRight size={16} />} endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
> >
Weiter zur Gewichtung Weiter zur Gewichtung
</Button> </Button>
@@ -22,7 +22,7 @@ export function NeedBuilderErrorState({ message, onRetry }: Props) {
variant="contained" variant="contained"
startIcon={<RotateCcw size={16} />} startIcon={<RotateCcw size={16} />}
onClick={onRetry} onClick={onRetry}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
> >
Erneut versuchen Erneut versuchen
</Button> </Button>
+4 -4
View File
@@ -15,7 +15,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = { const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail', 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'] const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
@@ -66,7 +66,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{c.assetType && ( {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" /> <Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
</Box> </Box>
@@ -143,7 +143,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
variant="determinate" variant="determinate"
value={overallConfidence * 100} value={overallConfidence * 100}
sx={{ sx={{
height: 6, borderRadius: 3, bgcolor: '#e2e8f0', height: 6, borderRadius: 3, bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { '& .MuiLinearProgress-bar': {
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b', bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
}, },
@@ -171,7 +171,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={((weights[k] ?? 0) / maxWeight) * 100} 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> <Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
</Box> </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 { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParsedNeedCriteria } from '../../domain/needBuilder'
@@ -73,7 +73,46 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }} slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/> />
</Box> </Box>
<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="Ihr Beitrag — exkl. MAB des Vermieters"
/>
</Box> </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> </AccordionDetails>
</Accordion> </Accordion>
) )
+52 -6
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material' import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums' import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements' import { NeedExtendedRequirements } from './NeedExtendedRequirements'
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
{ label: 'Retail', value: AssetType.RETAIL }, { label: 'Retail', value: AssetType.RETAIL },
{ label: 'Logistik', value: AssetType.LOGISTICS }, { label: 'Logistik', value: AssetType.LOGISTICS },
{ label: 'Produktion', value: AssetType.PRODUCTION }, { label: 'Produktion', value: AssetType.PRODUCTION },
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL }, { label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gemischt', value: AssetType.MIXED }, { 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 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 }) { function FieldLabel({ children }: { children: React.ReactNode }) {
return ( return (
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}> <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 clickable
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })} onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === 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) 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' } : { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
} }
/> />
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
))} ))}
</Stack> </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 */} {/* Budget */}
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel> <FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
@@ -175,7 +197,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable clickable
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })} onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
sx={c.budgetRange?.maxPerSqm === p.value 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' } : { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
} }
/> />
@@ -220,6 +242,30 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack> </Stack>
)} )}
{/* Anonymous search */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e8e7e4'}` }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
Anonyme Suche
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
Firmenname wird nicht an Vermieter übermittelt
</Typography>
</Box>
<FormControlLabel
control={
<Switch
size="small"
checked={c.isAnonymous ?? false}
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
/>
}
label=""
sx={{ m: 0 }}
/>
</Box>
<NeedExtendedRequirements criteria={c} onChange={set} /> <NeedExtendedRequirements criteria={c} onChange={set} />
</Card> </Card>
) )
+5 -5
View File
@@ -77,7 +77,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
sx={{ sx={{
p: 3, p: 3,
border: '1px solid', border: '1px solid',
borderColor: isRecording ? '#dc2626' : '#e2e8f0', borderColor: isRecording ? '#dc2626' : '#e8e7e4',
transition: 'border-color 0.2s', transition: 'border-color 0.2s',
bgcolor: isRecording ? '#fff5f5' : 'white', bgcolor: isRecording ? '#fff5f5' : 'white',
}} }}
@@ -101,11 +101,11 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
/> />
) : isAnalyzing ? ( ) : isAnalyzing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} /> <CircularProgress size={14} sx={{ color: '#152642' }} />
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert</Typography> <Typography variant="caption" sx={{ color: '#152642', fontWeight: 600 }}>Analysiert</Typography>
</Box> </Box>
) : isAutoGen && text ? ( ) : 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} ) : null}
</Box> </Box>
@@ -154,7 +154,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
size="small" size="small"
disabled={isAnalyzing || !isSpeechSupported} disabled={isAnalyzing || !isSpeechSupported}
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'} 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} /> <Mic size={16} />
</IconButton> </IconButton>
+1 -1
View File
@@ -109,7 +109,7 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
</Box> </Box>
<Card sx={{ p: 3 }}> <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 }} /> <Divider sx={{ my: 2.5 }} />
<SliderGroup label="Weiche Kriterien" keys={SOFT_KEYS} raw={raw} onSlider={handleSlider} color="#7c3aed" /> <SliderGroup label="Weiche Kriterien" keys={SOFT_KEYS} raw={raw} onSlider={handleSlider} color="#7c3aed" />
</Card> </Card>
+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 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{options.map(opt => { {options.map(opt => {
const selected = value.includes(opt.value) const selected = value.includes(opt.value)
const accent = opt.color ?? '#1e3a5f' const accent = opt.color ?? '#152642'
return ( return (
<Chip <Chip
key={opt.value} key={opt.value}
@@ -57,7 +57,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
onClick={() => set({ signalType: filters.signalType === t ? '' : t })} onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
sx={{ sx={{
fontWeight: filters.signalType === t ? 700 : 400, fontWeight: filters.signalType === t ? 700 : 400,
bgcolor: filters.signalType === t ? '#1e3a5f' : undefined, bgcolor: filters.signalType === t ? '#152642' : undefined,
color: filters.signalType === t ? 'white' : undefined, color: filters.signalType === t ? 'white' : undefined,
}} }}
/> />
@@ -131,7 +131,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
label={`${activeCount} Filter aktiv`} label={`${activeCount} Filter aktiv`}
size="small" size="small"
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)} onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f' }} sx={{ bgcolor: '#eff6ff', color: '#152642' }}
/> />
)} )}
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
+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 DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
export const TEXT_MUTED = '#94a3b8' export const TEXT_MUTED = '#94a3b8'
export const TEXT_WHITE = '#ffffff' 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)' export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -188,16 +189,16 @@ export function Sidebar({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 1.25, gap: 1.25,
px: collapsed ? 0 : 1.5, pl: collapsed ? 0 : isActive ? 1.25 : 1.5,
pr: collapsed ? 0 : 1.5,
py: 0.75, py: 0.75,
mx: 0.5, mx: 0.5,
borderRadius: 1, borderRadius: 1,
justifyContent: collapsed ? 'center' : 'flex-start', justifyContent: collapsed ? 'center' : 'flex-start',
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent', borderLeft: collapsed ? 'none' : `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
'&:hover': { backgroundColor: 'transparent',
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG, '&:hover': { backgroundColor: NAV_HOVER_BG },
}, transition: 'border-color 0.15s ease',
transition: 'background-color 0.15s ease',
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
@@ -208,8 +209,9 @@ export function Sidebar({
variant="body2" variant="body2"
sx={{ sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED, color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 500 : 400, fontWeight: isActive ? 600 : 400,
fontSize: '0.8125rem', fontSize: '0.875rem',
letterSpacing: '-0.01em',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
@@ -263,7 +265,7 @@ export function Sidebar({
width: 32, width: 32,
height: 32, height: 32,
fontSize: '0.75rem', fontSize: '0.75rem',
bgcolor: '#1e3a5f', bgcolor: '#b8975a',
flexShrink: 0, flexShrink: 0,
}} }}
> >
+5 -4
View File
@@ -31,10 +31,10 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Box <Box
component="header" component="header"
sx={{ sx={{
height: 56, height: 64,
flexShrink: 0, flexShrink: 0,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
borderBottom: '1px solid #e2e8f0', borderBottom: '1px solid #e8e7e4',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -51,12 +51,13 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Chip <Chip
label={config.label} label={config.label}
size="small" size="small"
variant="outlined"
sx={{ sx={{
backgroundColor: config.chipColor, color: '#152642',
color: '#ffffff',
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
height: 22, height: 22,
border: '1px solid #e8e7e4',
}} }}
/> />
<Typography <Typography
+2 -3
View File
@@ -4,8 +4,7 @@ import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
const TYPE_DOT: Record<string, string> = { const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f', VERIFIED_PORTFOLIO: '#152642',
EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1', MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed', FUTURE_AVAILABILITY: '#7c3aed',
} }
@@ -93,7 +92,7 @@ export function CompareTray() {
variant="contained" variant="contained"
size="small" size="small"
onClick={() => navigate('/demand/compare')} 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 Vergleich starten
</Button> </Button>
@@ -13,7 +13,7 @@ export function OrganizationContextBadge() {
variant="outlined" variant="outlined"
icon={<Building2 size={12} color="#64748b" />} icon={<Building2 size={12} color="#64748b" />}
label={currentUser.organizationName} 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, sx,
}: PageHeaderProps) { }: PageHeaderProps) {
return ( return (
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', ...sx }}> <Box sx={{ px: 3, py: 3, borderBottom: '1px solid #e8e7e4', ...sx }}>
{breadcrumbs && breadcrumbs.length > 0 && ( {breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs separator="/" sx={{ mb: 1 }}> <Breadcrumbs separator="/" sx={{ mb: 1 }}>
{breadcrumbs.map((crumb) => {breadcrumbs.map((crumb) =>
@@ -52,7 +52,7 @@ export function PageHeader({
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3 }}> <Typography variant="h6" sx={{ fontFamily: '"DM Serif Display", serif', fontWeight: 400, fontSize: '1.25rem', lineHeight: 1.3 }}>
{title} {title}
</Typography> </Typography>
{badge} {badge}
+1 -1
View File
@@ -45,7 +45,7 @@ export function UserMenu() {
return ( return (
<> <>
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}> <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} {initials}
</Avatar> </Avatar>
</IconButton> </IconButton>
+15 -7
View File
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
import { import {
LayoutDashboard, LayoutDashboard,
Building2, Building2,
Target,
CheckSquare, CheckSquare,
Search, Search,
List, List,
@@ -13,7 +12,7 @@ import {
MessageSquare, MessageSquare,
Kanban, Kanban,
BellRing, BellRing,
Plus, Settings,
} from 'lucide-react' } from 'lucide-react'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,17 +44,15 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
abbreviation: 'VW', abbreviation: 'VW',
icon: Building2, icon: Building2,
firstPath: '/supply/dashboard', firstPath: '/supply/dashboard',
chipColor: '#1e3a5f', chipColor: '#152642',
navItems: [ navItems: [
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, { 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/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar }, { path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList }, { path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
], ],
}, },
[WorkspaceType.DEMAND]: { [WorkspaceType.DEMAND]: {
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare }, { 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 // Ordered list for rendering workspace tabs
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { Box, Button, Divider, Typography } from '@mui/material' import { Box, Button, Divider, Typography } from '@mui/material'
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react' import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
// ── Future Availability Card ────────────────────────────────────────────────── // ── Future Availability Card ──────────────────────────────────────────────────
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
const isControlled = vm.signalIsControlled ?? false const isControlled = vm.signalIsControlled ?? false
const navigate = useNavigate() const navigate = useNavigate()
@@ -31,7 +32,6 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
borderRadius: 2, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
border: `1px solid ${borderColor}`, border: `1px solid ${borderColor}`,
borderLeft: `3px solid ${accentColor}`,
background: 'white', background: 'white',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
boxShadow: '0 1px 8px rgba(0,0,0,0.06)', boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
@@ -243,4 +243,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
</Box> </Box>
</Box> </Box>
) )
} })
@@ -1,77 +1,89 @@
import { Box, Button, Chip, Divider, Typography } from '@mui/material' import { Box, Button, Chip, Tooltip, Typography } from '@mui/material'
import { import { Building2, CheckCircle2 } from 'lucide-react'
Building2,
CheckCircle2,
} from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { LocationPreview } from '../shared/LocationPreview' import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge' import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' 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 type { MatchCardViewModel } from './MatchCardViewModel'
import { FutureAvailabilityCard } from './FutureAvailabilityCard' import { FutureAvailabilityCard } from './FutureAvailabilityCard'
// ── Main component ────────────────────────────────────────────────────────────
interface Props { interface Props {
vm: MatchCardViewModel vm: MatchCardViewModel
imageUrl?: string imageUrl?: string
lat?: number lat?: number
lng?: number lng?: number
cityLabel?: string cityLabel?: string
overlayTypeLabel?: string
overlayLocationLabel?: string
} }
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) { export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
const tier = getScoreTier(vm.matchScore) const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier] const theme = SCORE_THEME[tier]
const { currentUser } = useSessionStore() const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' 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 isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
const confPct = Math.round(vm.confidenceScore * 100)
// Future availability cards use the new design
if (isFuture) { if (isFuture) {
return <FutureAvailabilityCard vm={vm} /> return <FutureAvailabilityCard vm={vm} />
} }
// secondary chips — max 2 visible
const chips: Array<{ label: string; color?: string }> = []
if (vm.availabilityLabel) chips.push({ label: vm.availabilityLabel })
if (vm.fitOutLabel) chips.push({ label: vm.fitOutLabel })
if (vm.isDivisible && vm.minDivisibleUnitSqm) chips.push({ label: `Teilbar ab ${vm.minDivisibleUnitSqm}` })
const visibleChips = chips.slice(0, 2)
const hiddenChips = chips.slice(2)
return ( return (
<Box sx={{ <Box sx={{
borderRadius: 2, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
boxShadow: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${theme.cardBorder}`, border: `1px solid ${DS_BORDER.default}`,
background: theme.cardBg, bgcolor: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
transition: 'box-shadow 0.15s, transform 0.1s', transition: 'border-color 0.15s',
'&:hover': { '&:hover': { borderColor: '#c8c7c4' },
boxShadow: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
transform: 'translateY(-1px)',
},
}}> }}>
{/* ── Hero zone ── */} {/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}> <Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} /> <LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={200} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
{/* Score badge — bottom-left editorial */}
<Box sx={{ <Box sx={{
position: 'absolute', top: 10, left: 10, position: 'absolute', bottom: 10, left: 12,
background: theme.gradient, borderRadius: '10px', bgcolor: theme.gradient, borderRadius: '8px',
px: 1.5, py: 0.5, px: 1.25, py: 0.375,
boxShadow: `0 4px 16px ${theme.glow}`,
border: `1px solid ${theme.border}`, border: `1px solid ${theme.border}`,
minWidth: 52, textAlign: 'center',
}}> }}>
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}> <Typography sx={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.625rem',
color: '#ffffff', lineHeight: 1,
}}>
{vm.matchScore}% {vm.matchScore}%
</Typography> </Typography>
</Box> </Box>
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
{/* Top-right badge strip */}
<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 }} /> <Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && ( {vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip <Chip
icon={<Building2 size={9} color="#1e3a5f" />} icon={<Building2 size={9} color="#152642" />}
label="Ihr Objekt" label="Ihr Objekt"
size="small" 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" /> <HeatBadge propertyId={vm.propertyId} size="sm" />
@@ -79,24 +91,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
</Box> </Box>
{/* ── Card body ── */} {/* ── Card body ── */}
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}> <Box sx={{ p: 2.25, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Unit-first title (floor + street) */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap> {/* Title */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{vm.title} {vm.title}
</Typography> </Typography>
{/* Building name as subtitle when a unit or floor range is in the title */}
{vm.propertySubtitle && ( {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} {vm.propertySubtitle}
</Typography> </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(' · ')} {[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
</Typography> </Typography>
{/* Unit area + rent when a specific unit is matched */} {/* Unit details */}
{vm.preMarketUnit && ( {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.areaSqm.toLocaleString('de-CH')} m²
{vm.preMarketUnit.rentPricePerSqm {vm.preMarketUnit.rentPricePerSqm
? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.` ? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
@@ -104,43 +116,70 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
</Typography> </Typography>
)} )}
{/* Regular explainability summary */} {/* Confidence + secondary chips */}
<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 }} />
{visibleChips.map((c, i) => (
<Chip key={i} label={c.label} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
))}
{hiddenChips.length > 0 && (
<Tooltip title={hiddenChips.map(c => c.label).join(' · ')} arrow>
<Chip label={`+${hiddenChips.length}`} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
</Tooltip>
)}
</Box>
{/* Explainability */}
{vm.explainabilitySummary && ( {vm.explainabilitySummary && (
<>
<Divider sx={{ my: 1.25 }} />
<Typography variant="body2" sx={{ <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', overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
}}> }}>
{vm.explainabilitySummary} {vm.explainabilitySummary}
</Typography> </Typography>
</>
)} )}
{vm.reasons.length > 0 && ( {vm.reasons.length > 0 && (
<> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
<Divider sx={{ my: 1.25 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{vm.reasons.slice(0, 3).map((r, i) => ( {vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}> <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> <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} {r.label}
</Typography> </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} {r.explanation}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
))} ))}
</Box> </Box>
</>
)} )}
{/* Actions */} {/* 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 => ( {vm.actions.map(a => (
<Button <Button
key={a.id} key={a.id}
@@ -148,9 +187,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
variant={a.variant === 'primary' ? 'contained' : 'outlined'} variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick} onClick={a.onClick}
disabled={a.disabled} disabled={a.disabled}
sx={{ sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
}}
> >
{a.label} {a.label}
</Button> </Button>
@@ -26,7 +26,7 @@ export function MatchActionToolbar({ actions }: Props) {
onClick={action.onClick} onClick={action.onClick}
sx={ sx={
action.variant === 'primary' action.variant === 'primary'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } } ? { bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }
: action.variant === 'danger' : action.variant === 'danger'
? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } } ? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } }
: {} : {}
+178 -77
View File
@@ -1,119 +1,220 @@
import { Alert, Box, Card, Divider, Typography } from '@mui/material' import { memo } from 'react'
import { MapPin } from 'lucide-react' import { Alert, Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
import { MatchCardHeader } from './MatchCardHeader' import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { MatchReasonList } from './MatchReasonList' import { useSessionStore } from '../../stores/sessionStore'
import { TradeoffList } from './TradeoffList' import { useInquiryStore } from '../../stores/inquiryStore'
import { MatchDataQualitySummary } from './MatchDataQualitySummary' import { HeatBadge } from '../shared/HeatBadge'
import { MatchActionToolbar } from './MatchActionToolbar' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { MatchCardRestrictedState } from './MatchCardRestrictedState' import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown' import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import type { MatchCardViewModel } from './MatchCardViewModel' import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props { interface Props {
vm: MatchCardViewModel vm: MatchCardViewModel
imageUrl?: string
overlayTypeLabel?: string
} }
export function MatchCardCompact({ vm }: Props) { export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState /> if (vm.isRestricted) return <MatchCardRestrictedState />
const borderColor = vm.isCompareSelected const { currentUser } = useSessionStore()
? '#7c3aed' const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
: vm.isSelected const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
? '#1e3a5f'
: 'transparent' 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'
const extraChips: string[] = []
if (vm.availabilityLabel) extraChips.push(vm.availabilityLabel)
if (vm.fitOutLabel) extraChips.push(vm.fitOutLabel)
if (vm.isDivisible && vm.minDivisibleUnitSqm) extraChips.push(`Teilbar ab ${vm.minDivisibleUnitSqm}`)
if (topRisk) extraChips.push(topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko')
const visibleExtra = extraChips.slice(0, 2)
const hiddenExtra = extraChips.slice(2)
return ( return (
<Card <Box sx={{
sx={{ display: 'flex',
p: 2.5,
mb: 1.5, mb: 1.5,
border: `2px solid ${borderColor}`, 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, opacity: vm.isStaleData ? 0.75 : 1,
transition: 'border-color 0.15s', transition: 'border-color 0.15s',
}} '&:hover': { borderColor: '#c8c7c4' },
> }}>
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
{/* ── Image strip ── */}
<Box sx={{ width: 120, 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>
)}
{/* Score badge */}
<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={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.125rem',
color: '#ffffff', lineHeight: 1,
}}>
{vm.matchScore}%
</Typography>
</Box>
{overlayTypeLabel && (
<Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0,
px: 0.75, py: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%)',
}}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
{overlayTypeLabel}
</Typography>
</Box>
)}
</Box>
{/* ── Content ── */}
<Box sx={{ flex: 1, p: 2, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{vm.disclaimer && ( {vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}> <Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
{vm.disclaimer}
</Alert>
)} )}
{vm.isReviewRequired && ( {/* Badges row */}
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center', mb: 0.75 }}>
Manuelle Überprüfung erforderlich <HeatBadge propertyId={vm.propertyId} size="sm" />
</Alert> <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="#152642" />} label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
)} )}
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{visibleExtra.map((label, i) => (
<Chip key={i} label={label} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
))}
{hiddenExtra.length > 0 && (
<Tooltip title={hiddenExtra.join(' · ')} arrow>
<Chip label={`+${hiddenExtra.length}`} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
</Tooltip>
)}
</Box>
{/* Header: score → type → confidence → availability → risk */} {/* Title + location */}
<MatchCardHeader vm={vm} compact /> <Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{/* Title + location (max 2 lines) */}
<Box sx={{ mt: 1.25, mb: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{vm.title} {vm.title}
</Typography> </Typography>
{vm.propertySubtitle && ( {vm.propertySubtitle && (
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap> <Typography variant="caption" sx={{ color: DS_TEXT.secondary, fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
{vm.propertySubtitle} {vm.propertySubtitle}
</Typography> </Typography>
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
<MapPin size={13} color="#64748b" /> <MapPin size={11} color="#9a9a9a" />
<Typography variant="body2" color="text.secondary"> <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{vm.locationLabel} {vm.locationLabel}
</Typography> </Typography>
</Box> </Box>
{vm.explainabilitySummary && ( {vm.explainabilitySummary && (
<Typography <Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 500, mt: 0.75, lineHeight: 1.5,
variant="body2" overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
color="text.secondary"
sx={{
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{vm.explainabilitySummary} {vm.explainabilitySummary}
</Typography> </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>
)}
{/* Top reason only */}
{vm.reasons.length > 0 && ( {vm.reasons.length > 0 && (
<Box sx={{ mb: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35, mt: 0.75 }}>
<MatchReasonList reasons={vm.reasons.slice(0, 1)} /> {vm.reasons.slice(0, 2).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={11} color="#1d6342" style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>
{r.label}
</Typography>
</Box>
))}
</Box> </Box>
)} )}
{/* Top tradeoff only (compact) */} {vm.scoreBreakdown && vm.allFactors && vm.allFactors.length > 0 && (
{vm.tradeoffs.length > 0 && ( <>
<Box sx={{ mb: 1 }}> <Divider sx={{ my: 0.75 }} />
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact /> <ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
</Box> </>
)} )}
{/* Data quality — only critical warning in compact mode */} {topRisk && topRisk.level === 'CRITICAL' && (
<MatchDataQualitySummary <Alert icon={<AlertTriangle size={13} />} severity="error"
dataQualityScore={vm.dataQualityScore} sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
missingData={vm.missingData} {topRisk.description}
compact </Alert>
/> )}
<Box sx={{ mt: 1.25 }}> {/* Actions */}
<MatchActionToolbar actions={vm.actions} compact /> <Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1, flexWrap: 'wrap' }}>
<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}
size="small"
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick}
disabled={a.disabled}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
>
{a.label}
</Button>
))}
</Box>
</Box>
</Box> </Box>
</Card>
) )
} })
@@ -4,8 +4,7 @@ import { MatchScoreDisplay } from './MatchScoreDisplay'
import type { MatchCardViewModel } from './MatchCardViewModel' import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified', color: '#152642' },
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
} }
@@ -38,10 +38,10 @@ export function MatchCardHeader({ vm, compact }: Props) {
/> />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && ( {vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip <Chip
icon={<Building2 size={10} color="#1e3a5f" />} icon={<Building2 size={10} color="#152642" />}
label="Ihr Objekt" label="Ihr Objekt"
size="small" 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 && ( {vm.assetType && (
@@ -1,6 +1,7 @@
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match' import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
import type { PropertyUnit } from '../../domain/property' import type { PropertyUnit } from '../../domain/property'
import type { FitOutInvestment } from '../../lib/fitOutUtils'
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini' export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
signalIsControlled?: boolean signalIsControlled?: boolean
signalAreaSqmEstimate?: number 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) // Unit-first display (primary)
propertySubtitle?: string // building name shown below the unit title propertySubtitle?: string // building name shown below the unit title
@@ -25,7 +25,7 @@ const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } { function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0 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.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' } if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' } if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
@@ -93,15 +93,15 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact =
return ( return (
<Box> <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 Hart-Kriterien
</Typography> </Typography>
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)} {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 }}> <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% {sb.hardMatchScore}/100 × 60%
</Typography> </Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{hardContrib} Pkt</Typography> <Typography variant="caption" sx={{ fontWeight: 800, color: '#152642' }}>{hardContrib} Pkt</Typography>
</Box> </Box>
{softFactors.length > 0 && ( {softFactors.length > 0 && (
@@ -10,7 +10,7 @@ export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'L
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{c.dots.map((filled, i) => ( {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 }}> <Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
{c.label} {c.label}
+1
View File
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
export { MatchActionToolbar } from './MatchActionToolbar' export { MatchActionToolbar } from './MatchActionToolbar'
export { MatchCardSkeleton } from './MatchCardSkeleton' export { MatchCardSkeleton } from './MatchCardSkeleton'
export { MatchCardRestrictedState } from './MatchCardRestrictedState' export { MatchCardRestrictedState } from './MatchCardRestrictedState'
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
export type { export type {
MatchCardViewModel, MatchCardViewModel,
MatchCardVariant, 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' }}> <Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{need && ( {need && (
<Chip <Chip
label={need.companyName} label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
size="small" 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 <Chip
@@ -5,7 +5,7 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' }, PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' }, APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' }, REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
SHORTLISTED: { label: 'Shortlist', color: '#1e3a5f' }, SHORTLISTED: { label: 'Shortlist', color: '#152642' },
} }
export function MatchStatusBadge({ status }: { status?: MatchStatus }) { 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 }}> <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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, mr: 0.5, minWidth: 0 }}>
{need.companyName} <Typography variant="body2" sx={{ fontWeight: 600, lineHeight: 1.3 }} noWrap>
{need.isAnonymous ? 'Anonyme Anfrage' : need.companyName}
</Typography> </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 && ( {pendingCount > 0 && (
<Chip label={pendingCount} size="small" <Chip label={pendingCount} size="small"
sx={{ bgcolor: '#d97706', color: 'white', fontSize: 10, height: 18, minWidth: 22 }} /> 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 }[] = [ const rows: { icon: React.ReactNode; label: string; text: string }[] = [
{ {
icon: <Target size={15} color="#1e3a5f" />, icon: <Target size={15} color="#152642" />,
label: 'Gesamteignung', label: 'Gesamteignung',
text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`, text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`,
}, },
@@ -0,0 +1,133 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
function chfRange(min: number, max: number): string {
if (Math.round(min) === Math.round(max)) return chf(min)
return `${chf(min)} ${chf(max)}`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
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, tenantBudgetPerSqm = 0 }: Props) {
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && (
<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')}`}
/>
{isReadyToMoveIn ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : (
<Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
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>
)
}
@@ -0,0 +1,92 @@
import { useState, useEffect } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
TextField, Typography,
} from '@mui/material'
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
}
export function InquiryQuickDialog() {
const showToast = useToastStore(s => s.showToast)
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
useEffect(() => {
if (dialogOpen && pendingInquiry) {
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
}
}, [dialogOpen, pendingInquiry])
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
closeInquiryDialog()
}
if (!pendingInquiry) return null
return (
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Send size={16} />
Anfrage senden
</DialogTitle>
<DialogContent sx={{ pt: 0 }}>
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{pendingInquiry.location}
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
{' · '}Match {pendingInquiry.matchScore}%
</Typography>
</Box>
<TextField
fullWidth
multiline
rows={8}
value={message}
onChange={e => setMessage(e.target.value)}
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
Der Text kann vor dem Absenden angepasst werden.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
<Button
onClick={handleSend}
variant="contained"
size="small"
disabled={!message.trim()}
startIcon={<Send size={14} />}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
</DialogActions>
</Dialog>
)
}
@@ -6,8 +6,7 @@ import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal' import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = { const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' }, VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#152642' },
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
@@ -102,7 +101,7 @@ export function MatchDetailHeader({ match, property, signal, onBack, onCompare,
size="small" size="small"
startIcon={<Columns2 size={14} />} startIcon={<Columns2 size={14} />}
onClick={onCompare} onClick={onCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
> >
Vergleichen Vergleichen
</Button> </Button>
@@ -19,6 +19,10 @@ interface MatchDetailHeroProps {
keyFacts: Array<{ label: string; value: string }> keyFacts: Array<{ label: string; value: string }>
onCompare: () => void onCompare: () => void
onShortlist: () => void onShortlist: () => void
searchCenterLat?: number
searchCenterLng?: number
searchRadiusKm?: number
searchLabel?: string
} }
export function MatchDetailHero({ export function MatchDetailHero({
@@ -32,13 +36,17 @@ export function MatchDetailHero({
keyFacts, keyFacts,
onCompare, onCompare,
onShortlist, onShortlist,
searchCenterLat,
searchCenterLng,
searchRadiusKm,
searchLabel,
}: MatchDetailHeroProps) { }: MatchDetailHeroProps) {
return ( return (
<> <>
{/* Hero: image first, map fallback */} {/* Hero: image first, map fallback */}
{!isFuture && ( {!isFuture && (
property?.images?.[0] ? ( property?.images?.[0] ? (
<Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e2e8f0', flexShrink: 0 }}> <Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e8e7e4', flexShrink: 0 }}>
<img <img
src={property.images[0]} src={property.images[0]}
alt={property.title} alt={property.title}
@@ -51,6 +59,10 @@ export function MatchDetailHero({
lng={property.location.coordinates.lng} lng={property.location.coordinates.lng}
label={property.title} label={property.title}
height={340} height={340}
searchCenterLat={searchCenterLat}
searchCenterLng={searchCenterLng}
searchRadiusKm={searchRadiusKm}
searchLabel={searchLabel}
/> />
) : null ) : null
)} )}
@@ -93,7 +105,7 @@ export function MatchDetailHero({
size="small" size="small"
startIcon={<Columns2 size={14} />} startIcon={<Columns2 size={14} />}
onClick={onCompare} onClick={onCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }} sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
> >
Vergleichen Vergleichen
</Button> </Button>
@@ -1,9 +1,11 @@
import { Box, Button, Chip, Paper, Typography } from '@mui/material' import { Box, Button, Chip, Paper, Typography } from '@mui/material'
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react' import { Building2, Clock, ExternalLink, HardHat, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds' import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']> type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -61,6 +63,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />} {property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />} {property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />} {property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')}`} />}
{property.hardFacts?.fitOut && (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return <KeyFactRow label="Ausbaustandard" value={mab ? `${base} + CHF ${mab}/m² MAB` : base} />
})()}
</Paper> </Paper>
{/* Eigenschaften */} {/* Eigenschaften */}
@@ -132,6 +140,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
</Paper> </Paper>
)} )}
{/* Grundriss */}
{(() => {
const plans: Array<{ url: string; label?: string }> = []
if (matchedUnit?.floorPlanUrl) {
const lbl = matchedUnit.unitLabel
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
: FLOOR_LABEL(matchedUnit.floorLevel)
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
} else {
units.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
}
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} />
})()}
{/* Beschreibung */} {/* Beschreibung */}
{property.description && ( {property.description && (
<Paper sx={{ p: 2.5 }}> <Paper sx={{ p: 2.5 }}>
@@ -157,7 +183,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.dataQuality.lastVerifiedAt && ( {property.dataQuality.lastVerifiedAt && (
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} /> <KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
)} )}
{property.sourceUrl && ( {property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}> <Box sx={{ mt: 1.25 }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}> <Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
Zum Originalinserat Zum Originalinserat
@@ -0,0 +1,100 @@
import { memo, useState } from 'react'
import { Box, Button, Paper, Typography } from '@mui/material'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { PropertyMap } from '../shared'
import {
ExecutiveSummaryPanel,
NeedAlignmentPanel,
LocationIntelligencePanel,
TradeoffPanel,
RiskPanel,
MissingInformationPanel,
FutureAvailabilityContextPanel,
ScoreBreakdownPanel,
} from '.'
import { MatchDetailPropertySections } from './MatchDetailPropertySections'
import { useMatchDetail } from '../../hooks/useMatches'
import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need'
import type { FutureSignal } from '../../domain/futureSignal'
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
import { lookupCityCoords } from '../../lib/locationIntelligence'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
interface Props {
match: Match
property: Property | null
need: Need | null
signal: FutureSignal | null
isFuture: boolean
taxCalculatorUrl?: string
}
export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown({
match,
property,
need,
signal,
isFuture,
taxCalculatorUrl,
}: Props) {
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
const searchLabel = need?.preferredLocations?.[0]
return (
<>
{/* ── Full analysis toggle ── */}
<Button
variant="outlined"
onClick={() => setShowFullAnalysis(v => !v)}
fullWidth
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
sx={{
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
textTransform: 'none', fontWeight: 500,
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
}}
>
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
</Button>
{/* ── Full analysis (hidden by default) ── */}
{showFullAnalysis && (
<>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
<LocationIntelligencePanel property={property} />
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
searchCenterLat={searchCenter?.lat}
searchCenterLng={searchCenter?.lng}
searchRadiusKm={need?.searchRadius}
searchLabel={searchLabel}
/>
</Paper>
)}
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
</>
)}
</>
)
})
@@ -1,23 +1,17 @@
import { Box, Button, Paper, Typography } from '@mui/material' import { Box, Button, Paper, Typography } from '@mui/material'
import { MessageSquare } from 'lucide-react'
import type { Match, NextBestAction } from '../../domain/match' import type { Match, NextBestAction } from '../../domain/match'
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 } const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
HIGH: 'contained',
MEDIUM: 'outlined',
LOW: 'outlined',
}
interface Props { interface Props {
match: Match match: Match
onCompare?: () => void onCompare?: () => void
onShortlist?: () => void onPipeline?: () => void
onReject?: () => void onInquire?: () => void
onReview?: () => void
} }
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) { export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort( const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2) (a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
) )
@@ -26,19 +20,27 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
<Paper sx={{ p: 2.5, mb: 2 }}> <Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography> <Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
{engineActions.length > 0 && ( <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}> {onInquire && (
{engineActions.map((action, i) => ( <Button
fullWidth
size="small"
variant="contained"
startIcon={<MessageSquare size={14} />}
onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
)}
{engineActions.length > 0 && engineActions.map((action, i) => (
<Box key={i}> <Box key={i}>
<Button <Button
fullWidth fullWidth
size="small" size="small"
variant={PRIORITY_COLOR[action.priority]} variant="outlined"
sx={ sx={{ justifyContent: 'flex-start' }}
action.priority === 'HIGH'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
: { justifyContent: 'flex-start' }
}
> >
{action.label} {action.label}
</Button> </Button>
@@ -49,14 +51,10 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
)} )}
</Box> </Box>
))} ))}
</Box>
)}
{/* Standard actions */} {onPipeline && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}> <Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
{onShortlist && ( Zur Pipeline hinzufügen
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
Zur Shortlist hinzufügen
</Button> </Button>
)} )}
{onCompare && ( {onCompare && (
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
Zum Vergleich hinzufügen Zum Vergleich hinzufügen
</Button> </Button>
)} )}
{onReview && (
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
Zur Überprüfung senden
</Button>
)}
{onReject && (
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
Match ablehnen
</Button>
)}
</Box> </Box>
</Paper> </Paper>
) )
@@ -11,11 +11,11 @@ import {
ExternalLink, ExternalLink,
Info, Info,
Layers, Layers,
ShieldCheck,
Tag, Tag,
Train, Train,
TrendingUp, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { import {
ASSET_LABELS, ASSET_LABELS,
@@ -26,6 +26,7 @@ import {
SOURCE_LABELS, SOURCE_LABELS,
UnitRow, UnitRow,
} from './MatchDetailPropertyDetails' } from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds' import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
interface PropertyDetailPublicSectionsProps { interface PropertyDetailPublicSectionsProps {
@@ -238,6 +239,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
</Paper> </Paper>
)} )}
{/* ── Grundriss ── */}
{(() => {
const allUnits = property.units ?? []
const plans: Array<{ url: string; label?: string }> = []
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} mb={2} />
})()}
{/* ── Beschreibung ── */} {/* ── Beschreibung ── */}
{property.description && ( {property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}> <Paper sx={{ mb: 2, p: 2.5 }}>
@@ -270,7 +283,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
/> />
)} )}
{property.sourceUrl && ( {property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}> <Box sx={{ mt: 1.25 }}>
<Button <Button
size="small" size="small"
@@ -1,5 +1,5 @@
import { Box, Chip, Paper, Typography } from '@mui/material' import { Box, Chip, Paper, Typography } from '@mui/material'
import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react' import { Banknote, Calendar, HardHat, MapPin, Maximize2, Tag } from 'lucide-react'
import type { Match } from '../../domain/match' import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal' import type { FutureSignal } from '../../domain/futureSignal'
@@ -42,6 +42,16 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '' }, { icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '' },
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '' }, { icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '' },
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '' }, { icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '' },
...(property?.hardFacts?.fitOut ? [{
icon: <HardHat size={15} />,
label: 'Ausbaustandard',
value: (() => {
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
return mab ? `${base} + CHF ${mab}/m² MAB` : base
})(),
}] : []),
] ]
return ( return (
+3
View File
@@ -1,5 +1,7 @@
export { LocationIntelligencePanel } from './LocationIntelligencePanel' export { LocationIntelligencePanel } from './LocationIntelligencePanel'
export { MatchDetailHeader } from './MatchDetailHeader' export { MatchDetailHeader } from './MatchDetailHeader'
export { MatchDetailHero } from './MatchDetailHero'
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel' export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
export { PropertyOverviewPanel } from './PropertyOverviewPanel' export { PropertyOverviewPanel } from './PropertyOverviewPanel'
export { NeedAlignmentPanel } from './NeedAlignmentPanel' export { NeedAlignmentPanel } from './NeedAlignmentPanel'
@@ -10,4 +12,5 @@ export { MissingInformationPanel } from './MissingInformationPanel'
export { SourceProvenancePanel } from './SourceProvenancePanel' export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel' export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel' export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -20,7 +20,7 @@ export function factorLabel(criterion: string): string {
// Convert normalised weight to 1-5 importance level relative to other factors in the same set // Convert normalised weight to 1-5 importance level relative to other factors in the same set
export function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } { export function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0 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.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' } if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' } if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
<TextField <TextField
label="Fläche (m²)" value={areaSqm} label="Fläche (m²)" value={areaSqm}
onChange={e => onAreaSqmChange(e.target.value)} onChange={e => onAreaSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/> />
<TextField <TextField
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm} label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
onChange={e => onRentPerSqmChange(e.target.value)} onChange={e => onRentPerSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/> />
<TextField <TextField
@@ -0,0 +1,33 @@
import { Box, Card, TextField, Typography } from '@mui/material'
interface Props {
floorPlanUrl: string
onFloorPlanUrlChange: (v: string) => void
}
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
URL zu einem Grundrissbild hilft Interessenten, sich die Fläche vorzustellen.
</Typography>
<TextField
label="Grundriss-URL"
value={floorPlanUrl}
onChange={e => onFloorPlanUrlChange(e.target.value)}
size="small"
fullWidth
placeholder="https://…"
/>
{floorPlanUrl && (
<Box
component="img"
src={floorPlanUrl}
alt="Grundriss Vorschau"
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
/>
)}
</Card>
)
}
@@ -1,4 +1,4 @@
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material' import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants' import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
interface Props { interface Props {
@@ -10,6 +10,12 @@ interface Props {
onParkingChange: (v: string) => void onParkingChange: (v: string) => void
ceilingHeight: string ceilingHeight: string
onCeilingHeightChange: (v: string) => void onCeilingHeightChange: (v: string) => void
mieterausbaubeitrag: string
onMieterausbaubeitragChange: (v: string) => void
isFlexible: boolean
onIsFlexibleChange: (v: boolean) => void
minLettableSqm: string
onMinLettableSqmChange: (v: string) => void
} }
export function TechnicalDetailsSection({ export function TechnicalDetailsSection({
@@ -17,6 +23,9 @@ export function TechnicalDetailsSection({
fitOut, onFitOutChange, fitOut, onFitOutChange,
parking, onParkingChange, parking, onParkingChange,
ceilingHeight, onCeilingHeightChange, ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) { }: Props) {
return ( return (
<Card sx={{ p: 3, mb: 3 }}> <Card sx={{ p: 3, mb: 3 }}>
@@ -35,17 +44,59 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem> <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))} ))}
</TextField> </TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField <TextField
label="Parkplätze" value={parking} label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)} onChange={e => onParkingChange(e.target.value)}
size="small" type="number" inputProps={{ min: 0 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
/> />
<TextField <TextField
label="Deckenhöhe (m)" value={ceilingHeight} label="Deckenhöhe (m)" value={ceilingHeight}
onChange={e => onCeilingHeightChange(e.target.value)} onChange={e => onCeilingHeightChange(e.target.value)}
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
/> />
</Box> </Box>
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
control={
<Switch
checked={isFlexible}
onChange={e => onIsFlexibleChange(e.target.checked)}
size="small"
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Fläche ist teilbar</Typography>
<Typography variant="caption" color="text.secondary">
Mieter können auch nur einen Teil der Fläche mieten
</Typography>
</Box>
}
sx={{ m: 0, flex: 1 }}
/>
{isFlexible && (
<TextField
label="Mindesteinheit (m²)"
value={minLettableSqm}
onChange={e => onMinLettableSqmChange(e.target.value)}
size="small"
type="number"
sx={{ width: 180 }}
slotProps={{ htmlInput: { min: 10, step: 10 } }}
helperText="Kleinste vermietbare Einheit"
/>
)}
</Box>
</Card> </Card>
) )
} }

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