29 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:25:43 +02:00
100 changed files with 3843 additions and 1241 deletions
+17 -1
View File
@@ -13,7 +13,23 @@
"Bash(git pull *)",
"Bash(node scripts/check-tokens.js)",
"Bash(echo \"EXIT:$?\")",
"Bash(echo \"EXIT_CODE:$?\")"
"Bash(echo \"EXIT_CODE:$?\")",
"Bash(Get-ChildItem \"c:\\\\Users\\\\beni_\\\\AppData\\\\Local\\\\Temp\\\\docx-gen\\\\node_modules\\\\docx\" -Recurse -Name)",
"Bash(start \"C:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\Aenderungsprotokoll_Meeting_26052026.docx\")",
"Bash(start http://localhost:5176)",
"Bash(start http://localhost:5176/demand/ai-search)",
"Bash(node -e ' *)",
"Skill(run)",
"Skill(run:*)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173)",
"Bash(powershell.exe -Command \"Start-Process 'http://localhost:5173'\")",
"Bash(findstr \"LISTENING\")",
"Bash(findstr \":517\")",
"Bash(findstr \"LISTEN\")",
"Bash(findstr \":5\")",
"Bash(echo \"Exit: $?\")",
"Bash(npx vercel *)",
"Bash(Start-Process \"http://localhost:5180\")"
]
}
}
+17 -1
View File
@@ -5,7 +5,23 @@
"Bash(git commit *)",
"Bash(node -e ' *)",
"Bash(git stash *)",
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)",
"Bash(pandoc --version)",
"Bash(npm list *)",
"Read(//c/Program Files/LibreOffice/program/**)",
"Read(//c/Program Files/Microsoft Office/**)",
"Read(//c/Program Files \\(x86\\)/**)",
"Bash(mkdir -p /tmp/docx-gen)",
"Read(//tmp/**)",
"Bash(npm init *)",
"Bash(node generate.js)",
"Bash(timeout 8 bash -c \"until curl -s http://localhost:5173 > /dev/null; do sleep 1; done\")",
"Bash(dir \"c:\\\\Users\\\\beni_\\\\OneDrive\\\\Desktop\\\\property-match\\\\src\" -Depth 2)",
"Bash(start \"\" \"http://localhost:5178/demand/results\")",
"Bash(start \"\" \"http://localhost:5178/supply/properties\")"
],
"additionalDirectories": [
"c:\\Users\\beni_\\AppData\\Local\\Temp\\docx-gen"
]
}
}
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries'
import type { InquiryFilters } from '../../provider/IInquiryProvider'
import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid'
@@ -17,8 +18,14 @@ function loadViewMode(): ViewMode {
return stored === 'grid' ? 'grid' : 'list'
}
export function ActiveInquiriesTab() {
const { data: inquiries = [], isLoading } = useActiveInquiries()
interface Props {
filters?: InquiryFilters
emptyTitle?: string
emptyDescription?: string
}
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme()
@@ -41,8 +48,8 @@ export function ActiveInquiriesTab() {
return (
<EmptyState
icon={<Inbox size={40} />}
title="Keine aktiven Anfragen"
description="Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier."
title={emptyTitle ?? 'Keine aktiven Anfragen'}
description={emptyDescription ?? 'Sobald Interessenten Anfragen zu Ihren Objekten stellen, erscheinen diese hier.'}
/>
)
}
+3 -13
View File
@@ -96,19 +96,9 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
{formatInquiryDate(inquiry.createdAt)}
</Typography>
{inquiry.matchScore !== undefined && (
<Box
sx={{
px: 1,
py: 0.25,
borderRadius: 1,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
Match {inquiry.matchScore}%
</Box>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
{inquiry.matchScore}%
</Typography>
)}
</Box>
</Paper>
@@ -56,6 +56,7 @@ export function InquiryChat({
<InquiryReplyComposer
inquiryId={inquiry.id}
defaultSubject={inquiry.subject}
tenantName={inquiry.tenantName}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={onPendingAttachmentConsumed}
/>
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { ClipboardList } from 'lucide-react'
import { ArrowRight, Building2, ClipboardList, MapPin, Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useInquiryById, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { usePropertyById } from '../../hooks/useProperties'
import { InquiryChat } from './InquiryChat'
import { RelatedPropertyCardPanel } from './RelatedPropertyCardPanel'
import type { Attachment } from '../../domain/inquiry'
@@ -12,7 +14,9 @@ interface InquiryDetailPanelProps {
export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
const { data: inquiry, isLoading } = useInquiryById(inquiryId)
const { data: property } = usePropertyById(inquiry?.propertyId ?? '')
const markRead = useMarkThreadAsRead()
const navigate = useNavigate()
const [preparationOpen, setPreparationOpen] = useState(false)
const [offerWizardOpen, setOfferWizardOpen] = useState(false)
const [pendingAttachment, setPendingAttachment] = useState<Attachment | null>(null)
@@ -43,10 +47,13 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
}
const isLatentInquiry = !!inquiry.needId
const propertyImage = property?.images?.[0]
return (
<>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header: tenant + subject + action */}
<Box
sx={{
px: 2.5,
@@ -92,19 +99,85 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, overflow: 'hidden' }}>
{/* Property context bar */}
<Box
sx={{
px: 2,
py: 1,
borderBottom: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexShrink: 0,
}}
>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 1,
overflow: 'hidden',
flexShrink: 0,
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{propertyImage ? (
<Box
component="img"
src={propertyImage}
alt={property?.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Building2 size={18} color="#94a3b8" />
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
>
{property?.title ?? '—'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
<MapPin size={11} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{property?.location.city}
{property?.areaSqm ? ` · ${property.areaSqm.toLocaleString('de-CH')}` : ''}
{property?.rentPricePerSqm ? ` · CHF ${property.rentPricePerSqm}/m²/J.` : ''}
</Typography>
</Box>
</Box>
<Button
size="small"
endIcon={<ArrowRight size={12} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
>
Objekt ansehen
</Button>
</Box>
{/* Chat (flex: 1) */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<InquiryChat
inquiry={inquiry}
onCreateOffer={() => setOfferWizardOpen(true)}
pendingAttachment={pendingAttachment}
onPendingAttachmentConsumed={() => setPendingAttachment(null)}
/>
<Box sx={{ width: { xs: '100%', md: 300 }, flexShrink: 0, borderTop: { xs: '1px solid #e2e8f0', md: 'none' }, maxHeight: { xs: 280, md: 'none' }, overflowY: 'auto' }}>
<RelatedPropertyCardPanel
propertyId={inquiry.propertyId}
inquiryId={inquiry.id}
/>
</Box>
</Box>
{/* Bottom: weitere passende Objekte */}
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0' }}>
<RelatedPropertyCardPanel
propertyId={inquiry.propertyId}
inquiryId={inquiry.id}
compact
/>
</Box>
</Box>
@@ -17,11 +17,10 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
onClick={onClick}
sx={{
px: 2,
py: 1.5,
py: 1,
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid',
borderLeftColor: selected ? '#152642' : hasUnread ? '#2563eb' : 'transparent',
bgcolor: selected ? '#f1f5f9' : 'white',
borderLeft: selected ? '4px solid #2563eb' : `3px solid ${hasUnread ? '#2563eb' : 'transparent'}`,
bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s',
'&:hover': { bgcolor: selected ? '#f1f5f9' : '#f8fafc' },
@@ -30,7 +29,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography
variant="body2"
sx={{ fontWeight: hasUnread ? 700 : 600, color: '#0f172a', fontSize: '0.85rem' }}
sx={{ fontWeight: selected || hasUnread ? 700 : 600, color: selected ? '#1d4ed8' : '#0f172a', fontSize: '0.85rem' }}
>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
@@ -76,20 +75,9 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
{propertyLabelFromId(inquiry.propertyId)}
</Typography>
{inquiry.matchScore !== undefined && (
<Box
sx={{
ml: 'auto',
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: '#e0e7ff',
color: '#3730a3',
fontWeight: 600,
fontSize: '0.7rem',
}}
>
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
{inquiry.matchScore}%
</Box>
</Typography>
)}
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
@@ -7,7 +7,7 @@ import {
TextField,
Typography,
} from '@mui/material'
import { Send, Paperclip, X } from 'lucide-react'
import { Paperclip, Send, Sparkles, X } from 'lucide-react'
import { useSendInquiryReply } from '../../hooks/useInquiries'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
@@ -16,6 +16,7 @@ import { formatFileSize } from './inquiryUtils'
interface InquiryReplyComposerProps {
inquiryId: string
defaultSubject: string
tenantName?: string
onSent?: () => void
pendingAttachment?: Attachment | null
onPendingAttachmentConsumed?: () => void
@@ -24,6 +25,7 @@ interface InquiryReplyComposerProps {
export function InquiryReplyComposer({
inquiryId,
defaultSubject,
tenantName,
onSent,
pendingAttachment,
onPendingAttachmentConsumed,
@@ -33,6 +35,7 @@ export function InquiryReplyComposer({
)
const [body, setBody] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [isAIDraft, setIsAIDraft] = useState(false)
const sendReply = useSendInquiryReply()
const showToast = useToastStore(s => s.showToast)
@@ -49,6 +52,14 @@ export function InquiryReplyComposer({
const sending = sendReply.isPending
function handleAIDraft() {
const salutation = tenantName ? `Sehr geehrte/r ${tenantName}` : 'Sehr geehrte Damen und Herren'
setBody(
`${salutation},\n\nVielen Dank für Ihre Anfrage. Gerne bestätigen wir, dass das Objekt zum gewünschten Zeitpunkt verfügbar ist.\n\nWir würden Ihnen gerne einen Besichtigungstermin vorschlagen — bitte teilen Sie uns Ihre Verfügbarkeit mit, damit wir einen passenden Termin finden können.\n\nFür Rückfragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüssen`
)
setIsAIDraft(true)
}
const handleAddMockAttachment = () => {
const name = `Anhang_${attachments.length + 1}.pdf`
setAttachments(prev => [
@@ -82,6 +93,7 @@ export function InquiryReplyComposer({
showToast('Antwort gesendet', 'success')
setBody('')
setAttachments([])
setIsAIDraft(false)
onSent?.()
}
@@ -103,15 +115,26 @@ export function InquiryReplyComposer({
onChange={e => setSubject(e.target.value)}
fullWidth
/>
{isAIDraft && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Sparkles size={12} color="#7c3aed" />
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
KI-Entwurf bitte prüfen und anpassen
</Typography>
</Box>
)}
<TextField
size="small"
label="Nachricht"
value={body}
onChange={e => setBody(e.target.value)}
onChange={e => { setBody(e.target.value); setIsAIDraft(false) }}
multiline
rows={4}
rows={7}
placeholder="Antwort verfassen..."
fullWidth
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
/>
{attachments.length > 0 && (
@@ -149,15 +172,32 @@ export function InquiryReplyComposer({
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, justifyContent: 'space-between' }}>
<Button
size="small"
startIcon={<Paperclip size={14} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none' }}
>
Anhang
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 0.75 }}>
<Button
size="small"
startIcon={<Sparkles size={13} />}
onClick={handleAIDraft}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
color: '#7c3aed',
borderColor: '#ddd6fe',
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' },
}}
variant="outlined"
>
KI-Entwurf
</Button>
<Button
size="small"
startIcon={<Paperclip size={13} />}
onClick={handleAddMockAttachment}
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
>
Anhang
</Button>
</Box>
<Button
variant="contained"
size="small"
@@ -2,6 +2,9 @@ import { Box, Button, CircularProgress, IconButton, TextField, Typography } from
import { ArrowLeft, FileText, Paperclip, Send, X } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useSendOffer } from '../../hooks/useOffers'
import { useCreateOffer } from '../../hooks/useInquiries'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties'
@@ -24,6 +27,9 @@ export function OfferChatComposer() {
const reset = useOfferWizardStore(s => s.reset)
const sendOffer = useSendOffer()
const createOffer = useCreateOffer()
const { data: latentNeed } = useLatentNeedById(needId)
const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => {
@@ -60,6 +66,19 @@ export function OfferChatComposer() {
showToast(`Fehler: ${res.error}`, 'error')
return
}
// Angebot als Konversation im Nachfrager-Postfach materialisieren
await createOffer.mutateAsync({
organizationId: currentUser?.organizationId ?? 'org-wincasa',
tenantOrgId: latentNeed?.tenantOrgId ?? 'org-mobimo',
needId: needId ?? '',
propertyId: selectedPropertyIds[0] ?? '',
offeredPropertyIds: selectedPropertyIds,
subject,
message: body,
senderName: currentUser?.organizationName ?? 'Wincasa AG',
recipientName: latentNeed?.tenantCompany,
attachments,
})
showToast('Angebot erfolgreich gesendet', 'success')
reset()
}
@@ -1,5 +1,5 @@
import { Box, Button, CircularProgress, Divider, Typography } from '@mui/material'
import { ArrowRight, Building2, Calendar, MapPin, Ruler, Trophy } from 'lucide-react'
import { ArrowRight, Building2, Calendar, MapPin, Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
@@ -8,9 +8,10 @@ import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
interface RelatedPropertyCardPanelProps {
propertyId: string
inquiryId: string
compact?: boolean
}
export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPropertyCardPanelProps) {
export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: RelatedPropertyCardPanelProps) {
const { data: property, isLoading } = usePropertyById(propertyId)
const navigate = useNavigate()
const {
@@ -18,7 +19,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
isLoading: loadingMatches,
} = useAdditionalMatchesForInquiry(inquiryId, { minScore: 80, excludePropertyId: propertyId })
if (isLoading) {
if (isLoading && !compact) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4 }}>
<CircularProgress size={20} />
@@ -26,6 +27,44 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
)
}
if (compact) {
return (
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
>
Weitere passende Objekte
</Typography>
{loadingMatches ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
<CircularProgress size={16} />
</Box>
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
Keine weiteren Matches
</Typography>
) : (
<Box
sx={{
display: 'flex',
gap: 1.25,
overflowX: 'auto',
pb: 0.5,
'&::-webkit-scrollbar': { height: 3 },
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
}}
>
{additionalMatches.map(m => (
<MatchSliderCard key={m.propertyId} match={m} />
))}
</Box>
)}
</Box>
)
}
if (!property) {
return (
<Box sx={{ p: 2 }}>
@@ -51,25 +90,22 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
overflowY: 'auto',
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Bezogenes Objekt
</Typography>
<Box
sx={{
height: 140,
aspectRatio: '3/2',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: image ? `url(${image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
bgcolor: '#f4f3f0',
}}
>
{!image && <Building2 size={32} color="#94a3b8" />}
{image ? (
<Box component="img" src={image} alt={property.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={32} color="#94a3b8" />
</Box>
)}
</Box>
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
@@ -77,20 +113,20 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<MapPin size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.location.city}
{property.location.district ? `, ${property.location.district}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Ruler size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569', fontSize: '0.8125rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Calendar size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
@@ -114,14 +150,10 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
Objekt ansehen
</Button>
{/* Weitere Matches */}
<Divider sx={{ my: 0.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Trophy size={14} color="#d97706" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
{loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
@@ -140,74 +172,117 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
)
}
function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
onClick={() => navigate('/supply/properties')}
sx={{
flexShrink: 0,
width: 168,
border: '1px solid #e8e7e4',
borderRadius: 1.5,
overflow: 'hidden',
cursor: 'pointer',
bgcolor: 'white',
transition: 'border-color 0.15s, box-shadow 0.15s',
'&:hover': {
borderColor: '#b0aead',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
},
}}
>
{/* Image — 16:9 crop, compact supplementary card */}
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
{match.imageUrl ? (
<Box
component="img"
src={match.imageUrl}
alt={match.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={16} color="#94a3b8" />
</Box>
)}
{/* Score badge overlay */}
<Box
sx={{
position: 'absolute',
top: 6,
right: 6,
bgcolor: 'rgba(15,23,42,0.72)',
color: 'white',
fontSize: '0.7rem',
fontWeight: 700,
px: 0.75,
py: 0.25,
borderRadius: '4px',
lineHeight: 1.4,
}}
>
{match.matchScore}%
</Box>
</Box>
<Box sx={{ p: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
{match.matchScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{match.location}
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
+ {match.reasons[0]}
</Typography>
)}
</Box>
</Box>
)
}
function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
const navigate = useNavigate()
return (
<Box
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: 'white',
borderTop: '1px solid #f1f5f9',
pt: 1,
'&:first-of-type': { borderTop: 'none', pt: 0 },
}}
>
{match.imageUrl && (
<Box
sx={{
height: 70,
backgroundImage: `url(${match.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3 }}>
{match.title}
</Typography>
<Box
sx={{
px: 0.75,
py: 0.125,
borderRadius: 1,
bgcolor: match.matchScore >= 90 ? '#fef3c7' : '#e0e7ff',
color: match.matchScore >= 90 ? '#92400e' : '#3730a3',
fontWeight: 700,
fontSize: '0.65rem',
flexShrink: 0,
ml: 0.5,
}}
>
{match.matchScore}%
</Box>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', display: 'block', mb: 0.5 }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m² · CHF {match.rentPricePerSqm}/m²/Jahr
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
{match.matchScore}%
</Typography>
{match.reasons.length > 0 && (
<Box sx={{ mb: 0.5 }}>
{match.reasons.map((r, i) => (
<Typography key={i} variant="caption" sx={{ color: '#15803d', fontSize: '0.68rem', display: 'block', lineHeight: 1.4 }}>
+ {r}
</Typography>
))}
</Box>
)}
{match.topUncertainty && (
<Typography variant="caption" sx={{ color: '#92400e', fontSize: '0.68rem', display: 'block', lineHeight: 1.4, mb: 0.25 }}>
{match.topUncertainty}
</Typography>
)}
<Button
size="small"
endIcon={<ArrowRight size={11} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#152642' }}
>
Ansehen
</Button>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
+ {match.reasons[0]}
</Typography>
)}
<Button
size="small"
endIcon={<ArrowRight size={10} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: '#152642' }}
>
Ansehen
</Button>
</Box>
)
}
+11 -24
View File
@@ -20,8 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -155,11 +155,7 @@ export function CompareTableBody({
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const fitOutByLandlord = prop.hardFacts?.fitOutByLandlord
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
@@ -170,21 +166,12 @@ export function CompareTableBody({
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
// Annuitätischer Ausbau-Aufschlag pro Monat (= 0 bei Vermieter-Übernahme / bezugsfertig)
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut, rentPricePerSqm: prop.rentPricePerSqm, mabPerSqm, fitOutByLandlord,
})
const fitOutMonthly = Math.round(fitOutPerSqm * prop.areaSqm / 12)
const fitOutMonthlyLabel = fitOutMonthly > 0 ? fitOutMonthly.toLocaleString('de-CH') : null
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
@@ -205,7 +192,7 @@ export function CompareTableBody({
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
@@ -213,7 +200,7 @@ export function CompareTableBody({
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography>
)}
</Box>
@@ -73,16 +73,18 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
/>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 200"
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
/>
</Box>
{c.requiredFitOut && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
<TextField
size="small" type="number" fullWidth placeholder="z.B. 200"
value={c.fitOutBudgetMaxPerSqm ?? ''}
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
helperText="Überbrückt Flächen unter Ihrem Mindest-Ausbaustandard — Sie tragen die Differenz selbst (exkl. MAB des Vermieters)."
/>
</Box>
)}
</Box>
{/* Divisibility */}
+2 -26
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
import { Box, Card, Chip, Slider, Stack, TextField, Typography } from '@mui/material'
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
@@ -242,31 +242,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
</Stack>
)}
{/* Anonymous search */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e8e7e4'}` }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
Anonyme Suche
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
Firmenname wird nicht an Vermieter übermittelt
</Typography>
</Box>
<FormControlLabel
control={
<Switch
size="small"
checked={c.isAnonymous ?? false}
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
/>
}
label=""
sx={{ m: 0 }}
/>
</Box>
<NeedExtendedRequirements criteria={c} onChange={set} />
<NeedExtendedRequirements criteria={c} onChange={set} />
</Card>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { memo } from 'react'
import { Box, Button, Typography } from '@mui/material'
import { Ruler } from 'lucide-react'
import { useNavigate } from 'react-router'
import { ROUTES } from '../../lib/constants'
import type { Property } from '../../domain/property'
import type { VerifiedPortfolioResult } from '../../domain/unifiedResult'
import type { Match } from '../../domain/match'
import { MatchStrength, ResultType } from '../../domain/enums'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useCompareStore } from '../../stores/compareStore'
interface Props {
property: Property
score: number
needId: string
}
function buildSyntheticResult(property: Property, needId: string, score: number, matchId: string): VerifiedPortfolioResult {
const strength = score >= 85 ? MatchStrength.STRONG : score >= 70 ? MatchStrength.MODERATE : MatchStrength.WEAK
const syntheticMatch = {
id: matchId,
needId,
propertyId: property.id,
matchScore: score,
matchStrength: strength,
scoreBreakdown: { hardMatchScore: score, softFactorScore: score, confidenceModifier: 0, dataQualityModifier: 0, totalScore: score },
confidenceLevel: score / 100,
} as unknown as Match
return {
matchId,
needId,
matchScore: score,
resultType: ResultType.VERIFIED_PORTFOLIO,
property,
match: syntheticMatch,
}
}
export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score, needId }: Props) {
const navigate = useNavigate()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const openSavedDialog = usePipelineStore(s => s.openSavedDialog)
const { addToCompare, removeFromCompare, isInCompare, isFull } = useCompareStore()
const scoreColor = score >= 85 ? '#16a34a' : score >= 70 ? '#d97706' : '#dc2626'
const scoreBg = score >= 85 ? '#dcfce7' : score >= 70 ? '#fef3c7' : '#fee2e2'
// Match IDs in the store use double-underscore format: m__propId__prop__needId
const matchId = `m__${property.id}__prop__${needId}`
const inCompare = isInCompare(matchId)
function handleInquire() {
openInquiryDialog({
propertyTitle: property.title,
location: property.location.city,
matchScore: score,
matchId,
propertyId: property.id,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleShortlist() {
openSavedDialog({
resultId: matchId,
resultType: 'VERIFIED_PORTFOLIO',
title: property.title,
matchScore: score,
location: property.location.city,
propertyId: property.id,
propertyAddress: `${property.title}, ${property.location.city}`,
areaLabel: property.areaSqm ? `${property.areaSqm}` : undefined,
rentLabel: property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : undefined,
})
}
function handleCompare() {
if (inCompare) {
removeFromCompare(matchId)
} else if (!isFull()) {
addToCompare(buildSyntheticResult(property, needId, score, matchId))
}
}
function handleDetails() {
navigate(`/demand/results/${matchId}`)
}
function handleUnit() {
navigate(`/demand/property/${property.id}`)
}
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1.25 }}>
{property.images?.[0] ? (
<Box component="img" src={property.images[0]} alt=""
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
) : (
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Ruler size={18} color="#3730a3" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
{property.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
{property.location.city} · {property.areaSqm} m²
</Typography>
</Box>
<Box sx={{ bgcolor: scoreBg, color: scoreColor, px: 1, py: 0.375, borderRadius: 1, fontWeight: 700, fontSize: '0.8rem', flexShrink: 0 }}>
{score}%
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.75, px: 1.25, pb: 1.25, flexWrap: 'wrap' }}>
<ActionButton onClick={handleInquire} primary>Anfrage</ActionButton>
<ActionButton onClick={handleShortlist}>Merken</ActionButton>
<ActionButton onClick={handleCompare} disabled={!inCompare && isFull()}>
{inCompare ? 'Im Vergleich' : 'Vergleichen'}
</ActionButton>
<ActionButton onClick={handleDetails}>Details </ActionButton>
<ActionButton onClick={handleUnit}>Zur Einheit </ActionButton>
</Box>
</Box>
)
})
function ActionButton({ children, onClick, primary, disabled }: {
children: React.ReactNode
onClick: () => void
primary?: boolean
disabled?: boolean
}) {
return (
<Button
size="small"
variant={primary ? 'contained' : 'outlined'}
onClick={onClick}
disabled={disabled}
sx={{
textTransform: 'none',
fontWeight: 600,
fontSize: '0.75rem',
py: 0.25,
px: 1,
minWidth: 0,
...(primary
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
}}
>
{children}
</Button>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { memo } from 'react'
import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import type { Need } from '../../domain/need'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
interface Props {
need: Need
selected: boolean
matchCount: number
topScore: number
onClick: () => void
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, matchCount, topScore, onClick }: Props) {
const statusCfg = statusConfig(need.status)
const locationText = need.preferredLocations.slice(0, 2).join(', ')
const scoreColor = topScore >= 80 ? '#16a34a' : topScore >= 65 ? '#d97706' : '#dc2626'
return (
<Paper
onClick={onClick}
elevation={0}
sx={{
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#152642' : '#e2e8f0',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': { borderColor: selected ? '#152642' : '#94a3b8', boxShadow: '0 2px 6px rgba(15,23,42,0.06)' },
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
</Box>
{matchCount > 0 && (
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
{matchCount} Treffer
</Box>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
{need.companyName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
</Box>
{topScore > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
</Box>
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
{topScore}%
</Typography>
</Box>
)}
</Paper>
)
})
+326
View File
@@ -0,0 +1,326 @@
import { memo, useMemo, useState } from 'react'
import {
Accordion, AccordionDetails, AccordionSummary,
Box, Button, Chip, Divider, LinearProgress, Slider, Switch, TextField, Typography,
} from '@mui/material'
import { Bell, Calendar, CheckCircle2, ChevronDown, MapPin, Ruler, Target, Wallet } from 'lucide-react'
import type { Need, NotificationConfig, UpdateNeedInput } from '../../domain/need'
import type { Property } from '../../domain/property'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { confidenceHex } from '../../lib/utils'
import { useMatchesByNeed } from '../../hooks/useMatches'
import { PropertyMatchRow } from './PropertyMatchRow'
const WEIGHT_LABELS: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansionspotenzial',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Publikumsverkehr',
talentAccess: 'Talentzugang', esg: 'ESG', taxEnvironment: 'Steuerumgebung',
}
function statusConfig(status: Need['status']): { label: string; bg: string; fg: string } {
if (status === 'DRAFT') return { label: 'Entwurf', bg: '#fef3c7', fg: '#92400e' }
return { label: 'Aktiv', bg: '#dcfce7', fg: '#166534' }
}
interface Props {
need: Need
properties: Property[]
onStart: () => void
onEdit: () => void
onArchive: () => void
onUpdate: (data: UpdateNeedInput) => void
}
export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, onStart, onEdit, onArchive, onUpdate }: Props) {
const statusCfg = statusConfig(need.status)
const conf = need.confidenceInCriteria ?? 0
const confColor = confidenceHex(conf)
const [notif, setNotif] = useState<NotificationConfig>(() => need.notificationConfig ?? { enabled: true, minScore: 80 })
const [notifDirty, setNotifDirty] = useState(false)
function handleNotifChange(patch: Partial<NotificationConfig>) {
setNotif(prev => ({ ...prev, ...patch }))
setNotifDirty(true)
}
function handleNotifSave() {
onUpdate({ notificationConfig: notif })
setNotifDirty(false)
}
const { data: matches = [] } = useMatchesByNeed(need.id)
const notifThreshold = need.notificationConfig?.minScore ?? 80
const matchCount = matches.filter(m => m.matchScore >= notifThreshold).length
const scoredProperties = useMemo(() => {
const seen = new Set<string>()
return matches
.map(m => {
const property = properties.find(p => p.id === m.propertyId)
return property ? { property, score: Math.round(m.matchScore) } : null
})
.filter((x): x is { property: Property; score: number } => x !== null)
.sort((a, b) => b.score - a.score)
.filter(x => {
if (seen.has(x.property.id)) return false
seen.add(x.property.id)
return true
})
}, [matches, properties])
const topScore = scoredProperties.length > 0 ? scoredProperties[0].score : 0
const topProperties = scoredProperties.slice(0, 3)
const topWeights = useMemo(
() => Object.entries(need.weightingProfile).filter(([, v]) => v > 0.03).sort(([, a], [, b]) => b - a).slice(0, 6),
[need.weightingProfile],
)
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
{need.companyName}
</Typography>
{need.contactName && (
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
)}
</Box>
<Box>
<SectionLabel>Suchkriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1.25 }}>
<CriteriaBox icon={<MapPin size={13} />} label="Standort" value={need.preferredLocations.join(', ') || '—'} />
<CriteriaBox icon={<Ruler size={13} />} label="Fläche" value={`${need.requiredArea.min}${need.requiredArea.max}`} />
<CriteriaBox icon={<Wallet size={13} />} label="Budget" value={`CHF ${need.budgetRange.maxPerSqm} /m²`} />
<CriteriaBox icon={<Calendar size={13} />} label="Einzug ab" value={need.timing.earliestMoveIn || '—'} />
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<SectionLabel>Konfidenz der Kriterien</SectionLabel>
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor, fontSize: '0.8rem' }}>
{Math.round(conf * 100)}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={conf * 100} sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
}} />
</Box>
{(need.mustCriteriaText?.length ?? 0) > 0 && (
<Box>
<SectionLabel>Must-have Kriterien</SectionLabel>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustCriteriaText!.map((c, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
</Box>
))}
</Box>
</Box>
)}
{topWeights.length > 0 && (
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topWeights.map(([key, val]) => {
const pct = Math.round(val * 100)
return (
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{WEIGHT_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
</Box>
</Box>
)
})}
</Box>
</AccordionDetails>
</Accordion>
)}
<Accordion elevation={0} disableGutters sx={{
border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden',
'&:before': { display: 'none' }, bgcolor: 'white',
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Benachrichtigungen
</Typography>
{notif.enabled && (
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
ab {notif.minScore}%
</Box>
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ px: 2, pt: 0, pb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
Neue Treffer oberhalb der Schwelle melden
</Typography>
</Box>
<Switch
size="small"
checked={notif.enabled}
onChange={e => handleNotifChange({ enabled: e.target.checked })}
sx={{ '& .MuiSwitch-thumb': { bgcolor: notif.enabled ? '#152642' : undefined } }}
/>
</Box>
{notif.enabled && (
<>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
{notif.minScore}%
</Typography>
</Box>
<Slider
value={notif.minScore}
min={60} max={95} step={5}
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
</Box>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', mb: 0.5 }}>
E-Mail (optional)
</Typography>
<TextField
size="small"
fullWidth
placeholder="name@firma.ch"
value={notif.emailAddress ?? ''}
onChange={e => handleNotifChange({ emailAddress: e.target.value || undefined })}
inputProps={{ type: 'email' }}
sx={{ '& .MuiOutlinedInput-root': { fontSize: '0.85rem' } }}
helperText="Im Produktivsystem wird bei neuen Treffern eine E-Mail ausgelöst"
/>
</Box>
</>
)}
{notifDirty && (
<Button
variant="contained"
size="small"
onClick={handleNotifSave}
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
>
Speichern
</Button>
)}
</Box>
</AccordionDetails>
</Accordion>
{scoredProperties.length > 0 && (
<>
<Divider />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Target size={14} color="#15803d" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Passende Objekte
</Typography>
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
{matchCount} Treffer
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{topProperties.map(({ property, score }) => (
<PropertyMatchRow key={property.id} property={property} score={score} needId={need.id} />
))}
</Box>
{matchCount > 3 && (
<Box
onClick={onStart}
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
>
Alle {matchCount} Ergebnisse anzeigen
</Box>
)}
</Box>
</>
)}
</Box>
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
<Button variant="outlined" size="large" onClick={onEdit}
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
Bearbeiten
</Button>
<Button variant="contained" size="large" onClick={onStart}
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
Suche starten
</Button>
</Box>
</Box>
)
})
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{children}
</Typography>
)
}
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useState } from 'react'
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Plus, Search } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles, useArchiveNeed, useUpdateNeed } from '../../hooks/useNeeds'
import { useProperties } from '../../hooks/useProperties'
import { useMatches } from '../../hooks/useMatches'
import { ResultType } from '../../domain/enums'
import { ROUTES } from '../../lib/constants'
import { EmptyState } from '../ui'
import { SavedNeedCard } from './SavedNeedCard'
import { SavedNeedDetail } from './SavedNeedDetail'
import { needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
interface Props {
onNewSearch: (prefillNeedId?: string) => void
}
export function SavedProfilesTab({ onNewSearch }: Props) {
const navigate = useNavigate()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const { data: needs = [], isLoading } = useNeedProfiles()
const { data: properties = [] } = useProperties({ resultType: ResultType.VERIFIED_PORTFOLIO })
const { data: allMatches = [] } = useMatches()
const archiveMutation = useArchiveNeed()
const updateMutation = useUpdateNeed()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [mobileView, setMobileView] = useState<'list' | 'detail'>('list')
const visible = useMemo(
() =>
needs
.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT')
.sort((a, b) => {
const aActive = !a.status || a.status === 'ACTIVE'
const bActive = !b.status || b.status === 'ACTIVE'
if (aActive !== bActive) return aActive ? -1 : 1
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
}),
[needs],
)
const { matchCounts, topScores } = useMemo(() => {
const counts: Record<string, number> = {}
const tops: Record<string, number> = {}
for (const n of visible) {
const needMatches = allMatches.filter(m => m.needId === n.id)
const threshold = n.notificationConfig?.minScore ?? 80
counts[n.id] = needMatches.filter(m => m.matchScore >= threshold).length
tops[n.id] = needMatches.length > 0 ? Math.round(Math.max(...needMatches.map(m => m.matchScore))) : 0
}
return { matchCounts: counts, topScores: tops }
}, [visible, allMatches])
useEffect(() => {
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
}, [visible, selectedId, isMobile])
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
function handleSelect(id: string) {
setSelectedId(id)
if (isMobile) setMobileView('detail')
}
function handleStart(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
}
function handleEdit(needId: string) {
onNewSearch(needId)
}
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}><CircularProgress size={24} /></Box>
}
if (visible.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', pt: 8, gap: 2 }}>
<EmptyState title="Keine Suchprofile" description="Sie haben noch keine Suchprofile gespeichert." />
<Button variant="outlined" startIcon={<Plus size={16} />} onClick={() => onNewSearch()} sx={{ textTransform: 'none', mt: 1 }}>
Neue Suche erstellen
</Button>
</Box>
)
}
const list = (
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Search size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{visible.length}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 1.25, display: 'flex', flexDirection: 'column', gap: 1 }}>
{visible.map(n => (
<SavedNeedCard
key={n.id}
need={n}
selected={n.id === selectedId}
matchCount={matchCounts[n.id] ?? 0}
topScore={topScores[n.id] ?? 0}
onClick={() => handleSelect(n.id)}
/>
))}
</Box>
<Box sx={{ flexShrink: 0, p: 1.5, borderTop: '1px solid #e2e8f0' }}>
<Button fullWidth variant="outlined" size="small" startIcon={<Plus size={14} />} onClick={() => onNewSearch()}
sx={{ textTransform: 'none', fontSize: '0.8rem' }}>
Neue Suche
</Button>
</Box>
</Box>
)
if (isMobile) {
if (mobileView === 'detail' && selectedNeed) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => setMobileView('list')}><ArrowLeft size={18} /></IconButton>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }} noWrap>{selectedNeed.companyName}</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
</Box>
</Box>
)
}
return <Box sx={{ height: '100%', overflow: 'hidden' }}>{list}</Box>
}
return (
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{list}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{selectedNeed ? (
<SavedNeedDetail
need={selectedNeed}
properties={properties}
onStart={() => handleStart(selectedNeed.id)}
onEdit={() => handleEdit(selectedNeed.id)}
onArchive={() => archiveMutation.mutate(selectedNeed.id)}
onUpdate={(data) => updateMutation.mutate({ id: selectedNeed.id, ...data })}
/>
) : (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<EmptyState icon={<Search size={40} />} title="Profil auswählen" description="Wählen Sie ein Suchprofil, um Details zu sehen." />
</Box>
)}
</Box>
</Box>
)
}
+4
View File
@@ -9,3 +9,7 @@ export { NeedCardPreview } from './NeedCardPreview'
export { NeedInput } from './NeedInput'
export { VoiceNeedInput } from './VoiceNeedInput'
export { WeightingEditor } from './WeightingEditor'
export { PropertyMatchRow } from './PropertyMatchRow'
export { SavedNeedCard } from './SavedNeedCard'
export { SavedNeedDetail } from './SavedNeedDetail'
export { SavedProfilesTab } from './SavedProfilesTab'
+6 -1
View File
@@ -21,16 +21,21 @@ export function AppShell() {
const sidebarCollapsed = useLayoutStore(s => s.sidebarCollapsed)
const setActiveWorkspace = useLayoutStore(s => s.setActiveWorkspace)
const toggleSidebar = useLayoutStore(s => s.toggleSidebar)
const setSidebarCollapsed = useLayoutStore(s => s.setSidebarCollapsed)
const { currentUser } = useSessionStore()
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const isCompact = useMediaQuery(theme.breakpoints.down('xl'))
const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [location.pathname])
// Auto-collapse sidebar on compact screens (laptops < 1536px)
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
// Sync active workspace with URL
useEffect(() => {
const detected = getWorkspaceFromPath(location.pathname)
+99 -9
View File
@@ -1,22 +1,74 @@
import { useState } from 'react'
import { Badge, IconButton, Popover, Typography } from '@mui/material'
import { useMemo, useState } from 'react'
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
import { Bell } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles } from '../../hooks/useNeeds'
import { useMatches } from '../../hooks/useMatches'
import { ROUTES } from '../../lib/constants'
function loadSeenCounts(): Record<string, number> {
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
catch { return {} }
}
function saveSeenCounts(counts: Record<string, number>) {
localStorage.setItem('notif-seen-counts', JSON.stringify(counts))
}
export function NotificationButton() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [seenCounts, setSeenCounts] = useState<Record<string, number>>(loadSeenCounts)
const navigate = useNavigate()
const { data: needs = [] } = useNeedProfiles()
const { data: allMatches = [] } = useMatches()
const profilesWithMatches = useMemo(() => {
const active = needs.filter(n =>
(!n.status || n.status === 'ACTIVE' || n.status === 'DRAFT') &&
(n.notificationConfig?.enabled !== false),
)
return active
.map(n => {
const minScore = n.notificationConfig?.minScore ?? 80
return {
need: n,
count: allMatches.filter(m => m.needId === n.id && m.matchScore >= minScore).length,
minScore,
}
})
.filter(x => x.count > 0)
}, [needs, allMatches])
// Badge only lights up for profiles with matches the user hasn't seen yet
const newProfiles = useMemo(
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
[profilesWithMatches, seenCounts],
)
const badgeCount = newProfiles.length
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
// Mark all current matches as seen
const updated = { ...seenCounts }
for (const { need, count } of profilesWithMatches) {
updated[need.id] = count
}
setSeenCounts(updated)
saveSeenCounts(updated)
}
function handleClose() {
setAnchorEl(null)
function handleClose() { setAnchorEl(null) }
function goToResults(needId: string) {
navigate(ROUTES.DEMAND.RESULTS, { state: { fromNeedBuilder: true, activeNeedId: needId } })
handleClose()
}
return (
<>
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
<Badge badgeContent={0} color="error">
<Badge badgeContent={badgeCount || null} color="error">
<Bell size={20} />
</Badge>
</IconButton>
@@ -27,12 +79,50 @@ export function NotificationButton() {
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 280, p: 2 } } }}
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Benachrichtigungen</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine neuen Benachrichtigungen
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
Neue Treffer
</Typography>
{profilesWithMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Keine aktiven Benachrichtigungen
</Typography>
) : (
<>
{profilesWithMatches.map(({ need, count, minScore }) => (
<Box
key={need.id}
onClick={() => goToResults(need.id)}
sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
{need.companyName}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
ab {minScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
{count} Treffer
</Typography>
</Box>
))}
<Divider sx={{ my: 1.25 }} />
<Box
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
>
Alle Suchprofile anzeigen
</Box>
</>
)}
</Popover>
</>
)
+2 -2
View File
@@ -28,7 +28,7 @@ export function PageHeader({
sx,
}: PageHeaderProps) {
return (
<Box sx={{ px: 3, py: 3, borderBottom: '1px solid #e8e7e4', ...sx }}>
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e8e7e4', bgcolor: 'white', ...sx }}>
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
{breadcrumbs.map((crumb) =>
@@ -52,7 +52,7 @@ export function PageHeader({
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontFamily: '"DM Serif Display", serif', fontWeight: 400, fontSize: '1.25rem', lineHeight: 1.3 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
{title}
</Typography>
{badge}
+2 -2
View File
@@ -32,8 +32,8 @@ export function RightContextPanel() {
right: 0,
top: 56,
height: 'calc(100vh - 56px)',
width: 320,
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(320px)',
width: { xs: '85vw', sm: 300, lg: 320 },
transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(110%)',
transition: 'transform 0.25s ease',
bgcolor: '#fff',
borderLeft: '1px solid #e2e8f0',
@@ -1,5 +1,5 @@
import { Box, Button, Chip, Tooltip, Typography } from '@mui/material'
import { Building2, CheckCircle2 } from 'lucide-react'
import { Box, Button, Chip, Typography } from '@mui/material'
import { Building2, CheckCircle2, MapPin } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { LocationPreview } from '../shared/LocationPreview'
@@ -34,13 +34,8 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
return <FutureAvailabilityCard vm={vm} />
}
// secondary chips — max 2 visible
const chips: Array<{ label: string; color?: string }> = []
if (vm.availabilityLabel) chips.push({ label: vm.availabilityLabel })
if (vm.fitOutLabel) chips.push({ label: vm.fitOutLabel })
if (vm.isDivisible && vm.minDivisibleUnitSqm) chips.push({ label: `Teilbar ab ${vm.minDivisibleUnitSqm}` })
const visibleChips = chips.slice(0, 2)
const hiddenChips = chips.slice(2)
// secondary chips — max 1 visible (availability only), no overflow chip
const secondaryChip = vm.availabilityLabel ?? vm.fitOutLabel ?? null
return (
<Box sx={{
@@ -51,8 +46,15 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
display: 'flex',
flexDirection: 'column',
transition: 'border-color 0.15s',
'&:hover': { borderColor: '#c8c7c4' },
position: 'relative',
zIndex: 0,
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
'&:hover': {
borderColor: '#b0aead',
transform: 'scale(1.025)',
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
zIndex: 1,
},
}}>
{/* ── Hero zone ── */}
@@ -67,15 +69,29 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
border: `1px solid ${theme.border}`,
}}>
<Typography sx={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.625rem',
color: '#ffffff', lineHeight: 1,
fontWeight: 700, fontSize: '1.625rem',
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.02em',
}}>
{vm.matchScore}%
</Typography>
</Box>
{/* Top-right badge strip */}
{/* Location pill — Ginesta-style, top-left */}
<Box sx={{
position: 'absolute', top: 10, left: 10,
bgcolor: 'rgba(255,255,255,0.92)',
borderRadius: '20px',
px: 1, py: 0.35,
display: 'flex', alignItems: 'center', gap: 0.4,
backdropFilter: 'blur(4px)',
}}>
<MapPin size={9} color="#64748b" />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
{vm.locationLabel}
</Typography>
</Box>
{/* Top-right badge strip — result type + owner + heat */}
<Box sx={{ position: 'absolute', top: 10, right: 10, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
@@ -116,19 +132,13 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
</Typography>
)}
{/* Confidence + secondary chips */}
{/* Confidence + one secondary chip max */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{visibleChips.map((c, i) => (
<Chip key={i} label={c.label} size="small" variant="outlined"
{secondaryChip && (
<Chip label={secondaryChip} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
))}
{hiddenChips.length > 0 && (
<Tooltip title={hiddenChips.map(c => c.label).join(' · ')} arrow>
<Chip label={`+${hiddenChips.length}`} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
</Tooltip>
)}
</Box>
@@ -145,7 +155,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
{vm.reasons.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
{vm.reasons.slice(0, 2).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
+69 -87
View File
@@ -1,23 +1,21 @@
import { memo } from 'react'
import { Alert, Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { Alert, Box, Button, Chip, Typography } from '@mui/material'
import { MapPin, AlertTriangle } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
import type { MatchCardViewModel } from './MatchCardViewModel'
interface Props {
vm: MatchCardViewModel
imageUrl?: string
overlayTypeLabel?: string
}
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: Props) {
if (vm.isRestricted) return <MatchCardRestrictedState />
const { currentUser } = useSessionStore()
@@ -32,18 +30,20 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
const leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
const extraChips: string[] = []
if (vm.availabilityLabel) extraChips.push(vm.availabilityLabel)
if (vm.fitOutLabel) extraChips.push(vm.fitOutLabel)
if (vm.isDivisible && vm.minDivisibleUnitSqm) extraChips.push(`Teilbar ab ${vm.minDivisibleUnitSqm} `)
if (topRisk) extraChips.push(topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko')
const visibleExtra = extraChips.slice(0, 2)
const hiddenExtra = extraChips.slice(2)
// Compact metric line — area · rent · availability
const metricParts: string[] = []
if (vm.areaSqm) metricParts.push(`${vm.areaSqm.toLocaleString('de-CH')}`)
if (vm.rentPerSqm) metricParts.push(`CHF ${vm.rentPerSqm}//J`)
if (vm.availabilityLabel) metricParts.push(vm.availabilityLabel)
const metricLine = metricParts.join(' · ')
// Detail action — prefer the "Details →" action if available, fallback to first action
const detailAction = vm.actions.find(a => a.actionType === 'OPEN_DETAIL' && a.label.includes('Detail'))
?? vm.actions.find(a => a.actionType === 'OPEN_DETAIL')
return (
<Box sx={{
display: 'flex',
mb: 1.5,
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${DS_BORDER.default}`,
@@ -51,12 +51,19 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
bgcolor: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'border-color 0.15s',
'&:hover': { borderColor: '#c8c7c4' },
transition: 'border-color 0.15s, transform 0.18s ease, box-shadow 0.18s ease',
position: 'relative',
zIndex: 0,
'&:hover': {
borderColor: '#b0aead',
transform: 'scale(1.012)',
boxShadow: '0 6px 20px rgba(0,0,0,0.10)',
zIndex: 1,
},
}}>
{/* ── Image strip ── */}
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
{/* ── Image strip — 180px, Ginesta proportion ── */}
<Box sx={{ width: 180, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
{imageUrl ? (
<img
src={imageUrl}
@@ -73,7 +80,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
</Box>
)}
{/* Score badge */}
{/* Score badge — top left */}
<Box sx={{
position: 'absolute', top: 8, left: 8,
bgcolor: scoreBadgeBg, borderRadius: '6px',
@@ -81,58 +88,50 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
border: `1px solid ${SCORE_THEME[tier].border}`,
}}>
<Typography sx={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.125rem',
color: '#ffffff', lineHeight: 1,
fontWeight: 700, fontSize: '1.125rem',
color: '#ffffff', lineHeight: 1, letterSpacing: '-0.01em',
}}>
{vm.matchScore}%
</Typography>
</Box>
{overlayTypeLabel && (
<Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0,
px: 0.75, py: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%)',
}}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
{overlayTypeLabel}
</Typography>
</Box>
)}
{/* Location pill — Ginesta-style, top right */}
<Box sx={{
position: 'absolute', top: 8, right: 8,
bgcolor: 'rgba(255,255,255,0.92)',
borderRadius: '20px',
px: 1, py: 0.35,
display: 'flex', alignItems: 'center', gap: 0.4,
backdropFilter: 'blur(4px)',
}}>
<MapPin size={9} color="#64748b" />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 500, color: '#374151', lineHeight: 1 }}>
{vm.locationLabel}
</Typography>
</Box>
</Box>
{/* ── Content ── */}
<Box sx={{ flex: 1, p: 2, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Box sx={{ flex: 1, px: 2, py: 2.5, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
)}
{/* Badges row */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center', mb: 0.75 }}>
<HeatBadge propertyId={vm.propertyId} size="sm" />
{/* Chips — result type + heat + confidence */}
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', mb: 0.875 }}>
<Chip label={rt.label} size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip icon={<Building2 size={9} color="#152642" />} label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
)}
<HeatBadge propertyId={vm.propertyId} size="sm" />
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{visibleExtra.map((label, i) => (
<Chip key={i} label={label} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
))}
{hiddenExtra.length > 0 && (
<Tooltip title={hiddenExtra.join(' · ')} arrow>
<Chip label={`+${hiddenExtra.length}`} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
</Tooltip>
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18 }} />
)}
</Box>
{/* Title + location */}
{/* Title */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{vm.title}
</Typography>
@@ -141,40 +140,25 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
{vm.propertySubtitle}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
<MapPin size={11} color="#9a9a9a" />
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{vm.locationLabel}
</Typography>
</Box>
{/* Metric line — Ginesta-style */}
{metricLine && (
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8', mt: 0.5, letterSpacing: 0.1 }}>
{metricLine}
</Typography>
)}
{/* Explainability summary — 1 line */}
{vm.explainabilitySummary && (
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 500, mt: 0.75, lineHeight: 1.5,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
<Typography variant="body2" sx={{
color: DS_TEXT.secondary, fontWeight: 500, mt: 0.875, lineHeight: 1.5,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical',
}}>
{vm.explainabilitySummary}
</Typography>
)}
{vm.reasons.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35, mt: 0.75 }}>
{vm.reasons.slice(0, 2).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={11} color="#1d6342" style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>
{r.label}
</Typography>
</Box>
))}
</Box>
)}
{vm.scoreBreakdown && vm.allFactors && vm.allFactors.length > 0 && (
<>
<Divider sx={{ my: 0.75 }} />
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
</>
)}
{/* Critical risk only */}
{topRisk && topRisk.level === 'CRITICAL' && (
<Alert icon={<AlertTriangle size={13} />} severity="error"
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
@@ -182,8 +166,8 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
</Alert>
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1, flexWrap: 'wrap' }}>
{/* 2 Buttons — Anfrage + Details */}
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1.25 }}>
<Button
size="small"
variant="contained"
@@ -201,18 +185,16 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
>
Anfrage
</Button>
{vm.actions.map(a => (
{detailAction && (
<Button
key={a.id}
size="small"
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick}
disabled={a.disabled}
variant="outlined"
onClick={detailAction.onClick}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
>
{a.label}
Details
</Button>
))}
)}
</Box>
</Box>
</Box>
@@ -86,6 +86,10 @@ export interface MatchCardViewModel {
preMarketUnit?: PropertyUnit // specific unit being released pre-market
preMarketAllUnits?: PropertyUnit[] // all units of the backing property
// Key metrics for compact display (Ginesta-style metric line)
areaSqm?: number
rentPerSqm?: number
// Score transparency
scoreBreakdown?: {
hardMatchScore: number
@@ -0,0 +1,82 @@
import { Box, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { Lightbulb, MessageSquareQuote } from 'lucide-react'
import { useFitOutAdvice } from '../../hooks/useAI'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const RECOMMENDATION_META: Record<string, { label: string; bg: string; border: string; fg: string }> = {
MIETERAUSBAU: { label: 'Mieterausbau', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
BKZ: { label: 'Baukostenzuschuss (BKZ)', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
MAB_AMORTISATION: { label: 'MAB-Amortisation', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
}
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
requiredFitOut?: string
tenantBudgetPerSqm?: number
}
export function FitOutAdvicePanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, requiredFitOut, tenantBudgetPerSqm }: Props) {
const { data, isLoading, isError } = useFitOutAdvice({
fitOut,
areaSqm,
mabPerSqm,
requiredFitOut,
tenantBudgetPerSqm,
monthlyRentPerSqm: Math.round(rentPricePerSqm / 12),
})
// Fallback: KI-Ausfall blockiert nie — Panel wird einfach nicht gezeigt
if (isError) return null
if (isLoading || !data) {
return (
<Paper sx={{ p: 2.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CircularProgress size={16} />
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>KI-Ausbauempfehlung wird erstellt</Typography>
</Paper>
)
}
const advice = data.data
const meta = RECOMMENDATION_META[advice.recommendation] ?? RECOMMENDATION_META.MIETERAUSBAU
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Lightbulb size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>KI-Ausbauempfehlung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.75 }}>{advice.headline}</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, mb: 1.5, lineHeight: 1.55 }}>
{advice.explanation}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.5, bgcolor: DS_SURFACE.neutral.bg, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Geschätzte Nettoinvestition</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{advice.estimatedNetInvestment}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<MessageSquareQuote size={14} color={DS_TEXT.muted} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, lineHeight: 1.5 }}>
<strong>Verhandlungstipp:</strong> {advice.negotiationTip}
</Typography>
</Box>
{data.provenance?.fallbackUsed && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
Hinweis: KI nicht verfügbar Richtwert-basierte Empfehlung.
</Typography>
)}
</Paper>
)
}
+27 -46
View File
@@ -1,33 +1,23 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const RATE_PCT = Math.round(FITOUT_ANNUITY_RATE * 100)
interface Props {
fitOut: string
areaSqm: number
mabPerSqm: number
rentPricePerSqm: number
tenantBudgetPerSqm?: number
fitOutByLandlord?: boolean
}
function chf(value: number): string {
return `CHF ${Math.round(value).toLocaleString('de-CH')}.`
}
function chfRange(min: number, max: number): string {
if (Math.round(min) === Math.round(max)) return chf(min)
return `${chf(min)} ${chf(max)}`
}
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
@@ -49,35 +39,26 @@ function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
)
}
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, fitOutByLandlord }: Props) {
const { fitOutPerSqm } = effectiveAnnualBurdenPerSqm({ fitOut, rentPricePerSqm, mabPerSqm, fitOutByLandlord })
const rentPerYear = rentPricePerSqm * areaSqm
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
const fitOutPerYear = fitOutPerSqm * areaSqm
const totalPerYear = rentPerYear + fitOutPerYear
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
const investment = isReadyToMoveIn
? null
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
const fitOutPerYear = investment && !investment.isFullyCovered ? {
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
} : { min: 0, max: 0 }
const totalPerYear = {
min: rentPerYear + fitOutPerYear.min,
max: rentPerYear + fitOutPerYear.max,
}
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
// Kein Aufschlag → entweder Vermieter übernimmt oder Fläche bereits bezugsfertig
const hasSurcharge = fitOutPerYear > 0
const isWarning = totalPerYear > rentPerYear * 1.3
const disclaimer = hasSurcharge
? `Ausbaukosten nach CRB/BKP-Normen, annuitätisch über ${FITOUT_AMORTIZATION_YEARS} Jahre zu ${RATE_PCT}% p.a. Tatsächliche Kosten je nach Ausbauumfang.`
: null
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<HardHat size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
{!isReadyToMoveIn && (
{hasSurcharge && (
<Chip
label="CRB/BKP Richtwerte"
size="small"
@@ -92,32 +73,32 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
value={chf(rentPerYear)}
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`}
/>
{isReadyToMoveIn ? (
{fitOutByLandlord ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — Ausbau im Mietzins enthalten`}
/>
) : !hasSurcharge ? (
<Row
label="Ausbau"
value="CHF 0."
sub={`${fitOutLabel} — bezugsfertig`}
/>
) : investment?.isFullyCovered ? (
<Row
label="Ausbau (amort.)"
value="CHF 0."
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
/>
) : (
<Row
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
label={`Ausbau (annuit. ${FITOUT_AMORTIZATION_YEARS} J. / ${RATE_PCT}%)`}
value={chf(fitOutPerYear)}
sub={
`CHF ${investment?.grossPerSqm.min}${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
`${fitOutLabel}` +
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
` × ${areaSqm.toLocaleString('de-CH')} ÷ ${AMORTIZATION_YEARS} J.`
` — CHF ${fitOutPerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')}`
}
/>
)}
<Row
label="Total/Jahr"
value={chfRange(totalPerYear.min, totalPerYear.max)}
value={chf(totalPerYear)}
isTotal
isWarning={isWarning}
/>
@@ -6,7 +6,9 @@ import {
import { Send } from 'lucide-react'
import { useToastStore } from '../../stores/toastStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useMoveStage } from '../../hooks/usePipeline'
import { useCreateInquiry } from '../../hooks/useInquiries'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
@@ -19,7 +21,8 @@ export function InquiryQuickDialog() {
const dialogOpen = useInquiryStore(s => s.dialogOpen)
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
const addInquiry = useInquiryStore(s => s.addInquiry)
const currentUser = useSessionStore(s => s.currentUser)
const createInquiry = useCreateInquiry()
const { mutate: moveStage } = useMoveStage()
const [message, setMessage] = useState('')
@@ -32,7 +35,18 @@ export function InquiryQuickDialog() {
function handleSend() {
if (!pendingInquiry) return
addInquiry(pendingInquiry, message)
createInquiry.mutate({
organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa)
tenantOrgId: currentUser?.organizationId ?? 'org-mobimo',
propertyId: pendingInquiry.propertyId ?? 'unknown',
tenantName: currentUser?.name ?? 'Demand User',
tenantCompany: currentUser?.organizationName,
tenantEmail: currentUser?.email,
propertyAddress: pendingInquiry.propertyTitle,
subject: `Anfrage: ${pendingInquiry.propertyTitle}`,
message,
matchScore: pendingInquiry.matchScore,
})
if (pendingInquiry.pipelineItemId) {
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
}
@@ -0,0 +1,70 @@
import { Box, Chip, Paper, Typography } from '@mui/material'
import { TrendingUp } from 'lucide-react'
import { estimateMarketRent, type RentVerdict } from '../../lib/rentEstimate'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
const VERDICT_META: Record<RentVerdict, { label: string; bg: string; border: string; fg: string }> = {
BELOW: { label: 'Unter Markt', bg: DS_SURFACE.success.bg, border: DS_SURFACE.success.border, fg: DS_TEXT.success },
AT: { label: 'Marktkonform', bg: DS_SURFACE.blue.bg, border: DS_SURFACE.blue.border, fg: DS_TEXT.signalDark },
ABOVE: { label: 'Über Markt', bg: DS_SURFACE.warning.bg, border: DS_SURFACE.warning.border, fg: DS_TEXT.warning },
}
interface Props {
city: string
assetType: string
askingRentPerSqm: number
futureRentPerSqm?: number // Pre-Market: erwarteter künftiger Preis
}
function chf(v: number): string {
return `CHF ${Math.round(v).toLocaleString('de-CH')}/m²`
}
export function MarketPricePanel({ city, assetType, askingRentPerSqm, futureRentPerSqm }: Props) {
const est = estimateMarketRent(city, assetType, askingRentPerSqm)
if (!est) return null
const meta = VERDICT_META[est.verdict]
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<TrendingUp size={15} color={DS_TEXT.secondary} />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Marktpreis-Einschätzung</Typography>
<Chip
label={meta.label}
size="small"
sx={{ ml: 'auto', bgcolor: meta.bg, color: meta.fg, fontWeight: 700, fontSize: 11, height: 22, border: `1px solid ${meta.border}` }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 1.25 }}>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Angebotsmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>{chf(est.askingRentPerSqm)}</Typography>
</Box>
<Box sx={{ flex: 1, p: 1.25, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Faire Marktmiete</Typography>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
{chf(est.fairRentPerSqm)}
<Box component="span" sx={{ ml: 0.75, fontSize: 12, fontWeight: 600, color: meta.fg }}>
{est.deltaPct > 0 ? `+${est.deltaPct}%` : `${est.deltaPct}%`}
</Box>
</Typography>
</Box>
</Box>
{futureRentPerSqm != null && futureRentPerSqm > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', py: 1, px: 1.5, mb: 1.25, bgcolor: DS_SURFACE.purple.bg, border: `1px solid ${DS_SURFACE.purple.border}`, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 600 }}>Erwartet (Pre-Market)</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{chf(est.askingRentPerSqm)} <Box component="span" sx={{ color: DS_TEXT.muted }}></Box> {chf(futureRentPerSqm)}
</Typography>
</Box>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block' }}>
{est.rationale}
</Typography>
</Paper>
)
}
+2
View File
@@ -13,4 +13,6 @@ export { SourceProvenancePanel } from './SourceProvenancePanel'
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
export { NextActionsPanel } from './NextActionsPanel'
export { FitOutCostPanel } from './FitOutCostPanel'
export { FitOutAdvicePanel } from './FitOutAdvicePanel'
export { MarketPricePanel } from './MarketPricePanel'
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
@@ -1,11 +1,16 @@
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC'])
interface Props {
floor: string
onFloorChange: (v: string) => void
fitOut: string
onFitOutChange: (v: string) => void
fitOutByLandlord: boolean | undefined
onFitOutByLandlordChange: (v: boolean) => void
parking: string
onParkingChange: (v: string) => void
ceilingHeight: string
@@ -21,12 +26,14 @@ interface Props {
export function TechnicalDetailsSection({
floor, onFloorChange,
fitOut, onFitOutChange,
fitOutByLandlord, onFitOutByLandlordChange,
parking, onParkingChange,
ceilingHeight, onCeilingHeightChange,
mieterausbaubeitrag, onMieterausbaubeitragChange,
isFlexible, onIsFlexibleChange,
minLettableSqm, onMinLettableSqmChange,
}: Props) {
const showFitOutResponsibility = NEEDS_FIT_OUT.has(fitOut)
return (
<Card sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2 }}>Technische Details (optional)</Typography>
@@ -44,14 +51,6 @@ export function TechnicalDetailsSection({
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
))}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" fullWidth
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters"
/>
<TextField
label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)}
@@ -64,6 +63,46 @@ export function TechnicalDetailsSection({
/>
</Box>
{/* Ausbau-Träger — nur relevant, wenn die Fläche noch Ausbau benötigt (Rohbau/Edelrohbau) */}
{showFitOutResponsibility && (
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9' }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive
size="small"
value={fitOutByLandlord === undefined ? null : fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) onFitOutByLandlordChange(v === 'landlord') }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{fitOutByLandlord === undefined ? (
<Typography variant="caption" sx={{ flex: 1, minWidth: 220, mt: 0.5, color: '#b45309' }}>
Pflichtangabe bei Rohbau/Edelrohbau bitte wählen, wer den Ausbau trägt.
</Typography>
) : fitOutByLandlord ? (
<Typography variant="caption" color="text.secondary" sx={{ flex: 1, minWidth: 220, mt: 0.5 }}>
Ausbau im Mietzins enthalten bitte die <strong>bezugsfertige</strong> Miete eintragen.
Es wird kein Aufschlag berechnet.
</Typography>
) : (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
value={mieterausbaubeitrag}
onChange={e => onMieterausbaubeitragChange(e.target.value)}
size="small" type="number" sx={{ width: 240 }}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters — optional, leer = kein Beitrag"
/>
)}
</Box>
</Box>
)}
{/* Divisibility */}
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
@@ -32,7 +32,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: ()
return (
<Box sx={{
width: { xs: '100%', md: 340 }, flexShrink: 0,
width: { xs: '100%', md: 300, lg: 320, xl: 340 }, flexShrink: 0,
display: 'flex', flexDirection: 'column',
bgcolor: 'white', borderLeft: `1px solid ${DS_BORDER.default}`, overflow: 'hidden',
}}>
+1 -11
View File
@@ -125,16 +125,6 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
? result.property.images?.[0]
: undefined
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
? resolveImageLabel({
assetType: result.property.assetType,
city: result.property.location.city,
district: result.property.location.district,
prestige: result.property.softFactors?.prestige,
floorLevel: result.property.floorLevel,
propertyId: result.property.id,
})
: null
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} />
}
+1 -1
View File
@@ -86,7 +86,7 @@ export function UnifiedResultFeed({ results, view = 'list' }: Props) {
))}
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{items.map(result => (
<UnifiedResultCard key={result.matchId} result={result} view="list" />
))}
+2 -2
View File
@@ -20,13 +20,13 @@ export function DashboardHeader({ orgName = 'Demo Organisation', lastUpdated }:
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
{orgName}
</Typography>
<Chip label="Demo" size="small" color="info" variant="outlined" />
</Box>
{lastUpdated && (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
<Typography variant="body2" color="text.secondary">
Zuletzt aktualisiert: {formatLastUpdated(lastUpdated)}
</Typography>
)}
+1
View File
@@ -188,6 +188,7 @@ export function PreMarketPanel({ p }: { p: Property }) {
{(p.units?.length ?? 0) > 0 && (
<PreMarketUnitGrid
property={p}
units={p.units!}
unitStates={unitStates}
unitSaving={unitSaving}
@@ -0,0 +1,170 @@
import { useState } from 'react'
import { Box, Button, Chip, CircularProgress, Divider, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import type { Property, PropertyUnit } from '../../domain/property'
import { usePreMarketRentRecommendation } from '../../hooks/useAI'
import { useUpdateUnit } from '../../hooks/useProperties'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
const VERDICT_META: Record<string, { label: string; bg: string; fg: string }> = {
UNDERPRICED: { label: 'zu günstig', bg: '#fef3c7', fg: '#92400e' },
FAIR: { label: 'marktgerecht', bg: '#dcfce7', fg: '#166534' },
AMBITIOUS: { label: 'ambitioniert', bg: '#fee2e2', fg: '#991b1b' },
}
interface Draft {
fitOut?: string
fitOutByLandlord?: boolean
mieterausbaubeitragPerSqm?: number
parkingSpots?: number
expectedRentPerSqm?: number
}
interface Props {
property: Property
unit: PropertyUnit
}
/** Pre-Market-Konfiguration einer Einheit: erst Ausbau/Parkplätze, dann KI-Preisempfehlung. */
export function PreMarketPriceAdvisor({ property, unit }: Props) {
const updateUnit = useUpdateUnit(property.id)
const [d, setD] = useState<Draft>(() => ({
fitOut: unit.fitOut ?? '',
fitOutByLandlord: unit.fitOutByLandlord,
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
parkingSpots: unit.parkingSpots,
expectedRentPerSqm: unit.expectedRentPerSqm,
}))
const facts = resolveUnitFacts(property, unit)
const current = facts.rentPricePerSqm
const effFit = d.fitOut || property.hardFacts?.fitOut
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
const { data, isLoading, isError } = usePreMarketRentRecommendation({
city: property.location?.city ?? '',
assetType: property.assetType,
areaSqm: unit.areaSqm,
currentRentPerSqm: current,
availableFrom: unit.schattenmarktRelease?.availableFrom,
})
const rec = data?.data
const verdict = rec ? VERDICT_META[rec.verdict] : null
const save = (patch: Partial<PropertyUnit>) => updateUnit.mutate({ unitId: unit.id, data: patch })
return (
<Box sx={{ mt: 0.75, p: 1.25, borderRadius: 1, bgcolor: 'white', border: `1px solid ${DS_SURFACE.purple.border}` }}>
{/* 1) Ausbau & Ausstattung */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 1 }}>
<TextField
select size="small" label="Ausbaustandard"
value={d.fitOut ?? ''}
onChange={e => {
const v = e.target.value
const nb = v === 'SHELL' || v === 'BASIC'
setD(p => ({ ...p, fitOut: v }))
save({ fitOut: (v || undefined) as PropertyUnit['fitOut'], ...(nb ? {} : { fitOutByLandlord: undefined, mieterausbaubeitragPerSqm: undefined }) })
}}
>
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''}</MenuItem>
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
</TextField>
<TextField
label="Parkplätze" type="number" size="small"
value={d.parkingSpots ?? ''}
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
onBlur={e => save({ parkingSpots: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0 } }}
helperText={property.hardFacts?.parking != null ? `Pool: ${property.hardFacts.parking}` : undefined}
/>
</Box>
{needsBuild && (
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1 }}>
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
<ToggleButtonGroup
exclusive size="small"
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => {
if (!v) return
const landlord = v === 'landlord'
setD(p => ({ ...p, fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) }))
save({ fitOutByLandlord: landlord, ...(landlord ? { mieterausbaubeitragPerSqm: undefined } : {}) })
}}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
</ToggleButtonGroup>
{d.fitOutByLandlord === false && (
<TextField
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 140 }}
value={d.mieterausbaubeitragPerSqm ?? ''}
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
onBlur={e => save({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
/>
)}
</Box>
)}
<Divider sx={{ my: 1.25 }} />
{/* 2) KI-Preisempfehlung */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<Sparkles size={12} color={DS_PRE_MARKET.accent} />
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, color: '#5b21b6' }}>KI-Preisempfehlung Pre-Market</Typography>
{verdict && <Chip label={verdict.label} size="small" sx={{ height: 16, fontSize: '0.6rem', fontWeight: 700, bgcolor: verdict.bg, color: verdict.fg, ml: 'auto' }} />}
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<CircularProgress size={12} sx={{ color: DS_PRE_MARKET.accent }} />
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9' }}>Marktanalyse läuft</Typography>
</Box>
) : isError || !rec ? (
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>Keine Empfehlung verfügbar.</Typography>
) : (
<>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: '#5b21b6' }}>
Empfehlung: CHF {rec.recommendedPerSqm}/m²
<Box component="span" sx={{ fontWeight: 400, color: '#6d28d9' }}> (CHF {rec.rangeMinPerSqm}{rec.rangeMaxPerSqm})</Box>
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: '#6d28d9', mb: 0.25 }}>
Heute CHF {current}/m²
<Box component="span" sx={{ fontWeight: 700, ml: 0.5 }}>
{rec.deltaVsCurrentPct > 0 ? `+${rec.deltaVsCurrentPct}%` : `${rec.deltaVsCurrentPct}%`} vs. heute
</Box>
</Typography>
<Typography sx={{ fontSize: '0.6rem', color: '#7c3aed', mb: 0.625 }}>
{rec.drivers.join(' · ')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<TextField
type="number" size="small" label="Pre-Market-Preis"
placeholder={`z.B. ${rec.recommendedPerSqm}`}
value={d.expectedRentPerSqm ?? ''}
onChange={e => setD(p => ({ ...p, expectedRentPerSqm: parseInt(e.target.value) || undefined }))}
onBlur={e => save({ expectedRentPerSqm: parseInt(e.target.value) || undefined })}
slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: 0, step: 10 } }}
sx={{ width: 150, '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
/>
<Button
size="small"
onClick={() => { setD(p => ({ ...p, expectedRentPerSqm: rec.recommendedPerSqm })); save({ expectedRentPerSqm: rec.recommendedPerSqm }) }}
sx={{ textTransform: 'none', fontSize: '0.68rem', color: DS_PRE_MARKET.accent }}
>
Empfehlung übernehmen
</Button>
</Box>
</>
)}
</Box>
)
}
+123 -89
View File
@@ -1,8 +1,13 @@
import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff } from 'lucide-react'
import { useState } from 'react'
import { Box, CircularProgress, Collapse, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff, Pencil } from 'lucide-react'
import type { Property } from '../../domain/property'
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
import { DS_BORDER, DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { floorLabel } from './PropertyDetailHelpers'
import { UnitFieldsEditor } from './UnitFieldsEditor'
import { PreMarketPriceAdvisor } from './PreMarketPriceAdvisor'
type PropertyUnit = NonNullable<Property['units']>[number]
@@ -13,6 +18,7 @@ export interface UnitReleaseState {
}
interface Props {
property: Property
units: PropertyUnit[]
unitStates: Record<string, UnitReleaseState>
unitSaving: Record<string, boolean>
@@ -20,7 +26,9 @@ interface Props {
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
}
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
export function PreMarketUnitGrid({ property, units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
const [editing, setEditing] = useState<string | null>(null)
return (
<Box sx={{ mt: 1.25, pt: 1.25, borderTop: `1px solid ${DS_PRE_MARKET.border}` }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#4c1d95', textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.63rem', display: 'block', mb: 0.875 }}>
@@ -28,100 +36,126 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
</Typography>
{units.map(u => {
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '', anonymous: false }
const f = resolveUnitFacts(property, u)
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
const detailParts: string[] = []
if (fitLabel) detailParts.push(fitLabel)
if (needsBuild) detailParts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
if (f.parkingSpots != null) detailParts.push(`${f.parkingSpots} PP`)
detailParts.push(u.expectedRentPerSqm ? `erwartet CHF ${u.expectedRentPerSqm}/m²` : `CHF ${f.rentPricePerSqm}/m²`)
return (
<Box
key={u.id}
sx={{
display: 'grid', gridTemplateColumns: '1fr 140px auto',
gap: 1, alignItems: 'start', py: 0.875,
borderBottom: '1px solid #f3e8ff',
'&:last-child': { borderBottom: 'none' },
}}
>
{/* Unit info + anonym toggle (shown when enabled) */}
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
{u.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{us.enabled && (
<Tooltip title="Adresse und Objektname werden im Matching-Feed ausgeblendet — Interessenten sehen nur Fläche, Typ und Standortregion">
<Box
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.625,
px: 0.75, py: 0.25, borderRadius: 1,
border: '1px solid',
borderColor: us.anonymous ? '#ddd6fe' : '#e2e8f0',
bgcolor: us.anonymous ? '#f5f3ff' : 'transparent',
cursor: 'pointer',
transition: 'background 0.15s, border-color 0.15s',
}}
onClick={() => {
const next = { ...us, anonymous: !us.anonymous }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous)
}}
>
<EyeOff size={11} color={us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8'} />
<Typography sx={{ fontSize: '0.62rem', fontWeight: us.anonymous ? 700 : 400, color: us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8', lineHeight: 1 }}>
Anonym
</Typography>
<Switch
size="small"
checked={us.anonymous}
onChange={(_, checked) => {
const next = { ...us, anonymous: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, checked)
}}
onClick={e => e.stopPropagation()}
<Box key={u.id} sx={{ borderBottom: '1px solid #f3e8ff', '&:last-child': { borderBottom: 'none' }, py: 0.875 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 140px auto', gap: 1, alignItems: 'start' }}>
{/* Unit info + anonym toggle (shown when enabled) */}
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
{u.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{us.enabled && (
<Tooltip title="Adresse und Objektname werden im Matching-Feed ausgeblendet — Interessenten sehen nur Fläche, Typ und Standortregion">
<Box
sx={{
width: 28, height: 16, p: 0,
'& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } },
'& .MuiSwitch-thumb': { width: 12, height: 12 },
'& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.625,
px: 0.75, py: 0.25, borderRadius: 1,
border: '1px solid',
borderColor: us.anonymous ? '#ddd6fe' : '#e2e8f0',
bgcolor: us.anonymous ? '#f5f3ff' : 'transparent',
cursor: 'pointer',
transition: 'background 0.15s, border-color 0.15s',
}}
/>
</Box>
</Tooltip>
)}
</Box>
onClick={() => {
const next = { ...us, anonymous: !us.anonymous }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous)
}}
>
<EyeOff size={11} color={us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8'} />
<Typography sx={{ fontSize: '0.62rem', fontWeight: us.anonymous ? 700 : 400, color: us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8', lineHeight: 1 }}>
Anonym
</Typography>
<Switch
size="small"
checked={us.anonymous}
onChange={(_, checked) => {
const next = { ...us, anonymous: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, checked)
}}
onClick={e => e.stopPropagation()}
sx={{
width: 28, height: 16, p: 0,
'& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } },
'& .MuiSwitch-thumb': { width: 12, height: 12 },
'& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
}}
/>
</Box>
</Tooltip>
)}
</Box>
{/* Available-from date */}
<TextField
type="date"
size="small"
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous)
}}
/>
{/* Enabled toggle */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
<Switch
{/* Available-from date */}
<TextField
type="date"
size="small"
checked={us.enabled}
onChange={(_, checked) => {
const next = { ...us, enabled: checked }
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, checked, us.availableFrom, us.anonymous)
}}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous)
}}
/>
{/* Edit (nur vor Freigabe — danach inline) + enabled toggle */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, mt: 0.25 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
{!us.enabled && (
<Tooltip title="Ausbau, Preis & Parkplätze dieser Einheit bearbeiten">
<IconButton size="small" onClick={() => setEditing(editing === u.id ? null : u.id)} sx={{ p: 0.25, color: editing === u.id ? DS_PRE_MARKET.accent : DS_TEXT.disabled }}>
<Pencil size={12} />
</IconButton>
</Tooltip>
)}
<Switch
size="small"
checked={us.enabled}
onChange={(_, checked) => {
const next = { ...us, enabled: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, checked, us.availableFrom, us.anonymous)
}}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
}}
/>
</Box>
</Box>
{/* Vor Freigabe: Lese-Details + Stift-Editor. Nach Freigabe: volle Inline-Konfiguration. */}
{us.enabled ? (
<PreMarketPriceAdvisor property={property} unit={u} />
) : (
<>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.62rem', display: 'block', mt: 0.25 }}>
{detailParts.join(' · ')}
</Typography>
<Collapse in={editing === u.id}>
<Box sx={{ border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, mt: 0.75, overflow: 'hidden' }}>
<UnitFieldsEditor property={property} unit={u} onClose={() => setEditing(null)} />
</Box>
</Collapse>
</>
)}
</Box>
)
})}
@@ -1,4 +1,4 @@
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { ExternalLink, FileText } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property'
import { PropertyMap } from '../shared'
@@ -6,6 +6,11 @@ import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
import { Field, FieldGrid, SectionTitle } from './PropertyDetailHelpers'
import { UnitStructurePanel } from './UnitStructurePanel'
import { PreMarketPanel } from './PreMarketPanel'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
// Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant
const FIT_OUT_NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
interface PropertyDetailOverviewProps {
p: Property
@@ -15,16 +20,38 @@ interface PropertyDetailOverviewProps {
}
export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: PropertyDetailOverviewProps) {
const rentLabel = p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
// Mehr-Einheiten-Objekt: Preis als Spanne, Fläche mit Anzahl, Verfügbarkeit pro Einheit
const units = p.units ?? []
const isMultiUnit = units.length > 1
const unitPrices = units.map(u => u.rentPricePerSqm ?? p.rentPricePerSqm).filter(v => v > 0)
const priceMin = unitPrices.length ? Math.min(...unitPrices) : p.rentPricePerSqm
const priceMax = unitPrices.length ? Math.max(...unitPrices) : p.rentPricePerSqm
const rentLabel = isMultiUnit && priceMin !== priceMax
? `CHF ${priceMin.toLocaleString('de-CH')}${priceMax.toLocaleString('de-CH')}`
: p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : undefined
const areaLabel = isMultiUnit
? `${p.areaSqm.toLocaleString('de-CH')} m² · ${units.length} Einheiten`
: `${p.areaSqm.toLocaleString('de-CH')}`
const availLabel = isMultiUnit
? 'pro Einheit ↓'
: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined
// Effektive (Draft-überlagerte) hardFacts für den Bearbeiten-Modus
const hf = draft.hardFacts ?? p.hardFacts ?? {}
const editFitOut = hf.fitOut ?? ''
const editByLandlord = hf.fitOutByLandlord
const showBuildResponsibility = FIT_OUT_NEEDS_BUILD.has(editFitOut)
const patchHF = (patch: Partial<NonNullable<Property['hardFacts']>>) =>
onDraftChange({ ...draft, hardFacts: { ...hf, ...patch } })
return (
<Box>
{/* Key metrics */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 0.75 }}>
{[
{ label: 'Fläche', value: `${p.areaSqm.toLocaleString('de-CH')}` },
{ label: 'CHF/m²/Jahr', value: rentLabel },
{ label: 'Verfügbar ab', value: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : undefined },
{ label: 'Fläche', value: areaLabel },
{ label: isMultiUnit ? 'CHF/m²/Jahr (Spanne)' : 'CHF/m²/Jahr', value: rentLabel },
{ label: 'Verfügbar ab', value: availLabel },
].map(({ label, value }) => (
<Box key={label} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
@@ -132,54 +159,78 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
{/* Object details */}
<SectionTitle title="Objekt & Lage" />
{editing ? (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
<TextField
select label="Ausbaustandard"
size="small" fullWidth
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
>
{[
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basisausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters zum Ausbau"
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Parkplätze"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0 } }}
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
/>
<TextField
label="Deckenhöhe (m)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
/>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<TextField
select label="Ausbaustandard"
size="small" fullWidth
value={editFitOut}
onChange={e => patchHF({ fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined })}
>
{FIT_OUT_OPTIONS.map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<TextField
label="Parkplätze"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { min: 0 } }}
value={hf.parking ?? ''}
onChange={e => patchHF({ parking: parseInt(e.target.value) || undefined })}
/>
<TextField
label="Deckenhöhe (m)"
size="small" fullWidth type="number"
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
value={hf.ceilingHeightM ?? ''}
onChange={e => patchHF({ ceilingHeightM: parseFloat(e.target.value) || undefined })}
/>
</Box>
{showBuildResponsibility && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Wer baut aus? <Box component="span" sx={{ color: '#dc2626' }}>*</Box>
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<ToggleButtonGroup
exclusive size="small"
value={editByLandlord === undefined ? null : editByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) patchHF({ fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) }) }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter übernimmt</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter baut aus</ToggleButton>
</ToggleButtonGroup>
{editByLandlord === undefined && (
<Typography variant="caption" sx={{ color: '#b45309' }}>
Pflichtangabe bei Rohbau/Edelrohbau
</Typography>
)}
{editByLandlord === false && (
<TextField
label="Mieterausbaubeitrag (CHF/m²)"
size="small" type="number" sx={{ width: 240 }}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
helperText="Beitrag des Vermieters — optional"
value={hf.mieterausbaubeitragPerSqm ?? ''}
onChange={e => patchHF({ mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined })}
/>
)}
</Box>
</Box>
)}
</Box>
) : (
<FieldGrid>
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
{p.hardFacts?.mieterausbaubeitragPerSqm && (
<Field label={isMultiUnit ? 'Ausbaustandard (Objekt-Standard)' : 'Ausbaustandard'} value={p.hardFacts?.fitOut ? (FIT_OUT_LABELS[p.hardFacts.fitOut] ?? p.hardFacts.fitOut) : undefined} />
{p.hardFacts?.fitOut && FIT_OUT_NEEDS_BUILD.has(p.hardFacts.fitOut) && (
<Field label="Ausbau-Träger" value={p.hardFacts.fitOutByLandlord ? 'Vermieter (im Mietzins)' : 'Mieter'} />
)}
{p.hardFacts?.mieterausbaubeitragPerSqm && !p.hardFacts.fitOutByLandlord && (
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
)}
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label={isMultiUnit ? 'Parkplätze (Objekt-Pool)' : 'Parkplätze'} value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
<Field label="Importiert aus" value={p.importedFrom} />
@@ -51,6 +51,13 @@ export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: Prope
function saveEdit() {
if (!property) return
// Träger-Pflicht bei Rohbau/Edelrohbau (SHELL/BASIC)
const effFitOut = draft.hardFacts?.fitOut ?? property.hardFacts?.fitOut
const effByLandlord = draft.hardFacts?.fitOutByLandlord ?? property.hardFacts?.fitOutByLandlord
if ((effFitOut === 'SHELL' || effFitOut === 'BASIC') && effByLandlord === undefined) {
showToast('Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter).', 'error')
return
}
updateProperty.mutate(
{ id: property.id, input: draft },
{
@@ -43,19 +43,26 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
<Box
onClick={() => onSelect(p.id)}
sx={{
borderRadius: 1,
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${DS_BORDER.default}`,
bgcolor: '#ffffff',
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
transition: 'border-color 0.15s',
'&:hover': { borderColor: '#c8c7c4' },
position: 'relative',
zIndex: 0,
transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s',
'&:hover': {
borderColor: '#b0aead',
transform: 'scale(1.025)',
boxShadow: '0 10px 32px rgba(0,0,0,0.13)',
zIndex: 1,
},
}}
>
{/* ── Image — clean, no text overlay ── */}
<Box sx={{ position: 'relative', height: 150, flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
<Box sx={{ position: 'relative', aspectRatio: '15/8', flexShrink: 0, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
{hasImage ? (
<Box component="img" src={p.images![0]} alt={p.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
@@ -78,24 +85,24 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
</Box>
{/* ── Body ── */}
<Box sx={{ px: 1.75, pt: 1.5, pb: 1.75, display: 'flex', flexDirection: 'column', gap: 1.125, flex: 1 }}>
<Box sx={{ px: 2.5, pt: 2.25, pb: 2.5, display: 'flex', flexDirection: 'column', gap: 2, flex: 1 }}>
{/* Title + location + status inline */}
<Box>
<Typography sx={{ fontWeight: 600, fontSize: '0.9375rem', color: DS_TEXT.primary, lineHeight: 1.3 }} noWrap>
<Typography sx={{ fontWeight: 600, fontSize: '1.0625rem', color: DS_TEXT.primary, lineHeight: 1.3 }} noWrap>
{p.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 0.6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
<MapPin size={11} color="#9a9a9a" />
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted }}>
<MapPin size={12} color="#9a9a9a" />
<Typography sx={{ fontSize: '0.8125rem', color: DS_TEXT.muted }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
</Box>
{/* Status — plain dot + text, no pill container */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: status.dot, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.8rem', fontWeight: 500, color: status.text, lineHeight: 1 }}>
{status.label}
{p.availabilityStatus === 'AVAILABLE_SOON' && availDate ? ` · ab ${availDate}` : ''}
</Typography>
@@ -104,62 +111,58 @@ export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({
</Box>
{/* 3 key metrics — no boxes, whitespace only */}
<Box sx={{ display: 'flex', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 2.5 }}>
<Box>
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Fläche</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Fläche</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
{p.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Box>
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Miete</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: DS_TEXT.primary }}>
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Miete</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: DS_TEXT.primary }}>
{p.rentPricePerSqm}
<Typography component="span" sx={{ fontSize: '0.62rem', fontWeight: 400, color: DS_TEXT.muted }}> CHF/m²/J</Typography>
<Typography component="span" sx={{ fontSize: '0.7rem', fontWeight: 400, color: DS_TEXT.muted }}> CHF/m²/J</Typography>
</Typography>
</Box>
<Tooltip title={hasInquiries ? `${inquiryCount} aktive Anfragen` : 'Noch keine Anfragen'} placement="top" arrow>
<Box>
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.muted, mb: 0.25 }}>Anfragen</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '0.875rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
<Typography sx={{ fontSize: '0.75rem', color: DS_TEXT.muted, mb: 0.35 }}>Anfragen</Typography>
<Typography sx={{ fontWeight: 600, fontSize: '1rem', color: hasInquiries ? '#a16207' : DS_TEXT.muted }}>
{inquiryCount ?? 0}
</Typography>
</Box>
</Tooltip>
</Box>
{/* Property spec chips */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{p.hardFacts?.fitOut && (() => {
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
return (
<Tooltip title={meta.tip} placement="top" arrow>
<Chip label={meta.label} size="small"
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
</Tooltip>
)
})()}
{p.contractDurationMonths && (
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 20, fontSize: '0.68rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
)}
</Box>
{/* Data completeness */}
<Box sx={{ mt: 'auto' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', color: DS_TEXT.disabled, letterSpacing: '0.03em' }}>
Datenvollständigkeit
</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
{/* Chips + Datenvollständigkeit in einer Zeile */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mt: 'auto' }}>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
{p.hardFacts?.fitOut && (() => {
const meta = FIT_OUT_META[p.hardFacts!.fitOut!] ?? { label: p.hardFacts!.fitOut!, tip: '' }
return (
<Tooltip title={meta.tip} placement="top" arrow>
<Chip label={meta.label} size="small"
sx={{ height: 22, fontSize: '0.73rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}`, cursor: 'help' }} />
</Tooltip>
)
})()}
{p.contractDurationMonths && (
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 22, fontSize: '0.73rem', bgcolor: '#f4f3f0', color: DS_TEXT.secondary, border: `1px solid ${DS_BORDER.default}` }} />
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.68rem', color: DS_TEXT.disabled }}>Daten</Typography>
<LinearProgress variant="determinate" value={confPct}
sx={{ width: 44, height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
<Typography sx={{ fontSize: '0.78rem', fontWeight: 700, color: p.confidenceScore >= 0.75 ? '#b8975a' : DS_TEXT.muted }}>
{confPct}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={confPct}
sx={{ height: 3, borderRadius: 1, bgcolor: '#f4f3f0',
'& .MuiLinearProgress-bar': { bgcolor: '#152642', borderRadius: 1 } }} />
</Box>
</Box>
</Box>
+285 -157
View File
@@ -1,26 +1,185 @@
import { useState } from 'react'
import {
Drawer,
Box,
Typography,
IconButton,
Chip,
Divider,
TextField,
Button,
Switch,
FormControlLabel,
Tooltip,
Drawer, Box, Typography, IconButton, Chip, Divider,
TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete,
} from '@mui/material'
import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore'
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
import { usePropertyById } from '../../hooks/useProperties'
import {
useReminder, useCompleteReminder, useDismissReminder,
useSnoozeReminder, useCreateReminder,
} from '../../hooks/useReminders'
import { usePropertyById, useProperties } from '../../hooks/useProperties'
import { ReminderPriorityBadge } from './ReminderPriorityBadge'
import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { ReminderStatus } from '../../domain/reminder'
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
import { ReminderStatus, ReminderType, ReminderPriority } from '../../domain/reminder'
import type { Property } from '../../domain/property'
import {
SHADOW_RISK_COLOR, SHADOW_RISK_LABEL,
STATUS_CHIP_COLOR, STATUS_LABEL,
SectionTitle, DateRow, ActivityEntry,
} from './reminderDetailHelpers'
// ── Constants ────────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<ReminderType, string> = {
LEASE_EXPIRY: 'Mietablauf',
BREAK_OPTION: 'Break-Option',
RENT_REVIEW: 'Mietanpassung',
INSPECTION: 'Inspektion',
INSURANCE_RENEWAL: 'Versicherung',
MAINTENANCE: 'Unterhalt',
SCHATTENMARKT_RELEASE: 'Pre-Market',
CUSTOM: 'Individuell',
}
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
[ReminderType.LEASE_EXPIRY]: 'contractEndDate',
[ReminderType.BREAK_OPTION]: 'breakOptionDate',
[ReminderType.RENT_REVIEW]: 'eventDate',
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
}
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
[ReminderType.LEASE_EXPIRY]: 'Vertragsende',
[ReminderType.BREAK_OPTION]: 'Break-Option',
[ReminderType.RENT_REVIEW]: 'Ereignisdatum',
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
}
const MOCK_TODAY = new Date('2026-05-20')
function calcPriority(dueDateStr: string): ReminderPriority {
const days = Math.ceil((new Date(dueDateStr).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
if (days <= 14) return ReminderPriority.URGENT
if (days <= 30) return ReminderPriority.HIGH
if (days <= 60) return ReminderPriority.MEDIUM
return ReminderPriority.LOW
}
// ── Create Form ───────────────────────────────────────────────────────────────
function CreateForm({ onClose }: { onClose: () => void }) {
const { data: properties = [] } = useProperties()
const create = useCreateReminder()
const [type, setType] = useState<ReminderType>(ReminderType.LEASE_EXPIRY)
const [selectedProperty, setSelectedProperty] = useState<Property | null>(null)
const [tenantName, setTenantName] = useState('')
const [dueDate, setDueDate] = useState('')
const [note, setNote] = useState('')
function handlePropertyChange(_: unknown, prop: Property | null) {
setSelectedProperty(prop)
setTenantName(prop?.currentTenant ?? '')
}
function handleSubmit() {
if (!selectedProperty || !dueDate) return
create.mutate(
{
type,
priority: calcPriority(dueDate),
status: ReminderStatus.ACTIVE,
propertyId: selectedProperty.id,
propertyTitle: selectedProperty.title,
propertyCity: selectedProperty.location.city,
propertyDistrict: selectedProperty.location.district,
tenantName: tenantName || '—',
dueDate,
eventDate: dueDate,
areaSqm: selectedProperty.areaSqm,
currentRentPerSqm: selectedProperty.rentPricePerSqm,
currency: 'CHF',
shadowMarketRisk: 'NONE',
schattenmarktEnabled: false,
note: note || undefined,
organizationId: selectedProperty.organizationId ?? 'org-1',
},
{ onSuccess: onClose },
)
}
const canSubmit = !!selectedProperty && !!dueDate && !create.isPending
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Typ */}
<FormControl size="small" fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={type}
label="Typ"
onChange={e => setType(e.target.value as ReminderType)}
>
{Object.values(ReminderType).map(t => (
<MenuItem key={t} value={t}>{TYPE_LABELS[t]}</MenuItem>
))}
</Select>
</FormControl>
{/* Objekt */}
<Autocomplete
options={properties}
getOptionLabel={p => p.title}
value={selectedProperty}
onChange={handlePropertyChange}
size="small"
renderInput={params => (
<TextField {...params} label="Objekt" placeholder="Objekt auswählen…" />
)}
/>
{/* Mieter — nur wenn Objekt ausgewählt */}
{selectedProperty && (
<TextField
label="Mieter"
size="small"
fullWidth
value={tenantName}
onChange={e => setTenantName(e.target.value)}
placeholder="Mietername eingeben…"
/>
)}
{/* Fälligkeitsdatum */}
<TextField
label="Fälligkeitsdatum"
type="date"
size="small"
fullWidth
value={dueDate}
onChange={e => setDueDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
{/* Notiz */}
<TextField
label="Notiz (optional)"
multiline
rows={3}
size="small"
fullWidth
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Kontext oder Hinweise…"
/>
<Button
variant="contained"
fullWidth
disabled={!canSubmit}
onClick={handleSubmit}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
{create.isPending ? 'Wird erstellt…' : 'Reminder erstellen'}
</Button>
</Box>
)
}
// ── Main Drawer ───────────────────────────────────────────────────────────────
export function ReminderDetailDrawer() {
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
@@ -38,74 +197,79 @@ export function ReminderDetailDrawer() {
setSelectedId(null)
}
const isNew = !selectedId
const isCreateMode = !selectedId
const secondDateKey = reminder ? TYPE_SECOND_DATE[reminder.type] : undefined
const secondDateLabel = reminder ? TYPE_SECOND_LABEL[reminder.type] : undefined
const secondDateValue = secondDateKey && reminder ? reminder[secondDateKey] : undefined
const showSecondDate = secondDateValue && reminder && secondDateValue !== reminder.dueDate
const showPreMarket = reminder &&
reminder.shadowMarketRisk !== 'NONE' &&
reminder.shadowMarketRisk !== 'LOW'
const isActionable = reminder &&
(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED)
return (
<Drawer
anchor="right"
open={drawerOpen}
onClose={handleClose}
slotProps={{ paper: { sx: { width: 480, display: 'flex', flexDirection: 'column' } } }}
slotProps={{ paper: { sx: { width: 460, display: 'flex', flexDirection: 'column' } } }}
>
{/* Header */}
<Box
sx={{
px: 2.5,
py: 2,
borderBottom: '1px solid #e2e8f0',
display: 'flex',
alignItems: 'flex-start',
gap: 1,
flexShrink: 0,
}}
>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
{isNew ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Neuer Reminder</Typography>
) : reminder ? (
<>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<ReminderTypeBadge type={reminder.type} />
<ReminderPriorityBadge priority={reminder.priority} />
<Chip
label={STATUS_LABEL[reminder.status]}
color={STATUS_CHIP_COLOR[reminder.status]}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Box>
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</>
) : null}
{/* ── Header ── */}
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 }}>
{isCreateMode ? (
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
Neuer Reminder
</Typography>
) : reminder ? (
<>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<ReminderTypeBadge type={reminder.type} />
<ReminderPriorityBadge priority={reminder.priority} />
<Chip
label={STATUS_LABEL[reminder.status]}
color={STATUS_CHIP_COLOR[reminder.status]}
size="small"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</Box>
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</>
) : null}
</Box>
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0, color: '#94a3b8' }}>
<X size={18} />
</IconButton>
</Box>
<IconButton size="small" onClick={handleClose} sx={{ flexShrink: 0 }}>
<X size={18} />
</IconButton>
</Box>
{/* Scrollable body */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }} className="flex flex-col gap-5">
{isNew && (
<Typography variant="body2" color="text.secondary">
Neue Reminder-Erstellung noch nicht implementiert.
</Typography>
)}
{/* ── Body ── */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5, display: 'flex', flexDirection: 'column' }}>
{/* Create mode */}
{isCreateMode && <CreateForm onClose={handleClose} />}
{/* Detail mode */}
{reminder && (
<>
{/* 2. Property */}
<Box>
<SectionTitle icon={<MapPin size={15} color="#64748b" />}>Objekt</SectionTitle>
<Box className="flex flex-col gap-1">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{reminder.propertyTitle}</Typography>
<Typography variant="caption" color="text.secondary">
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}
</Typography>
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</Typography>
<Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* 1. Objekt */}
<Box sx={{ mb: 2.5 }}>
<Typography variant="body1" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25 }}>
{reminder.propertyTitle}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
{reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''}&nbsp;·&nbsp;{reminder.tenantName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{reminderProperty?.leaseContractUrl ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
<FileText size={12} color="#152642" />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#64748b" />
<Button
size="small"
variant="outlined"
@@ -113,94 +277,58 @@ export function ReminderDetailDrawer() {
href={reminderProperty.leaseContractUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#152642' }}
sx={{
textTransform: 'none', fontSize: '0.68rem',
py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#334155',
}}
>
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<FileText size={12} color="#cbd5e1" />
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Vertrag</Typography>
</Box>
)}
{showPreMarket && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
<Eye size={11} color={SHADOW_RISK_COLOR[reminder.shadowMarketRisk]} />
<Typography variant="caption" sx={{
color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
fontWeight: 600, fontSize: '0.68rem',
}}>
Pre-Mkt: {SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
</Typography>
</Box>
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 3. Dates */}
<Box>
<SectionTitle icon={<Calendar size={15} color="#64748b" />}>Daten &amp; Fristen</SectionTitle>
<Box className="flex flex-col gap-1.5">
<DateRow label="Fälligkeitsdatum" value={reminder.dueDate} />
<DateRow label="Ereignisdatum" value={reminder.eventDate} />
<DateRow label="Vertragsende" value={reminder.contractEndDate} />
<DateRow label="Break-Option" value={reminder.breakOptionDate} />
{/* 2. Fristen */}
<Box sx={{ mb: 2.5 }}>
<SectionTitle icon={<Calendar size={14} color="#64748b" />}>Fristen</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<DateRow label="Fälligkeit" value={reminder.dueDate} />
{showSecondDate && (
<DateRow label={secondDateLabel!} value={secondDateValue} />
)}
{reminder.snoozedUntil && (
<DateRow label="Schlummern bis" value={reminder.snoozedUntil} />
<DateRow label="Schlummert bis" value={reminder.snoozedUntil} />
)}
</Box>
</Box>
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 4. Financials */}
<Box>
<SectionTitle icon={<DollarSign size={15} color="#64748b" />}>Finanzen</SectionTitle>
<Box className="flex flex-col gap-1.5">
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Miete/m²</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.currency} {reminder.currentRentPerSqm.toLocaleString('de-CH')} / Monat
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1 }}>
<Typography variant="caption" color="text.secondary">Total / Monat</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{reminder.currency} {(reminder.currentRentPerSqm * reminder.areaSqm).toLocaleString('de-CH')}
</Typography>
</Box>
</Box>
</Box>
<Divider />
{/* 5. Pre-Market */}
<Box>
<SectionTitle icon={<Eye size={15} color="#64748b" />}>Pre-Market Risiko</SectionTitle>
<Box className="flex flex-col gap-2">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: SHADOW_RISK_COLOR[reminder.shadowMarketRisk],
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600, color: SHADOW_RISK_COLOR[reminder.shadowMarketRisk] }}>
{SHADOW_RISK_LABEL[reminder.shadowMarketRisk]}
</Typography>
</Box>
<Tooltip title="Nur Anzeige — Änderung über Objektverwaltung">
<FormControlLabel
control={<Switch checked={reminder.schattenmarktEnabled} size="small" readOnly />}
label={
<Typography variant="caption">
Pre-Market aktiviert
</Typography>
}
/>
</Tooltip>
</Box>
</Box>
<Divider />
{/* 6. Note */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>Notiz</Typography>
{/* 3. Notiz */}
<Box sx={{ mb: isActionable ? 0 : 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, fontSize: '0.8125rem' }}>
Notiz
</Typography>
<TextField
multiline
rows={3}
@@ -213,24 +341,24 @@ export function ReminderDetailDrawer() {
/>
</Box>
{/* Actions */}
{(reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) && (
{/* 4. Aktionen */}
{isActionable && (
<>
<Divider />
<Box className="flex flex-col gap-2">
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>Aktionen</Typography>
<Box className="flex gap-2">
<Divider sx={{ my: 2.5 }} />
<Box sx={{ mb: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.8125rem' }}>
Aktionen
</Typography>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
size="small"
variant="contained"
color="success"
sx={{ textTransform: 'none', flex: 1 }}
sx={{ textTransform: 'none', flex: 1, fontWeight: 600 }}
onClick={() => complete.mutate({ id: reminder.id, note: noteValue || undefined })}
>
Erledigt
</Button>
<Button
size="small"
variant="outlined"
color="error"
sx={{ textTransform: 'none', flex: 1 }}
@@ -239,7 +367,7 @@ export function ReminderDetailDrawer() {
Verwerfen
</Button>
</Box>
<Box className="flex gap-2 items-center">
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="date"
size="small"
@@ -248,10 +376,10 @@ export function ReminderDetailDrawer() {
sx={{ flex: 1, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<Button
size="small"
variant="outlined"
size="small"
disabled={!snoozeDate}
sx={{ textTransform: 'none', whiteSpace: 'nowrap' }}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }}
onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })}
>
Schlummern bis
@@ -261,18 +389,18 @@ export function ReminderDetailDrawer() {
</>
)}
<Divider />
<Divider sx={{ mb: 2.5 }} />
{/* 7. Activity */}
{/* 5. Verlauf */}
<Box>
<SectionTitle icon={<Activity size={15} color="#64748b" />}>Aktivitätslog</SectionTitle>
<SectionTitle icon={<Activity size={14} color="#64748b" />}>Verlauf</SectionTitle>
<Box>
{[...reminder.activity].reverse().map((entry, i) => (
<ActivityEntry key={i} entry={entry} />
))}
</Box>
</Box>
</>
</Box>
)}
</Box>
</Drawer>
+2 -2
View File
@@ -28,7 +28,7 @@ const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: st
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e8e7e4' },
]
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
const LIST_COLS = '90px 130px 1fr 140px 80px 90px'
function applyFilters(
reminders: Reminder[],
@@ -141,7 +141,7 @@ export function ReminderFeed() {
borderBottom: '1px solid #e2e8f0',
}}
>
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Fläche', 'Status', 'Aktionen'].map(h => (
{['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Status', 'Aktionen'].map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.7rem' }}>
{h}
</Typography>
+3 -4
View File
@@ -14,17 +14,16 @@ export function ReminderHeader() {
<Box
sx={{
bgcolor: 'white',
borderBottom: '1px solid',
borderColor: 'divider',
borderBottom: '1px solid #e8e7e4',
px: 3,
py: 2,
py: 2.5,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1e293b' }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>
Reminder Manager
</Typography>
<Typography variant="body2" color="text.secondary">
+106 -64
View File
@@ -6,7 +6,7 @@ import { useReminderStore } from '../../stores/reminderStore'
import type { FilterHorizon } from '../../stores/reminderStore'
import { ReminderType } from '../../domain/reminder'
interface KpiCardProps {
interface SecondaryKpiProps {
icon: React.ReactNode
label: string
value: number | string
@@ -15,35 +15,34 @@ interface KpiCardProps {
onClick: () => void
}
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
function SecondaryKpi({ icon, label, value, color, active, onClick }: SecondaryKpiProps) {
return (
<Box
onClick={onClick}
sx={{
flex: 1,
px: 1.5,
py: 1.125,
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 1,
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
borderRadius: 1.5,
bgcolor: active ? `${color}08` : 'white',
flexDirection: 'column',
gap: 0.25,
cursor: 'pointer',
borderRadius: 1.5,
border: active ? `1.5px solid ${color}` : '1px solid #e8e7e4',
bgcolor: active ? `${color}08` : 'white',
transition: 'border-color 0.15s, background 0.15s',
minWidth: 0,
'&:hover': { bgcolor: `${color}06`, borderColor: color },
}}
>
<Box sx={{ opacity: active ? 1 : 0.55, flexShrink: 0 }}>{icon}</Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, color, lineHeight: 1.1, fontSize: '1.1rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ opacity: active ? 1 : 0.5, flexShrink: 0 }}>{icon}</Box>
<Typography sx={{ fontWeight: 700, color, fontSize: '1.25rem', lineHeight: 1 }}>
{value}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', whiteSpace: 'nowrap', fontSize: '0.68rem' }}>
{label}
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{label}
</Typography>
</Box>
)
}
@@ -60,69 +59,112 @@ export function ReminderKpiBar() {
)
function toggleHorizon(h: FilterHorizon) {
if (filterHorizon === h) {
setFilterHorizon('ALL')
} else {
setFilterHorizon(h)
setFilterType('ALL') // clear type filter so count matches
}
if (filterHorizon === h) setFilterHorizon('ALL')
else { setFilterHorizon(h); setFilterType('ALL') }
}
function togglePreMarket() {
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
setFilterType('ALL')
} else {
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
setFilterHorizon('ALL') // clear horizon filter so count matches
}
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) setFilterType('ALL')
else { setFilterType(ReminderType.SCHATTENMARKT_RELEASE); setFilterHorizon('ALL') }
}
if (isLoading) {
return (
<Box className="flex gap-3">
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} variant="rounded" height={68} sx={{ flex: 1 }} />
))}
<Box sx={{ display: 'flex', gap: 2 }}>
<Skeleton variant="rounded" height={76} sx={{ flex: '0 0 220px' }} />
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
{[1, 2, 3].map(i => <Skeleton key={i} variant="rounded" height={76} sx={{ flex: 1 }} />)}
</Box>
</Box>
)
}
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
const isOverdue = ins.overdueCount > 0
const overdueActive = filterHorizon === 'OVERDUE'
return (
<Box className="flex gap-3">
<KpiCard
icon={<AlertOctagon size={18} color="#dc2626" />}
label="Überfällig"
value={ins.overdueCount}
color="#dc2626"
active={filterHorizon === 'OVERDUE'}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'stretch' }}>
{/* Focal card — Überfällig */}
<Box
onClick={() => toggleHorizon('OVERDUE')}
/>
<KpiCard
icon={<Calendar size={18} color="#ea580c" />}
label="Diese Woche"
value={ins.dueThisWeek}
color="#ea580c"
active={filterHorizon === 'THIS_WEEK'}
onClick={() => toggleHorizon('THIS_WEEK')}
/>
<KpiCard
icon={<CalendarDays size={18} color="#0369a1" />}
label="Dieser Monat"
value={ins.dueThisMonth}
color="#0369a1"
active={filterHorizon === 'THIS_MONTH'}
onClick={() => toggleHorizon('THIS_MONTH')}
/>
<KpiCard
icon={<Eye size={18} color="#be185d" />}
label="Pre-Market Risiko"
value={ins.schattenmarktReadyCount}
color="#be185d"
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
onClick={togglePreMarket}
/>
sx={{
flex: '0 0 200px',
px: 2.5,
py: 2,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 0.5,
borderRadius: 1.5,
cursor: 'pointer',
border: overdueActive
? '1.5px solid #dc2626'
: isOverdue
? '1.5px solid #fca5a5'
: '1px solid #e8e7e4',
bgcolor: overdueActive
? '#fef2f2'
: isOverdue
? '#fff5f5'
: 'white',
transition: 'border-color 0.15s, background 0.15s',
'&:hover': { borderColor: '#dc2626', bgcolor: '#fef2f2' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AlertOctagon size={16} color={isOverdue ? '#dc2626' : '#cbd5e1'} />
<Typography
sx={{
fontWeight: 800,
fontSize: '2rem',
lineHeight: 1,
color: isOverdue ? '#dc2626' : '#94a3b8',
letterSpacing: '-0.02em',
}}
>
{ins.overdueCount}
</Typography>
</Box>
<Typography
variant="caption"
sx={{
color: isOverdue ? '#dc2626' : '#94a3b8',
fontWeight: isOverdue ? 600 : 400,
fontSize: '0.75rem',
}}
>
Überfällig
</Typography>
</Box>
{/* Secondary KPIs */}
<Box sx={{ flex: 1, display: 'flex', gap: 2 }}>
<SecondaryKpi
icon={<Calendar size={14} color="#ea580c" />}
label="Diese Woche"
value={ins.dueThisWeek}
color="#ea580c"
active={filterHorizon === 'THIS_WEEK'}
onClick={() => toggleHorizon('THIS_WEEK')}
/>
<SecondaryKpi
icon={<CalendarDays size={14} color="#0369a1" />}
label="Dieser Monat"
value={ins.dueThisMonth}
color="#0369a1"
active={filterHorizon === 'THIS_MONTH'}
onClick={() => toggleHorizon('THIS_MONTH')}
/>
<SecondaryKpi
icon={<Eye size={14} color="#be185d" />}
label="Pre-Market Risiko"
value={ins.schattenmarktReadyCount}
color="#be185d"
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
onClick={togglePreMarket}
/>
</Box>
</Box>
)
}
+19 -22
View File
@@ -70,10 +70,10 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
onClick={handleRowClick}
sx={{
display: 'grid',
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
gridTemplateColumns: '90px 130px 1fr 140px 80px 90px',
alignItems: 'center',
gap: 1,
pl: 1.5,
pl: 2,
pr: 2,
py: 1.25,
borderBottom: '1px solid #f1f5f9',
@@ -84,8 +84,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
transition: 'background-color 0.1s',
}}
>
{/* Priority badge */}
<Box>
{/* Priority dot */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ReminderPriorityBadge priority={reminder.priority} />
</Box>
@@ -112,25 +112,22 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
<ReminderDaysIndicator dueDate={reminder.dueDate} />
</Box>
{/* Area */}
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{reminder.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{/* Status chip */}
{/* Status — only shown for non-default states */}
<Box>
<Chip
label={STATUS_LABEL[reminder.status]}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 500,
bgcolor: STATUS_STYLE[reminder.status]?.bg ?? '#f8fafc',
color: STATUS_STYLE[reminder.status]?.color ?? '#64748b',
border: `1px solid ${STATUS_STYLE[reminder.status]?.border ?? '#e2e8f0'}`,
}}
/>
{reminder.status !== ReminderStatus.ACTIVE && (
<Chip
label={STATUS_LABEL[reminder.status]}
size="small"
sx={{
height: 20,
fontSize: '0.7rem',
fontWeight: 500,
bgcolor: STATUS_STYLE[reminder.status]?.bg ?? '#f8fafc',
color: STATUS_STYLE[reminder.status]?.color ?? '#64748b',
border: `1px solid ${STATUS_STYLE[reminder.status]?.border ?? '#e2e8f0'}`,
}}
/>
)}
</Box>
{/* Actions */}
@@ -5,7 +5,7 @@ const CONFIG: Record<ReminderPriority, { color: string; label: string }> = {
URGENT: { color: '#dc2626', label: 'Dringend' },
HIGH: { color: '#ea580c', label: 'Hoch' },
MEDIUM: { color: '#ca8a04', label: 'Mittel' },
LOW: { color: '#64748b', label: 'Niedrig' },
LOW: { color: '#94a3b8', label: 'Niedrig' },
}
interface Props {
@@ -15,9 +15,9 @@ interface Props {
export function ReminderPriorityBadge({ priority }: Props) {
const { color, label } = CONFIG[priority]
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
<Typography variant="caption" sx={{ color, fontWeight: 600, fontSize: '0.7rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.6 }}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: color, flexShrink: 0 }} />
<Typography sx={{ color, fontWeight: 600, fontSize: '0.7rem', lineHeight: 1, whiteSpace: 'nowrap' }}>
{label}
</Typography>
</Box>
+133
View File
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import type { Property, PropertyUnit } from '../../domain/property'
import { useUpdateUnit } from '../../hooks/useProperties'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
const NEEDS_BUILD = new Set(['SHELL', 'BASIC'])
interface UnitDraft {
rentPricePerSqm?: number
availableFrom?: string
fitOut?: string
fitOutByLandlord?: boolean
mieterausbaubeitragPerSqm?: number
parkingSpots?: number
}
interface Props {
property: Property
unit: PropertyUnit
onClose: () => void
}
/** Shared per-unit editor: conditions (price, availability), fit-out group, parking allocation. */
export function UnitFieldsEditor({ property, unit, onClose }: Props) {
const updateUnit = useUpdateUnit(property.id)
const [d, setD] = useState<UnitDraft>(() => ({
rentPricePerSqm: unit.rentPricePerSqm ?? property.rentPricePerSqm,
availableFrom: unit.availableFrom,
fitOut: unit.fitOut ?? '',
fitOutByLandlord: unit.fitOutByLandlord,
mieterausbaubeitragPerSqm: unit.mieterausbaubeitragPerSqm,
parkingSpots: unit.parkingSpots,
}))
const effFit = d.fitOut || property.hardFacts?.fitOut
const needsBuild = NEEDS_BUILD.has(effFit ?? '')
function save() {
const fit = (d.fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined
const nb = fit === 'SHELL' || fit === 'BASIC'
updateUnit.mutate({
unitId: unit.id,
data: {
rentPricePerSqm: d.rentPricePerSqm,
availableFrom: d.availableFrom || undefined,
fitOut: fit,
fitOutByLandlord: nb ? d.fitOutByLandlord : undefined,
mieterausbaubeitragPerSqm: nb && d.fitOutByLandlord === false ? d.mieterausbaubeitragPerSqm : undefined,
parkingSpots: d.parkingSpots,
},
})
onClose()
}
return (
<Box sx={{ px: 2, py: 1.5, bgcolor: '#f8fafc', borderTop: `1px solid ${DS_BORDER.default}` }}>
{/* Konditionen */}
<Box sx={{ display: 'grid', gridTemplateColumns: unit.available ? '1fr 1fr 1fr' : '1fr 1fr', gap: 1.25 }}>
<TextField
label="Preis (CHF/m²/Jahr)" type="number" size="small"
value={d.rentPricePerSqm ?? ''}
onChange={e => setD(p => ({ ...p, rentPricePerSqm: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0, step: 10 } }}
helperText="Leer = Objekt-Preis"
/>
{unit.available && (
<TextField
label="Verfügbar ab" type="date" size="small"
slotProps={{ inputLabel: { shrink: true } }}
value={d.availableFrom ?? ''}
onChange={e => setD(p => ({ ...p, availableFrom: e.target.value }))}
helperText="Leer = sofort"
/>
)}
<TextField
label="Parkplätze (Zuteilung)" type="number" size="small"
value={d.parkingSpots ?? ''}
onChange={e => setD(p => ({ ...p, parkingSpots: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0 } }}
helperText={property.hardFacts?.parking != null ? `Objekt-Pool: ${property.hardFacts.parking}` : 'Leer = Objekt-Wert'}
/>
</Box>
{/* Ausbau — Standard + Träger + MAB als Gruppe */}
<Box sx={{ mt: 1.5, p: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1, bgcolor: 'white' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 1 }}>
Ausbau
</Typography>
<TextField
select size="small" label="Ausbaustandard" fullWidth
value={d.fitOut ?? ''}
onChange={e => setD(p => ({ ...p, fitOut: e.target.value }))}
>
<MenuItem value="">Wie Objekt{property.hardFacts?.fitOut ? ` (${FIT_OUT_LABELS[property.hardFacts.fitOut] ?? property.hardFacts.fitOut})` : ''}</MenuItem>
<MenuItem value="SHELL">{FIT_OUT_LABELS.SHELL}</MenuItem>
<MenuItem value="BASIC">{FIT_OUT_LABELS.BASIC}</MenuItem>
<MenuItem value="FULL">{FIT_OUT_LABELS.FULL}</MenuItem>
<MenuItem value="PREMIUM">{FIT_OUT_LABELS.PREMIUM}</MenuItem>
</TextField>
{needsBuild && (
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center', flexWrap: 'wrap', mt: 1.25 }}>
<Typography variant="caption" color="text.secondary">Wer baut aus?</Typography>
<ToggleButtonGroup
exclusive size="small"
value={d.fitOutByLandlord === undefined ? null : d.fitOutByLandlord ? 'landlord' : 'tenant'}
onChange={(_, v) => { if (v) setD(p => ({ ...p, fitOutByLandlord: v === 'landlord', ...(v === 'landlord' ? { mieterausbaubeitragPerSqm: undefined } : {}) })) }}
>
<ToggleButton value="landlord" sx={{ textTransform: 'none' }}>Vermieter</ToggleButton>
<ToggleButton value="tenant" sx={{ textTransform: 'none' }}>Mieter</ToggleButton>
</ToggleButtonGroup>
{d.fitOutByLandlord === false && (
<TextField
label="MAB (CHF/m²)" type="number" size="small" sx={{ width: 150 }}
value={d.mieterausbaubeitragPerSqm ?? ''}
onChange={e => setD(p => ({ ...p, mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined }))}
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
/>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}>leer = wie Objekt</Typography>
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mt: 1.5, justifyContent: 'flex-end' }}>
<Button size="small" onClick={onClose} sx={{ textTransform: 'none', color: DS_TEXT.muted }}>Abbrechen</Button>
<Button size="small" variant="contained" onClick={save} sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>Speichern</Button>
</Box>
</Box>
)
}
+54 -2
View File
@@ -1,13 +1,16 @@
import { useMemo, useState } from 'react'
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Users } from 'lucide-react'
import { ChevronDown, ChevronUp, ExternalLink, FileText, Layers, Pencil, Users } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
import type { Lease } from '../../domain/lease'
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
import { useUpdateUnit } from '../../hooks/useProperties'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { FIT_OUT_LABELS } from '../../lib/constants'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { floorLabel } from './PropertyDetailHelpers'
import { UnitFieldsEditor } from './UnitFieldsEditor'
function MatchPill({ m }: { m: UnitNeedMatch }) {
const bg = m.matchScore >= 85 ? '#fef3c7' : '#e0e7ff'
@@ -50,6 +53,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
const [editingUnit, setEditingUnit] = useState<string | null>(null)
const updateUnit = useUpdateUnit(p.id)
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
@@ -229,6 +233,7 @@ export function UnitStructurePanel({ p }: { p: Property }) {
propertyId: p.id,
floor: floorLabel(u),
fitOut: p.hardFacts?.fitOut,
fitOutByLandlord: p.hardFacts?.fitOutByLandlord,
parking: p.hardFacts?.parking,
ceilingHeight: p.hardFacts?.ceilingHeightM,
mieterausbaubeitragPerSqm: p.hardFacts?.mieterausbaubeitragPerSqm,
@@ -285,9 +290,19 @@ export function UnitStructurePanel({ p }: { p: Property }) {
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</IconButton>
)}
{/* Edit price / availability per unit */}
<Tooltip title="Preis & Verfügbarkeit bearbeiten">
<IconButton
size="small"
onClick={() => setEditingUnit(editingUnit === u.id ? null : u.id)}
sx={{ p: 0.25, color: editingUnit === u.id ? '#2563eb' : DS_TEXT.disabled }}
>
<Pencil size={11} />
</IconButton>
</Tooltip>
</Box>
{/* Area */}
{/* Area + Preis + Verfügbarkeit pro Einheit */}
<Box sx={{ textAlign: 'right' }}>
<Typography variant="caption" sx={{ color: '#374151', fontWeight: u.available ? 600 : 400 }}>
{(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m²
@@ -297,9 +312,46 @@ export function UnitStructurePanel({ p }: { p: Property }) {
ab {u.minLettableSqm} m²
</Typography>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.6rem', display: 'block' }}>
CHF {(u.rentPricePerSqm ?? p.rentPricePerSqm).toLocaleString('de-CH')}/m²
</Typography>
{u.available && (() => {
const av = u.availableFrom ?? u.schattenmarktRelease?.availableFrom
return (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled, fontSize: '0.6rem', display: 'block' }}>
{av ? `ab ${new Date(av).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}` : 'sofort'}
</Typography>
)
})()}
</Box>
</Box>
{/* Always-visible per-unit details (read) */}
{(() => {
const f = resolveUnitFacts(p, u)
const fitLabel = f.fitOut ? (FIT_OUT_LABELS[f.fitOut] ?? f.fitOut) : null
const needsBuild = f.fitOut === 'SHELL' || f.fitOut === 'BASIC'
const parts: string[] = []
if (fitLabel) parts.push(`Ausbau: ${fitLabel}`)
if (needsBuild) parts.push(`Träger: ${f.fitOutByLandlord ? 'Vermieter' : 'Mieter'}`)
if (needsBuild && !f.fitOutByLandlord && f.mabPerSqm > 0) parts.push(`MAB CHF ${f.mabPerSqm}/m²`)
if (f.parkingSpots != null) parts.push(`${f.parkingSpots} PP`)
if (u.expectedRentPerSqm) parts.push(`erwartet CHF ${u.expectedRentPerSqm}/m²`)
if (parts.length === 0) return null
return (
<Box sx={{ px: 1.5, pb: 0.625, mt: -0.25, borderBottom: isLastRow ? 'none' : `1px solid ${DS_BORDER.muted}` }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.63rem' }}>
{parts.join(' · ')}
</Typography>
</Box>
)
})()}
{/* Inline editor: full per-unit fields */}
<Collapse in={editingUnit === u.id}>
<UnitFieldsEditor property={p} unit={u} onClose={() => setEditingUnit(null)} />
</Collapse>
{/* Expanded: matches for free units */}
<Collapse in={isExpanded && u.available}>
<Box sx={{ px: 2, py: 1, bgcolor: DS_SURFACE.neutral.bg, borderBottom: !isLastUnit ? `1px solid ${DS_BORDER.default}` : 'none' }}>
+10
View File
@@ -20,10 +20,20 @@ export interface InquiryMessage {
export type InquiryStatus = 'new' | 'in_progress' | 'answered' | 'archived'
/** Konversationstyp: Nachfrager-initiierte Anfrage vs. Verwaltung-initiiertes Angebot. */
export type InquiryKind = 'INQUIRY' | 'OFFER'
export interface Inquiry {
id: string
/** Supply-Organisation (Eigentümer/Verwaltung des Objekts). */
organizationId: string
/** Nachfrager-Organisation — für das Demand-Postfach. */
tenantOrgId?: string
/** INQUIRY (Demand→Supply) oder OFFER (Supply→Demand). Default INQUIRY. */
kind?: InquiryKind
propertyId: string
/** Bei OFFER: alle im Angebot enthaltenen Objekte. */
offeredPropertyIds?: string[]
needId?: string
tenantName: string
tenantCompany?: string
+2
View File
@@ -5,6 +5,8 @@ export interface LatentNeed {
id: string
title: string
tenantCompany?: string
/** Nachfrager-Organisation — Empfänger eines Angebots (Demo-Default-Routing: org-mobimo). */
tenantOrgId?: string
assetType: AssetType
desiredLocation: string
sizeRange: { min: number; max: number }
+7
View File
@@ -63,6 +63,12 @@ export interface WeightedPreference {
description?: string
}
export interface NotificationConfig {
enabled: boolean
minScore: number // 0100, default 80
emailAddress?: string
}
export interface Need {
id: string
companyName: string
@@ -103,6 +109,7 @@ export interface Need {
confidenceInCriteria: number
extractedFromText?: string
notes?: string
notificationConfig?: NotificationConfig
organizationId?: string
createdAt: string
updatedAt: string
+2
View File
@@ -56,6 +56,8 @@ export interface PropertyHardFacts {
floor?: number
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mieterausbaubeitragPerSqm?: number
/** true = Vermieter übernimmt den Ausbau & preist ihn in die Miete ein (kein Aufschlag, kein MAB). */
fitOutByLandlord?: boolean
parking?: number
publicTransportScore?: number
usageType?: string
+8
View File
@@ -17,6 +17,14 @@ export interface PropertyUnit {
areaSqm: number
available: boolean
rentPricePerSqm?: number // annual CHF/m²; falls back to property.rentPricePerSqm
availableFrom?: string // explicit availability date for a free unit (ISO); else derived from leases
// Unit-level overrides — fall back to property.hardFacts.* when unset.
// Relevant where units of one property are matched individually (pre-market, multi-unit).
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
fitOutByLandlord?: boolean // wer trägt den Ausbau (Default: Objekt-Wert)
mieterausbaubeitragPerSqm?: number // MAB der Einheit (Default: Objekt-Wert)
parkingSpots?: number // aus dem Objekt-Pool zugeteilte Parkplätze
expectedRentPerSqm?: number // erwarteter künftiger Preis (Pre-Market), ≠ heutige Sollmiete
leases?: Lease[] // Mietverträge — current, historical, future
/** @deprecated Use leases[].tenant.companyName */
currentTenant?: string
@@ -181,6 +181,27 @@ describe('calculateScore', () => {
expect(output.excludedReason).toBeTruthy()
})
// ── Ausbau-Annuität im Budget-Faktor ────────────────────────────────────────
function budgetFactor(need: ReturnType<typeof makeNeed>, prop: ReturnType<typeof makeProperty>) {
return calculateScore(need, prop).allHardFactors.find(f => f.criterion === 'budget')!
}
it('tenant-borne SHELL lowers the budget factor vs a fitted-out (FULL) property', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const shell = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const full = makeProperty({ ...base, hardFacts: { fitOut: 'FULL' } })
expect(budgetFactor(need, shell).score).toBeLessThan(budgetFactor(need, full).score)
})
it('landlord-borne SHELL keeps the budget factor (no fit-out surcharge)', () => {
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
const base = { rentPricePerSqm: 48, areaSqm: 300 }
const tenant = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL' } })
const landlord = makeProperty({ ...base, hardFacts: { fitOut: 'SHELL', fitOutByLandlord: true } })
expect(budgetFactor(need, landlord).score).toBeGreaterThan(budgetFactor(need, tenant).score)
})
it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
const need = makeNeed()
const prop = makeProperty()
+13 -11
View File
@@ -13,6 +13,7 @@ import type {
import { getCityIntelligence } from '../../lib/locationIntelligence'
import { formatUnitTitle, formatMultiUnitFloors, getUnitAvailability } from '../../domain/unit'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { resolveUnitFacts } from '../../lib/unitFacts'
import { AvailabilityStatus } from '../../domain/enums'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
@@ -152,29 +153,30 @@ export function buildMatchCardViewModel(
preMarketUnit: unit,
preMarketAllUnits: property?.units,
fitOutLabel: (() => {
const fitOut = property?.hardFacts?.fitOut
// Einheit-Werte haben Vorrang vor Objekt-Werten (Pre-Market / Mehr-Einheiten)
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
if (!fitOut) return undefined
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
const mab = property.hardFacts?.mieterausbaubeitragPerSqm
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return `${LABELS[fitOut]} — bezugsfertig`
if (mab) return `${LABELS[fitOut]} + CHF ${mab} MAB`
if (mabPerSqm) return `${LABELS[fitOut]} + CHF ${mabPerSqm} MAB`
return LABELS[fitOut] ?? fitOut
})(),
fitOutViable: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
if (!fitOut) return undefined
if (fitOut === 'FULL' || fitOut === 'PREMIUM') return true
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
if (fitOut === 'BASIC' && mab >= 150) return true
if (fitOut === 'SHELL' && mab >= 350) return true
if (fitOut === 'BASIC' && mabPerSqm >= 150) return true
if (fitOut === 'SHELL' && mabPerSqm >= 350) return true
return undefined
})(),
fitOutInvestment: (() => {
const fitOut = property?.hardFacts?.fitOut
if (!property) return undefined
const { fitOut, mabPerSqm } = resolveUnitFacts(property, unit)
if (!fitOut || fitOut === 'FULL' || fitOut === 'PREMIUM') return undefined
const mab = property.hardFacts?.mieterausbaubeitragPerSqm ?? 0
const area = property.areaSqm ?? 0
return calcFitOutInvestment(fitOut, area, mab, 0) ?? undefined
const area = unit?.areaSqm ?? property.areaSqm ?? 0
return calcFitOutInvestment(fitOut, area, mabPerSqm, 0) ?? undefined
})(),
isDivisible: property?.units ? property.units.length > 1 : false,
minDivisibleUnitSqm: (() => {
+23 -9
View File
@@ -15,6 +15,7 @@ import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffA
import { generateNextBestActions } from './rankingEngine'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
// ── Profile resolution ────────────────────────────────────────────────────────
@@ -159,7 +160,16 @@ function scoreLocation(need: Need, property: Property, weight: number): ScoreFac
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
const rent = property.rentPricePerSqm
// Effektive Jahresbelastung: Kaltmiete + annuitätischer Ausbau-Aufschlag (nur wenn Mieter ausbaut)
const { effectivePerSqm: rent, fitOutPerSqm } = effectiveAnnualBurdenPerSqm({
fitOut: property.hardFacts?.fitOut,
rentPricePerSqm: property.rentPricePerSqm,
mabPerSqm: property.hardFacts?.mieterausbaubeitragPerSqm ?? 0,
fitOutByLandlord: property.hardFacts?.fitOutByLandlord,
})
// Erklärt den Ausbau-Anteil transparent, wenn er den Score beeinflusst
const fitOutNote = fitOutPerSqm > 0 ? ` (inkl. CHF ${fitOutPerSqm}/m² Ausbau-Annuität)` : ''
let score: number
let explanation: string
@@ -171,18 +181,18 @@ function scoreBudget(need: Need, property: Property, weight: number): ScoreFacto
const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88
explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
explanation = `Effektive Belastung CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²${fitOutNote}`
} else {
const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75
explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45
explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
} else {
score = 20
explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
explanation = `Effektive Belastung CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)${fitOutNote}`
}
}
@@ -610,8 +620,12 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
]
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Final score: hard/soft weighted sum, clamped 0–100 ───────────────────
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
// ── Final score: hard/soft weighted sum + Datenqualität/Konfidenz-Modifikatoren,
// abzgl. severePenalty, clamped 0100 (Trust-first: schwache Datenlage senkt den Score) ──
const dataQualityModifier = calcDataQualityModifier(property)
const confidenceModifier = calcConfidenceModifier(property)
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40
+ dataQualityModifier + confidenceModifier - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
@@ -639,8 +653,8 @@ export function calculateScore(need: Need, property: Property): MatchEngineOutpu
finalScore,
hardMatchScore,
softFactorScore,
dataQualityModifier: 0,
confidenceModifier: 0,
dataQualityModifier,
confidenceModifier,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
+20 -2
View File
@@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { aiService, parseListingText } from '../services/aiService'
import type { OfferEmailPayload } from '../services/aiService'
import type { OfferEmailPayload, FitOutAdviceInput, PreMarketRentInput } from '../services/aiService'
export function useParseNeed() {
return useMutation({
@@ -25,3 +25,21 @@ export function useParseListingText() {
mutationFn: (text: string) => parseListingText(text),
})
}
export function useFitOutAdvice(input: FitOutAdviceInput | null) {
return useQuery({
queryKey: ['fitOutAdvice', input],
queryFn: () => aiService.generateFitOutAdvice(input!),
enabled: !!input,
staleTime: Infinity,
})
}
export function usePreMarketRentRecommendation(input: PreMarketRentInput | null) {
return useQuery({
queryKey: ['preMarketRent', input],
queryFn: () => aiService.recommendPreMarketRent(input!),
enabled: !!input && !!input.city,
staleTime: Infinity,
})
}
+28 -3
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { inquiryService } from '../services/inquiryService'
import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Attachment } from '../domain/inquiry'
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
import type { Attachment, InquiryMessage } from '../domain/inquiry'
import { useToastStore } from '../stores/toastStore'
export function useActiveInquiries(filters?: InquiryFilters) {
@@ -29,7 +29,7 @@ export function useSendInquiryReply() {
payload,
}: {
inquiryId: string
payload: { subject?: string; body: string; attachments?: Attachment[] }
payload: { subject?: string; body: string; attachments?: Attachment[]; senderType?: InquiryMessage['senderType']; senderName?: string }
}) => inquiryService.sendInquiryReply(inquiryId, payload),
onSuccess: (_data, { inquiryId }) => {
qc.invalidateQueries({ queryKey: ['inquiry', inquiryId] })
@@ -41,6 +41,31 @@ export function useSendInquiryReply() {
})
}
/** Demand→Supply: neue Anfrage erstellen (ersetzt die alte Zustand-Insel). */
export function useCreateInquiry() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateInquiryInput) => inquiryService.createInquiry(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inquiries'] })
},
onError: () => {
useToastStore.getState().showToast('Anfrage konnte nicht gesendet werden.', 'error')
},
})
}
/** Supply→Demand: Angebot als Konversation im Nachfrager-Postfach materialisieren. */
export function useCreateOffer() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: CreateOfferInput) => inquiryService.createOffer(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inquiries'] })
},
})
}
export function useMarkThreadAsRead() {
const qc = useQueryClient()
return useMutation({
+25 -3
View File
@@ -1,6 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { needService } from '../services/needService'
import type { CreateNeedInput } from '../domain/need'
import { useSessionStore } from '../stores/sessionStore'
import type { CreateNeedInput, UpdateNeedInput } from '../domain/need'
interface UseNeedsOptions {
refetchOnMount?: boolean | 'always'
@@ -8,9 +9,10 @@ interface UseNeedsOptions {
}
export function useNeeds(options?: UseNeedsOptions) {
const orgId = useSessionStore(s => s.currentUser?.organizationId)
return useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
queryKey: ['needs', orgId],
queryFn: () => needService.getAll({ organizationId: orgId }),
select: (res) => res.data ?? [],
...options,
})
@@ -36,3 +38,23 @@ export function useCreateNeed() {
},
})
}
export function useUpdateNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateNeedInput) => needService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
export function useArchiveNeed() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => needService.update(id, { status: 'INACTIVE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['needs'] })
},
})
}
+13 -5
View File
@@ -15,6 +15,8 @@ function validate(fields: {
city: string
areaSqm: string
rentPerSqm: string
fitOut: string
fitOutByLandlord: boolean | undefined
}): string | null {
if (!fields.street.trim()) return 'Strasse erforderlich'
if (!fields.postalCode.trim()) return 'PLZ erforderlich'
@@ -23,6 +25,9 @@ function validate(fields: {
|| Number(fields.areaSqm) <= 0) return 'Gültige Fläche eingeben'
if (!fields.rentPerSqm || isNaN(Number(fields.rentPerSqm))
|| Number(fields.rentPerSqm) <= 0) return 'Gültigen Mietpreis eingeben'
// Bei Rohbau/Edelrohbau muss der Ausbau-Träger aktiv gewählt werden
if ((fields.fitOut === 'SHELL' || fields.fitOut === 'BASIC')
&& fields.fitOutByLandlord === undefined) return 'Bitte wählen, wer den Ausbau trägt (Vermieter oder Mieter)'
return null
}
@@ -43,6 +48,7 @@ export interface NewListingFormState {
// Technical
floor: string
fitOut: string
fitOutByLandlord: boolean | undefined
parking: string
ceilingHeight: string
mieterausbaubeitrag: string
@@ -80,6 +86,7 @@ export interface NewListingFormHandlers {
setSoftLevel: (key: string, value: string) => void
setFloor: (v: string) => void
setFitOut: (v: string) => void
setFitOutByLandlord: (v: boolean | undefined) => void
setParking: (v: string) => void
setCeilingHeight: (v: string) => void
setMieterausbaubeitrag: (v: string) => void
@@ -117,6 +124,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
const [softLevels, setSoftLevels] = useState<Record<string, string>>(() => ({ ...emptySoftLevels(), ...pre.softLevels }))
const [floor, setFloor] = useState(pre.floor ?? '')
const [fitOut, setFitOut] = useState(pre.fitOut ?? '')
const [fitOutByLandlord, setFitOutByLandlord] = useState<boolean | undefined>(pre.fitOutByLandlord)
const [parking, setParking] = useState(pre.parking != null ? String(pre.parking) : '')
const [ceilingHeight, setCeilingHeight]= useState(pre.ceilingHeight != null ? String(pre.ceilingHeight) : '')
const [mieterausbaubeitrag, setMieterausbaubeitrag] = useState(pre.mieterausbaubeitragPerSqm != null ? String(pre.mieterausbaubeitragPerSqm) : '')
@@ -161,7 +169,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
}
function handleSubmit() {
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm })
const err = validate({ street, postalCode, city, areaSqm, rentPerSqm, fitOut, fitOutByLandlord })
if (err) { setError(err); return }
setError(null)
createProperty.mutate(
@@ -169,7 +177,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
assetType, street, houseNumber, postalCode, city,
areaSqm: Number(areaSqm), rentPerSqm: Number(rentPerSqm),
availableFrom, description, softLevels,
floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
}),
{
@@ -185,7 +193,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAreaSqm(''); setRentPerSqm(''); setAvailableFrom(''); setDescription('')
setContactName(''); setContactEmail(''); setContactPhone('')
setSoftLevels(emptySoftLevels())
setFloor(''); setFitOut(''); setParking(''); setCeilingHeight('')
setFloor(''); setFitOut(''); setFitOutByLandlord(undefined); setParking(''); setCeilingHeight('')
setMieterausbaubeitrag(''); setIsFlexible(false); setMinLettableSqm('')
setImages([]); setImageInput(''); setFloorPlanUrl('')
setAiText(''); setAiApplied(false)
@@ -195,7 +203,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
return {
assetType, areaSqm, rentPerSqm, availableFrom, description,
street, houseNumber, postalCode, city,
softLevels, floor, fitOut, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, mieterausbaubeitrag, isFlexible, minLettableSqm,
contactName, contactEmail, contactPhone,
images, imageInput, floorPlanUrl, aiText, aiApplied,
error, created,
@@ -205,7 +213,7 @@ export function useNewListingForm(pre: Prefill): NewListingFormState & NewListin
setAssetType, setAreaSqm, setRentPerSqm, setAvailableFrom, setDescription,
setStreet, setHouseNumber, setPostalCode, setCity,
setSoftLevel,
setFloor, setFitOut, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setFloor, setFitOut, setFitOutByLandlord, setParking, setCeilingHeight, setMieterausbaubeitrag, setIsFlexible, setMinLettableSqm,
setContactName, setContactEmail, setContactPhone,
setImageInput, setFloorPlanUrl, setAiText,
addImage, removeImage,
+16
View File
@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { reminderService } from '../services/reminderService'
import type { Reminder } from '../domain/reminder'
import { useToastStore } from '../stores/toastStore'
export function useReminders() {
@@ -69,6 +70,21 @@ export function useSnoozeReminder() {
})
}
export function useCreateReminder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: Omit<Reminder, 'id' | 'activity' | 'createdAt' | 'updatedAt'>) =>
reminderService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] })
queryClient.invalidateQueries({ queryKey: ['reminder-insights'] })
},
onError: () => {
useToastStore.getState().showToast('Reminder konnte nicht erstellt werden.', 'error')
},
})
}
export function useUpdateReminder() {
const queryClient = useQueryClient()
return useMutation({
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { estimateMarketRent, suggestFutureRent } from '../rentEstimate'
describe('estimateMarketRent', () => {
// Zürich OFFICE median = 42/Monat → 504/Jahr (getMarketRent rechnet ×12)
it('flags asking rent clearly above the city median as ABOVE', () => {
const est = estimateMarketRent('Zürich', 'OFFICE', 600)
expect(est).not.toBeNull()
expect(est!.verdict).toBe('ABOVE')
expect(est!.deltaPct).toBeGreaterThan(5)
expect(est!.fairRentPerSqm).toBe(504)
})
it('flags asking rent clearly below median as BELOW', () => {
const est = estimateMarketRent('Zürich', 'OFFICE', 400)
expect(est!.verdict).toBe('BELOW')
expect(est!.deltaPct).toBeLessThan(-5)
})
it('treats near-median asking rent as AT (within ±5%)', () => {
const est = estimateMarketRent('Zürich', 'OFFICE', 510)
expect(est!.verdict).toBe('AT')
})
it('returns null for unknown city', () => {
expect(estimateMarketRent('Atlantis', 'OFFICE', 480)).toBeNull()
})
it('suggestFutureRent indexes by the city rent trend', () => {
// Zürich rentTrend12m = +4.2% → 100 * 1.042 = 104 (rounded)
expect(suggestFutureRent('Zürich', 100)).toBe(104)
})
})
+12
View File
@@ -142,3 +142,15 @@ export const FIT_OUT_COST_CHF_PER_SQM: Record<string, { min: number; max: number
// Assumed amortization period for fit-out investment — change here to affect all cost calculations
export const FITOUT_AMORTIZATION_YEARS = 5
// Kalkulationszins p.a. für die annuitätische Amortisation der Ausbauinvestition (Schweizer Richtwert)
export const FITOUT_ANNUITY_RATE = 0.05
// Zentrale Ausbaustandard-Labels — branchenüblich + internationale Synonyme (CAT A/B, Core & Shell).
// Single Source of Truth für Dropdown, Panel, Score, Vergleich.
export const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau · Core & Shell',
BASIC: 'Edelrohbau · CAT A',
FULL: 'Ausgebaut · CAT B',
PREMIUM: 'Vollausgebaut · Plug & Play',
}
+42 -1
View File
@@ -1,15 +1,49 @@
import { FIT_OUT_COST_CHF_PER_SQM } from './constants'
import { FIT_OUT_COST_CHF_PER_SQM, FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE } from './constants'
export interface FitOutInvestment {
grossPerSqm: { min: number; max: number }
mabOffset: number
netPerSqm: { min: number; max: number }
netTotal: { min: number; max: number }
annualBurden: { min: number; max: number }
isFullyCovered: boolean
shortLabel: string
detailLabel: string
}
// Annuitätenfaktor: a = i(1+i)^n / ((1+i)^n 1); bei i=0 → 1/n (lineare Amortisation als Fallback)
export function annuityFactor(rate: number, years: number): number {
if (years <= 0) return 0
if (rate === 0) return 1 / years
const q = Math.pow(1 + rate, years)
return (rate * q) / (q - 1)
}
// Bezugsfertige Stufen — kein Ausbau-Aufschlag (Mieter muss nicht mehr investieren)
const FITOUT_READY = new Set(['FULL', 'PREMIUM'])
/**
* Effektive Jahresbelastung pro m². Grundsatz: die quotierte Miete gilt IMMER als realer Preis —
* die Plattform erfindet keinen Aufschlag. Nur wenn der Mieter ausbaut (SHELL/BASIC), wird die
* versteckte Ausbaukost annuitätisch sichtbar gemacht (abzgl. MAB). Übernimmt der Vermieter, ist der
* Ausbau bereits in der Miete enthalten → kein Aufschlag, keine Mieter-Capex.
*/
export function effectiveAnnualBurdenPerSqm(p: {
fitOut?: string
rentPricePerSqm: number
mabPerSqm: number
fitOutByLandlord?: boolean
}): { rentPerSqm: number; fitOutPerSqm: number; effectivePerSqm: number } {
if (p.fitOutByLandlord || FITOUT_READY.has(p.fitOut ?? '')) {
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm: 0, effectivePerSqm: p.rentPricePerSqm }
}
const bm = FIT_OUT_COST_CHF_PER_SQM[p.fitOut ?? '']
const grossMid = bm ? (bm.min + bm.max) / 2 : 0
const net = Math.max(0, grossMid - p.mabPerSqm)
const fitOutPerSqm = Math.round(net * annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS))
return { rentPerSqm: p.rentPricePerSqm, fitOutPerSqm, effectivePerSqm: p.rentPricePerSqm + fitOutPerSqm }
}
function formatChfK(value: number): string {
if (value >= 1000) return `CHF ${(value / 1000).toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 1 })} Mio.`
return `CHF ${Math.round(value / 1000)}k`
@@ -34,6 +68,12 @@ export function calcFitOutInvestment(
max: Math.round(netMax * areaSqm),
}
const factor = annuityFactor(FITOUT_ANNUITY_RATE, FITOUT_AMORTIZATION_YEARS)
const annualBurden = {
min: Math.round(netTotal.min * factor),
max: Math.round(netTotal.max * factor),
}
const isFullyCovered = netTotal.max <= 0
const shortLabel = isFullyCovered
@@ -51,6 +91,7 @@ export function calcFitOutInvestment(
mabOffset: mabPerSqm,
netPerSqm: { min: netMin, max: netMax },
netTotal,
annualBurden,
isFullyCovered,
shortLabel,
detailLabel,
+9 -7
View File
@@ -6,9 +6,9 @@ export interface CityIntelligence {
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
dominantIndustryClusters: string[]
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
medianRentOffice: number // CHF/m² für Bürofläche
medianRentLogistics: number
medianRentRetail: number
medianRentOffice: number // CHF/m²/Monat (Bürofläche) — getMarketRent rechnet auf Jahr um
medianRentLogistics: number // CHF/m²/Monat
medianRentRetail: number // CHF/m²/Monat
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
@@ -137,13 +137,15 @@ export function getCityIntelligence(city: string): CityIntelligence | null {
return key ? CITY_INTELLIGENCE[key] : null
}
/** Median-Marktmiete in CHF/m²/**Jahr** (Daten sind monatlich gespeichert → ×12), passend zu property.rentPricePerSqm. */
export function getMarketRent(city: string, assetType: string): number | null {
const intel = getCityIntelligence(city)
if (!intel) return null
if (assetType === 'OFFICE') return intel.medianRentOffice
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
if (assetType === 'RETAIL') return intel.medianRentRetail
return intel.medianRentOffice
const monthly =
assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL' ? intel.medianRentLogistics
: assetType === 'RETAIL' ? intel.medianRentRetail
: intel.medianRentOffice
return monthly * 12
}
// ── City coordinates (WGS84) ─────────────────────────────────────────────────
+42
View File
@@ -0,0 +1,42 @@
import { getMarketRent, getCityIntelligence } from './locationIntelligence'
export type RentVerdict = 'BELOW' | 'AT' | 'ABOVE'
export interface RentEstimate {
fairRentPerSqm: number // Median-Marktmiete als faire Benchmark
askingRentPerSqm: number
verdict: RentVerdict // Angebot vs. Markt
deltaPct: number // +über / unter Markt (gerundet)
rationale: string
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
}
/**
* Markt-Einschätzung der Angebotsmiete: Vergleich gegen die Median-Marktmiete
* (aus Standort-Intelligence) plus Leerstand/Miettrend als Kontext. Deterministisch.
*/
export function estimateMarketRent(city: string, assetType: string, askingRentPerSqm: number): RentEstimate | null {
const market = getMarketRent(city, assetType)
const intel = getCityIntelligence(city)
if (market == null || !intel || askingRentPerSqm <= 0) return null
const fair = market
const deltaPct = Math.round(((askingRentPerSqm - fair) / fair) * 100)
const verdict: RentVerdict = deltaPct > 5 ? 'ABOVE' : deltaPct < -5 ? 'BELOW' : 'AT'
const trendNote = `Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`
const verdictText =
verdict === 'ABOVE' ? `Angebot ${deltaPct}% über Marktmedian.`
: verdict === 'BELOW' ? `Angebot ${Math.abs(deltaPct)}% unter Marktmedian.`
: 'Angebot marktkonform.'
const rationale = `Median ${city}: CHF ${fair}/m² · Leerstand ${intel.vacancyRatePct}% · ${trendNote}. ${verdictText}`
const confidence = intel.demandStrength === 'LOW' ? 'LOW' : intel.avgDaysOnMarket > 70 ? 'MEDIUM' : 'HIGH'
return { fairRentPerSqm: fair, askingRentPerSqm, verdict, deltaPct, rationale, confidence }
}
/** Indexierter Vorschlag für den künftigen Preis (Pre-Market): Heutepreis × (1 + Miettrend). */
export function suggestFutureRent(city: string, currentRentPerSqm: number): number | null {
const intel = getCityIntelligence(city)
if (!intel || currentRentPerSqm <= 0) return null
return Math.round(currentRentPerSqm * (1 + intel.rentTrend12m / 100))
}
+25
View File
@@ -0,0 +1,25 @@
import type { Property, PropertyUnit } from '../domain/property'
export interface ResolvedUnitFacts {
fitOut?: 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM'
mabPerSqm: number
fitOutByLandlord?: boolean
parkingSpots?: number
rentPricePerSqm: number
}
/**
* Effektive Ausbau-/Preis-/Parkplatz-Werte für eine Einheit: Einheit-Wert ?? Objekt-Wert.
* Parkplätze: Einheit-Zuteilung; bei Einzel-Einheit-Objekten der ganze Objekt-Pool als Fallback.
*/
export function resolveUnitFacts(property: Property, unit?: PropertyUnit | null): ResolvedUnitFacts {
const hf = property.hardFacts
const singleUnit = (property.units?.length ?? 0) <= 1
return {
fitOut: unit?.fitOut ?? hf?.fitOut,
mabPerSqm: unit?.mieterausbaubeitragPerSqm ?? hf?.mieterausbaubeitragPerSqm ?? 0,
fitOutByLandlord: unit?.fitOutByLandlord ?? hf?.fitOutByLandlord,
parkingSpots: unit?.parkingSpots ?? (singleUnit ? hf?.parking : undefined),
rentPricePerSqm: unit?.rentPricePerSqm ?? property.rentPricePerSqm,
}
}
+46
View File
@@ -5,6 +5,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-001',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-technopark',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -39,6 +41,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-002',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-hardturmpark',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -73,6 +77,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-003',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-sihlcity',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -130,6 +136,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-004',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-bahnhofzug',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -186,6 +194,8 @@ export const mockDemandInquiries: Inquiry[] = [
{
id: 'dinq-005',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'INQUIRY',
propertyId: 'prop-dreispitz',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
@@ -238,4 +248,40 @@ export const mockDemandInquiries: Inquiry[] = [
},
],
},
// 6 — OFFER (Verwaltung → Nachfrager, proaktiv aus Latente Anfragen)
{
id: 'off-seed-001',
organizationId: 'org-wincasa',
tenantOrgId: 'org-mobimo',
kind: 'OFFER',
propertyId: 'prop-001',
offeredPropertyIds: ['prop-001', 'prop-093'],
tenantName: 'Mobimo Management AG',
propertyManagerCompany: 'Wincasa AG',
propertyAddress: '2 Objekte im Angebot',
subject: 'Passende Büroflächen zu Ihrem Bedarf',
message:
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
status: 'new',
unreadCount: 1,
isRead: false,
createdAt: '2026-05-24T10:00:00Z',
updatedAt: '2026-05-24T10:00:00Z',
thread: [
{
id: 'off-seed-001-1',
inquiryId: 'off-seed-001',
senderType: 'supply_user',
senderName: 'Wincasa AG',
subject: 'Passende Büroflächen zu Ihrem Bedarf',
body:
'Sehr geehrte Damen und Herren,\n\nbasierend auf Ihrem Suchprofil haben wir zwei passende Büroflächen für Sie zusammengestellt. Details finden Sie im beigefügten Angebot.\n\nFreundliche Grüsse\nWincasa AG',
attachments: [
{ id: 'off-att-001', fileName: 'Angebot_Bueroflaechen_Zuerich.pdf', fileType: 'application/pdf', fileSize: 312500, generated: true },
],
createdAt: '2026-05-24T10:00:00Z',
},
],
},
]
+170 -36
View File
@@ -2,10 +2,10 @@ import { AssetType } from '../domain/enums'
import type { Need } from '../domain/need'
export const mockNeeds: Need[] = [
// --- need-001: Innovatech AG — OFFICE Zürich ---
// --- need-001: OFFICE Zürich-West ---
{
id: 'need-001',
companyName: 'Innovatech AG',
companyName: 'Bürofläche Zürich-West',
contactName: 'Sandra Meier',
assetType: AssetType.OFFICE,
requiredArea: { min: 600, max: 1000 },
@@ -38,10 +38,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-01T09:00:00Z',
},
// --- need-002: Schweizer Logistik GmbH — LOGISTICS Basel ---
// --- need-002: LOGISTICS Basel ---
{
id: 'need-002',
companyName: 'Schweizer Logistik GmbH',
companyName: 'Logistikfläche Basel',
contactName: 'Thomas Brun',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1500, max: 4000 },
@@ -72,10 +72,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-10T11:00:00Z',
},
// --- need-003: Pharma Holding AG — OFFICE Basel ---
// --- need-003: OFFICE Basel ---
{
id: 'need-003',
companyName: 'Pharma Holding AG',
companyName: 'Bürofläche Basel Repräsentanz',
contactName: 'Ursula Schmid',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 800 },
@@ -110,10 +110,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-05T14:00:00Z',
},
// --- need-004: Retailer Zürich AG — RETAIL Zürich ---
// --- need-004: RETAIL Zürich ---
{
id: 'need-004',
companyName: 'Retailer Zürich AG',
companyName: 'Ladenlokal Zürich Innenstadt',
contactName: 'Marco Colombo',
assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 500 },
@@ -144,10 +144,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-22T08:00:00Z',
},
// --- need-005: TechStart GmbH — OFFICE Zug/Zürich ---
// --- need-005: OFFICE Zug/Zürich ---
{
id: 'need-005',
companyName: 'TechStart GmbH',
companyName: 'Bürofläche Zug / Zürich',
contactName: 'Florian Keller',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 700 },
@@ -179,10 +179,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-08T10:00:00Z',
},
// --- need-006: Lager & Spedition AG — LOGISTICS Winterthur ---
// --- need-006: LOGISTICS Winterthur ---
{
id: 'need-006',
companyName: 'Lager & Spedition AG',
companyName: 'Lagerfläche Winterthur',
contactName: 'Beat Zimmermann',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 1200, max: 3000 },
@@ -209,10 +209,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-09T16:00:00Z',
},
// --- need-007: Creative Studios AG — MIXED Zürich/Bern ---
// --- need-007: MIXED Zürich-West ---
{
id: 'need-007',
companyName: 'Creative Studios AG',
companyName: 'Gewerbefläche Zürich-West',
contactName: 'Nora Hauser',
assetType: AssetType.MIXED,
requiredArea: { min: 800, max: 1500 },
@@ -244,10 +244,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-03T11:00:00Z',
},
// --- need-008: Berner Produzenten GmbH — PRODUCTION Bern ---
// --- need-008: PRODUCTION Bern ---
{
id: 'need-008',
companyName: 'Berner Produzenten GmbH',
companyName: 'Produktionshalle Bern',
contactName: 'Hans Lüthi',
assetType: AssetType.PRODUCTION,
requiredArea: { min: 2000, max: 4000 },
@@ -274,10 +274,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-20T12:00:00Z',
},
// --- need-009: Geneva Commerce SA — RETAIL Genf ---
// --- need-009: RETAIL Genf ---
{
id: 'need-009',
companyName: 'Geneva Commerce SA',
companyName: 'Commerce Genève Centre',
contactName: 'Pierre Dupont',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 400 },
@@ -308,10 +308,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-04-18T09:00:00Z',
},
// --- need-011: Stadtladen Bern GmbH — RETAIL Bern Innenstadt ---
// --- need-011: RETAIL Bern Innenstadt ---
{
id: 'need-011',
companyName: 'Stadtladen Bern GmbH',
companyName: 'Ladenlokal Bern Altstadt',
contactName: 'Katrin Müller',
assetType: AssetType.RETAIL,
requiredArea: { min: 200, max: 400 },
@@ -342,10 +342,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-18T10:00:00Z',
},
// --- need-010: St.Galler Büros AG — OFFICE St.Gallen ---
// --- need-010: OFFICE St.Gallen ---
{
id: 'need-010',
companyName: 'St.Galler Büros AG',
companyName: 'Bürofläche St. Gallen',
contactName: 'Brigitte Fässler',
assetType: AssetType.OFFICE,
requiredArea: { min: 400, max: 800 },
@@ -377,10 +377,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-06T13:00:00Z',
},
// --- need-uc-a: Renato's Fahrradhändler — Use Case A ---
// --- need-uc-a: RETAIL Zürich EG ---
{
id: 'need-uc-a',
companyName: 'Velo City GmbH',
companyName: 'Ladenlokal Zürich EG Schaufenster',
contactName: 'Renato Marchetti',
assetType: AssetType.RETAIL,
requiredArea: { min: 120, max: 160 },
@@ -411,10 +411,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// --- need-uc-b: Renato's Umzugsfirma — Use Case B ---
// --- need-uc-b: OFFICE Zürich-West klein ---
{
id: 'need-uc-b',
companyName: 'Alp Transit Umzüge GmbH',
companyName: 'Bürofläche Zürich-West klein',
contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE,
requiredArea: { min: 140, max: 200 },
@@ -445,10 +445,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z',
},
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
// --- need-012: LIGHT_INDUSTRIAL Pratteln ---
{
id: 'need-012',
companyName: 'Gewerbe Solutions AG',
companyName: 'Gewerbefläche Pratteln',
contactName: 'Andreas Weber',
assetType: AssetType.LIGHT_INDUSTRIAL,
requiredArea: { min: 400, max: 750 },
@@ -477,10 +477,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-01T09:00:00Z',
},
// --- need-013: Bern Consulting AG — OFFICE Bern ---
// --- need-013: OFFICE Bern ---
{
id: 'need-013',
companyName: 'Bern Consulting AG',
companyName: 'Bürofläche Bern Bahnhof',
contactName: 'Sabine Gerber',
assetType: AssetType.OFFICE,
requiredArea: { min: 180, max: 320 },
@@ -512,10 +512,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-05T10:00:00Z',
},
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
// --- need-014: RETAIL Winterthur ---
{
id: 'need-014',
companyName: 'Winterthur Handel GmbH',
companyName: 'Retailfläche Winterthur',
contactName: 'Pascal Brunner',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 250 },
@@ -546,10 +546,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-03-20T11:00:00Z',
},
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
// --- need-015: OFFICE Luzern ---
{
id: 'need-015',
companyName: 'Luzern Advisory GmbH',
companyName: 'Bürofläche Luzern',
contactName: 'Markus Bucher',
assetType: AssetType.OFFICE,
requiredArea: { min: 350, max: 600 },
@@ -581,10 +581,10 @@ export const mockNeeds: Need[] = [
updatedAt: '2026-04-10T14:00:00Z',
},
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
// --- need-uc-c: OFFICE Zürich Premium ---
{
id: 'need-uc-c',
companyName: 'Wealth Advisory Partners AG',
companyName: 'Repräsentanzbüro Zürich Premium',
contactName: 'Renato Marchetti',
assetType: AssetType.OFFICE,
requiredArea: { min: 450, max: 560 },
@@ -616,4 +616,138 @@ export const mockNeeds: Need[] = [
createdAt: '2025-05-20T10:00:00Z',
updatedAt: '2025-05-22T10:00:00Z',
},
// --- org-mobimo: eigene Suchprofile der Mobimo Management AG ---
{
id: 'need-mob-001',
companyName: 'Bürofläche Zürich City',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 300, max: 600 },
preferredLocations: ['Zürich', 'Zürich City', 'Zürich Kreis 1', 'Zürich Kreis 4'],
excludedLocations: [],
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 20000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-10-01',
latestMoveIn: '2026-03-01',
contractDurationMonths: 60,
flexibleTiming: true,
},
mustCriteriaText: ['Gute ÖV-Anbindung', 'Repräsentative Lage', 'Klimaanlage'],
softFactors: {
minPrestige: 70,
minAccessibility: 80,
requireParking: false,
maxPublicTransportMinutes: 8,
},
weightingProfile: {
area: 0.18, location: 0.24, budget: 0.16, timing: 0.12,
prestige: 0.10, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.91,
notificationConfig: { enabled: true, minScore: 80, emailAddress: 'sandra.koch@mobimo.ch' },
organizationId: 'org-mobimo',
createdAt: '2025-05-10T09:00:00Z',
updatedAt: '2025-06-01T10:00:00Z',
},
{
id: 'need-mob-002',
companyName: 'Showroom Bern Innenstadt',
contactName: 'Sandra Koch',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 300 },
preferredLocations: ['Bern', 'Bern Innenstadt', 'Bern Gurtengasse', 'Bern Marktgasse'],
excludedLocations: [],
budgetRange: { maxPerSqm: 380, maxMonthlyTotal: 8000, currency: 'CHF' },
timing: {
earliestMoveIn: '2025-11-01',
latestMoveIn: '2026-06-01',
contractDurationMonths: 36,
flexibleTiming: false,
},
mustCriteriaText: ['Schaufenster Erdgeschoss', 'Hoher Publikumsverkehr', 'Liefermöglichkeit'],
softFactors: {
minPrestige: 65,
minAccessibility: 75,
requireParking: false,
maxPublicTransportMinutes: 5,
},
weightingProfile: {
area: 0.14, location: 0.28, budget: 0.15, timing: 0.10,
prestige: 0.08, accessibility: 0.07, expansionPotential: 0.02, flexibility: 0.03,
visibility: 0.08, footfall: 0.04, talentAccess: 0.00, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.87,
notificationConfig: { enabled: false, minScore: 75 },
organizationId: 'org-mobimo',
createdAt: '2025-04-20T11:00:00Z',
updatedAt: '2025-05-15T09:00:00Z',
},
{
id: 'need-mob-003',
companyName: 'Lager & Service Schaffhausen',
contactName: 'Sandra Koch',
assetType: AssetType.LOGISTICS,
requiredArea: { min: 800, max: 1500 },
preferredLocations: ['Schaffhausen', 'Neuhausen am Rheinfall', 'Thayngen'],
excludedLocations: [],
budgetRange: { maxPerSqm: 160, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-01-01',
latestMoveIn: '2026-09-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Autobahnanschluss < 10 Min', 'Rampe / Ladetor', 'Büroanteil mind. 80 m²'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.22, location: 0.20, budget: 0.20, timing: 0.12,
prestige: 0.01, accessibility: 0.12, expansionPotential: 0.05, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.01, esg: 0.02, taxEnvironment: 0.01,
},
confidenceInCriteria: 0.83,
notificationConfig: { enabled: true, minScore: 85 },
organizationId: 'org-mobimo',
createdAt: '2025-06-01T08:00:00Z',
updatedAt: '2025-06-10T14:00:00Z',
},
{
id: 'need-mob-004',
companyName: 'Repräsentanzfläche Zürich-West',
contactName: 'Sandra Koch',
assetType: AssetType.OFFICE,
requiredArea: { min: 500, max: 900 },
preferredLocations: ['Zürich-West', 'Zürich Kreis 5', 'Zürich Kreis 4', 'Zürich Hardbrücke'],
excludedLocations: [],
budgetRange: { maxPerSqm: 520, maxMonthlyTotal: 35000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-03-01',
latestMoveIn: '2026-12-01',
contractDurationMonths: 84,
flexibleTiming: false,
},
mustCriteriaText: ['Moderner Neubau oder Kernsanierung', 'Kollaborative Fläche', 'Fahrradabstellplätze', 'Minergie oder LEED-zertifiziert'],
softFactors: {
minPrestige: 75,
minAccessibility: 85,
requireParking: false,
maxPublicTransportMinutes: 6,
},
weightingProfile: {
area: 0.16, location: 0.22, budget: 0.14, timing: 0.10,
prestige: 0.12, accessibility: 0.09, expansionPotential: 0.05, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.93,
organizationId: 'org-mobimo',
createdAt: '2025-03-15T13:00:00Z',
updatedAt: '2025-06-05T11:00:00Z',
},
]
+1 -1
View File
@@ -153,7 +153,7 @@ export const mockProperties: Property[] = [
parkingSpots: 8,
publicTransportMinutes: 5,
},
hardFacts: { fitOut: 'BASIC' },
hardFacts: { fitOut: 'BASIC', fitOutByLandlord: true },
floorLevel: 2,
expansionPotentialSqm: 200,
contractDurationMonths: 48,
+13 -13
View File
@@ -27,7 +27,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Mieter hat bisher keine Verlängerungsabsicht signalisiert. Erstkontakt dringend.',
activity: [
{ at: '2026-04-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-04-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-10T14:30:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Mieter angerufen, kein Rückruf erhalten' },
],
organizationId: 'org-wincasa',
@@ -56,7 +56,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: true,
note: 'Break-Option läuft am 01.09 ab — Frist zur Ausübung ist 90 Tage vorher, also bis 03.06.',
activity: [
{ at: '2026-03-15T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-03-15T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-05T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieterdossier vorbereitet' },
],
organizationId: 'org-wincasa',
@@ -83,7 +83,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Gebäudeversicherung läuft am 30.06 ab. Police-Nummer ZG-2022-9912.',
activity: [
{ at: '2026-04-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-04-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-12T15:00:00Z', by: 'Sandra Wyss', action: 'NOTED', note: 'Offerte von Mobiliar angefordert' },
],
organizationId: 'org-wincasa',
@@ -140,7 +140,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: true,
note: 'Indexierte Mietanpassung per 01.10 möglich. LIK-Index prüfen.',
activity: [
{ at: '2026-04-05T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-05T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-18T09:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'LIK-Daten für Q1 2026 abrufbar' },
],
organizationId: 'org-wincasa',
@@ -168,7 +168,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Frist für Vertragsverhandlung: 9 Monate vor Ablauf. Markt Bern Industrie angespannt.',
activity: [
{ at: '2026-03-01T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-03-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-01T08:00:00Z',
@@ -195,7 +195,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Einheit noch nicht für Pre-Market freigegeben. 14 Monate Lead Time empfohlen.',
activity: [
{ at: '2026-04-18T10:00:00Z', by: 'Thomas Huber', action: 'CREATED', note: 'Pre-Market-Freigabe ausstehend' },
{ at: '2026-04-18T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
{ at: '2026-05-02T11:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Eigentümer informiert, Freigabe ausstehend' },
],
organizationId: 'org-wincasa',
@@ -250,7 +250,7 @@ export const mockReminders: Reminder[] = [
snoozedUntil: '2026-05-27',
note: 'Mieter erwägt Flächenreduktion. Gespräch vereinbart für 27.05.',
activity: [
{ at: '2026-04-02T09:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-02T09:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-14T16:00:00Z', by: 'Anna Meier', action: 'SNOOZED', note: 'Bis nach Gespräch mit Mieter zurückgestellt' },
],
organizationId: 'org-wincasa',
@@ -279,7 +279,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.LOW,
schattenmarktEnabled: true,
activity: [
{ at: '2026-03-20T10:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-03-20T10:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-03-20T10:00:00Z',
@@ -306,7 +306,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Erstgespräch über Verlängerung bis 01.07 einleiten.',
activity: [
{ at: '2026-03-10T08:00:00Z', by: 'Thomas Huber', action: 'CREATED' },
{ at: '2026-03-10T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-05-02T09:00:00Z', by: 'Thomas Huber', action: 'NOTED', note: 'Vermieterseite wünscht Mietpreiserhöhung +5%' },
],
organizationId: 'org-wincasa',
@@ -359,7 +359,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Pre-Market Freigabe 9 Monate vor Vertragsende. Eigentümer-Freigabe einholen.',
activity: [
{ at: '2026-04-25T11:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-04-25T11:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten & Marktdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-04-25T11:00:00Z',
@@ -415,7 +415,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
schattenmarktEnabled: true,
activity: [
{ at: '2026-02-01T08:00:00Z', by: 'Anna Meier', action: 'CREATED', note: 'Vertragsablauf-Erinnerung (4 Monate)' },
{ at: '2026-02-01T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-02-01T08:00:00Z',
@@ -440,7 +440,7 @@ export const mockReminders: Reminder[] = [
shadowMarketRisk: ShadowMarketRisk.NONE,
schattenmarktEnabled: false,
activity: [
{ at: '2026-04-20T08:00:00Z', by: 'Sandra Wyss', action: 'CREATED' },
{ at: '2026-04-20T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
],
organizationId: 'org-wincasa',
createdAt: '2026-04-20T08:00:00Z',
@@ -522,7 +522,7 @@ export const mockReminders: Reminder[] = [
schattenmarktEnabled: false,
note: 'Mietpreisanpassung +2.8% vereinbart, ab 01.09 gültig.',
activity: [
{ at: '2026-02-15T08:00:00Z', by: 'Anna Meier', action: 'CREATED' },
{ at: '2026-02-15T08:00:00Z', by: 'System', action: 'CREATED', note: 'Automatisch aus Vertragsdaten erstellt' },
{ at: '2026-04-14T11:00:00Z', by: 'Anna Meier', action: 'NOTED', note: 'Mieter hat Anpassung akzeptiert' },
{ at: '2026-04-15T14:00:00Z', by: 'Anna Meier', action: 'COMPLETED', note: 'Nachtrag unterzeichnet' },
],
+97 -64
View File
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react'
import { Box, Typography } from '@mui/material'
import { useNavigate } from 'react-router'
import { useEffect, useRef, useState } from 'react'
import { Box, Tab, Tabs, Typography } from '@mui/material'
import { useLocation, useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import {
NeedBuilderProgress,
@@ -11,12 +11,15 @@ import {
} from '../../components/demand'
import { AISearchActionBar } from '../../components/demand/AISearchActionBar'
import { AISearchSavePreview } from '../../components/demand/AISearchSavePreview'
import { SavedProfilesTab } from '../../components/demand/SavedProfilesTab'
import { AddToPipelineDialog } from '../../components/shortlist'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { useParseNeed } from '../../hooks/useAI'
import { useCreateNeed } from '../../hooks/useNeeds'
import { useCreateNeed, useNeed, useNeedProfiles } from '../../hooks/useNeeds'
import { useDefaultWeights } from '../../hooks/useWeighting'
import { NeedBuilderStep } from '../../domain/needBuilder'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
import { generateSummary, buildNeedInput } from '../../services/aiSearch/needSearchMapper'
import { generateSummary, buildNeedInput, needToParsedCriteria } from '../../services/aiSearch/needSearchMapper'
type ActionIntent = 'search' | 'save-profile'
@@ -41,8 +44,25 @@ export default function AISearch() {
const [isAnonymous, setIsAnonymous] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState(0)
const { data: savedProfiles = [] } = useNeedProfiles()
const locationPrefillId = (useLocation().state as { prefillNeedId?: string } | null)?.prefillNeedId
const [internalPrefillId, setInternalPrefillId] = useState<string | undefined>()
const prefillNeedId = internalPrefillId ?? locationPrefillId
const { data: prefillNeed } = useNeed(prefillNeedId ?? '')
const isManualTextRef = useRef(false)
useEffect(() => {
if (!prefillNeed) return
setCriteria(needToParsedCriteria(prefillNeed))
setWeights(prefillNeed.weightingProfile as Record<WeightingKey, number>)
setNeedTitle(prefillNeed.companyName)
setWeightingKey(k => k + 1)
setTab(0)
}, [prefillNeed])
function handleCriteriaChange(next: ParsedNeedCriteria) {
setCriteria(next)
if (!isManualTextRef.current) {
@@ -56,7 +76,6 @@ export default function AISearch() {
isManualTextRef.current = text !== ''
setIsAutoGen(false)
setInputText(text)
// Clear stale parse results when user edits the text manually
setCriteria({})
setParseResult(null)
}
@@ -198,78 +217,92 @@ export default function AISearch() {
const isProcessing = step === NeedBuilderStep.PARSING || step === NeedBuilderStep.SAVING
const isSaveStep = step === NeedBuilderStep.READY_TO_SAVE || step === NeedBuilderStep.SAVING
const overallConfidence = parseResult
? (() => {
const entries = Object.entries(parseResult.confidenceByField)
return entries.length > 0 ? entries.reduce((s, [, v]) => s + v, 0) / entries.length : 0
})()
: 0
const vals = parseResult ? Object.values(parseResult.confidenceByField) : []
const overallConfidence = vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
const savedCount = savedProfiles.filter(n => !n.status || n.status === 'ACTIVE' || n.status === 'DRAFT').length
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Flächensuche</Typography>
<AddToPipelineDialog />
<InquiryQuickDialog />
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Flächensuche</Typography>
<Typography variant="body2" color="text.secondary">
Sprechen, schreiben oder Felder ausfüllen dann sofort suchen oder als Suchprofil speichern
</Typography>
</Box>
<NeedBuilderProgress step={step} />
<Tabs
value={tab}
onChange={(_, v: number) => setTab(v)}
sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0, minHeight: 44 }}
>
<Tab label="Neue Suche" sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
<Tab label={`Gespeicherte Profile (${savedCount})`} sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.875rem', minHeight: 44, py: 0 }} />
</Tabs>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{tab === 0 && (
<>
<NeedBuilderProgress step={step} />
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* IDLE: full form */}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 1200, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
{(step === NeedBuilderStep.IDLE || step === NeedBuilderStep.PARSING) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: { md: 900, xl: 1200 }, mx: 'auto' }}>
<VoiceNeedInput
text={inputText}
onTextChange={handleTextChange}
isAutoGen={isAutoGen}
onAiSubmit={handleAiAutofill}
isAnalyzing={step === NeedBuilderStep.PARSING}
/>
<Box className="grid grid-cols-2 gap-4" sx={{ alignItems: 'start' }}>
<NeedInput criteria={criteria} onCriteriaChange={handleCriteriaChange} />
<WeightingEditor
key={weightingKey}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
/>
</Box>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
</Box>
)}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
weights={weights}
onChange={setWeights}
assetType={criteria.assetType}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
</Box>
<AISearchActionBar
canProceed={canProceed}
isSearching={isProcessing && intent === 'search'}
isSavingProfile={isProcessing && intent === 'save-profile'}
onSearch={() => handleAction('search')}
onSaveProfile={() => handleAction('save-profile')}
/>
)}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
)}
</>
)}
{/* Save preview step */}
{isSaveStep && parseResult && editedCriteria && (
<AISearchSavePreview
criteria={editedCriteria}
weights={weights}
parseResult={parseResult}
needTitle={needTitle}
overallConfidence={overallConfidence}
isSaving={step === NeedBuilderStep.SAVING}
isAnonymous={isAnonymous}
onNeedTitleChange={setNeedTitle}
onAnonymousChange={setIsAnonymous}
onBack={() => setStep(NeedBuilderStep.IDLE)}
onSave={handleSaveProfile}
/>
)}
{/* Error */}
{step === NeedBuilderStep.ERROR && (
<NeedBuilderErrorState message={error ?? 'Unbekannter Fehler'} onRetry={handleRetry} />
)}
</Box>
{tab === 1 && (
<Box sx={{ flex: 1, overflow: 'hidden' }}>
<SavedProfilesTab onNewSearch={(id) => { setInternalPrefillId(id); setTab(0) }} />
</Box>
)}
</Box>
)
}
+51 -37
View File
@@ -5,10 +5,9 @@ import {
InputAdornment, Alert,
} from '@mui/material'
import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'lucide-react'
import { mockDemandInquiries } from '../../mock-data/demandInquiries'
import { useInquiryStore } from '../../stores/inquiryStore'
import { useActiveInquiries, useSendInquiryReply, useMarkThreadAsRead } from '../../hooks/useInquiries'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import type { InquiryMessage } from '../../domain/inquiry'
import { mockProperties } from '../../mock-data/properties'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
import { AnfragenInquiryItem } from '../../components/demand/AnfragenInquiryItem'
@@ -18,10 +17,9 @@ import { useSessionStore } from '../../stores/sessionStore'
// ── Config ────────────────────────────────────────────────────────────────────
const FILTER_TABS = [
{ key: 'all', label: 'Alle' },
{ key: 'new', label: 'Neu' },
{ key: 'in_progress', label: 'Aktiv' },
{ key: 'answered', label: 'Beantwortet' },
{ key: 'all', label: 'Alle' },
{ key: 'INQUIRY', label: 'Gesendet' },
{ key: 'OFFER', label: 'Erhalten' },
]
// ── Anfragen page ─────────────────────────────────────────────────────────────
@@ -36,20 +34,23 @@ export default function Anfragen() {
const preselectedId = searchParams.get('inquiry')
const { currentUser } = useSessionStore()
const storeInquiries = useInquiryStore(s => s.sentInquiries)
const [inquiries, setInquiries] = useState(mockDemandInquiries)
const allInquiries = [...storeInquiries, ...inquiries]
const { data: allInquiries = [] } = useActiveInquiries({ tenantOrgId: currentUser?.organizationId })
const sendReply = useSendInquiryReply()
const markRead = useMarkThreadAsRead()
const [selectedId, setSelectedId] = useState<string | null>(
preselectedId ?? mockDemandInquiries[0]?.id ?? null
)
const [selectedId, setSelectedId] = useState<string | null>(preselectedId ?? null)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const [kindFilter, setKindFilter] = useState('all')
const [replyText, setReplyText] = useState('')
const [mobileShowChat, setMobileShowChat] = useState(!!preselectedId)
const [kiAlert, setKiAlert] = useState<{ title: string; stage: string } | null>(null)
const threadRef = useRef<HTMLDivElement>(null)
// Default-Auswahl: erste Konversation, sobald geladen
useEffect(() => {
if (!selectedId && allInquiries.length > 0) setSelectedId(allInquiries[0].id)
}, [allInquiries, selectedId])
const filtered = allInquiries.filter(inq => {
const q = search.toLowerCase()
const matchesSearch = !q ||
@@ -59,12 +60,14 @@ export default function Anfragen() {
(inq.propertyAddress?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerName?.toLowerCase().includes(q) ?? false) ||
(inq.propertyManagerCompany?.toLowerCase().includes(q) ?? false)
const matchesStatus = statusFilter === 'all' || inq.status === statusFilter
return matchesSearch && matchesStatus
const matchesKind = kindFilter === 'all' || (inq.kind ?? 'INQUIRY') === kindFilter
return matchesSearch && matchesKind
})
const selected = allInquiries.find(i => i.id === selectedId) ?? null
const totalUnread = allInquiries.reduce((sum, i) => sum + i.unreadCount, 0)
const offeredProperties = (selected?.kind === 'OFFER' ? selected.offeredPropertyIds ?? [] : [])
.map(id => mockProperties.find(p => p.id === id)).filter(Boolean)
// Pipeline link for currently selected inquiry
const linkedPipelineItem = selected?.propertyId
@@ -86,7 +89,7 @@ export default function Anfragen() {
function handleSelect(id: string) {
setSelectedId(id)
setInquiries(prev => prev.map(i => i.id === id ? { ...i, isRead: true, unreadCount: 0 } : i))
markRead.mutate(id)
setMobileShowChat(true)
setKiAlert(null)
}
@@ -95,20 +98,10 @@ export default function Anfragen() {
if (!replyText.trim() || !selectedId) return
const text = replyText.trim()
const msg: InquiryMessage = {
id: `msg-${Date.now()}`,
sendReply.mutate({
inquiryId: selectedId,
senderType: 'tenant',
senderName: 'Sie',
body: text,
attachments: [],
createdAt: new Date().toISOString(),
}
setInquiries(prev => prev.map(i =>
i.id === selectedId
? { ...i, thread: [...i.thread, msg], status: 'in_progress', updatedAt: new Date().toISOString() }
: i
))
payload: { body: text, senderType: 'tenant', senderName: currentUser?.name ?? 'Sie' },
})
setReplyText('')
// KI: detect stage transition from message content
@@ -134,8 +127,8 @@ export default function Anfragen() {
{/* ── Left panel ── */}
<Box
sx={{
width: { xs: mobileShowChat ? 0 : '100%', md: 320 },
minWidth: { md: 320 },
width: { xs: mobileShowChat ? 0 : '100%', md: 280, lg: 300, xl: 320 },
minWidth: { md: 280, lg: 300, xl: 320 },
flexShrink: 0,
display: 'flex', flexDirection: 'column',
borderRight: `1px solid ${DS_BORDER.default}`,
@@ -160,13 +153,13 @@ export default function Anfragen() {
/>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{FILTER_TABS.map(tab => (
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setStatusFilter(tab.key)}
<Chip key={tab.key} label={tab.label} size="small" onClick={() => setKindFilter(tab.key)}
sx={{
height: 22, fontSize: '0.7rem', cursor: 'pointer',
bgcolor: statusFilter === tab.key ? 'primary.main' : DS_BG.subtle,
color: statusFilter === tab.key ? 'white' : DS_TEXT.secondary,
fontWeight: statusFilter === tab.key ? 700 : 400,
'&:hover': { bgcolor: statusFilter === tab.key ? 'primary.dark' : DS_BG.muted },
bgcolor: kindFilter === tab.key ? 'primary.main' : DS_BG.subtle,
color: kindFilter === tab.key ? 'white' : DS_TEXT.secondary,
fontWeight: kindFilter === tab.key ? 700 : 400,
'&:hover': { bgcolor: kindFilter === tab.key ? 'primary.dark' : DS_BG.muted },
}}
/>
))}
@@ -291,6 +284,27 @@ export default function Anfragen() {
</Box>
)}
{/* Angebot: enthaltene Objekte */}
{offeredProperties.length > 0 && (
<Box sx={{ px: { xs: 2, md: 3 }, pt: 1.5, flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.4, display: 'block', mb: 0.75 }}>
Enthaltene Objekte ({offeredProperties.length})
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{offeredProperties.map(p => p && (
<Chip
key={p.id}
icon={<Building2 size={12} />}
label={`${p.title} · CHF ${p.rentPricePerSqm}/m²`}
size="small"
onClick={() => navigate(`/demand/property/${p.id}`)}
sx={{ cursor: 'pointer', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, border: `1px solid ${DS_SURFACE.blue.border}`, fontSize: '0.72rem', '& .MuiChip-icon': { color: DS_TEXT.signalDark } }}
/>
))}
</Box>
</Box>
)}
{/* Thread */}
<Box ref={threadRef} sx={{ flex: 1, overflowY: 'auto', px: { xs: 2, md: 3 }, py: 2.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{selected.thread.map(msg => (
+4 -4
View File
@@ -49,8 +49,8 @@ export default function Compare() {
if (compareItems.length === 0) {
return (
<Box>
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Vergleich</Typography>
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Vergleich</Typography>
</Box>
<CompareEmptyState />
</Box>
@@ -62,9 +62,9 @@ export default function Compare() {
<AddToPipelineDialog />
{/* Page header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Vergleich</Typography>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Vergleich</Typography>
<Chip label={`${compareItems.length} Ergebnisse`} size="small" />
</Box>
<Button variant="outlined" size="small" color="error" onClick={clearCompare}>
+28 -3
View File
@@ -8,7 +8,7 @@ import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { getCityIntelligence, lookupCityCoords } from '../../lib/locationIntelligence'
import { NextActionsPanel, FitOutCostPanel } from '../../components/match-detail'
import { NextActionsPanel, FitOutCostPanel, FitOutAdvicePanel, MarketPricePanel } from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches'
@@ -39,6 +39,7 @@ export default function MatchDetail() {
const { openInquiryDialog } = useInquiryStore()
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
const detailUnit = property?.units?.find(u => u.id === match?.unitId)
if (isLoading) {
return (
@@ -171,7 +172,7 @@ export default function MatchDetail() {
/>
{/* ── Main content ── */}
<Box sx={{ maxWidth: { sm: 780, md: 960, lg: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ maxWidth: { sm: 700, md: 860, lg: 1000, xl: 1100 }, mx: 'auto', px: { xs: 1.5, sm: 3 }, py: { xs: 2, sm: 3 }, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* ── Section A: Warum dieser Match? ── */}
<Paper sx={{ p: 2.5 }}>
@@ -220,7 +221,31 @@ export default function MatchDetail() {
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm ?? 0}
fitOutByLandlord={property.hardFacts.fitOutByLandlord}
/>
)}
{/* ── KI-Ausbauempfehlung: nur wenn Mieter ausbaut (SHELL/BASIC, kein Vermieter-Übernahme) ── */}
{!isFuture && property?.hardFacts?.fitOut
&& !property.hardFacts.fitOutByLandlord
&& (property.hardFacts.fitOut === 'SHELL' || property.hardFacts.fitOut === 'BASIC') && (
<FitOutAdvicePanel
fitOut={property.hardFacts.fitOut}
areaSqm={property.areaSqm}
mabPerSqm={property.hardFacts.mieterausbaubeitragPerSqm ?? 0}
rentPricePerSqm={property.rentPricePerSqm}
requiredFitOut={need?.requiredFitOut}
tenantBudgetPerSqm={need?.fitOutBudgetMaxPerSqm}
/>
)}
{/* ── Marktpreis-Einschätzung (Angebot vs. Markt; bei Pre-Market inkl. Zukunftspreis) ── */}
{property?.location?.city && property.rentPricePerSqm > 0 && (
<MarketPricePanel
city={property.location.city}
assetType={property.assetType}
askingRentPerSqm={detailUnit?.rentPricePerSqm ?? property.rentPricePerSqm}
futureRentPerSqm={isFuture ? detailUnit?.expectedRentPerSqm : undefined}
/>
)}
+4 -4
View File
@@ -73,11 +73,11 @@ export default function Pipeline() {
{/* Header */}
<Box sx={{
bgcolor: 'white', borderBottom: '1px solid #e2e8f0',
px: 3, py: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
bgcolor: 'white', borderBottom: '1px solid #e8e7e4',
px: 3, py: 2.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0,
}}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Deal Pipeline</Typography>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Deal Pipeline</Typography>
<Typography variant="body2" color="text.secondary">
Von der ersten Idee bis zum Abschluss per Drag &amp; Drop verschieben
</Typography>
@@ -109,7 +109,7 @@ export default function Pipeline() {
return (
<Box
key={stage.key}
sx={{ minWidth: syncedSelected ? 190 : 230, maxWidth: syncedSelected ? 230 : 270, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
sx={{ minWidth: syncedSelected ? 170 : 200, maxWidth: syncedSelected ? 210 : 260, flexShrink: 0, display: 'flex', flexDirection: 'column' }}
>
<Box sx={{ py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 700, color: stage.color, fontSize: '0.8125rem' }}>
+22 -102
View File
@@ -1,11 +1,10 @@
import { useState, useMemo, useEffect } from 'react'
import { Box, Button, Card, Chip, Typography } from '@mui/material'
import { Box, Button, Typography } from '@mui/material'
import { Zap } from 'lucide-react'
import { useNavigate, useLocation } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useUnifiedResults } from '../../hooks/useUnifiedResults'
import { useNeeds } from '../../hooks/useNeeds'
import { DecisionContextPanel } from '../../components/ui'
import {
FeedEmptyState,
FeedSkeleton,
@@ -72,16 +71,14 @@ export default function Results() {
const isStaff = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
// Counts over the full result set — single pass, stable across filter changes
const { platformCount, maisonWorkCount, futureCount, missingDataCount } = useMemo(() => {
let platform = 0, maison = 0, future = 0, missing = 0
const { platformCount, maisonWorkCount, futureCount } = useMemo(() => {
let platform = 0, maison = 0, future = 0
for (const r of results) {
if (r.resultType === 'VERIFIED_PORTFOLIO') platform++
else if (r.resultType === 'MAISON_WORK') maison++
else if (r.resultType === 'FUTURE_AVAILABILITY') future++
if ('match' in r && Array.isArray((r as { match?: { missingData?: unknown[] } }).match?.missingData) &&
((r as { match?: { missingData?: unknown[] } }).match?.missingData?.length ?? 0) > 0) missing++
}
return { platformCount: platform, maisonWorkCount: maison, futureCount: future, missingDataCount: missing }
return { platformCount: platform, maisonWorkCount: maison, futureCount: future }
}, [results])
// Filter + sort in one memo — only reruns when inputs change
@@ -98,7 +95,6 @@ export default function Results() {
return sortResults(filtered, sortBy)
}, [results, filterSource, sortBy, showFutureAvailability, showOwnProperties])
const strongCount = useMemo(() => sorted.filter(r => r.matchScore >= 80).length, [sorted])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -113,103 +109,27 @@ export default function Results() {
onViewChange={v => { setView(v); localStorage.setItem('view-results', v) }}
/>
{!isLoading && results.length > 0 && (
<DecisionContextPanel
decision="Welche Treffer lohnen sich für die Shortlist — und welche tragen Risiken, die zuerst geprüft werden müssen?"
context={activeNeed ? `Suche: ${activeNeed.assetType} · ${activeNeed.requiredArea.min}${activeNeed.requiredArea.max} m² · ${activeNeed.preferredLocations.join(', ')}` : undefined}
metrics={[
...(strongCount > 0 ? [{ label: 'starke Treffer (≥80)', value: strongCount, severity: 'positive' as const }] : []),
...(maisonWorkCount > 0 ? [{ label: 'Maison Work', value: maisonWorkCount, severity: 'neutral' as const }] : []),
...(futureCount > 0 ? [{ label: 'Zukunftssignale', value: futureCount, severity: 'neutral' as const }] : []),
...(missingDataCount > 0 ? [{ label: 'mit Datenlücken', value: missingDataCount, severity: 'warning' as const }] : []),
]}
risks={[
...(missingDataCount > 0 ? [`${missingDataCount} Treffer mit fehlenden Daten — Einschätzung eingeschränkt`] : []),
]}
actions={[
{ label: 'Vergleich öffnen', onClick: () => navigate('/demand/compare') },
{ label: 'Suche anpassen', onClick: () => navigate('/demand/ai-search') },
]}
/>
)}
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, pb: 10 }}>
{/* Aktive Suche */}
{activeNeed && (
<Card sx={{ bgcolor: '#eff6ff', p: 2, mb: 2, border: '1px solid #bfdbfe' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }} color="#152642">
Aktive Suche: {activeNeed.companyName}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px' }}>
<Typography variant="caption" color="text.secondary">
<strong>Typ:</strong> {activeNeed.assetType}
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Fläche:</strong> {activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m²
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Standort:</strong> {activeNeed.preferredLocations.join(', ')}
</Typography>
{activeNeed.budgetRange && activeNeed.budgetRange.maxPerSqm > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Budget:</strong> max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/Jahr
</Typography>
)}
{activeNeed.timing?.earliestMoveIn && !isNaN(new Date(activeNeed.timing.earliestMoveIn).getTime()) && (
<Typography variant="caption" color="text.secondary">
<strong>Bezug ab:</strong> {new Date(activeNeed.timing.earliestMoveIn).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' })}
</Typography>
)}
{activeNeed.searchRadius && (
<Typography variant="caption" color="text.secondary">
<strong>Radius:</strong> {activeNeed.searchRadius} km
</Typography>
)}
{activeNeed.requiresDivisibility && activeNeed.minDivisibleUnit && (
<Typography variant="caption" color="text.secondary">
<strong>Teilbar ab:</strong> {activeNeed.minDivisibleUnit} m²
</Typography>
)}
{activeNeed.requiredFitOut && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaustandard:</strong> min. {
activeNeed.requiredFitOut === 'BASIC' ? 'Basisausbau' :
activeNeed.requiredFitOut === 'FULL' ? 'Vollausbau' : 'Premiumausbau'
}
</Typography>
)}
{activeNeed.fitOutBudgetMaxPerSqm && (
<Typography variant="caption" color="text.secondary">
<strong>Ausbaubudget:</strong> max. CHF {activeNeed.fitOutBudgetMaxPerSqm}/m²
</Typography>
)}
{activeNeed.mustCriteriaText && activeNeed.mustCriteriaText.length > 0 && (
<Typography variant="caption" color="text.secondary">
<strong>Must-haves:</strong> {activeNeed.mustCriteriaText.slice(0, 3).join(' · ')}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
{activeNeed.isAnonymous && (
<Chip
label="Anonyme Suche"
size="small"
sx={{ bgcolor: '#7c3aed', color: 'white', fontWeight: 700, fontSize: 10, height: 20 }}
/>
)}
<Button
size="small"
variant="text"
onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#152642', flexShrink: 0 }}
>
Suche ändern
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, bgcolor: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: 1.5, px: 2, py: 1.25, mb: 2 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', alignItems: 'center' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
{activeNeed.companyName}
</Typography>
<Typography variant="caption" color="text.secondary">{activeNeed.assetType}</Typography>
<Typography variant="caption" color="text.secondary">{activeNeed.requiredArea.min}{activeNeed.requiredArea.max} m²</Typography>
<Typography variant="caption" color="text.secondary">{activeNeed.preferredLocations.join(', ')}</Typography>
{activeNeed.budgetRange?.maxPerSqm > 0 && (
<Typography variant="caption" color="text.secondary">max. CHF {activeNeed.budgetRange.maxPerSqm}/m²/J</Typography>
)}
</Box>
</Card>
<Button size="small" variant="text" onClick={() => navigate('/demand/ai-search')}
sx={{ color: '#152642', flexShrink: 0, textTransform: 'none', fontSize: '0.75rem' }}>
Suche ändern
</Button>
</Box>
)}
<ResultFilterBar
+14 -4
View File
@@ -2,15 +2,17 @@ import { useState } from 'react'
import { useLocation } from 'react-router'
import { Box, Tab, Tabs, Typography } from '@mui/material'
import { ActiveInquiriesTab, LatentInquiriesTab, OfferWizard } from '../../components/anfragencenter'
import { useSessionStore } from '../../stores/sessionStore'
export default function Anfragencenter() {
const location = useLocation()
const initialTab = (location.state as { tab?: number } | null)?.tab ?? 0
const [tab, setTab] = useState(initialTab)
const orgId = useSessionStore(s => s.currentUser?.organizationId)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ borderBottom: '1px solid #e2e8f0', px: 3, bgcolor: 'white', flexShrink: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700, pt: 2.5, pb: 1 }}>
<Box sx={{ borderBottom: '1px solid #e8e7e4', px: 3, bgcolor: 'white', flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a', pt: 2.5, pb: 1, display: 'block' }}>
Anfragencenter
</Typography>
<Tabs
@@ -21,12 +23,20 @@ export default function Anfragencenter() {
}}
>
<Tab label="Aktive Anfragen" />
<Tab label="Gesendet" />
<Tab label="Latente Anfragen" />
</Tabs>
</Box>
<Box sx={{ flex: 1, overflow: 'hidden' }}>
{tab === 0 && <ActiveInquiriesTab />}
{tab === 1 && <LatentInquiriesTab />}
{tab === 0 && <ActiveInquiriesTab filters={{ organizationId: orgId, kind: 'INQUIRY' }} />}
{tab === 1 && (
<ActiveInquiriesTab
filters={{ organizationId: orgId, kind: 'OFFER' }}
emptyTitle="Keine gesendeten Angebote"
emptyDescription="Angebote aus den latenten Anfragen erscheinen hier, sobald Sie sie versenden."
/>
)}
{tab === 2 && <LatentInquiriesTab />}
</Box>
<OfferWizard />
</Box>
+3 -3
View File
@@ -263,9 +263,9 @@ export default function DataQuality() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
<Typography variant="caption" color="text.secondary">
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Datenpflege</Typography>
<Typography variant="body2" color="text.secondary">
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
</Typography>
</Box>
+2 -2
View File
@@ -49,10 +49,10 @@ export default function FutureAvailability() {
<AddToShortlistDialog />
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Zukunftssignale</Typography>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Zukunftssignale</Typography>
<Typography variant="body2" color="text.secondary">Probabilistische Markt- und Verfügbarkeitssignale</Typography>
</Box>
<Chip
+3 -3
View File
@@ -1046,9 +1046,9 @@ export default function MarketIntelligence() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Page header */}
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Marktchancen</Typography>
<Typography variant="caption" color="text.secondary">
<Box sx={{ px: 3, py: 2.5, bgcolor: 'white', borderBottom: '1px solid #e8e7e4', flexShrink: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Marktchancen</Typography>
<Typography variant="body2" color="text.secondary">
KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise mit Portfolio-Match
</Typography>
</Box>
+2 -2
View File
@@ -76,9 +76,9 @@ export default function MatchCenter() {
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2, flexShrink: 0 }}>
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 2, mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Match Center</Typography>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Match Center</Typography>
<Typography variant="body2" color="text.secondary">Automatisch berechnete Matches</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
+9 -4
View File
@@ -339,10 +339,11 @@ export default function MyListings() {
}
return (
<Box sx={{ maxWidth: 1040, mx: 'auto', px: 3, py: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e8e7e4', px: 3, py: 2.5, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a' }}>Inserate</Typography>
<Typography variant="body2" color="text.secondary">
Direkt erstellte Inserate unabhängig vom Portfolio
</Typography>
@@ -357,6 +358,8 @@ export default function MyListings() {
</Button>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, maxWidth: 1040, width: '100%', mx: 'auto', boxSizing: 'border-box' }}>
{actionError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
{actionError}
@@ -415,12 +418,14 @@ export default function MyListings() {
</Box>
)}
</Box>{/* end scroll container */}
{/* Detail / edit drawer */}
<Drawer
anchor="right"
open={!!detailId}
onClose={() => setDetailId(null)}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: '90vw', md: 480, lg: 540, xl: 560 } } } }}
>
{detailId && (
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} />
+2 -1
View File
@@ -46,7 +46,7 @@ export default function NewListing() {
</Button>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>Neues Inserat erstellen</Typography>
<Typography sx={{ fontWeight: 700, fontSize: '1.125rem', lineHeight: 1.3, color: '#0f172a', mb: 0.5 }}>Neues Inserat erstellen</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{form.isPrefilled
? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.`
@@ -81,6 +81,7 @@ export default function NewListing() {
<TechnicalDetailsSection
floor={form.floor} onFloorChange={form.setFloor}
fitOut={form.fitOut} onFitOutChange={form.setFitOut}
fitOutByLandlord={form.fitOutByLandlord} onFitOutByLandlordChange={form.setFitOutByLandlord}
parking={form.parking} onParkingChange={form.setParking}
ceilingHeight={form.ceilingHeight} onCeilingHeightChange={form.setCeilingHeight}
mieterausbaubeitrag={form.mieterausbaubeitrag} onMieterausbaubeitragChange={form.setMieterausbaubeitrag}
+30 -6
View File
@@ -57,6 +57,9 @@ export default function Properties() {
const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
)
const [columns, setColumns] = useState<3 | 5 | 10>(() =>
(Number(localStorage.getItem('view-properties-cols')) as 3 | 5 | 10) || 3
)
const [headerOpen, setHeaderOpen] = useState(() =>
localStorage.getItem('props-header-open') !== 'false'
)
@@ -109,10 +112,31 @@ export default function Properties() {
title="Objektverwaltung"
subtitle={`${filtered.length} von ${properties.length} Objekten`}
secondaryActions={
<ViewToggle
view={view}
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{view === 'grid' && (
<Box sx={{ display: 'flex', border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
{([3, 5, 10] as const).map(n => (
<Box
key={n}
onClick={() => { setColumns(n); localStorage.setItem('view-properties-cols', String(n)) }}
sx={{
width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600,
bgcolor: columns === n ? '#152642' : 'transparent',
color: columns === n ? 'white' : '#64748b',
'&:hover': { bgcolor: columns === n ? '#162d4a' : '#f1f5f9' },
}}
>
{n}
</Box>
))}
</Box>
)}
<ViewToggle
view={view}
onChange={v => { setView(v); localStorage.setItem('view-properties', v) }}
/>
</Box>
}
/>
@@ -201,7 +225,7 @@ export default function Properties() {
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{view === 'grid' ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 3, p: 3 }}>
{filtered.map(p => (
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} inquiryCount={inquiryCountByProperty.get(p.id) ?? 0} />
))}
@@ -223,7 +247,7 @@ export default function Properties() {
anchor="right"
open={!!selectedId}
onClose={() => setSelectedId(null)}
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : 650, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
slotProps={{ paper: { sx: { width: isMobile ? '100vw' : { md: 520, lg: 580, xl: 640 }, boxShadow: '-4px 0 24px rgba(0,0,0,0.10)' } } }}
>
{selectedId && (
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
+7 -4
View File
@@ -30,12 +30,14 @@ export const LEVEL_TO_SCORE: Record<string, number | undefined> = {
LOW: 0.30, MEDIUM: 0.55, HIGH: 0.85, '': undefined,
}
import { FIT_OUT_LABELS } from '../../lib/constants'
export const FIT_OUT_OPTIONS = [
{ value: '', label: 'Keine Angabe' },
{ value: 'SHELL', label: 'Rohbau' },
{ value: 'BASIC', label: 'Basis-Ausbau' },
{ value: 'FULL', label: 'Vollausbau' },
{ value: 'PREMIUM', label: 'Premiumausbau' },
{ value: 'SHELL', label: FIT_OUT_LABELS.SHELL },
{ value: 'BASIC', label: FIT_OUT_LABELS.BASIC },
{ value: 'FULL', label: FIT_OUT_LABELS.FULL },
{ value: 'PREMIUM', label: FIT_OUT_LABELS.PREMIUM },
]
export interface LocationState {
@@ -51,6 +53,7 @@ export interface LocationState {
propertyId?: string
floor?: string
fitOut?: string
fitOutByLandlord?: boolean
parking?: number
ceilingHeight?: number
mieterausbaubeitragPerSqm?: number
+5 -2
View File
@@ -15,6 +15,7 @@ export function buildCreatePropertyInput(fields: {
softLevels: Record<string, string>
floor: string
fitOut: string
fitOutByLandlord?: boolean
parking: string
ceilingHeight: string
images: string[]
@@ -26,7 +27,7 @@ export function buildCreatePropertyInput(fields: {
const {
assetType, street, houseNumber, postalCode, city,
areaSqm, rentPerSqm, availableFrom, description,
softLevels, floor, fitOut, parking, ceilingHeight, images, floorPlanUrl,
softLevels, floor, fitOut, fitOutByLandlord, parking, ceilingHeight, images, floorPlanUrl,
mieterausbaubeitrag, isFlexible, minLettableSqm,
} = fields
@@ -45,7 +46,9 @@ export function buildCreatePropertyInput(fields: {
const hf = {
floor: floor ? parseInt(floor) : undefined,
fitOut: (fitOut || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined,
mieterausbaubeitragPerSqm: mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
fitOutByLandlord,
// MAB nur relevant, wenn der Mieter ausbaut — sonst nicht speichern
mieterausbaubeitragPerSqm: !fitOutByLandlord && mieterausbaubeitrag ? parseInt(mieterausbaubeitrag) : undefined,
parking: parking ? parseInt(parking) : undefined,
ceilingHeightM: ceilingHeight ? parseFloat(ceilingHeight) : undefined,
}
+36 -3
View File
@@ -1,17 +1,50 @@
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
import type { Inquiry, InquiryStatus, InquiryKind, InquiryMessage, Attachment } from '../domain/inquiry'
export interface InquiryFilters {
status?: InquiryStatus
propertyId?: string
organizationId?: string
organizationId?: string // Supply-Perspektive
tenantOrgId?: string // Demand-Perspektive
kind?: InquiryKind
}
/** Demand→Supply: neue Anfrage zu einem Objekt. */
export interface CreateInquiryInput {
organizationId: string // Supply-Org (Objekt-Eigentümer)
tenantOrgId: string // Demand-Org (Absender)
propertyId: string
needId?: string
tenantName: string
tenantCompany?: string
tenantEmail?: string
propertyAddress?: string
subject: string
message: string
matchScore?: number
}
/** Supply→Demand: Angebot zu einem latenten Bedarf. */
export interface CreateOfferInput {
organizationId: string // Supply-Org (Absender)
tenantOrgId: string // Demand-Org (Empfänger, aus need.organizationId)
needId: string
propertyId: string // primäres Objekt (Kompatibilität)
offeredPropertyIds: string[]
subject: string
message: string
senderName: string // Verwaltungs-/Org-Name
recipientName?: string
attachments?: Attachment[]
}
export interface IInquiryProvider {
getAll(filters?: InquiryFilters): Promise<Inquiry[]>
getById(id: string): Promise<Inquiry | null>
createInquiry(input: CreateInquiryInput): Promise<Inquiry>
createOffer(input: CreateOfferInput): Promise<Inquiry>
updateStatus(id: string, status: InquiryStatus): Promise<Inquiry>
markThreadAsRead(id: string): Promise<Inquiry>
getUnreadCount(): Promise<number>
getUnreadCount(filters?: InquiryFilters): Promise<number>
addMessage(
inquiryId: string,
msg: Omit<InquiryMessage, 'id' | 'inquiryId' | 'createdAt'>,
+92 -9
View File
@@ -1,20 +1,99 @@
import type { IInquiryProvider, InquiryFilters } from './IInquiryProvider'
import type { IInquiryProvider, InquiryFilters, CreateInquiryInput, CreateOfferInput } from './IInquiryProvider'
import type { Inquiry, InquiryStatus, InquiryMessage } from '../domain/inquiry'
import { mockInquiries } from '../mock-data/inquiries'
import { mockDemandInquiries } from '../mock-data/demandInquiries'
const store: Inquiry[] = mockInquiries.map(i => ({ ...i, thread: [...i.thread] }))
// Eine gemeinsame Konversationsmenge: Supply-Eingang (mockInquiries) + Demand-Perspektive
// (mockDemandInquiries, inkl. erhaltener Angebote). Beide Seiten lesen perspektivisch gefiltert.
const store: Inquiry[] = [...mockInquiries, ...mockDemandInquiries].map(i => ({ ...i, thread: [...i.thread] }))
function applyFilters(results: Inquiry[], filters?: InquiryFilters): Inquiry[] {
let out = results
if (filters?.status) out = out.filter(i => i.status === filters.status)
if (filters?.propertyId) out = out.filter(i => i.propertyId === filters.propertyId)
if (filters?.organizationId) out = out.filter(i => i.organizationId === filters.organizationId)
if (filters?.tenantOrgId) out = out.filter(i => i.tenantOrgId === filters.tenantOrgId)
if (filters?.kind) out = out.filter(i => (i.kind ?? 'INQUIRY') === filters.kind)
return out
}
export const MockupInquiryProvider: IInquiryProvider = {
async getAll(filters?: InquiryFilters): Promise<Inquiry[]> {
let results = [...store]
if (filters?.status) results = results.filter(i => i.status === filters.status)
if (filters?.propertyId) results = results.filter(i => i.propertyId === filters.propertyId)
if (filters?.organizationId) results = results.filter(i => i.organizationId === filters.organizationId)
return results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
return applyFilters([...store], filters).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
},
async getById(id: string): Promise<Inquiry | null> {
return store.find(i => i.id === id) ?? null
},
async createInquiry(input: CreateInquiryInput): Promise<Inquiry> {
const now = new Date().toISOString()
const id = `inq-${crypto.randomUUID().slice(0, 8)}`
const inquiry: Inquiry = {
id,
kind: 'INQUIRY',
organizationId: input.organizationId,
tenantOrgId: input.tenantOrgId,
propertyId: input.propertyId,
needId: input.needId,
tenantName: input.tenantName,
tenantCompany: input.tenantCompany,
tenantEmail: input.tenantEmail,
propertyAddress: input.propertyAddress,
subject: input.subject,
message: input.message,
status: 'new',
unreadCount: 1, // neu für die Verwaltung (Empfänger)
isRead: false,
matchScore: input.matchScore,
createdAt: now,
updatedAt: now,
thread: [{
id: crypto.randomUUID(),
inquiryId: id,
senderType: 'tenant',
senderName: input.tenantCompany ?? input.tenantName,
subject: input.subject,
body: input.message,
attachments: [],
createdAt: now,
}],
}
store.unshift(inquiry)
return inquiry
},
async createOffer(input: CreateOfferInput): Promise<Inquiry> {
const now = new Date().toISOString()
const id = `off-${crypto.randomUUID().slice(0, 8)}`
const inquiry: Inquiry = {
id,
kind: 'OFFER',
organizationId: input.organizationId,
tenantOrgId: input.tenantOrgId,
propertyId: input.propertyId,
offeredPropertyIds: input.offeredPropertyIds,
needId: input.needId,
tenantName: input.recipientName ?? '',
propertyManagerCompany: input.senderName,
subject: input.subject,
message: input.message,
status: 'new',
unreadCount: 1, // neu für den Nachfrager (Empfänger)
isRead: false,
createdAt: now,
updatedAt: now,
thread: [{
id: crypto.randomUUID(),
inquiryId: id,
senderType: 'supply_user',
senderName: input.senderName,
subject: input.subject,
body: input.message,
attachments: input.attachments ?? [],
createdAt: now,
}],
}
store.unshift(inquiry)
return inquiry
},
async updateStatus(id: string, status: InquiryStatus): Promise<Inquiry> {
const idx = store.findIndex(i => i.id === id)
if (idx === -1) throw new Error(`Inquiry ${id} not found`)
@@ -33,8 +112,8 @@ export const MockupInquiryProvider: IInquiryProvider = {
}
return store[idx]
},
async getUnreadCount(): Promise<number> {
return store.reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
async getUnreadCount(filters?: InquiryFilters): Promise<number> {
return applyFilters([...store], filters).reduce((sum, i) => sum + (i.unreadCount ?? 0), 0)
},
async addMessage(
inquiryId: string,
@@ -51,6 +130,10 @@ export const MockupInquiryProvider: IInquiryProvider = {
store[idx] = {
...store[idx],
thread: [...store[idx].thread, message],
// Neue Nachricht der Gegenseite → für den Empfänger ungelesen
unreadCount: (store[idx].unreadCount ?? 0) + 1,
isRead: false,
status: store[idx].status === 'new' ? 'in_progress' : store[idx].status,
updatedAt: message.createdAt,
}
return store[idx]
+8 -3
View File
@@ -17,11 +17,16 @@ export const MockupUnitProvider: IUnitProvider = {
return getAllUnits().find(u => u.id === unitId) ?? null
},
async update(unitId: string, data: Partial<PropertyUnit>) {
for (const prop of propertyStore) {
for (let i = 0; i < propertyStore.length; i++) {
const prop = propertyStore[i]
const idx = (prop.units ?? []).findIndex(u => u.id === unitId)
if (idx === -1) continue
prop.units![idx] = { ...prop.units![idx], ...data }
return prop.units![idx]
// Immutable update: neue Referenzen (Unit, units-Array, Property), damit React Query
// die Änderung via Structural-Sharing erkennt und neu rendert.
const nextUnits = [...prop.units!]
nextUnits[idx] = { ...nextUnits[idx], ...data }
propertyStore[i] = { ...prop, units: nextUnits }
return nextUnits[idx]
}
throw new Error(`Unit ${unitId} not found`)
},
+24
View File
@@ -176,6 +176,27 @@ export interface FitOutAdvice {
estimatedNetInvestment: string
}
// ── Pre-market rent recommendation ─────────────────────────────────────────────
export interface PreMarketRentInput {
city: string
assetType: string
areaSqm: number
currentRentPerSqm: number
availableFrom?: string // ISO — Pre-Market liegt in der Zukunft
}
export interface PreMarketRentRecommendation {
recommendedPerSqm: number
rangeMinPerSqm: number
rangeMaxPerSqm: number
verdict: 'UNDERPRICED' | 'FAIR' | 'AMBITIOUS' // Bewertung des heutigen Preises
deltaVsCurrentPct: number // Empfehlung vs. heutiger Preis
drivers: string[] // Vergleichsmiete, Angebot, Nachfrage, Trend …
rationale: string
confidence: 'LOW' | 'MEDIUM' | 'HIGH'
}
// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
@@ -220,6 +241,9 @@ export interface IAIService {
// Fit-out investment advice (demand side)
generateFitOutAdvice(input: FitOutAdviceInput): Promise<AIResponse<FitOutAdvice>>
// Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
// Legacy methods
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<AIResponse<string[]>>
@@ -57,6 +57,8 @@ import type {
MarketSignalClassification,
FitOutAdviceInput,
FitOutAdvice,
PreMarketRentInput,
PreMarketRentRecommendation,
} from '../IAIService'
import { ServiceErrorCode } from '../../types'
import { AppError } from '../../errors'
@@ -82,6 +84,7 @@ import { buildDecisionBriefPrompt } from '../prompts/decisionBriefPrompt'
import { buildDataQualityPrompt } from '../prompts/dataQualityPrompt'
import { buildMarketSignalPrompt } from '../prompts/marketSignalPrompt'
import { MockAIService } from '../mock/MockAIService'
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
// ── Config ────────────────────────────────────────────────────────────────────
@@ -606,6 +609,41 @@ Bitte analysiere die Situation und empfiehl die beste Option für den Mieter.`
}, () => MockAIService.generateFitOutAdvice(input))
},
// ── recommendPreMarketRent ──────────────────────────────────────────────────
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>> {
return withFallback('recommendPreMarketRent', async () => {
const intel = getCityIntelligence(input.city)
const comp = getMarketRent(input.city, input.assetType)
const system = `Du bist Schweizer Gewerbeimmobilien-Marktanalyst. Empfiehl einen Pre-Market-Mietpreis (CHF/m²/Jahr) auf Basis regionaler Vergleichsmieten, Angebot (Leerstand) und Nachfrage. Antworte als JSON:
{
"recommendedPerSqm": number,
"rangeMinPerSqm": number,
"rangeMaxPerSqm": number,
"verdict": "UNDERPRICED" | "FAIR" | "AMBITIOUS",
"deltaVsCurrentPct": number,
"drivers": ["kurze Treiber auf Deutsch"],
"rationale": "2-3 Sätze Begründung auf Deutsch",
"confidence": "LOW" | "MEDIUM" | "HIGH"
}`
const user = `Stadt: ${input.city}
Nutzung: ${input.assetType}
Fläche: ${input.areaSqm} m²
Heutiger Preis: CHF ${input.currentRentPerSqm}/m²
Vergleichsmiete (Median): ${comp ?? 'unbekannt'}
Leerstand: ${intel?.vacancyRatePct ?? '?'}%
Nachfrage: ${intel?.demandStrength ?? '?'}
Miettrend 12M: ${intel?.rentTrend12m ?? '?'}%
Ø Vermietungsdauer: ${intel?.avgDaysOnMarket ?? '?'} Tage`
const raw = await chat(system, user)
const json = extractJSON<PreMarketRentRecommendation>(raw)
if (!json || typeof json.recommendedPerSqm !== 'number') {
const fb = await MockAIService.recommendPreMarketRent(input)
return { ...fb, provenance: makeProvenance('mock', true, false, { fallbackReason: json ? 'schema_validation' : 'json_parse' }) }
}
return { data: json, provenance: makeProvenance('ai', false, true) }
}, () => MockAIService.recommendPreMarketRent(input))
},
// ── Legacy: extractCriteria ─────────────────────────────────────────────────
extractCriteria(input: string): Promise<AIResponse<CriteriaExtractionResult>> {
return withFallback('extractCriteria', async () => {
+71
View File
@@ -12,12 +12,15 @@ import type {
MarketSignalClassification,
FitOutAdviceInput,
FitOutAdvice,
PreMarketRentInput,
PreMarketRentRecommendation,
} from '../IAIService'
import { mockProvenance } from '../IAIService'
import { aiTraceStore } from '../tracing'
import { mockParseNeed } from './needParser'
import { buildComparisonSummary } from './compareBuilder'
import { buildMockDecisionBrief } from './decisionBrief'
import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence'
const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 }
const delay = (ms: number) => new Promise(r => setTimeout(r, ms))
@@ -338,6 +341,74 @@ export const MockAIService: IAIService = {
return { data, provenance: mockProvenance() }
}),
// ── recommendPreMarketRent ──────────────────────────────────────────────────
recommendPreMarketRent: (input: PreMarketRentInput) =>
traceMock('recommendPreMarketRent', async () => {
await delay(SIMULATED_DELAY.fast)
const intel = getCityIntelligence(input.city)
const comp = getMarketRent(input.city, input.assetType)
const current = input.currentRentPerSqm
const ASSET_LABELS: Record<string, string> = { OFFICE: 'Bürofläche', LOGISTICS: 'Logistikfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', RETAIL: 'Retailfläche', PRODUCTION: 'Produktionsfläche' }
const assetLabel = ASSET_LABELS[input.assetType] ?? 'Fläche'
// Ohne regionale Vergleichsdaten: nur grobe Schätzung, niedrige Konfidenz
if (!intel || comp == null) {
const rec = Math.round(current * 1.02)
const data: PreMarketRentRecommendation = {
recommendedPerSqm: rec, rangeMinPerSqm: Math.round(rec * 0.93), rangeMaxPerSqm: Math.round(rec * 1.07),
verdict: 'FAIR', deltaVsCurrentPct: 0,
drivers: ['Keine regionalen Vergleichsdaten verfügbar'],
rationale: 'Keine ausreichenden Marktdaten für diese Region — Empfehlung beruht auf dem heutigen Preis.',
confidence: 'LOW',
}
return { data, provenance: mockProvenance() }
}
// Empfehlung am HEUTIGEN Preis des Objekts verankert (realistisch) und nur begrenzt
// nach Marktmomentum (Angebot/Nachfrage/Trend) angepasst — keine Sprünge auf den
// stadtweiten Median (segment-grob). Vergleichsmiete dient nur als Spielraum-Check.
let adj = intel.rentTrend12m / 100 // Trend vorwärts (Pre-Market liegt in der Zukunft)
if (intel.vacancyRatePct < 2.5) adj += 0.04
else if (intel.vacancyRatePct < 4) adj += 0.02
else if (intel.vacancyRatePct > 5.5) adj -= 0.05
else if (intel.vacancyRatePct > 4.5) adj -= 0.02
adj += { VERY_HIGH: 0.05, HIGH: 0.025, MEDIUM: 0, LOW: -0.04 }[intel.demandStrength]
if (intel.avgDaysOnMarket < 35) adj += 0.015
else if (intel.avgDaysOnMarket > 75) adj -= 0.025
// Spielraum-Check: liegt der heutige Preis bereits über dem regionalen Marktband → kaum Luft nach oben
if (comp != null && current >= comp) adj = Math.min(adj, 0.02)
adj = Math.max(-0.10, Math.min(0.15, adj)) // realistischer Rahmen: 10 % … +15 %
const recommended = Math.round(current * (1 + adj))
const rangeMin = Math.round(recommended * 0.95)
const rangeMax = Math.round(recommended * 1.05)
const deltaVsCurrentPct = Math.round(adj * 100)
const verdict: PreMarketRentRecommendation['verdict'] =
deltaVsCurrentPct >= 5 ? 'UNDERPRICED' : deltaVsCurrentPct <= -4 ? 'AMBITIOUS' : 'FAIR'
const supplyLabel = intel.vacancyRatePct < 3 ? 'sehr knappes Angebot' : intel.vacancyRatePct > 5 ? 'entspanntes Angebot' : 'ausgeglichenes Angebot'
const demandLabel = { VERY_HIGH: 'sehr hohe Nachfrage', HIGH: 'hohe Nachfrage', MEDIUM: 'mittlere Nachfrage', LOW: 'schwache Nachfrage' }[intel.demandStrength]
const drivers = [
`Leerstand ${intel.vacancyRatePct}% (${supplyLabel})`,
demandLabel,
`Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}% (12 M)`,
`Ø Vermietungsdauer ${intel.avgDaysOnMarket} Tage`,
]
const verdictText =
verdict === 'UNDERPRICED' ? `Marktumfeld lässt Spielraum nach oben (+${deltaVsCurrentPct}% ggü. heute).`
: verdict === 'AMBITIOUS' ? `Marktumfeld eher schwächer (${deltaVsCurrentPct}% ggü. heute) — vorsichtig ansetzen.`
: 'Heutiger Preis ist marktgerecht.'
const rationale = `${assetLabel} in ${input.city}: ${supplyLabel}, ${demandLabel}, Miettrend ${intel.rentTrend12m >= 0 ? '+' : ''}${intel.rentTrend12m}%. Empfehlung für Pre-Market: CHF ${recommended}/m² (CHF ${rangeMin}${rangeMax}) — verankert am heutigen Preis CHF ${current}/m². ${verdictText}`
const confidence: PreMarketRentRecommendation['confidence'] =
intel.demandStrength === 'LOW' || intel.avgDaysOnMarket > 75 ? 'MEDIUM' : 'HIGH'
const data: PreMarketRentRecommendation = {
recommendedPerSqm: recommended, rangeMinPerSqm: rangeMin, rangeMaxPerSqm: rangeMax,
verdict, deltaVsCurrentPct, drivers, rationale, confidence,
}
return { data, provenance: mockProvenance() }
}),
// Legacy methods
extractCriteria: (_input: string) =>
traceMock('extractCriteria', async () => ({
+4
View File
@@ -22,5 +22,9 @@ export type {
DataQualityInput,
DataQualitySummary,
MarketSignalClassification,
FitOutAdvice,
FitOutAdviceInput,
PreMarketRentInput,
PreMarketRentRecommendation,
} from './ai/IAIService'
export { parseListingText } from './ai/mock/listingParser'
+26 -6
View File
@@ -1,6 +1,6 @@
import { MockupInquiryProvider } from '../provider/MockupInquiryProvider'
import type { InquiryFilters } from '../provider/IInquiryProvider'
import type { Inquiry, Attachment } from '../domain/inquiry'
import type { InquiryFilters, CreateInquiryInput, CreateOfferInput } from '../provider/IInquiryProvider'
import type { Inquiry, Attachment, InquiryMessage } from '../domain/inquiry'
import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors'
@@ -10,6 +10,8 @@ export interface InquiryReplyPayload {
subject?: string
body: string
attachments?: Attachment[]
senderType?: InquiryMessage['senderType'] // default 'supply_user'
senderName?: string
}
export const inquiryService = {
@@ -31,14 +33,32 @@ export const inquiryService = {
}
},
async createInquiry(input: CreateInquiryInput): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.createInquiry(input)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async createOffer(input: CreateOfferInput): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.createOffer(input)
return { data }
} catch (err) {
throwServiceError(err)
}
},
async sendInquiryReply(
inquiryId: string,
payload: InquiryReplyPayload,
): Promise<ItemResponse<Inquiry>> {
try {
const data = await provider.addMessage(inquiryId, {
senderType: 'supply_user',
senderName: 'Wincasa AG',
senderType: payload.senderType ?? 'supply_user',
senderName: payload.senderName ?? 'Wincasa AG',
subject: payload.subject,
body: payload.body,
attachments: payload.attachments ?? [],
@@ -58,9 +78,9 @@ export const inquiryService = {
}
},
async getUnreadCount(): Promise<ItemResponse<number>> {
async getUnreadCount(filters?: InquiryFilters): Promise<ItemResponse<number>> {
try {
const data = await provider.getUnreadCount()
const data = await provider.getUnreadCount(filters)
return { data }
} catch (err) {
throwServiceError(err)
+25 -2
View File
@@ -12,6 +12,7 @@ import type { Need } from '../domain/need'
import type { Property } from '../domain/property'
import { getEffectiveUnits } from '../domain/property'
import { calculateScore } from '../features/matching/scoreCalculator'
import { resolveUnitFacts } from '../lib/unitFacts'
function strengthFromScore(s: number): MatchStrength {
if (s >= 75) return MatchStrength.STRONG
@@ -71,24 +72,38 @@ function scoreProperty(
overrideArea?: number,
overridePrice?: number,
overrideResultType?: string,
overrideHardFacts?: Partial<NonNullable<Property['hardFacts']>>,
): MatchEngineOutput {
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined) {
if (overrideArea !== undefined || overridePrice !== undefined || overrideResultType !== undefined || overrideHardFacts !== undefined) {
return calculateScore(need, {
...prop,
areaSqm: overrideArea ?? prop.areaSqm,
rentPricePerSqm: overridePrice ?? prop.rentPricePerSqm,
resultType: (overrideResultType ?? prop.resultType) as ResultType,
// Einheit-Werte überschreiben Objekt-Werte (Fallback bleibt prop.hardFacts)
hardFacts: overrideHardFacts ? { ...prop.hardFacts, ...overrideHardFacts } : prop.hardFacts,
})
}
return calculateScore(need, prop)
}
// Externe Inserate aus Drittquellen (Scrapes) sind keine Plattform-Inventar-Objekte und
// gehören nicht in die Treffer (nur eigene Plattform-Objekte, Maison Work, Future Availability).
const EXTERNAL_SCRAPE_SOURCES = new Set(['HOMEGATE_SCRAPE', 'IMMOSCOUT_SCRAPE', 'NEWHOME_SCRAPE', 'MATCHOFFICE_SCRAPE'])
function isExternalListing(prop: Property): boolean {
return prop.resultType === ResultType.VERIFIED_PORTFOLIO
&& !!prop.sourceType && EXTERNAL_SCRAPE_SOURCES.has(prop.sourceType)
}
/** Generate matches for a single need against all properties and push them into matchStore. */
export function generateMatchesForNeed(need: Need): void {
const now = new Date().toISOString()
const MIN_SCORE = 22
for (const prop of propertyStore) {
// Externe Scrape-Inserate, die fälschlich als Plattform-Objekt getaggt sind → nicht matchen
if (isExternalListing(prop)) continue
const hasExplicitUnits = (prop.units ?? []).length > 0
if (hasExplicitUnits) {
@@ -101,7 +116,15 @@ export function generateMatchesForNeed(need: Need): void {
}
for (const unit of prop.units!) {
if (!unit.schattenmarktRelease?.enabled) continue
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unit.rentPricePerSqm ?? prop.rentPricePerSqm, ResultType.FUTURE_AVAILABILITY)
const facts = resolveUnitFacts(prop, unit)
// Pre-Market: erwarteter künftiger Preis hat Vorrang vor der heutigen Sollmiete
const unitPrice = unit.expectedRentPerSqm ?? unit.rentPricePerSqm ?? prop.rentPricePerSqm
const unitOutput = scoreProperty(need, prop, unit.areaSqm, unitPrice, ResultType.FUTURE_AVAILABILITY, {
fitOut: facts.fitOut,
mieterausbaubeitragPerSqm: facts.mabPerSqm,
fitOutByLandlord: facts.fitOutByLandlord,
parking: facts.parkingSpots,
})
if (unitOutput.excluded || unitOutput.finalScore < MIN_SCORE) continue
const resultId = `schattenmarkt-${prop.id}-${unit.id}`
matchStore.push(buildMatch(prop, unit.id, need, unitOutput, ResultType.FUTURE_AVAILABILITY, resultId, now))
+5 -1
View File
@@ -3,6 +3,7 @@ import type { NeedFilters } from '../provider/INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors'
import { useSessionStore } from '../stores/sessionStore'
const provider = MockupNeedProvider
@@ -25,7 +26,10 @@ export const needService = {
},
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
try {
const data = await provider.create(input)
// Neues Suchprofil der Organisation des Erstellers zuordnen (sonst fällt es aus der
// org-gefilterten Liste — Aktive-Suche-Leiste zeigt sonst ein fremdes Profil)
const orgId = useSessionStore.getState().currentUser?.organizationId
const data = await provider.create({ ...input, organizationId: input.organizationId ?? orgId })
return { data }
} catch (err) {
throwServiceError(err)
+2 -46
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { Inquiry, InquiryMessage } from '../domain/inquiry'
// Reine UI-State: der Anfrage-Dialog. Versendete Anfragen leben jetzt im Provider
// (MockupInquiryProvider) und werden via React Query gelesen — nicht mehr hier.
export interface PendingInquiry {
propertyTitle: string
location: string
@@ -13,15 +14,10 @@ export interface PendingInquiry {
}
interface InquiryStore {
// Dialog state
dialogOpen: boolean
pendingInquiry: PendingInquiry | null
openInquiryDialog: (item: PendingInquiry) => void
closeInquiryDialog: () => void
// Sent inquiries (in-memory, persists for the session)
sentInquiries: Inquiry[]
addInquiry: (item: PendingInquiry, message: string) => void
}
export const useInquiryStore = create<InquiryStore>((set) => ({
@@ -29,44 +25,4 @@ export const useInquiryStore = create<InquiryStore>((set) => ({
pendingInquiry: null,
openInquiryDialog: (item) => set({ dialogOpen: true, pendingInquiry: item }),
closeInquiryDialog: () => set({ dialogOpen: false, pendingInquiry: null }),
sentInquiries: [],
addInquiry: (item, message) => {
const now = new Date().toISOString()
const msgId = `msg-sent-${Date.now()}`
const id = `sent-${Date.now()}`
const thread: InquiryMessage = {
id: msgId,
inquiryId: id,
senderType: 'tenant',
senderName: 'Admin User',
body: message,
attachments: [],
createdAt: now,
}
const inquiry: Inquiry = {
id,
organizationId: 'org-wincasa',
propertyId: item.propertyId ?? 'unknown',
tenantName: 'Admin User',
tenantCompany: 'Mobimo Management AG',
tenantEmail: 'admin@ideal-sharing.ch',
propertyAddress: item.propertyTitle,
propertyManagerName: 'Verwalter',
propertyManagerCompany: '',
subject: 'Anfrage: ' + item.propertyTitle,
message,
status: 'new',
unreadCount: 0,
isRead: true,
matchScore: item.matchScore,
createdAt: now,
updatedAt: now,
thread: [thread],
}
set(state => ({ sentInquiries: [inquiry, ...state.sentInquiries] }))
},
}))
+2
View File
@@ -17,6 +17,7 @@ interface LayoutState {
// Actions
setActiveWorkspace: (workspace: WorkspaceType) => void
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
openRightPanel: (type: RightPanelContentType) => void
closeRightPanel: () => void
toggleRightPanel: (type: RightPanelContentType) => void
@@ -30,6 +31,7 @@ export const useLayoutStore = create<LayoutState>((set, get) => ({
setActiveWorkspace: (workspace) => set({ activeWorkspace: workspace }),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
openRightPanel: (type) => set({ isRightPanelOpen: true, rightPanelContentType: type }),
closeRightPanel: () => set({ isRightPanelOpen: false, rightPanelContentType: null }),
toggleRightPanel: (type) => {