45 Commits

Author SHA1 Message Date
Benjamin Sutter a825672197 feat: collapsible filter bar, compact property cards, offer wizard field selection
- Properties page: collapsible decision/filter strip (localStorage state), slim
  single-row Datenpflege status bar (chips + button), 4-column card grid
- PropertyIntelligenceCard: reduced image height (160→120px), tighter body padding
- OfferWizard (latente Anfragen): new "Felder wählen" step between property
  selection and PDF preview; uses ReportObjectFieldSelector per property
- MockPdfPreview: adds second PDF page showing selected properties side by side
  with photo, title, location and all chosen hard/soft fact fields
- offerWizardStore: adds select_fields step type and fieldSelections state

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:28:42 +02:00
Benjamin Sutter 11de05025c fix: merge EXTERNAL_MARKET into Verifiziertes Objekt
Only 3 result types shown to users: Verifiziertes Objekt, Maison Work,
Zukunftssignal. EXTERNAL_MARKET label and color now identical to
VERIFIED_PORTFOLIO — no visible distinction for end users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:18:33 +02:00
Benjamin Sutter 99c26b5464 feat: Decision Intelligence simplification pass
Phase 1 — MatchDetail: collapse from 11 panels to 3 visible sections
(Warum dieser Match? / Nächste Aktion / toggle). All analysis panels
(ScoreBreakdown, Risk, MissingData, NeedAlignment, Location, Tradeoffs,
FutureAvailabilityContext) hidden behind "Vollständige Analyse anzeigen".
Removes 2-column sidebar layout for cleaner single-column reading flow.

Phase 2 — Supply Dashboard: replace data quality KPIs with demand
intelligence. Hero now shows "Starke Match-Anfragen", active properties,
and Zukunftssignale count. Top 4 matches listed with score + reason +
next action. Data quality demoted to secondary collapsed notice.

Phase 3 — Future Availability: add informational banner above the results
feed when Zukunftssignale are present. Framed as professional market
intelligence (contract expiries, construction signals) not as risk.
Removed Zukunftssignal from DecisionContextPanel risks array.

