36 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
227 changed files with 7580 additions and 2812 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
"allow": [ "allow": [
"Bash(git commit -m ' *)", "Bash(git commit -m ' *)",
"Bash(git commit *)", "Bash(git commit *)",
"Bash(node -e ' *)" "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 -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 -1
View File
@@ -9,7 +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 },
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…'
+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>
+3 -3
View File
@@ -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>
) )
+51 -5
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'
@@ -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 -2
View File
@@ -4,7 +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',
MAISON_WORK: '#0369a1', MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed', FUTURE_AVAILABILITY: '#7c3aed',
} }
@@ -92,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,19 +1,15 @@
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 { 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
@@ -30,52 +26,64 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
const { currentUser } = useSessionStore() const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog) 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} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} /> <LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={200} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
{/* Score badge — bottom-left editorial */}
<Box sx={{ <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" />
@@ -83,24 +91,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
</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.`
@@ -108,43 +116,53 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
</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', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<Button <Button
size="small" size="small"
variant="contained" variant="contained"
@@ -158,7 +176,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
propertyId: vm.propertyId, propertyId: vm.propertyId,
}) })
}} }}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
> >
Anfrage Anfrage
</Button> </Button>
@@ -169,9 +187,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
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' } }
: {} : {}
+83 -79
View File
@@ -1,11 +1,11 @@
import { memo } from 'react' import { memo } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material' import { Alert, Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react' import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore' import { useInquiryStore } from '../../stores/inquiryStore'
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 { confidenceHex } from '../../lib/utils'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown' import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import { MatchCardRestrictedState } from './MatchCardRestrictedState' import { MatchCardRestrictedState } from './MatchCardRestrictedState'
@@ -25,12 +25,20 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const tier = getScoreTier(vm.matchScore) const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier] const scoreBadgeBg = SCORE_THEME[tier].gradient
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 confPct = Math.round(vm.confidenceScore * 100) const confPct = Math.round(vm.confidenceScore * 100)
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH') const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent' const leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
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 (
<Box sx={{ <Box sx={{
@@ -38,16 +46,17 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
mb: 1.5, mb: 1.5,
borderRadius: 2, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
border: `2px solid ${borderColor}`, border: `1px solid ${DS_BORDER.default}`,
boxShadow: '0 2px 12px rgba(0,0,0,0.07)', borderLeft: `3px solid ${leftAccent}`,
background: theme.cardBg, 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: 'box-shadow 0.15s', transition: 'border-color 0.15s',
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' }, '&:hover': { borderColor: '#c8c7c4' },
}}> }}>
{/* ── Image strip ── */} {/* ── Image strip ── */}
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}> <Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
{imageUrl ? ( {imageUrl ? (
<img <img
src={imageUrl} src={imageUrl}
@@ -56,35 +65,37 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
/> />
) : ( ) : (
<Box sx={{ <Box sx={{
width: '100%', height: '100%', minHeight: 120, width: '100%', height: '100%', minHeight: 140,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)', bgcolor: '#f4f3f0',
}}> }}>
<MapPin size={24} color="#7ba3bf" /> <MapPin size={24} color="#c4c4c4" />
</Box> </Box>
)} )}
{/* Score badge */} {/* Score badge */}
<Box sx={{ <Box sx={{
position: 'absolute', top: 8, left: 8, position: 'absolute', top: 8, left: 8,
background: theme.gradient, borderRadius: '8px', bgcolor: scoreBadgeBg, borderRadius: '6px',
px: 1, py: 0.25, px: 0.875, py: 0.25,
border: `1px solid ${theme.border}`, border: `1px solid ${SCORE_THEME[tier].border}`,
boxShadow: `0 2px 8px ${theme.glow}`, }}>
<Typography sx={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.125rem',
color: '#ffffff', lineHeight: 1,
}}> }}>
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
{vm.matchScore}% {vm.matchScore}%
</Typography> </Typography>
</Box> </Box>
{/* Asset type label */}
{overlayTypeLabel && ( {overlayTypeLabel && (
<Box sx={{ <Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0, position: 'absolute', bottom: 0, left: 0, right: 0,
px: 0.75, py: 0.5, px: 0.75, py: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)', background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%)',
}}> }}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}> <Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
{overlayTypeLabel} {overlayTypeLabel}
</Typography> </Typography>
</Box> </Box>
@@ -92,104 +103,91 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
</Box> </Box>
{/* ── Content ── */} {/* ── Content ── */}
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}> <Box sx={{ flex: 1, p: 2, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Alerts */}
{vm.disclaimer && ( {vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert> <Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
)} )}
{/* Badges row */} {/* Badges row */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center', mb: 0.75 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
<HeatBadge propertyId={vm.propertyId} size="sm" /> <HeatBadge propertyId={vm.propertyId} size="sm" />
<Chip label={rt.label} size="small" <Chip label={rt.label} size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} /> sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && ( {vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small" <Chip icon={<Building2 size={9} color="#152642" />} label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} /> 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" <Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} /> sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{vm.availabilityLabel && ( {visibleExtra.map((label, i) => (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} /> <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>
)} )}
{topRisk && (
<Chip
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
size="small" color="error" variant="outlined"
sx={{ fontSize: 10, height: 20 }}
/>
)}
</Box>
</Box> </Box>
{/* Title + location */} {/* Title + location */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap> <Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{vm.title} {vm.title}
</Typography> </Typography>
{vm.propertySubtitle && ( {vm.propertySubtitle && (
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap> <Typography variant="caption" sx={{ color: DS_TEXT.secondary, fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
{vm.propertySubtitle} {vm.propertySubtitle}
</Typography> </Typography>
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
<MapPin size={11} color="#64748b" /> <MapPin size={11} color="#9a9a9a" />
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{vm.locationLabel} {vm.locationLabel}
</Typography> </Typography>
</Box> </Box>
{/* Explainability summary */}
{vm.explainabilitySummary && ( {vm.explainabilitySummary && (
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45, <Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 500, mt: 0.75, lineHeight: 1.5,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}> overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{vm.explainabilitySummary} {vm.explainabilitySummary}
</Typography> </Typography>
)} )}
<Divider sx={{ my: 1 }} />
{/* Score breakdown */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 1 }}>
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
</Box>
)}
{/* Reasons — all 3, same style as grid card */}
{vm.reasons.length > 0 && ( {vm.reasons.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35, mt: 0.75 }}>
{vm.reasons.slice(0, 3).map((r, i) => ( {vm.reasons.slice(0, 2).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}> <Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} /> <CheckCircle2 size={11} color="#1d6342" style={{ flexShrink: 0 }} />
<Box> <Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
{r.label} {r.label}
</Typography> </Typography>
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
{r.explanation}
</Typography>
</Box>
</Box> </Box>
))} ))}
</Box> </Box>
)} )}
{/* Top tradeoff */} {vm.scoreBreakdown && vm.allFactors && vm.allFactors.length > 0 && (
{vm.tradeoffs.length > 0 && ( <>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}> <Divider sx={{ my: 0.75 }} />
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} /> <ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}> </>
{vm.tradeoffs[0].description} )}
</Typography>
</Box> {topRisk && topRisk.level === 'CRITICAL' && (
<Alert icon={<AlertTriangle size={13} />} severity="error"
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
{topRisk.description}
</Alert>
)} )}
{/* Actions */} {/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}> <Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1, flexWrap: 'wrap' }}>
<Button size="small" variant="contained" onClick={e => { <Button
size="small"
variant="contained"
onClick={e => {
e.stopPropagation() e.stopPropagation()
openInquiryDialog({ openInquiryDialog({
propertyTitle: vm.title, propertyTitle: vm.title,
@@ -199,13 +197,19 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
propertyId: vm.propertyId, propertyId: vm.propertyId,
}) })
}} }}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}> sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
>
Anfrage Anfrage
</Button> </Button>
{vm.actions.map(a => ( {vm.actions.map(a => (
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'} <Button
onClick={a.onClick} disabled={a.disabled} key={a.id}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}> 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} {a.label}
</Button> </Button>
))} ))}
@@ -4,7 +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' },
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>
)
}
@@ -82,7 +82,7 @@ export function InquiryQuickDialog() {
size="small" size="small"
disabled={!message.trim()} disabled={!message.trim()}
startIcon={<Send size={14} />} startIcon={<Send size={14} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
> >
Anfrage senden Anfrage senden
</Button> </Button>
@@ -6,7 +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' },
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' },
} }
@@ -101,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,11 +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 { 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 { FloorPlanSection } from './FloorPlanSection' import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds' 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']>
@@ -63,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 */}
@@ -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} />
</>
)}
</>
)
})
@@ -28,7 +28,7 @@ export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Pr
variant="contained" variant="contained"
startIcon={<MessageSquare size={14} />} startIcon={<MessageSquare size={14} />}
onClick={onInquire} onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }} sx={{ justifyContent: 'flex-start', bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
> >
Anfrage senden Anfrage senden
</Button> </Button>
@@ -11,7 +11,6 @@ import {
ExternalLink, ExternalLink,
Info, Info,
Layers, Layers,
ShieldCheck,
Tag, Tag,
Train, Train,
TrendingUp, TrendingUp,
@@ -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
@@ -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>
) )
} }
+2 -3
View File
@@ -41,7 +41,7 @@ function Slide0() {
return ( return (
<Box className="flex flex-col items-center text-center px-2"> <Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4"> <Box className="mb-4">
<Building2 size={48} color="#1e3a5f" /> <Building2 size={48} color="#152642" />
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}> <Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Willkommen bei Property Match Willkommen bei Property Match
@@ -74,7 +74,7 @@ function Slide1() {
return ( return (
<Box className="flex flex-col items-center text-center px-2"> <Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4"> <Box className="mb-4">
<Search size={48} color="#1e3a5f" /> <Search size={48} color="#152642" />
</Box> </Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}> <Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Bedarf beschreiben KI findet Matches Bedarf beschreiben KI findet Matches
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
open={open} open={open}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
disableEscapeKeyDown
> >
<DialogContent sx={{ pt: 4, pb: 2 }}> <DialogContent sx={{ pt: 4, pb: 2 }}>
{SLIDES[activeStep]} {SLIDES[activeStep]}
+1 -1
View File
@@ -18,7 +18,7 @@ export function DataCategoryBadgeList({ categories, max }: DataCategoryBadgeList
label={cat} label={cat}
sx={{ sx={{
bgcolor: 'rgba(30,58,95,0.07)', bgcolor: 'rgba(30,58,95,0.07)',
color: '#1e3a5f', color: '#152642',
border: 'none', border: 'none',
fontSize: '0.68rem', fontSize: '0.68rem',
}} }}
@@ -156,7 +156,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps
label={entity.value} label={entity.value}
sx={{ sx={{
bgcolor: 'rgba(30,58,95,0.08)', bgcolor: 'rgba(30,58,95,0.08)',
color: '#1e3a5f', color: '#152642',
border: 'none', border: 'none',
fontSize: '0.75rem', fontSize: '0.75rem',
cursor: 'default', cursor: 'default',
+2 -2
View File
@@ -48,10 +48,10 @@ export function MarketSignalFilterBar({ filters, onChange }: MarketSignalFilterB
sx={{ sx={{
fontSize: '0.65rem', fontSize: '0.65rem',
height: 20, height: 20,
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
@@ -50,7 +50,7 @@ export function ReviewGatePanel({ gate }: ReviewGatePanelProps) {
{reviewerNotes.length > 0 && ( {reviewerNotes.length > 0 && (
<Box sx={{ mt: 1, p: 1, bgcolor: 'rgba(30,58,95,0.04)', borderRadius: 1, border: '1px solid rgba(30,58,95,0.1)' }}> <Box sx={{ mt: 1, p: 1, bgcolor: 'rgba(30,58,95,0.04)', borderRadius: 1, border: '1px solid rgba(30,58,95,0.1)' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f', display: 'block', mb: 0.5 }}> <Typography variant="caption" sx={{ fontWeight: 600, color: '#152642', display: 'block', mb: 0.5 }}>
Reviewer-Informationen Reviewer-Informationen
</Typography> </Typography>
{reviewerNotes.map((note, i) => ( {reviewerNotes.map((note, i) => (
+1 -1
View File
@@ -50,7 +50,7 @@ export function SignalConversionPanel({ signal }: SignalConversionPanelProps) {
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<ArrowRight size={16} color="#1e3a5f" /> <ArrowRight size={16} color="#152642" />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
In Future Availability überführen In Future Availability überführen
</Typography> </Typography>
+1 -1
View File
@@ -43,7 +43,7 @@ export function SignalInbox({
<Chip <Chip
label={isLoading ? '…' : signals.length} label={isLoading ? '…' : signals.length}
size="small" size="small"
sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }} sx={{ bgcolor: '#152642', color: '#fff', fontSize: '0.7rem', height: 20 }}
/> />
</Box> </Box>

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