Phase 4 — Naming: standardise RESULT_TYPE_META to "Verifiziertes Objekt" /
"Externes Angebot" / "Maison Work" / "Zukunftssignal" across all screens.
EXTERNAL_MARKET gets distinct amber color (#d97706). Remove Gold/Silver/
Bronze tier label from match score badge — only the % number is shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:09:12 +02:00
Benjamin Sutter 58a0a05eff test: integration coverage for stores, services, MockAIService, and hooks
- Stores (29 tests): compareStore max-4 enforcement, toastStore unique IDs,
  shortlistStore dialog state, assistantStore context merge + clearConversation
- Services (37 tests): needService CRUD, propertyService dashboard summary
  active-status filter, matchService computeMatchesForNeed + getStrongMatches,
  futureSignalService summary math (distribution totals, highConfidence count)
- MockAIService (18 tests): follow-up question priority ordering, area ambiguity
  detection (ratio >8 and zero values), max-3 cap, trade-off risk logic
  (LOW/MEDIUM/HIGH thresholds), match explanation headline tiers, provenance shape
- Hooks (20 tests): useNeeds/useProperties/useMatches envelope unwrap, disabled-
  when-empty guards, useCreateNeed/useCreateProperty mutations, useNeed/useMatchDetail

All 257 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:55:02 +02:00
Benjamin Sutter e1f4beb898 refactor: architecture compliance pass — DS tokens, hook boundary, god component split, AI hardening
- DS token migration: Anfragen.tsx + child components (AnfragenInquiryItem, AnfragenMessageBubble)
  fully migrated; DS_TEXT.brandDark added; scoreTheme.ts moved to src/lib/ with re-export proxy
- Hook boundary: Results.tsx no longer calls needService directly — routes through useNeeds()
  with optional refetchOnMount/gcTime overrides
- NewListing.tsx (440L) split into useNewListingForm hook + 8 section components under
  src/components/new-listing/; page shell reduced to 121 lines
- AI hardening: Zod .strict() on all schemas, AIProvenance extended with schemaVersion/
  fallbackReason/traceId/latencyMs, AITraceStore stats with p50/p90/p99 + failure breakdowns,
  MockAIService buildFollowUpQuestions with priority ordering + area-ambiguity detection,
  prompt templates updated (LIGHT_INDUSTRIAL, budget unit, ambiguity detection, decimal precision)
- Tests: all 154 passing; fixed test regression caused by OfferEmailResponseSchema body min(50)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:10:39 +02:00
Benjamin Sutter e36c5bc979 refactor(arch): eliminate direct service calls in components — route all through hooks
New hook files:
- useAuth.ts: useLogin, useSwitchDemoRole, useSwitchOrganization
- useAI.ts: useParseNeed, useGenerateOfferEmail, useGenerateDecisionBrief, useParseListingText
- useAssistant.ts: useAssistantSuggestions (useQuery), useAssistantAnswer (useMutation)
- useOfferReport.ts: useOfferReportByInquiry, useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf
- useInquiryReport.ts: useInquiryReportByInquiry, useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport
- useMarketReport.ts: useMarketReport
- useWeighting.ts: useDefaultWeights (synchronous wrapper)
- useUnitMatches.ts: useUnitMatchesMap, useUnitBundle, useBundleMatches (useMemo wrappers)

Extended hooks: useProperties (add update/create/remove mutations),
useNeeds (add useCreateNeed), useMatches (add useNeedMatchesForProperty,
useAdditionalMatchesForInquiry), useReviewQueue (add useCreateReviewTask)

Updated 21 components/pages: all direct service imports replaced with hooks.
Deliberate exception: getRecommendedActions in DataQuality.tsx (pure sync utility, no provider access).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:56:32 +02:00
Benjamin Sutter f487435a94 fix: correct rent unit labels and monthly/annual calculation consistency
- NeedAlignmentPanel: display budget row in monthly (CHF/m²/Mt.) to match
  the Preis section, use exact division (no Math.round) to avoid 13×12≠152
- MatchDetailPropertySections + PropertyDetailPublicSections: replace
  Math.round(rentPricePerSqm/12) with exact division; show 2 decimal places
  when monthly is not a whole number (e.g. CHF 12.67 instead of CHF 13)
- Add /Jahr suffix to all 15 displays showing rentPricePerSqm or maxPerSqm
  without a time unit across results, compare, match-detail, supply, and
  anfragencenter components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:35:20 +02:00
Benjamin Sutter d4171fe9b5 feat(ai): observability tracing + improved prompt templates
- Add AITrace type, AITraceStore (circular buffer, localStorage in DEV,
  window.__aiTraces for DevTools), provenanceToStatus() helper
- Instrument OpenRouterAIService withFallback with latency tracking and
  trace recording across all three paths (no-key, success, error)
- Wrap all MockAIService methods with traceMock for consistent in-memory
  tracing including method name, latency, and validation status
- Improve all 6 prompt templates with ROLLE/AUFGABE/VERBOTE/BEISPIEL
  structure; marketSignalPrompt carries hard prohibition against claiming
  confirmed availability from unconfirmed signals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:18:42 +02:00
Benjamin Sutter 7934da7669 feat: design token system — DS_TEXT/DS_SURFACE/DS_BORDER + 237 hex migrations
Token infrastructure (src/lib/ds.ts):
- DS_TEXT: 16 semantic text color tokens (primary/secondary/muted/success/
  warning/error/info/brand/signal + dark variants successDark/warningDark/
  signalDark/infoDark for text on tinted surfaces)
- DS_BG: page/surface/subtle/muted background tokens
- DS_BORDER: default/muted/strong border tokens
- DS_SURFACE: 10 bg+border surface pairs (success/warning/error/info/indigo/
  purple/orange/blue/neutral/slate)
- DS_MATCH_TIER: score-tier surface aliases keyed by strong/moderate/weak
- DS_PRE_MARKET / DS_MARKET_SIGNAL: named aliases for futureCard tokens
- DS_SHADOW: card/panel/dialog elevation tokens
- BADGE_COLORS: 14 semantic presets for GenericBadge (confidenceHigh,
  riskMedium, preMarket, marketSignal, verified, gold, silver, bronze…)

GenericBadge (src/components/shared/GenericBadge.tsx):
- New semanticVariant prop (keyof BADGE_COLORS) — preferred over raw hex
- color prop becomes optional (fallback), type-documented as escape hatch

Priority file migrations — 237 hex literals replaced across 10 files:
  ScoreBreakdownPanel.tsx    −22  PipelineDetailPanel.tsx    −22
  FutureAvailabilityCard.tsx −26  AICompareSummary.tsx       −27
  LocationIntelligencePanel  −39  AssistantPromptSuggestions −18
  UnitStructurePanel.tsx     −25  BerichtDialog.tsx          −24
  PreMarketPanel.tsx         −24  PropertyActivityLogPanel   −10

ESLint (eslint.config.js):
- Updated rule message to reference new tokens (DS_TEXT, DS_SURFACE, etc.)
- Added 'stroke' to monitored property names
- Remains 'warn' for gradual migration; use check:tokens for CI gate

CI script (scripts/check-tokens.js + npm run check:tokens):
- Counts hex patterns in components/ + pages/
- Fails if count > THRESHOLD (ratchet: 1958 baseline, lower per sprint)
- Reports top 15 offenders for prioritizing next migration batch

Results: ESLint targeted sx-prop violations: 1752 → 1030 (−41%)
0 TypeScript errors, 154 tests green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:05:52 +02:00
Benjamin Sutter e62391af66 feat: Zod AI validation, AIProvenance governance, fix tests (154 green)
- Add AIProvenance + AIResponse<T> to IAIService — all 11 methods now
  return structured provenance (provider, model, source, fallbackUsed,
  validationPassed) instead of bare ItemResponse<T>
- Add schemas.ts with Zod schemas for all 8 AI response types;
  validateAIResponse() utility returns null on failure, never throws
- Rewrite OpenRouterAIService: every method validates AI JSON against
  its Zod schema; failed validation triggers MockAIService fallback
  with fallbackUsed:true — no invalid data can reach the UI
- Fix MockAIService.generateFollowUpQuestions: replace broken
  mockParseNeed(JSON.stringify(criteria)) with direct ParsedNeedCriteria
  field inspection; returns max 3 prioritised FollowUpQuestion objects
- Add provenance: mockProvenance() to all MockAIService responses
- Improve decisionBriefPrompt: structured JSON schema example,
  confidence vocabulary, availability disclaimer
- Improve matchExplanationPrompt: score-tier vocabulary, isFutureSignal
  flag forbids confirmed-availability language for future signals
- Add 102 new tests: mustHaveScorer (16), softFactorEnrichment (38),
  aiSchemas (52) — 154 total, all passing; 0 TypeScript errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:44:46 +02:00
Benjamin Sutter 8f1db31683 refactor: GenericBadge + Provider-Isolation
Teil A — GenericBadge:
- Neue src/components/shared/GenericBadge.tsx mit zwei Varianten:
  transparent (${color}18 Hintergrund, farbiger Text, opt. Border)
  solid (gefüllte Farbe, weisser Text)
- Props: label, color, variant, showBorder, bold, icon, size, tooltip, ariaLabel
- 7 Badges auf GenericBadge refactored (Config-Objekt + 1-Zeiler):
  ReviewStatusBadge, ReviewPriorityBadge, ReviewEntityTypeBadge,
  AIOutputStatusBadge, AIErrorBadge, MatchStatusBadge, ShortlistStatusBadge
- Barrel-Export in src/components/shared/index.ts

Teil B — Provider-Isolation:
- src/mock-data/propertyStore.ts als neutrales Daten-Modul erstellt
- MockupPropertyProvider: importiert aus mock-data statt selbst zu definieren
- MockupUnitProvider: importiert aus mock-data statt aus MockupPropertyProvider
- matchSyncService: importiert aus mock-data statt aus MockupPropertyProvider
- Kein Provider importiert mehr einen anderen Provider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:13:34 +02:00
Benjamin Sutter 7f090a48ff refactor: replace hardcoded hex colors with design tokens across priority components
- Add DS_COLORS.heat, DS_COLORS.futureCard, INQUIRY_STATUS_META tokens to ds.ts
- Eliminate 4 duplicate RESULT_TYPE_META / TYPE_META constants (now all use ds.ts)
- Eliminate 3 duplicate scoreColor / SCORE_COLOR functions (now all use matchScoreHex)
- HeatBadge: use DS_COLORS.heat.VERY_HOT / HOT tokens
- FutureAvailabilityCard: use DS_COLORS.futureCard.controlled / signal tokens
- IntelligenceMatchCard: import RESULT_TYPE_META from ds.ts, remove primary override
- MatchScoreDisplay: replace local scoreColor() with matchScoreHex()
- CompareColumnHeader: remove local TYPE_META + SCORE_COLOR, use ds.ts + utils.ts
- CompareTableBody: import RESULT_TYPE_META + matchScoreHex, fix #7c3aed → token
- compareUtils: remove TYPE_META + SCORE_COLOR, use dataQualityHex in scoreBar
- Anfragen + AnfragenInquiryItem: deduplicate STATUS_CONFIG → INQUIRY_STATUS_META
- Anfragen: #1e3a5f → 'primary.main', KI alert → futureCard.controlled tokens
- AnfragenMessageBubble: own-message bubble → 'primary.main', AI bubble → tokens
- ScoreBreakdownPanel: #1e3a5f → 'primary.main', #6d28d9 → futureCard.controlled
- ESLint: add warn rule against new hex colors in component sx props

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:02:01 +02:00
Benjamin Sutter c972392b78 feat: OpenRouter-ready AI service — all 11 methods implemented
IAIService:
+ generateMatchExplanation, summarizeTradeOffs, generateDataQualitySummary,
  classifyMarketSignal (4 new methods covering all documented AI output types)

OpenRouterAIService:
- All 11 methods now make real API calls via withFallback() pattern
- Every fallback is explicit (console.warn/error) — no silent mock bleed-through
- Proper JSON extraction with type-safe parsers, no any casts
- parseNeed: AI JSON → ParseNeedResult mapping (no TODO stubs)
- generateFollowUpQuestions, generateMatchExplanation, summarizeTradeOffs,
  generateDataQualitySummary, classifyMarketSignal, generateOfferEmail,
  extractCriteria, generateFollowUp: fully implemented

Prompts: +followUpQuestionsPrompt, +tradeOffPrompt, +dataQualityPrompt, +marketSignalPrompt

Factory (index.ts):
- VITE_AI_PROVIDER=mock|openrouter (new, takes priority)
- VITE_USE_REAL_AI=true still supported (legacy compat)
- Missing API key → explicit console.warn + MockAIService fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:43:51 +02:00
Benjamin Sutter 723f553939 refactor: move PipelineItems from Zustand to Provider→Service→React Query
PipelineItems are domain data and must not live in Zustand. Moves the
full stack to the correct layer: MockupPipelineProvider (localStorage
persistence + seed fallback) → pipelineService → usePipeline hooks
(useQuery for reads, useMutation for writes with cache invalidation).

pipelineStore is now UI-only: dialogOpen, pendingItem, openSavedDialog,
closeSavedDialog. All consumers updated to use the new hooks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:32:57 +02:00
Benjamin Sutter 0582031930 test: Vitest test infrastructure + 52 unit tests for matching engine
Install vitest, @vitest/coverage-v8, jsdom, @testing-library/react/jest-dom.
Add test / test:watch / test:coverage scripts to package.json.

Three test suites covering the business-critical scoring pipeline:

scoreCalculator.test.ts (28 tests)
- calcDataQualityModifier: all 5 boundary thresholds (+5 / 0 / -5 / -10 / -15)
- calcConfidenceModifier: verified/external/maison-work/future + low-conf stacking
- applyHardFilters: pass, wrong asset type, area tolerance, budget exclusion, OCCUPIED penalty, excluded city
- calculateScore: strong match ≥85, weak match <50, excluded=0, determinism,
  formula verification, DQ+confidence direction, occupied 25-point penalty,
  positive factors, allHardFactors completeness

rankingEngine.test.ts (10 tests)
- matchStrengthFromScore: STRONG/MODERATE/WEAK boundaries (78/52)
- rankMatches: score sort, type tiebreak (VERIFIED > EXTERNAL), confidence tiebreak
- buildFullMatch: all explainability fields, SHORTLIST+CONTACT for strong match,
  SCHEDULE for future signals, uncertainty indicators

matchCardAdapter.test.ts (14 tests)
- VERIFIED_PORTFOLIO: resultType, title, locationLabel, matchScore, scoreBreakdown,
  no disclaimer, reasons from positiveFactors, actions passthrough
- FUTURE_AVAILABILITY: resultType, disclaimer always present, signalProbability,
  signalQuality HIGH for probability >= 0.70

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:33:12 +02:00
Benjamin Sutter 713ef3ec08 docs: engineering governance — CLAUDE.md, ARCHITECTURE.md, CODE_REVIEW_CHECKLIST.md
Complete rewrite of CLAUDE.md with full binding rules (vision, architecture layers,
component limits, state management, design system, AI integration, performance,
security). New ARCHITECTURE.md covers folder structure, all data flows with ASCII
diagrams, scoring pipeline, AI flow, unified result feed, and workspace routing.
New CODE_REVIEW_CHECKLIST.md provides a 10-section review guide and pre-commit
quick checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:10:30 +02:00
Benjamin Sutter ad84fe8289 fix: rename compareUtils.ts → compareUtils.tsx (contains JSX)
.ts files cannot contain JSX — Vite/oxc parse error at the <Box> on line 83.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:54:46 +02:00
Benjamin Sutter ce67da73b3 perf: memoize expensive list computations + memo on grid/list items
Results.tsx:
- useMemo: filter + sort in one pass (was 7 separate array iterations per render)
- useMemo: platform/maison/future/missingData counts in single for-loop
- Fix: move queryClient.invalidateQueries from render body into useEffect

Properties.tsx:
- useMemo: wrap applyFilters() call (was full copy+sort on every render)
- useMemo: compute matchReady/criticalGaps/lowConfidence/staleOrOutdated/
  allMissingFields in a single for-loop (was 5 separate filter passes)

ReminderFeed.tsx:
- useMemo: wrap applyFilters() call
- useCallback: resetFilters (passed to ReminderEmptyState)

PropertyIntelligenceCard, ReminderListRow:
- React.memo: grid/list items no longer re-render when unrelated parent
  state changes (e.g. selectedId, filter UI state)

tsc --noEmit passes with zero errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:53:03 +02:00
Benjamin Sutter 86bdf142b4 refactor: consolidate duplicated UI score/color/badge logic into lib/
- Add matchScoreHex(), criterionScoreColor(), criterionScoreTextColor() to lib/utils.ts
- Add RESULT_TYPE_META (labels + colors) to lib/ds.ts as single source of truth
- Remove 5 local scoreColor() functions: StrongMatchMiniCard, MatchListCard,
  pipelineUtils (re-exported as matchScoreHex), CriterionRow, ScoreBreakdownPanel,
  ScoreInlineBreakdown
- Remove local RESULT_TYPE_META/TYPE_META from MatchCardHeader, ResultTypeBadge;
  remove RESULT_TYPE_LABEL/COLOR from pipelineConstants — all now use lib/ds.ts
- Replace local confidenceColor() in MatchCardHeader, ConfidenceFieldBadge,
  ReliabilityScorePanel with confidenceHex() from lib/utils.ts
- Replace local qualityColor() in propertyHelpers, DataQualityWidget with
  dataQualityColor() from lib/utils.ts
- Fix FUTURE_AVAILABILITY label inconsistency ('Future'/'Zukunft' → 'Zukunftssignal')
- Fix EXTERNAL_MARKET label inconsistency ('Direktinserat' → 'Plattform')
- tsc --noEmit passes with zero errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:47:08 +02:00
Benjamin Sutter 5d53f35dea refactor(state): clean layoutStore, add selectors, centralise STALE constants
- layoutStore: remove 4 dead field groups (pinnedPanels, selectedResultId,
  compareTrayVisible, notificationsOpen) — none were read outside the store
- CompareTray: drop dead useLayoutStore side-effect (state was write-only)
- 8 components: replace bare useStore() with explicit selectors / useShallow
  to prevent unnecessary re-renders on unrelated state mutations
- lib/constants: add STALE_MARKET_SIGNALS + STALE_REVIEW_QUEUE (30s each)
- useMarketSignals, useReviewQueue: use global constants instead of
  hook-local magic numbers
- Add STATE_MANAGEMENT.md: decision tree + rules for RQ/Zustand/local/derived

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:30:44 +02:00
Benjamin Sutter eedee83b49 refactor: extract SignalSourcesSection from FutureAvailabilityContextPanel (314 → 227 lines)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:18:49 +02:00
Benjamin Sutter 01c0f8b06f refactor: extract helpers from 3 match-detail/match-card components
- LocationIntelligencePanel (333→275): KpiTile + NEW_PROJECTS → own files
- FutureAvailabilityContextPanel (351→315): constants + utils → own files
- FutureAvailabilityCard (310→244): helpers + SignalQualityDots → own files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:16:32 +02:00
Benjamin Sutter c6055f0611 refactor: extract CompareTableBody from Compare.tsx (441 → 147 lines)
Move 18 inline table row definitions into CompareTableBody component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:13:59 +02:00
267 changed files with 16644 additions and 4338 deletions
+4 -1
View File
@@ -10,7 +10,10 @@
"Bash(awk '{print $NF}')",
"Bash(start http://localhost:5173)",
"Bash(npm install *)",
"Bash(git pull *)"
"Bash(git pull *)",
"Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")"
]
}
}
+4 -1
View File
@@ -2,7 +2,10 @@
"permissions": {
"allow": [
"Bash(git commit -m ' *)",
"Bash(git commit *)"
"Bash(git commit *)",
"Bash(node -e ' *)",
"Bash(git stash *)",
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
]
}
}
+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"}
+476
View File
@@ -0,0 +1,476 @@
# Property Match — Architecture Reference
> This document describes the architecture of the Property Match platform.
> For coding rules see [CLAUDE.md](./CLAUDE.md).
> For state management patterns see [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
---
## 1. Folder Structure
```
src/
├── assets/ Static images, icons
├── domain/ TypeScript types & enums (single source of truth)
│ ├── enums.ts AssetType, ResultType, MatchStatus, UserRole, WorkspaceType
│ ├── match.ts Match, ScoreBreakdown, ScoreFactor, TradeOff, Risk
│ ├── property.ts Property, Location, ContactPerson
│ ├── need.ts Need, AreaRange, BudgetRange, WeightingProfile
│ ├── unifiedResult.ts UnifiedMatchResult (discriminated union)
│ ├── scoring.ts ScoringWeightProfile, MatchEngineOutput
│ ├── aiOutput.ts AI scoring, extraction, monitoring types
│ ├── futureSignal.ts Future availability signals
│ ├── pipeline.ts Pipeline stages & items
│ ├── inquiry.ts Demand-side inquiry records
│ ├── reminder.ts Reminder & DueDate
│ └── index.ts Barrel re-exports
├── features/ Business logic algorithms (pure TypeScript)
│ └── matching/
│ ├── scoreCalculator.ts Hard/soft scoring, confidence & DQ modifiers
│ ├── rankingEngine.ts Match ranking, unified result assembly
│ ├── mustHaveScorer.ts Must-have criteria evaluation
│ ├── tradeOffAnalyzer.ts Tradeoff, risk, and missing-data analysis
│ └── matchCardAdapter.ts Match → MatchCardViewModel adapter
├── provider/ Data access layer (interface + mock per entity)
│ ├── IPropertyProvider.ts
│ ├── MockupPropertyProvider.ts
│ ├── INeedProvider.ts
│ ├── MockupNeedProvider.ts
│ └── ... (one pair per entity)
├── services/ Business logic services
│ ├── ai/
│ │ ├── IAIService.ts AI capability interface
│ │ ├── MockAIService.ts Deterministic mock (dev/CI)
│ │ ├── OpenRouterAIService.ts Real LLM calls (production)
│ │ └── prompts/ Structured prompt templates
│ ├── matchService.ts
│ ├── needService.ts
│ ├── propertyService.ts
│ ├── aiMonitoringService.ts
│ ├── governanceService.ts
│ ├── types.ts ListResponse<T>, ItemResponse<T>
│ └── errors.ts throwServiceError()
├── hooks/ React Query wrappers
│ ├── useProperties.ts
│ ├── useMatches.ts
│ ├── useUnifiedResults.ts Aggregates all result types for demand feed
│ └── ... (one file per entity)
├── stores/ Zustand UI stores (global UI state only)
│ ├── sessionStore.ts Auth, currentUser, workspace
│ ├── compareStore.ts Compare tray items (max 4)
│ ├── pipelineStore.ts Pipeline items + add-dialog
│ ├── assistantStore.ts AI assistant drawer conversation
│ ├── offerWizardStore.ts Multi-step offer wizard
│ ├── layoutStore.ts Sidebar, right panel, active workspace
│ ├── matchCenterStore.ts Supply match center selection
│ ├── reminderStore.ts Reminder filters + drawer
│ ├── shortlistStore.ts Shortlist selection + add-dialog
│ └── toastStore.ts Toast notification queue
├── lib/ Shared utilities & design system
│ ├── constants.ts All thresholds, labels, stale times, routes
│ ├── ds.ts DS_COLORS, RESULT_TYPE_META, score → level converters
│ ├── utils.ts matchScoreHex, confidenceHex, dataQualityColor, formatters
│ ├── theme.ts MUI theme
│ ├── queryClient.ts TanStack Query client config
│ ├── permissions.ts Role-based access helpers
│ ├── locationIntelligence.ts Location scoring
│ └── propertyHeat.ts Market heat analysis
├── components/ UI components (organized by feature)
│ ├── layout/ AppShell, PageHeader, RightContextPanel
│ ├── ui/ ErrorBoundary, EmptyState, Toast, DecisionContextPanel
│ ├── shared/ LocationPreview, ViewToggle (cross-feature)
│ ├── match-card/ MatchCard variants (compact/expanded/review/compare-mini)
│ ├── match-detail/ MatchDetailHero, ScoreBreakdownPanel, CriterionRow
│ ├── compare/ CompareMetricRow, CompareColumn, compareUtils
│ ├── pipeline/ PipelineCard, PipelineColumn, pipelineConstants, pipelineUtils
│ ├── results/ UnifiedResultFeed, ResultFilterBar, ResultTypeBadge
│ ├── supply/ PropertyIntelligenceCard, ReminderFeed, ReminderListRow, ...
│ ├── demand/ NeedInput, WeightingEditor, CriteriaReviewPanel, ...
│ ├── future-signals/ SignalCard, SignalFilterBar, FutureAvailabilityPanel, ...
│ ├── data-quality/ DataQualityPanel, FreshnessIndicator, ProvenancePanel, ...
│ ├── badges/ ConfidenceBadge, RiskBadge, FreshnessBadge, ...
│ ├── ai-monitoring/ AI audit log UI
│ └── ...
├── pages/ Route-level page components
│ ├── auth/LoginScreen.tsx
│ ├── supply/ SupplyDashboard, Properties, MatchCenter, ...
│ ├── demand/ AISearch, Results, MatchDetail, Compare, Pipeline, ...
│ └── ops/ MarketIntelligence
├── mock-data/ Seed data for mock providers
├── App.tsx React Router setup (all routes, lazy-loaded)
└── index.css Tailwind layer imports (no project styles here)
```
---
## 2. Data Flow Overview
The core data flow is **unidirectional** across four layers.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Component / Page │
│ │
│ Reads: useQuery hook Writes: useMutation hook │
│ UI state: Zustand selector Side effects: useEffect │
└────────────────┬──────────────────────────┬────────────────────────────┘
│ calls │ calls
▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ Hook Layer │
│ src/hooks/*.ts │
│ │
│ useQuery({ queryFn: () => service.getAll() }) │
│ useMutation({ mutationFn: (data) => service.create(data) }) │
└────────────────────────────────┬───────────────────────────────────────┘
│ calls
┌────────────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ src/services/*.ts │
│ │
│ Business logic, error handling, response standardization │
│ Reads session via sessionStore.getState() │
│ Calls scoring engine (features/matching/) as needed │
└────────────────────────────────┬───────────────────────────────────────┘
│ calls
┌────────────────────────────────────────────────────────────────────────┐
│ Provider Layer │
│ src/provider/*.ts │
│ │
│ IPropertyProvider (interface) │
│ MockupPropertyProvider (in-memory, dev) ← swap → RestPropertyProvider (prod)
└────────────────────────────────────────────────────────────────────────┘
```
---
## 3. Provider Swap Flow
Swapping from mock to real requires **one file change** — the service import. No hooks, components, or tests change.
```
Dev / CI Production
───────────────────────────────────── ─────────────────────────────────────
propertyService.ts propertyService.ts
import provider from import provider from
'./MockupPropertyProvider' './RestPropertyProvider'
One line change — nothing else
```
Provider interfaces enforce the contract. All methods are `async`. Mock providers hold in-memory seed data from `src/mock-data/`.
---
## 4. State Architecture
```
┌──────────────────────────────────────────┐
│ React Query Cache │
│ │
│ ['properties'] ── 5 min stale │
│ ['matches', needId] ── 2 min stale │
│ ['signals'] ── 5 min stale │
│ ['review-queue'] ── 30 s stale │
│ │
│ ← server data, cached, invalidated │
│ on mutation success → │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Zustand Stores │
│ │
│ sessionStore ← auth, currentUser │
│ layoutStore ← sidebar, workspace │
│ compareStore ← compare tray items │
│ pipelineStore ← pipeline + dialog │
│ assistantStore ← AI chat conversation │
│ offerWizardStore← multi-step wizard │
│ reminderStore ← filter + drawer │
│ toastStore ← notification queue │
│ │
│ ← UI state only, no server data → │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────┐
│ Component Local State │
│ │
│ useState ← form inputs, toggles │
│ useMemo ← derived / filtered lists │
│ useCallback ← stable callbacks │
└──────────────────────────────────────────┘
```
---
## 5. Matching & Scoring Flow
The matching engine (`src/features/matching/`) produces all scores. Components never compute scores — they display them.
```
Need (criteria) + Property (attributes)
│ │
└───────────┬────────────┘
┌─────────────────────┐
│ scoreCalculator │
│ │
│ 1. Hard filters │ ← Exclusions (wrong type, out of area, etc.)
│ 2. Hard scoring │ ← Area, location, budget, timing, asset type (0100 each)
│ 3. Soft scoring │ ← Prestige, accessibility, ESG, footfall, etc. (0100 each)
│ 4. Modifiers │ ← confidenceModifier (-20→+5) + dataQualityModifier (-15→0)
│ 5. totalScore │ ← Weighted average + modifiers (0100)
└──────────┬──────────┘
┌─────────────────────┐
│ tradeOffAnalyzer │ ← TradeOff[], Risk[], MissingDataItem[]
└──────────┬──────────┘
┌─────────────────────┐
│ rankingEngine │ ← Builds Match[], ranks, adds NextBestAction[]
└──────────┬──────────┘
┌─────────────────────┐
│ matchCardAdapter │ ← Match → MatchCardViewModel (UI props)
└──────────┬──────────┘
MatchCard / MatchDetail / Compare
```
**ScoreBreakdown shape:**
```ts
{
hardMatchScore: number // 0100: area, location, budget, timing, usage
softFactorScore: number // 0100: prestige, accessibility, ESG, etc.
confidenceModifier: number // 20 → +5
dataQualityModifier: number // 15 → 0
totalScore: number // 0100 final score
positiveFactors: ScoreFactor[] // top reasons this is a good match
negativeFactors: ScoreFactor[] // top concerns
tradeOffs: TradeOff[] // explicit trade-off explanations
risks: Risk[] // risk categories
missingData: MissingDataItem[] // fields that would improve the score
}
```
---
## 6. AI Flow
```
User input (text / voice)
NeedInput component
│ calls
aiService.parseNeed(text)
│ routes to
├── MockAIService (dev) ──────────────► deterministic ParsedNeed
└── OpenRouterAIService (prod) ────────► LLM API call
│ validates with Zod
ParseNeedResult
{ need: Partial<Need>, confidence: number, followUpQuestions: string[] }
CriteriaReviewPanel
(user confirms / overrides AI-extracted criteria)
Need saved → Results feed runs
```
**AI Governance loop:**
```
Every AI call
├─► aiMonitoringService.log({ modelId, promptHash, responseDigest, userId, ... })
└─► Visible in /ops/ai-monitoring (review queue for ops team)
```
**AI never speaks directly to the UI.** All AI outputs go through:
1. `IAIService` interface method
2. Zod validation
3. Typed domain object
4. Component that renders it with explainability metadata
---
## 7. Unified Result Feed Flow (Demand)
The demand workspace shows a unified feed that merges three result types.
```
useUnifiedResults(needId)
├─► matchService.getForNeed(needId)
│ └─► VERIFIED_PORTFOLIO matches (own properties)
│ └─► EXTERNAL_MARKET matches (partner/scraped)
├─► aiService.getMaisonWorkResults(needId)
│ └─► MAISON_WORK matches (AI-suggested)
└─► futureSignalService.getForNeed(needId)
└─► FUTURE_AVAILABILITY signals (AI-predicted)
├── probabilityScore (01)
├── signalBasis (string)
└── expectedAvailabilityDate
All merged into UnifiedMatchResult[]
Sorted by matchScore desc (default)
UnifiedResultFeed
├── MatchCard (VERIFIED_PORTFOLIO, EXTERNAL_MARKET, MAISON_WORK)
└── FutureAvailabilityCard (FUTURE_AVAILABILITY — different visual treatment)
```
**Future Availability signals are probabilistic** — they must always show `probabilityScore` and a basis explanation. They are never presented as confirmed availability.
---
## 8. Three-Workspace Architecture
```
App.tsx
├── /auth/login → LoginScreen
├── /supply/* ──────────────────────────────────► SUPPLY workspace
│ Protected: WorkspaceType.SUPPLY (PropertyManager, OrgAdmin)
│ Pages: SupplyDashboard, Properties,
│ MatchCenter, Anfragencenter,
│ FutureAvailability, DataQuality,
│ ReminderManager, MarketLeads,
│ NewListing, MyListings
├── /demand/* ──────────────────────────────────► DEMAND workspace
│ Protected: WorkspaceType.DEMAND (Tenant, CompanyAdmin)
│ Pages: AISearch, Results, MatchDetail,
│ Compare, Pipeline, Anfragen,
│ PropertyDetail, Shortlists
└── /ops/* ────────────────────────────────────► OPERATIONS workspace
Protected: WorkspaceType.OPERATIONS (Internal staff)
Pages: MarketIntelligence
```
Route protection enforced by `<ProtectedRoute workspace={...} />`. Each workspace has distinct data flows and does not share components with other workspaces unless through `src/components/shared/` or `src/components/ui/`.
---
## 9. Decision Screen Design Pattern
Every page is built around **one user decision**. The `DecisionContextPanel` component surfaces this explicitly at the top of each page.
```
┌─────────────────────────────────────────────────────────────┐
│ PageHeader — title, subtitle, secondary actions (ViewToggle) │
├─────────────────────────────────────────────────────────────┤
│ DecisionContextPanel │
│ decision: "Which matches are ready to shortlist?" │
│ context: "Search: OFFICE · 200500 m² · Zürich" │
│ metrics: [strongMatches: 3 ✓] [withGaps: 2 ⚠] │
│ risks: ["2 results have missing floor plan data"] │
│ actions: [Open Compare] [Refine Search] │
├─────────────────────────────────────────────────────────────┤
│ FilterBar / Controls │
├─────────────────────────────────────────────────────────────┤
│ Content (list / grid / table / kanban) │
└─────────────────────────────────────────────────────────────┘
```
If a screen cannot answer "what decision does this help the user make?", the design is wrong.
---
## 10. Error Boundary Architecture
```
App.tsx
└── AppErrorBoundary (top-level catch-all)
└── Route components
└── Feature-level try/catch in services
└── Error state passed via useQuery error prop
└── <ErrorState message={...} /> or <UnauthorizedState />
```
Every page handles three states: loading (`<FeedSkeleton />`), error (`<ErrorState />`), and empty (`<FeedEmptyState />`). These are never omitted.
---
## 11. Component Dependency Rules
```
pages/ can import from components/, hooks/, stores/, lib/, domain/
components/ can import from components/, hooks/, stores/, lib/, domain/
hooks/ can import from services/, lib/, domain/
services/ can import from provider/, features/, lib/, domain/
features/ can import from lib/, domain/
provider/ can import from domain/, mock-data/
lib/ can import from (nothing in src — pure utilities)
domain/ can import from (nothing — types only)
stores/ can import from domain/, lib/
```
**Forbidden imports:**
- `services/` must not import React hooks
- `domain/` must not import anything from `src/`
- `lib/constants.ts` must not import from components or services
- Circular imports of any kind
---
## 12. Key Design System Tokens
```
Scores (0100 scale)
≥ 80 → STRONG → DS_COLORS.confidence.high (#1a7a4a)
≥ 60 → MODERATE → DS_COLORS.confidence.medium (#d97706)
< 60 → WEAK → DS_COLORS.confidence.low (#c0392b)
Confidence (01 scale)
≥ 0.85 → HIGH → DS_COLORS.confidence.high
≥ 0.65 → MEDIUM → DS_COLORS.confidence.medium
< 0.65 → LOW → DS_COLORS.confidence.low
Data Quality (01 scale)
≥ 0.80 → HIGH
≥ 0.60 → MEDIUM
< 0.60 → LOW
Result Types
VERIFIED_PORTFOLIO → label 'Plattform' color #1e3a5f
EXTERNAL_MARKET → label 'Plattform' color #1e3a5f
MAISON_WORK → label 'Maison Work' color #0369a1
FUTURE_AVAILABILITY → label 'Zukunftssignal' color #7c3aed
Freshness
FRESH → DS_COLORS.freshness.fresh (#1a7a4a)
STALE → DS_COLORS.freshness.stale (#d97706)
OUTDATED → DS_COLORS.freshness.outdated (#dc2626)
```
All tokens from `src/lib/ds.ts`. All thresholds from `src/lib/constants.ts`.
+554 -51
View File
@@ -1,40 +1,99 @@
# property-match — Development Guidelines
# property-match — Engineering Guidelines
## Stack
> **This file is read by Claude Code on every session.** All rules here are binding.
> For architecture diagrams and data flows see [ARCHITECTURE.md](./ARCHITECTURE.md).
> For the pre-commit review checklist see [CODE_REVIEW_CHECKLIST.md](./CODE_REVIEW_CHECKLIST.md).
> For state management patterns see [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md).
- **Vite 8** + **React 19** + **TypeScript 6**
- **MUI v9** (`@mui/material`) — primary component library
- **Tailwind CSS v4** — utility classes via `@tailwindcss/vite` (no `tailwind.config.js`)
- **React Router v7** — import from `react-router`, not `react-router-dom`
---
## Components
## 1. Project Vision
Always reach for an existing MUI component before writing a custom one. Check the [MUI component list](https://mui.com/material-ui/all-components/) first. Only build a custom component when MUI has no equivalent or the required behavior diverges significantly from what MUI provides.
Property Match is a **Decision Intelligence Platform** — not a real estate portal.
## Styling
The distinction matters for every design decision:
Use Tailwind utility classes for all layout and styling. Do not write plain CSS rules or add styles to `.css` files. The only CSS file is `src/index.css`, which holds the Tailwind layer imports — do not add project styles there.
| Classical Portal | Decision Intelligence |
|---|---|
| Show listings, let the user decide | Surface scored matches with explainability |
| Generic SaaS listing UI | Every screen is designed around one user decision |
| More filters = better | Fewer, smarter signals = better |
| Trust by volume | Trust by transparency |
**Three design principles that override everything else:**
## Providers
1. **Explainability-first.** Every score, badge, and recommendation must be traceable. If the user cannot understand *why* a match is strong, the feature is not done. Score breakdowns, trade-off panels, risk indicators, and data provenance are first-class UI elements — not tooltips added at the end.
All data access and data actions live in `src/provider/`.
2. **Trust-first.** Confidence levels, data freshness, and missing-data warnings are shown proactively. We never surface a result that looks more certain than the underlying data supports. When data is stale or incomplete, we say so.
### Naming
3. **Better-than-Google.** A property search result that just shows "here are things that match your query" fails the user. Property Match should rank, explain trade-offs, surface future signals, and give the user a clear recommended next action — like a trusted advisor, not a search engine.
| Rule | Example |
|------|---------|
| Every provider file/class is suffixed `Provider` | `PropertyProvider`, `UserProvider` |
| Every provider backed by mock data is also prefixed `Mockup` | `MockupPropertyProvider`, `MockupUserProvider` |
---
### Interface pattern
## 2. Stack
Define a TypeScript interface for each provider so the mockup and the real implementation are interchangeable:
Every Interface should be prefixed with a capitalized I.
| Technology | Version | Role |
|---|---|---|
| Vite | 8 | Build tool & dev server |
| React | 19 | UI framework |
| TypeScript | 6 | Type safety |
| MUI | v9 (`@mui/material`) | Component library |
| Tailwind CSS | v4 | Utility classes (via `@tailwindcss/vite`) |
| React Router | v7 | Routing — import from `react-router`, not `react-router-dom` |
| TanStack Query | v5 | Server state & caching |
| Zustand | v5 | Global UI state |
| @dnd-kit | current | Drag-and-drop (Pipeline board) |
| Zod | current | Schema validation |
| Lucide React | current | Icon set (supplementary to MUI icons) |
**There is no `tailwind.config.js`.** Tailwind v4 uses `@tailwindcss/vite` — config is inline.
---
## 3. Three-Workspace Architecture
The app is organized into three protected workspaces. Every route lives in exactly one workspace.
| Workspace | Route prefix | Primary user | Core job-to-be-done |
|---|---|---|---|
| **SUPPLY** | `/supply/*` | Property manager / owner | Manage inventory, respond to demand, monitor market |
| **DEMAND** | `/demand/*` | Tenant / company | Find, compare, and pipeline commercial space |
| **OPERATIONS** | `/ops/*` | Internal staff | AI governance, market intelligence, audit logs |
Route protection is enforced at the router level via `<ProtectedRoute workspace={WorkspaceType.X} />`. Never add workspace-specific logic inside shared components — pass `isStaff`, `isOwner`, etc. as props.
---
## 4. Layered Architecture
Data and logic flow through four explicit layers. **Never skip a layer.**
```
Provider → Service → Hook (React Query) → Component
```
### 4.1 Provider Layer (`src/provider/`)
Providers are the **only** code that touches data storage. Everything else goes through them.
**Rules:**
- Every entity has an interface: `I[Entity]Provider` (file: `I[Entity]Provider.ts`)
- Every interface is implemented by a mockup: `Mockup[Entity]Provider` (file: `Mockup[Entity]Provider.ts`)
- Every method is `async` and returns a `Promise` — even in the mockup
- Mockups hold in-memory seed data from `src/mock-data/`
- Real implementations swap in without changing any call sites
**Naming:**
| Pattern | Example |
|---|---|
| Interface | `IPropertyProvider` |
| Mock implementation | `MockupPropertyProvider` |
| Real implementation (future) | `RestPropertyProvider`, `SupabasePropertyProvider` |
```ts
// src/provider/IPropertyProvider.ts
export interface PropertyProvider {
export interface IPropertyProvider {
getAll(): Promise<Property[]>
getById(id: string): Promise<Property | null>
create(data: CreatePropertyInput): Promise<Property>
@@ -43,38 +102,482 @@ export interface PropertyProvider {
}
```
### Async methods
Every method in a provider must be `async` and return a `Promise`, even in the mockup. This ensures the real provider can be swapped in without changing any call sites.
```ts
// src/provider/MockupPropertyProvider.ts
import type { PropertyProvider } from './PropertyProvider'
const properties: Property[] = [ /* seed data */ ]
export const MockupPropertyProvider: PropertyProvider = {
async getAll() {
return [...properties]
},
async getById(id) {
return properties.find(p => p.id === id) ?? null
},
async create(data) {
const next: Property = { id: crypto.randomUUID(), ...data }
properties.push(next)
return next
},
async update(id, data) {
const idx = properties.findIndex(p => p.id === id)
properties[idx] = { ...properties[idx], ...data }
return properties[idx]
},
async remove(id) {
const idx = properties.findIndex(p => p.id === id)
properties.splice(idx, 1)
},
export const MockupPropertyProvider: IPropertyProvider = {
async getAll() { return [...properties] },
async getById(id) { return properties.find(p => p.id === id) ?? null },
// ...
}
```
Swap to a real implementation by replacing `MockupPropertyProvider` with a provider that calls an API — no other code changes required.
**Anti-patterns:**
- Never call a provider directly from a component — use a service
- Never mutate provider state from outside the provider
- Never share state across providers (cross-provider coupling creates hidden dependencies)
### 4.2 Service Layer (`src/services/`)
Services wrap providers with business logic, error handling, and response standardization.
**Rules:**
- Named `[entity]Service` (e.g., `matchService`, `needService`)
- Return standardized types: `ListResponse<T>` or `ItemResponse<T>` from `services/types.ts`
- Errors are thrown via `throwServiceError()` from `services/errors.ts`
- Services never import React hooks — they are plain TypeScript
- Services access Zustand stores via `getState()`, never via `useStore()` hooks
```ts
// ✅ Service accessing auth — no hook
const user = useSessionStore.getState().currentUser
```
### 4.3 Hook Layer (`src/hooks/`)
Hooks wrap React Query around services.
**Rules:**
- One hook file per entity: `useProperties.ts`, `useMatches.ts`, etc.
- `useQuery` for reads, `useMutation` for writes
- Query keys follow the convention in STATE_MANAGEMENT.md
- Stale times are imported from `src/lib/constants.ts` — never defined locally in hooks
- Mutations call `queryClient.invalidateQueries` on success
```ts
// ✅ Stale time from constants — never local
import { STALE_PROPERTIES } from '../lib/constants'
export function useProperties() {
return useQuery({ queryKey: ['properties'], queryFn: () => propertyService.getAll(), staleTime: STALE_PROPERTIES })
}
```
### 4.4 Component Layer (`src/components/` + `src/pages/`)
Components are **pure UI**. They render data, dispatch mutations, and show state. They do not contain business logic.
---
## 5. Component Rules
### 5.1 Size Limits
| File type | Hard limit | Soft target |
|---|---|---|
| Page component (`src/pages/`) | 300 lines | 150200 lines |
| Feature component | 250 lines | 100150 lines |
| Atom/shared component | 150 lines | 50100 lines |
When a component exceeds its soft target, split it. Extract sub-components, move constants to a `*Constants.ts` file, move pure helpers to a `*Utils.ts` file (`.tsx` if it returns JSX).
### 5.2 No Business Logic in JSX
The render body is for presentation only. All logic belongs upstream.
```tsx
// ❌ Business logic in JSX
{matches.filter(m => m.score > 70 && m.status !== 'DISMISSED').map(...)}
// ✅ Logic in useMemo, computed before the return
const visibleMatches = useMemo(
() => matches.filter(m => m.score > 70 && m.status !== 'DISMISSED'),
[matches]
)
// then:
{visibleMatches.map(...)}
```
### 5.3 No Magic Strings
Every label, status, color, and route string has a named constant.
```ts
// ❌
if (user.role === 'PROPERTY_MANAGER') { ... }
navigate('/supply/properties')
// ✅
import { UserRole } from '../domain/enums'
import { ROUTES } from '../lib/constants'
if (user.role === UserRole.PROPERTY_MANAGER) { ... }
navigate(ROUTES.SUPPLY.PROPERTIES)
```
All German UI labels for enums live in `src/lib/constants.ts` (e.g., `ASSET_TYPE_LABELS`, `AVAILABILITY_LABELS`).
### 5.4 No `any` Casts
`any` disables TypeScript. Use `unknown` with type guards, or fix the type properly.
```ts
// ❌
const result = response as any
const { data } = result
// ✅
const result: ItemResponse<Property> = response
const { data } = result
```
The one allowed exception: third-party library types that don't ship proper types. Wrap the cast in a type-safe adapter so the `any` is isolated.
### 5.5 No Duplicated State
If a value can be computed from existing state, compute it. Do not store it.
```ts
// ❌ Duplicated — sync bugs guaranteed
const [overdueCount, setOverdueCount] = useState(0)
useEffect(() => setOverdueCount(reminders.filter(r => isPast(r.dueDate)).length), [reminders])
// ✅ Derived inline
const overdueCount = reminders.filter(r => isPast(r.dueDate)).length
```
See STATE_MANAGEMENT.md for the full decision tree.
### 5.6 Component Library First
Before writing a custom component, check the [MUI component list](https://mui.com/material-ui/all-components/). Only build custom when:
- MUI has no equivalent, or
- The required behavior diverges significantly from what MUI provides
### 5.7 Styling
Use Tailwind utility classes for all layout and spacing. Use MUI's `sx` prop for component-specific overrides and theme values. **Never add styles to `.css` files.** The only CSS file is `src/index.css` (Tailwind layer imports) — do not add project styles there.
---
## 6. State Management
Full rules in [STATE_MANAGEMENT.md](./STATE_MANAGEMENT.md). Summary:
| Data type | Where it lives |
|---|---|
| Server data (fetched from provider) | React Query (`useQuery`) |
| Write operations | React Query (`useMutation`) |
| Global UI state (dialog open/close, compare tray, wizard steps) | Zustand store |
| Auth session | `sessionStore` (Zustand, also read by services via `getState()`) |
| Component-local state (form inputs, hover, toggle) | `useState` |
| Values derived from existing state | Computed inline — never stored |
**Critical rules:**
- Never copy React Query data into a Zustand store
- Never call `useStore()` without a selector (use `s => s.field`)
- Services read Zustand via `getState()`, never via hooks
- Side effects (cache invalidation, analytics) go in `useEffect`, never in the render body
---
## 7. Domain & Types
### 7.1 Single Source of Truth
All domain types live in `src/domain/`. Never duplicate a type or interface.
| File | Contents |
|---|---|
| `enums.ts` | All enums: `AssetType`, `ResultType`, `MatchStatus`, `UserRole`, `WorkspaceType`, etc. |
| `property.ts` | `Property`, `Location`, `ContactPerson` |
| `match.ts` | `Match`, `ScoreBreakdown`, `ScoreFactor`, `TradeOff`, `Risk` |
| `need.ts` | `Need`, `AreaRange`, `BudgetRange`, `WeightingProfile` |
| `unifiedResult.ts` | `UnifiedMatchResult` (the discriminated union for result feeds) |
| `scoring.ts` | `ScoringWeightProfile`, `HardFilterResult`, `MatchEngineOutput` |
| `aiOutput.ts` | AI scoring, extraction, monitoring types |
| ... | See `/domain/index.ts` for full list |
### 7.2 No Inline Type Duplication
```ts
// ❌ Redefining a type locally
interface LocalProperty { id: string; title: string; ... }
// ✅ Import from domain
import type { Property } from '../../domain/property'
```
### 7.3 Response Types
All service responses use the standardized wrapper from `src/services/types.ts`:
```ts
type ListResponse<T> = { data: T[]; total: number }
type ItemResponse<T> = { data: T }
```
---
## 8. Design System
All visual constants are centralized. **Never hardcode a color, spacing multiplier, or border-radius in a component.**
### 8.1 Color Tokens (`src/lib/ds.ts`)
```ts
DS_COLORS.confidence.high // '#1a7a4a'
DS_COLORS.confidence.medium // '#d97706'
DS_COLORS.confidence.low // '#c0392b'
DS_COLORS.risk.critical // '#dc2626'
DS_COLORS.risk.warning // '#d97706'
DS_COLORS.freshness.stale // '#d97706'
DS_COLORS.freshness.outdated// '#dc2626'
```
### 8.2 Score → Color Helpers (`src/lib/utils.ts`)
| Function | Input | Output | Use for |
|---|---|---|---|
| `matchScoreHex(score)` | 0100 | hex string | Overall match score color |
| `confidenceHex(score)` | 01 | hex string | Confidence score color |
| `dataQualityColor(score)` | 01 | hex string | Data quality color |
| `criterionScoreColor(score)` | 0100 | MUI color token | Individual criterion badge |
| `criterionScoreTextColor(score)` | 0100 | hex string | Criterion text color |
### 8.3 Result Type Metadata (`src/lib/ds.ts`)
Use `RESULT_TYPE_META[resultType]` for label, color, and background. Never define these inline.
```ts
// ❌
label="Verified Portfolio"
bgcolor="#1e3a5f"
// ✅
import { RESULT_TYPE_META } from '../../lib/ds'
label={RESULT_TYPE_META[result.resultType]?.label}
bgcolor={RESULT_TYPE_META[result.resultType]?.color}
```
### 8.4 Thresholds (`src/lib/constants.ts`)
```ts
SCORE_STRONG = 80 // match score considered "strong"
SCORE_MODERATE = 60 // match score considered "moderate"
CONF_HIGH = 0.85 // confidence considered "high"
CONF_MEDIUM = 0.65 // confidence considered "medium"
DQ_HIGH = 0.8 // data quality considered "high"
DQ_MEDIUM = 0.6 // data quality considered "medium"
```
These thresholds are used by `scoreToConfidenceLevel()`, `scoreToDataQualityLevel()`, and all badge logic. Never re-define them locally.
---
## 9. AI Integration Rules
Property Match integrates AI as a **service**, not as a feature bolted onto the UI.
### 9.1 Model-Agnostic Interface
AI is accessed exclusively through `IAIService` (`src/services/ai/IAIService.ts`). The interface defines capabilities (parseNeed, compareProperties, generateDecisionBrief, etc.). No component imports an LLM client directly.
```ts
// ❌ Never in a component or hook
import OpenAI from 'openai'
const client = new OpenAI(...)
// ✅ Always via service
import { aiService } from '../services/aiService'
const result = await aiService.parseNeed(text)
```
### 9.2 Implementations
| Implementation | File | Used when |
|---|---|---|
| `MockAIService` | `src/services/ai/MockAIService.ts` | Dev / CI — deterministic, no API calls |
| `OpenRouterAIService` | `src/services/ai/OpenRouterAIService.ts` | Production — OpenRouter API |
Swap implementations by changing the export in `src/services/aiService.ts`. No other file changes required.
### 9.3 Structured Responses
AI calls must return typed, structured objects — never raw strings passed into JSX.
```ts
// ❌ Raw LLM output in JSX
<Typography>{llmResponse}</Typography>
// ✅ Parsed, validated, typed output
const result: ParsedNeed = await aiService.parseNeed(text)
<NeedCardPreview need={result.need} confidence={result.confidence} />
```
All AI response schemas are validated with Zod before use.
### 9.4 Explainable Outputs
Every AI output that influences a user decision must be accompanied by an explanation.
- Match scores include `ScoreFactor[]` (positive & negative)
- Trade-off analysis includes `TradeOff[]` with severity
- Future availability signals include `probabilityScore` + `signalBasis`
- AI-parsed needs include `confidence` + which fields were inferred vs. stated
### 9.5 Human-in-the-Loop
AI suggestions are always proposals — the user confirms or overrides.
- Need parsing: user reviews extracted criteria before search runs
- AI recommendations: presented with confidence, not as commands
- Review queue (`/ops`): all AI-generated content can be reviewed and corrected
- Governance: `aiMonitoringService` logs every AI output with model version, prompt hash, and response
### 9.6 Fallback Handling
Every AI call has a defined fallback. If the AI service fails or returns a low-confidence result:
- The UI shows a manual entry path
- Confidence badges reflect uncertainty
- No AI failure should block a user workflow
---
## 10. Performance Rules
### 10.1 No Expensive Calculations in Render
Any computation over a list, aggregation, or filter runs in `useMemo`, not in the render body.
```tsx
// ❌ Runs on every render — expensive if list is large
return (
<div>
{properties
.filter(p => p.confidenceScore >= 0.7)
.sort((a, b) => b.areaSqm - a.areaSqm)
.map(p => <PropertyCard key={p.id} property={p} />)}
</div>
)
// ✅ Runs only when dependencies change
const visible = useMemo(
() => properties.filter(p => p.confidenceScore >= 0.7).sort((a, b) => b.areaSqm - a.areaSqm),
[properties]
)
return <div>{visible.map(p => <PropertyCard key={p.id} property={p} />)}</div>
```
### 10.2 Memoize List Items
Components rendered in lists (feeds, tables, grids) must be wrapped in `React.memo` to prevent cascade re-renders when parent state changes (e.g., a filter toggle or `selectedId` update).
```ts
// ✅ List item — always memo
export const PropertyCard = memo(function PropertyCard({ property, onSelect }: Props) { ... })
export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props) { ... })
```
### 10.3 Stable Callbacks for Memoized Children
When passing callbacks as props to memoized children, wrap them in `useCallback`. An unstable function reference breaks `React.memo`.
```ts
// ✅ Stable reference — memo children won't re-render
const handleSelect = useCallback((id: string) => setSelectedId(id), [])
```
### 10.4 Single-Pass Aggregations
When computing multiple aggregates over the same list (e.g., count of matchReady + criticalGaps + lowConfidence), use a single `for` loop in one `useMemo` — not three separate `.filter()` calls.
### 10.5 Virtualization
The current page size (`DEFAULT_PAGE_SIZE = 25`) makes React virtualization unnecessary today. If paginated lists are removed or page size exceeds ~200 items, evaluate `@tanstack/virtual` for the result feed and property table.
### 10.6 Bundle Splitting
All routes are already lazy-loaded via `React.lazy()` in `App.tsx`. Keep it that way — do not import page components directly.
---
## 11. Security & Governance
### 11.1 Auth & Session
- Auth state lives in `sessionStore` (Zustand)
- On logout, **clear the React Query cache**: `queryClient.clear()`
- Role checks use `src/lib/permissions.ts` — never inline `role === 'PROPERTY_MANAGER'`
- Workspace access is enforced by `<ProtectedRoute workspace={...} />` — never guard with `if` inside a component
### 11.2 Role-Based Rendering
```ts
// ❌ Magic string role check
if (user.role === 'PROPERTY_MANAGER') { ... }
// ✅ Permission helper
import { canViewMatchCenter } from '../../lib/permissions'
if (canViewMatchCenter(user)) { ... }
```
### 11.3 No Sensitive Data in Client State
- Do not store tokens, passwords, or PII in Zustand stores beyond what is strictly needed for session identity
- Do not log sensitive fields in console statements
- Do not pass sensitive data as URL params (use navigation state instead)
### 11.4 AI Audit Logging
Every AI output that influences a business decision is logged via `aiMonitoringService`. Logs include:
- Timestamp
- Model ID and version
- Prompt hash (not the full prompt — for privacy)
- Response summary
- User ID and workspace
### 11.5 Data Lineage
Every property record carries `DataSource` metadata (origin, freshness, last verified). Surface this data proactively — especially when `freshness === 'STALE'` or `freshness === 'OUTDATED'`.
---
## 12. Adding a New Feature — Checklist
Before writing any code, answer these questions:
1. **Which workspace does this belong to?** (SUPPLY / DEMAND / OPS)
2. **What decision does this help the user make?** (If you can't answer this, reconsider the feature)
3. **Does a provider already exist for this entity?** If not, create `I[Entity]Provider` + `Mockup[Entity]Provider` first
4. **Does a service already exist?** If not, create `[entity]Service` that wraps the provider
5. **Does a hook already exist?** If not, create `use[Entity]` in `src/hooks/`
6. **Is this server state or UI state?** (see STATE_MANAGEMENT.md)
7. **Does this show a score or confidence value?** Use `DS_COLORS` + helper functions from `lib/utils.ts`
8. **Does this involve AI?** Go through `IAIService` — never call LLM APIs directly
9. **Does this need explainability?** Add score factors, provenance, or reasoning before calling it done
**Do not** add business logic to components. **Do not** duplicate types from `/domain/`. **Do not** hardcode colors, labels, or thresholds.
---
## 13. File Naming & Organization
| Location | What goes there |
|---|---|
| `src/pages/[workspace]/` | Route-level page components |
| `src/components/[feature]/` | Feature-specific components |
| `src/components/shared/` | Components used across features |
| `src/components/ui/` | Atomic UI: ErrorBoundary, EmptyState, Toast, DecisionContextPanel |
| `src/components/layout/` | AppShell, PageHeader, RightContextPanel |
| `src/hooks/` | React Query hooks |
| `src/services/` | Business logic services |
| `src/services/ai/` | AI service interface + implementations + prompts |
| `src/provider/` | Data access interfaces + mock implementations |
| `src/domain/` | TypeScript types and enums |
| `src/features/matching/` | Scoring and ranking algorithms |
| `src/stores/` | Zustand stores |
| `src/lib/` | Shared utilities, theme, constants, design system |
| `src/mock-data/` | Seed data for mock providers |
Each component folder has an `index.ts` barrel export. New components must be added to the barrel before use.
**File extension rule:** Any file that contains JSX must use `.tsx`. Plain TypeScript (no JSX) uses `.ts`. Violating this causes Vite parse errors.
---
## 14. TypeScript Rules
- `strict: true` is enforced — do not disable strict mode or individual checks
- Prefer `interface` for object shapes (can be extended); use `type` for unions and aliases
- Props interfaces are named `Props` (local) or `[ComponentName]Props` (exported)
- Never use `@ts-ignore` — fix the type instead
- `as unknown as T` is acceptable only when bridging genuinely untyped third-party code; wrap it in a named adapter function
- Enums from `src/domain/enums.ts` are the source of truth — never redefine values locally
+226
View File
@@ -0,0 +1,226 @@
# Code Review Checklist — Property Match
> Use this checklist before every PR review and before considering any implementation "done".
> For architecture context see [ARCHITECTURE.md](./ARCHITECTURE.md).
> For coding rules see [CLAUDE.md](./CLAUDE.md).
---
## A. Component Size & Structure
- [ ] **Is the component too large?**
- Page component: hard limit 300 lines, target 150200
- Feature component: hard limit 250 lines, target 100150
- Atom/shared component: hard limit 150 lines
- If over limit: extract sub-components, move constants to `*Constants.ts`, move helpers to `*Utils.ts` (`.tsx` if JSX)
- [ ] **Is business logic inside JSX?**
- `.filter()`, `.sort()`, `.reduce()`, conditional rendering based on computed values — these belong in `useMemo`, not inline in the return
- The render body should read computed values, not compute them
- [ ] **Are hardcoded strings present?**
- Status labels (`'ACTIVE'`, `'Aktiv'`, `'PROPERTY_MANAGER'`) must come from `enums.ts` or `constants.ts`
- Route paths must come from `ROUTES` constants, not string literals
- German UI labels for enums live in `constants.ts` (e.g., `ASSET_TYPE_LABELS`)
- [ ] **Are hardcoded colors or thresholds present?**
- Colors like `'#1a7a4a'`, `'#dc2626'` directly in `sx` props → use `DS_COLORS` from `lib/ds.ts`
- Score thresholds like `score >= 80` → use `SCORE_STRONG` from `constants.ts`
- Confidence thresholds like `conf >= 0.75` → use `CONF_HIGH` from `constants.ts`
- [ ] **Are duplicate result type labels/colors defined locally?**
- `RESULT_TYPE_META` in `lib/ds.ts` is the single source of truth
- `RESULT_TYPE_LABEL` / `RESULT_TYPE_COLOR` inline objects anywhere else → delete them
---
## B. State & Data
- [ ] **Is server data stored in Zustand?**
- Any `useEffect(() => setFoo(data), [data])` pattern that mirrors React Query data into local/Zustand state → remove it, read directly from the query
- Server data lives exclusively in the React Query cache
- [ ] **Is derived state stored in state?**
- `const [count, setCount] = useState(0)` + `useEffect` to keep it in sync → compute inline or use `useMemo`
- Exception: expensive computations may use `useMemo`
- [ ] **Is the full Zustand store subscribed to without a selector?**
- `const { a, b, c } = useMyStore()` — re-renders on every store mutation
- Use `useMyStore(s => s.a)` for single fields, `useShallow` for multiple fields
- [ ] **Is there a side effect in the render body?**
- `queryClient.invalidateQueries(...)` called during render → must be in `useEffect`
- Any `console.log`, analytics call, or mutation triggered directly in render body → `useEffect`
- [ ] **Are all hooks called before conditional early returns?**
- React rules of hooks: all `useState`, `useMemo`, `useCallback`, `useQuery` calls must appear before any `if (isLoading) return ...`
---
## C. Type Safety
- [ ] **Are there `any` casts?**
- `as any`, `: any`, `// @ts-ignore` — fix the type or use `unknown` with a type guard
- One exception: bridging untyped third-party code — must be isolated in a named adapter
- [ ] **Are domain types re-defined locally?**
- Any interface or type that duplicates something in `src/domain/` → import from domain instead
- [ ] **Are response types used?**
- Service functions should return `ListResponse<T>` or `ItemResponse<T>` from `services/types.ts`
- [ ] **Do `.tsx` files contain JSX and `.ts` files not?**
- Any file with JSX (`<Component />`, `<Box>`, `<Typography>`) must have `.tsx` extension
- Plain TypeScript utilities use `.ts`
---
## D. Layering & Architecture
- [ ] **Is business logic in a component?**
- Scoring calculations, ranking, filtering rules, AI calls, data transformation → move to service or `features/` layer
- Components only render and dispatch
- [ ] **Is a provider called directly from a component or hook?**
- `MockupPropertyProvider.getAll()` called in a hook → must go through `propertyService`
- All provider access is mediated by a service
- [ ] **Is an LLM API called directly?**
- Any `fetch` to an AI API, or direct `OpenAI` / `openrouter` SDK calls outside `src/services/ai/` → move behind `IAIService`
- [ ] **Are there cross-workspace component imports?**
- A supply component importing from `components/demand/` or vice versa → use `components/shared/` or `components/ui/` instead
- [ ] **Are the import direction rules respected?**
- `services/` must not import React hooks
- `domain/` must not import anything from `src/`
- `lib/constants.ts` must not import from components or services
- No circular imports
---
## E. Loading, Error & Empty States
- [ ] **Does every data-dependent view handle loading?**
- Every `useQuery` that drives a list or table must show a skeleton or spinner while `isLoading`
- Use the appropriate skeleton component (`FeedSkeleton`, `ReminderSkeleton`, etc.)
- [ ] **Does every data-dependent view handle error?**
- `isError` from `useQuery` must show `<ErrorState message={error.message} />`
- Never leave an error state blank or silently swallowed
- [ ] **Does every list handle the empty state?**
- Empty results after filtering → `<FeedEmptyState filtered={true} />` with a reset action
- Empty because no data exists → `<FeedEmptyState filtered={false} />`
---
## F. Performance
- [ ] **Are list items wrapped in `React.memo`?**
- Components rendered inside `.map()` in feeds, tables, or grids → should be `memo()`
- Without memo, every parent state change (e.g., filter toggle, `selectedId`) re-renders the entire list
- [ ] **Are callbacks to memoized children wrapped in `useCallback`?**
- Passing `() => setSelectedId(id)` inline as a prop to a `memo()`-wrapped child → the memo is broken
- Wrap with `useCallback` and explicit dependency array
- [ ] **Are multiple aggregations over the same list collapsed into one pass?**
- Three separate `useMemo` calls that each `.filter()` the same array → collapse into one `useMemo` with a `for` loop
- [ ] **Is `useMemo` used for expensive filters and sorts?**
- `.filter()` + `.sort()` over 25+ items in the render body → `useMemo`
- Decision aggregates (matchReady count, criticalGaps count, etc.) in `Properties.tsx` → already in `useMemo`
- [ ] **Are routes lazy-loaded?**
- All page imports in `App.tsx` must use `React.lazy()` — never static imports for route components
---
## G. Explainability & Trust
- [ ] **Does every score shown have a breakdown path?**
- A match score badge with no way to see *why* it's that score → add `ScoreBreakdownPanel` or link to MatchDetail
- [ ] **Are confidence levels shown proactively?**
- Data with `confidenceScore < CONF_MEDIUM` or `freshness === 'STALE'` → must be visually flagged
- `ConfidenceBadge`, `FreshnessIndicator`, `DataQualityBar` exist for this purpose
- [ ] **Are Future Availability signals clearly labeled as probabilistic?**
- `FUTURE_AVAILABILITY` results must always show `probabilityScore` and `signalBasis`
- They must never look identical to confirmed availability listings
- [ ] **Does AI output show its confidence?**
- AI-extracted need criteria → shown with confidence indicator
- AI match suggestions → shown with `MAISON_WORK` badge and probability
---
## H. Security
- [ ] **Is the React Query cache cleared on logout?**
- Logout flow must call `queryClient.clear()` — otherwise stale data from previous user persists
- [ ] **Are role checks using `permissions.ts` helpers?**
- `user.role === 'PROPERTY_MANAGER'` inline → use `canViewMatchCenter(user)` from `lib/permissions.ts`
- [ ] **Are workspace route guards in place?**
- Every supply/demand/ops route wrapped in `<ProtectedRoute workspace={...} />`
- Never guard with `if (user.workspace !== ...)` inside a component body
- [ ] **Is sensitive data stored in Zustand?**
- PII, tokens, and sensitive fields beyond current-user identity should not persist in client state
---
## I. Backend-Readiness
- [ ] **Is the provider interface complete?**
- Any new entity that creates a mock shortcut (array mutation inline, no interface) → add `I[Entity]Provider` and `Mockup[Entity]Provider`
- [ ] **Are async patterns consistent?**
- All provider methods are `async` — if a new provider method is synchronous, fix it before it gets called from a service
- [ ] **Are query keys stable and specific?**
- Query key `['properties']` is fine for list; `['properties', id]` for single item
- Keys that include derived or computed objects (not primitives) will break cache lookup
- [ ] **Are stale times centralized?**
- No `staleTime: 60_000` or `staleTime: 5 * 60 * 1000` inline in hook files
- Use `STALE_PROPERTIES`, `STALE_MATCHES`, etc. from `src/lib/constants.ts`
---
## J. AI & Model Governance
- [ ] **Is the AI call going through `IAIService`?**
- Direct fetch to AI API anywhere outside `src/services/ai/` → move it
- [ ] **Is the AI response validated with Zod before use?**
- Raw LLM string parsed inline with `JSON.parse()` without a schema → add Zod validation
- [ ] **Is the AI output logged via `aiMonitoringService`?**
- Any AI call that influences a business decision (need parsing, match recommendation, market signal) → log it
- [ ] **Does the AI flow have a fallback?**
- If `aiService` throws or returns `confidence < threshold`, is there a manual entry path?
- AI failures must never block the user workflow
---
## Quick Checklist — Pre-Commit
Before every commit, verify:
```
[ ] npx tsc --noEmit → zero errors
[ ] No new hardcoded colors, routes, or role strings
[ ] No business logic added to a JSX return block
[ ] No React Query data mirrored into Zustand or useState
[ ] All hooks called before early returns
[ ] New list-item components wrapped in React.memo
[ ] New provider methods are async and match the interface
[ ] New feature goes through Provider → Service → Hook → Component
[ ] Loading, error, and empty states handled for any new data-fetching view
[ ] File with JSX has .tsx extension
```
+223
View File
@@ -0,0 +1,223 @@
# State Management — Property Match
## Decision Tree
```
Is it server data (fetched from an API or provider)?
→ React Query (useQuery / useMutation)
Is it global UI state shared across unrelated components?
→ Zustand store
Is it local to a single component or parent-child chain?
→ useState / useReducer (local state)
Can it be computed from existing state/data?
→ Derived state (compute inline — no separate store field)
Is it cross-cutting auth / session context accessed in non-React code?
→ Zustand store read via getState() (not useStore hook)
```
---
## React Query — Server State
**Rule:** React Query owns all data that comes from a provider (mock or real).
Never copy React Query data into a Zustand store.
### When to use
- Fetching lists or detail records (`useQuery`)
- Creating, updating, deleting records (`useMutation`)
- Anything that needs cache invalidation or background refetch
### Patterns
```ts
// ✅ Correct — server data in React Query
const { data: properties } = useProperties()
// ✅ Correct — mutation with cache invalidation
const createProp = useCreateProperty()
createProp.mutate(input, {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['properties'] })
})
// ❌ Wrong — copying server data into a Zustand store
const [properties, setProperties] = useState([])
useEffect(() => { fetchProperties().then(setProperties) }, [])
```
### staleTime constants
All stale times live in `src/lib/constants.ts` — never define them locally in hooks.
| Constant | Value | Used for |
|----------|-------|---------|
| `STALE_PROPERTIES` | 5 min | Property lists and details |
| `STALE_MATCHES` | 2 min | Match results (change with need edits) |
| `STALE_SIGNALS` | 5 min | Future availability signals |
| `STALE_MARKET_SIGNALS` | 30 s | Market intelligence (ops team, real-time) |
| `STALE_REVIEW_QUEUE` | 30 s | Review queue tasks (ops team, real-time) |
### Query key conventions
```ts
['entity'] // list: ['properties'], ['matches'], ['reminders']
['entity', id] // single: ['property', id], ['match', id]
['entity', 'scope', id] // scoped: ['matches', 'need', needId]
['entity', filters] // filtered: ['properties', { assetType: 'OFFICE' }]
```
---
## Zustand — UI / Interaction State
**Rule:** Zustand owns UI state only. It must never hold data that belongs in React Query.
### When to use
- Multi-step wizard state (`offerWizardStore`)
- Dialog open/close + pending item (`shortlistStore`, `pipelineStore`)
- Sidebar / layout flags (`layoutStore`)
- Auth session (`sessionStore` — special case, also read by services via `getState()`)
- Toast queue (`toastStore`)
- Active selection in a panel (`matchCenterStore`, `reminderStore`)
### When NOT to use
- Data fetched from a provider → use React Query
- State used only inside one component → use `useState`
- Values derived from existing state → compute inline
### Selector rules
Always use a selector. Subscribing to the full store causes re-renders on every state mutation.
```ts
// ✅ Individual field selector — re-renders only when that field changes
const isOpen = useLayoutStore(s => s.isRightPanelOpen)
const close = useLayoutStore(s => s.closeRightPanel)
// ✅ useShallow for multiple fields from the same store
import { useShallow } from 'zustand/react/shallow'
const { filterType, filterStatus } = useReminderStore(
useShallow(s => ({ filterType: s.filterType, filterStatus: s.filterStatus }))
)
// ❌ No selector — subscribes to all store fields, re-renders on any change
const { isOpen, close } = useLayoutStore()
// ✅ Non-React code (services, mutations) reads store via getState — no subscription
const user = useSessionStore.getState().currentUser
```
### Store inventory
| Store | Responsibility | Key state |
|-------|---------------|-----------|
| `layoutStore` | App shell layout | `activeWorkspace`, `sidebarCollapsed`, `isRightPanelOpen` |
| `sessionStore` | Auth / current user | `currentUser`, `isAuthenticated`, `sessionStatus` |
| `assistantStore` | AI drawer conversation | `isOpen`, `messages`, `context`, `isLoading` |
| `compareStore` | Compare tray items | `compareItems[]` |
| `pipelineStore` | Pipeline items + add-dialog | `items[]`, `dialogOpen`, `pendingItem` |
| `shortlistStore` | Shortlist selection + add-dialog | `selectedShortlistId`, `dialogOpen`, `pendingItem` |
| `offerWizardStore` | Multi-step offer wizard | `isOpen`, `currentStep`, `selectedPropertyIds`, `editableFields` |
| `matchCenterStore` | Supply-side match center selection | `selectedPropertyId`, `selectedNeedId` |
| `reminderStore` | Reminder list filters + drawer | `filterType`, `filterStatus`, `selectedId`, `drawerOpen` |
| `toastStore` | Toast notification queue | `toasts[]` |
### Adding a new store field
Ask these questions first:
1. Is this server data? → React Query instead.
2. Is this only used in one component? → `useState` instead.
3. Is this derived from existing state? → compute it, don't store it.
---
## Local State — Component-Scoped
**Rule:** Default to `useState`. Only escalate to Zustand when state genuinely needs to be shared across unrelated components.
### When to use
- Form input values
- Toggle / accordion open state
- Hover / focus effects
- Step progress inside a self-contained wizard step
- Any state that resets when the component unmounts
```ts
// ✅ Local — form input, no other component needs this
const [name, setName] = useState('')
// ✅ Local — dialog only opened from one place
const [open, setOpen] = useState(false)
// ❌ Should be local — extracted to store unnecessarily
// (e.g. a "confirmDialogOpen" only ever toggled from one parent)
```
---
## Derived State — Compute, Don't Store
**Rule:** Never store a value that can be computed from existing state or query data. Compute it at render time.
```ts
// ✅ Derived — compute from store
const isFull = compareItems.length >= MAX_COMPARE_ITEMS // NOT stored
// ✅ Derived — compute from React Query data
const overdueReminders = reminders.filter(r => isPastDue(r.dueDate)) // NOT stored
// ❌ Stored derived state — causes sync bugs
const [overdueCount, setOverdueCount] = useState(0)
useEffect(() => setOverdueCount(reminders.filter(...).length), [reminders])
```
Exception: expensive computations (e.g. score calculation over thousands of items) may use `useMemo`.
---
## Context — When Neither React Query nor Zustand Fits
Use React Context for:
- Dependency injection (swap provider implementations)
- Tree-scoped state (e.g. a form context for nested inputs)
- Auth abstraction (`AuthProvider` wraps `sessionStore` so components don't import the store directly)
Do NOT use Context as a replacement for React Query or Zustand — it causes cascading re-renders without cache or subscription granularity.
---
## Service / Non-React Code
Services must not import React hooks. They access Zustand state via `getState()`:
```ts
// ✅ In a service — no hook, no subscription
const user = useSessionStore.getState().currentUser
// ✅ In a React Query mutation onError
onError: () => useToastStore.getState().showToast('Fehler aufgetreten', 'error')
// ❌ Services must never call useStore hooks
import { useSessionStore } from '../stores/sessionStore'
const { currentUser } = useSessionStore() // only valid inside a React component
```
---
## Anti-Patterns to Avoid
| Anti-pattern | Why bad | Fix |
|---|---|---|
| `useStore()` without selector | Re-renders on every store mutation | Use `s => s.field` selector or `useShallow` |
| Server data in Zustand | Duplicates cache, causes stale/sync bugs | React Query |
| Derived state stored in state | Sync bugs, extra renders | Compute inline |
| Local dialog state in global store | Bloats store, breaks encapsulation | `useState` |
| Cross-store imports | Tight coupling, circular risk | Keep stores independent |
| Hook-local `STALE_*` constants | Inconsistent cache behaviour | Use `src/lib/constants.ts` |
+25
View File
@@ -19,4 +19,29 @@ export default defineConfig([
globals: globals.browser,
},
},
// ── Design Token Enforcement ────────────────────────────────────────────────
// New hardcoded hex colors in components/pages are blocked (error).
// Existing violations are tracked by: npm run check:tokens
//
// Use instead:
// DS_TEXT.secondary, DS_SURFACE.success.bg (src/lib/ds.ts)
// matchScoreHex(score), criterionScoreTextColor(score) (src/lib/utils.ts)
// RESULT_TYPE_META[type].color (src/lib/ds.ts)
// MUI theme tokens: "primary.main", "text.secondary"
{
files: ['src/components/**/*.{ts,tsx}', 'src/pages/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'warn',
{
selector: [
'Property[key.name=/^(bgcolor|color|borderColor|background|fill|stroke)$/]',
' > Literal[value=/^#[0-9A-Fa-f]{3,8}$/]',
].join(''),
message:
'No hardcoded hex colors in sx/style props. Use DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG, BADGE_COLORS from src/lib/ds.ts, or helper functions from src/lib/utils.ts.',
},
],
},
},
])
+1296 -1
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -7,7 +7,11 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check:tokens": "node scripts/check-tokens.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -30,18 +34,23 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.7",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
"vite": "^8.0.12",
"vitest": "^4.1.7"
}
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* check-tokens.js
*
* Counts hardcoded hex color literals in sx props / color attributes
* across src/components and src/pages. Fails (exit 1) if count exceeds THRESHOLD.
*
* Usage:
* node scripts/check-tokens.js — report + fail if > threshold
* node scripts/check-tokens.js --report — report only, always exits 0
*
* Run via: npm run check:tokens
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, extname } from 'node:path'
// Hex literals allowed before CI blocks the build.
// This is a ratchet — lower it as migration progresses. Never raise it.
// Baseline after initial token migration (2026-05-24): 1958
const THRESHOLD = 1958
const HEX_PATTERN = /#[0-9A-Fa-f]{3,8}\b/g
const SEARCH_DIRS = ['src/components', 'src/pages']
function walk(dir) {
const files = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) files.push(...walk(full))
else if (entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name))) files.push(full)
}
return files
}
const reportOnly = process.argv.includes('--report')
const cwd = process.cwd()
let totalViolations = 0
const fileViolations = []
for (const searchDir of SEARCH_DIRS) {
const dir = join(cwd, searchDir)
for (const file of walk(dir)) {
const content = readFileSync(file, 'utf8')
const matches = content.match(HEX_PATTERN)
if (matches && matches.length > 0) {
totalViolations += matches.length
fileViolations.push({ file: file.replace(cwd + '\\', '').replace(cwd + '/', ''), count: matches.length })
}
}
}
fileViolations.sort((a, b) => b.count - a.count)
console.log('\n── Token Compliance Check ───────────────────────────────────────────')
console.log(`Total hex violations: ${totalViolations} (threshold: ${THRESHOLD})`)
console.log(`Status: ${totalViolations <= THRESHOLD ? '✅ PASS' : '❌ FAIL'}`)
console.log('\nTop 15 offenders:')
fileViolations.slice(0, 15).forEach(({ file, count }) => {
console.log(` ${count.toString().padStart(3)} ${file}`)
})
console.log('─────────────────────────────────────────────────────────────────────\n')
if (!reportOnly && totalViolations > THRESHOLD) {
console.error(`Error: ${totalViolations} hex violations exceed threshold of ${THRESHOLD}.`)
console.error('Run `node scripts/check-tokens.js --report` to see the full list.')
process.exit(1)
}
+2 -4
View File
@@ -12,7 +12,7 @@ const WORKSPACE_HOME: Record<string, string> = {
}
function RoleRedirect() {
const { currentUser } = useSessionStore()
const currentUser = useSessionStore(s => s.currentUser)
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
}
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads'))
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings'))
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
function App() {
return (
@@ -62,7 +61,6 @@ function App() {
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/market-leads" element={<MarketLeads />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} />
+2 -10
View File
@@ -1,4 +1,4 @@
import { Chip, Tooltip } from '@mui/material'
import { GenericBadge } from '../shared/GenericBadge'
import type { AIOutputError } from '../../domain/aiOutput'
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
@@ -11,13 +11,5 @@ const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
export function AIErrorBadge({ error }: { error: AIOutputError }) {
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
return (
<Tooltip title={error.message} arrow>
<Chip
size="small"
label={label}
sx={{ bgcolor: `${color}18`, color, fontWeight: 700, fontSize: '0.65rem', cursor: 'default' }}
/>
</Tooltip>
)
return <GenericBadge label={label} color={color} bold tooltip={error.message} />
}
@@ -1,21 +1,15 @@
import { Chip } from '@mui/material'
import { GenericBadge } from '../shared/GenericBadge'
import type { ReviewStatus } from '../../domain/enums'
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
FLAGGED: { label: 'Markiert', color: '#ea580c' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
FLAGGED: { label: 'Markiert', color: '#ea580c' },
}
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
return (
<Chip
size="small"
label={label}
sx={{ bgcolor: `${color}18`, color, fontWeight: 600, fontSize: '0.7rem' }}
/>
)
return <GenericBadge label={label} color={color} />
}
@@ -1,7 +1,6 @@
import { useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { aiService } from '../../services/aiService'
import { useGenerateOfferEmail } from '../../hooks/useAI'
interface AiOfferEmailButtonProps {
needTitle: string
@@ -16,33 +15,26 @@ export function AiOfferEmailButton({
matchScores,
onGenerated,
}: AiOfferEmailButtonProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const generateOfferEmail = useGenerateOfferEmail()
const handleClick = async () => {
setLoading(true)
setError(null)
try {
const res = await aiService.generateOfferEmail({
needTitle,
properties: selectedProperties,
matchScores,
})
onGenerated(res.data.subject, res.data.body)
} catch (e) {
setError(e instanceof Error ? e.message : 'KI-Generierung fehlgeschlagen')
} finally {
setLoading(false)
}
const handleClick = () => {
generateOfferEmail.mutate(
{ needTitle, properties: selectedProperties, matchScores },
{
onSuccess: (res) => {
onGenerated(res.data.subject, res.data.body)
},
},
)
}
return (
<Box>
<Button
size="small"
startIcon={loading ? <CircularProgress size={14} /> : <Sparkles size={14} />}
startIcon={generateOfferEmail.isPending ? <CircularProgress size={14} /> : <Sparkles size={14} />}
onClick={handleClick}
disabled={loading}
disabled={generateOfferEmail.isPending}
sx={{
textTransform: 'none',
color: '#7c3aed',
@@ -53,9 +45,11 @@ export function AiOfferEmailButton({
>
KI-Mail generieren
</Button>
{error && (
{generateOfferEmail.isError && (
<Typography variant="caption" sx={{ color: 'error.main', display: 'block', mt: 0.5 }}>
{error}
{generateOfferEmail.error instanceof Error
? generateOfferEmail.error.message
: 'KI-Generierung fehlgeschlagen'}
</Typography>
)}
</Box>
@@ -123,7 +123,6 @@ export function InquiryReplyComposer({
display: 'flex',
alignItems: 'center',
gap: 0.75,
bgcolor: 'white',
border: '1px solid',
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
borderRadius: 1,
@@ -3,6 +3,7 @@ import { Building2, MapPin, Ruler, Calendar } from 'lucide-react'
import type { InquiryPreparationReportDraft, ReportObjectFieldKey } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
import type { Inquiry } from '../../domain/inquiry'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props {
draft: InquiryPreparationReportDraft
@@ -100,39 +101,39 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
mb: 2,
}}
>
<Box sx={{ borderBottom: '3px solid #1e3a5f', pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e3a5f', fontFamily: 'inherit' }}>
<Box sx={{ borderBottom: `3px solid ${DS_TEXT.brand}`, pb: 2, mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_TEXT.brand, fontFamily: 'inherit' }}>
Objektvorschlag
</Typography>
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.muted, mt: 0.5 }}>
Wincasa AG · {new Date().toLocaleDateString('de-CH')}
</Typography>
</Box>
{editableByField['intro'] && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: '#1e293b' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, mb: 3, color: DS_TEXT.primary }}>
{editableByField['intro']}
</Typography>
)}
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
<Box sx={{ bgcolor: DS_BG.page, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, p: 2, mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Ihre Anfrage
</Typography>
<Typography variant="body2" sx={{ color: '#374151', mb: 0.5 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, mb: 0.5 }}>
<strong>Kontakt:</strong> {inquiry.tenantName}{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
</Typography>
<Typography variant="body2" sx={{ color: '#374151' }}>
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
<strong>Betreff:</strong> {inquiry.subject}
</Typography>
</Box>
{editableByField['highlights'] && (
<>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Warum diese Objekte passen
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151', mb: 3 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary, mb: 3 }}>
{editableByField['highlights']}
</Typography>
</>
@@ -145,7 +146,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
label={p.title}
size="small"
icon={<Building2 size={12} />}
sx={{ bgcolor: '#e0e7ff', color: '#1e3a5f', fontWeight: 600 }}
sx={{ bgcolor: DS_SURFACE.indigo.bg, color: DS_TEXT.brand, fontWeight: 600 }}
/>
))}
</Box>
@@ -161,7 +162,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
<Box key={property.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Divider sx={{ flex: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
Objekt {idx + 1} von {selectedProps.length}
</Typography>
<Divider sx={{ flex: 1 }} />
@@ -184,7 +185,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
bgcolor: DS_BORDER.default,
backgroundImage: property.mapImageUrl ? `url(${property.mapImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
@@ -194,7 +195,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
justifyContent: 'center',
}}
>
{!property.mapImageUrl && <MapPin size={28} color="#94a3b8" />}
{!property.mapImageUrl && <MapPin size={28} color={DS_TEXT.disabled} />}
</Box>
{/* Property photo */}
<Box
@@ -203,7 +204,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
height: 120,
borderRadius: 1,
overflow: 'hidden',
bgcolor: '#e2e8f0',
bgcolor: DS_BORDER.default,
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
@@ -212,23 +213,23 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
justifyContent: 'center',
}}
>
{!image && <Building2 size={28} color="#94a3b8" />}
{!image && <Building2 size={28} color={DS_TEXT.disabled} />}
</Box>
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 0.5, fontFamily: 'inherit' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_TEXT.brand, mb: 0.5, fontFamily: 'inherit' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<MapPin size={13} />
<Typography variant="caption">{property.location.city}{property.location.district ? `, ${property.location.district}` : ''}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<Ruler size={13} />
<Typography variant="caption">{property.areaSqm.toLocaleString('de-CH')} m²</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.muted }}>
<Calendar size={13} />
<Typography variant="caption">{new Date(property.availabilityDate).toLocaleDateString('de-CH')}</Typography>
</Box>
@@ -240,7 +241,7 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 0.75,
bgcolor: '#f8fafc',
bgcolor: DS_BG.page,
borderRadius: 1,
p: 1.5,
}}
@@ -250,10 +251,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
if (!val) return null
return (
<Box key={key}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block' }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', display: 'block' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.78rem' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary, fontSize: '0.78rem' }}>
{val}
</Typography>
</Box>
@@ -268,10 +269,10 @@ export function LatentInquiryReportPreview({ draft, inquiry, properties }: Props
{editableByField['next_steps'] && (
<Box sx={{ bgcolor: 'white', p: 3, boxShadow: '0 2px 12px rgba(0,0,0,0.1)', borderRadius: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.primary, mb: 1 }}>
Nächste Schritte
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: '#374151' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8, color: DS_TEXT.primary }}>
{editableByField['next_steps']}
</Typography>
</Box>
@@ -1,18 +1,86 @@
import { Box, Typography } from '@mui/material'
import { FileText } from 'lucide-react'
import { Box, Divider, Typography } from '@mui/material'
import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { mockProperties } from '../../mock-data/properties'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
interface MockPdfPreviewProps {
fields: Record<string, string>
fallbackFields: Record<string, string>
fieldSelections: ReportObjectFieldSelection[]
}
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) {
const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Asset Type',
floor: 'Stockwerk',
parking: 'Parkplätze',
fitOut: 'Ausbaugrad',
isBarrierFree: 'Barrierefrei',
ceilingHeightM: 'Raumhöhe',
floorLoad: 'Bodenlast',
loadingDocksCount: 'Anlieferung',
goodsLift: 'Warenaufzug',
passengerLift: 'Personenaufzug',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
internet: 'Internet',
deliveryAccess: 'Zufahrt',
publicTransportScore: 'ÖV-Anbindung',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
footfallScore: 'Passantenfrequenz',
commuterAccessScore: 'Pendlererreichbarkeit',
talentAccessScore: 'Talent Access',
esgScore: 'ESG',
flexibilityScore: 'Flexibilität',
expansionPotentialScore: 'Expansionspotenzial',
taxEnvironmentScore: 'Steuerumfeld',
microLocation: 'Mikrostandort',
competitionEnvironment: 'Konkurrenzumfeld',
infrastructure: 'Infrastruktur',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
const needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
fontFamily: '"Georgia", "Times New Roman", serif',
}}
>
{/* ── Page 1 — Offer letter ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
@@ -73,7 +142,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
variant="body2"
sx={{ fontSize: '0.875rem', mb: 0.5, lineHeight: 1.5 }}
>
<strong>{p.title}</strong> {p.location.city}, {p.areaSqm.toLocaleString('de-CH')} m², CHF {p.rentPricePerSqm}/m²
<strong>{p.title}</strong> {p.location.city}, {p.areaSqm.toLocaleString('de-CH')} m², CHF {p.rentPricePerSqm}/m²/Jahr
</Typography>
))}
{properties.length === 0 && (
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{v('closing')}
</Typography>
{/* ── Page 2 — Property details side by side ── */}
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
</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>
)
}
@@ -6,7 +6,7 @@ import {
} from '@mui/material'
import { Plus, Trash2, X } from 'lucide-react'
import type { OfferReportDraft, ViewingAppointmentOption } from '../../domain/offerReport'
import { offerReportService } from '../../services/offerReportService'
import { useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf } from '../../hooks/useOfferReport'
import { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
@@ -32,10 +32,16 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const createOfferReport = useCreateOfferReport()
const updateOfferReport = useUpdateOfferReport()
const generatePdf = useGenerateOfferReportPdf()
useEffect(() => {
offerReportService
.create(inquiryId, propertyId, tenantName, property?.title ?? '')
.then(d => { setDraft(d); setLoading(false) })
createOfferReport.mutate(
{ inquiryId, propertyId, tenantName, propertyTitle: property?.title ?? '' },
{ onSuccess: (d) => { setDraft(d); setLoading(false) } },
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inquiryId, propertyId, tenantName, property?.title])
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
@@ -70,32 +76,35 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
})
}
const handleGeneratePdf = async () => {
const handleGeneratePdf = () => {
if (!draft) return
await offerReportService.update(draft.id, {
editableFields: draft.editableFields,
viewingAppointments: draft.viewingAppointments,
})
setStep(3)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
setReady(true)
offerReportService.generatePdf(draft.id).then(setDraft)
} else {
setProgress(Math.min(100, p))
}
}, 200)
updateOfferReport.mutate(
{ draftId: draft.id, data: { editableFields: draft.editableFields, viewingAppointments: draft.viewingAppointments } },
{
onSuccess: () => {
setStep(3)
setGenerating(true)
setProgress(0)
let p = 0
timerRef.current = setInterval(() => {
p += Math.random() * 18 + 8
if (p >= 100) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
setReady(true)
generatePdf.mutate(draft.id, { onSuccess: setDraft })
} else {
setProgress(Math.min(100, p))
}
}, 200)
},
},
)
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
@@ -178,7 +187,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
size="small"
value={a.date}
onChange={e => updateAppointment(a.id, 'date', e.target.value)}
InputLabelProps={{ shrink: true }}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: 160 }}
/>
<TextField
@@ -219,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
progress={progress}
ready={ready}
draft={draft}
property={property}
property={property ?? undefined}
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
onAttach={() => {
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
@@ -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: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
}
}
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const needTitle = useOfferWizardStore(s => s.needTitle)
const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
</Typography>
</Box>
) : (
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} />
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
)}
</Box>
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
return
}
setOfferDraftId(res.data.id)
setStep('pdf_review')
setStep('select_fields')
}
if (!need) {
+16 -10
View File
@@ -12,22 +12,25 @@ import {
import { X } from 'lucide-react'
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer'
// 'checked' is internal — merged into pdf_review step; only 3 steps shown
// 'checked' is internal — merged into pdf_review step; 4 visible steps
const STEPS: { key: OfferStep; label: string }[] = [
{ key: 'select_properties', label: 'Objekte wählen' },
{ key: 'select_fields', label: 'Felder wählen' },
{ key: 'pdf_review', label: 'Vorschau & Prüfen' },
{ key: 'send', label: 'Senden' },
]
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
select_properties: 0,
pdf_review: 1,
checked: 1, // same visual step as pdf_review
send: 2,
select_fields: 1,
pdf_review: 2,
checked: 2, // same visual step as pdf_review
send: 3,
}
export function OfferWizard() {
@@ -48,12 +51,14 @@ export function OfferWizard() {
fullScreen={fullScreen}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
height: fullScreen ? '100vh' : '85vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
slotProps={{
paper: {
sx: {
height: fullScreen ? '100vh' : '85vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
},
}}
>
@@ -112,6 +117,7 @@ export function OfferWizard() {
{/* Step content */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{currentStep === 'select_properties' && <OfferPropertySelectionStep />}
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
{currentStep === 'pdf_review' && <OfferPdfReviewStep />}
{currentStep === 'checked' && <OfferCheckedAction />}
{currentStep === 'send' && <OfferChatComposer />}
@@ -6,9 +6,8 @@ import {
} from '@mui/material'
import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { inquiryReportService } from '../../services/inquiryReportService'
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
import { useToastStore } from '../../stores/toastStore'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
@@ -29,16 +28,16 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const [draft, setDraft] = useState<InquiryPreparationReportDraft | null>(null)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState(0)
const [finalizing, setFinalizing] = useState(false)
const { data: allProperties = [] } = useProperties()
const showToast = useToastStore(s => s.showToast)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const createInquiryReport = useCreateInquiryReport()
const updateInquiryReport = useUpdateInquiryReport()
const finalizeInquiryReport = useFinalizeInquiryReport()
const finalizing = finalizeInquiryReport.isPending
const selectedProperties = draft?.selectedPropertyIds
.map(id => allProperties.find(p => p.id === id))
.filter((p): p is Property => !!p) ?? []
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const toggleProperty = (id: string) => {
setSelectedIds(prev =>
@@ -46,7 +45,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
)
}
const handleGenerate = async () => {
const handleGenerate = () => {
setStep(1)
setGenerating(true)
setProgress(0)
@@ -57,10 +56,15 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
clearInterval(timerRef.current!)
setProgress(100)
setGenerating(false)
inquiryReportService.create(inquiryId, selectedIds).then(d => {
setDraft(d)
setStep(2)
})
createInquiryReport.mutate(
{ inquiryId, selectedPropertyIds: selectedIds },
{
onSuccess: (d) => {
setDraft(d)
setStep(2)
},
},
)
} else {
setProgress(Math.min(100, p))
}
@@ -85,18 +89,25 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
})
}
const handleFinalize = async () => {
const handleFinalize = () => {
if (!draft) return
setFinalizing(true)
await inquiryReportService.update(draft.id, { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections })
const finalized = await inquiryReportService.finalize(draft.id)
setDraft(finalized)
setFinalizing(false)
setStep(3)
updateInquiryReport.mutate(
{ draftId: draft.id, data: { editableFields: draft.editableFields, fieldSelections: draft.fieldSelections } },
{
onSuccess: () => {
finalizeInquiryReport.mutate(draft.id, {
onSuccess: (finalized) => {
setDraft(finalized)
setStep(3)
},
})
},
},
)
}
return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}>
<Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { matchService } from '../../services/matchService'
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
@@ -14,16 +13,10 @@ interface RelatedPropertyCardPanelProps {
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const [additionalMatches, setAdditionalMatches] = useState<AdditionalPropertyMatch[]>([])
const [loadingMatches, setLoadingMatches] = useState(false)
useEffect(() => {
setLoadingMatches(true)
matchService
.getAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
.then(setAdditionalMatches)
.finally(() => setLoadingMatches(false))
}, [inquiryId, propertyId])
const {
data: additionalMatches = [],
isLoading: loadingMatches,
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
if (isLoading) {
return (
@@ -94,7 +87,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Ruler size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
@@ -190,7 +183,7 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²/Jahr
</Typography>
{match.reasons.length > 0 && (
<Box sx={{ mb: 0.5 }}>
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
},
]
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
const isSelected = (key: ReportObjectFieldKey) =>
value.selectedOptionalFields.includes(key)
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
{FIELD_GROUPS.map(group => {
const groupKeys = group.fields.map(f => f.key)
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
return (
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
@@ -1,5 +1,6 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SuggestedQuestion } from '../../domain/assistant'
import { DS_TEXT, DS_BORDER, DS_SURFACE, BADGE_COLORS } from '../../lib/ds'
interface Props {
suggestions: SuggestedQuestion[]
@@ -9,26 +10,26 @@ interface Props {
const CATEGORY_COLORS: Record<string, string> = {
Match: '#4f46e5',
Datenqualität: '#d97706',
Priorisierung: '#1e3a5f',
Empfehlung: '#1a7a4a',
Risiko: '#c0392b',
Datenqualität: DS_TEXT.warning,
Priorisierung: DS_TEXT.brand,
Empfehlung: DS_TEXT.success,
Risiko: DS_TEXT.error,
Tradeoffs: '#ea580c',
Strategie: '#0891b2',
Analyse: '#7c3aed',
Analyse: BADGE_COLORS.preMarket,
Erklärung: '#0891b2',
Evidenz: '#64748b',
Review: '#7c3aed',
Konfidenz: '#d97706',
Fehler: '#c0392b',
Evidenz: DS_TEXT.muted,
Review: BADGE_COLORS.preMarket,
Konfidenz: DS_TEXT.warning,
Fehler: DS_TEXT.error,
Fehleranalyse: '#ea580c',
Eskalation: '#ea580c',
Prozess: '#64748b',
Kosten: '#1a7a4a',
Impact: '#d97706',
Optimierung: '#1a7a4a',
Aktion: '#1e3a5f',
Überblick: '#64748b',
Prozess: DS_TEXT.muted,
Kosten: DS_TEXT.success,
Impact: DS_TEXT.warning,
Optimierung: DS_TEXT.success,
Aktion: DS_TEXT.brand,
Überblick: DS_TEXT.muted,
Ranking: '#4f46e5',
}
@@ -37,12 +38,12 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
return (
<Box sx={{ px: 2, py: 1.25 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: DS_TEXT.disabled, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Vorschläge
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{suggestions.map(s => {
const catColor = CATEGORY_COLORS[s.category] ?? '#64748b'
const catColor = CATEGORY_COLORS[s.category] ?? DS_TEXT.muted
return (
<Box
key={s.id}
@@ -51,11 +52,11 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
px: 1.25,
py: 0.875,
borderRadius: 1.5,
border: '1px solid #e2e8f0',
border: `1px solid ${DS_BORDER.default}`,
cursor: disabled ? 'default' : 'pointer',
bgcolor: 'white',
opacity: disabled ? 0.5 : 1,
'&:hover': disabled ? {} : { bgcolor: '#f8fafc', borderColor: '#cbd5e1' },
'&:hover': disabled ? {} : { bgcolor: DS_SURFACE.neutral.bg, borderColor: DS_BORDER.strong },
transition: 'all 0.1s ease',
display: 'flex',
alignItems: 'center',
@@ -3,7 +3,8 @@ import { Sparkles } from 'lucide-react'
import { useAssistantStore } from '../../stores/assistantStore'
export function GlobalAIAssistantButton() {
const { isOpen, open } = useAssistantStore()
const isOpen = useAssistantStore(s => s.isOpen)
const open = useAssistantStore(s => s.open)
return (
<Tooltip title="AI Assistent öffnen" placement="left">
@@ -2,15 +2,16 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Box, Divider, Drawer, IconButton, TextField, Tooltip, Typography } from '@mui/material'
import { RotateCcw, Send, Sparkles, X } from 'lucide-react'
import { useLocation } from 'react-router'
import { useShallow } from 'zustand/react/shallow'
import { useAssistantStore } from '../../stores/assistantStore'
import { useSessionStore } from '../../stores/sessionStore'
import { aiAssistantService } from '../../services/aiAssistantService'
import { useAssistantSuggestions, useAssistantAnswer } from '../../hooks/useAssistant'
import { AssistantContextSummary } from './AssistantContextSummary'
import { AssistantMessageList } from './AssistantMessageList'
import { AssistantPromptSuggestions } from './AssistantPromptSuggestions'
import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext, SuggestedQuestion } from '../../domain/assistant'
import type { AssistantContext } from '../../domain/assistant'
import type { WorkspaceType } from '../../domain/enums'
function resolveWorkspace(pathname: string): WorkspaceType | null {
@@ -21,29 +22,18 @@ function resolveWorkspace(pathname: string): WorkspaceType | null {
export function GlobalAIAssistantDrawer() {
const { isOpen, close, context, setContext, messages, isLoading, error, addMessage, setLoading, setError, clearConversation } =
useAssistantStore()
useAssistantStore(useShallow(s => s))
const { currentUser } = useSessionStore()
const location = useLocation()
const [suggestions, setSuggestions] = useState<SuggestedQuestion[]>([])
const [inputText, setInputText] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
// Build context from route when drawer opens
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [isOpen, location.pathname])
const answerQuestion = useAssistantAnswer()
const { data: suggestions = [] } = useAssistantSuggestions(isOpen ? context : null)
// Refresh suggestions when route changes while open
// Build context from route when drawer opens or route changes while open
useEffect(() => {
if (!isOpen) return
const ctx: AssistantContext = {
@@ -53,8 +43,7 @@ export function GlobalAIAssistantDrawer() {
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
aiAssistantService.getSuggestions(ctx).then(setSuggestions)
}, [location.pathname])
}, [isOpen, location.pathname])
// Auto-scroll on new messages
useEffect(() => {
@@ -63,7 +52,7 @@ export function GlobalAIAssistantDrawer() {
}
}, [messages, isLoading])
const handleQuestion = useCallback(async (question: string) => {
const handleQuestion = useCallback((question: string) => {
if (!question.trim() || isLoading) return
setInputText('')
setError(null)
@@ -77,26 +66,32 @@ export function GlobalAIAssistantDrawer() {
addMessage(userMsg)
setLoading(true)
try {
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
const answer = await aiAssistantService.answerQuestion(ctx, question)
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
} catch {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
} finally {
setLoading(false)
const ctx = context ?? {
currentRoute: location.pathname,
workspace: resolveWorkspace(location.pathname),
userRole: currentUser?.role ?? 'VIEWER',
organizationId: currentUser?.organizationId ?? '',
}
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError])
answerQuestion.mutate(
{ context: ctx, question },
{
onSuccess: (answer) => {
addMessage({
id: crypto.randomUUID(),
role: 'assistant',
createdAt: new Date().toISOString(),
...answer,
})
setLoading(false)
},
onError: () => {
setError('Antwort konnte nicht generiert werden. Bitte erneut versuchen.')
setLoading(false)
},
},
)
}, [context, isLoading, location.pathname, currentUser, addMessage, setLoading, setError, answerQuestion])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -107,10 +102,6 @@ export function GlobalAIAssistantDrawer() {
const handleClear = () => {
clearConversation()
setSuggestions([])
if (context) {
aiAssistantService.getSuggestions(context).then(setSuggestions)
}
}
const showSuggestions = suggestions.length > 0 && messages.length === 0
+4 -3
View File
@@ -1,6 +1,6 @@
import { Box, Chip, Typography } from '@mui/material'
import { UserRole } from '../../domain/enums'
import { authService } from '../../services/authService'
import { useSwitchDemoRole } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore'
const DEMO_ROLES: { role: UserRole; label: string }[] = [
@@ -10,9 +10,10 @@ const DEMO_ROLES: { role: UserRole; label: string }[] = [
export function DemoRoleSwitcher() {
const { currentUser } = useSessionStore()
const switchDemoRole = useSwitchDemoRole()
async function handleSwitch(role: UserRole) {
await authService.switchDemoRole(role)
function handleSwitch(role: UserRole) {
switchDemoRole.mutate(role)
}
return (
+4 -3
View File
@@ -1,6 +1,6 @@
import { FormControl, MenuItem, Select, Typography } from '@mui/material'
import type { SelectChangeEvent } from '@mui/material'
import { authService } from '../../services/authService'
import { useSwitchOrganization } from '../../hooks/useAuth'
import { useSessionStore } from '../../stores/sessionStore'
const MOCK_ORGANIZATIONS = [
@@ -11,9 +11,10 @@ const MOCK_ORGANIZATIONS = [
export function OrganizationSwitcher() {
const { activeOrganizationId } = useSessionStore()
const switchOrganization = useSwitchOrganization()
async function handleChange(e: SelectChangeEvent<string>) {
await authService.switchOrganization(e.target.value)
function handleChange(e: SelectChangeEvent<string>) {
switchOrganization.mutate(e.target.value)
}
return (
+1 -2
View File
@@ -1,5 +1,5 @@
import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
import { ShieldCheck, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums'
import { DS_COLORS } from '../../lib/ds'
import { RESULT_TYPE_LABELS } from '../../lib/constants'
@@ -11,7 +11,6 @@ interface ResultTypeBadgeProps {
const ICONS: Record<ResultType, React.ElementType> = {
VERIFIED_PORTFOLIO: ShieldCheck,
EXTERNAL_MARKET: Globe,
MAISON_WORK: Building2,
FUTURE_AVAILABILITY: Sparkles,
}
-1
View File
@@ -10,7 +10,6 @@ interface SourceTypeBadgeProps {
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
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 },
}
+27 -26
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Alert, Box, Chip, CircularProgress, Collapse, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, Trophy, TrendingDown, ShieldCheck, AlertTriangle, Info, ArrowRight, CheckCircle2, XCircle, Star } from 'lucide-react'
import type { ComparisonSummary } from '../../services/aiService'
import { DS_TEXT, DS_BG, DS_BORDER, DS_SURFACE } from '../../lib/ds'
interface Props {
summary?: ComparisonSummary
@@ -12,7 +13,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
const [open, setOpen] = useState(true)
return (
<Box sx={{ mb: 2, border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
<Box sx={{ mb: 2, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, overflow: 'hidden' }}>
<Box
onClick={() => setOpen(v => !v)}
sx={{
@@ -21,14 +22,14 @@ export function AICompareSummary({ summary, isLoading }: Props) {
justifyContent: 'space-between',
px: 2,
py: 1.25,
bgcolor: '#f8fafc',
bgcolor: DS_SURFACE.neutral.bg,
cursor: 'pointer',
'&:hover': { bgcolor: '#f1f5f9' },
'&:hover': { bgcolor: DS_BG.subtle },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AI Vergleichs-Zusammenfassung</Typography>
<Chip label="Beta" size="small" sx={{ fontSize: 10, bgcolor: '#ede9fe', color: '#6d28d9' }} />
<Chip label="Beta" size="small" sx={{ fontSize: 10, bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signal }} />
</Box>
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</Box>
@@ -45,15 +46,15 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{!isLoading && summary && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Overall assessment */}
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.5 }}>{summary.overallAssessment}</Typography>
<Box sx={{ bgcolor: DS_SURFACE.info.bg, border: `1px solid ${DS_SURFACE.info.border}`, borderRadius: 1, p: 1.5 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.infoDark, lineHeight: 1.5 }}>{summary.overallAssessment}</Typography>
</Box>
{/* Strongest option */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Trophy size={16} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
<Trophy size={16} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Stärkstes Match</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Stärkstes Match</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.strongestOption.label}</Typography>
<Typography variant="caption" color="text.secondary">{summary.strongestOption.reason}</Typography>
</Box>
@@ -62,9 +63,9 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{/* Best value */}
{summary.bestValue && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<TrendingDown size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
<TrendingDown size={16} color={DS_TEXT.brand} style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Bestes Preis-Leistungs-Verhältnis</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Bestes Preis-Leistungs-Verhältnis</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{summary.bestValue.label}</Typography>
<Typography variant="caption" color="text.secondary">{summary.bestValue.reason}</Typography>
</Box>
@@ -75,7 +76,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<ShieldCheck size={16} color="#0891b2" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Höchste Datenkonfidenz</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Höchste Datenkonfidenz</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{summary.highestConfidence.label}
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.5 }}>
@@ -88,9 +89,9 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{/* Tradeoffs */}
{summary.biggestTradeoffs.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<AlertTriangle size={16} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
<AlertTriangle size={16} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Wichtigste Abwägungen</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Wichtigste Abwägungen</Typography>
{summary.biggestTradeoffs.map((t, i) => (
<Typography key={i} variant="caption" color="text.secondary" sx={{ display: 'block' }}>· {t}</Typography>
))}
@@ -101,9 +102,9 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{/* Missing data */}
{summary.missingDataWarnings.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<Info size={16} color="#c0392b" style={{ flexShrink: 0, marginTop: 2 }} />
<Info size={16} color={DS_TEXT.error} style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Fehlende Informationen</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Fehlende Informationen</Typography>
{summary.missingDataWarnings.map((w, i) => (
<Typography key={i} variant="caption" color="error" sx={{ display: 'block' }}>· {w}</Typography>
))}
@@ -114,14 +115,14 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{/* Per-property assessment */}
{summary.perPropertyAssessment.length > 0 && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block', mb: 0.75 }}>Objektbewertung</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block', mb: 0.75 }}>Objektbewertung</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
{summary.perPropertyAssessment.map(prop => (
<Box
key={prop.matchId}
sx={{
flex: '1 1 200px',
border: '1px solid #e2e8f0',
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 1,
p: 1.25,
display: 'flex',
@@ -129,13 +130,13 @@ export function AICompareSummary({ summary, isLoading }: Props) {
gap: 0.75,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f' }}>{prop.label}</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_TEXT.brand }}>{prop.label}</Typography>
{/* Strengths */}
<Box>
{prop.strengths.map((s, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{s}</Typography>
</Box>
))}
@@ -145,7 +146,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
<Box>
{prop.weaknesses.map((w, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mb: 0.25 }}>
<XCircle size={12} color="#dc2626" style={{ flexShrink: 0, marginTop: 2 }} />
<XCircle size={12} color={DS_TEXT.danger} style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{w}</Typography>
</Box>
))}
@@ -153,8 +154,8 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{/* Best for */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<Star size={12} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ color: '#92400e', lineHeight: 1.4 }}>
<Star size={12} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.4 }}>
<strong>Am besten für:</strong> {prop.bestFor}
</Typography>
</Box>
@@ -162,7 +163,7 @@ export function AICompareSummary({ summary, isLoading }: Props) {
{/* Key risk */}
{prop.keyRisk && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<AlertTriangle size={12} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
<AlertTriangle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.4 }}>{prop.keyRisk}</Typography>
</Box>
)}
@@ -173,10 +174,10 @@ export function AICompareSummary({ summary, isLoading }: Props) {
)}
{/* Recommendation */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', pt: 0.5, borderTop: '1px solid #f1f5f9', mt: 0.5 }}>
<ArrowRight size={16} color="#1e3a5f" style={{ flexShrink: 0, marginTop: 2 }} />
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', pt: 0.5, borderTop: `1px solid ${DS_BORDER.muted}`, mt: 0.5 }}>
<ArrowRight size={16} color={DS_TEXT.brand} style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b', display: 'block' }}>Empfehlung</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.muted, display: 'block' }}>Empfehlung</Typography>
<Typography variant="body2">{summary.recommendation}</Typography>
</Box>
</Box>
+9 -14
View File
@@ -1,18 +1,12 @@
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePipelineItems } from '../../hooks/usePipeline'
import { usePipelineStore } from '../../stores/pipelineStore'
import { RESULT_TYPE_META } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
interface Props {
item: UnifiedMatchResult
onRemove: () => void
@@ -20,9 +14,10 @@ interface Props {
export function CompareColumnHeader({ item, onRemove }: Props) {
const navigate = useNavigate()
const { items: pipelineItems, openSavedDialog } = usePipelineStore()
const { data: pipelineItems = [] } = usePipelineItems()
const { openSavedDialog } = usePipelineStore()
const meta = TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
@@ -48,7 +43,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
propertyId: prop?.id,
propertyAddress: prop ? `${title}, ${subtitle}${district ? `, ${district}` : ''}` : undefined,
areaLabel: prop?.areaSqm ? `${prop.areaSqm.toLocaleString('de-CH')}` : undefined,
rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²` : undefined,
rentLabel: prop?.rentPricePerSqm ? `CHF ${prop.rentPricePerSqm}/m²/Jahr` : undefined,
availabilityLabel: prop?.availabilityDate ?? undefined,
})
}
@@ -84,7 +79,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
{/* Row 3: score */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: SCORE_COLOR(item.matchScore), lineHeight: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: matchScoreHex(item.matchScore), lineHeight: 1 }}>
{item.matchScore}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
@@ -150,7 +145,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
variant="contained"
startIcon={<Kanban size={12} />}
onClick={handleSave}
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5 }}
>
Merken
</Button>
@@ -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>
)
})
+340
View File
@@ -0,0 +1,340 @@
import type { ReactNode } from 'react'
import {
Box,
Chip,
TableBody,
TableCell,
TableRow,
Typography,
} from '@mui/material'
import { AlertOctagon, AlertTriangle, CheckCircle2, Trophy, XCircle, Zap } from 'lucide-react'
import {
HARD_CRITERIA,
RISK_LEVEL_ORDER,
getProp,
getSig,
LABEL_SX,
DATA_SX,
scoreBar,
} from './compareUtils'
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
function row(label: string, cells: ReactNode[]) {
return (
<TableRow hover key={label}>
<TableCell sx={LABEL_SX}>{label}</TableCell>
{cells.map((cell, i) => (
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
))}
</TableRow>
)
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface CompareTableBodyProps {
compareItems: UnifiedMatchResult[]
bestScoreIdx: number
worstConfIdx: number
worstDQIdx: number
missingCriticalCounts: number[]
maxMissingCritical: number
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CompareTableBody({
compareItems,
bestScoreIdx,
worstConfIdx,
worstDQIdx,
missingCriticalCounts,
maxMissingCritical,
}: CompareTableBodyProps) {
return (
<TableBody>
{/* 1. Result Type */}
{row('1. Result-Typ', compareItems.map(item => {
const m = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
return <Chip label={m.label} size="small" sx={{ bgcolor: m.color, color: 'white', fontWeight: 600, fontSize: 11 }} />
}))}
{/* 2. Source / Provenance */}
{row('2. Quelle / Provenienz', compareItems.map(item => {
const prop = getProp(item)
const sig = getSig(item)
const label = prop?.sourceLabel ?? sig?.source?.type ?? null
return label
? <Typography variant="body2">{label}</Typography>
: <MissingDataCell reason="Quellenangabe fehlt — Datenverlässlichkeit unklar" />
}))}
{/* 3. Match Score */}
{row('3. Match Score', compareItems.map((item, idx) => (
<CompareCell
highlight={idx === bestScoreIdx ? 'best' : 'none'}
icon={idx === bestScoreIdx ? <Trophy size={14} color="#1a7a4a" /> : undefined}
iconTooltip="Höchster Match Score"
>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography variant="h5" sx={{ fontWeight: 800, color: matchScoreHex(item.matchScore), lineHeight: 1 }}>
{item.matchScore}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
</Box>
</CompareCell>
)))}
{/* 4. Confidence Score */}
{row('4. Konfidenz', compareItems.map((item, idx) => (
<CompareCell
highlight={idx === worstConfIdx && item.match.confidenceLevel < 0.6 ? 'worst' : 'none'}
icon={idx === worstConfIdx && item.match.confidenceLevel < 0.6 ? <AlertTriangle size={14} color="#d97706" /> : undefined}
iconTooltip="Niedrigste Konfidenz"
>
{scoreBar(item.match.confidenceLevel)}
</CompareCell>
)))}
{/* 5. Data Quality Score */}
{row('5. Datenqualität', compareItems.map((item, idx) => {
const prop = getProp(item)
const dq = prop?.dataQuality.score ?? null
if (dq === null) return <MissingDataCell reason="Keine Datenqualitätsinformation verfügbar" />
return (
<CompareCell
highlight={idx === worstDQIdx && dq < 0.6 ? 'worst' : 'none'}
icon={idx === worstDQIdx && dq < 0.6 ? <AlertTriangle size={14} color="#d97706" /> : undefined}
iconTooltip="Niedrigste Datenqualität"
>
{scoreBar(dq)}
</CompareCell>
)
}))}
{/* 6. Asset Type */}
{row('6. Nutzungstyp', compareItems.map(item => {
const prop = getProp(item)
const label = prop?.assetType ?? null
return label
? <Chip label={label} size="small" variant="outlined" />
: <MissingDataCell />
}))}
{/* 7. Location */}
{row('7. Standort', compareItems.map(item => {
const prop = getProp(item)
const sig = getSig(item)
const city = prop?.location?.city ?? sig?.locationHint ?? null
const district = prop?.location?.district
return city
? <Typography variant="body2">{city}{district ? `, ${district}` : ''}</Typography>
: <MissingDataCell />
}))}
{/* 8. Area */}
{row('8. Fläche', compareItems.map(item => {
const prop = getProp(item)
const sig = getSig(item)
const area = prop?.areaSqm ?? sig?.areaSqmEstimate ?? null
return area !== null
? <Typography variant="body2">{area.toLocaleString('de-CH')} m²{sig ? ' (Schätzung)' : ''}</Typography>
: <MissingDataCell />
}))}
{/* 9. Rent / Budget Fit */}
{row('9. Miete / Budget', compareItems.map(item => {
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
CHF {prop.rentPricePerSqm}/m²/Jahr
</Typography>
{prop.totalRentMonthly && (
<Typography variant="caption" color="text.secondary">
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
</Typography>
)}
</Box>
)
}))}
{/* 10. Availability / Time Horizon */}
{row('10. Verfügbarkeit', compareItems.map(item => {
const prop = getProp(item)
const sig = getSig(item)
if (prop) return <Typography variant="body2">{prop.availabilityDate}</Typography>
if (sig) return (
<CompareCell highlight="future" icon={<Zap size={14} color={DS_COLORS.futureCard.controlled.accent} />} iconTooltip="Probabilistisches Signal — keine bestätigte Verfügbarkeit">
<Typography variant="body2">~{sig.timeHorizonMonths} Monate</Typography>
<Typography variant="caption" sx={{ color: DS_COLORS.futureCard.controlled.accent }}>
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
</Typography>
</CompareCell>
)
return <MissingDataCell />
}))}
{/* 11. Hard Criteria Fit */}
{row('11. Hardkriterien', compareItems.map(item => {
const hardMatches = item.match.positiveFactors.filter(f => HARD_CRITERIA.has(f.criterion))
const total = 4
const count = hardMatches.length
const color = count >= 3 ? '#1a7a4a' : count >= 2 ? '#d97706' : '#c0392b'
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color }}>
{count}/{total} erfüllt
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 0.5 }}>
{hardMatches.map(f => (
<Chip key={f.criterion} label={f.criterion} size="small"
icon={<CheckCircle2 size={10} />}
sx={{ fontSize: 10, bgcolor: '#f0fdf4', color: '#166534', '& .MuiChip-icon': { color: '#1a7a4a' } }} />
))}
</Box>
</Box>
)
}))}
{/* 12. Top Soft Factors */}
{row('12. Soft Factors', compareItems.map(item => {
const softFactors = item.match.positiveFactors
.filter(f => !HARD_CRITERIA.has(f.criterion))
.slice(0, 3)
if (softFactors.length === 0) return <MissingDataCell />
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{softFactors.map(f => (
<Chip key={f.criterion} label={f.criterion} size="small"
sx={{ fontSize: 10, bgcolor: '#eff6ff', color: '#1e40af' }} />
))}
</Box>
)
}))}
{/* 13. Main Strengths */}
{row('13. Stärken', compareItems.map(item => {
const top = item.match.positiveFactors.slice(0, 2)
if (top.length === 0) return <MissingDataCell />
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{top.map((f, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<CheckCircle2 size={13} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ lineHeight: 1.4 }}>{f.explanation}</Typography>
</Box>
))}
</Box>
)
}))}
{/* 14. Main Tradeoffs */}
{row('14. Abwägungen', compareItems.map(item => {
const tradeoffs = item.match.tradeoffs?.slice(0, 2) ?? []
if (tradeoffs.length === 0) return (
<Typography variant="body2" color="text.secondary">Keine signifikanten Abwägungen</Typography>
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{tradeoffs.map((t, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<AlertTriangle size={13} color="#d97706" style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ lineHeight: 1.4 }}>{t.criterion}: {t.concern}</Typography>
</Box>
))}
</Box>
)
}))}
{/* 15. Main Risks */}
{row('15. Risiken', compareItems.map(item => {
const risks = [...(item.match.risks ?? [])].sort(
(a, b) => (RISK_LEVEL_ORDER[a.level] ?? 4) - (RISK_LEVEL_ORDER[b.level] ?? 4)
).slice(0, 2)
if (risks.length === 0) return (
<Typography variant="body2" color="text.secondary">Keine identifizierten Risiken</Typography>
)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{risks.map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<XCircle size={13} color={r.level === 'CRITICAL' || r.level === 'HIGH' ? '#c0392b' : '#d97706'} style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ lineHeight: 1.4 }}>{r.description}</Typography>
</Box>
))}
</Box>
)
}))}
{/* 16. Missing Data */}
{row('16. Fehlende Daten', compareItems.map((item, idx) => {
const total = item.match.missingData?.length ?? 0
const critical = missingCriticalCounts[idx]
if (total === 0) return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={14} color="#1a7a4a" />
<Typography variant="body2" sx={{ color: '#1a7a4a' }}>Vollständig</Typography>
</Box>
)
return (
<CompareCell
highlight={critical > 0 && critical === maxMissingCritical ? 'critical' : critical > 0 ? 'worst' : 'none'}
icon={critical > 0 ? <AlertOctagon size={14} color="#c0392b" /> : <AlertTriangle size={14} color="#d97706" />}
iconTooltip={critical > 0 ? 'Kritische Pflichtfelder fehlen' : 'Optionale Felder fehlen'}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{total} fehlend</Typography>
{critical > 0 && (
<Typography variant="caption" color="error">{critical} kritisch</Typography>
)}
</CompareCell>
)
}))}
{/* 17. Future Availability Context */}
{row('17. Zukunftskontext', compareItems.map(item => {
const sig = getSig(item)
if (!sig) return (
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>Nicht anwendbar</Typography>
)
return (
<CompareCell highlight="future" icon={<Zap size={14} color={DS_COLORS.futureCard.controlled.accent} />} iconTooltip="Probabilistisches Zukunftssignal">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_COLORS.futureCard.controlled.accent }}>
{Math.round(sig.probability * 100)}% Wahrscheinlichkeit
</Typography>
<Typography variant="caption" color="text.secondary">
Sensitivität: {sig.sensitivityLevel}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
{sig.disclaimer}
</Typography>
</Box>
</CompareCell>
)
}))}
{/* 18. Recommended Next Action */}
{row('18. Nächste Aktion', compareItems.map(item => {
const action = item.match.nextBestActions?.[0]
if (!action) return <MissingDataCell />
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{action.label}</Typography>
{action.description && (
<Typography variant="caption" color="text.secondary">{action.description}</Typography>
)}
</Box>
)
}))}
</TableBody>
)
}
@@ -2,11 +2,11 @@ import type { ReactNode } from 'react'
import { Box, LinearProgress, Typography } from '@mui/material'
import type { WeightingKey } from '../../domain/needBuilder'
import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
import { dataQualityHex } from '../../lib/utils'
// ── Helpers ───────────────────────────────────────────────────────────────────
export const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
export const SCORE_COLOR = (s: number) => s >= 78 ? '#1a7a4a' : s >= 52 ? '#d97706' : '#c0392b'
export function getProp(item: UnifiedMatchResult) {
return item.resultType !== 'FUTURE_AVAILABILITY'
@@ -20,13 +20,6 @@ export function getSig(item: UnifiedMatchResult) {
: null
}
export const TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
export const RISK_LEVEL_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
export const CRITERION_ALIASES: Record<WeightingKey, string[]> = {
@@ -78,7 +71,7 @@ export const DATA_SX = {
// ── Score bar helper ──────────────────────────────────────────────────────────
export function scoreBar(value: number, label?: string): ReactNode {
const color = value >= 0.8 ? '#1a7a4a' : value >= 0.6 ? '#d97706' : '#c0392b'
const color = dataQualityHex(value)
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 70 }}>
+1
View File
@@ -3,3 +3,4 @@ export { CompareColumnHeader } from './CompareColumnHeader'
export { CompareCell, MissingDataCell } from './CompareCell'
export { AICompareSummary } from './AICompareSummary'
export { CompareCriteriaCard } from './CompareCriteriaCard'
export { CompareTableBody } from './CompareTableBody'
@@ -0,0 +1,63 @@
import { Alert, Box, Button, CircularProgress, Divider } from '@mui/material'
import { ArrowRight, Bookmark, Search } from 'lucide-react'
interface Props {
canProceed: boolean
isSearching: boolean
isSavingProfile: boolean
onSearch: () => void
onSaveProfile: () => void
}
export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, onSearch, onSaveProfile }: Props) {
const isProcessing = isSearching || isSavingProfile
return (
<>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="contained"
size="large"
disabled={!canProceed || isProcessing}
onClick={onSearch}
endIcon={isSearching ? <CircularProgress size={18} color="inherit" /> : <Search size={18} />}
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
textTransform: 'none',
}}
>
{isSearching ? 'Sucht…' : 'Jetzt suchen'}
</Button>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Button
variant="outlined"
size="large"
disabled={!canProceed || isProcessing}
onClick={onSaveProfile}
startIcon={<Bookmark size={16} />}
endIcon={isSavingProfile ? <CircularProgress size={18} color="inherit" /> : <ArrowRight size={18} />}
sx={{
py: 1.5,
fontSize: 15,
fontWeight: 500,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{isSavingProfile ? 'Analysiert…' : 'Als Suchprofil speichern'}
</Button>
</Box>
<Alert severity="info" sx={{ mt: -1 }}>
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird auch in Zukunft.
</Alert>
</>
)
}
@@ -0,0 +1,62 @@
import { Alert, Box, Button, CircularProgress } from '@mui/material'
import { Save } from 'lucide-react'
import { NeedCardPreview } from './NeedCardPreview'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
interface Props {
criteria: ParsedNeedCriteria
weights: Record<WeightingKey, number>
parseResult: ParseNeedResult
needTitle: string
overallConfidence: number
isSaving: boolean
onNeedTitleChange: (t: string) => void
onBack: () => void
onSave: () => void
}
export function AISearchSavePreview({
criteria,
weights,
parseResult,
needTitle,
overallConfidence,
isSaving,
onNeedTitleChange,
onBack,
onSave,
}: Props) {
return (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Alert severity="success" sx={{ mb: 3 }}>
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
</Alert>
<NeedCardPreview
criteria={criteria}
weights={weights}
confidenceByField={parseResult.confidenceByField}
missingFields={parseResult.missingFields}
needTitle={needTitle}
onNeedTitleChange={onNeedTitleChange}
/>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 3 }}>
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
Zurück
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={isSaving}
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{isSaving
? 'Wird gespeichert…'
: overallConfidence < 0.6
? 'Als Entwurf speichern'
: 'Suchprofil speichern & Matching starten'}
</Button>
</Box>
</Box>
)
}
+43 -23
View File
@@ -1,71 +1,86 @@
import { Box, Chip, Typography } from '@mui/material'
import { Kanban } from 'lucide-react'
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { Inquiry } from '../../domain/inquiry'
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
new: { label: 'Neu', color: '#dc2626', bgColor: '#fef2f2' },
in_progress: { label: 'Aktiv', color: '#d97706', bgColor: '#fffbeb' },
answered: { label: 'Beantwortet', color: '#1a7a4a', bgColor: '#f0fdf4' },
archived: { label: 'Archiviert', color: '#64748b', bgColor: '#f8fafc' },
}
interface AnfragenInquiryItemProps {
inq: Inquiry
isSelected: boolean
hasPipeline: boolean
perspective?: 'demand' | 'supply'
onSelect: (id: string) => void
}
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }: AnfragenInquiryItemProps) {
const cfg = STATUS_CONFIG[inq.status ?? 'new']
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger
const primaryTitle = perspective === 'demand'
? (inq.propertyAddress ?? inq.subject)
: inq.tenantName
const secondaryLine = perspective === 'demand'
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
: (inq.tenantCompany ?? '')
const lastMsgPrefix = (() => {
if (!lastMsg) return ''
if (perspective === 'demand') {
return lastMsg.senderType === 'tenant'
? 'Sie: '
: `${inq.propertyManagerName ?? 'Verwalter'}: `
}
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
})()
return (
<Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
borderBottom: `1px solid ${DS_BORDER.muted}`,
cursor: 'pointer',
bgcolor: isSelected ? '#eff6ff' : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid #1e3a5f' : '3px solid transparent',
'&:hover': { bgcolor: isSelected ? '#eff6ff' : '#f8fafc' },
bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid' : '3px solid transparent',
borderLeftColor: isSelected ? 'primary.main' : 'transparent',
'&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : DS_BG.page },
transition: 'background-color 0.1s ease',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: '#dc2626', flexShrink: 0 }} />}
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{inq.tenantName}
{primaryTitle}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{inq.unreadCount > 0 && (
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: '#dc2626', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: unreadColor, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box>
</Box>
{inq.tenantCompany && (
{secondaryLine && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{inq.tenantCompany}
{secondaryLine}
</Typography>
)}
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
{inq.subject}
</Typography>
{lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
{lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `}
{lastMsgPrefix}
{lastMsg.body.split('\n')[0]}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label={cfg?.label ?? inq.status} size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bgColor, color: cfg?.color, fontWeight: 600 }} />
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bg, color: cfg?.fg, fontWeight: 600 }} />
{inq.matchScore && (
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? '#1a7a4a' : '#d97706', fontWeight: 700, fontSize: '0.7rem' }}>
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, fontSize: '0.7rem' }}>
{inq.matchScore}%
</Typography>
)}
@@ -74,7 +89,12 @@ export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, onSelect }:
icon={<Kanban size={9} />}
label="Pipeline"
size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: '#eff6ff', color: '#1e3a5f', fontWeight: 600, '& .MuiChip-icon': { color: '#1e3a5f' } }}
sx={{
height: 16, fontSize: '0.6rem', fontWeight: 600,
bgcolor: DS_COLORS.futureCard.signal.headerBg,
color: 'primary.main',
'& .MuiChip-icon': { color: 'primary.main' },
}}
/>
)}
</Box>
+13 -12
View File
@@ -1,8 +1,9 @@
import { Box, Typography } from '@mui/material'
import { Bot, Building2, FileText } from 'lucide-react'
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
const isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
@@ -11,10 +12,10 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
{!isOwnMessage && isAI && <Bot size={11} color="#7c3aed" />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color="#64748b" />}
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{msg.senderName} · {date} {time}
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
</Typography>
</Box>
<Box
@@ -22,15 +23,15 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
maxWidth: { xs: '88%', md: '72%' },
px: 2, py: 1.25,
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: isOwnMessage ? '#1e3a5f' : isAI ? '#f5f3ff' : 'white',
border: isOwnMessage ? 'none' : isAI ? '1px solid #ddd6fe' : '1px solid #e2e8f0',
bgcolor: isOwnMessage ? 'primary.main' : isAI ? DS_COLORS.futureCard.controlled.headerBg : 'white',
border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : `1px solid ${DS_BORDER.default}`,
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
}}
>
<Typography
variant="body2"
sx={{
color: isOwnMessage ? 'white' : isAI ? '#5b21b6' : '#1e293b',
color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : DS_TEXT.primary,
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
}}
>
@@ -44,17 +45,17 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 0.75,
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : '#f1f5f9',
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : DS_BG.subtle,
borderRadius: 1.5, cursor: 'pointer',
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : '#e2e8f0' },
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : DS_BG.muted },
}}
>
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : '#64748b'} />
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : '#475569', flex: 1 }} noWrap>
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : DS_TEXT.muted} />
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : DS_TEXT.secondary, flex: 1 }} noWrap>
{att.fileName}
</Typography>
{att.fileSize && (
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : '#94a3b8', flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : DS_TEXT.disabled, flexShrink: 0 }}>
{att.fileSize < 1024 * 1024
? `${Math.round(att.fileSize / 1024)} KB`
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
@@ -1,22 +1,17 @@
import { Chip } from '@mui/material'
import { confidenceHex } from '../../lib/utils'
interface Props {
confidence: number
size?: 'small' | 'medium'
}
function confidenceColor(c: number): string {
if (c >= 0.8) return '#1a7a4a'
if (c >= 0.6) return '#d97706'
return '#c0392b'
}
export function ConfidenceFieldBadge({ confidence, size = 'small' }: Props) {
return (
<Chip
label={`${Math.round(confidence * 100)}%`}
size={size}
sx={{ bgcolor: confidenceColor(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
sx={{ bgcolor: confidenceHex(confidence), color: 'white', fontWeight: 700, fontSize: 11 }}
/>
)
}
@@ -10,7 +10,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
}
@@ -38,7 +38,7 @@ function displayAreaRange(v: ParsedNeedCriteria['areaRange']): string {
return v ? `${v.min}${v.max}` : ''
}
function displayBudget(v: ParsedNeedCriteria['budgetRange']): string {
return v ? `CHF ${v.maxPerSqm}/m²` : ''
return v ? `CHF ${v.maxPerSqm}/m²/Jahr` : ''
}
function displayTiming(v: ParsedNeedCriteria['timing']): string {
if (!v) return ''
+2 -2
View File
@@ -15,7 +15,7 @@ interface Props {
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
}
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
@@ -95,7 +95,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
<Wallet size={16} color="#64748b" style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography variant="caption" color="text.secondary">Budget</Typography>
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²</Typography>
<Typography variant="body2">max. CHF {c.budgetRange.maxPerSqm}/m²/Jahr</Typography>
</Box>
</Box>
)}
@@ -0,0 +1,80 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
interface Props {
criteria: ParsedNeedCriteria
onChange: (c: ParsedNeedCriteria) => void
}
export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props) {
return (
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
<TextField
size="small" type="number" fullWidth placeholder="0"
value={c.requiredParkingMin ?? ''}
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 100 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
<TextField
select size="small" fullWidth
value={c.requiredFitOut ?? ''}
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
>
<MenuItem value="">Kein Mindeststandard</MenuItem>
<MenuItem value="BASIC">Basisausbau</MenuItem>
<MenuItem value="FULL">Vollausbau</MenuItem>
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
</TextField>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 6"
value={c.minCeilingHeightM ?? ''}
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 36"
value={c.minContractDurationMonths ?? ''}
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
)
}
+4 -74
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { Accordion, AccordionDetails, AccordionSummary, Box, Card, Checkbox, Chip, FormControlLabel, MenuItem, Stack, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
interface Props {
criteria: ParsedNeedCriteria
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
{ label: 'Retail', value: AssetType.RETAIL },
{ label: 'Logistik', value: AssetType.LOGISTICS },
{ label: 'Produktion', value: AssetType.PRODUCTION },
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
{ label: 'Gemischt', value: AssetType.MIXED },
]
@@ -220,77 +220,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack>
)}
{/* Extended requirements */}
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
{/* Boolean requirement checkboxes — 2×2 grid */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireGroundFloor ?? false} onChange={e => set({ ...c, requireGroundFloor: e.target.checked || undefined })} />}
label={<Typography variant="caption">Erdgeschoss erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireAirConditioning ?? false} onChange={e => set({ ...c, requireAirConditioning: e.target.checked || undefined })} />}
label={<Typography variant="caption">Klimaanlage erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireLoadingDock ?? false} onChange={e => set({ ...c, requireLoadingDock: e.target.checked || undefined })} />}
label={<Typography variant="caption">Laderampe erforderlich</Typography>}
/>
<FormControlLabel
control={<Checkbox size="small" checked={c.requireBarrierFree ?? false} onChange={e => set({ ...c, requireBarrierFree: e.target.checked || undefined })} />}
label={<Typography variant="caption">Barrierefrei erforderlich</Typography>}
/>
</Box>
{/* Numeric / select fields — 2×2 grid */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Parkplätze</Typography>
<TextField
size="small" type="number" fullWidth placeholder="0"
value={c.requiredParkingMin ?? ''}
onChange={e => set({ ...c, requiredParkingMin: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 100 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Ausbaustandard</Typography>
<TextField
select size="small" fullWidth
value={c.requiredFitOut ?? ''}
onChange={e => set({ ...c, requiredFitOut: (e.target.value as 'BASIC' | 'FULL' | 'PREMIUM') || undefined })}
>
<MenuItem value="">Kein Mindeststandard</MenuItem>
<MenuItem value="BASIC">Basisausbau</MenuItem>
<MenuItem value="FULL">Vollausbau</MenuItem>
<MenuItem value="PREMIUM">Premiumausbau</MenuItem>
</TextField>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Deckenhöhe (m)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 6"
value={c.minCeilingHeightM ?? ''}
onChange={e => set({ ...c, minCeilingHeightM: parseFloat(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 2, max: 20, step: 0.5 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindest-Vertragslaufzeit (Monate)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 36"
value={c.minContractDurationMonths ?? ''}
onChange={e => set({ ...c, minContractDurationMonths: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
</Box>
</AccordionDetails>
</Accordion>
<NeedExtendedRequirements criteria={c} onChange={set} />
</Card>
)
}
+4 -4
View File
@@ -3,7 +3,7 @@ import { Box, Button, Card, Divider, Slider, Typography } from '@mui/material'
import { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
import { weightingService } from '../../services/weightingService'
import { useDefaultWeights } from '../../hooks/useWeighting'
const HARD_KEYS: WeightingKey[] = ['area', 'location', 'budget', 'timing']
const SOFT_KEYS: WeightingKey[] = [
@@ -74,6 +74,7 @@ function SliderGroup({
export function WeightingEditor({ weights, onChange, assetType }: Props) {
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
const defaultWeights = useDefaultWeights(assetType)
function handleSlider(key: WeightingKey, value: number) {
const updated = { ...raw, [key]: value }
@@ -82,9 +83,8 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
}
function handleReset() {
const defaults = weightingService.getDefaultWeights(assetType)
setRaw(toRaw(defaults))
onChange(defaults)
setRaw(toRaw(defaultWeights))
onChange(defaultWeights)
}
return (
@@ -6,9 +6,9 @@ import { SensitivityBadge } from './SensitivityBadge'
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore'
import { reviewService } from '../../services/reviewService'
import { ReviewStatus } from '../../domain/enums'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -51,6 +51,7 @@ interface Props {
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
const updateStatus = useUpdateSignalReviewStatus()
const createReviewTask = useCreateReviewTask()
const { openAddDialog } = useShortlistStore()
const showToast = useToastStore((s) => s.showToast)
const [reviewTaskSent, setReviewTaskSent] = useState(false)
@@ -75,14 +76,13 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
}
}
async function handleSendReview() {
try {
await reviewService.createReviewTask(signal.id)
setReviewTaskSent(true)
showToast('Prüfungsaufgabe erstellt.')
} catch {
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
}
function handleSendReview() {
createReviewTask.mutate(signal.id, {
onSuccess: () => {
setReviewTaskSent(true)
showToast('Prüfungsaufgabe erstellt.')
},
})
}
function handleShortlist() {
+4 -2
View File
@@ -17,8 +17,10 @@ import { WorkspaceType } from '../../domain/enums'
// ---------------------------------------------------------------------------
export function AppShell() {
const { activeWorkspace, sidebarCollapsed, setActiveWorkspace, toggleSidebar } =
useLayoutStore()
const activeWorkspace = useLayoutStore(s => s.activeWorkspace)
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
-8
View File
@@ -1,28 +1,20 @@
import { useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router'
import { Box, Button, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
import { useLayoutStore } from '../../stores/layoutStore'
const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
EXTERNAL_MARKET: '#d97706',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed',
}
export function CompareTray() {
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
const { setCompareTrayVisible } = useLayoutStore()
const navigate = useNavigate()
const location = useLocation()
const isDemand = location.pathname.startsWith('/demand')
useEffect(() => {
setCompareTrayVisible(compareItems.length > 0 && isDemand)
}, [compareItems.length, setCompareTrayVisible, isDemand])
if (!isDemand) return null
const getTitle = (item: (typeof compareItems)[number]) => {
+3 -1
View File
@@ -18,7 +18,9 @@ const PANEL_PLACEHOLDERS: Record<RightPanelContentType, string> = {
}
export function RightContextPanel() {
const { isRightPanelOpen, rightPanelContentType, closeRightPanel } = useLayoutStore()
const isRightPanelOpen = useLayoutStore(s => s.isRightPanelOpen)
const rightPanelContentType = useLayoutStore(s => s.rightPanelContentType)
const closeRightPanel = useLayoutStore(s => s.closeRightPanel)
const title = rightPanelContentType ? PANEL_TITLES[rightPanelContentType] : ''
const placeholder = rightPanelContentType ? PANEL_PLACEHOLDERS[rightPanelContentType] : ''
+14 -6
View File
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
import {
LayoutDashboard,
Building2,
Target,
CheckSquare,
Search,
List,
@@ -13,7 +12,7 @@ import {
MessageSquare,
Kanban,
BellRing,
Plus,
Settings,
} from 'lucide-react'
// ---------------------------------------------------------------------------
@@ -50,12 +49,10 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar },
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList },
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
{ path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
{ path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
],
},
[WorkspaceType.DEMAND]: {
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
],
},
[WorkspaceType.OPERATIONS]: {
label: 'Operations',
abbreviation: 'OP',
icon: Settings,
firstPath: '/ops/review-queue',
chipColor: '#6b21a8',
navItems: [
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
],
},
}
// Ordered list for rendering workspace tabs
@@ -1,90 +1,25 @@
import { memo } from 'react'
import { Box, Button, Divider, Typography } from '@mui/material'
import {
AlertCircle,
BarChart2,
Briefcase,
CheckCircle2,
FileCheck,
FileText,
Newspaper,
ShieldCheck,
User,
} from 'lucide-react'
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
import { useNavigate } from 'react-router'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { floorLabel, SOURCE_META, ASSET_TYPE_LABELS, getOpportunityHeadline } from './futureAvailabilityCardHelpers'
import { SignalQualityDots } from './SignalQualityDots'
import { DS_COLORS, DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import type { MatchCardViewModel } from './MatchCardViewModel'
function floorLabel(level: number): string {
if (level === 0) return 'EG'
if (level < 0) return `UG ${Math.abs(level)}`
return `${level}.OG`
}
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={11} /> },
PRESS: { label: 'Pressebericht', icon: <Newspaper size={11} /> },
CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: <FileCheck size={11} /> },
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={11} /> },
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={11} /> },
MANUAL: { label: 'Analyst', icon: <User size={11} /> },
LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: <ShieldCheck size={11} /> },
}
const ASSET_TYPE_LABELS: Record<string, string> = {
OFFICE: 'Bürofläche',
LOGISTICS: 'Lagerfläche',
RETAIL: 'Retailfläche',
PRODUCTION: 'Produktionsfläche',
MIXED: 'Gewerbefläche',
}
// Signal type → opportunity headline (with asset type)
function getOpportunityHeadline(signalType: string | undefined, assetTypeLabel: string): string {
switch (signalType) {
case 'LEASE_EXPIRY': return `${assetTypeLabel} wird verfügbar`
case 'POSSIBLE_MOVE_OUT': return `Mögliche ${assetTypeLabel} erkannt`
case 'EXPANSION': return `Unternehmen sucht ${assetTypeLabel}`
case 'CONSTRUCTION_PROJECT': return `Neubau: ${assetTypeLabel} in Planung`
case 'RESTRUCTURING': return `Mögliche Flächenfreigabe erkannt`
case 'SPACE_CONSOLIDATION': return `Mögliche Teilfläche erkannt`
case 'PROJECT_DEVELOPMENT': return `Neue Fläche in Projektentwicklung`
default: return `Potenzielle ${assetTypeLabel} erkannt`
}
}
// Signal quality dots display
function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) {
const config = {
HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' },
MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' },
LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' },
}
const c = quality ? config[quality] : config.LOW
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{c.dots.map((filled, i) => (
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
))}
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
{c.label}
</Typography>
</Box>
)
}
// ── Future Availability Card ──────────────────────────────────────────────────
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
const isControlled = vm.signalIsControlled ?? false
const navigate = useNavigate()
// PRE-MARKET VERIFIED: soft purple / institutional premium
// MARKET SIGNAL: slate blue / analytical
const accentColor = isControlled ? '#7c3aed' : '#1d4ed8'
const headerBg = isControlled ? '#faf5ff' : '#eff6ff'
const borderColor = isControlled ? '#e9d5ff' : '#bfdbfe'
const badgeBg = isControlled ? '#ede9fe' : '#dbeafe'
const badgeColor = isControlled ? '#5b21b6' : '#1e40af'
const cardColors = isControlled ? DS_COLORS.futureCard.controlled : DS_COLORS.futureCard.signal
const accentColor = cardColors.accent
const headerBg = cardColors.headerBg
const borderColor = cardColors.border
const badgeBg = cardColors.badgeBg
const badgeColor = cardColors.badgeText
const assetLabel = vm.assetType ? (ASSET_TYPE_LABELS[vm.assetType] ?? vm.assetType) : 'Fläche'
const headline = getOpportunityHeadline(vm.signalType, assetLabel)
@@ -110,7 +45,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* Asset type · location + badge */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.8 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.8 }}>
{assetLabel.toUpperCase()} · {vm.locationLabel?.split(',')[0]?.toUpperCase() ?? ''}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: badgeBg, color: badgeColor, px: 0.875, py: 0.25, borderRadius: 1, flexShrink: 0 }}>
@@ -122,19 +57,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
</Box>
{/* Opportunity headline */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#1e293b', mb: 0.75 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary, mb: 0.75 }}>
{headline}
</Typography>
{/* Key facts */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 0.875 }}>
{vm.signalAreaSqmEstimate ? (
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#1e293b' }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: DS_TEXT.primary }}>
{isControlled ? '' : '~'}{vm.signalAreaSqmEstimate.toLocaleString('de-CH')} m²
</Typography>
) : null}
{vm.availabilityLabel && (
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
{vm.availabilityLabel}
</Typography>
)}
@@ -142,20 +77,20 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* Source attribution */}
{isControlled ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
<ShieldCheck size={10} color="#1a7a4a" />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#1a7a4a' }}>Direkte Verwaltungsquelle</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 0.875, py: 0.3, width: 'fit-content' }}>
<ShieldCheck size={10} color={DS_TEXT.success} />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: DS_TEXT.success }}>Direkte Verwaltungsquelle</Typography>
</Box>
) : sourceMeta ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, width: 'fit-content' }}>
<Box sx={{ color: '#64748b', display: 'flex' }}>{sourceMeta.icon}</Box>
<Typography sx={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 500 }}>{sourceMeta.label}</Typography>
<Box sx={{ color: DS_TEXT.muted, display: 'flex' }}>{sourceMeta.icon}</Box>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted, fontWeight: 500 }}>{sourceMeta.label}</Typography>
</Box>
) : null}
</Box>
{/* ── Score + signal quality strip ────────────────────────────────────── */}
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: '1px solid #f1f5f9', bgcolor: 'white' }}>
<Box sx={{ px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1.5, borderBottom: `1px solid ${DS_BORDER.muted}`, bgcolor: 'white' }}>
<Box sx={{
background: theme.gradient, borderRadius: '8px',
px: 1.25, py: 0.4, border: `1px solid ${theme.border}`,
@@ -167,8 +102,8 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
</Box>
<SignalQualityDots quality={vm.signalQuality} />
{!isControlled && vm.signalProbability !== undefined && (
<Box sx={{ ml: 'auto', bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.75, py: 0.2 }}>
<Typography sx={{ fontSize: '0.63rem', color: '#64748b', fontWeight: 600 }}>
<Box sx={{ ml: 'auto', bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, px: 0.75, py: 0.2 }}>
<Typography sx={{ fontSize: '0.63rem', color: DS_TEXT.muted, fontWeight: 600 }}>
{Math.round(vm.signalProbability * 100)}% Signalw.
</Typography>
</Box>
@@ -182,10 +117,10 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{!isControlled && (
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 0.6, mb: 1.25,
bgcolor: '#fefce8', border: '1px solid #fde68a', borderRadius: 1, px: 1, py: 0.625,
bgcolor: DS_SURFACE.warning.bg, border: `1px solid ${DS_SURFACE.warning.border}`, borderRadius: 1, px: 1, py: 0.625,
}}>
<AlertCircle size={11} color="#92400e" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#78350f', lineHeight: 1.45 }}>
<AlertCircle size={11} color={DS_COLORS.heat.VERY_HOT.text} style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: DS_COLORS.heat.VERY_HOT.text, lineHeight: 1.45 }}>
Probabilistischer Marktindikator kein bestätigtes Objekt. Dient als strategischer Frühindikator.
</Typography>
</Box>
@@ -196,8 +131,8 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4, mb: 1.25 }}>
{(vm.signalConfirmedFacts ?? []).slice(0, 3).map((fact, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: '#1a7a4a', fontWeight: 500 }}>{fact}</Typography>
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.success, fontWeight: 500 }}>{fact}</Typography>
</Box>
))}
</Box>
@@ -205,19 +140,19 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* PRE-MARKET VERIFIED: specific unit info */}
{isControlled && vm.preMarketUnit && (
<Box sx={{ bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#1a7a4a', textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
<Box sx={{ bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 1.25, py: 0.875, mb: 1.25 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.success, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.4 }}>
Freigegebene Einheit
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: '0.72rem', color: '#1e293b', fontWeight: 600 }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.primary, fontWeight: 600 }}>
{floorLabel(vm.preMarketUnit.floorLevel)}{vm.preMarketUnit.unitLabel ? ` · ${vm.preMarketUnit.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{vm.preMarketUnit.schattenmarktRelease?.availableFrom && (
<Typography sx={{ fontSize: '0.72rem', color: '#475569' }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary }}>
ab {new Date(vm.preMarketUnit.schattenmarktRelease.availableFrom).toLocaleDateString('de-CH', { month: 'short', year: 'numeric' })}
</Typography>
)}
@@ -228,14 +163,14 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* MARKET SIGNAL: market indicators */}
{!isControlled && (vm.signalMarketIndicators?.length ?? 0) > 0 && (
<Box sx={{ mb: 1.25 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.5 }}>
Erkannte Marktindikatoren
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35 }}>
{(vm.signalMarketIndicators ?? []).slice(0, 3).map((ind, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '5px', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: '#475569', lineHeight: 1.3 }}>{ind}</Typography>
<Box sx={{ width: 4, height: 4, borderRadius: '50%', bgcolor: DS_COLORS.futureCard.signal.indicator, mt: '5px', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>{ind}</Typography>
</Box>
))}
</Box>
@@ -246,16 +181,16 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{vm.reasons.length > 0 && (
<>
<Divider sx={{ mb: 1 }} />
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
<Typography sx={{ fontSize: '0.63rem', fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.6, mb: 0.6 }}>
Warum relevant für Ihre Suche?
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.25 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.6 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>{r.label}</Typography>
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>{r.explanation}</Typography>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary, lineHeight: 1.3 }}>{r.label}</Typography>
<Typography sx={{ fontSize: '0.69rem', color: DS_TEXT.muted, lineHeight: 1.3 }}>{r.explanation}</Typography>
</Box>
</Box>
))}
@@ -276,7 +211,7 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
...(a.variant === 'primary' && {
bgcolor: accentColor,
'&:hover': { bgcolor: isControlled ? '#6d28d9' : '#1e40af' },
'&:hover': { bgcolor: cardColors.accentHover },
}),
}}
>
@@ -290,7 +225,9 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
onClick={() => navigate(`/demand/property/${vm.propertyId}${vm.unitId ? `?unit=${vm.unitId}` : ''}`)}
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, ml: 'auto',
bgcolor: DS_COLORS.futureCard.controlled.accent,
'&:hover': { bgcolor: DS_COLORS.futureCard.controlled.accentHover },
ml: 'auto',
}}
>
Zur Einheit
@@ -300,11 +237,11 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
{/* Disclaimer footnote */}
{vm.disclaimer && (
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: '1px solid #f1f5f9', pt: 0.75 }}>
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1, fontSize: '0.63rem', lineHeight: 1.4, borderTop: `1px solid ${DS_BORDER.muted}`, pt: 0.75 }}>
{vm.disclaimer}
</Typography>
)}
</Box>
</Box>
)
}
})
@@ -4,21 +4,14 @@ import {
CheckCircle2,
} from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import type { MatchCardViewModel } from './MatchCardViewModel'
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
// ── Constants ─────────────────────────────────────────────────────────────────
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Future Availability', color: '#7c3aed' },
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
@@ -27,12 +20,15 @@ interface Props {
lat?: number
lng?: number
cityLabel?: string
overlayTypeLabel?: string
overlayLocationLabel?: string
}
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
@@ -59,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
{/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
<Box sx={{
position: 'absolute', top: 10, left: 10,
background: theme.gradient, borderRadius: '10px',
@@ -71,11 +67,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
{vm.matchScore}%
</Typography>
{tier !== 'bronze' && (
<Typography sx={{ fontSize: '0.575rem', color: theme.text, opacity: 0.8, textTransform: 'uppercase', letterSpacing: 0.8, lineHeight: 1.2 }}>
{theme.label}
</Typography>
)}
</Box>
<Box sx={{ position: 'absolute', top: 10, right: 44, 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 }} />
@@ -153,7 +144,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Button
size="small"
variant="contained"
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: vm.title,
location: vm.locationLabel ?? '',
matchScore: vm.matchScore,
matchId: vm.id,
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage
</Button>
{vm.actions.map(a => (
<Button
key={a.id}
@@ -163,10 +171,6 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
disabled={a.disabled}
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
...(a.variant === 'primary' && {
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
}),
}}
>
{a.label}
+187 -90
View File
@@ -1,119 +1,216 @@
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import { MatchCardHeader } from './MatchCardHeader'
import { MatchReasonList } from './MatchReasonList'
import { TradeoffList } from './TradeoffList'
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
import { MatchActionToolbar } from './MatchActionToolbar'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import { memo } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
vm: MatchCardViewModel
imageUrl?: string
overlayTypeLabel?: string
}
export function MatchCardCompact({ vm }: Props) {
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState />
const borderColor = vm.isCompareSelected
? '#7c3aed'
: vm.isSelected
? '#1e3a5f'
: 'transparent'
const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const confPct = Math.round(vm.confidenceScore * 100)
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
return (
<Card
sx={{
p: 2.5,
mb: 1.5,
border: `2px solid ${borderColor}`,
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'border-color 0.15s',
}}
>
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
{vm.disclaimer}
</Alert>
)}
<Box sx={{
display: 'flex',
mb: 1.5,
borderRadius: 2,
overflow: 'hidden',
border: `2px solid ${borderColor}`,
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
background: theme.cardBg,
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'box-shadow 0.15s',
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
}}>
{vm.isReviewRequired && (
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
Manuelle Überprüfung erforderlich
</Alert>
)}
{/* ── Image strip ── */}
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
{imageUrl ? (
<img
src={imageUrl}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{
width: '100%', height: '100%', minHeight: 120,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
}}>
<MapPin size={24} color="#7ba3bf" />
</Box>
)}
{/* Header: score → type → confidence → availability → risk */}
<MatchCardHeader vm={vm} compact />
{/* Score badge */}
<Box sx={{
position: 'absolute', top: 8, left: 8,
background: theme.gradient, borderRadius: '8px',
px: 1, py: 0.25,
border: `1px solid ${theme.border}`,
boxShadow: `0 2px 8px ${theme.glow}`,
}}>
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
{vm.matchScore}%
</Typography>
</Box>
{/* Title + location (max 2 lines) */}
<Box sx={{ mt: 1.25, mb: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{/* Asset type label */}
{overlayTypeLabel && (
<Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0,
px: 0.75, py: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
}}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
{overlayTypeLabel}
</Typography>
</Box>
)}
</Box>
{/* ── Content ── */}
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Alerts */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
)}
{/* Badges row */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
<HeatBadge propertyId={vm.propertyId} size="sm" />
<Chip label={rt.label} size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
)}
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
)}
{topRisk && (
<Chip
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
size="small" color="error" variant="outlined"
sx={{ fontSize: 10, height: 20 }}
/>
)}
</Box>
</Box>
{/* Title + location */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
{vm.title}
</Typography>
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<MapPin size={13} color="#64748b" />
<Typography variant="body2" color="text.secondary">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
<MapPin size={11} color="#64748b" />
<Typography variant="caption" color="text.secondary">
{vm.locationLabel}
</Typography>
</Box>
{/* Explainability summary */}
{vm.explainabilitySummary && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{vm.explainabilitySummary}
</Typography>
)}
<Divider sx={{ my: 1 }} />
{/* Score breakdown */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 1 }}>
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
</Box>
)}
{/* Reasons — all 3, same style as grid card */}
{vm.reasons.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
{r.label}
</Typography>
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
{r.explanation}
</Typography>
</Box>
</Box>
))}
</Box>
)}
{/* Top tradeoff */}
{vm.tradeoffs.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
{vm.tradeoffs[0].concern}
</Typography>
</Box>
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Button size="small" variant="contained" onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: vm.title,
location: vm.locationLabel ?? '',
matchScore: vm.matchScore,
matchId: vm.id,
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
Anfrage
</Button>
{vm.actions.map(a => (
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick} disabled={a.disabled}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
{a.label}
</Button>
))}
</Box>
</Box>
<Divider sx={{ mb: 1.25 }} />
{/* Score formula — always visible */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 1.25 }}>
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
</Box>
)}
{/* Top reason only */}
{vm.reasons.length > 0 && (
<Box sx={{ mb: 1 }}>
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
</Box>
)}
{/* Top tradeoff only (compact) */}
{vm.tradeoffs.length > 0 && (
<Box sx={{ mb: 1 }}>
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
</Box>
)}
{/* Data quality — only critical warning in compact mode */}
<MatchDataQualitySummary
dataQualityScore={vm.dataQualityScore}
missingData={vm.missingData}
compact
/>
<Box sx={{ mt: 1.25 }}>
<MatchActionToolbar actions={vm.actions} compact />
</Box>
</Card>
</Box>
)
}
})
@@ -5,7 +5,6 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direkt', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
}
+3 -14
View File
@@ -3,21 +3,10 @@ import { Building2 } from 'lucide-react'
import { MatchScoreDisplay } from './MatchScoreDisplay'
import { HeatBadge } from '../shared'
import { useSessionStore } from '../../stores/sessionStore'
import { RESULT_TYPE_META } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Plattform', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Plattform', color: '#1e3a5f' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
function confidenceColor(score: number): string {
if (score >= 0.75) return '#1a7a4a'
if (score >= 0.55) return '#d97706'
return '#c0392b'
}
interface Props {
vm: MatchCardViewModel
compact?: boolean
@@ -61,7 +50,7 @@ export function MatchCardHeader({ vm, compact }: Props) {
<Chip
label={`${confPct}% Konfidenz`}
size="small"
sx={{ bgcolor: confidenceColor(vm.confidenceScore), color: 'white', fontSize: 11 }}
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 11 }}
/>
{vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 11 }} />
@@ -1,22 +1,17 @@
import { Box, Typography } from '@mui/material'
import { matchScoreHex } from '../../lib/utils'
interface Props {
score: number
size?: 'sm' | 'md' | 'lg'
}
function scoreColor(score: number): string {
if (score >= 78) return '#1a7a4a'
if (score >= 52) return '#d97706'
return '#c0392b'
}
const FONT_SIZES: Record<string, string> = { sm: '1.25rem', md: '1.75rem', lg: '2.5rem' }
export function MatchScoreDisplay({ score, size = 'md' }: Props) {
return (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.25, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 800, fontSize: FONT_SIZES[size], color: scoreColor(score), lineHeight: 1 }}>
<Typography sx={{ fontWeight: 800, fontSize: FONT_SIZES[size], color: matchScoreHex(score), lineHeight: 1 }}>
{score}
</Typography>
<Typography variant="caption" color="text.secondary">/100</Typography>
@@ -1,5 +1,6 @@
import { Box, Divider, LinearProgress, Typography } from '@mui/material'
import type { ScoreFactor } from '../../domain/match'
import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils'
interface ScoreBreakdownData {
hardMatchScore: number
@@ -22,14 +23,6 @@ const CRITERION_LABEL: Record<string, string> = {
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
function scoreColor(v: number): 'success' | 'warning' | 'error' {
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
}
function scoreTextColor(v: number): string {
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
}
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
@@ -43,7 +36,7 @@ function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: numb
const label = CRITERION_LABEL[factor.criterion] ?? factor.criterion
const pct = Math.round(factor.weight * 100)
const imp = importanceLabel(factor.weight, maxWeight)
const color = scoreColor(factor.score)
const color = criterionScoreColor(factor.score)
return (
<Box sx={{ mb: 1.25 }}>
@@ -54,7 +47,7 @@ function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: numb
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>{pct}%</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: criterionScoreTextColor(factor.score) }}>
{factor.score}/100
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', minWidth: 40, textAlign: 'right' }}>
@@ -82,7 +75,7 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact =
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.5 }}>
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
{' = '}{hardContrib} + {softContrib}{' = '}
<span style={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}>{sb.totalScore}</span>
<span style={{ fontWeight: 800, color: criterionScoreTextColor(sb.totalScore) }}>{sb.totalScore}</span>
</Typography>
</Box>
)
@@ -0,0 +1,20 @@
import { Box, Typography } from '@mui/material'
export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) {
const config = {
HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' },
MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' },
LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' },
}
const c = quality ? config[quality] : config.LOW
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{c.dots.map((filled, i) => (
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
))}
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
{c.label}
</Typography>
</Box>
)
}
@@ -0,0 +1,38 @@
import { Briefcase, BarChart2, FileCheck, FileText, User, ShieldCheck, Newspaper } from 'lucide-react'
export function floorLabel(level: number): string {
if (level === 0) return 'EG'
if (level < 0) return `UG ${Math.abs(level)}`
return `${level}.OG`
}
export const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={11} /> },
PRESS: { label: 'Pressebericht', icon: <Newspaper size={11} /> },
CONSTRUCTION_PERMIT:{ label: 'Baubewilligung', icon: <FileCheck size={11} /> },
COMPANY_REPORT: { label: 'Geschäftsbericht', icon: <FileText size={11} /> },
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={11} /> },
MANUAL: { label: 'Analyst', icon: <User size={11} /> },
LEASE_CONTRACT: { label: 'Vertrag verifiziert', icon: <ShieldCheck size={11} /> },
}
export const ASSET_TYPE_LABELS: Record<string, string> = {
OFFICE: 'Bürofläche',
LOGISTICS: 'Lagerfläche',
RETAIL: 'Retailfläche',
PRODUCTION: 'Produktionsfläche',
MIXED: 'Gewerbefläche',
}
export function getOpportunityHeadline(signalType: string | undefined, assetTypeLabel: string): string {
switch (signalType) {
case 'LEASE_EXPIRY': return `${assetTypeLabel} wird verfügbar`
case 'POSSIBLE_MOVE_OUT': return `Mögliche ${assetTypeLabel} erkannt`
case 'EXPANSION': return `Unternehmen sucht ${assetTypeLabel}`
case 'CONSTRUCTION_PROJECT': return `Neubau: ${assetTypeLabel} in Planung`
case 'RESTRUCTURING': return `Mögliche Flächenfreigabe erkannt`
case 'SPACE_CONSOLIDATION': return `Mögliche Teilfläche erkannt`
case 'PROJECT_DEVELOPMENT': return `Neue Fläche in Projektentwicklung`
default: return `Potenzielle ${assetTypeLabel} erkannt`
}
}
+1
View File
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
export { MatchActionToolbar } from './MatchActionToolbar'
export { MatchCardSkeleton } from './MatchCardSkeleton'
export { MatchCardRestrictedState } from './MatchCardRestrictedState'
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
export type {
MatchCardViewModel,
MatchCardVariant,
@@ -2,12 +2,12 @@ import { Box, CircularProgress, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches'
import { usePropertyById } from '../../hooks/useProperties'
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { useCompareStore } from '../../stores/compareStore'
import { useToastStore } from '../../stores/toastStore'
import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import { reviewService } from '../../services/reviewService'
import { MatchCenterEmptyState } from './MatchCenterEmptyState'
import { MatchStatusBadge } from './MatchStatusBadge'
import type { MatchCardAction } from '../match-card/MatchCardViewModel'
@@ -18,6 +18,7 @@ export function MatchBriefingPanel() {
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
const { addToCompare } = useCompareStore()
const approveMatch = useApproveMatch()
const createReviewTask = useCreateReviewTask()
const showToast = useToastStore((s) => s.showToast)
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
@@ -64,7 +65,7 @@ export function MatchBriefingPanel() {
label: 'Zur Prüfung',
actionType: 'SEND_REVIEW',
variant: 'secondary',
onClick: () => { reviewService.createReviewTask(match.id) },
onClick: () => { createReviewTask.mutate(match.id) },
},
{
id: 'compare',
@@ -3,6 +3,7 @@ import { MatchStatusBadge } from './MatchStatusBadge'
import type { Match } from '../../domain/match'
import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need'
import { matchScoreHex } from '../../lib/utils'
const STRENGTH_META: Record<string, { label: string; bg: string; color: string }> = {
STRONG: { label: 'Stark', bg: '#f0fdf4', color: '#1a7a4a' },
@@ -10,12 +11,6 @@ const STRENGTH_META: Record<string, { label: string; bg: string; color: string }
WEAK: { label: 'Schwach', bg: '#fff1f2', color: '#c0392b' },
}
function scoreColor(score: number) {
if (score >= 80) return '#1a7a4a'
if (score >= 60) return '#d97706'
return '#c0392b'
}
interface Props {
match: Match
property: Property | undefined
@@ -49,7 +44,7 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
width: 52,
height: 52,
borderRadius: 2,
bgcolor: scoreColor(match.matchScore),
bgcolor: matchScoreHex(match.matchScore),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -1,4 +1,4 @@
import { Chip } from '@mui/material'
import { GenericBadge } from '../shared/GenericBadge'
import type { MatchStatus } from '../../domain/enums'
const STATUS_META: Record<string, { label: string; color: string }> = {
@@ -10,9 +10,6 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
export function MatchStatusBadge({ status }: { status?: MatchStatus }) {
if (!status) return null
const meta = STATUS_META[status] ?? { label: status, color: '#64748b' }
return (
<Chip label={meta.label} size="small"
sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 11 }} />
)
const { label, color } = STATUS_META[status] ?? { label: status, color: '#64748b' }
return <GenericBadge label={label} color={color} variant="solid" />
}
+3 -10
View File
@@ -1,19 +1,12 @@
import { Box, LinearProgress, Typography } from '@mui/material'
import type { ScoreFactor } from '../../domain/match'
import { factorLabel, importanceLabel } from './scoreBreakdownConstants'
function scoreColor(v: number): 'success' | 'warning' | 'error' {
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
}
function scoreTextColor(v: number): string {
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
}
import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils'
export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
const label = factorLabel(factor.criterion)
const pct = Math.round(factor.weight * 100)
const color = scoreColor(factor.score)
const color = criterionScoreColor(factor.score)
const imp = importanceLabel(factor.weight, maxWeight)
return (
@@ -34,7 +27,7 @@ export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWe
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: criterionScoreTextColor(factor.score) }}>
{factor.score}/100
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', minWidth: 44, textAlign: 'right' }}>
@@ -0,0 +1,51 @@
import { Box, Paper, Typography } from '@mui/material'
import { FileImage } from 'lucide-react'
import { DS_TEXT } from '../../lib/ds'
interface FloorPlan {
url: string
label?: string
}
interface Props {
plans: FloorPlan[]
mb?: number | string
}
export function FloorPlanSection({ plans, mb }: Props) {
if (plans.length === 0) return null
return (
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<FileImage size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{plans.map((p, i) => (
<Box key={i}>
{p.label && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
{p.label}
</Typography>
)}
<Box
component="img"
src={p.url}
alt={p.label ?? 'Grundriss'}
sx={{
width: '100%',
borderRadius: 1,
border: '1px solid rgba(0,0,0,0.08)',
display: 'block',
objectFit: 'contain',
maxHeight: 480,
bgcolor: '#f8fafc',
}}
/>
</Box>
))}
</Box>
</Paper>
)
}
@@ -1,11 +1,9 @@
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
import { Box, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
import {
AlertTriangle,
Calendar,
CheckCircle2,
Clock,
ExternalLink,
Globe,
ShieldCheck,
Sparkles,
TrendingUp,
@@ -14,44 +12,9 @@ import {
import type { Match } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal'
import { SIGNAL_TYPE_LABELS, SIGNAL_ACTION, SOURCE_META } from './futureAvailabilityConstants'
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' },
}
const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
INTERNAL: { label: 'Intern', color: 'warning' },
PUBLIC: { label: 'Öffentlich', color: 'default' },
}
const RISK_META: Record<string, { label: string; color: string }> = {
LOW: { label: 'Niedrig', color: '#1a7a4a' },
MEDIUM: { label: 'Mittel', color: '#d97706' },
HIGH: { label: 'Hoch', color: '#c0392b' },
CRITICAL: { label: 'Kritisch', color: '#7f1d1d' },
}
function ageLabel(isoDate: string): string {
const diffMs = Date.now() - new Date(isoDate).getTime()
const days = Math.floor(diffMs / 86400000)
if (days < 1) return 'Heute erkannt'
if (days < 7) return `Vor ${days} Tag${days === 1 ? '' : 'en'} erkannt`
if (days < 30) return `Vor ${Math.floor(days / 7)} Woche${Math.floor(days / 7) === 1 ? '' : 'n'} erkannt`
if (days < 365) return `Vor ${Math.floor(days / 30)} Monat${Math.floor(days / 30) === 1 ? '' : 'en'} erkannt`
return `Vor ${Math.floor(days / 365)} Jahr${Math.floor(days / 365) === 1 ? '' : 'en'} erkannt`
}
function domainLabel(url: string): string {
try {
const u = new URL(url)
return u.hostname.replace(/^www\./, '')
} catch {
return url.length > 60 ? url.slice(0, 57) + '…' : url
}
}
import { CREDIBILITY_META, SENSITIVITY_META, RISK_META, ageLabel } from './futureAvailabilityContextUtils'
import { SignalSourcesSection } from './SignalSourcesSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_PRE_MARKET, DS_MARKET_SIGNAL, DS_BORDER } from '../../lib/ds'
interface Props {
match: Match
@@ -73,81 +36,85 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
...(signal.evidence?.sourceUrls ?? []),
]
const probBarColor = probPct >= 70 ? '#1a7a4a' : probPct >= 50 ? '#d97706' : '#c0392b'
const probBarColor = probPct >= 70 ? DS_TEXT.success : probPct >= 50 ? DS_TEXT.warning : DS_TEXT.error
return (
<Paper sx={{ p: 2.5, mb: 2 }}>
{/* ── Header ──────────────────────────────────────────────────────────── */}
{/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75, flexWrap: 'wrap' }}>
<Zap size={16} color="#7c3aed" />
<Zap size={16} color={DS_PRE_MARKET.accent} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Future Availability Signal</Typography>
{signal.isVerified && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: '#f0fdf4', border: '1px solid #86efac', borderRadius: 1, px: 0.75, py: 0.2 }}>
<ShieldCheck size={11} color="#1a7a4a" />
<Typography sx={{ fontSize: '0.68rem', color: '#1a7a4a', fontWeight: 700 }}>Verifiziert</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`, borderRadius: 1, px: 0.75, py: 0.2 }}>
<ShieldCheck size={11} color={DS_TEXT.success} />
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.success, fontWeight: 700 }}>Verifiziert</Typography>
</Box>
)}
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: '#94a3b8' }}>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.disabled }}>
<Clock size={12} />
<Typography variant="caption" color="text.secondary">{ageLabel(signal.createdAt)}</Typography>
</Box>
</Box>
{signal.title && (
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: '#1e293b', mb: 2, lineHeight: 1.4 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: DS_TEXT.primary, mb: 2, lineHeight: 1.4 }}>
{signal.title}
</Typography>
)}
{/* ── 1. KI-Zusammenfassung ─────────────────────────────────────────── */}
{/* ── 1. KI-Zusammenfassung ── */}
{signal.aiSummary && (
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
<Sparkles size={14} color="#7c3aed" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#4c1d95', fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
<Sparkles size={14} color={DS_PRE_MARKET.accent} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.signalDark, fontSize: '0.8125rem' }}>KI-Zusammenfassung</Typography>
</Box>
<Box sx={{ bgcolor: '#faf5ff', border: '1px solid #e9d5ff', borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: '#1e1b4b', lineHeight: 1.7 }}>
<Box sx={{ bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.7 }}>
{signal.aiSummary}
</Typography>
</Box>
</Box>
)}
{/* ── 2. Strategische Einschätzung ─────────────────────────────────── */}
{/* ── 2. Strategische Einschätzung ── */}
{signal.strategicInterpretation && (
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.875 }}>
<TrendingUp size={14} color="#0369a1" />
<TrendingUp size={14} color={DS_TEXT.info} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Strategische Einschätzung</Typography>
</Box>
<Box sx={{ bgcolor: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: '#0c4a6e', lineHeight: 1.7 }}>
<Box sx={{ bgcolor: DS_SURFACE.info.bg, border: `1px solid ${DS_SURFACE.info.border}`, borderRadius: 1.5, p: 1.75 }}>
<Typography variant="body2" sx={{ color: DS_TEXT.infoDark, lineHeight: 1.7 }}>
{signal.strategicInterpretation}
</Typography>
</Box>
</Box>
)}
{/* ── 3. Handlungsempfehlung ───────────────────────────────────────── */}
{/* ── 3. Handlungsempfehlung ── */}
{action && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, bgcolor: action.urgency === 'high' ? '#fff7ed' : '#f8fafc', border: `1px solid ${action.urgency === 'high' ? '#fed7aa' : '#e2e8f0'}`, borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5 }}>
<Zap size={14} color={action.urgency === 'high' ? '#c2410c' : '#64748b'} style={{ flexShrink: 0, marginTop: 2 }} />
<Box sx={{
display: 'flex', alignItems: 'flex-start', gap: 1,
bgcolor: action.urgency === 'high' ? DS_SURFACE.orange.bg : DS_BG.page,
border: `1px solid ${action.urgency === 'high' ? DS_SURFACE.orange.border : DS_BORDER.default}`,
borderRadius: 1.5, px: 1.5, py: 1.25, mb: 2.5,
}}>
<Zap size={14} color={action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.muted} style={{ flexShrink: 0, marginTop: 2 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? '#9a3412' : '#475569', mb: 0.25 }}>Empfohlene Aktion</Typography>
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? '#7c2d12' : '#334155', lineHeight: 1.5 }}>{action.label}</Typography>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: action.urgency === 'high' ? DS_TEXT.warningDark : DS_TEXT.secondary, mb: 0.25 }}>Empfohlene Aktion</Typography>
<Typography sx={{ fontSize: '0.82rem', color: action.urgency === 'high' ? DS_TEXT.error : DS_TEXT.primary, lineHeight: 1.5 }}>{action.label}</Typography>
</Box>
</Box>
)}
<Divider sx={{ mb: 2 }} />
{/* ── 4. Signal-Kenndaten ──────────────────────────────────────────── */}
{/* ── 4. Signal-Kenndaten ── */}
<Box sx={{ mb: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.25, fontSize: '0.8125rem' }}>Signal-Kenndaten</Typography>
{/* Probability bar */}
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.4 }}>
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit</Typography>
@@ -157,7 +124,7 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
variant="determinate"
value={probPct}
sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
height: 6, borderRadius: 3, bgcolor: DS_BG.subtle,
'& .MuiLinearProgress-bar': { bgcolor: probBarColor, borderRadius: 3 },
}}
/>
@@ -167,13 +134,13 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
{signal.signalType && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Signaltyp</Typography>
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: '#ede9fe', color: '#6d28d9', fontWeight: 600 }} />
<Chip label={SIGNAL_TYPE_LABELS[signal.signalType] ?? signal.signalType} size="small" sx={{ bgcolor: DS_PRE_MARKET.badgeBg, color: DS_PRE_MARKET.accentHover, fontWeight: 600 }} />
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>Zeithorizont</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={13} color="#64748b" />
<Calendar size={13} color={DS_TEXT.muted} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
</Box>
</Box>
@@ -194,22 +161,22 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Divider sx={{ mb: 2 }} />
{/* ── 5. Erkannte Marktindikatoren ──────────────────────────────────── */}
{/* ── 5. Erkannte Marktindikatoren ── */}
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
<Box sx={{ mb: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Erkannte Marktindikatoren</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{signal.marketIndicators.map((indicator, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.875 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: '#1d4ed8', mt: '6px', flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: '#334155', lineHeight: 1.55 }}>{indicator}</Typography>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: DS_MARKET_SIGNAL.accent, mt: '6px', flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>{indicator}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* ── 6. Transparenz (Bestätigt / Nicht bestätigt) ─────────────────── */}
{/* ── 6. Transparenz ── */}
{((signal.confirmedFacts && signal.confirmedFacts.length > 0) ||
(signal.unconfirmedFacts && signal.unconfirmedFacts.length > 0)) && (
<Box sx={{ mb: 2.5 }}>
@@ -217,14 +184,14 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{(signal.confirmedFacts ?? []).map((fact, i) => (
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={14} color="#1a7a4a" style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
<CheckCircle2 size={14} color={DS_TEXT.success} style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.success, fontWeight: 500, lineHeight: 1.45 }}>{fact}</Typography>
</Box>
))}
{(signal.unconfirmedFacts ?? []).map((fact, i) => (
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<AlertTriangle size={14} color="#d97706" style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: '#92400e', lineHeight: 1.45 }}>{fact}</Typography>
<AlertTriangle size={14} color={DS_TEXT.warning} style={{ flexShrink: 0, marginTop: 1 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.warningDark, lineHeight: 1.45 }}>{fact}</Typography>
</Box>
))}
</Box>
@@ -233,103 +200,16 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
<Divider sx={{ mb: 2 }} />
{/* ── 7. Quellen & Belege ───────────────────────────────────────────── */}
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<Globe size={14} color="#0369a1" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Quellen &amp; Belege</Typography>
</Box>
{/* ── 7. Quellen & Belege ── */}
<SignalSourcesSection
sourceMeta={sourceMeta}
credMeta={credMeta}
isVerifiedContract={isVerifiedContract}
allSourceUrls={allSourceUrls}
signal={signal}
/>
{/* Primary source metadata — always shown */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.25 }}>
{sourceMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
{sourceMeta.icon}
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
</Box>
)}
{credMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: credMeta.color }} />
<Typography sx={{ fontSize: '0.75rem', color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
</Box>
)}
{signal.source.publishedAt && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
<Calendar size={11} color="#64748b" />
<Typography variant="caption" color="text.secondary">
{new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
</Typography>
</Box>
)}
</Box>
{/* Verified contract special box */}
{isVerifiedContract && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<ShieldCheck size={13} color="#1a7a4a" />
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Vertragsende aus internem ERP bestätigt</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<ShieldCheck size={13} color="#1a7a4a" />
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Verwaltung hat Marktfreigabe erteilt</Typography>
</Box>
</Box>
)}
{/* All source URLs */}
{allSourceUrls.length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{allSourceUrls.map((url, i) => (
<Button
key={i}
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="small"
variant="outlined"
startIcon={<ExternalLink size={12} />}
sx={{
textTransform: 'none', justifyContent: 'flex-start',
color: '#0369a1', borderColor: '#bae6fd',
'&:hover': { borderColor: '#0369a1', bgcolor: '#f0f9ff' },
fontSize: '0.78rem', fontWeight: 500,
}}
>
{domainLabel(url)}
</Button>
))}
</Box>
) : (
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 1.25, py: 0.875 }}>
<Typography variant="caption" color="text.secondary">
Kein direkter Quellenlink verfügbar Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}.
</Typography>
</Box>
)}
{/* Evidence extraction date */}
{signal.evidence?.extractedAt && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.875 }}>
<Clock size={11} color="#94a3b8" />
<Typography variant="caption" color="text.secondary">
Daten extrahiert: {new Date(signal.evidence.extractedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'short', year: 'numeric' })}
</Typography>
</Box>
)}
{/* Evidence summary */}
{signal.evidence?.summary && (
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.25, mt: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.3 }}>Evidenz-Zusammenfassung</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.55 }}>{signal.evidence.summary}</Typography>
</Box>
)}
</Box>
{/* ── Footer ──────────────────────────────────────────────────────────── */}
{/* ── Footer ── */}
<Divider sx={{ mb: 1.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25, flexWrap: 'wrap' }}>
@@ -338,12 +218,12 @@ export function FutureAvailabilityContextPanel({ match: _match, signal }: Props)
{signal.verifiedBy && (
<>
<Typography variant="caption" color="text.secondary">Verifiziert von:</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>{signal.verifiedBy}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success }}>{signal.verifiedBy}</Typography>
</>
)}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: '#f8fafc', borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.55, bgcolor: DS_BG.page, borderRadius: 1, px: 1.25, py: 1, fontStyle: 'italic' }}>
{signal.disclaimer}
</Typography>
</Paper>
@@ -0,0 +1,92 @@
import { useState, useEffect } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
TextField, Typography,
} from '@mui/material'
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : ''
return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse`
}
export function InquiryQuickDialog() {
const showToast = useToastStore(s => s.showToast)
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
useEffect(() => {
if (dialogOpen && pendingInquiry) {
setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel))
}
}, [dialogOpen, pendingInquiry])
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
closeInquiryDialog()
}
if (!pendingInquiry) return null
return (
<Dialog open={dialogOpen} onClose={closeInquiryDialog} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700, pb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Send size={16} />
Anfrage senden
</DialogTitle>
<DialogContent sx={{ pt: 0 }}>
<Box sx={{ bgcolor: DS_BG.subtle, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, p: 1.5, mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{pendingInquiry.propertyTitle}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{pendingInquiry.location}
{pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`}
{pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`}
{' · '}Match {pendingInquiry.matchScore}%
</Typography>
</Box>
<TextField
fullWidth
multiline
rows={8}
value={message}
onChange={e => setMessage(e.target.value)}
sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }}
/>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1 }}>
Der Text kann vor dem Absenden angepasst werden.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button onClick={closeInquiryDialog} size="small" sx={{ color: DS_TEXT.muted }}>Abbrechen</Button>
<Button
onClick={handleSend}
variant="contained"
size="small"
disabled={!message.trim()}
startIcon={<Send size={14} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
</DialogActions>
</Dialog>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { Box, Typography } from '@mui/material'
export function KpiTile({
label,
value,
sub,
color,
}: {
label: string
value: string
sub?: string
color?: string
}) {
return (
<Box
sx={{
flex: 1,
p: 1.5,
bgcolor: '#f8fafc',
borderRadius: 1.5,
border: '1px solid #e2e8f0',
minWidth: 100,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
{label}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: color ?? '#0f1923', lineHeight: 1.2 }}>
{value}
</Typography>
{sub && (
<Typography variant="caption" color="text.secondary">
{sub}
</Typography>
)}
</Box>
)
}
@@ -7,66 +7,10 @@ import { useNavigate } from 'react-router'
import { useProperties } from '../../hooks/useProperties'
import { getCityIntelligence } from '../../lib/locationIntelligence'
import type { Property } from '../../domain/property'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { SoftFactorBar } from './SoftFactorBar'
// ── Sub-components ────────────────────────────────────────────────────────────
function KpiTile({
label,
value,
sub,
color,
}: {
label: string
value: string
sub?: string
color?: string
}) {
return (
<Box
sx={{
flex: 1,
p: 1.5,
bgcolor: '#f8fafc',
borderRadius: 1.5,
border: '1px solid #e2e8f0',
minWidth: 100,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.25 }}>
{label}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: color ?? '#0f1923', lineHeight: 1.2 }}>
{value}
</Typography>
{sub && (
<Typography variant="caption" color="text.secondary">
{sub}
</Typography>
)}
</Box>
)
}
const NEW_PROJECTS: Record<string, { title: string; area: string; completion: string; note: string }[]> = {
'Zürich': [
{ title: 'Ensemble Zürich-West', area: '5002000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
{ title: 'The Circle Phase II', area: '10005000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
],
'Basel': [
{ title: 'Basel SBB Tower', area: '3001500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
{ title: 'Erlenmatt Ost', area: '6002500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
],
'Zug': [
{ title: 'Zug Innovation Campus', area: '2001000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
],
'Bern': [
{ title: 'Bern West Business Park', area: '4003000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
],
'Winterthur': [
{ title: 'Sulzerareal Phase 4', area: '8004000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
],
}
import { KpiTile } from './KpiTile'
import { NEW_PROJECTS } from './locationIntelligenceConstants'
// ── Main component ────────────────────────────────────────────────────────────
@@ -120,31 +64,31 @@ export function LocationIntelligencePanel({ property }: Props) {
label="Leerstandsquote"
value={`${intel.vacancyRatePct}%`}
sub={intel.vacancyRatePct < 3 ? 'Angespannter Markt' : intel.vacancyRatePct < 5 ? 'Ausgeglichen' : 'Käufermarkt'}
color={intel.vacancyRatePct < 3 ? '#c0392b' : intel.vacancyRatePct < 5 ? '#d97706' : '#1a7a4a'}
color={intel.vacancyRatePct < 3 ? DS_TEXT.error : intel.vacancyRatePct < 5 ? DS_TEXT.warning : DS_TEXT.success}
/>
<KpiTile
label="Mietpreis-Trend"
value={`${rentTrendPositive ? '+' : ''}${intel.rentTrend12m}%`}
sub="letzte 12 Monate"
color={rentTrendPositive ? '#c0392b' : '#1a7a4a'}
color={rentTrendPositive ? DS_TEXT.error : DS_TEXT.success}
/>
<KpiTile
label="Kaufkraft-Index"
value={`${intel.purchasingPowerIndex}`}
sub="CH-Mittel = 100"
color={intel.purchasingPowerIndex >= 115 ? '#1a7a4a' : intel.purchasingPowerIndex >= 95 ? '#d97706' : '#c0392b'}
color={intel.purchasingPowerIndex >= 115 ? DS_TEXT.success : intel.purchasingPowerIndex >= 95 ? DS_TEXT.warning : DS_TEXT.error}
/>
<KpiTile
label="Ø Vermietungsdauer"
value={`${intel.avgDaysOnMarket}T`}
sub="Tage auf dem Markt"
color={intel.avgDaysOnMarket < 40 ? '#c0392b' : intel.avgDaysOnMarket < 65 ? '#d97706' : '#1a7a4a'}
color={intel.avgDaysOnMarket < 40 ? DS_TEXT.error : intel.avgDaysOnMarket < 65 ? DS_TEXT.warning : DS_TEXT.success}
/>
</Box>
{/* Rent trend interpretation */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : '#f0fdf4', borderRadius: 1.5, mb: 2 }}>
<Box sx={{ color: rentTrendPositive ? '#d97706' : '#1a7a4a', mt: 0.1 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.5, bgcolor: rentTrendPositive ? '#fff8f0' : DS_SURFACE.success.bg, borderRadius: 1.5, mb: 2 }}>
<Box sx={{ color: rentTrendPositive ? DS_TEXT.warning : DS_TEXT.success, mt: 0.1 }}>
{rentTrendPositive ? <TrendingUp size={16} /> : <TrendingDown size={16} />}
</Box>
<Typography variant="body2" sx={{ color: rentTrendPositive ? '#92400e' : '#14532d' }}>
@@ -156,29 +100,29 @@ export function LocationIntelligencePanel({ property }: Props) {
{/* Tax + demand */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_SURFACE.neutral.bg, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Percent size={13} color="#64748b" />
<Percent size={13} color={DS_TEXT.muted} />
<Typography variant="caption" color="text.secondary">Steuerindex Kanton</Typography>
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? '#1a7a4a' : intel.taxIndexCanton <= 105 ? '#d97706' : '#c0392b' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: intel.taxIndexCanton <= 80 ? DS_TEXT.success : intel.taxIndexCanton <= 105 ? DS_TEXT.warning : DS_TEXT.error }}>
{intel.taxIndexCanton} <Typography component="span" variant="caption" color="text.secondary">(CH = 100)</Typography>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{intel.taxIndexCanton <= 75 ? 'Sehr steuerattraktiv' : intel.taxIndexCanton <= 95 ? 'Günstige Steuerlast' : intel.taxIndexCanton <= 110 ? 'Durchschnittlich' : 'Hohe Steuerlast'}
</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 140 }}>
<Box sx={{ flex: 1, p: 1.5, bgcolor: DS_SURFACE.neutral.bg, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, minWidth: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Activity size={13} color="#64748b" />
<Activity size={13} color={DS_TEXT.muted} />
<Typography variant="caption" color="text.secondary">Nachfragestärke</Typography>
</Box>
<Chip
label={{ LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }[intel.demandStrength]}
size="small"
sx={{
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: '#f0fdf4' }[intel.demandStrength],
color: { LOW: '#64748b', MEDIUM: '#d97706', HIGH: '#16a34a', VERY_HIGH: '#1a7a4a' }[intel.demandStrength],
bgcolor: { LOW: '#f1f5f9', MEDIUM: '#fef3c7', HIGH: '#dcfce7', VERY_HIGH: DS_SURFACE.success.bg }[intel.demandStrength],
color: { LOW: DS_TEXT.muted, MEDIUM: DS_TEXT.warning, HIGH: '#16a34a', VERY_HIGH: DS_TEXT.success }[intel.demandStrength],
fontWeight: 600, fontSize: 11,
}}
/>
@@ -190,12 +134,12 @@ export function LocationIntelligencePanel({ property }: Props) {
{/* Industry clusters */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 0.75 }}>
Dominante Branchen-Cluster
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{intel.dominantIndustryClusters.map(c => (
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 22 }} />
<Chip key={c} label={c} size="small" sx={{ bgcolor: '#eff6ff', color: DS_TEXT.brand, fontSize: 11, height: 22 }} />
))}
</Box>
</Box>
@@ -204,8 +148,8 @@ export function LocationIntelligencePanel({ property }: Props) {
{intel.plannedInfrastructure.length > 0 && (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<Train size={13} color="#64748b" />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
<Train size={13} color={DS_TEXT.muted} />
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
Geplante Infrastruktur-Projekte
</Typography>
</Box>
@@ -230,7 +174,7 @@ export function LocationIntelligencePanel({ property }: Props) {
{/* ── Soft Factors ── */}
{hasSoftFactors && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 1 }}>
KI-berechnete Standortqualität
</Typography>
<SoftFactorBar
@@ -278,7 +222,7 @@ export function LocationIntelligencePanel({ property }: Props) {
{comparables.length > 0 && (
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, display: 'block', mb: 0.75 }}>
Vergleichbare Angebote in {city}
</Typography>
{comparables.map(p => (
@@ -288,7 +232,7 @@ export function LocationIntelligencePanel({ property }: Props) {
sx={{
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.75,
borderBottom: '1px solid #f1f5f9', cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc', borderRadius: 0.5 },
'&:hover': { bgcolor: DS_SURFACE.neutral.bg, borderRadius: 0.5 },
}}
>
<Building2 size={13} color="#3b82f6" />
@@ -297,7 +241,7 @@ export function LocationIntelligencePanel({ property }: Props) {
{p.title}
</Typography>
<Typography variant="caption" color="text.secondary">
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²
{p.areaSqm} m² · CHF {p.rentPricePerSqm}/m²/Jahr
{p.rentPricePerSqm < property.rentPricePerSqm && ' · günstiger'}
{p.rentPricePerSqm > property.rentPricePerSqm && ' · teurer'}
</Typography>
@@ -7,7 +7,6 @@ import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
EXTERNAL_MARKET: { label: 'Direktinserat', color: '#d97706' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
@@ -1,8 +1,11 @@
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
import { useMatchDetail } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -17,8 +20,11 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
const flexibleUnits = units.filter(u => u.isFlexible && u.minLettableSqm != null)
const preMarketUnits = units.filter(u => u.schattenmarktRelease?.enabled)
const otherUnits = units.filter(u => !u.schattenmarktRelease?.enabled)
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
const rawMonthlyPerSqm = property.rentPricePerSqm / 12
const monthlyPerSqmLabel = Number.isInteger(rawMonthlyPerSqm)
? `CHF ${rawMonthlyPerSqm}.`
: `CHF ${rawMonthlyPerSqm.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined)
const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType
@@ -27,11 +33,11 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Preis */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" />
<Tag size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Monat" value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Monat" value={monthlyPerSqmLabel} />
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.`} />
{property.ancillaryCosts != null && (
<KeyFactRow label="Nebenkosten" value={`CHF ${Math.round(property.areaSqm * property.ancillaryCosts / 12).toLocaleString('de-CH')}/Mt. (CHF ${property.ancillaryCosts}/m²/a)`} />
@@ -41,7 +47,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Hauptangaben */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box>
<KeyFactRow label="Verfügbarkeit" value={property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' }) : 'Auf Anfrage'} />
@@ -63,27 +69,27 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.softFactors && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" />
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{property.softFactors.publicTransportMinutes != null && (
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }} />
<Chip size="small" icon={<Train size={11} />} label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, fontWeight: 500 }} />
)}
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }} />
<Chip size="small" label={`${property.softFactors.parkingSpots} Parkplätze`} sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }} />
)}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
<Chip size="small" label="Prestigestandort" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
)}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }} />
<Chip size="small" label="Hohe Sichtbarkeit" sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }} />
)}
{property.softFactors.passerbyFrequency && (
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }} />
<Chip size="small" label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`} sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }} />
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }} />
<Chip size="small" label={`Talentindex: ${property.softFactors.talentAccess}`} sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }} />
)}
</Box>
</Paper>
@@ -93,12 +99,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" />
<Clock size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
<Train size={18} color={DS_MARKET_SIGNAL.accent} />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{property.softFactors.publicTransportMinutes} Min. zu Fuss</Typography>
@@ -115,7 +121,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{units.length > 0 && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Layers size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
@@ -128,14 +134,32 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
</Paper>
)}
{/* Grundriss */}
{(() => {
const plans: Array<{ url: string; label?: string }> = []
if (matchedUnit?.floorPlanUrl) {
const lbl = matchedUnit.unitLabel
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
: FLOOR_LABEL(matchedUnit.floorLevel)
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
} else {
units.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
}
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} />
})()}
{/* Beschreibung */}
{property.description && (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" />
<Building2 size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
{property.description}
</Typography>
</Paper>
@@ -144,7 +168,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{/* Quelle & Referenz */}
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
@@ -153,9 +177,9 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
{property.dataQuality.lastVerifiedAt && (
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
)}
{property.sourceUrl && (
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}>
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
Zum Originalinserat
</Button>
</Box>
@@ -0,0 +1,92 @@
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'
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)
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}
/>
</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} />
</>
)}
</>
)
})
@@ -56,16 +56,22 @@ function buildRows(need: Need, property: Property): AlignmentRow[] {
fit: locationFit,
})
// Budget
// Budget — both values are annual CHF/m²; display as monthly for consistency with Preis section
const budgetFit: FitStatus = property.rentPricePerSqm <= need.budgetRange.maxPerSqm
? 'MATCH'
: property.rentPricePerSqm <= need.budgetRange.maxPerSqm * 1.1
? 'PARTIAL'
: 'NO_MATCH'
function fmtMt(annual: number): string {
const m = annual / 12
return Number.isInteger(m)
? `CHF ${m}./m²/Mt.`
: `CHF ${m.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/m²/Mt.`
}
rows.push({
label: 'Budget',
needValue: `max. CHF ${need.budgetRange.maxPerSqm}/m²`,
resultValue: `CHF ${property.rentPricePerSqm}/m²`,
needValue: `max. ${fmtMt(need.budgetRange.maxPerSqm)}`,
resultValue: fmtMt(property.rentPricePerSqm),
fit: budgetFit,
})
@@ -1,23 +1,17 @@
import { Box, Button, Paper, Typography } from '@mui/material'
import { MessageSquare } from 'lucide-react'
import type { Match, NextBestAction } from '../../domain/match'
const PRIORITY_ORDER: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 }
const PRIORITY_COLOR: Record<string, 'contained' | 'outlined'> = {
HIGH: 'contained',
MEDIUM: 'outlined',
LOW: 'outlined',
}
interface Props {
match: Match
onCompare?: () => void
onShortlist?: () => void
onReject?: () => void
onReview?: () => void
onPipeline?: () => void
onInquire?: () => void
}
export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onReview }: Props) {
export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Props) {
const engineActions: NextBestAction[] = [...(match.nextBestActions ?? [])].sort(
(a, b) => (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2)
)
@@ -26,37 +20,41 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
<Paper sx={{ p: 2.5, mb: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1.5 }}>Empfohlene Aktionen</Typography>
{engineActions.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
{engineActions.map((action, i) => (
<Box key={i}>
<Button
fullWidth
size="small"
variant={PRIORITY_COLOR[action.priority]}
sx={
action.priority === 'HIGH'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, justifyContent: 'flex-start' }
: { justifyContent: 'flex-start' }
}
>
{action.label}
</Button>
{action.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{action.description}
</Typography>
)}
</Box>
))}
</Box>
)}
{/* Standard actions */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
{onShortlist && (
<Button fullWidth size="small" variant="outlined" onClick={onShortlist} sx={{ justifyContent: 'flex-start' }}>
Zur Shortlist hinzufügen
{onInquire && (
<Button
fullWidth
size="small"
variant="contained"
startIcon={<MessageSquare size={14} />}
onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
)}
{engineActions.length > 0 && engineActions.map((action, i) => (
<Box key={i}>
<Button
fullWidth
size="small"
variant="outlined"
sx={{ justifyContent: 'flex-start' }}
>
{action.label}
</Button>
{action.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, px: 1 }}>
{action.description}
</Typography>
)}
</Box>
))}
{onPipeline && (
<Button fullWidth size="small" variant="outlined" onClick={onPipeline} sx={{ justifyContent: 'flex-start' }}>
Zur Pipeline hinzufügen
</Button>
)}
{onCompare && (
@@ -64,16 +62,6 @@ export function NextActionsPanel({ match, onCompare, onShortlist, onReject, onRe
Zum Vergleich hinzufügen
</Button>
)}
{onReview && (
<Button fullWidth size="small" variant="outlined" onClick={onReview} sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}>
Zur Überprüfung senden
</Button>
)}
{onReject && (
<Button fullWidth size="small" variant="outlined" onClick={onReject} sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}>
Match ablehnen
</Button>
)}
</Box>
</Paper>
)
@@ -11,11 +11,11 @@ import {
ExternalLink,
Info,
Layers,
ShieldCheck,
Tag,
Train,
TrendingUp,
} from 'lucide-react'
import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property'
import {
ASSET_LABELS,
@@ -26,6 +26,8 @@ import {
SOURCE_LABELS,
UnitRow,
} from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
interface PropertyDetailPublicSectionsProps {
property: Property
@@ -37,8 +39,11 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled)
const flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined)
const monthlyPerSqm = Math.round(property.rentPricePerSqm / 12)
const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12)
const rawMonthlyPerSqm = property.rentPricePerSqm / 12
const monthlyPerSqmLabel = Number.isInteger(rawMonthlyPerSqm)
? `CHF ${rawMonthlyPerSqm}.`
: `CHF ${rawMonthlyPerSqm.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const minLettable = property.areaSqmMin
?? (flexibleUnits.length > 0
? Math.min(...flexibleUnits.map(u => u.minLettableSqm!))
@@ -54,11 +59,11 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Preis ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Tag size={15} color="#374151" />
<Tag size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Preis</Typography>
</Box>
<KeyFactRow label="Monatliche Miete" value={`CHF ${totalMonthly.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Monat" value={`CHF ${monthlyPerSqm.toLocaleString('de-CH')}.`} />
<KeyFactRow label="Pro m²/Monat" value={monthlyPerSqmLabel} />
<KeyFactRow label="Pro m²/Jahr" value={`CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}.`} />
{property.ancillaryCosts != null && (
<KeyFactRow
@@ -71,7 +76,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Hauptangaben ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Hauptangaben</Typography>
</Box>
<KeyFactRow
@@ -124,7 +129,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.softFactors && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color="#374151" />
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Eigenschaften</Typography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
@@ -133,49 +138,49 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
size="small"
icon={<Train size={11} />}
label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`}
sx={{ bgcolor: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe', fontWeight: 500 }}
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, fontWeight: 500 }}
/>
)}
{property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && (
<Chip
size="small"
label={`${property.softFactors.parkingSpots} Parkplätze`}
sx={{ bgcolor: '#f8fafc', color: '#374151', border: '1px solid #e2e8f0', fontWeight: 500 }}
sx={{ bgcolor: DS_BG.page, color: DS_TEXT.primary, border: `1px solid ${DS_BORDER.default}`, fontWeight: 500 }}
/>
)}
{property.softFactors.prestige != null && property.softFactors.prestige >= 80 && (
<Chip
size="small"
label="Prestigestandort"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
/>
)}
{property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && (
<Chip
size="small"
label="Hohe Sichtbarkeit"
sx={{ bgcolor: '#fef9c3', color: '#92400e', border: '1px solid #fde68a', fontWeight: 500 }}
sx={{ bgcolor: DS_SURFACE.warning.bg, color: DS_TEXT.warningDark, border: `1px solid ${DS_SURFACE.warning.border}`, fontWeight: 500 }}
/>
)}
{property.softFactors.passerbyFrequency && (
<Chip
size="small"
label={`Laufkundschaft: ${PASSERBY_LABELS[property.softFactors.passerbyFrequency]}`}
sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #bbf7d0', fontWeight: 500 }}
sx={{ bgcolor: DS_SURFACE.success.bg, color: DS_TEXT.success, border: `1px solid ${DS_SURFACE.success.border}`, fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label="Hoher Talentzugang"
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
/>
)}
{property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && (
<Chip
size="small"
label={`Talentindex: ${property.softFactors.talentAccess}`}
sx={{ bgcolor: '#faf5ff', color: '#5b21b6', border: '1px solid #e9d5ff', fontWeight: 500 }}
sx={{ bgcolor: DS_SURFACE.purple.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.purple.border}`, fontWeight: 500 }}
/>
)}
</Box>
@@ -186,12 +191,12 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{property.softFactors?.publicTransportMinutes != null && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Clock size={15} color="#374151" />
<Clock size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Wegzeit</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: '#eff6ff', border: '1px solid #bfdbfe', flexShrink: 0 }}>
<Train size={18} color="#1d4ed8" />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: '50%', bgcolor: DS_SURFACE.blue.bg, border: `1px solid ${DS_SURFACE.blue.border}`, flexShrink: 0 }}>
<Train size={18} color={DS_MARKET_SIGNAL.accent} />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
@@ -217,7 +222,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{(property.units ?? []).length > 0 && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Layers size={15} color="#374151" />
<Layers size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Einheiten</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 80px 110px 120px auto', gap: 1.5, px: 2, mb: 0.75 }}>
@@ -234,14 +239,26 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
</Paper>
)}
{/* ── Grundriss ── */}
{(() => {
const allUnits = property.units ?? []
const plans: Array<{ url: string; label?: string }> = []
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
})
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
return <FloorPlanSection plans={plans} mb={2} />
})()}
{/* ── Beschreibung ── */}
{property.description && (
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Building2 size={15} color="#374151" />
<Building2 size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Beschreibung</Typography>
</Box>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: '#374151', whiteSpace: 'pre-wrap' }}>
<Typography variant="body2" sx={{ lineHeight: 1.75, color: DS_TEXT.primary, whiteSpace: 'pre-wrap' }}>
{property.description}
</Typography>
</Paper>
@@ -250,7 +267,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
{/* ── Quelle & Referenz ── */}
<Paper sx={{ mb: 2, p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Info size={15} color="#374151" />
<Info size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Quelle & Referenz</Typography>
</Box>
<KeyFactRow label="Datenquelle" value={sourceLabel} />
@@ -266,7 +283,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
/>
)}
{property.sourceUrl && (
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
<Box sx={{ mt: 1.25 }}>
<Button
size="small"
@@ -275,7 +292,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
href={property.sourceUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#cbd5e1', color: '#374151' }}
sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}
>
Zum Originalinserat
</Button>
@@ -39,7 +39,7 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
] : [
{ icon: <MapPin size={15} />, label: 'Standort', value: property ? `${property.location.city}${property.location.district ? `, ${property.location.district}` : ''}` : '' },
{ icon: <Maximize2 size={15} />, label: 'Nutzfläche', value: property ? `${property.areaSqm}` : '' },
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²` : '' },
{ 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: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '' },
]
@@ -5,16 +5,9 @@ import type { Match } from '../../domain/match'
import type { FutureSignal } from '../../domain/futureSignal'
import { CREDIBILITY_LABELS, HARD_KEYS, factorLabel } from './scoreBreakdownConstants'
import { CriterionRow } from './CriterionRow'
// ── Shared helpers ─────────────────────────────────────────────────────────────
function scoreColor(v: number): 'success' | 'warning' | 'error' {
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
}
function scoreTextColor(v: number): string {
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
}
import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils'
import { DS_COLORS, DS_TEXT, DS_SURFACE } from '../../lib/ds'
import { SCORE_STRONG, SCORE_MODERATE } from '../../lib/constants'
// ── Standard breakdown (VERIFIED_PORTFOLIO / EXTERNAL / MAISON) ──────────────
@@ -47,28 +40,28 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
return (
<>
{/* Hard criteria */}
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'primary.main', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
{hasAllFactors ? 'Hart-Kriterien' : 'Bewertungsfaktoren'}
</Typography>
{hardFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxHardWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: hasAllFactors && softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.indigo.bg, px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: hasAllFactors && softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'primary.main' }}>
Hart-Kriterien {sb.hardMatchScore}/100 × 60%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>
<Typography variant="caption" sx={{ fontWeight: 800, color: 'primary.main' }}>
{hardContrib} Pkt
</Typography>
</Box>
{/* Soft factors: full list when allFactors present, otherwise summary row */}
{!hasAllFactors && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
Soft-Faktoren {sb.softFactorScore}/100 × 40%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.secondary }}>
{softContrib} Pkt
</Typography>
</Box>
@@ -77,15 +70,15 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
{hasAllFactors && softFactors.length > 0 && (
<>
<Divider sx={{ mb: 2 }} />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 1.25 }}>
Soft-Faktoren
</Typography>
{softFactors.map((f, i) => <CriterionRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mt: 0.25, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>
Soft-Score {sb.softFactorScore}/100 × 40%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.secondary }}>
{softContrib} Pkt
</Typography>
</Box>
@@ -94,10 +87,10 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
{/* Tax link */}
{taxCalculatorUrl && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: '#f0f9ff', borderRadius: 1, border: '1px solid #bae6fd' }}>
<ExternalLink size={13} color="#0369a1" style={{ flexShrink: 0 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, p: 1.25, bgcolor: DS_SURFACE.info.bg, borderRadius: 1, border: `1px solid ${DS_SURFACE.info.border}` }}>
<ExternalLink size={13} color={DS_TEXT.info} style={{ flexShrink: 0 }} />
<Box>
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.info, fontWeight: 600, display: 'block', lineHeight: 1.2 }}>
Steuerlast in Bewertung eingeflossen
</Typography>
<Link
@@ -105,7 +98,7 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{ fontSize: '0.72rem', color: '#0369a1' }}
sx={{ fontSize: '0.72rem', color: DS_TEXT.info }}
>
Steuerrechner Gemeinde öffnen
</Link>
@@ -116,11 +109,11 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
<Divider sx={{ mb: 1.5 }} />
{/* Formula row — always visible */}
<Box sx={{ bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mb: 1.25 }}>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.4, fontWeight: 500 }}>
<Box sx={{ bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mb: 1.25 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 0.4, fontWeight: 500 }}>
Berechnung
</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.6 }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: DS_TEXT.primary, display: 'block', lineHeight: 1.6 }}>
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
{' = '}{hardContrib} + {softContrib} = {baseSum}
</Typography>
@@ -129,13 +122,13 @@ function StandardBreakdown({ match, taxCalculatorUrl }: StandardBreakdownProps)
{/* Total */}
<Box sx={{
display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
bgcolor: baseSum >= 78 ? '#f0fdf4' : baseSum >= 52 ? '#fffbeb' : '#fef2f2',
bgcolor: baseSum >= SCORE_STRONG ? DS_SURFACE.success.bg : baseSum >= SCORE_MODERATE ? DS_SURFACE.warning.bg : DS_SURFACE.error.bg,
p: 1.5, borderRadius: 1,
}}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
<Typography
variant="h4"
sx={{ fontWeight: 800, color: scoreTextColor(Math.round(baseSum)) }}
sx={{ fontWeight: 800, color: criterionScoreTextColor(Math.round(baseSum)) }}
>
{baseSum}/100
</Typography>
@@ -170,7 +163,7 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
<>
{/* Schritt 1 — Kriterien-Match */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.futureCard.controlled.accent, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
Schritt 1 Kriterien-Match
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.25, lineHeight: 1.4 }}>
@@ -183,18 +176,18 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
<Box key={i} sx={{ mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6, mb: 0.4 }}>
{isPositive
? <CheckCircle2 size={12} color="#1a7a4a" style={{ flexShrink: 0 }} />
: <X size={12} color="#c0392b" style={{ flexShrink: 0 }} />
? <CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
: <X size={12} color={DS_TEXT.error} style={{ flexShrink: 0 }} />
}
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b' }}>{factorLabel(f.criterion)}</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(f.score), ml: 1 }}>{f.score}/100</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{factorLabel(f.criterion)}</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: criterionScoreTextColor(f.score), ml: 1 }}>{f.score}/100</Typography>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={f.score}
color={scoreColor(f.score)}
color={criterionScoreColor(f.score)}
sx={{ height: 5, borderRadius: 3, mb: 0.35, ml: 2.25 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', ml: 2.25, lineHeight: 1.3 }}>
@@ -204,9 +197,9 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
)
})}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.75, borderRadius: 1, mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>Basis-Score</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e293b' }}>{sb.hardMatchScore}/100</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, px: 1.25, py: 0.75, borderRadius: 1, mt: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary }}>Basis-Score</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.primary }}>{sb.hardMatchScore}/100</Typography>
</Box>
</Box>
@@ -214,27 +207,27 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
{/* Schritt 2 — Verlässlichkeit */}
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#6d28d9', textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.futureCard.controlled.accent, textTransform: 'uppercase', letterSpacing: 0.6, display: 'block', mb: 0.75 }}>
Schritt 2 Verlässlichkeit des Signals
</Typography>
{isVerifiedContract ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<ShieldCheck size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#1a7a4a', lineHeight: 1.3 }}>
<ShieldCheck size={13} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.success, lineHeight: 1.3 }}>
Vertragsende aus ERP verifiziert keine Schätzung
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1 }}>
<ShieldCheck size={13} color="#1a7a4a" style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#1a7a4a', lineHeight: 1.3 }}>
<ShieldCheck size={13} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.success, lineHeight: 1.3 }}>
Verwaltung hat Freigabe erteilt
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f0fdf4', px: 1.25, py: 0.75, borderRadius: 1, border: '1px solid #bbf7d0' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1a7a4a' }}>Signal-Abschlag</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1a7a4a' }}>keiner</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.success.bg, px: 1.25, py: 0.75, borderRadius: 1, border: `1px solid ${DS_SURFACE.success.border}` }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success }}>Signal-Abschlag</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.success }}>keiner</Typography>
</Box>
</>
) : (
@@ -242,18 +235,18 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
{probPct !== null && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Eintretenswahrscheinlichkeit: {probPct}%</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#c0392b' }}>{Math.round(signalDeduction * 0.7)} Pkt.</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.error }}>{Math.round(signalDeduction * 0.7)} Pkt.</Typography>
</Box>
)}
{credLabel && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
<Typography variant="caption" color="text.secondary">Quellenqualität: {credLabel}</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706' }}>{Math.round(signalDeduction * 0.3)} Pkt.</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.warning }}>{Math.round(signalDeduction * 0.3)} Pkt.</Typography>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#fff7ed', px: 1.25, py: 0.75, borderRadius: 1, border: '1px solid #fed7aa' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#d97706' }}>Signal-Abschlag</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#d97706' }}>{signalDeduction} Punkte</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: DS_SURFACE.orange.bg, px: 1.25, py: 0.75, borderRadius: 1, border: `1px solid ${DS_SURFACE.orange.border}` }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.warning }}>Signal-Abschlag</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: DS_TEXT.warning }}>{signalDeduction} Punkte</Typography>
</Box>
</>
)}
@@ -262,11 +255,11 @@ function SignalScoreDerivation({ match, signal }: SignalScoreDerivationProps) {
<Divider sx={{ mb: 1.5 }} />
{/* Gesamt */}
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: '#f8fafc', p: 1.5, borderRadius: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', bgcolor: DS_SURFACE.neutral.bg, p: 1.5, borderRadius: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Gesamt-Score</Typography>
<Typography
variant="h4"
sx={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}
sx={{ fontWeight: 800, color: criterionScoreTextColor(sb.totalScore) }}
>
{sb.totalScore}/100
</Typography>
@@ -0,0 +1,117 @@
import { Box, Button, Typography } from '@mui/material'
import { Calendar, Clock, ExternalLink, Globe, ShieldCheck } from 'lucide-react'
import type { FutureSignal } from '../../domain/futureSignal'
import { domainLabel } from './futureAvailabilityContextUtils'
interface SignalSourcesSectionProps {
sourceMeta: { label: string; icon: React.ReactNode; badge?: string; description?: string } | null
credMeta: { label: string; color: string } | null
isVerifiedContract: boolean
allSourceUrls: string[]
signal: FutureSignal
}
export function SignalSourcesSection({
sourceMeta,
credMeta,
isVerifiedContract,
allSourceUrls,
signal,
}: SignalSourcesSectionProps) {
return (
<Box sx={{ mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
<Globe size={14} color="#0369a1" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Quellen &amp; Belege</Typography>
</Box>
{/* Primary source metadata — always shown */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.25 }}>
{sourceMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: isVerifiedContract ? '#f0fdf4' : '#ede9fe', color: isVerifiedContract ? '#1a7a4a' : '#6d28d9', borderRadius: 1, px: 1, py: 0.5 }}>
{sourceMeta.icon}
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600 }}>{sourceMeta.label}</Typography>
</Box>
)}
{credMeta && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: credMeta.color }} />
<Typography sx={{ fontSize: '0.75rem', color: credMeta.color, fontWeight: 600 }}>{credMeta.label}</Typography>
</Box>
)}
{signal.source.publishedAt && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 0.875, py: 0.4 }}>
<Calendar size={11} color="#64748b" />
<Typography variant="caption" color="text.secondary">
{new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })}
</Typography>
</Box>
)}
</Box>
{/* Verified contract special box */}
{isVerifiedContract && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, bgcolor: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 1, px: 1.25, py: 1, mb: 1.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<ShieldCheck size={13} color="#1a7a4a" />
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Vertragsende aus internem ERP bestätigt</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<ShieldCheck size={13} color="#1a7a4a" />
<Typography variant="caption" sx={{ color: '#1a7a4a', fontWeight: 600 }}>Verwaltung hat Marktfreigabe erteilt</Typography>
</Box>
</Box>
)}
{/* All source URLs */}
{allSourceUrls.length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
{allSourceUrls.map((url, i) => (
<Button
key={i}
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
size="small"
variant="outlined"
startIcon={<ExternalLink size={12} />}
sx={{
textTransform: 'none', justifyContent: 'flex-start',
color: '#0369a1', borderColor: '#bae6fd',
'&:hover': { borderColor: '#0369a1', bgcolor: '#f0f9ff' },
fontSize: '0.78rem', fontWeight: 500,
}}
>
{domainLabel(url)}
</Button>
))}
</Box>
) : (
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, px: 1.25, py: 0.875 }}>
<Typography variant="caption" color="text.secondary">
Kein direkter Quellenlink verfügbar Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}.
</Typography>
</Box>
)}
{/* Evidence extraction date */}
{signal.evidence?.extractedAt && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.875 }}>
<Clock size={11} color="#94a3b8" />
<Typography variant="caption" color="text.secondary">
Daten extrahiert: {new Date(signal.evidence.extractedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'short', year: 'numeric' })}
</Typography>
</Box>
)}
{/* Evidence summary */}
{signal.evidence?.summary && (
<Box sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1, p: 1.25, mt: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569', display: 'block', mb: 0.3 }}>Evidenz-Zusammenfassung</Typography>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.55 }}>{signal.evidence.summary}</Typography>
</Box>
)}
</Box>
)
}
@@ -0,0 +1,37 @@
export const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' },
MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' },
LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' },
}
export const SENSITIVITY_META: Record<string, { label: string; color: 'error' | 'warning' | 'default' }> = {
CONFIDENTIAL: { label: 'Vertraulich', color: 'error' },
INTERNAL: { label: 'Intern', color: 'warning' },
PUBLIC: { label: 'Öffentlich', color: 'default' },
}
export const RISK_META: Record<string, { label: string; color: string }> = {
LOW: { label: 'Niedrig', color: '#1a7a4a' },
MEDIUM: { label: 'Mittel', color: '#d97706' },
HIGH: { label: 'Hoch', color: '#c0392b' },
CRITICAL: { label: 'Kritisch', color: '#7f1d1d' },
}
export function ageLabel(isoDate: string): string {
const diffMs = Date.now() - new Date(isoDate).getTime()
const days = Math.floor(diffMs / 86400000)
if (days < 1) return 'Heute erkannt'
if (days < 7) return `Vor ${days} Tag${days === 1 ? '' : 'en'} erkannt`
if (days < 30) return `Vor ${Math.floor(days / 7)} Woche${Math.floor(days / 7) === 1 ? '' : 'n'} erkannt`
if (days < 365) return `Vor ${Math.floor(days / 30)} Monat${Math.floor(days / 30) === 1 ? '' : 'en'} erkannt`
return `Vor ${Math.floor(days / 365)} Jahr${Math.floor(days / 365) === 1 ? '' : 'en'} erkannt`
}
export function domainLabel(url: string): string {
try {
const u = new URL(url)
return u.hostname.replace(/^www\./, '')
} catch {
return url.length > 60 ? url.slice(0, 57) + '…' : url
}
}
+2
View File
@@ -1,5 +1,7 @@
export { LocationIntelligencePanel } from './LocationIntelligencePanel'
export { MatchDetailHeader } from './MatchDetailHeader'
export { MatchDetailHero } from './MatchDetailHero'
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
export { PropertyOverviewPanel } from './PropertyOverviewPanel'
export { NeedAlignmentPanel } from './NeedAlignmentPanel'
@@ -0,0 +1,19 @@
export const NEW_PROJECTS: Record<string, { title: string; area: string; completion: string; note: string }[]> = {
'Zürich': [
{ title: 'Ensemble Zürich-West', area: '5002000 m²', completion: 'Q3 2026', note: 'Büroflächen im Neubauprojekt, Kreis 5' },
{ title: 'The Circle Phase II', area: '10005000 m²', completion: 'Q1 2027', note: 'Premium-Büros, Flughafen Zürich' },
],
'Basel': [
{ title: 'Basel SBB Tower', area: '3001500 m²', completion: 'Q4 2026', note: 'Gemischte Nutzung, zentrale Lage' },
{ title: 'Erlenmatt Ost', area: '6002500 m²', completion: 'Q2 2027', note: 'Modernes Stadtentwicklungsareal' },
],
'Zug': [
{ title: 'Zug Innovation Campus', area: '2001000 m²', completion: 'Q1 2026', note: 'Steuerattraktiv, ÖV-optimal' },
],
'Bern': [
{ title: 'Bern West Business Park', area: '4003000 m²', completion: 'Q2 2026', note: 'Modernes Gewerbeareal Ausserholligen' },
],
'Winterthur': [
{ title: 'Sulzerareal Phase 4', area: '8004000 m²', completion: 'Q3 2027', note: 'Industrie-Loft-Flächen im Stadtentwicklungsgebiet' },
],
}
@@ -0,0 +1,45 @@
import { Box, Card, TextField, Typography } from '@mui/material'
interface Props {
street: string
onStreetChange: (v: string) => void
houseNumber: string
onHouseNumberChange: (v: string) => void
postalCode: string
onPostalCodeChange: (v: string) => void
city: string
onCityChange: (v: string) => void
}
export function AddressSection({
street, onStreetChange,
houseNumber, onHouseNumberChange,
postalCode, onPostalCodeChange,
city, onCityChange,
}: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Adresse</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '3fr 1fr', gap: 2, mb: 2 }}>
<TextField
label="Strasse" value={street}
onChange={e => onStreetChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="Nr." value={houseNumber}
onChange={e => onHouseNumberChange(e.target.value)} size="small" fullWidth
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 2 }}>
<TextField
label="PLZ" value={postalCode}
onChange={e => onPostalCodeChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="Ort" value={city}
onChange={e => onCityChange(e.target.value)} size="small" fullWidth
/>
</Box>
</Card>
)
}
@@ -0,0 +1,52 @@
import { Alert, Box, Button, Card, CircularProgress, TextField, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
interface Props {
text: string
onTextChange: (v: string) => void
onParse: () => void
parsing: boolean
applied: boolean
}
export function AiAssistCard({ text, onTextChange, onParse, parsing, applied }: Props) {
return (
<Card sx={{ p: 3, mb: 3, border: `1px solid ${DS_SURFACE.indigo.border}`, bgcolor: DS_SURFACE.indigo.bg }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Sparkles size={16} color={DS_TEXT.brand} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DS_TEXT.brand }}>
KI-Hilfe Formular automatisch ausfüllen
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
Beschreiben Sie die Fläche in eigenen Worten die KI füllt die Felder automatisch aus.
</Typography>
<TextField
multiline
rows={3}
fullWidth
size="small"
placeholder="z.B.: Bürofläche 450 m² im Zentrum Zürich, 3. OG, CHF 280/m²/Jahr, sehr gute ÖV-Anbindung, 4 Parkplätze, Vollausbau, verfügbar ab Juli 2025"
value={text}
onChange={e => onTextChange(e.target.value)}
sx={{ mb: 1.5, bgcolor: 'white' }}
/>
{applied && (
<Alert severity="success" sx={{ mb: 1.5, py: 0.5 }}>
Felder wurden automatisch ausgefüllt bitte überprüfen und ergänzen.
</Alert>
)}
<Button
variant="contained"
size="small"
startIcon={parsing ? <CircularProgress size={13} color="inherit" /> : <Sparkles size={13} />}
onClick={onParse}
disabled={!text.trim() || parsing}
sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_BORDER.strong }, textTransform: 'none' }}
>
{parsing ? 'Analysiert…' : 'KI analysieren'}
</Button>
</Card>
)
}
@@ -0,0 +1,63 @@
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
import { ASSET_TYPE_LABELS } from '../../pages/supply/newListingConstants'
interface Props {
assetType: string
onAssetTypeChange: (v: string) => void
areaSqm: string
onAreaSqmChange: (v: string) => void
rentPerSqm: string
onRentPerSqmChange: (v: string) => void
availableFrom: string
onAvailableFromChange: (v: string) => void
description: string
onDescriptionChange: (v: string) => void
}
export function AreaDetailsSection({
assetType, onAssetTypeChange,
areaSqm, onAreaSqmChange,
rentPerSqm, onRentPerSqmChange,
availableFrom, onAvailableFromChange,
description, onDescriptionChange,
}: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Flächendetails</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
select label="Flächentyp" value={assetType}
onChange={e => onAssetTypeChange(e.target.value)} size="small" fullWidth
>
{Object.entries(ASSET_TYPE_LABELS).map(([v, l]) => (
<MenuItem key={v} value={v}>{l}</MenuItem>
))}
</TextField>
<TextField
label="Fläche (m²)" value={areaSqm}
onChange={e => onAreaSqmChange(e.target.value)}
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/>
<TextField
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
onChange={e => onRentPerSqmChange(e.target.value)}
size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/>
<TextField
label="Verfügbar ab" value={availableFrom}
onChange={e => onAvailableFromChange(e.target.value)}
size="small" type="date"
slotProps={{ inputLabel: { shrink: true } }} fullWidth
/>
</Box>
<TextField
label="Beschreibung (optional)" value={description}
onChange={e => onDescriptionChange(e.target.value)}
size="small" multiline rows={3} fullWidth sx={{ mt: 2 }}
/>
</Card>
)
}
@@ -0,0 +1,33 @@
import { Box, Card, TextField, Typography } from '@mui/material'
interface Props {
name: string
onNameChange: (v: string) => void
email: string
onEmailChange: (v: string) => void
phone: string
onPhoneChange: (v: string) => void
}
export function ContactSection({ name, onNameChange, email, onEmailChange, phone, onPhoneChange }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Kontakt (optional)</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
label="Name" value={name}
onChange={e => onNameChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="Telefon" value={phone}
onChange={e => onPhoneChange(e.target.value)} size="small" fullWidth
/>
<TextField
label="E-Mail" value={email}
onChange={e => onEmailChange(e.target.value)}
size="small" type="email" fullWidth sx={{ gridColumn: '1 / -1' }}
/>
</Box>
</Card>
)
}
@@ -0,0 +1,28 @@
import { Box, Button, Typography } from '@mui/material'
import { CheckCircle } from 'lucide-react'
import { DS_TEXT } from '../../lib/ds'
interface Props {
onViewListings: () => void
onCreateAnother: () => void
}
export function CreatedScreen({ onViewListings, onCreateAnother }: Props) {
return (
<Box sx={{ maxWidth: 520, mx: 'auto', mt: 8, px: 3, textAlign: 'center' }}>
<CheckCircle size={48} color={DS_TEXT.success} style={{ marginBottom: 16 }} />
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>Inserat erstellt</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Das Inserat wurde veröffentlicht und ist für passende Suchanfragen sichtbar.
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button variant="outlined" onClick={onViewListings}>
Meine Inserate
</Button>
<Button variant="contained" sx={{ bgcolor: DS_TEXT.brand, '&:hover': { bgcolor: DS_TEXT.brandDark } }} onClick={onCreateAnother}>
Weiteres Inserat
</Button>
</Box>
</Box>
)
}
@@ -0,0 +1,33 @@
import { Box, Card, TextField, Typography } from '@mui/material'
interface Props {
floorPlanUrl: string
onFloorPlanUrlChange: (v: string) => void
}
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
URL zu einem Grundrissbild hilft Interessenten, sich die Fläche vorzustellen.
</Typography>
<TextField
label="Grundriss-URL"
value={floorPlanUrl}
onChange={e => onFloorPlanUrlChange(e.target.value)}
size="small"
fullWidth
placeholder="https://…"
/>
{floorPlanUrl && (
<Box
component="img"
src={floorPlanUrl}
alt="Grundriss Vorschau"
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
/>
)}
</Card>
)
}
@@ -0,0 +1,52 @@
import { Box, Button, Card, Chip, TextField, Typography } from '@mui/material'
import { ImagePlus } from 'lucide-react'
interface Props {
images: string[]
imageInput: string
onImageInputChange: (v: string) => void
onAdd: () => void
onRemove: (index: number) => void
}
export function ImageUrlSection({ images, imageInput, onImageInputChange, onAdd, onRemove }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Bilder (optional)</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
<TextField
label="Bild-URL eingeben"
value={imageInput}
onChange={e => onImageInputChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); onAdd() } }}
size="small"
fullWidth
placeholder="https://…"
/>
<Button
variant="outlined"
size="small"
startIcon={<ImagePlus size={15} />}
onClick={onAdd}
disabled={!imageInput.trim()}
sx={{ whiteSpace: 'nowrap', textTransform: 'none' }}
>
Hinzufügen
</Button>
</Box>
{images.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{images.map((url, i) => (
<Chip
key={i}
label={url.length > 40 ? url.slice(0, 40) + '…' : url}
size="small"
onDelete={() => onRemove(i)}
sx={{ maxWidth: 300 }}
/>
))}
</Box>
)}
</Card>
)
}
@@ -0,0 +1,37 @@
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
import { SOFT_FACTORS, LEVEL_OPTIONS } from '../../pages/supply/newListingConstants'
interface Props {
softLevels: Record<string, string>
onChange: (key: string, value: string) => void
}
export function SoftFactorsSection({ softLevels, onChange }: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Lage & Ausstrahlung</Typography>
<Typography variant="caption" color="text.secondary">
Diese Angaben verbessern die Match-Qualität erheblich.
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
{SOFT_FACTORS.map(({ key, label }) => (
<TextField
key={key}
select
label={label}
value={softLevels[key] ?? ''}
onChange={e => onChange(key, e.target.value)}
size="small"
fullWidth
>
{LEVEL_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
))}
</Box>
</Card>
)
}
@@ -0,0 +1,51 @@
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
interface Props {
floor: string
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
parking: string
onParkingChange: (v: string) => void
ceilingHeight: string
onCeilingHeightChange: (v: string) => void
}
export function TechnicalDetailsSection({
floor, onFloorChange,
fitOut, onFitOutChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
}: Props) {
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 2 }}>
<TextField
label="Stockwerk" value={floor}
onChange={e => onFloorChange(e.target.value)}
size="small" type="number" fullWidth placeholder="0 = EG"
/>
<TextField
select label="Ausbaustandard" value={fitOut}
onChange={e => onFitOutChange(e.target.value)} size="small" fullWidth
>
{FIT_OUT_OPTIONS.map(o => (
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)}
size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
/>
<TextField
label="Deckenhöhe (m)" value={ceilingHeight}
onChange={e => onCeilingHeightChange(e.target.value)}
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
/>
</Box>
</Card>
)
}
+9
View File
@@ -0,0 +1,9 @@
export { AiAssistCard } from './AiAssistCard'
export { AreaDetailsSection } from './AreaDetailsSection'
export { AddressSection } from './AddressSection'
export { SoftFactorsSection } from './SoftFactorsSection'
export { TechnicalDetailsSection } from './TechnicalDetailsSection'
export { ImageUrlSection } from './ImageUrlSection'
export { ContactSection } from './ContactSection'
export { CreatedScreen } from './CreatedScreen'
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
open={open}
maxWidth="sm"
fullWidth
disableEscapeKeyDown
>
<DialogContent sx={{ pt: 4, pb: 2 }}>
{SLIDES[activeStep]}
+2 -7
View File
@@ -1,10 +1,5 @@
import { Box, LinearProgress, Tooltip, Typography } from '@mui/material'
function scoreColor(score: number): string {
if (score >= 0.85) return '#15803d'
if (score >= 0.65) return '#a16207'
return '#dc2626'
}
import { confidenceHex } from '../../lib/utils'
interface ReliabilityScorePanelProps {
score: number
@@ -13,7 +8,7 @@ interface ReliabilityScorePanelProps {
export function ReliabilityScorePanel({ score, compact = false }: ReliabilityScorePanelProps) {
const pct = Math.round(score * 100)
const color = scoreColor(score)
const color = confidenceHex(score)
if (compact) {
return (
+60 -7
View File
@@ -1,13 +1,14 @@
import { useNavigate } from 'react-router'
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
import { HeatBadge } from '../shared'
import type { PipelineItem } from '../../domain/pipeline'
import { STAGES, RESULT_TYPE_LABEL, RESULT_TYPE_COLOR } from './pipelineConstants'
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
import { detailPath } from './pipelineUtils'
import { useInquiryStore } from '../../stores/inquiryStore'
// ── DraggableCard ─────────────────────────────────────────────────────────────
@@ -25,8 +26,9 @@ export function DraggableCard({
onChatClick?: (e: React.MouseEvent) => void
}) {
const navigate = useNavigate()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
const stageConfig = STAGES.find(s => s.key === item.stage)!
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
const path = detailPath(item)
const style = !isDragOverlay ? {
@@ -68,8 +70,8 @@ export function DraggableCard({
<HeatBadge propertyId={item.propertyId} size="sm" />
<Chip
size="small"
label={RESULT_TYPE_LABEL[item.resultType] ?? item.resultType}
sx={{ bgcolor: RESULT_TYPE_COLOR[item.resultType] ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
label={RESULT_TYPE_META[item.resultType]?.label ?? item.resultType}
sx={{ bgcolor: RESULT_TYPE_META[item.resultType]?.color ?? '#475569', color: 'white', fontSize: 10, height: 20, fontWeight: 600 }}
/>
<Chip
size="small"
@@ -87,7 +89,7 @@ export function DraggableCard({
</Tooltip>
)}
{path && (
<Tooltip title="Objekt öffnen">
<Tooltip title="Match öffnen">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); navigate(path) }}
@@ -135,6 +137,57 @@ export function DraggableCard({
{item.notes}
</Typography>
)}
{/* Row 6: Anfrage / Konversation */}
{!isDragOverlay && (
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
{item.stage !== 'SAVED' || item.inquiryId ? (
<Button
size="small"
variant="outlined"
startIcon={<MessagesSquare size={11} />}
fullWidth
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
}}
sx={{
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
borderColor: '#1e3a5f', color: '#1e3a5f',
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
}}
>
Zur Konversation
</Button>
) : (
<Button
size="small"
variant="contained"
startIcon={<Send size={11} />}
fullWidth
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: item.title,
location: item.propertyAddress ?? item.location,
areaLabel: item.areaLabel,
rentLabel: item.rentLabel,
matchScore: item.matchScore,
pipelineItemId: item.id,
})
}}
sx={{
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
}}
>
Anfrage senden
</Button>
)}
</Box>
)}
</Card>
)
}
+9 -1
View File
@@ -11,6 +11,7 @@ export function DroppableColumn({
selectedId,
onSelect,
onChatClick,
onCardChatClick,
isOver,
}: {
stage: { key: PipelineStage; label: string; color: string; bgColor: string }
@@ -18,6 +19,7 @@ export function DroppableColumn({
selectedId: string | null
onSelect: (item: PipelineItem) => void
onChatClick: (inquiryId: string) => void
onCardChatClick?: (item: PipelineItem) => void
isOver: boolean
}) {
const { setNodeRef } = useDroppable({ id: stage.key })
@@ -43,7 +45,13 @@ export function DroppableColumn({
item={item}
isSelected={item.id === selectedId}
onSelect={onSelect}
onChatClick={item.inquiryId ? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) } : undefined}
onChatClick={
item.inquiryId
? (e) => { e.stopPropagation(); onChatClick(item.inquiryId!) }
: onCardChatClick
? (e) => { e.stopPropagation(); onCardChatClick(item) }
: undefined
}
/>
))}
{items.length === 0 && (

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