18 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:18:02 +02:00
85 changed files with 4621 additions and 1190 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
"allow": [ "allow": [
"Bash(git commit -m ' *)", "Bash(git commit -m ' *)",
"Bash(git commit *)", "Bash(git commit *)",
"Bash(node -e ' *)" "Bash(node -e ' *)",
"Bash(git stash *)",
"Read(//c/Users/beni_/.claude/projects/c--Users-beni--OneDrive-Desktop-property-match/8e388ddd-e02e-47fe-8bb9-cdb8164c9fc3/tool-results/**)"
] ]
} }
} }
+11
View File
@@ -0,0 +1,11 @@
> Why do I have a folder named ".vercel" in my project?
The ".vercel" folder is created when you link a directory to a Vercel project.
> What does the "project.json" file contain?
The "project.json" file contains:
- The ID of the Vercel project that you linked ("projectId")
- The ID of the user or team your Vercel project is owned by ("orgId")
> Should I commit the ".vercel" folder?
No, you should not share the ".vercel" folder with anyone.
Upon creation, it will be automatically added to your ".gitignore" file.
+1
View File
@@ -0,0 +1 @@
{"projectId":"prj_0UhtBM4Jg83OXPDkzhOM6x0gYfEY","orgId":"team_Quh2pScdkVSiGHkdwh6ntKZ5","projectName":"property-match"}
+1 -3
View File
@@ -26,7 +26,7 @@ const Anfragencenter = lazy(() => import('./pages/supply/Anfragencenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability')) const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
const DataQuality = lazy(() => import('./pages/supply/DataQuality')) const DataQuality = lazy(() => import('./pages/supply/DataQuality'))
const ReminderManager = lazy(() => import('./pages/supply/ReminderManager')) const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
const MarketLeads = lazy(() => import('./pages/supply/MarketLeads')) const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
const NewListing = lazy(() => import('./pages/supply/NewListing')) const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings')) const MyListings = lazy(() => import('./pages/supply/MyListings'))
@@ -38,7 +38,6 @@ const Anfragen = lazy(() => import('./pages/demand/Anfragen'))
const Pipeline = lazy(() => import('./pages/demand/Pipeline')) const Pipeline = lazy(() => import('./pages/demand/Pipeline'))
const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail')) const PropertyDetail = lazy(() => import('./pages/demand/PropertyDetail'))
const MarketIntelligence = lazy(() => import('./pages/ops/MarketIntelligence'))
function App() { function App() {
return ( return (
@@ -62,7 +61,6 @@ function App() {
<Route path="/supply/future-availability" element={<FutureAvailability />} /> <Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} /> <Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/reminder-manager" element={<ReminderManager />} /> <Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/market-leads" element={<MarketLeads />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} /> <Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} /> <Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} /> <Route path="/supply/my-listings" element={<MyListings />} />
@@ -123,7 +123,6 @@ export function InquiryReplyComposer({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.75, gap: 0.75,
bgcolor: 'white',
border: '1px solid', border: '1px solid',
borderColor: a.generated ? '#bfdbfe' : '#cbd5e1', borderColor: a.generated ? '#bfdbfe' : '#cbd5e1',
borderRadius: 1, borderRadius: 1,
@@ -1,18 +1,86 @@
import { Box, Typography } from '@mui/material' import { Box, Divider, Typography } from '@mui/material'
import { FileText } from 'lucide-react' import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { mockProperties } from '../../mock-data/properties' import { mockProperties } from '../../mock-data/properties'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
interface MockPdfPreviewProps { interface MockPdfPreviewProps {
fields: Record<string, string> fields: Record<string, string>
fallbackFields: Record<string, string> fallbackFields: Record<string, string>
fieldSelections: ReportObjectFieldSelection[]
} }
export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps) { const FIELD_LABELS: Partial<Record<ReportObjectFieldKey, string>> = {
areaSqm: 'Fläche',
rentPricePerSqm: 'Mietpreis',
availabilityDate: 'Verfügbar ab',
leaseTerm: 'Mietlaufzeit',
breakoutOption: 'Breakout-Option',
currentTenant: 'Aktueller Mieter',
propertyNumber: 'Objektnummer',
assetType: 'Asset Type',
floor: 'Stockwerk',
parking: 'Parkplätze',
fitOut: 'Ausbaugrad',
isBarrierFree: 'Barrierefrei',
ceilingHeightM: 'Raumhöhe',
floorLoad: 'Bodenlast',
loadingDocksCount: 'Anlieferung',
goodsLift: 'Warenaufzug',
passengerLift: 'Personenaufzug',
powerSupplyKva: 'Stromanschluss',
hasServerRoom: 'Serverraum',
internet: 'Internet',
deliveryAccess: 'Zufahrt',
publicTransportScore: 'ÖV-Anbindung',
prestigeScore: 'Prestige',
visibilityScore: 'Sichtbarkeit',
footfallScore: 'Passantenfrequenz',
commuterAccessScore: 'Pendlererreichbarkeit',
talentAccessScore: 'Talent Access',
esgScore: 'ESG',
flexibilityScore: 'Flexibilität',
expansionPotentialScore: 'Expansionspotenzial',
taxEnvironmentScore: 'Steuerumfeld',
microLocation: 'Mikrostandort',
competitionEnvironment: 'Konkurrenzumfeld',
infrastructure: 'Infrastruktur',
description: 'Beschreibung',
}
function getFieldValue(property: Property, key: ReportObjectFieldKey): string | null {
switch (key) {
case 'areaSqm': return property.areaSqm ? `${property.areaSqm.toLocaleString('de-CH')}` : null
case 'rentPricePerSqm': return property.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/J` : null
case 'availabilityDate': return property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : null
case 'leaseTerm': return property.leaseTerm ?? null
case 'breakoutOption': return property.breakoutOption != null ? (property.breakoutOption ? 'Ja' : 'Nein') : null
case 'currentTenant': return property.currentTenant ?? null
case 'propertyNumber': return property.propertyNumber ?? null
case 'assetType': return property.assetType ?? null
case 'floor': return property.floorLevel != null ? String(property.floorLevel) : null
case 'parking': return property.softFactors?.parkingSpots != null ? `${property.softFactors.parkingSpots} Pl.` : null
case 'ceilingHeightM': return property.hardFacts?.ceilingHeightM != null ? `${property.hardFacts.ceilingHeightM} m` : null
case 'loadingDocksCount': return property.hardFacts?.loadingDocksCount != null ? String(property.hardFacts.loadingDocksCount) : null
case 'powerSupplyKva': return property.hardFacts?.powerSupplyKva != null ? `${property.hardFacts.powerSupplyKva} kVA` : null
case 'hasServerRoom': return property.hardFacts?.hasServerRoom != null ? (property.hardFacts.hasServerRoom ? 'Ja' : 'Nein') : null
case 'isBarrierFree': return property.hardFacts?.isBarrierFree != null ? (property.hardFacts.isBarrierFree ? 'Ja' : 'Nein') : null
case 'fitOut': return property.hardFacts?.fitOut ?? null
case 'publicTransportScore': return property.softFactors?.publicTransportMinutes != null ? `${property.softFactors.publicTransportMinutes} Min.` : null
case 'prestigeScore': return property.softFactors?.prestige != null ? `${property.softFactors.prestige}/100` : null
case 'visibilityScore': return property.softFactors?.visibilityScore != null ? `${property.softFactors.visibilityScore}/100` : null
case 'description': return property.description ?? null
default: return null
}
}
export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: MockPdfPreviewProps) {
const needTitle = useOfferWizardStore(s => s.needTitle) const needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds) const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const properties = mockProperties.filter(p => propertyIds.includes(p.id)) const properties = mockProperties.filter(p => propertyIds.includes(p.id))
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? '' const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
@@ -29,6 +97,7 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
fontFamily: '"Georgia", "Times New Roman", serif', fontFamily: '"Georgia", "Times New Roman", serif',
}} }}
> >
{/* ── Page 1 — Offer letter ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<FileText size={16} /> <FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}> <Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
@@ -90,6 +159,93 @@ export function MockPdfPreview({ fields, fallbackFields }: MockPdfPreviewProps)
<Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}> <Typography variant="body2" sx={{ fontSize: '0.875rem', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{v('closing')} {v('closing')}
</Typography> </Typography>
{/* ── Page 2 — Property details side by side ── */}
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.min(properties.length, 3)}, 1fr)`,
gap: 1.5,
}}
>
{properties.map(p => {
const selection = fieldSelections.find(fs => fs.propertyId === p.id)
const optionalFields = selection?.selectedOptionalFields ?? []
const image = p.images?.[0]
return (
<Box
key={p.id}
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Photo */}
{image ? (
<Box
component="img"
src={image}
alt={p.title}
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={22} color="#94a3b8" />
</Box>
)}
{/* Info */}
<Box sx={{ p: 1.25, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
{p.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
<MapPin size={10} color="#94a3b8" />
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
</Box>
{/* Selected fields */}
{optionalFields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{optionalFields.map(key => {
const val = getFieldValue(p, key)
if (!val) return null
return (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
{val}
</Typography>
</Box>
)
})}
</Box>
)}
</Box>
</Box>
)
})}
</Box>
</>
)}
</Box> </Box>
) )
} }
@@ -104,7 +104,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
} }
return ( return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}> <Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}> <Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Angebot erstellen</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button> <Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
@@ -187,7 +187,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
size="small" size="small"
value={a.date} value={a.date}
onChange={e => updateAppointment(a.id, 'date', e.target.value)} onChange={e => updateAppointment(a.id, 'date', e.target.value)}
InputLabelProps={{ shrink: true }} slotProps={{ inputLabel: { shrink: true } }}
sx={{ width: 160 }} sx={{ width: 160 }}
/> />
<TextField <TextField
@@ -228,7 +228,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
progress={progress} progress={progress}
ready={ready} ready={ready}
draft={draft} draft={draft}
property={property} property={property ?? undefined}
onDownload={() => showToast('PDF wird heruntergeladen…', 'info')} onDownload={() => showToast('PDF wird heruntergeladen…', 'info')}
onAttach={() => { onAttach={() => {
const label = `Angebot_${property?.title ?? 'Objekt'}.pdf` const label = `Angebot_${property?.title ?? 'Objekt'}.pdf`
@@ -0,0 +1,87 @@
import { useEffect } from 'react'
import { Box, Button, Stack, Typography } from '@mui/material'
import { ArrowLeft } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useProperties } from '../../hooks/useProperties'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function OfferFieldSelectionStep() {
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const setFieldSelections = useOfferWizardStore(s => s.setFieldSelections)
const updateFieldSelection = useOfferWizardStore(s => s.updateFieldSelection)
const setStep = useOfferWizardStore(s => s.setStep)
const { data: allProperties = [] } = useProperties()
const selectedProperties = allProperties.filter(p => propertyIds.includes(p.id))
// Initialise selections when properties are loaded or change
useEffect(() => {
if (selectedProperties.length === 0) return
const existing = new Set(fieldSelections.map(fs => fs.propertyId))
const missing = selectedProperties.filter(p => !existing.has(p.id))
if (missing.length > 0) {
setFieldSelections([
...fieldSelections,
...missing.map(p => ({
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
})),
])
}
// run only when property list or selections change length
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProperties.length, fieldSelections.length])
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
</Typography>
<Stack spacing={2}>
{selectedProperties.map(p => {
const selection: ReportObjectFieldSelection =
fieldSelections.find(fs => fs.propertyId === p.id) ?? {
propertyId: p.id,
mandatoryFields: MANDATORY_FIELDS,
selectedOptionalFields: DEFAULT_FIELDS,
}
return (
<ReportObjectFieldSelector
key={p.id}
propertyTitle={p.title}
value={selection}
onChange={v => updateFieldSelection(p.id, v)}
/>
)
})}
</Stack>
</Box>
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button
startIcon={<ArrowLeft size={14} />}
onClick={() => setStep('select_properties')}
sx={{ textTransform: 'none' }}
>
Zurück
</Button>
<Button
variant="contained"
onClick={() => setStep('pdf_review')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
</Box>
</Box>
)
}
@@ -66,6 +66,8 @@ export function OfferPdfReviewStep() {
} }
} }
const fieldSelections = useOfferWizardStore(s => s.fieldSelections)
const needTitle = useOfferWizardStore(s => s.needTitle) const needTitle = useOfferWizardStore(s => s.needTitle)
const addAttachment = useOfferWizardStore(s => s.addAttachment) const addAttachment = useOfferWizardStore(s => s.addAttachment)
const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft) const setMessageDraft = useOfferWizardStore(s => s.setMessageDraft)
@@ -128,7 +130,7 @@ export function OfferPdfReviewStep() {
</Typography> </Typography>
</Box> </Box>
) : ( ) : (
<MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} /> <MockPdfPreview fields={editableFieldsStore} fallbackFields={fallback} fieldSelections={fieldSelections} />
)} )}
</Box> </Box>
@@ -70,7 +70,7 @@ export function OfferPropertySelectionStep() {
return return
} }
setOfferDraftId(res.data.id) setOfferDraftId(res.data.id)
setStep('pdf_review') setStep('select_fields')
} }
if (!need) { if (!need) {
+11 -5
View File
@@ -12,22 +12,25 @@ import {
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore' import { useOfferWizardStore, type OfferStep } from '../../stores/offerWizardStore'
import { OfferPropertySelectionStep } from './OfferPropertySelectionStep' import { OfferPropertySelectionStep } from './OfferPropertySelectionStep'
import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep' import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction' import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer' import { OfferChatComposer } from './OfferChatComposer'
// 'checked' is internal — merged into pdf_review step; only 3 steps shown // 'checked' is internal — merged into pdf_review step; 4 visible steps
const STEPS: { key: OfferStep; label: string }[] = [ const STEPS: { key: OfferStep; label: string }[] = [
{ key: 'select_properties', label: 'Objekte wählen' }, { key: 'select_properties', label: 'Objekte wählen' },
{ key: 'select_fields', label: 'Felder wählen' },
{ key: 'pdf_review', label: 'Vorschau & Prüfen' }, { key: 'pdf_review', label: 'Vorschau & Prüfen' },
{ key: 'send', label: 'Senden' }, { key: 'send', label: 'Senden' },
] ]
const STEP_DISPLAY_INDEX: Record<OfferStep, number> = { const STEP_DISPLAY_INDEX: Record<OfferStep, number> = {
select_properties: 0, select_properties: 0,
pdf_review: 1, select_fields: 1,
checked: 1, // same visual step as pdf_review pdf_review: 2,
send: 2, checked: 2, // same visual step as pdf_review
send: 3,
} }
export function OfferWizard() { export function OfferWizard() {
@@ -48,13 +51,15 @@ export function OfferWizard() {
fullScreen={fullScreen} fullScreen={fullScreen}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
PaperProps={{ slotProps={{
paper: {
sx: { sx: {
height: fullScreen ? '100vh' : '85vh', height: fullScreen ? '100vh' : '85vh',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
}, },
},
}} }}
> >
{/* Header */} {/* Header */}
@@ -112,6 +117,7 @@ export function OfferWizard() {
{/* Step content */} {/* Step content */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> <Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{currentStep === 'select_properties' && <OfferPropertySelectionStep />} {currentStep === 'select_properties' && <OfferPropertySelectionStep />}
{currentStep === 'select_fields' && <OfferFieldSelectionStep />}
{currentStep === 'pdf_review' && <OfferPdfReviewStep />} {currentStep === 'pdf_review' && <OfferPdfReviewStep />}
{currentStep === 'checked' && <OfferCheckedAction />} {currentStep === 'checked' && <OfferCheckedAction />}
{currentStep === 'send' && <OfferChatComposer />} {currentStep === 'send' && <OfferChatComposer />}
@@ -6,7 +6,6 @@ import {
} from '@mui/material' } from '@mui/material'
import { Download, Send, X } from 'lucide-react' import { Download, Send, X } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry' import type { Inquiry } from '../../domain/inquiry'
import type { Property } from '../../domain/property'
import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport' import type { InquiryPreparationReportDraft, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport' import { useCreateInquiryReport, useUpdateInquiryReport, useFinalizeInquiryReport } from '../../hooks/useInquiryReport'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
@@ -40,10 +39,6 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO) const portfolioProps = allProperties.filter(p => p.resultType === ResultType.VERIFIED_PORTFOLIO)
const selectedProperties = draft?.selectedPropertyIds
.map(id => allProperties.find(p => p.id === id))
.filter((p): p is Property => !!p) ?? []
const toggleProperty = (id: string) => { const toggleProperty = (id: string) => {
setSelectedIds(prev => setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id],
@@ -112,7 +107,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
} }
return ( return (
<Dialog open fullWidth maxWidth="lg" PaperProps={{ sx: { height: '90vh', display: 'flex', flexDirection: 'column' } }}> <Dialog open fullWidth maxWidth="lg" slotProps={{ paper: { sx: { height: '90vh', display: 'flex', flexDirection: 'column' } } }}>
<Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}> <Box sx={{ px: 3, pt: 2.5, pb: 1.5, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Vorbereitung starten</Typography>
<Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button> <Button size="small" onClick={onClose} sx={{ minWidth: 0, p: 0.5 }}><X size={18} /></Button>
@@ -82,8 +82,6 @@ const FIELD_GROUPS: FieldGroup[] = [
}, },
] ]
const MANDATORY: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) { export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Props) {
const isSelected = (key: ReportObjectFieldKey) => const isSelected = (key: ReportObjectFieldKey) =>
value.selectedOptionalFields.includes(key) value.selectedOptionalFields.includes(key)
@@ -115,7 +113,6 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
{FIELD_GROUPS.map(group => { {FIELD_GROUPS.map(group => {
const groupKeys = group.fields.map(f => f.key) const groupKeys = group.fields.map(f => f.key)
const allSelected = groupKeys.every(k => value.selectedOptionalFields.includes(k))
return ( return (
<Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}> <Accordion key={group.label} disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', mb: 0.5, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}> <AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 40, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
@@ -0,0 +1,24 @@
import { memo } from 'react'
import type { ReactNode } from 'react'
import { TableCell, TableRow } from '@mui/material'
import { LABEL_SX, DATA_SX } from './compareUtils'
// ── Props ─────────────────────────────────────────────────────────────────────
export interface CompareMetricRowProps {
label: string
cells: ReactNode[]
}
// ── Component ─────────────────────────────────────────────────────────────────
export const CompareMetricRow = memo(function CompareMetricRow({ label, cells }: CompareMetricRowProps) {
return (
<TableRow hover>
<TableCell sx={LABEL_SX}>{label}</TableCell>
{cells.map((cell, i) => (
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
))}
</TableRow>
)
})
+14 -6
View File
@@ -3,7 +3,6 @@ import type { LucideIcon } from 'lucide-react'
import { import {
LayoutDashboard, LayoutDashboard,
Building2, Building2,
Target,
CheckSquare, CheckSquare,
Search, Search,
List, List,
@@ -13,7 +12,7 @@ import {
MessageSquare, MessageSquare,
Kanban, Kanban,
BellRing, BellRing,
Plus, Settings,
} from 'lucide-react' } from 'lucide-react'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -50,12 +49,10 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard }, { path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
{ path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, { path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing },
{ path: '/supply/market-leads', label: 'Markt-Leads', icon: Target },
{ path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare }, { path: '/supply/anfragen', label: 'Anfragencenter', icon: MessageSquare },
{ path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare },
{ path: '/supply/market-intelligence', label: 'Markt Intelligence', icon: Radar }, { path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar },
{ path: '/supply/my-listings', label: 'Meine Inserate', icon: ClipboardList }, { path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList },
{ path: '/supply/new-listing', label: 'Neues Inserat', icon: Plus },
], ],
}, },
[WorkspaceType.DEMAND]: { [WorkspaceType.DEMAND]: {
@@ -72,6 +69,17 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
{ path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare }, { path: '/demand/anfragen', label: 'Anfragen', icon: MessageSquare },
], ],
}, },
[WorkspaceType.OPERATIONS]: {
label: 'Operations',
abbreviation: 'OP',
icon: Settings,
firstPath: '/ops/review-queue',
chipColor: '#6b21a8',
navItems: [
{ path: '/ops/review-queue', label: 'Review Queue', icon: CheckSquare },
{ path: '/ops/audit', label: 'Audit Log', icon: ClipboardList },
],
},
} }
// Ordered list for rendering workspace tabs // Ordered list for rendering workspace tabs
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { Box, Button, Divider, Typography } from '@mui/material' import { Box, Button, Divider, Typography } from '@mui/material'
import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react' import { AlertCircle, CheckCircle2, ShieldCheck } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
@@ -9,7 +10,7 @@ import type { MatchCardViewModel } from './MatchCardViewModel'
// ── Future Availability Card ────────────────────────────────────────────────── // ── Future Availability Card ──────────────────────────────────────────────────
export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) { export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
const isControlled = vm.signalIsControlled ?? false const isControlled = vm.signalIsControlled ?? false
const navigate = useNavigate() const navigate = useNavigate()
@@ -243,4 +244,4 @@ export function FutureAvailabilityCard({ vm }: { vm: MatchCardViewModel }) {
</Box> </Box>
</Box> </Box>
) )
} })
@@ -182,7 +182,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} /> <AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}> <Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
{vm.tradeoffs[0].description} {vm.tradeoffs[0].concern}
</Typography> </Typography>
</Box> </Box>
)} )}
+1
View File
@@ -11,6 +11,7 @@ export { MatchDataQualitySummary } from './MatchDataQualitySummary'
export { MatchActionToolbar } from './MatchActionToolbar' export { MatchActionToolbar } from './MatchActionToolbar'
export { MatchCardSkeleton } from './MatchCardSkeleton' export { MatchCardSkeleton } from './MatchCardSkeleton'
export { MatchCardRestrictedState } from './MatchCardRestrictedState' export { MatchCardRestrictedState } from './MatchCardRestrictedState'
export { FutureAvailabilityCard } from './FutureAvailabilityCard'
export type { export type {
MatchCardViewModel, MatchCardViewModel,
MatchCardVariant, MatchCardVariant,
@@ -5,7 +5,7 @@ import { ResultType } from '../../domain/enums'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
import { FloorPlanSection } from './FloorPlanSection' import { FloorPlanSection } from './FloorPlanSection'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds' import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']> type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
@@ -0,0 +1,92 @@
import { memo, useState } from 'react'
import { Box, Button, Paper, Typography } from '@mui/material'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { PropertyMap } from '../shared'
import {
ExecutiveSummaryPanel,
NeedAlignmentPanel,
LocationIntelligencePanel,
TradeoffPanel,
RiskPanel,
MissingInformationPanel,
FutureAvailabilityContextPanel,
ScoreBreakdownPanel,
} from '.'
import { MatchDetailPropertySections } from './MatchDetailPropertySections'
import { useMatchDetail } from '../../hooks/useMatches'
import type { Property } from '../../domain/property'
import type { Need } from '../../domain/need'
import type { FutureSignal } from '../../domain/futureSignal'
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
interface Props {
match: Match
property: Property | null
need: Need | null
signal: FutureSignal | null
isFuture: boolean
taxCalculatorUrl?: string
}
export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown({
match,
property,
need,
signal,
isFuture,
taxCalculatorUrl,
}: Props) {
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
return (
<>
{/* ── Full analysis toggle ── */}
<Button
variant="outlined"
onClick={() => setShowFullAnalysis(v => !v)}
fullWidth
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
sx={{
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong,
textTransform: 'none', fontWeight: 500,
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
}}
>
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
</Button>
{/* ── Full analysis (hidden by default) ── */}
{showFullAnalysis && (
<>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
<LocationIntelligencePanel property={property} />
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
/>
</Paper>
)}
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
</>
)}
</>
)
})
@@ -11,7 +11,6 @@ import {
ExternalLink, ExternalLink,
Info, Info,
Layers, Layers,
ShieldCheck,
Tag, Tag,
Train, Train,
TrendingUp, TrendingUp,
+2
View File
@@ -1,5 +1,7 @@
export { LocationIntelligencePanel } from './LocationIntelligencePanel' export { LocationIntelligencePanel } from './LocationIntelligencePanel'
export { MatchDetailHeader } from './MatchDetailHeader' export { MatchDetailHeader } from './MatchDetailHeader'
export { MatchDetailHero } from './MatchDetailHero'
export { MatchDetailScoreBreakdown } from './MatchDetailScoreBreakdown'
export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel' export { ExecutiveSummaryPanel } from './ExecutiveSummaryPanel'
export { PropertyOverviewPanel } from './PropertyOverviewPanel' export { PropertyOverviewPanel } from './PropertyOverviewPanel'
export { NeedAlignmentPanel } from './NeedAlignmentPanel' export { NeedAlignmentPanel } from './NeedAlignmentPanel'
@@ -37,13 +37,13 @@ export function AreaDetailsSection({
<TextField <TextField
label="Fläche (m²)" value={areaSqm} label="Fläche (m²)" value={areaSqm}
onChange={e => onAreaSqmChange(e.target.value)} onChange={e => onAreaSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/> />
<TextField <TextField
label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm} label="Mietpreis (CHF/m²/Jahr)" value={rentPerSqm}
onChange={e => onRentPerSqmChange(e.target.value)} onChange={e => onRentPerSqmChange(e.target.value)}
size="small" type="number" inputProps={{ min: 1 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { min: 1 } }} fullWidth
/> />
<TextField <TextField
@@ -38,12 +38,12 @@ export function TechnicalDetailsSection({
<TextField <TextField
label="Parkplätze" value={parking} label="Parkplätze" value={parking}
onChange={e => onParkingChange(e.target.value)} onChange={e => onParkingChange(e.target.value)}
size="small" type="number" inputProps={{ min: 0 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { min: 0 } }} fullWidth
/> />
<TextField <TextField
label="Deckenhöhe (m)" value={ceilingHeight} label="Deckenhöhe (m)" value={ceilingHeight}
onChange={e => onCeilingHeightChange(e.target.value)} onChange={e => onCeilingHeightChange(e.target.value)}
size="small" type="number" inputProps={{ step: 0.1, min: 2 }} fullWidth size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
/> />
</Box> </Box>
</Card> </Card>
@@ -146,7 +146,6 @@ export function WelcomeDialog() {
open={open} open={open}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
disableEscapeKeyDown
> >
<DialogContent sx={{ pt: 4, pb: 2 }}> <DialogContent sx={{ pt: 4, pb: 2 }}>
{SLIDES[activeStep]} {SLIDES[activeStep]}
+5
View File
@@ -0,0 +1,5 @@
export { STAGES, NEXT_STAGE, MOCK_DOCS, RESULT_TYPE_META } from './pipelineConstants'
export { scoreColor, detailPath, getKiInsight } from './pipelineUtils'
export { DraggableCard } from './PipelineCard'
export { DroppableColumn } from './PipelineColumn'
export { DetailPanel } from './PipelineDetailPanel'
+1 -1
View File
@@ -78,7 +78,7 @@ export function BerichtDialog({ onClose, report, propertyId }: BerichtDialogProp
onClose={ready ? onClose : undefined} onClose={ready ? onClose : undefined}
maxWidth="md" maxWidth="md"
fullWidth fullWidth
PaperProps={{ sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } }} slotProps={{ paper: { sx: { height: '88vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' } } }}
> >
{/* Header */} {/* Header */}
<Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}> <Box sx={{ px: 3, py: 1.5, borderBottom: `1px solid ${DS_BORDER.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
+198
View File
@@ -0,0 +1,198 @@
import { useMemo } from 'react'
import { Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { getKpisForRadius } from '../../domain/marketReport'
import type { MarketKpis } from '../../domain/marketReport'
interface Props {
baseKpis: MarketKpis
assetTypeLabel: string
radius: number
onRadiusChange: (r: number) => void
}
const RADII = [5, 10, 15, 20]
function vacancyColor(pct: number): string {
if (pct < 4) return '#1a7a4a'
if (pct < 8) return '#d97706'
return '#dc2626'
}
function vacancyLabel(pct: number): string {
if (pct < 4) return 'Engpassmarkt'
if (pct < 8) return 'Ausgewogen'
return 'Überangebot'
}
function searchesColor(count: number): string {
if (count >= 15) return '#1a7a4a'
if (count >= 7) return '#d97706'
return '#dc2626'
}
function searchesLabel(count: number): string {
if (count >= 15) return 'Hohe Nachfrage'
if (count >= 7) return 'Moderate Nachfrage'
return 'Geringe Nachfrage'
}
function durationColor(months: number): string {
if (months < 4) return '#1a7a4a'
if (months < 7) return '#d97706'
return '#dc2626'
}
function durationLabel(months: number): string {
if (months < 4) return 'Schnell absorbiert'
if (months < 7) return 'Moderat'
return 'Träger Markt'
}
function ratioColor(ratio: number): string {
if (ratio >= 2) return '#1a7a4a'
if (ratio >= 1) return '#d97706'
return '#dc2626'
}
function ratioLabel(ratio: number): string {
if (ratio >= 2) return 'Nachfrageüberhang'
if (ratio >= 1) return 'Leicht ausgeglichen'
return 'Angebotsüberhang'
}
interface KpiCardProps {
label: string
value: string
badge: string
badgeColor: string
}
function KpiCard({ label, value, badge, badgeColor }: KpiCardProps) {
return (
<Box
sx={{
flex: 1,
minWidth: 0,
p: 1.5,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: '#ffffff',
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 500, fontSize: '0.7rem', lineHeight: 1.2 }}>
{label}
</Typography>
<Typography variant="body1" sx={{ fontWeight: 700, color: '#0f172a', lineHeight: 1 }}>
{value}
</Typography>
<Box
sx={{
display: 'inline-block',
px: 0.75,
py: 0.25,
borderRadius: 0.75,
bgcolor: `${badgeColor}18`,
alignSelf: 'flex-start',
mt: 0.25,
}}
>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: badgeColor, lineHeight: 1.4 }}>
{badge}
</Typography>
</Box>
</Box>
)
}
export function MarketKpiPanel({ baseKpis, assetTypeLabel, radius, onRadiusChange }: Props) {
const kpis = useMemo(
() => radius === 5 ? baseKpis : getKpisForRadius(baseKpis, radius),
[baseKpis, radius]
)
return (
<Box
sx={{
mb: 2.5,
p: 1.75,
border: '1px solid #e2e8f0',
borderRadius: 2,
bgcolor: '#f8fafc',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.8125rem' }}>
Marktumfeld {assetTypeLabel}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Vergleichbare Flächen im Umkreis
</Typography>
</Box>
<ToggleButtonGroup
value={radius}
exclusive
onChange={(_, v) => v !== null && onRadiusChange(v)}
size="small"
sx={{
'& .MuiToggleButton-root': {
py: 0.25,
px: 0.875,
fontSize: '0.7rem',
textTransform: 'none',
fontWeight: 600,
border: '1px solid #e2e8f0',
color: '#64748b',
'&.Mui-selected': {
bgcolor: '#1e3a5f',
color: '#ffffff',
borderColor: '#1e3a5f',
'&:hover': { bgcolor: '#1e3a5f' },
},
},
}}
>
{RADII.map(r => (
<ToggleButton key={r} value={r}>
{r} km
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<KpiCard
label="Leerstandsquote"
value={`${kpis.vacancyRatePct.toFixed(1)} %`}
badge={vacancyLabel(kpis.vacancyRatePct)}
badgeColor={vacancyColor(kpis.vacancyRatePct)}
/>
<KpiCard
label="Aktive Suchanfragen"
value={String(kpis.activePlatformSearches)}
badge={searchesLabel(kpis.activePlatformSearches)}
badgeColor={searchesColor(kpis.activePlatformSearches)}
/>
<KpiCard
label="Ø Leerstandsdauer"
value={`${kpis.avgVacancyMonths.toFixed(1)} Mo.`}
badge={durationLabel(kpis.avgVacancyMonths)}
badgeColor={durationColor(kpis.avgVacancyMonths)}
/>
<KpiCard
label="Nachfrage / Angebot"
value={`${kpis.demandSupplyRatio.toFixed(1)}×`}
badge={ratioLabel(kpis.demandSupplyRatio)}
badgeColor={ratioColor(kpis.demandSupplyRatio)}
/>
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', mt: 1, display: 'block' }}>
Basis: {kpis.comparableListings} vergleichbare {assetTypeLabel}-Flächen im {radius}-km-Radius · KI-gestützte Schätzung
</Typography>
</Box>
)
}
@@ -1,4 +1,5 @@
import { Box, Divider, LinearProgress, TextField, Typography } from '@mui/material' import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
import { ExternalLink, FileText } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property' import type { Property, UpdatePropertyInput } from '../../domain/property'
import { PropertyMap } from '../shared' import { PropertyMap } from '../shared'
import { getAssetTypeLabel, qualityColor } from './propertyHelpers' import { getAssetTypeLabel, qualityColor } from './propertyHelpers'
@@ -97,8 +98,32 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })} onChange={e => onDraftChange({ ...draft, leaseEndDate: e.target.value })}
/> />
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, mb: 0.5 }}>
<FileText size={13} color="#64748b" />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.4 }}>
Mietvertrag
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
<TextField
label="Vertrag URL"
size="small"
value={draft.leaseContractUrl ?? p.leaseContractUrl ?? ''}
onChange={e => onDraftChange({ ...draft, leaseContractUrl: e.target.value })}
placeholder="https://…"
slotProps={{ input: { startAdornment: <ExternalLink size={12} style={{ marginRight: 6, color: '#94a3b8', flexShrink: 0 }} /> } }}
/>
<TextField
label="Bezeichnung (optional)"
size="small"
value={draft.leaseContractName ?? p.leaseContractName ?? ''}
onChange={e => onDraftChange({ ...draft, leaseContractName: e.target.value })}
placeholder="z.B. Mietvertrag 20212026"
/>
</Box>
</Box> </Box>
) : ( ) : (
<>
<FieldGrid> <FieldGrid>
<Field label="Aktueller Mieter" value={p.currentTenant} /> <Field label="Aktueller Mieter" value={p.currentTenant} />
<Field label="Mietlaufzeit" value={p.leaseTerm} /> <Field label="Mietlaufzeit" value={p.leaseTerm} />
@@ -109,6 +134,39 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
<Field label="Importiert aus" value={p.importedFrom} /> <Field label="Importiert aus" value={p.importedFrom} />
<Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} /> <Field label="Zuletzt aktualisiert" value={p.lastUpdatedAt ? new Date(p.lastUpdatedAt).toLocaleDateString('de-CH') : undefined} />
</FieldGrid> </FieldGrid>
{/* Mietvertrag */}
<Box sx={{ mt: 1.5, p: 1.5, border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: p.leaseContractUrl ? '#f8fafc' : '#fafafa' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: p.leaseContractUrl ? 0.75 : 0 }}>
<FileText size={13} color={p.leaseContractUrl ? '#1e3a5f' : '#94a3b8'} />
<Typography variant="caption" sx={{ fontWeight: 700, color: p.leaseContractUrl ? '#1e3a5f' : '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.4 }}>
Mietvertrag
</Typography>
</Box>
{p.leaseContractUrl ? (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500 }} noWrap>
{p.leaseContractName ?? 'Mietvertrag'}
</Typography>
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={11} />}
href={p.leaseContractUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.25, px: 1, borderColor: '#cbd5e1', color: '#1e3a5f', whiteSpace: 'nowrap', flexShrink: 0 }}
>
Öffnen
</Button>
</Box>
) : (
<Typography variant="caption" sx={{ color: '#94a3b8' }}>
Noch kein Mietvertrag hinterlegt im Bearbeitungsmodus hinzufügen.
</Typography>
)}
</Box>
</>
)} )}
{/* Floor / unit structure */} {/* Floor / unit structure */}
+8 -6
View File
@@ -28,11 +28,13 @@ import { PropertyMarketSignalsTab } from './PropertyMarketSignalsTab'
interface PropertyDetailViewProps { interface PropertyDetailViewProps {
propertyId: string propertyId: string
onClose?: () => void onClose?: () => void
hideTabs?: Array<'Matchability' | 'Marktsignale'>
} }
const TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const const ALL_TABS = ['Übersicht', 'Matchability', 'Marktsignale'] as const
export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewProps) { export function PropertyDetailView({ propertyId, onClose, hideTabs = [] }: PropertyDetailViewProps) {
const TABS = ALL_TABS.filter(t => !hideTabs.includes(t as 'Matchability' | 'Marktsignale'))
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<UpdatePropertyInput>({}) const [draft, setDraft] = useState<UpdatePropertyInput>({})
@@ -175,8 +177,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
</Tabs> </Tabs>
{/* Tab content */} {/* Tab content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: tab === 0 ? 2.5 : 0 }}> <Box sx={{ flex: 1, overflowY: 'auto', p: TABS[tab] === 'Übersicht' ? 2.5 : 0 }}>
{tab === 0 && ( {TABS[tab] === 'Übersicht' && (
<PropertyDetailOverview <PropertyDetailOverview
p={property} p={property}
editing={editing} editing={editing}
@@ -184,8 +186,8 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr
onDraftChange={setDraft} onDraftChange={setDraft}
/> />
)} )}
{tab === 1 && <MatchabilityTabPanel propertyId={propertyId} />} {TABS[tab] === 'Matchability' && <MatchabilityTabPanel propertyId={propertyId} />}
{tab === 2 && <PropertyMarketSignalsTab propertyId={propertyId} />} {TABS[tab] === 'Marktsignale' && <PropertyMarketSignalsTab propertyId={propertyId} />}
</Box> </Box>
</Box> </Box>
) )
@@ -1,6 +1,6 @@
import { memo } from 'react' import { memo } from 'react'
import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material' import { Box, Chip, LinearProgress, Typography } from '@mui/material'
import { LocationPreview } from '../shared/LocationPreview' import { MapPin, Maximize2, TrendingUp, Calendar } from 'lucide-react'
import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers' import { getAssetTypeColor, getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
@@ -9,133 +9,176 @@ interface Props {
onSelect: (id: string) => void onSelect: (id: string) => void
} }
function availabilityBadgeColor(status: string): string { function availabilityColor(status: string) {
if (status === 'AVAILABLE_NOW') return '#1a7a4a' if (status === 'AVAILABLE_NOW') return { bg: '#dcfce7', text: '#15803d' }
if (status === 'AVAILABLE_SOON') return '#d97706' if (status === 'AVAILABLE_SOON') return { bg: '#fef9c3', text: '#a16207' }
return '#64748b' return { bg: '#f1f5f9', text: '#64748b' }
} }
export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) { export const PropertyIntelligenceCard = memo(function PropertyIntelligenceCard({ property: p, onSelect }: Props) {
const confPct = Math.round(p.confidenceScore * 100) const confPct = Math.round(p.confidenceScore * 100)
const confColor = p.confidenceScore >= 0.75 ? '#1a7a4a' : p.confidenceScore >= 0.55 ? '#d97706' : '#c0392b' const confColor = p.confidenceScore >= 0.75 ? '#15803d' : p.confidenceScore >= 0.55 ? '#d97706' : '#dc2626'
const annualRent = Math.round(p.areaSqm * p.rentPricePerSqm / 1000)
const avail = availabilityColor(p.availabilityStatus)
const assetColor = getAssetTypeColor(p.assetType)
const hasImage = !!p.images?.[0]
return ( return (
<Box <Box
onClick={() => onSelect(p.id)}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
border: '1px solid #e8d5c4', border: '1px solid #e2e8f0',
background: 'linear-gradient(160deg,#fdf8f3,#faf0e6)', bgcolor: 'white',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)', boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
transition: 'box-shadow 0.15s, transform 0.1s',
cursor: 'pointer', cursor: 'pointer',
transition: 'box-shadow 0.15s, transform 0.12s',
'&:hover': { '&:hover': {
boxShadow: '0 6px 24px rgba(0,0,0,0.10)', boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
transform: 'translateY(-1px)', transform: 'translateY(-2px)',
borderColor: '#cbd5e1',
}, },
}} }}
onClick={() => onSelect(p.id)}
> >
{/* Image zone */} {/* ── Image / header ── */}
<Box sx={{ position: 'relative' }}> <Box sx={{ position: 'relative', height: 120, flexShrink: 0, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
<LocationPreview {hasImage ? (
imageUrl={p.images?.[0]} <Box
lat={p.location.coordinates?.lat} component="img"
lng={p.location.coordinates?.lng} src={p.images![0]}
address={`${p.address.street} ${p.address.houseNumber}, ${p.address.city}`} alt={p.title}
cityLabel={p.location.city} sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
height={175}
/> />
) : (
{/* Availability badge */} <Box
<Box sx={{ position: 'absolute', top: 10, left: 10 }}>
<Chip
label={getAvailabilityLabel(p.availabilityStatus)}
size="small"
sx={{ sx={{
bgcolor: availabilityBadgeColor(p.availabilityStatus), width: '100%', height: '100%',
color: 'white', background: `linear-gradient(135deg, ${assetColor}22 0%, ${assetColor}44 100%)`,
fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10,
height: 20,
}} }}
/> >
<Typography sx={{ fontSize: '2.5rem', opacity: 0.3 }}>🏢</Typography>
</Box> </Box>
)}
{/* Asset type chip */} {/* Overlay gradient */}
<Box sx={{ position: 'absolute', top: 10, right: 44 }}> <Box sx={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.45) 0%, transparent 55%)' }} />
{/* Top badges */}
<Box sx={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 0.5 }}>
<Chip <Chip
label={getAssetTypeLabel(p.assetType)} label={getAssetTypeLabel(p.assetType)}
size="small" size="small"
sx={{ sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: assetColor, color: 'white', borderRadius: 1 }}
bgcolor: getAssetTypeColor(p.assetType), />
color: 'white', <Chip
fontWeight: 600, label={getAvailabilityLabel(p.availabilityStatus)}
fontSize: 10, size="small"
height: 20, sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700, bgcolor: avail.bg, color: avail.text, borderRadius: 1 }}
}}
/> />
</Box> </Box>
{/* Bottom: annual rent on image */}
<Box sx={{ position: 'absolute', bottom: 8, left: 10, right: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<Box>
<Typography sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.65rem', lineHeight: 1 }}>Jahresmiete ca.</Typography>
<Typography sx={{ color: 'white', fontWeight: 800, fontSize: '1.05rem', lineHeight: 1.2 }}>
CHF {annualRent}k
</Typography>
</Box>
<Typography sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '0.72rem', fontWeight: 600 }}>
{p.rentPricePerSqm} /m²/J
</Typography>
</Box>
</Box> </Box>
{/* Card body */} {/* ── Card body ── */}
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 0, flex: 1 }}> <Box sx={{ p: 1.5, display: 'flex', flexDirection: 'column', gap: 1, flex: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
{/* Title + location */}
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: '#0f172a', fontSize: '0.875rem' }} noWrap>
{p.title} {p.title}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25, mb: 1.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.3 }}>
<MapPin size={11} color="#94a3b8" />
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem' }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''} {p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography> </Typography>
</Box>
</Box>
{/* Key specs */} {/* Key metrics row */}
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<Chip label={`${p.areaSqm}`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} /> <Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
<Chip label={`CHF ${p.rentPricePerSqm}/m²/Jahr`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} /> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
{p.contractDurationMonths && ( <Maximize2 size={10} color="#64748b" />
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small" sx={{ fontSize: 11, bgcolor: '#f1f5f9', height: 22 }} /> <Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Fläche</Typography>
</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
{p.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
{p.availabilityDate && (
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
<Calendar size={10} color="#64748b" />
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Ab</Typography>
</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
{new Date(p.availabilityDate).toLocaleDateString('de-CH', { month: 'short', year: '2-digit' })}
</Typography>
</Box>
)}
{p.softFactors?.prestige != null && (
<Box sx={{ flex: 1, bgcolor: '#f8fafc', borderRadius: 1, px: 1, py: 0.75, textAlign: 'center', border: '1px solid #f1f5f9' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.3, mb: 0.1 }}>
<TrendingUp size={10} color="#64748b" />
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Prestige</Typography>
</Box>
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#0f172a' }}>
{p.softFactors.prestige}
</Typography>
</Box>
)} )}
</Box> </Box>
{/* Soft factors */} {/* Soft factor chips */}
{p.softFactors && ( {p.softFactors && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1.25 }}> <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{p.softFactors.prestige != null && ( {p.softFactors.publicTransportMinutes != null && (
<Chip label={`Prestige ${p.softFactors.prestige}`} size="small" <Chip label={`🚇 ${p.softFactors.publicTransportMinutes} min ÖV`} size="small"
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} /> sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f0f9ff', color: '#0369a1' }} />
)} )}
{p.softFactors.accessibility != null && ( {p.hardFacts?.fitOut && (
<Chip label={`ÖV ${p.softFactors.publicTransportMinutes ?? p.softFactors.accessibility}min`} size="small" <Chip label={p.hardFacts.fitOut} size="small"
sx={{ fontSize: 10, bgcolor: 'rgba(30,58,95,0.08)', color: '#1e3a5f', height: 20 }} /> sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f5f3ff', color: '#6d28d9' }} />
)}
{p.contractDurationMonths && (
<Chip label={`${p.contractDurationMonths}M Vertrag`} size="small"
sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#f1f5f9', color: '#475569' }} />
)} )}
</Box> </Box>
)} )}
{/* Confidence */} {/* Confidence bar */}
<Box sx={{ mt: 'auto', pt: 1 }}> <Box sx={{ mt: 'auto' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Datenkonfidenz</Typography> <Typography sx={{ fontSize: '0.67rem', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.3 }}>Datenkonfidenz</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: confColor }}>{confPct}%</Typography> <Typography sx={{ fontSize: '0.72rem', fontWeight: 700, color: confColor }}>{confPct}%</Typography>
</Box> </Box>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={confPct} value={confPct}
sx={{ sx={{
height: 5, borderRadius: 3, bgcolor: '#e2e8f0', height: 4, borderRadius: 2, bgcolor: '#f1f5f9',
'& .MuiLinearProgress-bar': { bgcolor: confColor }, '& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 2 },
}} }}
/> />
</Box> </Box>
<Button
size="small"
variant="text"
onClick={e => { e.stopPropagation(); onSelect(p.id) }}
sx={{ mt: 1.25, alignSelf: 'flex-end', textTransform: 'none', fontSize: '0.75rem', color: '#1e3a5f', px: 0 }}
>
Details anzeigen
</Button>
</Box> </Box>
</Box> </Box>
) )
@@ -5,21 +5,33 @@ import { useMarketReport } from '../../hooks/useMarketReport'
import type { SignalCategory } from '../../domain/marketReport' import type { SignalCategory } from '../../domain/marketReport'
import { SignalCard } from './SignalCard' import { SignalCard } from './SignalCard'
import { BerichtDialog } from './BerichtDialog' import { BerichtDialog } from './BerichtDialog'
import { MarketKpiPanel } from './MarketKpiPanel'
interface Props { interface Props {
propertyId: string propertyId: string
} }
const SECTION_CONFIG: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [ const STATIC_SECTIONS: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
{ category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' }, { category: 'development', label: 'Entwicklungsnews', icon: <Building2 size={16} />, color: '#1e3a5f' },
{ category: 'demand', label: 'Nachfrage 5 km', icon: <TrendingUp size={16} />, color: '#1a7a4a' },
{ category: 'supply', label: 'Angebot 5 km', icon: <BarChart2 size={16} />, color: '#b45309' },
{ category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' }, { category: 'negotiation', label: 'Verhandlungshinweise', icon: <Lightbulb size={16} />, color: '#7c3aed' },
] ]
export function PropertyMarketSignalsTab({ propertyId }: Props) { export function PropertyMarketSignalsTab({ propertyId }: Props) {
const { data: report = null, isLoading: loading } = useMarketReport(propertyId) const { data: report = null, isLoading: loading } = useMarketReport(propertyId)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [radius, setRadius] = useState(5)
const dynamicSections: { category: SignalCategory; label: string; icon: React.ReactNode; color: string }[] = [
{ category: 'demand', label: `Nachfrage ${radius} km`, icon: <TrendingUp size={16} />, color: '#1a7a4a' },
{ category: 'supply', label: `Angebot ${radius} km`, icon: <BarChart2 size={16} />, color: '#b45309' },
]
const sectionConfig = [
STATIC_SECTIONS[0],
dynamicSections[0],
dynamicSections[1],
STATIC_SECTIONS[1],
]
if (loading) { if (loading) {
return ( return (
@@ -58,7 +70,17 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor. Für dieses Objekt liegen noch keine KI-generierten Marktsignale vor.
</Alert> </Alert>
) : ( ) : (
SECTION_CONFIG.map(({ category, label, icon, color }) => { <>
{report.kpis && (
<MarketKpiPanel
baseKpis={report.kpis}
assetTypeLabel={report.assetTypeLabel}
radius={radius}
onRadiusChange={setRadius}
/>
)}
{sectionConfig.map(({ category, label, icon, color }) => {
const signals = report.signals.filter(s => s.category === category) const signals = report.signals.filter(s => s.category === category)
if (signals.length === 0) return null if (signals.length === 0) return null
return ( return (
@@ -77,7 +99,8 @@ export function PropertyMarketSignalsTab({ propertyId }: Props) {
{signals.map(s => <SignalCard key={s.id} signal={s} />)} {signals.map(s => <SignalCard key={s.id} signal={s} />)}
</Box> </Box>
) )
}) })}
</>
)} )}
{dialogOpen && ( {dialogOpen && (
+25 -2
View File
@@ -12,18 +12,20 @@ import {
FormControlLabel, FormControlLabel,
Tooltip, Tooltip,
} from '@mui/material' } from '@mui/material'
import { X, MapPin, Calendar, DollarSign, Eye, Activity } from 'lucide-react' import { X, MapPin, Calendar, DollarSign, Eye, Activity, FileText, ExternalLink } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore' import { useReminderStore } from '../../stores/reminderStore'
import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' import { useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
import { usePropertyById } from '../../hooks/useProperties'
import { ReminderPriorityBadge } from './ReminderPriorityBadge' import { ReminderPriorityBadge } from './ReminderPriorityBadge'
import { ReminderTypeBadge } from './ReminderTypeBadge' import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator' import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { ReminderStatus } from '../../domain/reminder' import { ReminderStatus } from '../../domain/reminder'
import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, ACTION_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers' import { SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, STATUS_CHIP_COLOR, STATUS_LABEL, SectionTitle, DateRow, ActivityEntry } from './reminderDetailHelpers'
export function ReminderDetailDrawer() { export function ReminderDetailDrawer() {
const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore() const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore()
const { data: reminder } = useReminder(selectedId ?? '') const { data: reminder } = useReminder(selectedId ?? '')
const { data: reminderProperty } = usePropertyById(reminder?.propertyId ?? '')
const complete = useCompleteReminder() const complete = useCompleteReminder()
const dismiss = useDismissReminder() const dismiss = useDismissReminder()
const snooze = useSnoozeReminder() const snooze = useSnoozeReminder()
@@ -101,6 +103,27 @@ export function ReminderDetailDrawer() {
</Typography> </Typography>
<Typography variant="caption" color="text.secondary">Mieter: {reminder.tenantName}</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> <Typography variant="caption" color="text.secondary">Fläche: {reminder.areaSqm.toLocaleString('de-CH')} m²</Typography>
{reminderProperty?.leaseContractUrl ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5 }}>
<FileText size={12} color="#1e3a5f" />
<Button
size="small"
variant="outlined"
endIcon={<ExternalLink size={10} />}
href={reminderProperty.leaseContractUrl}
target="_blank"
rel="noopener noreferrer"
sx={{ textTransform: 'none', fontSize: '0.68rem', py: 0.125, px: 0.75, borderColor: '#cbd5e1', color: '#1e3a5f' }}
>
{reminderProperty.leaseContractName ?? 'Mietvertrag'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<FileText size={12} color="#cbd5e1" />
<Typography variant="caption" sx={{ color: '#cbd5e1' }}>Kein Mietvertrag hinterlegt</Typography>
</Box>
)}
</Box> </Box>
</Box> </Box>
+109 -28
View File
@@ -1,5 +1,5 @@
import { useMemo, useCallback } from 'react' import { useMemo, useCallback } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Chip, Typography } from '@mui/material'
import { useReminders } from '../../hooks/useReminders' import { useReminders } from '../../hooks/useReminders'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useReminderStore } from '../../stores/reminderStore' import { useReminderStore } from '../../stores/reminderStore'
@@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard'
import { ReminderSkeleton } from './ReminderSkeleton' import { ReminderSkeleton } from './ReminderSkeleton'
import { ReminderEmptyState } from './ReminderEmptyState' import { ReminderEmptyState } from './ReminderEmptyState'
import type { Reminder } from '../../domain/reminder' import type { Reminder } from '../../domain/reminder'
import { ReminderStatus } from '../../domain/reminder'
import type { FilterHorizon } from '../../stores/reminderStore'
const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px' const MOCK_TODAY = new Date('2026-05-20')
function getHorizon(dueDate: string): FilterHorizon {
const days = Math.ceil((new Date(dueDate).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
if (days <= 0) return 'OVERDUE'
if (days <= 7) return 'THIS_WEEK'
if (days <= 30) return 'THIS_MONTH'
return 'LATER'
}
const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: string; border: string }[] = [
{ key: 'OVERDUE', label: 'Überfällig', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
{ key: 'THIS_WEEK', label: 'Diese Woche', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
{ key: 'THIS_MONTH', label: 'Dieser Monat', color: '#0369a1', bg: '#f0f9ff', border: '#bae6fd' },
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e2e8f0' },
]
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
function applyFilters( function applyFilters(
reminders: Reminder[], reminders: Reminder[],
filterType: string, filterType: string,
filterStatus: string, filterStatus: string,
filterPriority: string, filterHorizon: string,
searchQuery: string, searchQuery: string,
): Reminder[] { ): Reminder[] {
return reminders.filter(r => { return reminders.filter(r => {
if (filterStatus === 'ACTIVE') {
if (r.status !== ReminderStatus.ACTIVE && r.status !== ReminderStatus.SNOOZED) return false
} else if (filterStatus !== 'ALL') {
if (r.status !== filterStatus) return false
}
if (filterType !== 'ALL' && r.type !== filterType) return false if (filterType !== 'ALL' && r.type !== filterType) return false
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
if (searchQuery) { if (searchQuery) {
const q = searchQuery.toLowerCase() const q = searchQuery.toLowerCase()
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase() const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
if (!haystack.includes(q)) return false if (!hay.includes(q)) return false
} }
return true return true
}) })
@@ -34,38 +57,60 @@ function applyFilters(
export function ReminderFeed() { export function ReminderFeed() {
const { data, isLoading } = useReminders() const { data, isLoading } = useReminders()
const { const {
filterType, filterStatus, filterPriority, searchQuery, viewMode, filterType, filterStatus, filterHorizon, searchQuery, viewMode,
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery, setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery,
} = useReminderStore(useShallow(s => ({ } = useReminderStore(useShallow(s => ({
filterType: s.filterType, filterStatus: s.filterStatus, filterType: s.filterType, filterStatus: s.filterStatus,
filterPriority: s.filterPriority, searchQuery: s.searchQuery, filterHorizon: s.filterHorizon, searchQuery: s.searchQuery,
viewMode: s.viewMode, setFilterType: s.setFilterType, viewMode: s.viewMode, setFilterType: s.setFilterType,
setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority, setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon,
setSearchQuery: s.setSearchQuery, setSearchQuery: s.setSearchQuery,
}))) })))
const reminders = data ?? [] const reminders = data ?? []
const filtered = useMemo( const filtered = useMemo(
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery), () => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery),
[reminders, filterType, filterStatus, filterPriority, searchQuery], [reminders, filterType, filterStatus, filterHorizon, searchQuery],
) )
const grouped = useMemo(() => {
const map: Record<string, Reminder[]> = {
OVERDUE: [], THIS_WEEK: [], THIS_MONTH: [], LATER: [],
}
filtered.forEach(r => map[getHorizon(r.dueDate)].push(r))
return map
}, [filtered])
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
setFilterType('ALL') setFilterType('ALL')
setFilterStatus('ALL') setFilterStatus('ACTIVE')
setFilterPriority('ALL') setFilterHorizon('ALL')
setSearchQuery('') setSearchQuery('')
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery]) }, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery])
if (isLoading) return <ReminderSkeleton /> if (isLoading) return <ReminderSkeleton />
if (filtered.length === 0) return <ReminderEmptyState onReset={resetFilters} />
if (filtered.length === 0) {
return <ReminderEmptyState onReset={resetFilters} />
}
if (viewMode === 'card') { if (viewMode === 'card') {
return ( return (
<Box className="flex flex-col gap-4">
{HORIZON_CONFIG.map(({ key, label, color }) => {
const group = grouped[key]
if (!group || group.length === 0) return null
return (
<Box key={key}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, px: 0.5 }}>
<Box sx={{ width: 3, height: 16, borderRadius: 1, bgcolor: color, flexShrink: 0 }} />
<Typography variant="body2" sx={{ fontWeight: 700, color, fontSize: '0.8125rem' }}>
{label}
</Typography>
<Chip
label={group.length}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: color, color: 'white', ml: 0.25 }}
/>
</Box>
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
@@ -73,20 +118,22 @@ export function ReminderFeed() {
gap: 2, gap: 2,
}} }}
> >
{filtered.map(r => ( {group.map(r => <ReminderCard key={r.id} reminder={r} />)}
<ReminderCard key={r.id} reminder={r} /> </Box>
))} </Box>
)
})}
</Box> </Box>
) )
} }
return ( return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}> <Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
{/* Table header */} {/* Table header */}
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: LIST_HEADER_COLS, gridTemplateColumns: LIST_COLS,
gap: 1, gap: 1,
px: 2, px: 2,
py: 1, py: 1,
@@ -101,9 +148,43 @@ export function ReminderFeed() {
))} ))}
</Box> </Box>
{filtered.map(r => ( {/* Grouped rows */}
<ReminderListRow key={r.id} reminder={r} /> {HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
))} const group = grouped[key]
if (!group || group.length === 0) return null
return (
<Box key={key}>
{/* Section divider */}
<Box
sx={{
px: 2,
py: 0.625,
bgcolor: bg,
borderTop: `1px solid ${border}`,
borderBottom: `1px solid ${border}`,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography
variant="caption"
sx={{ fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: 0.6, fontSize: '0.65rem' }}
>
{label}
</Typography>
<Chip
label={group.length}
size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: color, color: 'white', px: 0 }}
/>
</Box>
{/* Rows */}
{group.map(r => <ReminderListRow key={r.id} reminder={r} />)}
</Box>
)
})}
</Box> </Box>
) )
} }
+43 -81
View File
@@ -1,9 +1,9 @@
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography } from '@mui/material' import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography, Select, MenuItem, FormControl, FormControlLabel, Switch } from '@mui/material'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useReminderStore } from '../../stores/reminderStore' import { useReminderStore } from '../../stores/reminderStore'
import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder' import { ReminderType } from '../../domain/reminder'
import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder' import type { ReminderType as ReminderTypeType } from '../../domain/reminder'
const TYPE_LABELS: Record<ReminderTypeType, string> = { const TYPE_LABELS: Record<ReminderTypeType, string> = {
LEASE_EXPIRY: 'Mietablauf', LEASE_EXPIRY: 'Mietablauf',
@@ -16,38 +16,23 @@ const TYPE_LABELS: Record<ReminderTypeType, string> = {
CUSTOM: 'Individuell', CUSTOM: 'Individuell',
} }
const STATUS_LABELS: Record<ReminderStatusType, string> = {
ACTIVE: 'Aktiv',
SNOOZED: 'Schlummern',
COMPLETED: 'Erledigt',
DISMISSED: 'Verworfen',
}
const PRIORITY_LABELS: Record<ReminderPriorityType, string> = {
URGENT: 'Dringend',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
}
export function ReminderFilterBar() { export function ReminderFilterBar() {
const { const {
filterType, setFilterType, filterType, setFilterType,
filterStatus, setFilterStatus, filterStatus, setFilterStatus,
filterPriority, setFilterPriority,
searchQuery, setSearchQuery, searchQuery, setSearchQuery,
viewMode, setViewMode, viewMode, setViewMode,
} = useReminderStore(useShallow(s => ({ } = useReminderStore(useShallow(s => ({
filterType: s.filterType, setFilterType: s.setFilterType, filterType: s.filterType, setFilterType: s.setFilterType,
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus, filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
filterPriority: s.filterPriority, setFilterPriority: s.setFilterPriority,
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery, searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
viewMode: s.viewMode, setViewMode: s.setViewMode, viewMode: s.viewMode, setViewMode: s.setViewMode,
}))) })))
const showArchive = filterStatus === 'ALL'
return ( return (
<Box className="flex flex-col gap-3"> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Box className="flex items-center gap-3 flex-wrap">
{/* Search */} {/* Search */}
<TextField <TextField
size="small" size="small"
@@ -63,7 +48,43 @@ export function ReminderFilterBar() {
), ),
}, },
}} }}
sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} sx={{ width: 200, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
{/* Type selector */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>Typ:</Typography>
<FormControl size="small">
<Select
value={filterType}
onChange={e => setFilterType(e.target.value as ReminderTypeType | 'ALL')}
sx={{ fontSize: '0.8125rem', minWidth: 150 }}
>
<MenuItem value="ALL" sx={{ fontSize: '0.8125rem' }}>Alle Typen</MenuItem>
{Object.values(ReminderType).map(t => (
<MenuItem key={t} value={t} sx={{ fontSize: '0.8125rem' }}>
{TYPE_LABELS[t]}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{/* Archive toggle */}
<FormControlLabel
control={
<Switch
size="small"
checked={showArchive}
onChange={e => setFilterStatus(e.target.checked ? 'ALL' : 'ACTIVE')}
/>
}
label={
<Typography variant="caption" color="text.secondary">
Abgeschlossene anzeigen
</Typography>
}
sx={{ m: 0 }}
/> />
{/* View mode */} {/* View mode */}
@@ -80,64 +101,5 @@ export function ReminderFilterBar() {
</ToggleButtonGroup> </ToggleButtonGroup>
</Box> </Box>
</Box> </Box>
<Box className="flex flex-wrap gap-3">
{/* Type filter */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Typ:</Typography>
<ToggleButtonGroup
value={filterType}
exclusive
onChange={(_, v) => v && setFilterType(v)}
size="small"
>
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
{Object.values(ReminderType).map(t => (
<ToggleButton key={t} value={t} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
{TYPE_LABELS[t]}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
</Box>
<Box className="flex flex-wrap gap-3">
{/* Status filter */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Status:</Typography>
<ToggleButtonGroup
value={filterStatus}
exclusive
onChange={(_, v) => v && setFilterStatus(v)}
size="small"
>
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
{Object.values(ReminderStatus).map(s => (
<ToggleButton key={s} value={s} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
{STATUS_LABELS[s]}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
{/* Priority filter */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Priorität:</Typography>
<ToggleButtonGroup
value={filterPriority}
exclusive
onChange={(_, v) => v && setFilterPriority(v)}
size="small"
>
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
{Object.values(ReminderPriority).map(p => (
<ToggleButton key={p} value={p} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
{PRIORITY_LABELS[p]}
</ToggleButton>
))}
</ToggleButtonGroup>
</Box>
</Box>
</Box>
) )
} }
+70 -22
View File
@@ -1,32 +1,46 @@
import { Box, Paper, Typography, Skeleton } from '@mui/material' import { Box, Typography, Skeleton } from '@mui/material'
import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react' import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { useReminderInsights } from '../../hooks/useReminders' import { useReminderInsights } from '../../hooks/useReminders'
import { useReminderStore } from '../../stores/reminderStore'
import type { FilterHorizon } from '../../stores/reminderStore'
import { ReminderType } from '../../domain/reminder'
interface KpiItemProps { interface KpiCardProps {
icon: React.ReactNode icon: React.ReactNode
label: string label: string
value: number | string value: number | string
color: string color: string
active: boolean
onClick: () => void
} }
function KpiItem({ icon, label, value, color }: KpiItemProps) { function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
return ( return (
<Paper <Box
variant="outlined" onClick={onClick}
sx={{ sx={{
flex: 1, flex: 1,
p: 1.5, p: 1.5,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 1.5, gap: 1.5,
borderColor: '#e2e8f0', border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: active ? `${color}10` : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
minWidth: 0, minWidth: 0,
'&:hover': {
bgcolor: `${color}0d`,
borderColor: color,
},
}} }}
> >
<Box <Box
sx={{ sx={{
width: 36, width: 34,
height: 36, height: 34,
borderRadius: 1, borderRadius: 1,
bgcolor: `${color}18`, bgcolor: `${color}18`,
display: 'flex', display: 'flex',
@@ -41,16 +55,42 @@ function KpiItem({ icon, label, value, color }: KpiItemProps) {
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}> <Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
{value} {value}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}> <Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap', fontSize: '0.7rem' }}>
{label} {label}
</Typography> </Typography>
</Box> </Box>
</Paper> </Box>
) )
} }
export function ReminderKpiBar() { export function ReminderKpiBar() {
const { data, isLoading } = useReminderInsights() const { data, isLoading } = useReminderInsights()
const { filterHorizon, setFilterHorizon, filterType, setFilterType } = useReminderStore(
useShallow(s => ({
filterHorizon: s.filterHorizon,
setFilterHorizon: s.setFilterHorizon,
filterType: s.filterType,
setFilterType: s.setFilterType,
}))
)
function toggleHorizon(h: FilterHorizon) {
if (filterHorizon === h) {
setFilterHorizon('ALL')
} else {
setFilterHorizon(h)
setFilterType('ALL') // clear type filter so count matches
}
}
function togglePreMarket() {
if (filterType === ReminderType.SCHATTENMARKT_RELEASE) {
setFilterType('ALL')
} else {
setFilterType(ReminderType.SCHATTENMARKT_RELEASE)
setFilterHorizon('ALL') // clear horizon filter so count matches
}
}
if (isLoading) { if (isLoading) {
return ( return (
@@ -62,33 +102,41 @@ export function ReminderKpiBar() {
) )
} }
const insights = data ?? { urgentCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 } const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
return ( return (
<Box className="flex gap-3"> <Box className="flex gap-3">
<KpiItem <KpiCard
icon={<AlertTriangle size={18} color="#dc2626" />} icon={<AlertOctagon size={18} color="#dc2626" />}
label="Dringend" label="Überfällig"
value={insights.urgentCount} value={ins.overdueCount}
color="#dc2626" color="#dc2626"
active={filterHorizon === 'OVERDUE'}
onClick={() => toggleHorizon('OVERDUE')}
/> />
<KpiItem <KpiCard
icon={<Calendar size={18} color="#ea580c" />} icon={<Calendar size={18} color="#ea580c" />}
label="Diese Woche" label="Diese Woche"
value={insights.dueThisWeek} value={ins.dueThisWeek}
color="#ea580c" color="#ea580c"
active={filterHorizon === 'THIS_WEEK'}
onClick={() => toggleHorizon('THIS_WEEK')}
/> />
<KpiItem <KpiCard
icon={<CalendarDays size={18} color="#0369a1" />} icon={<CalendarDays size={18} color="#0369a1" />}
label="Dieser Monat" label="Dieser Monat"
value={insights.dueThisMonth} value={ins.dueThisMonth}
color="#0369a1" color="#0369a1"
active={filterHorizon === 'THIS_MONTH'}
onClick={() => toggleHorizon('THIS_MONTH')}
/> />
<KpiItem <KpiCard
icon={<Eye size={18} color="#be185d" />} icon={<Eye size={18} color="#be185d" />}
label="Pre-Market Risiko" label="Pre-Market Risiko"
value={insights.schattenmarktReadyCount} value={ins.schattenmarktReadyCount}
color="#be185d" color="#be185d"
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
onClick={togglePreMarket}
/> />
</Box> </Box>
) )
+13 -2
View File
@@ -7,7 +7,14 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
import { ReminderDaysIndicator } from './ReminderDaysIndicator' import { ReminderDaysIndicator } from './ReminderDaysIndicator'
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
import { useReminderStore } from '../../stores/reminderStore' import { useReminderStore } from '../../stores/reminderStore'
import { ReminderStatus } from '../../domain/reminder' import { ReminderStatus, ReminderPriority } from '../../domain/reminder'
const PRIORITY_BORDER: Record<string, string> = {
[ReminderPriority.URGENT]: '#dc2626',
[ReminderPriority.HIGH]: '#ea580c',
[ReminderPriority.MEDIUM]: '#ca8a04',
[ReminderPriority.LOW]: 'transparent',
}
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = { const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
ACTIVE: 'info', ACTIVE: 'info',
@@ -56,6 +63,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
snooze.mutate({ id: reminder.id, until: '2026-05-27' }) snooze.mutate({ id: reminder.id, until: '2026-05-27' })
} }
const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent'
return ( return (
<Box <Box
onClick={handleRowClick} onClick={handleRowClick}
@@ -64,9 +73,11 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px', gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
alignItems: 'center', alignItems: 'center',
gap: 1, gap: 1,
px: 2, pl: 1.5,
pr: 2,
py: 1.25, py: 1.25,
borderBottom: '1px solid #f1f5f9', borderBottom: '1px solid #f1f5f9',
borderLeft: `3px solid ${borderColor}`,
bgcolor: 'white', bgcolor: 'white',
cursor: 'pointer', cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' }, '&:hover': { bgcolor: '#f8fafc' },
+13
View File
@@ -1,5 +1,15 @@
import type { SignalType, RiskLevel, ReviewStatus } from './enums' import type { SignalType, RiskLevel, ReviewStatus } from './enums'
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
export interface ExtractedContact {
type: ContactType
value: string
confidence: ContactConfidence
label?: string
}
export interface SignalSource { export interface SignalSource {
type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT' type: 'PRESS' | 'CONSTRUCTION_PERMIT' | 'JOB_POSTING' | 'COMPANY_REPORT' | 'MARKET_DATA' | 'MANUAL' | 'LEASE_CONTRACT'
url?: string url?: string
@@ -58,4 +68,7 @@ export interface FutureSignal {
// Unit-level reference: if set, this signal is about a specific rentable unit // Unit-level reference: if set, this signal is about a specific rentable unit
unitId?: string unitId?: string
// KI-extracted contact information from web crawler sources
extractedContacts?: ExtractedContact[]
} }
+24
View File
@@ -11,8 +11,32 @@ export interface PropertyMarketSignal {
confidence?: number confidence?: number
} }
// Key market performance indicators for a given radius.
// Base values are stored at 5 km; other radii are computed via getKpisForRadius().
export interface MarketKpis {
vacancyRatePct: number // % of comparable properties currently vacant
activePlatformSearches: number // active search requests on the platform
avgVacancyMonths: number // average months comparable properties remain vacant
demandSupplyRatio: number // demand / supply ratio (> 1 = more demand than supply)
comparableListings: number // number of comparable active listings
}
/** Scale base KPIs (at 5 km) for a larger radius. */
export function getKpisForRadius(base: MarketKpis, radiusKm: number): MarketKpis {
const f = radiusKm / 5
return {
vacancyRatePct: +Math.min(35, base.vacancyRatePct + (f - 1) * 1.8).toFixed(1),
activePlatformSearches: Math.round(base.activePlatformSearches * f * 0.85),
avgVacancyMonths: +Math.min(24, base.avgVacancyMonths * (1 + (f - 1) * 0.12)).toFixed(1),
demandSupplyRatio: +Math.max(0.3, base.demandSupplyRatio / (1 + (f - 1) * 0.18)).toFixed(1),
comparableListings: Math.round(base.comparableListings * f * 1.1),
}
}
export interface MarketReport { export interface MarketReport {
propertyId: string propertyId: string
generatedAt: string generatedAt: string
assetTypeLabel: string // e.g. "Büro"
kpis: MarketKpis // base values at 5 km radius
signals: PropertyMarketSignal[] signals: PropertyMarketSignal[]
} }
+27
View File
@@ -0,0 +1,27 @@
import type { AssetType } from './enums'
export type HinweisVisibility = 'INTERN' | 'PLATTFORM'
export type HinweisDirection = 'SUCHE' | 'VERFUEGBARKEIT'
export type HinweisStatus = 'OFFEN' | 'IN_BEARBEITUNG' | 'ARCHIVIERT'
export type HinweisQuelle = 'NETZWERKEVENT' | 'TELEFONAT' | 'BESICHTIGUNG' | 'MESSE' | 'EMAIL' | 'SONSTIGES'
export interface MarktHinweis {
id: string
createdAt: string
createdBy: string
verwaltungId: string
verwaltungName: string
direction: HinweisDirection
assetType: AssetType
locationHint: string
areaSqmMin?: number
areaSqmMax?: number
companyName?: string
isAnonymized: boolean
quelle: HinweisQuelle
note?: string
visibility: HinweisVisibility
status: HinweisStatus
}
export type CreateMarktHinweisInput = Omit<MarktHinweis, 'id' | 'createdAt' | 'createdBy' | 'verwaltungId' | 'verwaltungName'>
+4 -1
View File
@@ -3,6 +3,7 @@ import type {
FreshnessStatus, RiskLevel, SourceType, DataQualityLevel, FreshnessStatus, RiskLevel, SourceType, DataQualityLevel,
} from './enums' } from './enums'
import { AvailabilityStatus as AS } from './enums' import { AvailabilityStatus as AS } from './enums'
import type { PropertyUnit } from './unit'
// Re-export unit types so all existing imports from 'property' continue to work // Re-export unit types so all existing imports from 'property' continue to work
export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit' export type { PropertyUnit, UnitBundle, UnitNeedMatch } from './unit'
@@ -135,6 +136,8 @@ export interface Property {
description?: string description?: string
images?: string[] images?: string[]
floorPlanUrl?: string floorPlanUrl?: string
leaseContractUrl?: string // link to the signed lease document
leaseContractName?: string // display label, e.g. "Mietvertrag 20212026"
propertyNumber?: string propertyNumber?: string
units?: PropertyUnit[] units?: PropertyUnit[]
@@ -150,7 +153,7 @@ export interface Property {
importedAt?: string importedAt?: string
lastUpdatedAt?: string lastUpdatedAt?: string
schattenmarktRelease?: { enabled: boolean; leadTimeMonths: number } schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED' status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
lastReviewedAt?: string lastReviewedAt?: string
+3
View File
@@ -29,6 +29,9 @@ export interface FutureAvailabilityResult extends UnifiedResultBase {
unit?: PropertyUnit // specific rentable unit (when signal has a unitId) unit?: PropertyUnit // specific rentable unit (when signal has a unitId)
} }
/** @deprecated Alias kept for backward compatibility — use VerifiedPortfolioResult */
export type ExternalMarketResult = VerifiedPortfolioResult
export type UnifiedMatchResult = export type UnifiedMatchResult =
| VerifiedPortfolioResult | VerifiedPortfolioResult
| FutureAvailabilityResult | FutureAvailabilityResult
@@ -44,9 +44,9 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
it('scoreBreakdown reflects hard and soft components from the engine', () => { it('scoreBreakdown reflects hard and soft components from the engine', () => {
const vm = buildMatchCardViewModel(result, []) const vm = buildMatchCardViewModel(result, [])
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore) expect(vm.scoreBreakdown!.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore) expect(vm.scoreBreakdown!.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore) expect(vm.scoreBreakdown!.totalScore).toBe(match.scoreBreakdown.totalScore)
}) })
it('does NOT set a disclaimer for verified portfolio results', () => { it('does NOT set a disclaimer for verified portfolio results', () => {
@@ -65,7 +65,7 @@ describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
}) })
it('passes through supplied actions unchanged', () => { it('passes through supplied actions unchanged', () => {
const actions = [{ label: 'Shortlist', actionType: 'SHORTLIST' as const, primary: true }] const actions = [{ id: 'shortlist', label: 'Shortlist', actionType: 'SAVE_SHORTLIST' as const, primary: true, onClick: () => {} }]
const vm = buildMatchCardViewModel(result, actions) const vm = buildMatchCardViewModel(result, actions)
expect(vm.actions).toBe(actions) expect(vm.actions).toBe(actions)
}) })
+3
View File
@@ -61,6 +61,9 @@ describe('useCreateNeed', () => {
preferredLocations: ['Zürich'], preferredLocations: ['Zürich'],
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' }, budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' },
organizationId: 'org-test', organizationId: 'org-test',
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
confidenceInCriteria: 0.8,
}) })
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.data.companyName).toBe('Hook Test GmbH') expect(result.current.data?.data.companyName).toBe('Hook Test GmbH')
@@ -71,9 +71,14 @@ describe('useCreateProperty', () => {
assetType: 'LOGISTICS', assetType: 'LOGISTICS',
resultType: 'VERIFIED_PORTFOLIO', resultType: 'VERIFIED_PORTFOLIO',
location: { city: 'Bern', country: 'CH' }, location: { city: 'Bern', country: 'CH' },
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '3000', city: 'Bern', country: 'CH' },
areaSqm: 300, areaSqm: 300,
rentPricePerSqm: 100, rentPricePerSqm: 100,
availabilityDate: '2025-01-01',
availabilityStatus: 'AVAILABLE_NOW', availabilityStatus: 'AVAILABLE_NOW',
sourceType: 'DIRECT',
confidenceScore: 0.8,
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
organizationId: 'org-test', organizationId: 'org-test',
}) })
await waitFor(() => expect(result.current.isSuccess).toBe(true)) await waitFor(() => expect(result.current.isSuccess).toBe(true))
+21
View File
@@ -0,0 +1,21 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { marktHinweisService } from '../services/marktHinweisService'
import type { CreateMarktHinweisInput } from '../domain/marktHinweis'
export function useMarktHinweise() {
return useQuery({
queryKey: ['marktHinweise'],
queryFn: () => marktHinweisService.getAll(),
staleTime: 60_000,
})
}
export function useCreateMarktHinweis() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateMarktHinweisInput) => marktHinweisService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['marktHinweise'] })
},
})
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { useMatchDetail } from './useMatches'
import { propertyService } from '../services/propertyService' import { propertyService } from '../services/propertyService'
import { needService } from '../services/needService' import { needService } from '../services/needService'
import { futureSignalService } from '../services/futureSignalService' import { futureSignalService } from '../services/futureSignalService'
import type { ItemResponse } from '../services/types'
import type { Property } from '../domain/property' import type { Property } from '../domain/property'
import type { Need } from '../domain/need' import type { Need } from '../domain/need'
import type { FutureSignal } from '../domain/futureSignal' import type { FutureSignal } from '../domain/futureSignal'
@@ -13,14 +14,14 @@ export function useMatchDetailData(matchId: string) {
const { data: match, isLoading } = useMatchDetail(matchId) const { data: match, isLoading } = useMatchDetail(matchId)
const isFuture = match?.resultType === 'FUTURE_AVAILABILITY' const isFuture = match?.resultType === 'FUTURE_AVAILABILITY'
const { data: property = null } = useQuery<Property | null>({ const { data: property = null } = useQuery<ItemResponse<Property | null>, Error, Property | null>({
queryKey: ['property', match?.propertyId], queryKey: ['property', match?.propertyId],
queryFn: () => propertyService.getById(match!.propertyId), queryFn: () => propertyService.getById(match!.propertyId),
enabled: !!match && !isFuture, enabled: !!match && !isFuture,
select: r => r.data ?? null, select: r => r.data ?? null,
}) })
const { data: need = null } = useQuery<Need | null>({ const { data: need = null } = useQuery<ItemResponse<Need | null>, Error, Need | null>({
queryKey: ['need', match?.needId], queryKey: ['need', match?.needId],
queryFn: () => needService.getById(match!.needId), queryFn: () => needService.getById(match!.needId),
enabled: !!match?.needId, enabled: !!match?.needId,
+1
View File
@@ -13,6 +13,7 @@ interface PropertyFilter {
minAreaSqm?: number minAreaSqm?: number
maxRentPerSqm?: number maxRentPerSqm?: number
organizationId?: string organizationId?: string
sourceType?: string
} }
export function useProperties(filter?: PropertyFilter) { export function useProperties(filter?: PropertyFilter) {
+2 -2
View File
@@ -22,13 +22,13 @@ export function useSchattenmarktSignals(properties: Property[]): FutureSignal[]
for (const unit of releasedUnits) { for (const unit of releasedUnits) {
const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate const availableFrom = unit.schattenmarktRelease?.availableFrom ?? unit.leaseEndDate ?? p.leaseEndDate
if (!availableFrom) continue if (!availableFrom) continue
const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths) const triggerDate = getTriggerDate(availableFrom, rel.leadTimeMonths ?? 0)
if (MOCK_TODAY < triggerDate) continue if (MOCK_TODAY < triggerDate) continue
signals.push(buildUnitSignal(p, unit, availableFrom)) signals.push(buildUnitSignal(p, unit, availableFrom))
} }
} else { } else {
// Backward compat: property-level signal (no explicit unit releases defined) // Backward compat: property-level signal (no explicit unit releases defined)
const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths) const triggerDate = getEarliestTriggerDate(p, rel.leadTimeMonths ?? 0)
if (!triggerDate || MOCK_TODAY < triggerDate) continue if (!triggerDate || MOCK_TODAY < triggerDate) continue
signals.push(buildPropertySignal(p)) signals.push(buildPropertySignal(p))
} }
+1 -2
View File
@@ -1,12 +1,11 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useMatches, useMatchesByNeed } from './useMatches' import { useMatchesByNeed } from './useMatches'
import { useProperties } from './useProperties' import { useProperties } from './useProperties'
import { useFutureSignals } from './useFutureSignals' import { useFutureSignals } from './useFutureSignals'
import { useSchattenmarktSignals } from './useSchattenmarktSignals' import { useSchattenmarktSignals } from './useSchattenmarktSignals'
import type { import type {
UnifiedMatchResult, UnifiedMatchResult,
VerifiedPortfolioResult, VerifiedPortfolioResult,
FutureAvailabilityResult,
} from '../domain/unifiedResult' } from '../domain/unifiedResult'
export function useUnifiedResults(needId?: string) { export function useUnifiedResults(needId?: string) {
+11
View File
@@ -0,0 +1,11 @@
/** Maps a Need id (need-XXX) to its corresponding LatentNeed id (lneed-XXX). */
export const NEED_TO_LATENT_NEED: Record<string, string> = {
'need-001': 'lneed-007',
'need-002': 'lneed-008',
'need-004': 'lneed-009',
'need-005': 'lneed-010',
'need-012': 'lneed-011',
'need-013': 'lneed-012',
'need-014': 'lneed-013',
'need-015': 'lneed-014',
}
+4
View File
@@ -67,6 +67,10 @@ const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
UserRole.PROPERTY_MANAGER, UserRole.PROPERTY_MANAGER,
UserRole.DEMAND_USER, UserRole.DEMAND_USER,
], ],
[WorkspaceType.OPERATIONS]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
],
} }
// ── Core Functions ──────────────────────────────────────────────────────────── // ── Core Functions ────────────────────────────────────────────────────────────
+167 -119
View File
@@ -422,48 +422,58 @@ export const mockFutureSignals: FutureSignal[] = [
unconfirmedFacts: ['Welche Lagerstandorte konkret betroffen', 'Zeitpunkt eines möglichen Auszugs', 'Ob Fläche ganz oder teilweise aufgegeben'], unconfirmedFacts: ['Welche Lagerstandorte konkret betroffen', 'Zeitpunkt eines möglichen Auszugs', 'Ob Fläche ganz oder teilweise aufgegeben'],
}, },
// ── DEMAND signals — company is looking for space → Markt-Leads (Verwaltung) ─ // ── DEMAND signals — company is looking for space → KI-Signale (Verwaltung) ─
// signal-001: DataCloud Systems AG — Büroexpansion Zürich-West // signal-001: Alpbach Advisors AG — Neuer Stadtstandort Zürich
// Signal type: Strategic announcement — firm opening Zürich branch for client proximity.
// NOT a hiring surge. Trigger is a press release + LinkedIn location updates of key staff.
{ {
id: 'signal-001', id: 'signal-001',
signalType: SignalType.EXPANSION, signalType: SignalType.EXPANSION,
signalDirection: 'DEMAND', signalDirection: 'DEMAND',
companyName: 'DataCloud Systems AG', companyName: 'Alpbach Advisors AG',
locationHint: 'Zürich-West / Technopark', locationHint: 'Zürich, Innenstadt / Paradeplatz',
title: 'DataCloud Systems AG sucht ~600 m² Bürofläche in Zürich-West', title: 'Alpbach Advisors AG eröffnet Zürich-Büro — Standort gesucht',
areaSqmEstimate: 600, areaSqmEstimate: 320,
probability: 0.72, probability: 0.78,
confidenceScore: 0.68, confidenceScore: 0.74,
timeHorizonMonths: 10, timeHorizonMonths: 8,
source: { source: {
type: 'JOB_POSTING', type: 'PRESS',
url: 'https://example.com/jobs/datacloud', url: 'https://example.com/news/alpbach-zuerich-expansion',
publishedAt: '2025-04-20', publishedAt: '2025-04-15',
credibility: 'MEDIUM', credibility: 'HIGH',
}, },
sensitivityLevel: 'INTERNAL', sensitivityLevel: 'INTERNAL',
disclaimer: 'Erkannte Flächennachfrage aus öffentlichen Quellen. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.', disclaimer: 'Erkannte Flächennachfrage aus Medienmitteilung und LinkedIn-Daten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.LOW,
marketIndicator: 'Tech-Sektor Stellenwachstum +38% YoY', marketIndicator: 'Wealth-Management Zürich: Neuansiedlungen +14% 2024',
relevanceScore: 0.76, relevanceScore: 0.80,
isVerified: false, isVerified: false,
expiresAt: '2026-03-01', expiresAt: '2026-02-01',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-05-01T07:00:00Z', createdAt: '2025-04-18T08:00:00Z',
updatedAt: '2025-05-10T07:00:00Z', updatedAt: '2025-05-12T09:00:00Z',
aiSummary: 'KI hat in 90 Tagen 14 Stelleninserate von DataCloud Systems AG mit explizitem Standort Zürich-West/Technopark ausgewertet. Das Wachstumsmuster (DevOps, Cloud-Architektur, Sales) deutet auf eine Teamvergrösserung von ~40 Personen hin — entspricht einem Flächenbedarf von ca. 600 m². Potenzieller Mieter für Büroflächen in Zürich-West.', aiSummary: 'Alpbach Advisors AG (Luzern, 38 Mitarbeitende, Vermögensverwaltung) hat am 15. April eine Medienmitteilung veröffentlicht: «Alpbach eröffnet Zürich-Büro zur Stärkung der Kundennähe am Finanzplatz». Drei Senior-Partner haben ihren LinkedIn-Standort zeitgleich auf Zürich aktualisiert. Das ist ein strategischer Stadtstandort-Entscheid — kein Wachstum, sondern Nähe zu institutionellen Kunden am Paradeplatz. Typischer Flächenbedarf für ein Repräsentationsbüro dieser Grösse: 280360 m², Lage Innenstadt oder Seefeld zwingend.',
marketIndicators: [ marketIndicators: [
'14 Stelleninserate mit Standort Zürich-West in 90 Tagen', 'Medienmitteilung vom 15. April: Zürich-Büro offiziell angekündigt',
'Teamwachstum ~40 Personen (DevOps, Cloud-Architektur, Sales)', '3 Senior-Partner haben LinkedIn-Standort auf Zürich aktualisiert',
'Tech-Sektor Zürich-West: +38% Stellenwachstum 2024', 'Wealth-Management Zürich Innenstadt: Nachfrage nach Repräsentationsflächen stabil hoch',
],
strategicInterpretation: 'Öffentlich bestätigte Standorteröffnung — Flächensuche muss bereits aktiv sein (Ankündigung vor Einzug). Repräsentationsbüro verlangt Innenstadt/Paradeplatz-Nähe. Hohe Abschlusswahrscheinlichkeit da Entscheid bereits gefallen.',
confirmedFacts: ['Medienmitteilung öffentlich', 'Zürich-Standort strategisch bestätigt', 'Partner-Team bereits in Zürich aktiv'],
unconfirmedFacts: ['Exakte Lage (Paradeplatz vs. Seefeld)', 'Ausbaustandard (Repräsentation vs. Standard)', 'Einzugstermin'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://alpbach-advisors.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Dr. Markus Suter (Managing Partner)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
{ type: 'EMAIL', value: 'zuerich@alpbach-advisors.ch', confidence: 'MEDIUM', label: 'Neue Zürich-Adresse (geschätzt)' },
{ type: 'LINKEDIN', value: 'linkedin.com/company/alpbach-advisors', confidence: 'HIGH', label: 'LinkedIn Firmenprofil' },
], ],
strategicInterpretation: 'DataCloud Systems AG befindet sich in einer klaren Wachstumsphase mit Fokus auf Zürich-West. Unternehmen mit diesem Wachstumsmuster sucht typischerweise 500700 m² Bürofläche mit Ausbauoptionen.',
confirmedFacts: ['Region Zürich-West aus Inseraten bestätigt', 'Flächentyp Büro plausibel', 'Wachstumstrend seit 6 Monaten vorhanden'],
unconfirmedFacts: ['Exakter Flächenbedarf', 'Ob interner Ausbau am aktuellen Standort geplant', 'Zeitplan für die Entscheidung'],
}, },
// signal-004: Novabio Pharma AG — Laborexpansion Basel Allschwil // signal-004: Novabio Pharma AG — Laborerweiterung Basel Allschwil
// Signal type: Business report — explicitly states capacity expansion in Life-Sciences corridor.
// Not hiring-driven. Trigger is published strategic plan + lab manager job profiles.
{ {
id: 'signal-004', id: 'signal-004',
signalType: SignalType.EXPANSION, signalType: SignalType.EXPANSION,
@@ -491,154 +501,186 @@ export const mockFutureSignals: FutureSignal[] = [
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-04-02T09:00:00Z', createdAt: '2025-04-02T09:00:00Z',
updatedAt: '2025-05-08T10:00:00Z', updatedAt: '2025-05-08T10:00:00Z',
aiSummary: 'Novabio Pharma AG kommuniziert im Geschäftsbericht 2024 eine "strategische Kapazitätserweiterung im Forschungsbereich Basel-Allschwil bis 2026". 6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil identifiziert. Potenzieller Mieter für Labor-/Büroflächen im Life-Sciences-Korridor.', aiSummary: 'Novabio Pharma AG kommuniziert im Geschäftsbericht 2024 explizit eine «strategische Kapazitätserweiterung im Forschungsbereich Basel-Allschwil bis 2026». Parallel wurden 6 Inserate für Senior Scientists und Lab-Manager mit Standortangabe Allschwil identifiziert. Das Signal ist qualitativ stark: nicht Headcount-getrieben, sondern strategisch kommuniziert. Typischer Laborbedarf für diese Teamgrösse: 400500 m² Labor + 200250 m² Büro.',
marketIndicators: [ marketIndicators: [
'Geschäftsbericht 2024: «Kapazitätserweiterung Basel-Allschwil bis 2026» explizit erwähnt', 'Geschäftsbericht 2024: «Kapazitätserweiterung Basel-Allschwil bis 2026» explizit erwähnt',
'6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil in 60 Tagen', '6 Inserate für Senior Scientists und Lab-Manager mit Standort Allschwil in 60 Tagen',
'Life-Sciences-Korridor Basel-Allschwil: höchste CH-weite Laborflächen-Nachfrage 2024', 'Life-Sciences-Korridor Basel-Allschwil: höchste CH-weite Laborflächen-Nachfrage 2024',
], ],
strategicInterpretation: 'Novabio Pharma AG kommuniziert Expansion öffentlich — Flächensuche sehr wahrscheinlich aktiv. Fokus auf Laborpersonal deutet auf kombinierten Lab-/Bürobedarf von 600800 m² hin.', strategicInterpretation: 'Expansion öffentlich kommuniziert — Flächensuche sehr wahrscheinlich bereits aktiv. Kombination aus publizierter Strategie und Laborpersonal-Inseraten ergibt starkes Signal für Lab-/Bürobedarf von 600800 m².',
confirmedFacts: ['Expansionspläne im Geschäftsbericht bestätigt', 'Region Basel-Allschwil als Zielstandort genannt', 'Einstellungstrend seit Q1 2025 sichtbar'], confirmedFacts: ['Expansionspläne im Geschäftsbericht bestätigt', 'Region Basel-Allschwil als Zielstandort genannt', 'Einstellungstrend seit Q1 2025 sichtbar'],
unconfirmedFacts: ['Ob Neubau oder Bestandsfläche gesucht', 'Exakter Flächenbedarf Labor vs. Büro', 'Zeitpunkt des Einzugs'], unconfirmedFacts: ['Ob Neubau oder Bestandsfläche gesucht', 'Exakter Flächenbedarf Labor vs. Büro', 'Zeitpunkt des Einzugs'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://novabio.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Dr. Anna Hoffmann (CFO)', confidence: 'HIGH', label: 'Aus Geschäftsbericht 2024' },
{ type: 'EMAIL', value: 'expansion@novabio.ch', confidence: 'LOW', label: 'Spekulativ — nicht bestätigt' },
{ type: 'PHONE', value: '+41 61 200 34 56', confidence: 'MEDIUM', label: 'Hauptnummer laut Website' },
],
}, },
// signal-007: SwissCart E-Commerce GmbH — Logistikexpansion Zug // signal-007: EBH Treuhand AG — Fusion zweier Winterthurer Büros
// Signal type: Merger → consolidation → need for larger single space.
// NOT hiring. Trigger is Handelsregister entry showing new merged entity.
{ {
id: 'signal-007', id: 'signal-007',
signalType: SignalType.EXPANSION, signalType: SignalType.EXPANSION,
signalDirection: 'DEMAND', signalDirection: 'DEMAND',
companyName: 'SwissCart E-Commerce GmbH', companyName: 'EBH Treuhand AG',
locationHint: 'Zug, Industriestrasse', locationHint: 'Winterthur, Zentrum',
title: 'SwissCart E-Commerce GmbH sucht ~580 m² Logistikfläche in Zug', title: 'EBH Treuhand AG — Fusion zweier Büros, konsolidierter Standort gesucht',
areaSqmEstimate: 580, areaSqmEstimate: 450,
probability: 0.66, probability: 0.71,
confidenceScore: 0.62, confidenceScore: 0.67,
timeHorizonMonths: 9, timeHorizonMonths: 7,
source: { source: {
type: 'JOB_POSTING', type: 'MARKET_DATA',
url: 'https://example.com/jobs/swisscart-zug', url: 'https://example.com/handelsregister/ebh-fusion',
publishedAt: '2025-04-18', publishedAt: '2025-04-02',
credibility: 'MEDIUM', credibility: 'HIGH',
}, },
sensitivityLevel: 'INTERNAL', sensitivityLevel: 'INTERNAL',
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.', disclaimer: 'Signal aus Handelsregistereintrag. Fusion bestätigt — Flächenbedarf abgeleitet. Kein bestätigtes Mietverhältnis.',
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.LOW,
marketIndicator: 'E-Commerce Zug: Stellenwachstum +55% YoY', marketIndicator: 'Treuhand-Fusionen Zürich/Winterthur 20242025: +18% gegenüber Vorjahr',
relevanceScore: 0.69, relevanceScore: 0.74,
isVerified: false, isVerified: false,
expiresAt: '2026-02-01', expiresAt: '2026-01-01',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-04-20T07:00:00Z', createdAt: '2025-04-05T08:00:00Z',
updatedAt: '2025-05-09T08:00:00Z', updatedAt: '2025-05-10T09:00:00Z',
aiSummary: 'In 45 Tagen wurden 9 Stelleninserate für Lager- und Logistikpersonal von SwissCart E-Commerce GmbH in Zug identifiziert. E-Commerce-Umsatz CH 2024 +22% — Nachfrage nach Lagerflächen steigt. Potenzieller Mieter für Logistikflächen in der Region Zug.', aiSummary: 'Das Handelsregister Kanton Zürich zeigt am 2. April 2025 die Fusion von «Eichenberger Treuhand GmbH» (200 m², Stadthausstrasse) und «Bretscher & Huber Treuhand AG» (220 m², Tösstalstrasse) zur neuen «EBH Treuhand AG». Beide Einzelfirmen haben separate Mietverträge, die KI auf 2019 bzw. 2021 datiert — typische 6-Jahres-Laufzeiten enden 2025 bzw. 2027. Die fusionierte Entität (ca. 28 Mitarbeitende) benötigt einen gemeinsamen neuen Standort von ~400500 m². Die Entscheidung dürfte zeitnah fallen.',
marketIndicators: [ marketIndicators: [
'9 Stelleninserate für Lager- und Logistikpersonal in Zug in 45 Tagen', 'Handelsregistereintrag: Fusion zu EBH Treuhand AG am 2. April 2025 bestätigt',
'E-Commerce-Umsatz CH 2024: +22% — Nachfrage nach Lagerflächen steigt', 'Kombinierte Teamgrösse ca. 28 Personen — bisherige Flächen je ~200 m² nicht mehr ausreichend',
'Zug Industriestrasse: aktuelle Leerstandsquote Lager unter 3%', 'Treuhand-Fusionen Winterthur 2024: +18% — Konsolidierungsdruck durch Digitalisierung',
],
strategicInterpretation: 'Fusion ist der stärkste mögliche Indikator für Flächenbedarf — eine neue juristische Einheit muss zwingend einen gemeinsamen Standort finden. Zeitdruck durch auslaufende Altmietverträge. Winterthur Zentrum bevorzugt für Kundenfrequenz.',
confirmedFacts: ['Fusion im Handelsregister bestätigt', 'Kombinierte Teamgrösse ca. 28 Personen bekannt', 'Beide Altstandorte bekannt'],
unconfirmedFacts: ['Ob einer der Altstandorte weitergeführt wird', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://ebh-treuhand.ch', confidence: 'HIGH', label: 'Neue Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Thomas Eichenberger (Geschäftsführer)', confidence: 'HIGH', label: 'Aus Handelsregistereintrag' },
{ type: 'EMAIL', value: 'info@ebh-treuhand.ch', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
{ type: 'PHONE', value: '+41 52 212 44 00', confidence: 'MEDIUM', label: 'Bisherige Eichenberger-Nummer' },
], ],
strategicInterpretation: 'Massiver Personalaufbau im Logistikbereich deutet auf konkreten Flächenbedarf hin. Zug als Steuerkanton bleibt attraktiver Firmensitz — kein Standortwechsel erwartet, nur Erweiterung.',
confirmedFacts: ['Region Zug aus Inseraten bestätigt', 'Flächentyp Logistik/Lager aus Stellenprofilen ableitbar', 'Wachstumstrend seit Q3 2024 sichtbar'],
unconfirmedFacts: ['Ob zusätzliche Bürofläche benötigt', 'Exakter Flächenbedarf', 'Bevorzugte Strassenlage (Anlieferung)'],
}, },
// signal-010: Ostschweiz Digital AG — Büroexpansion St. Gallen // signal-010: Centra Schweiz AG — Standortverlagerung Schlieren → Zürich Innenstadt
// Signal type: Rightsizing / relocation — company moving from suburban to central location
// for talent attraction. NOT about more space — about better location.
{ {
id: 'signal-010', id: 'signal-010',
signalType: SignalType.EXPANSION, signalType: SignalType.EXPANSION,
signalDirection: 'DEMAND', signalDirection: 'DEMAND',
companyName: 'Ostschweiz Digital AG', companyName: 'Centra Schweiz AG',
locationHint: 'St. Gallen, Riethüsli', locationHint: 'Zürich, Kreis 4 / 5',
title: 'Ostschweiz Digital AG sucht ~480 m² Bürofläche in St. Gallen', title: 'Centra Schweiz AG verlagert Standort von Schlieren in Zürich Innenstadt',
areaSqmEstimate: 480, areaSqmEstimate: 380,
probability: 0.60, probability: 0.66,
confidenceScore: 0.55, confidenceScore: 0.62,
timeHorizonMonths: 10, timeHorizonMonths: 11,
source: { source: {
type: 'JOB_POSTING', type: 'PRESS',
url: 'https://example.com/jobs/ostschweiz-digital', url: 'https://example.com/news/centra-standortverlagerung',
publishedAt: '2025-04-22', publishedAt: '2025-04-10',
credibility: 'MEDIUM', credibility: 'MEDIUM',
}, },
sensitivityLevel: 'INTERNAL', sensitivityLevel: 'INTERNAL',
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.', disclaimer: 'Signal aus Pressemitteilung und LinkedIn-Jobinseraten. Verlagerungsentscheid kommuniziert — Flächensuche wahrscheinlich aktiv.',
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024', marketIndicator: 'Zürich Kreis 5: Neuzuzüge Büronutzer +21% 2024',
relevanceScore: 0.62, relevanceScore: 0.67,
isVerified: false, isVerified: false,
expiresAt: '2026-03-01', expiresAt: '2026-04-01',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-04-25T08:00:00Z', createdAt: '2025-04-12T08:00:00Z',
updatedAt: '2025-05-10T07:00:00Z', updatedAt: '2025-05-10T07:00:00Z',
aiSummary: '8 Stelleninserate für Software-Entwickler und UX-Designer in St. Gallen in 60 Tagen. Mitarbeiterzahl von 22 auf 31 (+41%) in 9 Monaten. Kontinuierliches Wachstum deutet auf Bedarf nach grösserer Bürofläche hin — potenzieller Mieter für Büroflächen in St. Gallen.', aiSummary: 'Centra Schweiz AG (IT-Beratung, aktuell Schlieren, ~35 Mitarbeitende) hat in einer Medienmitteilung «eine Standortoptimierung zur Verbesserung der Mitarbeitergewinnung» angekündigt. Konkret: Verlagerung von Schlieren nach Zürich Innenstadt. Die Fläche wird nicht grösser — sie wird urbaner. KI identifizierte 4 aktuelle Stelleninserate, die explizit «Zürich» als Arbeitsort nennen, obwohl die Firma noch in Schlieren domiziliert ist. Das deutet auf einen laufenden Standortwechsel hin.',
marketIndicators: [ marketIndicators: [
'8 Stelleninserate für Software-Entwickler und UX-Designer in St. Gallen in 60 Tagen', 'Medienmitteilung: Verlagerung Schlieren → Zürich Innenstadt offiziell angekündigt',
'Ostschweiz Digital AG: Mitarbeiterzahl von 22 auf 31 (+41%) in 9 Monaten', '4 Stelleninserate nennen «Zürich» als Arbeitsort trotz aktuellem Schlieren-Domizil',
'Digitalwirtschaft Ostschweiz: +28% Beschäftigte 2024 (IHK-Bericht)', 'Zürich Kreis 4/5: Neuzuzüge von Tech- und Beratungsunternehmen +21% 2024',
],
strategicInterpretation: 'Standortverlagerung zur Talentgewinnung — kein Wachstum, sondern Lageoptimierung. Gesuchte Fläche ähnlich gross wie aktuell (~350400 m²), aber moderner Ausbaustandard und ÖV-Nähe erwartet. Entscheid gefallen, Flächensuche aktiv.',
confirmedFacts: ['Verlagerungsentscheid aus Medienmitteilung bestätigt', 'Zielgebiet Zürich Innenstadt kommuniziert', 'Aktuelle Fläche Schlieren ~380 m²'],
unconfirmedFacts: ['Exakter Zielbezirk (Kreis 4 vs. 5)', 'Ob Fläche gleichgross oder kleiner', 'Einzugstermin'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://centra-schweiz.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'CONTACT_PERSON', value: 'Nicole Baumann (CEO)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
{ type: 'EMAIL', value: 'n.baumann@centra-schweiz.ch', confidence: 'MEDIUM', label: 'Aus Stelleninserat-Signatur' },
{ type: 'LINKEDIN', value: 'linkedin.com/company/centra-schweiz', confidence: 'HIGH', label: 'LinkedIn Firmenprofil' },
], ],
strategicInterpretation: 'Kontinuierliches Teamwachstum mit Fokus auf St. Gallen deutet auf organische Expansion hin. Erweiterungsfläche am bisherigen Standort oder Umzug in grössere Einheit möglich.',
confirmedFacts: ['Region St. Gallen aus Inseraten bestätigt', 'Wachstumstrend seit Q3 2024 nachweisbar', 'Flächentyp Büro aus Stellenprofilen ableitbar'],
unconfirmedFacts: ['Ob aktueller Standort Riethüsli oder neue Lage', 'Exakter Flächenbedarf', 'Zeitpunkt der Entscheidung'],
}, },
// signal-013: Geneva Finance Partners SA — Büroexpansion Genf // signal-013: Rheintaler Spedition AG — Spin-off braucht eigenen Standort
// Signal type: Corporate spin-off — new legal entity split from parent, needs its own space.
// NOT organic growth. Trigger is Handelsregister + parent company press release.
{ {
id: 'signal-013', id: 'signal-013',
signalType: SignalType.EXPANSION, signalType: SignalType.EXPANSION,
signalDirection: 'DEMAND', signalDirection: 'DEMAND',
companyName: 'Geneva Finance Partners SA', companyName: 'Rheintaler Logistik Solutions AG',
locationHint: 'Genf, La Praille', locationHint: 'St. Gallen / Rheintal',
title: 'Geneva Finance Partners SA sucht ~520 m² Bürofläche in Genf La Praille', title: 'Rheintaler Logistik Solutions AG — Spin-off sucht eigenständigen Standort',
areaSqmEstimate: 520, areaSqmEstimate: 680,
probability: 0.58, probability: 0.69,
confidenceScore: 0.53, confidenceScore: 0.65,
timeHorizonMonths: 20, timeHorizonMonths: 9,
source: { source: {
type: 'COMPANY_REPORT', type: 'MARKET_DATA',
url: 'https://example.com/annual/gfp-2025', url: 'https://example.com/handelsregister/rheintaler-spinoff',
publishedAt: '2025-03-05', publishedAt: '2025-03-20',
credibility: 'MEDIUM', credibility: 'HIGH',
}, },
sensitivityLevel: 'INTERNAL', sensitivityLevel: 'INTERNAL',
disclaimer: 'Erkannte Flächennachfrage aus Jahresbericht. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.', disclaimer: 'Signal aus Handelsregistereintrag und Pressemitteilung der Muttergesellschaft. Neue juristische Einheit — eigenständiger Standort notwendig.',
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.LOW,
marketIndicator: 'Büroflächennachfrage Genf: +15% 2025', marketIndicator: 'Logistik Ostschweiz: Nachfrage nach kombinierten Büro-/Gewerbeflächen stabil',
relevanceScore: 0.60, relevanceScore: 0.72,
isVerified: false, isVerified: false,
expiresAt: '2027-01-01', expiresAt: '2026-02-01',
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-03-08T11:00:00Z', createdAt: '2025-03-25T09:00:00Z',
updatedAt: '2025-05-07T10:00:00Z', updatedAt: '2025-05-08T10:00:00Z',
aiSummary: 'Jahresbericht 2024: «Expansion des Genfer Teams auf 40 FTE bis 2026» angekündigt. 5 neue Inserate für Senior-Portfoliomanager mit Standort Genf in 30 Tagen. Potenzieller Mieter für Büroflächen in Genf La Praille.', aiSummary: 'Die Muttergesellschaft «Rheintaler Transport AG» hat am 20. März im Handelsregister die Ausgliederung des Bereichs Logistik-Software in eine eigenständige Tochter «Rheintaler Logistik Solutions AG» eingetragen. Medienmitteilung: «Die neue Einheit agiert ab Q3 2025 vollständig selbständig». Spin-offs dieser Art benötigen zwingend einen eigenen Standort — sie können nicht auf der Mutteradresse firmieren, wenn sie an Kunden pitchen. Typischer Bedarf: 400500 m² Büro + 150200 m² kleines Lager/Technikraum.',
marketIndicators: [ marketIndicators: [
'Jahresbericht 2024: «Expansion des Genfer Teams auf 40 FTE bis 2026» angekündigt', 'Handelsregistereintrag: neue AG «Rheintaler Logistik Solutions AG» per 20. März',
'Genf Finanzsektor: +15% Büroflächennachfrage 2025 (CBRE-Bericht)', 'Medienmitteilung: «Vollständige operative Selbständigkeit ab Q3 2025»',
'5 neue Inserate für Senior-Portfoliomanager mit Standort Genf in 30 Tagen', 'Logistik-Software Ostschweiz: Nachfrage nach Gewerbeflächen mit Büro wächst',
],
strategicInterpretation: 'Spin-off ist ein sehr starkes Flächensignal — neue juristische Einheit kann nicht auf der Mutteradresse verbleiben, wenn sie selbständig am Markt auftritt. Zeitdruck durch angekündigte Q3-Selbständigkeit. Kombination Büro + kleines Lager typisch für Logistik-Software-Firmen.',
confirmedFacts: ['Handelsregistereintrag neue AG bestätigt', 'Selbständigkeit ab Q3 2025 kommuniziert', 'Muttergesellschaft Standort Rheintal bekannt'],
unconfirmedFacts: ['Ob im Rheintal bleiben oder nach St. Gallen', 'Exakter Lagerbedarf', 'Budget pro m²'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://rheintaler-logistik-solutions.ch', confidence: 'MEDIUM', label: 'Neue Website (noch im Aufbau)' },
{ type: 'CONTACT_PERSON', value: 'Florian Zäch (CEO Spin-off)', confidence: 'HIGH', label: 'Aus Medienmitteilung' },
{ type: 'EMAIL', value: 'f.zaech@rheintaler-ls.ch', confidence: 'LOW', label: 'Spekulativ — neue Domain' },
{ type: 'PHONE', value: '+41 71 750 33 00', confidence: 'MEDIUM', label: 'Bisherige Mutternummer' },
], ],
strategicInterpretation: 'Öffentlich kommunizierte Expansion mit klarem Zielhorizont 2026. La Praille bietet kostengünstigere Büroflächen als Genf Innenstadt bei guter ÖV-Anbindung. Flächensuche dürfte aktiv beginnen.',
confirmedFacts: ['Expansion im Jahresbericht kommuniziert', 'Teamwachstum auf 40 FTE bis 2026 bestätigt', 'Inserate für Genf aktiv'],
unconfirmedFacts: ['Ob La Praille oder andere Lage bevorzugt', 'Exakter Flächenbedarf', 'Ob Repräsentationsbüros benötigt'],
}, },
// signal-015: BernTech Innovation AG — Büroexpansion Bern Breitenrain // signal-015: BernTech Innovation AG — VC-Finanzierung, Einzug neuer Standort Bern
// Signal type: Venture funding → company outgrows current space.
// Clear signal: public funding round announcement with explicit growth roadmap.
{ {
id: 'signal-015', id: 'signal-015',
signalType: SignalType.EXPANSION, signalType: SignalType.EXPANSION,
signalDirection: 'DEMAND', signalDirection: 'DEMAND',
companyName: 'BernTech Innovation AG', companyName: 'BernTech Innovation AG',
locationHint: 'Bern, Breitenrain', locationHint: 'Bern, Breitenrain / Lorraine',
title: 'BernTech Innovation AG sucht ~1\'800 m² Bürofläche in Bern Breitenrain', title: 'BernTech Innovation AG — CHF 12 Mio. Finanzierungsrunde, Standortsuche Bern',
areaSqmEstimate: 1800, areaSqmEstimate: 1800,
probability: 0.68, probability: 0.68,
confidenceScore: 0.64, confidenceScore: 0.64,
timeHorizonMonths: 16, timeHorizonMonths: 16,
source: { source: {
type: 'JOB_POSTING', type: 'PRESS',
url: 'https://example.com/jobs/berntech', url: 'https://example.com/news/berntech-finanzierung-2025',
publishedAt: '2025-04-25', publishedAt: '2025-03-18',
credibility: 'MEDIUM', credibility: 'HIGH',
}, },
sensitivityLevel: 'INTERNAL', sensitivityLevel: 'INTERNAL',
disclaimer: 'Erkannte Flächennachfrage aus Stelleninseraten und VC-Finanzierungsdaten. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.', disclaimer: 'Erkannte Flächennachfrage aus VC-Finanzierungsdaten und Pressebericht. Kein bestätigtes Mietverhältnis — potenzielle Mieteranbahnung für Verwaltung.',
riskLevel: RiskLevel.MEDIUM, riskLevel: RiskLevel.MEDIUM,
marketIndicator: 'Bern Tech-Ökosystem: Risikokapital +40% 2024', marketIndicator: 'Bern Tech-Ökosystem: Risikokapital +40% 2024',
relevanceScore: 0.66, relevanceScore: 0.66,
@@ -647,14 +689,20 @@ export const mockFutureSignals: FutureSignal[] = [
organizationId: 'org-wincasa', organizationId: 'org-wincasa',
createdAt: '2025-04-28T09:00:00Z', createdAt: '2025-04-28T09:00:00Z',
updatedAt: '2025-05-10T08:00:00Z', updatedAt: '2025-05-10T08:00:00Z',
aiSummary: '18 Stelleninserate in 60 Tagen (Fokus Software-Engineering und Product Management). VC-Finanzierungsrunde CHF 12 Mio. im März 2025 öffentlich bestätigt. Frisch finanziertes Wachstumsunternehmen mit Budget für Flächenexpansion — potenzieller Mieter für Büroflächen in Bern.', aiSummary: 'BernTech Innovation AG hat am 18. März eine VC-Finanzierungsrunde von CHF 12 Mio. öffentlich bestätigt (Medien: Startupticker, Handelszeitung). Im Pressebericht explizit: «Die Mittel fliessen in Produktentwicklung, Infrastruktur und ein neues Büro in Bern». Das ist kein abgeleitetes Signal — der CEO hat den Flächenbedarf selbst kommuniziert. Aktuelle Fläche: ~450 m² in Bern Lorraine. Zielgrösse für nächste Phase: 1\'5002\'000 m².',
marketIndicators: [ marketIndicators: [
'18 Stelleninserate in 60 Tagen — Fokus Software-Engineering und Product Management', 'VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt (Startupticker, Handelszeitung)',
'BernTech Innovation AG: VC-Finanzierungsrunde CHF 12 Mio. im März 2025 bestätigt', 'CEO-Zitat: «Neues Büro in Bern» explizit als Verwendungszweck kommuniziert',
'Bern Tech-Ökosystem: Risikokapital +40% 2024 — stärkstes Wachstum seit 2019', 'Bern Tech-Ökosystem: Risikokapital +40% 2024 — stärkstes Wachstum seit 2019',
], ],
strategicInterpretation: 'Frisch finanziertes Wachstumsunternehmen mit aktivem Stellenaufbau und gesichertem Budget. VC-Finanzierung als starkes Signal für konkreten Flächenbedarf. Breitenrain als Tech-Standort in Bern etabliert.', strategicInterpretation: 'CEO hat den Flächenbedarf selbst öffentlich kommuniziert — das ist das stärkste mögliche Signal. Budget vorhanden, Standort Bern bestätigt, Suche aktiv. Breitenrain und Lorraine als etablierte Tech-Quartiere in Bern bevorzugt.',
confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Region Bern aus Inseraten bestätigt', 'Wachstumstrend seit Q1 2025 sichtbar'], confirmedFacts: ['VC-Finanzierungsrunde CHF 12 Mio. öffentlich bestätigt', 'Neues Büro Bern vom CEO kommuniziert', 'Aktueller Standort Lorraine ~450 m²'],
unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung: 1\'5002\'000 m²)', 'Ob Breitenrain oder andere Berner Lage bevorzugt', 'Zeitpunkt Einzug'], unconfirmedFacts: ['Exakter Flächenbedarf (Schätzung 1\'5002\'000 m²)', 'Ob Breitenrain oder andere Berner Lage', 'Einzugstermin'],
extractedContacts: [
{ type: 'WEBSITE', value: 'https://berntech.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
{ type: 'EMAIL', value: 'hello@berntech.ch', confidence: 'MEDIUM', label: 'Allgemeiner Kontakt' },
{ type: 'CONTACT_PERSON', value: 'Stefan Leuenberger (CEO)', confidence: 'HIGH', label: 'LinkedIn + Pressebericht' },
{ type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' },
],
}, },
] ]
+220
View File
@@ -146,6 +146,226 @@ export const mockLatentNeeds: LatentNeed[] = [
createdAt: '2026-05-09T08:15:00Z', createdAt: '2026-05-09T08:15:00Z',
}, },
// ─── Suchabos linked to direct listing matches ────────────────────────────
{
id: 'lneed-007',
title: 'Modernes Büro in Zürich für 6001000m²',
tenantCompany: 'Innovatech AG',
assetType: AssetType.OFFICE,
desiredLocation: 'Zürich-West / Kreis 5',
sizeRange: { min: 600, max: 1000 },
budgetRange: { min: 32, max: 45 },
timing: 'Bezug ab Herbst 2025, flexibel',
mustHaveCriteria: [
'ÖV-Anbindung unter 6 Min',
'Mindestfläche 600m²',
'Moderner Ausbau',
],
weightedPreferences: [
{ criterion: 'Lage Zürich-West', weight: 0.35 },
{ criterion: 'Ausbaustandard', weight: 0.25, description: 'Modern, repräsentativ' },
{ criterion: 'Erweiterungsoption', weight: 0.20 },
{ criterion: 'ESG-Standard', weight: 0.10 },
{ criterion: 'Parkplätze', weight: 0.10 },
],
aiSummary: 'Technologieunternehmen mit solidem Wachstum sucht Bürofläche in Zürich-West. Hohe Abschlussbereitschaft bei passendem Objekt.',
publicVisibility: true,
status: 'public',
createdAt: '2026-04-15T10:00:00Z',
},
{
id: 'lneed-008',
title: 'Logistikfläche Basel-Region 15004000m²',
tenantCompany: 'Schweizer Logistik GmbH',
assetType: AssetType.LOGISTICS,
desiredLocation: 'Basel / Muttenz / Pratteln / Reinach BL',
sizeRange: { min: 1500, max: 4000 },
budgetRange: { min: 12, max: 18 },
timing: 'Bezug ab September 2025',
mustHaveCriteria: [
'Autobahnanschluss unter 5 Min',
'Rampe / Andienung vorhanden',
'Hallenhöhe min 6m',
],
weightedPreferences: [
{ criterion: 'Rampenanzahl', weight: 0.30 },
{ criterion: 'Lkw-Wendekreis', weight: 0.25 },
{ criterion: 'Erweiterungspotenzial', weight: 0.20 },
{ criterion: '24/7-Betrieb', weight: 0.15 },
{ criterion: 'Sprinkleranlage', weight: 0.10 },
],
aiSummary: 'Etablierter Logistiker mit klaren Anforderungen. Region Basel priorisiert. Sehr gute Matchbasis mit verfügbaren Hallenflächen.',
publicVisibility: true,
status: 'public',
createdAt: '2026-03-20T14:00:00Z',
},
{
id: 'lneed-009',
title: 'Premium-Retailfläche Zürich Innenstadt',
tenantCompany: 'Retailer Zürich AG',
assetType: AssetType.RETAIL,
desiredLocation: 'Zürich Innenstadt / Bahnhofstrasse / Niederdorf',
sizeRange: { min: 200, max: 500 },
budgetRange: { min: 60, max: 100 },
timing: 'Bezug ab September 2025, hohe Dringlichkeit',
mustHaveCriteria: [
'Laufkundschaft und Passantenfrequenz',
'Schaufensterfront',
'Erdgeschoss',
],
weightedPreferences: [
{ criterion: 'Fussgängerfrequenz', weight: 0.35 },
{ criterion: 'Lagequalität', weight: 0.30, description: 'Zürich Innenstadt' },
{ criterion: 'Sichtbarkeit', weight: 0.20 },
{ criterion: 'Deckenhöhe', weight: 0.10 },
{ criterion: 'Lagerfläche', weight: 0.05 },
],
aiSummary: 'Retailer mit starkem Markenbewusstsein sucht Toplage in Zürich. Klare Präferenz Innenstadt, solider Abschluss bei Matching.',
publicVisibility: true,
status: 'public',
createdAt: '2026-03-10T11:00:00Z',
},
{
id: 'lneed-010',
title: 'Büro 300700m² für Tech-Startup Zug/Zürich',
tenantCompany: 'TechStart GmbH',
assetType: AssetType.OFFICE,
desiredLocation: 'Zug / Zürich / Baar',
sizeRange: { min: 300, max: 700 },
budgetRange: { min: 30, max: 48 },
timing: 'Bezug ab Oktober 2025, flexibel bis Q2 2026',
mustHaveCriteria: [
'Moderner Ausbau',
'Schnelles Internet / Glasfaser',
'Fahrradabstellplätze',
],
weightedPreferences: [
{ criterion: 'Tech-Umfeld / Startup-Nähe', weight: 0.30 },
{ criterion: 'ÖV-Anbindung', weight: 0.25 },
{ criterion: 'Flexibler Grundriss', weight: 0.20 },
{ criterion: 'Steuerumfeld', weight: 0.15, description: 'Zug bevorzugt' },
{ criterion: 'ESG-Qualität', weight: 0.10 },
],
aiSummary: 'Wachsendes Tech-Startup mit flexibler Lage zwischen Zug und Zürich. Hohe Bereitschaft bei modernem Objekt mit gutem Preis-Leistungs-Verhältnis.',
publicVisibility: true,
status: 'public',
createdAt: '2026-04-28T13:00:00Z',
},
{
id: 'lneed-011',
title: 'Gewerbe-/Lagerfläche Pratteln 400750m²',
tenantCompany: 'Gewerbe Solutions AG',
assetType: AssetType.LIGHT_INDUSTRIAL,
desiredLocation: 'Pratteln / Muttenz / Schweizerhalle',
sizeRange: { min: 400, max: 750 },
budgetRange: { min: 10, max: 14 },
timing: 'Bezug ab April 2026',
mustHaveCriteria: [
'Lkw-Zufahrt',
'Hallenhöhe min 5m',
'Drehstrom vorhanden',
],
weightedPreferences: [
{ criterion: 'Autobahnnähe', weight: 0.30 },
{ criterion: 'Aussenanlagefläche', weight: 0.25 },
{ criterion: 'Erweiterungspotenzial', weight: 0.20 },
{ criterion: 'Parkplätze', weight: 0.15 },
{ criterion: 'Büroanteil', weight: 0.10, description: 'Kleines Büro on-site gewünscht' },
],
aiSummary: 'Gewerbebetrieb mit klaren Anforderungen sucht Fläche im Raum Pratteln. Gute Ausgangslage für verfügbare Gewerbeliegenschaften.',
publicVisibility: true,
status: 'public',
createdAt: '2026-03-01T09:00:00Z',
},
{
id: 'lneed-012',
title: 'Büro 180320m² Bern Zentrum / Bahnhof',
tenantCompany: 'Bern Consulting AG',
assetType: AssetType.OFFICE,
desiredLocation: 'Bern Bollwerk / Bahnhof / Innenstadt',
sizeRange: { min: 180, max: 320 },
budgetRange: { min: 20, max: 30 },
timing: 'Bezug ab Mai 2026',
mustHaveCriteria: [
'ÖV-Anbindung unter 5 Min zum Bahnhof',
'Repräsentative Lage',
'Konferenzraum',
],
weightedPreferences: [
{ criterion: 'Bahnhofsnähe', weight: 0.35, description: 'Kundenmeetings erfordern zentrale Lage' },
{ criterion: 'Ausbaustandard', weight: 0.25 },
{ criterion: 'Flexibilität', weight: 0.20 },
{ criterion: 'Natürliches Licht', weight: 0.10 },
{ criterion: 'IT-Infrastruktur', weight: 0.10 },
],
aiSummary: 'Beratungsunternehmen sucht repräsentatives Büro nahe Bern HB. Zentrale Lage ist entscheidend für Kundenkontakte. Guter Match mit Berner Citylagen.',
publicVisibility: true,
status: 'public',
createdAt: '2026-04-05T10:00:00Z',
},
{
id: 'lneed-013',
title: 'Verkaufsfläche Winterthur Zentrum 150250m²',
tenantCompany: 'Winterthur Handel GmbH',
assetType: AssetType.RETAIL,
desiredLocation: 'Winterthur Zentrum / Stadthausstrasse / Altstadt',
sizeRange: { min: 150, max: 250 },
budgetRange: { min: 25, max: 40 },
timing: 'Bezug ab Juni 2026',
mustHaveCriteria: [
'Schaufensterfront',
'Erdgeschoss',
'Fussgängerfrequenz',
],
weightedPreferences: [
{ criterion: 'Lage Innenstadt Winterthur', weight: 0.40 },
{ criterion: 'Schaufenstergrösse', weight: 0.25 },
{ criterion: 'Deckenhöhe', weight: 0.15 },
{ criterion: 'Lagerraum', weight: 0.10 },
{ criterion: 'Barrierefrei', weight: 0.10 },
],
aiSummary: 'Lokaler Handelsbetrieb sucht Fläche in Winterthur Innenstadt. Klare Anforderungen, realistisches Budget. Guter Match mit Stadtzentrum-Angeboten.',
publicVisibility: true,
status: 'public',
createdAt: '2026-03-20T11:00:00Z',
},
{
id: 'lneed-014',
title: 'Bürofläche 350600m² Luzern',
tenantCompany: 'Luzern Advisory GmbH',
assetType: AssetType.OFFICE,
desiredLocation: 'Luzern / Luzern-West / Emmen',
sizeRange: { min: 350, max: 600 },
budgetRange: { min: 18, max: 26 },
timing: 'Bezug ab Juni 2026, flexibel bis Januar 2027',
mustHaveCriteria: [
'ÖV-Anbindung unter 10 Min',
'Moderner Ausbau',
'Parkplätze vorhanden',
],
weightedPreferences: [
{ criterion: 'Standortqualität Luzern', weight: 0.30 },
{ criterion: 'Ausbaustandard', weight: 0.25 },
{ criterion: 'Parkplatzverfügbarkeit', weight: 0.20 },
{ criterion: 'Erweiterungspotenzial', weight: 0.15 },
{ criterion: 'ESG', weight: 0.10 },
],
aiSummary: 'Beratungsgesellschaft sucht modernes Büro in Luzern. Gute Balance zwischen Lage, Qualität und Budget. Solides Matchprofil.',
publicVisibility: true,
status: 'public',
createdAt: '2026-04-10T14:00:00Z',
},
// ─── Original lneed-006 ────────────────────────────────────────────────────
{ {
id: 'lneed-006', id: 'lneed-006',
title: 'Pop-up-Retailfläche temporär 6 Monate', title: 'Pop-up-Retailfläche temporär 6 Monate',
+130
View File
@@ -0,0 +1,130 @@
import { AssetType } from '../domain/enums'
import type { MarktHinweis } from '../domain/marktHinweis'
export const marktHinweise: MarktHinweis[] = [
{
id: 'mh-001',
createdAt: '2026-05-22T09:15:00Z',
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.OFFICE,
locationHint: 'Zürich Innenstadt',
areaSqmMin: 200,
areaSqmMax: 400,
companyName: 'Fintech Solutions AG',
isAnonymized: false,
quelle: 'NETZWERKEVENT',
note: 'Sucht modernes Büro, Nähe HB, max CHF 350/m²/J. Kontakt: CEO Andreas Baum',
visibility: 'INTERN',
status: 'OFFEN',
},
{
id: 'mh-002',
createdAt: '2026-05-21T14:30:00Z',
createdBy: 'Sarah Keller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.LOGISTICS,
locationHint: 'Pratteln / Muttenz',
areaSqmMin: 800,
areaSqmMax: 1500,
companyName: 'Elektro Bernhard GmbH',
isAnonymized: false,
quelle: 'TELEFONAT',
note: 'Benötigt Lagerfläche mit Rampe und Kran. Einzug Q1/2027.',
visibility: 'INTERN',
status: 'OFFEN',
},
{
id: 'mh-003',
createdAt: '2026-05-20T11:00:00Z',
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'VERFUEGBARKEIT',
assetType: AssetType.OFFICE,
locationHint: 'Basel',
areaSqmMin: 350,
areaSqmMax: 350,
companyName: 'Fläche frei per Sept. 2026',
isAnonymized: false,
quelle: 'BESICHTIGUNG',
note: 'Bürofläche 350m², guter Zustand, 4. OG, Aufzug.',
visibility: 'INTERN',
status: 'OFFEN',
},
{
id: 'mh-004',
createdAt: '2026-05-19T08:45:00Z',
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.RETAIL,
locationHint: 'Winterthur Altstadt',
areaSqmMin: 80,
areaSqmMax: 150,
companyName: 'Boutique-Kette',
isAnonymized: false,
quelle: 'MESSE',
note: 'Ladenfläche EG, Fussgängerzone bevorzugt.',
visibility: 'PLATTFORM',
status: 'OFFEN',
},
{
id: 'mh-005',
createdAt: '2026-05-18T16:20:00Z',
createdBy: 'Petra Züst',
verwaltungId: 'v-002',
verwaltungName: 'Livit AG',
direction: 'SUCHE',
assetType: AssetType.LIGHT_INDUSTRIAL,
locationHint: 'Raum Bern',
areaSqmMin: 400,
areaSqmMax: 700,
isAnonymized: true,
quelle: 'TELEFONAT',
note: 'Produktionsfirma sucht Gewerbehalle, Deckenhöhe mind. 5m.',
visibility: 'PLATTFORM',
status: 'OFFEN',
},
{
id: 'mh-006',
createdAt: '2026-05-17T10:30:00Z',
createdBy: 'Marco Lüthi',
verwaltungId: 'v-003',
verwaltungName: 'Zurich Invest AG',
direction: 'VERFUEGBARKEIT',
assetType: AssetType.OFFICE,
locationHint: 'Zürich West',
areaSqmMin: 550,
areaSqmMax: 550,
companyName: 'Fläche frei per April 2027',
isAnonymized: false,
quelle: 'SONSTIGES',
note: 'Repräsentatives Büro, Ausbaustandard A, direkte SBB-Anbindung.',
visibility: 'PLATTFORM',
status: 'OFFEN',
},
{
id: 'mh-007',
createdAt: '2026-05-15T09:00:00Z',
createdBy: 'Sarah Keller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
direction: 'SUCHE',
assetType: AssetType.OFFICE,
locationHint: 'Luzern',
areaSqmMin: 180,
areaSqmMax: 320,
companyName: 'Startup Hub Luzern',
isAnonymized: false,
quelle: 'NETZWERKEVENT',
note: 'Flexibler Ausbau gewünscht, Co-Working-tauglich.',
visibility: 'INTERN',
status: 'IN_BEARBEITUNG',
},
]
+317
View File
@@ -1759,4 +1759,321 @@ export const mockMatches: Match[] = [
createdAt: '2026-05-20T08:20:00Z', createdAt: '2026-05-20T08:20:00Z',
updatedAt: '2026-05-20T08:20:00Z', updatedAt: '2026-05-20T08:20:00Z',
}, },
// ───────────────────────────────────────────────────────────────────────────
// DIRECT LISTING MATCHES (prop-d01 … prop-d06)
// ───────────────────────────────────────────────────────────────────────────
{
id: 'match-065',
propertyId: 'prop-d01',
needId: 'need-005',
matchScore: 83,
matchStrength: MatchStrength.STRONG,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 88, softFactorScore: 80, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 83 },
positiveFactors: [
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '320m² liegt im Zielkorridor (300700m²)' },
{ criterion: 'Standort', weight: 0.22, score: 90, contribution: 19.8, explanation: 'Zürich Altstetten — bevorzugte Region Zürich' },
{ criterion: 'Budget', weight: 0.17, score: 88, contribution: 14.9, explanation: 'CHF 380/m²/J unter Maximum CHF 576/m²/J' },
],
negativeFactors: [
{ criterion: 'ÖV', weight: 0.10, score: 65, contribution: 6.5, explanation: '8 Minuten zur S-Bahn — leicht über Wunsch-Maximum (10 Min.)' },
],
tradeoffs: [],
explainabilitySummary: 'Sehr guter Match für TechStart GmbH: Fläche, Standort Zürich und Budget passen. Minimaler ÖV-Abzug.',
confidenceLevel: 0.80,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: [],
organizationId: 'org-wincasa',
createdAt: '2026-04-15T09:00:00Z',
updatedAt: '2026-04-15T09:00:00Z',
},
{
id: 'match-066',
propertyId: 'prop-d01',
needId: 'need-001',
matchScore: 64,
matchStrength: MatchStrength.MODERATE,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.80, dataQualityModifier: 0.78, totalScore: 64 },
positiveFactors: [
{ criterion: 'Standort', weight: 0.18, score: 88, contribution: 15.8, explanation: 'Zürich Altstetten trifft bevorzugte Region' },
{ criterion: 'Budget', weight: 0.14, score: 90, contribution: 12.6, explanation: 'CHF 380/m²/J unter Maximum CHF 540/m²/J' },
],
negativeFactors: [
{ criterion: 'Fläche', weight: 0.18, score: 40, contribution: 7.2, explanation: '320m² unterschreitet Minimum von 600m² erheblich' },
],
tradeoffs: [
{ criterion: 'Fläche', concern: '320m² — halb so gross wie Mindestbedarf (600m²)', severity: 'HIGH', mitigation: 'Evtl. Erweiterungsoptionen im Gebäude prüfen' },
],
explainabilitySummary: 'Standort und Budget passen für Innovatech AG, aber Fläche deutlich zu klein. Nur als Notfall-Option.',
confidenceLevel: 0.80,
riskLevel: RiskLevel.MEDIUM,
uncertaintyIndicators: ['Fläche unter Minimum'],
organizationId: 'org-wincasa',
createdAt: '2026-04-15T09:05:00Z',
updatedAt: '2026-04-15T09:05:00Z',
},
{
id: 'match-067',
propertyId: 'prop-d02',
needId: 'need-002',
matchScore: 66,
matchStrength: MatchStrength.MODERATE,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 70, softFactorScore: 78, confidenceModifier: 0.85, dataQualityModifier: 0.82, totalScore: 66 },
positiveFactors: [
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Pratteln — explizit bevorzugte Region für Schweizer Logistik GmbH' },
{ criterion: 'Budget', weight: 0.18, score: 95, contribution: 17.1, explanation: 'CHF 145/m²/J weit unter Maximum CHF 216/m²/J' },
{ criterion: 'Hallenhöhe', weight: 0.10, score: 90, contribution: 9.0, explanation: '6.5m Hallenhöhe — Mindestanforderung 6m erfüllt' },
],
negativeFactors: [
{ criterion: 'Fläche', weight: 0.23, score: 22, contribution: 5.1, explanation: '580m² vs. Minimum 1500m² — erhebliche Unterdeckung' },
],
tradeoffs: [
{ criterion: 'Fläche', concern: 'Nur 39% des Mindestbedarfs gedeckt', severity: 'HIGH', mitigation: 'Kombinierbar mit benachbarten Einheiten falls verfügbar' },
],
explainabilitySummary: 'Idealer Standort und Preis für Schweizer Logistik GmbH — Fläche reicht aber nicht aus.',
confidenceLevel: 0.85,
riskLevel: RiskLevel.MEDIUM,
uncertaintyIndicators: ['Fläche zu gering'],
organizationId: 'org-wincasa',
createdAt: '2026-04-20T10:00:00Z',
updatedAt: '2026-04-20T10:00:00Z',
},
{
id: 'match-068',
propertyId: 'prop-d03',
needId: 'need-005',
matchScore: 61,
matchStrength: MatchStrength.MODERATE,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 65, softFactorScore: 70, confidenceModifier: 0.78, dataQualityModifier: 0.74, totalScore: 61 },
positiveFactors: [
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 295/m²/J deutlich unter Maximum CHF 576/m²/J' },
{ criterion: 'Ausbau', weight: 0.08, score: 85, contribution: 6.8, explanation: 'Vollausbau trifft "Moderner Ausbau"-Anforderung' },
],
negativeFactors: [
{ criterion: 'Standort', weight: 0.22, score: 30, contribution: 6.6, explanation: 'Bern liegt ausserhalb bevorzugter Regionen Zürich/Zug' },
{ criterion: 'Fläche', weight: 0.17, score: 62, contribution: 10.5, explanation: '240m² leicht unter Minimum 300m²' },
],
tradeoffs: [
{ criterion: 'Standort', concern: 'Bern liegt nicht in Zürich/Zug — TechStart GmbH hat keine Niederlassung in Bern', severity: 'MEDIUM', mitigation: 'Falls Remote-Team Bern ausgebaut wird, attraktive Option' },
],
explainabilitySummary: 'Gutes Budget-Match für TechStart, aber Standort Bern ist nicht ihre bevorzugte Region.',
confidenceLevel: 0.78,
riskLevel: RiskLevel.MEDIUM,
uncertaintyIndicators: ['Standort ausserhalb Präferenz'],
organizationId: 'org-wincasa',
createdAt: '2026-04-22T11:00:00Z',
updatedAt: '2026-04-22T11:00:00Z',
},
{
id: 'match-069',
propertyId: 'prop-d04',
needId: 'need-004',
matchScore: 62,
matchStrength: MatchStrength.MODERATE,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 68, softFactorScore: 72, confidenceModifier: 0.72, dataQualityModifier: 0.68, totalScore: 62 },
positiveFactors: [
{ criterion: 'Budget', weight: 0.12, score: 100, contribution: 12.0, explanation: 'CHF 360/m²/J weit unter Maximum CHF 1200/m²/J' },
{ criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' },
{ criterion: 'Erdgeschoss', weight: 0.15, score: 100, contribution: 15.0, explanation: 'EG-Lage wie gefordert' },
],
negativeFactors: [
{ criterion: 'Standort', weight: 0.20, score: 32, contribution: 6.4, explanation: 'Winterthur vs. Zürich Innenstadt — andere Stadt' },
{ criterion: 'Fläche', weight: 0.10, score: 55, contribution: 5.5, explanation: '185m² leicht unter Minimum 200m²' },
],
tradeoffs: [
{ criterion: 'Standort', concern: 'Winterthur Stadtzentrum ≠ Zürich Innenstadt', severity: 'MEDIUM', mitigation: 'Winterthur hat hohe Fussgängerfrequenz, günstigere Miete' },
],
explainabilitySummary: 'Gute Retail-Attribute, aber Winterthur ist nicht das gesuchte Zürich-Innenstadt-Format.',
confidenceLevel: 0.72,
riskLevel: RiskLevel.MEDIUM,
uncertaintyIndicators: ['Standort differiert'],
organizationId: 'org-wincasa',
createdAt: '2026-02-15T14:00:00Z',
updatedAt: '2026-02-15T14:00:00Z',
},
{
id: 'match-070',
propertyId: 'prop-d05',
needId: 'need-002',
matchScore: 81,
matchStrength: MatchStrength.STRONG,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 85, softFactorScore: 82, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 81 },
positiveFactors: [
{ criterion: 'Standort', weight: 0.18, score: 98, contribution: 17.6, explanation: 'Muttenz — explizit bevorzugte Region für Schweizer Logistik GmbH' },
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 130/m²/J deutlich unter Maximum CHF 216/m²/J' },
{ criterion: 'Hallenhöhe', weight: 0.10, score: 100, contribution: 10.0, explanation: '9m — deutlich über Mindestanforderung 6m' },
{ criterion: 'Ladelucken', weight: 0.08, score: 100, contribution: 8.0, explanation: '4 Ladelucken vorhanden — Anforderung erfüllt' },
],
negativeFactors: [
{ criterion: 'Fläche', weight: 0.23, score: 65, contribution: 14.9, explanation: '1200m² leicht unter Minimum 1500m² (-20%)' },
],
tradeoffs: [
{ criterion: 'Fläche', concern: '1200m² vs. Minimum 1500m² — 20% Unterdeckung', severity: 'LOW', mitigation: 'Erweiterungspotenzial auf Nachbargrundstück vorhanden' },
],
explainabilitySummary: 'Starker Logistik-Match: Muttenz, Hallenhöhe, Budget und Ladelucken passen. Fläche leicht unter Minimum.',
confidenceLevel: 0.88,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: [],
organizationId: 'org-wincasa',
createdAt: '2026-05-01T08:00:00Z',
updatedAt: '2026-05-01T08:00:00Z',
},
{
id: 'match-071',
propertyId: 'prop-d06',
needId: 'need-005',
matchScore: 70,
matchStrength: MatchStrength.MODERATE,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 75, softFactorScore: 72, confidenceModifier: 0.70, dataQualityModifier: 0.65, totalScore: 70 },
positiveFactors: [
{ criterion: 'Fläche', weight: 0.17, score: 95, contribution: 16.2, explanation: '450m² liegt im Zielkorridor (300700m²)' },
{ criterion: 'Budget', weight: 0.17, score: 100, contribution: 17.0, explanation: 'CHF 260/m²/J deutlich unter Maximum CHF 576/m²/J' },
],
negativeFactors: [
{ criterion: 'Standort', weight: 0.22, score: 42, contribution: 9.2, explanation: 'Luzern liegt ausserhalb bevorzugter Regionen Zürich/Zug/Baar' },
],
tradeoffs: [
{ criterion: 'Standort', concern: 'Luzern ist 45 Min. von Zürich entfernt', severity: 'MEDIUM', mitigation: 'Attraktiver Preis und S-Bahn-Direktverbindung könnten Kompromiss sein' },
],
explainabilitySummary: 'Fläche und Budget passen sehr gut für TechStart GmbH. Luzern ist nicht ihre Wunschregion, aber preislich attraktiv.',
confidenceLevel: 0.70,
riskLevel: RiskLevel.MEDIUM,
uncertaintyIndicators: ['Standort ausserhalb Präferenz', 'Inserat inaktiv'],
organizationId: 'org-wincasa',
createdAt: '2026-01-15T09:00:00Z',
updatedAt: '2026-01-15T09:00:00Z',
},
// ───────────────────────────────────────────────────────────────────────────
// Direct listings — Strong matches (>80%) ensuring all 6 have ≥1 STRONG
// ───────────────────────────────────────────────────────────────────────────
{
id: 'match-072',
propertyId: 'prop-d02',
needId: 'need-012',
matchScore: 85,
matchStrength: MatchStrength.STRONG,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 88, softFactorScore: 84, confidenceModifier: 0.90, dataQualityModifier: 0.87, totalScore: 85 },
positiveFactors: [
{ criterion: 'Standort', weight: 0.20, score: 98, contribution: 19.6, explanation: 'Pratteln — explizit bevorzugte Region für Gewerbe Solutions AG' },
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 145/m²/J deutlich unter Maximum CHF 168/m²/J' },
{ criterion: 'Fläche', weight: 0.22, score: 90, contribution: 19.8, explanation: '580m² liegt ideal im Zielkorridor (400750m²)' },
{ criterion: 'Lkw-Zufahrt', weight: 0.10, score: 100, contribution: 10.0, explanation: 'Lkw-Zufahrt vorhanden — Pflichtkriterium erfüllt' },
],
negativeFactors: [
{ criterion: 'Hallenhöhe', weight: 0.08, score: 70, contribution: 5.6, explanation: 'Angabe Hallenhöhe nicht vollständig dokumentiert' },
],
tradeoffs: [
{ criterion: 'Hallenhöhe', concern: 'Angabe Hallenhöhe unvollständig — Besichtigung nötig', severity: 'LOW', mitigation: 'Vor Mietabschluss Vor-Ort-Prüfung empfohlen' },
],
explainabilitySummary: 'Ausgezeichneter Standort und Preis-Leistungs-Verhältnis für Gewerbe Solutions AG. Pratteln ist die Wunschregion, Fläche und Budget passen sehr gut.',
confidenceLevel: 0.90,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: [],
organizationId: 'org-wincasa',
createdAt: '2026-05-10T08:00:00Z',
updatedAt: '2026-05-10T08:00:00Z',
},
{
id: 'match-073',
propertyId: 'prop-d03',
needId: 'need-013',
matchScore: 84,
matchStrength: MatchStrength.STRONG,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 87, softFactorScore: 83, confidenceModifier: 0.88, dataQualityModifier: 0.85, totalScore: 84 },
positiveFactors: [
{ criterion: 'Standort', weight: 0.25, score: 96, contribution: 24.0, explanation: 'Bern Bollwerk — perfekter Fit mit Bern Innenstadt / Bahnhof-Nähe' },
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 295/m²/J unter Maximum CHF 360/m²/J' },
{ criterion: 'Fläche', weight: 0.18, score: 92, contribution: 16.6, explanation: '240m² liegt gut im Korridor (180320m²)' },
{ criterion: 'ÖV', weight: 0.09, score: 100, contribution: 9.0, explanation: 'Hauptbahnhof Bern < 5 Min zu Fuss' },
],
negativeFactors: [
{ criterion: 'Konferenzraum', weight: 0.06, score: 60, contribution: 3.6, explanation: 'Konferenzraumsituation nicht explizit dokumentiert' },
],
tradeoffs: [],
explainabilitySummary: 'Starke Übereinstimmung: Bern Bollwerk trifft genau die Anforderungen von Bern Consulting AG — Lage, Preis und Fläche passen.',
confidenceLevel: 0.88,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: [],
organizationId: 'org-wincasa',
createdAt: '2026-05-10T09:00:00Z',
updatedAt: '2026-05-10T09:00:00Z',
},
{
id: 'match-074',
propertyId: 'prop-d04',
needId: 'need-014',
matchScore: 82,
matchStrength: MatchStrength.STRONG,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 86, softFactorScore: 80, confidenceModifier: 0.84, dataQualityModifier: 0.82, totalScore: 82 },
positiveFactors: [
{ criterion: 'Standort', weight: 0.22, score: 97, contribution: 21.3, explanation: 'Winterthur Stadthausstrasse — Wunschlage für Winterthur Handel GmbH' },
{ criterion: 'Schaufensterfront', weight: 0.12, score: 100, contribution: 12.0, explanation: 'Schaufensterfront vorhanden — Pflichtkriterium erfüllt' },
{ criterion: 'Erdgeschoss', weight: 0.12, score: 100, contribution: 12.0, explanation: 'EG-Lage wie gefordert' },
{ criterion: 'Budget', weight: 0.14, score: 100, contribution: 14.0, explanation: 'CHF 360/m²/J deutlich unter Maximum CHF 480/m²/J' },
],
negativeFactors: [
{ criterion: 'Fläche', weight: 0.12, score: 68, contribution: 8.2, explanation: '185m² leicht unter Minimum 150m², aber am unteren Rand' },
{ criterion: 'Inaktiv', weight: 0.04, score: 0, contribution: 0, explanation: 'Inserat derzeit inaktiv — Verfügbarkeit zu klären' },
],
tradeoffs: [
{ criterion: 'Status', concern: 'Inserat inaktiv — Verfügbarkeit muss bestätigt werden', severity: 'LOW', mitigation: 'Direkter Kontakt mit Eigentümer empfohlen' },
],
explainabilitySummary: 'Winterthur Handel GmbH findet hier genau die gesuchte Retailfläche am richtigen Ort. Status zu klären.',
confidenceLevel: 0.84,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: ['Inserat inaktiv'],
organizationId: 'org-wincasa',
createdAt: '2026-05-10T10:00:00Z',
updatedAt: '2026-05-10T10:00:00Z',
},
{
id: 'match-075',
propertyId: 'prop-d06',
needId: 'need-015',
matchScore: 83,
matchStrength: MatchStrength.STRONG,
status: MatchStatus.PENDING_REVIEW,
scoreBreakdown: { hardMatchScore: 86, softFactorScore: 82, confidenceModifier: 0.86, dataQualityModifier: 0.84, totalScore: 83 },
positiveFactors: [
{ criterion: 'Standort', weight: 0.22, score: 95, contribution: 20.9, explanation: 'Luzern Zürichstrasse — Wunschlage für Luzern Advisory GmbH' },
{ criterion: 'Fläche', weight: 0.19, score: 96, contribution: 18.2, explanation: '450m² liegt ideal im Korridor (350600m²)' },
{ criterion: 'Budget', weight: 0.18, score: 100, contribution: 18.0, explanation: 'CHF 260/m²/J klar unter Maximum CHF 312/m²/J' },
{ criterion: 'ÖV', weight: 0.08, score: 88, contribution: 7.0, explanation: 'Gute S-Bahn-Anbindung Luzern Zentrum' },
],
negativeFactors: [
{ criterion: 'Inaktiv', weight: 0.04, score: 0, contribution: 0, explanation: 'Inserat derzeit inaktiv — Bezugsdatum offen' },
],
tradeoffs: [
{ criterion: 'Status', concern: 'Inserat inaktiv — Aktivierung und Verfügbarkeit zu klären', severity: 'LOW', mitigation: 'Direkte Anfrage an Eigentümer empfohlen' },
],
explainabilitySummary: 'Sehr guter Match für Luzern Advisory GmbH: Lage, Fläche und Budget passen ausgezeichnet. Inserat-Status zu klären.',
confidenceLevel: 0.86,
riskLevel: RiskLevel.LOW,
uncertaintyIndicators: ['Inserat inaktiv'],
organizationId: 'org-wincasa',
createdAt: '2026-05-10T11:00:00Z',
updatedAt: '2026-05-10T11:00:00Z',
},
] ]
+136
View File
@@ -437,6 +437,142 @@ export const mockNeeds: Need[] = [
updatedAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z',
}, },
// --- need-012: Gewerbe Solutions AG — LIGHT_INDUSTRIAL Pratteln ---
{
id: 'need-012',
companyName: 'Gewerbe Solutions AG',
contactName: 'Andreas Weber',
assetType: AssetType.LIGHT_INDUSTRIAL,
requiredArea: { min: 400, max: 750 },
preferredLocations: ['Pratteln', 'Muttenz', 'Schweizerhalle', 'Liestal'],
excludedLocations: [],
budgetRange: { maxPerSqm: 168, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-04-01',
latestMoveIn: '2026-10-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['Lkw-Zufahrt', 'Hallenhöhe min 5m', 'Drehstrom vorhanden'],
softFactors: {
requireParking: true,
},
weightingProfile: {
area: 0.22, location: 0.20, budget: 0.18, timing: 0.12,
prestige: 0.02, accessibility: 0.10, expansionPotential: 0.06, flexibility: 0.03,
visibility: 0.01, footfall: 0.00, talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.01,
},
confidenceInCriteria: 0.90,
extractedFromText: 'Gewerbe-/Lagerfläche 450700m² in Pratteln oder Muttenz, Lkw-Zufahrt, max. CHF 168/m²/J.',
organizationId: 'org-wincasa',
createdAt: '2026-03-01T09:00:00Z',
updatedAt: '2026-03-01T09:00:00Z',
},
// --- need-013: Bern Consulting AG — OFFICE Bern ---
{
id: 'need-013',
companyName: 'Bern Consulting AG',
contactName: 'Sabine Gerber',
assetType: AssetType.OFFICE,
requiredArea: { min: 180, max: 320 },
preferredLocations: ['Bern', 'Bern Bollwerk', 'Bern Bahnhof', 'Bern Innenstadt'],
excludedLocations: [],
budgetRange: { maxPerSqm: 360, maxMonthlyTotal: 8500, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-05-01',
latestMoveIn: '2026-11-01',
contractDurationMonths: 48,
flexibleTiming: true,
},
mustCriteriaText: ['ÖV-Anbindung < 5 Min', 'Repräsentative Lage', 'Konferenzraum vorhanden'],
softFactors: {
minPrestige: 65,
minAccessibility: 80,
requireParking: false,
maxPublicTransportMinutes: 5,
},
weightingProfile: {
area: 0.18, location: 0.25, budget: 0.18, timing: 0.11,
prestige: 0.08, accessibility: 0.09, expansionPotential: 0.02, flexibility: 0.03,
visibility: 0.02, footfall: 0.01, talentAccess: 0.05, esg: 0.02, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.88,
extractedFromText: 'Beratungsunternehmen sucht Bürofläche 200300m² in Bern Zentrum nahe Bahnhof, max. CHF 360/m²/J.',
organizationId: 'org-wincasa',
createdAt: '2026-04-05T10:00:00Z',
updatedAt: '2026-04-05T10:00:00Z',
},
// --- need-014: Winterthur Handel GmbH — RETAIL Winterthur ---
{
id: 'need-014',
companyName: 'Winterthur Handel GmbH',
contactName: 'Pascal Brunner',
assetType: AssetType.RETAIL,
requiredArea: { min: 150, max: 250 },
preferredLocations: ['Winterthur', 'Winterthur Zentrum', 'Winterthur Stadthausstrasse', 'Winterthur Altstadt'],
excludedLocations: [],
budgetRange: { maxPerSqm: 480, maxMonthlyTotal: 8000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-06-01',
latestMoveIn: '2026-12-01',
contractDurationMonths: 36,
flexibleTiming: false,
},
mustCriteriaText: ['Schaufensterfront', 'Erdgeschoss', 'Gute Fussgängerfrequenz'],
softFactors: {
minPrestige: 70,
requireParking: false,
maxPublicTransportMinutes: 8,
},
weightingProfile: {
area: 0.12, location: 0.22, budget: 0.14, timing: 0.10,
prestige: 0.06, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.03,
visibility: 0.12, footfall: 0.12, talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
},
confidenceInCriteria: 0.87,
extractedFromText: 'Handelsbetrieb sucht Verkaufsfläche 160230m² in Winterthur Zentrum, Schaufenster, max. CHF 480/m²/J.',
organizationId: 'org-wincasa',
createdAt: '2026-03-20T11:00:00Z',
updatedAt: '2026-03-20T11:00:00Z',
},
// --- need-015: Luzern Advisory GmbH — OFFICE Luzern ---
{
id: 'need-015',
companyName: 'Luzern Advisory GmbH',
contactName: 'Markus Bucher',
assetType: AssetType.OFFICE,
requiredArea: { min: 350, max: 600 },
preferredLocations: ['Luzern', 'Luzern Zentrum', 'Luzern-West', 'Emmen'],
excludedLocations: [],
budgetRange: { maxPerSqm: 312, maxMonthlyTotal: 14000, currency: 'CHF' },
timing: {
earliestMoveIn: '2026-06-01',
latestMoveIn: '2027-01-01',
contractDurationMonths: 60,
flexibleTiming: true,
},
mustCriteriaText: ['ÖV < 10 Min', 'Moderner Ausbau', 'Parkplätze vorhanden'],
softFactors: {
minPrestige: 60,
minAccessibility: 70,
requireParking: true,
maxPublicTransportMinutes: 10,
},
weightingProfile: {
area: 0.19, location: 0.22, budget: 0.18, timing: 0.11,
prestige: 0.07, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.04,
visibility: 0.02, footfall: 0.01, talentAccess: 0.04, esg: 0.02, taxEnvironment: 0.01,
},
confidenceInCriteria: 0.86,
extractedFromText: 'Beratungsgesellschaft sucht moderne Bürofläche 400550m² in Luzern, max. CHF 312/m²/J, Parkplätze nötig.',
organizationId: 'org-wincasa',
createdAt: '2026-04-10T14:00:00Z',
updatedAt: '2026-04-10T14:00:00Z',
},
// --- need-uc-c: Renato's Vermögensverwalter — Use Case C --- // --- need-uc-c: Renato's Vermögensverwalter — Use Case C ---
{ {
id: 'need-uc-c', id: 'need-uc-c',
+181 -6
View File
@@ -608,7 +608,7 @@ export const mockProperties: Property[] = [
prestige: 56, prestige: 56,
accessibility: 88, accessibility: 88,
visibilityScore: 72, visibilityScore: 72,
passerbyFrequency: 'MEDIUM_HIGH', passerbyFrequency: 'HIGH',
talentAccess: 65, talentAccess: 65,
parkingSpots: 1, parkingSpots: 1,
publicTransportMinutes: 5, publicTransportMinutes: 5,
@@ -830,7 +830,7 @@ export const mockProperties: Property[] = [
prestige: 60, prestige: 60,
accessibility: 88, accessibility: 88,
visibilityScore: 75, visibilityScore: 75,
passerbyFrequency: 'MEDIUM_HIGH', passerbyFrequency: 'HIGH',
talentAccess: 70, talentAccess: 70,
parkingSpots: 2, parkingSpots: 2,
publicTransportMinutes: 5, publicTransportMinutes: 5,
@@ -2017,7 +2017,7 @@ export const mockProperties: Property[] = [
prestige: 48, prestige: 48,
accessibility: 78, accessibility: 78,
visibilityScore: 68, visibilityScore: 68,
passerbyFrequency: 'MEDIUM_HIGH', passerbyFrequency: 'HIGH',
talentAccess: 60, talentAccess: 60,
parkingSpots: 2, parkingSpots: 2,
publicTransportMinutes: 5, publicTransportMinutes: 5,
@@ -2230,7 +2230,7 @@ export const mockProperties: Property[] = [
prestige: 58, prestige: 58,
accessibility: 85, accessibility: 85,
visibilityScore: 70, visibilityScore: 70,
passerbyFrequency: 'MEDIUM_HIGH', passerbyFrequency: 'HIGH',
talentAccess: 65, talentAccess: 65,
parkingSpots: 2, parkingSpots: 2,
publicTransportMinutes: 5, publicTransportMinutes: 5,
@@ -2373,7 +2373,7 @@ export const mockProperties: Property[] = [
prestige: 55, prestige: 55,
accessibility: 82, accessibility: 82,
visibilityScore: 70, visibilityScore: 70,
passerbyFrequency: 'MEDIUM_HIGH', passerbyFrequency: 'HIGH',
talentAccess: 65, talentAccess: 65,
parkingSpots: 0, parkingSpots: 0,
publicTransportMinutes: 5, publicTransportMinutes: 5,
@@ -3453,7 +3453,7 @@ export const mockProperties: Property[] = [
prestige: 85, prestige: 85,
accessibility: 95, accessibility: 95,
visibilityScore: 88, visibilityScore: 88,
passerbyFrequency: 'MEDIUM_HIGH', passerbyFrequency: 'HIGH',
talentAccess: 76, talentAccess: 76,
parkingSpots: 0, parkingSpots: 0,
publicTransportMinutes: 3, publicTransportMinutes: 3,
@@ -4487,8 +4487,183 @@ export const mockProperties: Property[] = [
updatedAt: '2025-05-22T10:00:00Z', updatedAt: '2025-05-22T10:00:00Z',
}, },
// ─────────────────────────────────────────────────────────────────────────────
// DIRECT LISTINGS (6) — created via Inserate-Formular, sourceType DIRECT
// ─────────────────────────────────────────────────────────────────────────────
{
id: 'prop-d01',
title: 'Bürofläche Bachmattstrasse 28',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Zürich', district: 'Altstetten', canton: 'ZH', country: 'CH', coordinates: { lat: 47.3906, lng: 8.4886 } },
address: { street: 'Bachmattstrasse', houseNumber: '28', postalCode: '8048', city: 'Zürich', country: 'CH' },
areaSqm: 320,
rentPricePerSqm: 380,
availabilityDate: '2026-06-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.80,
dataQuality: { score: 0.78, missingCriticalFields: [], missingOptionalFields: ['softFactors', 'hardFacts'], lastVerifiedAt: '2026-03-10', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 60, accessibility: 75, publicTransportMinutes: 8, parkingSpots: 4 },
hardFacts: { floor: 3, fitOut: 'BASIC', parking: 4 },
floorLevel: 3,
ancillaryCosts: 4.2,
riskLevel: RiskLevel.LOW,
description: 'Helle Bürofläche im 3. OG an der Bachmattstrasse, Zürich-Altstetten. 320 m², Basisausbau, Parkplätze im Haus. 8 Minuten zur S-Bahn Altstetten.',
images: ['https://images.unsplash.com/photo-1497366216548-37526070297c?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-02-14T09:30:00Z',
updatedAt: '2026-02-14T09:30:00Z',
},
{
id: 'prop-d02',
title: 'Gewerbe- & Lagerfläche Pratteln',
assetType: AssetType.LIGHT_INDUSTRIAL,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Pratteln', district: 'Industriezone Nord', canton: 'BL', country: 'CH', coordinates: { lat: 47.5199, lng: 7.6951 } },
address: { street: 'Industriestrasse', houseNumber: '14', postalCode: '4133', city: 'Pratteln', country: 'CH' },
areaSqm: 580,
rentPricePerSqm: 145,
availabilityDate: '2026-05-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
sourceType: 'DIRECT',
confidenceScore: 0.85,
dataQuality: { score: 0.82, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-04-01', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 40, accessibility: 80, parkingSpots: 10 },
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 10, ceilingHeightM: 6.5, loadingDocksCount: 2 },
floorLevel: 0,
ancillaryCosts: 2.8,
riskLevel: RiskLevel.LOW,
description: 'Gewerbe- und Lagerhalle im Industriegebiet Pratteln. Erdgeschoss, 580 m², 6,5 m Hallenhöhe, 2 Ladelucken, 10 Aussenparkplätze. Sofort verfügbar.',
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-03-05T11:00:00Z',
updatedAt: '2026-03-05T11:00:00Z',
},
{
id: 'prop-d03',
title: 'Bürofläche Bollwerk 15, Bern',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Bern', district: 'Innenstadt', canton: 'BE', country: 'CH', coordinates: { lat: 46.9497, lng: 7.4391 } },
address: { street: 'Bollwerk', houseNumber: '15', postalCode: '3011', city: 'Bern', country: 'CH' },
areaSqm: 240,
rentPricePerSqm: 295,
availabilityDate: '2026-07-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.78,
dataQuality: { score: 0.74, missingCriticalFields: [], missingOptionalFields: ['hardFacts'], lastVerifiedAt: '2026-04-20', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 72, accessibility: 92, publicTransportMinutes: 3, parkingSpots: 0 },
hardFacts: { floor: 4, fitOut: 'FULL' },
floorLevel: 4,
ancillaryCosts: 3.5,
riskLevel: RiskLevel.LOW,
description: 'Repräsentative Bürofläche im 4. OG am Bollwerk, Bern. 240 m², Vollausbau, Toplage direkt beim Bahnhof Bern (3 Min.). Kein Parkplatz.',
images: ['https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-04-02T14:15:00Z',
updatedAt: '2026-04-02T14:15:00Z',
},
{
id: 'prop-d04',
title: 'Verkaufsfläche Stadthausstrasse Winterthur',
assetType: AssetType.RETAIL,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'INACTIVE',
location: { city: 'Winterthur', district: 'Zentrum', canton: 'ZH', country: 'CH', coordinates: { lat: 47.4997, lng: 8.7241 } },
address: { street: 'Stadthausstrasse', houseNumber: '6', postalCode: '8400', city: 'Winterthur', country: 'CH' },
areaSqm: 185,
rentPricePerSqm: 360,
availabilityDate: '2026-08-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.72,
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] },
softFactors: { prestige: 68, accessibility: 85, visibilityScore: 80, publicTransportMinutes: 5 },
hardFacts: { floor: 0, fitOut: 'BASIC', hasStorefront: true },
floorLevel: 0,
ancillaryCosts: 5.0,
riskLevel: RiskLevel.MEDIUM,
description: 'Ladenfläche an der Stadthausstrasse Winterthur, Erdgeschoss, Schaufensterfront. 185 m², Basisausbau. Zentrale Lage, hohe Passantenfrequenz. Derzeit deaktiviert — Verhandlung sistiert.',
images: ['https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-01-20T10:00:00Z',
updatedAt: '2026-04-15T08:00:00Z',
},
{
id: 'prop-d05',
title: 'Logistikhalle Muttenz Rheinstrasse',
assetType: AssetType.LOGISTICS,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'ACTIVE',
location: { city: 'Muttenz', district: 'Frenkendorf Ost', canton: 'BL', country: 'CH', coordinates: { lat: 47.5150, lng: 7.6469 } },
address: { street: 'Rheinstrasse', houseNumber: '61', postalCode: '4132', city: 'Muttenz', country: 'CH' },
areaSqm: 1200,
rentPricePerSqm: 130,
availabilityDate: '2026-06-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.88,
dataQuality: { score: 0.85, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-05-01', freshness: DataFreshness.FRESH, warnings: [] },
softFactors: { prestige: 35, accessibility: 88, parkingSpots: 20 },
hardFacts: { floor: 0, fitOut: 'SHELL', parking: 20, ceilingHeightM: 9.0, loadingDocksCount: 4, powerSupplyKva: 400 },
floorLevel: 0,
ancillaryCosts: 2.2,
riskLevel: RiskLevel.LOW,
description: 'Moderne Logistikhalle in Muttenz, 1200 m², 9 m Hallenhöhe, 4 Ladelucken, Stromanschluss 400 kVA. Direkter Autobahnanschluss A2/A3, 20 Aussenparkplätze.',
images: ['https://images.unsplash.com/photo-1586528116311-ad8dd3c8310d?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2026-04-28T16:00:00Z',
updatedAt: '2026-04-28T16:00:00Z',
},
{
id: 'prop-d06',
title: 'Bürofläche Zürichstrasse 45, Luzern',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
status: 'INACTIVE',
location: { city: 'Luzern', district: 'Littau', canton: 'LU', country: 'CH', coordinates: { lat: 47.0530, lng: 8.2592 } },
address: { street: 'Zürichstrasse', houseNumber: '45', postalCode: '6014', city: 'Luzern', country: 'CH' },
areaSqm: 450,
rentPricePerSqm: 260,
availabilityDate: '2026-09-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
sourceType: 'DIRECT',
confidenceScore: 0.70,
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] },
softFactors: { prestige: 55, accessibility: 70, publicTransportMinutes: 12, parkingSpots: 8 },
hardFacts: { floor: 1, fitOut: 'BASIC', parking: 8 },
floorLevel: 1,
ancillaryCosts: 3.8,
riskLevel: RiskLevel.MEDIUM,
description: 'Bürofläche Zürichstrasse 45, Luzern-Littau. 450 m², 1. OG, Basisausbau. 8 Tiefgaragenplätze. Deaktiviert — Preisüberarbeitung ausstehend.',
images: ['https://images.unsplash.com/photo-1524758631624-e2822e304c36?w=800&h=400&fit=crop'],
organizationId: 'org-wincasa',
createdAt: '2025-12-10T08:00:00Z',
updatedAt: '2026-03-20T12:00:00Z',
},
] ]
// Assign lease contract URLs to ~50 % of properties (every other one)
mockProperties.forEach((p, i) => {
if (i % 2 !== 0) return
const startYear = p.leaseStartDate ? new Date(p.leaseStartDate).getFullYear() : 2020 + (i % 5)
const endYear = p.leaseEndDate ? new Date(p.leaseEndDate).getFullYear() : startYear + 5
p.leaseContractUrl = `https://docs.wincasa.ch/mietvertraege/${p.id}.pdf`
p.leaseContractName = `Mietvertrag ${startYear}${endYear}`
})
// Assign images sequentially per pool so no two properties ever share the same image // Assign images sequentially per pool so no two properties ever share the same image
// (until pool wraps, which happens after ~12 properties per pool type) // (until pool wraps, which happens after ~12 properties per pool type)
const _poolCounters: Record<string, number> = {} const _poolCounters: Record<string, number> = {}
+40
View File
@@ -4,6 +4,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-001', propertyId: 'prop-001',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 4.2,
activePlatformSearches: 14,
avgVacancyMonths: 4.2,
demandSupplyRatio: 2.3,
comparableListings: 3,
},
signals: [ signals: [
{ {
id: 'sig-001-1', id: 'sig-001-1',
@@ -44,6 +52,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-007', propertyId: 'prop-007',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 3.8,
activePlatformSearches: 11,
avgVacancyMonths: 3.5,
demandSupplyRatio: 2.8,
comparableListings: 1,
},
signals: [ signals: [
{ {
id: 'sig-007-1', id: 'sig-007-1',
@@ -83,6 +99,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-008', propertyId: 'prop-008',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 7.1,
activePlatformSearches: 7,
avgVacancyMonths: 6.0,
demandSupplyRatio: 1.4,
comparableListings: 4,
},
signals: [ signals: [
{ {
id: 'sig-008-1', id: 'sig-008-1',
@@ -123,6 +147,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-012', propertyId: 'prop-012',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Büro',
kpis: {
vacancyRatePct: 2.9,
activePlatformSearches: 22,
avgVacancyMonths: 2.8,
demandSupplyRatio: 3.4,
comparableListings: 2,
},
signals: [ signals: [
{ {
id: 'sig-012-1', id: 'sig-012-1',
@@ -172,6 +204,14 @@ export const mockPropertyMarketSignals: MarketReport[] = [
{ {
propertyId: 'prop-014', propertyId: 'prop-014',
generatedAt: '2025-05-18T08:00:00Z', generatedAt: '2025-05-18T08:00:00Z',
assetTypeLabel: 'Lager / Logistik',
kpis: {
vacancyRatePct: 5.5,
activePlatformSearches: 11,
avgVacancyMonths: 5.8,
demandSupplyRatio: 1.9,
comparableListings: 2,
},
signals: [ signals: [
{ {
id: 'sig-014-1', id: 'sig-014-1',
+2 -3
View File
@@ -8,7 +8,6 @@ import { Search, Send, Paperclip, ArrowLeft, Bot, Building2, Kanban } from 'luci
import { mockDemandInquiries } from '../../mock-data/demandInquiries' import { mockDemandInquiries } from '../../mock-data/demandInquiries'
import { useInquiryStore } from '../../stores/inquiryStore' import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline' import { usePipelineItems, useMoveStage } from '../../hooks/usePipeline'
import { useToastStore } from '../../stores/toastStore'
import type { InquiryMessage } from '../../domain/inquiry' import type { InquiryMessage } from '../../domain/inquiry'
import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection' import { STAGE_ORDER, STAGE_LABELS, detectKiStage } from './anfragenKiDetection'
import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble' import { AnfragenMessageBubble } from '../../components/demand/AnfragenMessageBubble'
@@ -32,7 +31,7 @@ export default function Anfragen() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const { data: pipelineItems = [] } = usePipelineItems() const { data: pipelineItems = [] } = usePipelineItems()
const { mutate: moveStage } = useMoveStage() const { mutate: moveStage } = useMoveStage()
const showToast = useToastStore(s => s.showToast)
const preselectedId = searchParams.get('inquiry') const preselectedId = searchParams.get('inquiry')
@@ -156,7 +155,7 @@ export default function Anfragen() {
<TextField <TextField
size="small" placeholder="Suchen..." fullWidth size="small" placeholder="Suchen..." fullWidth
value={search} onChange={e => setSearch(e.target.value)} value={search} onChange={e => setSearch(e.target.value)}
InputProps={{ startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> }} slotProps={{ input: { startAdornment: <InputAdornment position="start"><Search size={14} color={DS_TEXT.disabled} /></InputAdornment> } }}
sx={{ mb: 1.25 }} sx={{ mb: 1.25 }}
/> />
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
-1
View File
@@ -41,7 +41,6 @@ export default function Compare() {
overallWinnerIdx, overallWinnerIdx,
bestScoreIdx, bestScoreIdx,
worstConfIdx, worstConfIdx,
dqScores,
worstDQIdx, worstDQIdx,
missingCriticalCounts, missingCriticalCounts,
maxMissingCritical, maxMissingCritical,
+12 -61
View File
@@ -1,31 +1,19 @@
import { useState } from 'react'
import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog' import { InquiryQuickDialog } from '../../components/match-detail/InquiryQuickDialog'
import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material' import { Box, Button, Chip, CircularProgress, Paper, Typography } from '@mui/material'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react' import { AlertTriangle, ArrowLeft } from 'lucide-react'
import { useNavigate, useParams } from 'react-router' import { useNavigate, useParams } from 'react-router'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineStore } from '../../stores/pipelineStore'
import { useInquiryStore } from '../../stores/inquiryStore' import { useInquiryStore } from '../../stores/inquiryStore'
import { AddToPipelineDialog } from '../../components/shortlist' import { AddToPipelineDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList' import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { PropertyMap } from '../../components/shared'
import { getCityIntelligence } from '../../lib/locationIntelligence' import { getCityIntelligence } from '../../lib/locationIntelligence'
import { import { NextActionsPanel } from '../../components/match-detail'
LocationIntelligencePanel,
ExecutiveSummaryPanel,
NeedAlignmentPanel,
ScoreBreakdownPanel,
TradeoffPanel,
RiskPanel,
MissingInformationPanel,
FutureAvailabilityContextPanel,
NextActionsPanel,
} from '../../components/match-detail'
import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel' import type { MatchCardReason } from '../../components/match-card/MatchCardViewModel'
import { useMatchDetailData } from '../../hooks/useMatchDetailData' import { useMatchDetailData } from '../../hooks/useMatchDetailData'
import { useMatchDetail } from '../../hooks/useMatches' import { useMatchDetail } from '../../hooks/useMatches'
import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero' import { MatchDetailHero } from '../../components/match-detail/MatchDetailHero'
import { MatchDetailPropertySections } from '../../components/match-detail/MatchDetailPropertySections' import { MatchDetailScoreBreakdown } from '../../components/match-detail/MatchDetailScoreBreakdown'
import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds' import { RESULT_TYPE_META, DS_TEXT, DS_SURFACE, DS_BORDER, DS_BG } from '../../lib/ds'
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
@@ -49,7 +37,6 @@ export default function MatchDetail() {
const { addToCompare } = useCompareStore() const { addToCompare } = useCompareStore()
const { openSavedDialog } = usePipelineStore() const { openSavedDialog } = usePipelineStore()
const { openInquiryDialog } = useInquiryStore() const { openInquiryDialog } = useInquiryStore()
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '') const { match, property, need, signal, isLoading, isFuture } = useMatchDetailData(matchId ?? '')
@@ -91,7 +78,7 @@ export default function MatchDetail() {
const handleCompare = () => { const handleCompare = () => {
if (match && !isFuture && property) { if (match && !isFuture && property) {
addToCompare({ addToCompare({
resultType: property.resultType ?? 'VERIFIED_PORTFOLIO', resultType: (property.resultType ?? 'VERIFIED_PORTFOLIO') as 'VERIFIED_PORTFOLIO' | 'MAISON_WORK',
matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property, matchId: match.id, needId: match.needId, matchScore: match.matchScore, match, property,
}) })
} else if (match && isFuture && signal) { } else if (match && isFuture && signal) {
@@ -210,51 +197,15 @@ export default function MatchDetail() {
})} })}
/> />
{/* ── Full analysis toggle ── */} {/* ── Full analysis toggle + expanded panels ── */}
<Button <MatchDetailScoreBreakdown
variant="outlined" match={match}
onClick={() => setShowFullAnalysis(v => !v)} property={property}
fullWidth need={need}
endIcon={showFullAnalysis ? <ChevronUp size={15} /> : <ChevronDown size={15} />} signal={signal}
sx={{ isFuture={isFuture}
borderStyle: 'dashed', color: DS_TEXT.muted, borderColor: DS_BORDER.strong, taxCalculatorUrl={taxCalculatorUrl}
textTransform: 'none', fontWeight: 500,
'&:hover': { borderColor: DS_TEXT.muted, bgcolor: DS_BG.subtle },
}}
>
{showFullAnalysis ? 'Weniger anzeigen' : 'Vollständige Analyse anzeigen'}
</Button>
{/* ── Full analysis (hidden by default) ── */}
{showFullAnalysis && (
<>
<ExecutiveSummaryPanel match={match} />
<NeedAlignmentPanel match={match} need={need} property={property} />
{!isFuture && property && <MatchDetailPropertySections property={property} match={match} />}
<LocationIntelligencePanel property={property} />
{!isFuture && property?.images?.[0] && property?.location?.coordinates && (
<Paper sx={{ overflow: 'hidden', p: 0 }}>
<Box sx={{ px: 2.5, pt: 2, pb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>Standort</Typography>
<Typography variant="caption" color="text.secondary">
{property.address.street} {property.address.houseNumber}, {property.address.postalCode} {property.address.city}
</Typography>
</Box>
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={220}
/> />
</Paper>
)}
<TradeoffPanel match={match} />
<RiskPanel match={match} />
<MissingInformationPanel match={match} />
{isFuture && <FutureAvailabilityContextPanel match={match} signal={signal} />}
<ScoreBreakdownPanel match={match} taxCalculatorUrl={taxCalculatorUrl} isFuture={isFuture} signal={signal} />
</>
)}
</Box> </Box>
</Box> </Box>
) )
+361 -179
View File
@@ -1,231 +1,413 @@
import { useState } from 'react' import { memo, useMemo, useState } from 'react'
import { import {
Alert,
Box, Box,
Card, Card,
Chip, Chip,
Drawer,
IconButton,
LinearProgress, LinearProgress,
MenuItem,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { ErrorState, LoadingPage, SectionContainer } from '../../components/ui' import { AlertTriangle, CheckCircle2, Clock, Edit2, RefreshCw } from 'lucide-react'
import { LoadingPage, ErrorState } from '../../components/ui'
import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality' import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality'
import { PropertyDetailView } from '../../components/supply'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { getRecommendedActions } from '../../services/dataQualityService' import { getRecommendedActions } from '../../services/dataQualityService'
import { DataFreshness } from '../../domain/enums' import { DataFreshness } from '../../domain/enums'
import type { Property } from '../../domain/property'
type QualityFilter = '' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INCOMPLETE' // ── Types ────────────────────────────────────────────────────────────────────
function getQualityColor(score: number): 'success' | 'warning' | 'error' { type QualityFilter = 'ALL' | 'INCOMPLETE' | 'STALE' | 'LOW' | 'MEDIUM' | 'HIGH'
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning' // ── Helpers ──────────────────────────────────────────────────────────────────
return 'error'
const FIELD_LABEL: Record<string, string> = {
'Mietpreis/m²': 'Mietpreis',
'Fläche m²': 'Fläche',
'Verfügbarkeit': 'Verfügbarkeit',
'Adresse': 'Adresse',
'Beschreibung': 'Beschreibung',
'Bilder': 'Bilder',
'Ausbaustandard': 'Ausbaustandard',
'Soft Factors': 'Soft Factors',
'Jahresmiete (CHF)': 'Jahresmiete',
'Expansionspotenzial': 'Erweiterung',
} }
function priorityOf(p: Property): number {
const hasCritical = p.dataQuality.missingCriticalFields.length > 0
const isStale = p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
if (hasCritical && isStale) return 0
if (hasCritical) return 1
if (isStale) return 2
if (p.dataQuality.score < 0.6) return 3
if (p.dataQuality.score < 0.8) return 4
return 5
}
// ── Row ──────────────────────────────────────────────────────────────────────
const PropertyRow = memo(function PropertyRow({
property,
onEdit,
}: {
property: Property
onEdit: (id: string) => void
}) {
const q = property.dataQuality
const hasCritical = q.missingCriticalFields.length > 0
const isStale = q.freshness === DataFreshness.STALE || q.freshness === DataFreshness.OUTDATED
const topAction = getRecommendedActions(q, q.freshness)[0] ?? null
const visibleFields = q.missingCriticalFields.slice(0, 3)
const overflow = q.missingCriticalFields.length - visibleFields.length
return (
<Box
onClick={() => onEdit(property.id)}
sx={{
display: 'grid',
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
alignItems: 'center',
px: 2, py: 1.25,
borderBottom: '1px solid #f1f5f9',
cursor: 'pointer',
bgcolor: hasCritical ? '#fff8f8' : 'white',
'&:hover': { bgcolor: hasCritical ? '#fff0f0' : '#f8fafc' },
transition: 'background 0.1s',
gap: 1,
}}
>
{/* Objekt */}
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', lineHeight: 1.3 }}>
{property.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
{property.location.city}
</Typography>
</Box>
{/* Score */}
<Box>
<DataQualityBadge quality={q} showLabel />
</Box>
{/* Fehlende Pflichtfelder */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.4 }}>
{hasCritical ? (
<>
{visibleFields.map(f => (
<Chip
key={f}
label={FIELD_LABEL[f] ?? f}
size="small"
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626', fontWeight: 600 }}
/>
))}
{overflow > 0 && (
<Chip
label={`+${overflow}`}
size="small"
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#fee2e2', color: '#dc2626' }}
/>
)}
</>
) : (
<Chip
icon={<CheckCircle2 size={11} />}
label="Vollständig"
size="small"
sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#dcfce7', color: '#16a34a' }}
/>
)}
</Box>
{/* Aktualität */}
<Box>
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
</Box>
{/* Empfehlung */}
<Box>
{topAction ? (
<Tooltip title={topAction.detail}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{topAction.priority === 'HIGH'
? <AlertTriangle size={12} color="#dc2626" />
: isStale
? <Clock size={12} color="#d97706" />
: <RefreshCw size={12} color="#64748b" />
}
<Typography
variant="caption"
sx={{
color: topAction.priority === 'HIGH' ? '#dc2626' : '#d97706',
fontWeight: 600,
fontSize: '0.68rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{topAction.label}
</Typography>
</Box>
</Tooltip>
) : (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem' }}></Typography>
)}
</Box>
{/* Edit button */}
<Box onClick={e => e.stopPropagation()}>
<Tooltip title="Objekt bearbeiten">
<IconButton
size="small"
onClick={() => onEdit(property.id)}
sx={{ color: '#94a3b8', '&:hover': { color: '#1e3a5f' } }}
>
<Edit2 size={14} />
</IconButton>
</Tooltip>
</Box>
</Box>
)
})
// ── KPI Card ─────────────────────────────────────────────────────────────────
function KpiCard({
label, value, sub, color, active, onClick,
}: {
label: string
value: string | number
sub?: string
color: string
active: boolean
onClick: () => void
}) {
return (
<Card
onClick={onClick}
sx={{
p: 2.5,
cursor: 'pointer',
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
bgcolor: active ? `${color}08` : 'white',
transition: 'all 0.15s',
'&:hover': { borderColor: color, bgcolor: `${color}08` },
}}
>
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
{label}
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color, lineHeight: 1.2, my: 0.5 }}>
{value}
</Typography>
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
</Card>
)
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function DataQuality() { export default function DataQuality() {
const [qualityFilter, setQualityFilter] = useState<QualityFilter>('') const [filter, setFilter] = useState<QualityFilter>('ALL')
const [detailId, setDetailId] = useState<string | null>(null)
const { data: properties = [], isLoading, error } = useProperties() const { data: properties = [], isLoading, error } = useProperties()
if (isLoading) return <LoadingPage /> if (isLoading) return <LoadingPage />
if (error) return <ErrorState /> if (error) return <ErrorState />
const avgScore = properties.length const total = properties.length
? properties.reduce((sum, p) => sum + p.dataQuality.score, 0) / properties.length
const avgScore = total
? properties.reduce((s, p) => s + p.dataQuality.score, 0) / total
: 0 : 0
const criticalIssues = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0) const incomplete = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0)
const staleData = properties.filter( const stale = properties.filter(
p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED, p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED,
) )
const highQuality = properties.filter(p => p.dataQuality.score >= 0.8) const high = properties.filter(p => p.dataQuality.score >= 0.8)
const medQuality = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8) const medium = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8)
const lowQuality = properties.filter(p => p.dataQuality.score < 0.6) const low = properties.filter(p => p.dataQuality.score < 0.6)
const filtered = [...properties] const filtered = useMemo(() => {
.filter(p => { const base = [...properties]
if (!qualityFilter) return true const result = base.filter(p => {
if (qualityFilter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0 if (filter === 'ALL') return true
if (qualityFilter === 'HIGH') return p.dataQuality.score >= 0.8 && p.dataQuality.missingCriticalFields.length === 0 if (filter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0
if (qualityFilter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8 if (filter === 'STALE') return p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED
if (qualityFilter === 'LOW') return p.dataQuality.score < 0.6 if (filter === 'HIGH') return p.dataQuality.score >= 0.8
if (filter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8
if (filter === 'LOW') return p.dataQuality.score < 0.6
return true return true
}) })
.sort((a, b) => a.dataQuality.score - b.dataQuality.score) return result.sort((a, b) => priorityOf(a) - priorityOf(b))
}, [properties, filter])
const scoreColor = avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#dc2626'
function toggleFilter(f: QualityFilter) {
setFilter(prev => prev === f ? 'ALL' : f)
}
return ( return (
<Box> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Page Header */} {/* Header */}
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid', borderColor: 'divider', px: 3, py: 2 }}> <Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">Datenpflege</Typography> <Typography variant="h6" sx={{ fontWeight: 700 }}>Datenpflege</Typography>
<Typography variant="body2" color="text.secondary">Vollständigkeit, Aktualität und Vertrauen der Objektdaten</Typography> <Typography variant="caption" color="text.secondary">
Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste
</Typography>
</Box> </Box>
<Box sx={{ px: 3, py: 3 }} className="flex flex-col gap-4"> <Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Summary Stats */} {/* KPI cards */}
<Box className="grid grid-cols-3 gap-4"> <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2 }}>
<Card sx={{ p: 2.5 }}> <KpiCard
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Ø Qualitätsscore</Typography> label=Qualitätsscore"
<Typography variant="h4" sx={{ fontWeight: 700, color: avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#c0392b' }}> value={`${Math.round(avgScore * 100)}%`}
{Math.round(avgScore * 100)}% color={scoreColor}
</Typography> active={filter === 'ALL'}
<LinearProgress variant="determinate" value={avgScore * 100} color={getQualityColor(avgScore)} onClick={() => setFilter('ALL')}
sx={{ height: 8, borderRadius: 4, mt: 1 }} /> sub={`${total} Objekte insgesamt`}
</Card> />
<KpiCard
<Card sx={{ p: 2.5 }}> label="Pflichtfelder fehlen"
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Pflichtfelder fehlen</Typography> value={incomplete.length}
<Typography variant="h4" sx={{ fontWeight: 700, color: criticalIssues.length > 2 ? '#c0392b' : criticalIssues.length > 0 ? '#d97706' : '#1a7a4a' }}> sub={`von ${total} Objekten — klicken zum Filtern`}
{criticalIssues.length} color="#dc2626"
</Typography> active={filter === 'INCOMPLETE'}
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography> onClick={() => toggleFilter('INCOMPLETE')}
</Card> />
<KpiCard
<Card sx={{ p: 2.5 }}> label="Veraltete Daten"
<Typography variant="overline" color="text.secondary" sx={{ display: 'block' }}>Veraltete Daten</Typography> value={stale.length}
<Typography variant="h4" sx={{ fontWeight: 700, color: staleData.length > 2 ? '#c0392b' : staleData.length > 0 ? '#d97706' : '#1a7a4a' }}> sub={`von ${total} Objekten — klicken zum Filtern`}
{staleData.length} color="#d97706"
</Typography> active={filter === 'STALE'}
<Typography variant="caption" color="text.secondary">von {properties.length} Objekten</Typography> onClick={() => toggleFilter('STALE')}
</Card>
</Box>
{/* Quality Distribution */}
<SectionContainer title="Qualitätsverteilung">
<Card sx={{ p: 2.5 }}>
<Box className="flex flex-col gap-3">
{[
{ label: 'Hoch (≥80%)', count: highQuality.length, color: 'success' as const },
{ label: 'Mittel (6079%)', count: medQuality.length, color: 'warning' as const },
{ label: 'Niedrig (<60%)', count: lowQuality.length, color: 'error' as const },
].map(row => (
<Box key={row.label} className="flex items-center gap-3">
<Typography variant="body2" sx={{ width: 120, flexShrink: 0 }}>{row.label}</Typography>
<Chip label={row.count} size="small" color={row.color} />
<Box className="flex-1">
<LinearProgress
variant="determinate"
value={properties.length > 0 ? (row.count / properties.length) * 100 : 0}
color={row.color}
sx={{ height: 10, borderRadius: 5 }}
/> />
</Box> </Box>
<Typography variant="caption" color="text.secondary" sx={{ width: 40, textAlign: 'right' }}>
{properties.length > 0 ? Math.round((row.count / properties.length) * 100) : 0}% {/* Quality distribution */}
<Card sx={{ p: 2.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>Qualitätsverteilung</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{[
{ label: 'Hoch (≥80%)', count: high.length, color: '#16a34a', f: 'HIGH' as QualityFilter },
{ label: 'Mittel (6079%)', count: medium.length, color: '#d97706', f: 'MEDIUM' as QualityFilter },
{ label: 'Niedrig (<60%)', count: low.length, color: '#dc2626', f: 'LOW' as QualityFilter },
].map(row => (
<Box
key={row.label}
onClick={() => toggleFilter(row.f)}
sx={{
display: 'flex', alignItems: 'center', gap: 2, cursor: 'pointer',
p: 0.75, borderRadius: 1,
bgcolor: filter === row.f ? `${row.color}10` : 'transparent',
'&:hover': { bgcolor: `${row.color}08` },
transition: 'background 0.1s',
}}
>
<Typography variant="body2" sx={{ width: 130, flexShrink: 0, fontWeight: filter === row.f ? 700 : 400 }}>
{row.label}
</Typography>
<Chip
label={row.count}
size="small"
sx={{ height: 20, fontSize: '0.7rem', fontWeight: 700, bgcolor: `${row.color}18`, color: row.color, flexShrink: 0 }}
/>
<Box sx={{ flex: 1 }}>
<LinearProgress
variant="determinate"
value={total > 0 ? (row.count / total) * 100 : 0}
sx={{
height: 10, borderRadius: 5,
bgcolor: `${row.color}18`,
'& .MuiLinearProgress-bar': { bgcolor: row.color, borderRadius: 5 },
}}
/>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', width: 36, textAlign: 'right', flexShrink: 0 }}>
{total > 0 ? Math.round((row.count / total) * 100) : 0}%
</Typography> </Typography>
</Box> </Box>
))} ))}
</Box> </Box>
</Card> </Card>
</SectionContainer>
{/* Objects Table */} {/* Object list */}
<SectionContainer title="Objektübersicht">
{/* Filter bar */}
<Box sx={{ display: 'flex', gap: 1.5, mb: 1.5, alignItems: 'center' }}>
<Select
size="small"
value={qualityFilter}
onChange={e => setQualityFilter(e.target.value as QualityFilter)}
displayEmpty
sx={{ fontSize: '0.8125rem', minWidth: 180 }}
>
<MenuItem value="">Alle Qualitätsstufen</MenuItem>
<MenuItem value="HIGH">Hoch (80%)</MenuItem>
<MenuItem value="MEDIUM">Mittel (6079%)</MenuItem>
<MenuItem value="LOW">Niedrig ({'<'}60%)</MenuItem>
<MenuItem value="INCOMPLETE">Pflichtfelder fehlen</MenuItem>
</Select>
<Typography variant="caption" color="text.secondary">
{filtered.length} von {properties.length} Objekten
</Typography>
</Box>
<Card>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Qualität</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktualität</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Pflichtfelder</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Nächste Massnahme</Typography></TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.map(property => {
const q = property.dataQuality
const hasCritical = q.missingCriticalFields.length > 0
const actions = getRecommendedActions(q, q.freshness)
const topAction = actions[0] ?? null
return (
<TableRow
key={property.id}
hover
sx={hasCritical ? { bgcolor: 'rgba(192,57,43,0.03)' } : {}}
>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.25 }}>{property.title}</Typography>
<Typography variant="caption" color="text.secondary">{property.location.city}</Typography>
</TableCell>
<TableCell>
<DataQualityBadge quality={q} showLabel />
</TableCell>
<TableCell>
<FreshnessIndicator freshness={q.freshness} lastUpdated={property.sourceUpdatedAt} />
</TableCell>
<TableCell>
{q.missingCriticalFields.length === 0 ? (
<Chip label="Vollständig" color="success" size="small" />
) : (
<Tooltip
title={q.missingCriticalFields.join(', ')}
arrow
>
<Chip
label={`${q.missingCriticalFields.length} fehlen`}
color="error"
size="small"
variant="outlined"
/>
</Tooltip>
)}
</TableCell>
<TableCell>
{topAction ? (
<Box> <Box>
<Typography variant="caption" sx={{ fontWeight: 600, color: topAction.priority === 'HIGH' ? '#c0392b' : '#d97706' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{topAction.label} <Typography variant="subtitle2" sx={{ fontWeight: 700, flex: 1 }}>Objektübersicht</Typography>
<Typography variant="caption" color="text.secondary">
{filtered.length} von {total} Objekten · sortiert nach Handlungsbedarf
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}> {filter !== 'ALL' && (
{topAction.detail} <Chip
</Typography> label={`Filter: ${filter}`}
</Box> size="small"
) : ( onDelete={() => setFilter('ALL')}
<Typography variant="caption" color="text.secondary"></Typography> sx={{ height: 22, fontSize: '0.68rem' }}
/>
)} )}
</TableCell> </Box>
</TableRow>
) {filtered.length > 0 ? (
})} <Card sx={{ overflow: 'hidden' }}>
</TableBody> {/* Table header */}
</Table> <Box
sx={{
display: 'grid',
gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px',
px: 2, py: 1,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
gap: 1,
}}
>
{['Objekt', 'Qualität', 'Fehlende Pflichtfelder', 'Aktualität', 'Empfehlung', ''].map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 700, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
{h}
</Typography>
))}
</Box>
{filtered.map(p => (
<PropertyRow key={p.id} property={p} onEdit={setDetailId} />
))}
</Card> </Card>
</SectionContainer> ) : (
<Alert severity="success" sx={{ mt: 1 }}>
Keine Objekte in dieser Kategorie alles in Ordnung!
</Alert>
)}
</Box>
</Box> </Box>
{/* Edit drawer */}
<Drawer
anchor="right"
open={!!detailId}
onClose={() => setDetailId(null)}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
>
{detailId && (
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} />
)}
</Drawer>
</Box> </Box>
) )
} }
File diff suppressed because it is too large Load Diff
-296
View File
@@ -1,296 +0,0 @@
import {
Box,
Chip,
Divider,
Paper,
Skeleton,
Typography,
} from '@mui/material'
import {
AlertCircle,
BarChart2,
Briefcase,
Building2,
CheckCircle2,
FileText,
Newspaper,
Target,
TrendingUp,
Users,
} from 'lucide-react'
import { useNavigate } from 'react-router'
import { useMarketLeads } from '../../hooks/useMarketLeads'
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
import { DecisionContextPanel } from '../../components/ui'
import type { MarketLead } from '../../hooks/useMarketLeads'
import type { Property } from '../../domain/property'
// ── Source meta ──────────────────────────────────────────────────────────────
const SOURCE_META: Record<string, { label: string; icon: React.ReactNode }> = {
JOB_POSTING: { label: 'Stelleninserate', icon: <Briefcase size={12} /> },
PRESS: { label: 'Pressebericht', icon: <Newspaper size={12} /> },
COMPANY_REPORT: { label: 'Geschäftsbericht',icon: <FileText size={12} /> },
MARKET_DATA: { label: 'Marktdaten', icon: <BarChart2 size={12} /> },
}
const QUALITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hohe Signalqualität', color: DS_TEXT.success },
MEDIUM: { label: 'Mittlere Signalqualität', color: DS_TEXT.warning },
LOW: { label: 'Niedrige Signalqualität', color: DS_TEXT.error },
}
function signalQuality(probability: number): 'HIGH' | 'MEDIUM' | 'LOW' {
return probability >= 0.70 ? 'HIGH' : probability >= 0.50 ? 'MEDIUM' : 'LOW'
}
// ── Property match chip ───────────────────────────────────────────────────────
function PropertyMatchRow({ p }: { p: Property }) {
const navigate = useNavigate()
return (
<Box
onClick={() => navigate('/supply/properties')}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.25, py: 0.75, borderRadius: 1,
bgcolor: DS_SURFACE.success.bg, border: `1px solid ${DS_SURFACE.success.border}`,
cursor: 'pointer', '&:hover': { bgcolor: DS_SURFACE.success.bg },
}}
>
<Building2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.success, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.title}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.success, flexShrink: 0 }}>
{p.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
)
}
// ── Lead card ─────────────────────────────────────────────────────────────────
function LeadCard({ lead }: { lead: MarketLead }) {
const { signal, matchingProperties } = lead
const probPct = Math.round(signal.probability * 100)
const qual = signalQuality(signal.probability)
const qualMeta = QUALITY_META[qual]
const sourceMeta = SOURCE_META[signal.source.type] ?? null
return (
<Paper
sx={{
p: 2.5, mb: 2,
borderLeft: `3px solid ${DS_MARKET_SIGNAL.accent}`,
position: 'relative',
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.25 }}>
<Box sx={{ mt: 0.25, flexShrink: 0 }}>
<Target size={18} color={DS_MARKET_SIGNAL.accent} />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25, mb: 0.25 }}>
{signal.companyName ?? signal.locationHint}
</Typography>
<Typography variant="body2" color="text.secondary">
{signal.title}
</Typography>
</Box>
</Box>
<Chip
label="Market Signal"
size="small"
sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700, fontSize: '0.68rem', flexShrink: 0 }}
/>
</Box>
{/* Key facts strip */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1.75 }}>
{signal.areaSqmEstimate && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Flächenbedarf</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
</Box>
)}
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Zeithorizont</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>~{signal.timeHorizonMonths} Monate</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Wahrscheinlichkeit</Typography>
<Typography variant="body2" sx={{ fontWeight: 700, color: qualMeta.color }}>{probPct}%</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Standort</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{signal.locationHint}</Typography>
</Box>
{sourceMeta && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>Quelle</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{sourceMeta.icon}
<Typography variant="body2" sx={{ fontWeight: 600 }}>{sourceMeta.label}</Typography>
</Box>
</Box>
)}
</Box>
<Divider sx={{ mb: 1.75 }} />
{/* Market indicators */}
{signal.marketIndicators && signal.marketIndicators.length > 0 && (
<Box sx={{ mb: 1.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Erkannte Nachfragesignale
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{signal.marketIndicators.map((ind, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<TrendingUp size={11} color={DS_MARKET_SIGNAL.accent} style={{ marginTop: 3, flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.4 }}>{ind}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Confirmed / unconfirmed facts */}
{((signal.confirmedFacts?.length ?? 0) > 0 || (signal.unconfirmedFacts?.length ?? 0) > 0) && (
<Box sx={{ mb: 1.75 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
{(signal.confirmedFacts ?? []).map((f, i) => (
<Box key={`c-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<CheckCircle2 size={12} color={DS_TEXT.success} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.success, fontWeight: 500 }}>{f}</Typography>
</Box>
))}
{(signal.unconfirmedFacts ?? []).map((f, i) => (
<Box key={`u-${i}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<AlertCircle size={12} color={DS_TEXT.warning} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.warning, fontWeight: 500 }}>{f}</Typography>
</Box>
))}
</Box>
</Box>
)}
<Divider sx={{ mb: 1.5 }} />
{/* Portfolio matches */}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Passende Objekte im Portfolio ({matchingProperties.length})
</Typography>
{matchingProperties.length === 0 ? (
<Typography variant="caption" color="text.disabled" sx={{ fontStyle: 'italic' }}>
Kein passendes Portfolioobjekt gefunden manuell prüfen empfohlen
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{matchingProperties.slice(0, 3).map(p => (
<PropertyMatchRow key={p.id} p={p} />
))}
{matchingProperties.length > 3 && (
<Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}>
+{matchingProperties.length - 3} weitere Objekte
</Typography>
)}
</Box>
)}
</Box>
{/* Disclaimer */}
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BG.subtle}`, lineHeight: 1.4 }}>
{signal.disclaimer}
</Typography>
</Paper>
)
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
function LeadSkeleton() {
return (
<Paper sx={{ p: 2.5, mb: 2, borderLeft: `3px solid ${DS_BORDER.default}` }}>
<Skeleton variant="text" width="60%" height={24} sx={{ mb: 0.5 }} />
<Skeleton variant="text" width="80%" height={18} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" height={48} sx={{ borderRadius: 1, mb: 2 }} />
<Skeleton variant="rectangular" height={64} sx={{ borderRadius: 1 }} />
</Paper>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function MarketLeads() {
const { data: leads, isLoading } = useMarketLeads()
const leadsWithMatch = leads.filter(l => l.matchingProperties.length > 0)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<Box sx={{ px: 3, py: 2, bgcolor: 'white', borderBottom: `1px solid ${DS_BORDER.default}`, flexShrink: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Users size={20} color={DS_MARKET_SIGNAL.accent} />
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Markt-Leads</Typography>
<Typography variant="caption" color="text.secondary">
Erkannte Unternehmen mit aktivem Flächenbedarf potenzielle Mieter für Ihr Portfolio
</Typography>
</Box>
{!isLoading && (
<Chip
label={`${leads.length} aktive Leads`}
size="small"
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, fontWeight: 700 }}
/>
)}
</Box>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', px: 3, py: 3 }}>
{/* Decision context */}
{!isLoading && leads.length > 0 && (
<DecisionContextPanel
decision="Welche Unternehmen könnten als Nachmieter für Ihre Objekte in Frage kommen?"
context={`${leads.length} erkannte Nachfragesignale · ${leadsWithMatch.length} mit passendem Portfolioobjekt`}
metrics={[
{ label: 'aktive Markt-Leads', value: leads.length, severity: 'positive' },
...(leadsWithMatch.length > 0
? [{ label: 'mit passendem Objekt', value: leadsWithMatch.length, severity: 'positive' as const }]
: []),
]}
risks={leads.length - leadsWithMatch.length > 0
? [`${leads.length - leadsWithMatch.length} Leads ohne direkten Portfolio-Match — Leerstandsanalyse empfohlen`]
: []
}
actions={[]}
/>
)}
{isLoading ? (
<>
<LeadSkeleton />
<LeadSkeleton />
<LeadSkeleton />
</>
) : leads.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Target size={32} color={DS_TEXT.disabled} style={{ marginBottom: 12 }} />
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>Keine aktiven Markt-Leads gefunden.</Typography>
<Typography variant="caption" color="text.disabled">Signale werden laufend aus öffentlichen Quellen erkannt.</Typography>
</Paper>
) : (
leads.map(lead => <LeadCard key={lead.signal.id} lead={lead} />)
)}
</Box>
</Box>
)
}
+337 -89
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { memo, useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { import {
Alert, Alert,
@@ -10,14 +10,21 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Drawer,
IconButton, IconButton,
Switch, Switch,
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { Plus, Trash2 } from 'lucide-react' import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties' import { useProperties, useUpdateProperty, useRemoveProperty } from '../../hooks/useProperties'
import { useMatchesByProperty } from '../../hooks/useMatches'
import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { PropertyDetailView } from '../../components/supply'
import { PublicNeedDetail, OfferWizard } from '../../components/anfragencenter'
import { NEED_TO_LATENT_NEED } from '../../lib/needToLatentNeedMap'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import type { Match } from '../../domain/match'
const ASSET_LABELS: Record<string, string> = { const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', OFFICE: 'Büro',
@@ -28,11 +35,276 @@ const ASSET_LABELS: Record<string, string> = {
MIXED: 'Gemischt', MIXED: 'Gemischt',
} }
const GRID_COLS = '2fr 1fr 80px 100px 110px 120px 80px 48px'
function formatDate(iso?: string) { function formatDate(iso?: string) {
if (!iso) return '' if (!iso) return ''
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }) return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
} }
function matchColor(score: number): string {
if (score >= 80) return '#1a7a4a'
if (score >= 60) return '#d97706'
return '#dc2626'
}
// ─── Suchabo dialog ────────────────────────────────────────────────────────
function MatchListItem({
match,
onSelect,
}: {
match: Match
onSelect: (needId: string) => void
}) {
const latentNeedId = NEED_TO_LATENT_NEED[match.needId] ?? null
const { data: latentNeed } = useLatentNeedById(latentNeedId)
const companyName = latentNeed?.tenantCompany ?? match.needId
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2, py: 1.5,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: 'white',
gap: 1,
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{companyName}
</Typography>
{latentNeed && (
<Typography variant="caption" sx={{ color: '#64748b' }}>
{latentNeed.desiredLocation}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Chip
label={`${match.matchScore}%`}
size="small"
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 700,
bgcolor: `${matchColor(match.matchScore)}18`,
color: matchColor(match.matchScore),
}}
/>
{latentNeedId ? (
<Button
size="small"
variant="contained"
onClick={() => onSelect(match.needId)}
sx={{
textTransform: 'none',
fontSize: '0.75rem',
bgcolor: '#1e3a5f',
'&:hover': { bgcolor: '#162d4a' },
}}
>
Anschreiben
</Button>
) : (
<Tooltip title="Kein öffentliches Suchabo verknüpft">
<span>
<Button size="small" disabled sx={{ textTransform: 'none', fontSize: '0.75rem' }}>
Anschreiben
</Button>
</span>
</Tooltip>
)}
</Box>
</Box>
)
}
interface SuchaboDialogProps {
propertyId: string
matches: Match[]
onClose: () => void
}
function SuchaboDialogContent({ matches }: SuchaboDialogProps) {
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(
matches.length === 1 ? matches[0].needId : null,
)
const latentNeedId = selectedNeedId ? (NEED_TO_LATENT_NEED[selectedNeedId] ?? null) : null
const { data: latentNeed, isLoading } = useLatentNeedById(latentNeedId)
const showList = !selectedNeedId
if (showList) {
return (
<Box sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
{matches.map(m => (
<MatchListItem key={m.id} match={m} onSelect={setSelectedNeedId} />
))}
</Box>
)
}
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={28} />
</Box>
)
}
if (!latentNeed) {
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">Suchabo nicht gefunden.</Typography>
</Box>
)
}
return (
<Box>
{matches.length > 1 && (
<Box sx={{ px: 2, pt: 1.5 }}>
<Button
startIcon={<ArrowLeft size={14} />}
size="small"
onClick={() => setSelectedNeedId(null)}
sx={{ textTransform: 'none', color: '#64748b', fontWeight: 500 }}
>
Alle Suchabos
</Button>
</Box>
)}
<PublicNeedDetail need={latentNeed} />
</Box>
)
}
// ─── Row ───────────────────────────────────────────────────────────────────
interface RowProps {
p: Property
isLast: boolean
onDetail: (id: string) => void
onToggleStatus: (p: Property) => void
onDelete: (p: Property) => void
onChipClick: (propertyId: string, matches: Match[]) => void
updatePending: boolean
removePending: boolean
}
const ListingRow = memo(function ListingRow({
p, isLast, onDetail, onToggleStatus, onDelete, onChipClick, updatePending, removePending,
}: RowProps) {
const { data: matches = [] } = useMatchesByProperty(p.id)
const topScore = matches.length > 0 ? Math.max(...matches.map(m => m.matchScore)) : 0
return (
<Box
onClick={() => onDetail(p.id)}
sx={{
display: 'grid',
gridTemplateColumns: GRID_COLS,
px: 2, py: 1.25,
alignItems: 'center',
borderBottom: isLast ? 'none' : '1px solid #f1f5f9',
cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' },
transition: 'background 0.1s',
}}
>
{/* Title + type */}
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a', lineHeight: 1.3 }}>
{p.title}
</Typography>
<Chip
label={ASSET_LABELS[p.assetType] ?? p.assetType}
size="small"
sx={{ height: 16, fontSize: '0.6rem', mt: 0.25, bgcolor: '#f1f5f9', color: '#475569' }}
/>
</Box>
{/* City */}
<Typography variant="body2" sx={{ color: '#374151' }}>
{p.location.city}
</Typography>
{/* Area */}
<Typography variant="body2" sx={{ color: '#374151' }}>
{p.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{/* Rent */}
<Typography variant="body2" sx={{ color: '#374151' }}>
CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
</Typography>
{/* Match count */}
<Box onClick={e => e.stopPropagation()}>
{matches.length > 0 ? (
<Tooltip title="Suchabos anzeigen und anschreiben">
<Chip
label={`${matches.length} Suchabo${matches.length !== 1 ? 's' : ''}`}
size="small"
onClick={() => onChipClick(p.id, matches)}
sx={{
height: 20,
fontSize: '0.65rem',
fontWeight: 600,
bgcolor: `${matchColor(topScore)}18`,
color: matchColor(topScore),
cursor: 'pointer',
'&:hover': { opacity: 0.85 },
}}
/>
</Tooltip>
) : (
<Typography variant="caption" sx={{ color: '#cbd5e1' }}></Typography>
)}
</Box>
{/* Created */}
<Typography variant="caption" sx={{ color: '#64748b' }}>
{formatDate(p.createdAt)}
</Typography>
{/* Status toggle */}
<Box onClick={e => e.stopPropagation()}>
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
<Switch
size="small"
checked={p.status === 'ACTIVE'}
onChange={() => onToggleStatus(p)}
disabled={updatePending}
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
/>
</Tooltip>
</Box>
{/* Delete */}
<Box onClick={e => e.stopPropagation()}>
<Tooltip title="Inserat löschen">
<IconButton
size="small"
onClick={() => onDelete(p)}
disabled={removePending}
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
>
<Trash2 size={15} />
</IconButton>
</Tooltip>
</Box>
</Box>
)
})
// ─── Page ─────────────────────────────────────────────────────────────────
export default function MyListings() { export default function MyListings() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -40,26 +312,25 @@ export default function MyListings() {
const updateProperty = useUpdateProperty() const updateProperty = useUpdateProperty()
const removeProperty = useRemoveProperty() const removeProperty = useRemoveProperty()
const [detailId, setDetailId] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Property | null>(null) const [confirmDelete, setConfirmDelete] = useState<Property | null>(null)
const [actionError, setActionError] = useState<string | null>(null) const [actionError, setActionError] = useState<string | null>(null)
const [suchaboDialog, setSuchaboDialog] = useState<{
propertyId: string
matches: Match[]
} | null>(null)
function handleToggleStatus(p: Property) { function handleToggleStatus(p: Property) {
const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
updateProperty.mutate( updateProperty.mutate(
{ id: p.id, input: { status: next } }, { id: p.id, input: { status: next } },
{ { onError: () => setActionError('Status konnte nicht geändert werden.') },
onError: () => {
setActionError('Status konnte nicht geändert werden.')
},
},
) )
} }
function handleDelete(p: Property) { function handleDelete(p: Property) {
removeProperty.mutate(p.id, { removeProperty.mutate(p.id, {
onSuccess: () => { onSuccess: () => setConfirmDelete(null),
setConfirmDelete(null)
},
onError: () => { onError: () => {
setActionError('Inserat konnte nicht gelöscht werden.') setActionError('Inserat konnte nicht gelöscht werden.')
setConfirmDelete(null) setConfirmDelete(null)
@@ -68,10 +339,10 @@ export default function MyListings() {
} }
return ( return (
<Box sx={{ maxWidth: 960, mx: 'auto', px: 3, py: 4 }}> <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', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>Meine Inserate</Typography> <Typography variant="h5" sx={{ fontWeight: 700 }}>Inserate</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Direkt erstellte Inserate unabhängig vom Portfolio Direkt erstellte Inserate unabhängig vom Portfolio
</Typography> </Typography>
@@ -116,98 +387,72 @@ export default function MyListings() {
{/* Header */} {/* Header */}
<Box sx={{ <Box sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px', gridTemplateColumns: GRID_COLS,
px: 2, py: 1, px: 2, py: 1,
bgcolor: '#f8fafc', bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0', borderBottom: '1px solid #e2e8f0',
}}> }}>
{['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Erstellt', 'Aktiv', ''].map(h => ( {['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Suchabos', 'Erstellt', 'Aktiv', ''].map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}> <Typography key={h} variant="caption" sx={{ fontWeight: 600, color: '#64748b', fontSize: '0.65rem', textTransform: 'uppercase' }}>
{h} {h}
</Typography> </Typography>
))} ))}
</Box> </Box>
{listings.map((p, i) => { {listings.map((p, i) => (
const isLast = i === listings.length - 1 <ListingRow
return (
<Box
key={p.id} key={p.id}
sx={{ p={p}
display: 'grid', isLast={i === listings.length - 1}
gridTemplateColumns: '2fr 1fr 80px 100px 120px 80px 48px', onDetail={setDetailId}
px: 2, py: 1.25, onToggleStatus={handleToggleStatus}
alignItems: 'center', onDelete={setConfirmDelete}
borderBottom: isLast ? 'none' : '1px solid #f1f5f9', onChipClick={(pid, m) => setSuchaboDialog({ propertyId: pid, matches: m })}
'&:hover': { bgcolor: '#f8fafc' }, updatePending={updateProperty.isPending}
transition: 'background 0.1s', removePending={removeProperty.isPending}
}}
>
{/* Title + type */}
<Box>
<Typography variant="body2" sx={{ fontWeight: 500, color: '#0f172a', lineHeight: 1.3 }}>
{p.title}
</Typography>
<Chip
label={ASSET_LABELS[p.assetType] ?? p.assetType}
size="small"
sx={{ height: 16, fontSize: '0.6rem', mt: 0.25, bgcolor: '#f1f5f9', color: '#475569' }}
/> />
</Box> ))}
{/* City */}
<Typography variant="body2" sx={{ color: '#374151' }}>
{p.location.city}
</Typography>
{/* Area */}
<Typography variant="body2" sx={{ color: '#374151' }}>
{p.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{/* Rent */}
<Typography variant="body2" sx={{ color: '#374151' }}>
CHF {p.rentPricePerSqm.toLocaleString('de-CH')}
</Typography>
{/* Created */}
<Typography variant="caption" sx={{ color: '#64748b' }}>
{formatDate(p.createdAt)}
</Typography>
{/* Status toggle */}
<Box>
<Tooltip title={p.status === 'ACTIVE' ? 'Deaktivieren' : 'Aktivieren'}>
<Switch
size="small"
checked={p.status === 'ACTIVE'}
onChange={() => handleToggleStatus(p)}
disabled={updateProperty.isPending}
sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }}
/>
</Tooltip>
</Box>
{/* Delete */}
<Box>
<Tooltip title="Inserat löschen">
<IconButton
size="small"
onClick={() => setConfirmDelete(p)}
disabled={removeProperty.isPending}
sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }}
>
<Trash2 size={15} />
</IconButton>
</Tooltip>
</Box>
</Box>
)
})}
</Box> </Box>
)} )}
{/* Delete confirmation dialog */} {/* Detail / edit drawer */}
<Drawer
anchor="right"
open={!!detailId}
onClose={() => setDetailId(null)}
slotProps={{ paper: { sx: { width: { xs: '100%', sm: 560 } } } }}
>
{detailId && (
<PropertyDetailView propertyId={detailId} onClose={() => setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} />
)}
</Drawer>
{/* Suchabo dialog */}
<Dialog
open={!!suchaboDialog}
onClose={() => setSuchaboDialog(null)}
maxWidth="sm"
fullWidth
slotProps={{ paper: { sx: { maxHeight: '85vh' } } }}
>
<DialogTitle sx={{ fontWeight: 700, fontSize: '1rem', pb: 1 }}>
Suchabos
{suchaboDialog && (
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 400 }}>
{suchaboDialog.matches.length} Suchabo{suchaboDialog.matches.length !== 1 ? 's' : ''} matchen dieses Inserat
</Typography>
)}
</DialogTitle>
{suchaboDialog && (
<SuchaboDialogContent
propertyId={suchaboDialog.propertyId}
matches={suchaboDialog.matches}
onClose={() => setSuchaboDialog(null)}
/>
)}
</Dialog>
{/* Delete confirmation */}
<Dialog open={!!confirmDelete} onClose={() => setConfirmDelete(null)} maxWidth="xs" fullWidth> <Dialog open={!!confirmDelete} onClose={() => setConfirmDelete(null)} maxWidth="xs" fullWidth>
<DialogTitle>Inserat löschen?</DialogTitle> <DialogTitle>Inserat löschen?</DialogTitle>
<DialogContent> <DialogContent>
@@ -229,6 +474,9 @@ export default function MyListings() {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* OfferWizard — triggered by PublicNeedDetail's "Angebot erstellen" button */}
<OfferWizard />
</Box> </Box>
) )
} }
+83 -42
View File
@@ -1,8 +1,8 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { Box, Drawer, useMediaQuery, useTheme } from '@mui/material' import { Box, Button, Chip, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { PageHeader } from '../../components/layout' import { PageHeader } from '../../components/layout'
import { DecisionContextPanel } from '../../components/ui'
import { ViewToggle } from '../../components/shared' import { ViewToggle } from '../../components/shared'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply' import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply'
@@ -56,6 +56,9 @@ export default function Properties() {
const [view, setView] = useState<'list' | 'grid'>(() => const [view, setView] = useState<'list' | 'grid'>(() =>
(localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list' (localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list'
) )
const [headerOpen, setHeaderOpen] = useState(() =>
localStorage.getItem('props-header-open') !== 'false'
)
const { data: properties = [], isLoading, isError } = useProperties() const { data: properties = [], isLoading, isError } = useProperties()
@@ -103,54 +106,92 @@ export default function Properties() {
} }
/> />
{/* Collapse toggle strip */}
<Box
onClick={() => {
const next = !headerOpen
setHeaderOpen(next)
localStorage.setItem('props-header-open', String(next))
}}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
py: 0.5,
bgcolor: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
cursor: 'pointer',
flexShrink: 0,
userSelect: 'none',
'&:hover': { bgcolor: '#f1f5f9' },
}}
>
<Typography sx={{ fontSize: '0.68rem', color: '#94a3b8', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Filter & Übersicht
</Typography>
{headerOpen ? <ChevronUp size={12} color="#94a3b8" /> : <ChevronDown size={12} color="#94a3b8" />}
</Box>
<Collapse in={headerOpen}>
{!isLoading && properties.length > 0 && ( {!isLoading && properties.length > 0 && (
<DecisionContextPanel <Box
decision="Welche Objekte sind matchbereit — und wo blockieren Datenlücken Matches?" sx={{
context="Objekte mit fehlenden Pflichtfeldern oder Konfidenz < 55% erscheinen im Match-Center nicht oder mit niedrigem Rang." display: 'flex',
metrics={[ alignItems: 'center',
{ label: 'matchbereit', value: matchReady.length, severity: matchReady.length > 0 ? 'positive' : 'warning' }, justifyContent: 'space-between',
...(criticalGaps.length > 0 px: 2.5,
? [{ label: 'kritische Datenlücken', value: criticalGaps.length, severity: 'critical' as const }] py: 0.75,
: [] bgcolor: '#f8fafc',
), borderBottom: '1px solid #e2e8f0',
...(lowConfidence.length > 0 borderLeft: '3px solid #1e3a5f',
? [{ label: 'Konfidenz < 55%', value: lowConfidence.length, severity: 'warning' as const }] flexShrink: 0,
: [] gap: 1,
), }}
...(staleOrOutdated.length > 0 >
? [{ label: 'veraltete Daten', value: staleOrOutdated.length, severity: 'warning' as const }] <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
: [] <Chip
), label={`${matchReady.length} matchbereit`}
]} size="small"
missing={allMissingFields.length > 0 sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600,
? [`Fehlende Pflichtfelder bei ${criticalGaps.length} Objekten: ${allMissingFields.join(', ')}`] bgcolor: matchReady.length > 0 ? '#f0fdf4' : '#fef9c3',
: [] color: matchReady.length > 0 ? '#1a7a4a' : '#92400e' }}
}
risks={[
...(criticalGaps.length > 0
? [`${criticalGaps.length} Objekte werden potenziellen Mietern nicht angezeigt`]
: []
),
...(staleOrOutdated.length > 0
? [`${staleOrOutdated.length} Objekte mit veralteten Preisen oder Verfügbarkeiten`]
: []
),
]}
actions={[
{
label: 'Datenpflege starten',
primary: criticalGaps.length > 0 || staleOrOutdated.length > 0,
onClick: () => navigate('/supply/data-quality'),
},
]}
/> />
{criticalGaps.length > 0 && (
<Chip
label={`${criticalGaps.length} kritische Datenlücken`}
size="small"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef2f2', color: '#991b1b' }}
/>
)}
{staleOrOutdated.length > 0 && (
<Chip
label={`${staleOrOutdated.length} veraltete Daten`}
size="small"
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#fef9c3', color: '#92400e' }}
/>
)}
</Box>
<Button
size="small"
variant={criticalGaps.length > 0 || staleOrOutdated.length > 0 ? 'contained' : 'outlined'}
onClick={() => navigate('/supply/data-quality')}
sx={criticalGaps.length > 0 || staleOrOutdated.length > 0
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
: { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.5, flexShrink: 0 }
}
>
Datenpflege
</Button>
</Box>
)} )}
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} /> <PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
</Collapse>
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>
{view === 'grid' ? ( {view === 'grid' ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 2, p: 2 }}> <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 1.5, p: 1.5 }}>
{filtered.map(p => ( {filtered.map(p => (
<PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} /> <PropertyIntelligenceCard key={p.id} property={p} onSelect={setSelectedId} />
))} ))}
+1 -1
View File
@@ -53,7 +53,7 @@ export default function SupplyDashboard() {
<Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}> <Paper sx={{ p: 0, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}` }}>
{/* KPI strip */} {/* KPI strip */}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' }, divideX: true }}> <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr 1fr', md: 'repeat(3, 1fr)' } }}>
{/* Starke Matches */} {/* Starke Matches */}
<Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}> <Box sx={{ p: 2.5, borderRight: `1px solid ${DS_BORDER.default}`, bgcolor: strongMatchCount > 0 ? DS_SURFACE.success.bg : 'white' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
+8
View File
@@ -0,0 +1,8 @@
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
export interface IMarktHinweisProvider {
getAll(): Promise<MarktHinweis[]>
getById(id: string): Promise<MarktHinweis | null>
create(data: CreateMarktHinweisInput): Promise<MarktHinweis>
update(id: string, data: Partial<MarktHinweis>): Promise<MarktHinweis>
}
@@ -0,0 +1,36 @@
import type { IMarktHinweisProvider } from './IMarktHinweisProvider'
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
import { marktHinweise as seed } from '../mock-data/marktHinweise'
const store: MarktHinweis[] = [...seed]
export const MockupMarktHinweisProvider: IMarktHinweisProvider = {
async getAll() {
return [...store]
},
async getById(id: string) {
return store.find(h => h.id === id) ?? null
},
async create(data: CreateMarktHinweisInput) {
const next: MarktHinweis = {
...data,
id: `mh-${Date.now()}`,
createdAt: new Date().toISOString(),
createdBy: 'Thomas Müller',
verwaltungId: 'v-001',
verwaltungName: 'Wincasa AG',
}
store.push(next)
return next
},
async update(id: string, data: Partial<MarktHinweis>) {
const idx = store.findIndex(h => h.id === id)
if (idx === -1) throw new Error(`MarktHinweis ${id} not found`)
const updated: MarktHinweis = { ...store[idx], ...data }
store[idx] = updated
return updated
},
}
@@ -7,7 +7,7 @@ describe('futureSignalService', () => {
const result = await futureSignalService.getAll() const result = await futureSignalService.getAll()
expect(Array.isArray(result.data)).toBe(true) expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0) expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length) expect(result.meta!.total).toBe(result.data.length)
}) })
it('each signal has required fields', async () => { it('each signal has required fields', async () => {
+1 -1
View File
@@ -7,7 +7,7 @@ describe('matchService', () => {
const result = await matchService.getAll() const result = await matchService.getAll()
expect(Array.isArray(result.data)).toBe(true) expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0) expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length) expect(result.meta!.total).toBe(result.data.length)
}) })
it('each match has required fields', async () => { it('each match has required fields', async () => {
+9 -3
View File
@@ -7,9 +7,9 @@ describe('needService', () => {
const result = await needService.getAll() const result = await needService.getAll()
expect(Array.isArray(result.data)).toBe(true) expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0) expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length) expect(result.meta!.total).toBe(result.data.length)
expect(result.meta.page).toBe(1) expect(result.meta!.page).toBe(1)
expect(result.meta.hasMore).toBe(false) expect(result.meta!.hasMore).toBe(false)
}) })
it('each need has required fields', async () => { it('each need has required fields', async () => {
@@ -49,6 +49,9 @@ describe('needService', () => {
preferredLocations: ['Zürich'], preferredLocations: ['Zürich'],
budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const }, budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const },
organizationId: 'org-test', organizationId: 'org-test',
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
confidenceInCriteria: 0.8,
} }
const result = await needService.create(input) const result = await needService.create(input)
expect(result.data.companyName).toBe('Test GmbH') expect(result.data.companyName).toBe('Test GmbH')
@@ -79,6 +82,9 @@ describe('needService', () => {
preferredLocations: ['Bern'], preferredLocations: ['Bern'],
budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const }, budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const },
organizationId: 'org-test', organizationId: 'org-test',
timing: { earliestMoveIn: '2025-01-01', latestMoveIn: '2026-01-01', flexibleTiming: true },
weightingProfile: { area: 0.3, location: 0.3, budget: 0.2, timing: 0.1, prestige: 0, accessibility: 0, expansionPotential: 0, flexibility: 0, visibility: 0, footfall: 0, talentAccess: 0, esg: 0, taxEnvironment: 0 },
confidenceInCriteria: 0.8,
}) })
const id = created.data.id const id = created.data.id
await needService.remove(id) await needService.remove(id)
@@ -7,8 +7,8 @@ describe('propertyService', () => {
const result = await propertyService.getAll() const result = await propertyService.getAll()
expect(Array.isArray(result.data)).toBe(true) expect(Array.isArray(result.data)).toBe(true)
expect(result.data.length).toBeGreaterThan(0) expect(result.data.length).toBeGreaterThan(0)
expect(result.meta.total).toBe(result.data.length) expect(result.meta!.total).toBe(result.data.length)
expect(result.meta.hasMore).toBe(false) expect(result.meta!.hasMore).toBe(false)
}) })
it('each property has required fields', async () => { it('each property has required fields', async () => {
@@ -62,9 +62,14 @@ describe('propertyService', () => {
assetType: 'LOGISTICS' as const, assetType: 'LOGISTICS' as const,
resultType: 'VERIFIED_PORTFOLIO' as const, resultType: 'VERIFIED_PORTFOLIO' as const,
location: { city: 'Basel', country: 'CH' as const }, location: { city: 'Basel', country: 'CH' as const },
address: { street: 'Teststr.', houseNumber: '1', postalCode: '4000', city: 'Basel', country: 'CH' },
areaSqm: 500, areaSqm: 500,
rentPricePerSqm: 120, rentPricePerSqm: 120,
availabilityDate: '2025-01-01',
availabilityStatus: 'AVAILABLE_NOW' as const, availabilityStatus: 'AVAILABLE_NOW' as const,
sourceType: 'DIRECT',
confidenceScore: 0.8,
dataQuality: { score: 0.8, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH' as const, warnings: [] },
organizationId: 'org-test', organizationId: 'org-test',
} }
const result = await propertyService.create(input) const result = await propertyService.create(input)
@@ -73,8 +73,8 @@ describe('MockAIService.generateFollowUpQuestions', () => {
assetType: 'OFFICE', assetType: 'OFFICE',
areaRange: { min: 300, max: 600 }, areaRange: { min: 300, max: 600 },
preferredLocations: ['Zürich'], preferredLocations: ['Zürich'],
budgetRange: { maxPerSqm: 400 }, budgetRange: { maxPerSqm: 400, currency: 'CHF' },
timing: { earliestMoveIn: '2025-09-01' }, timing: { earliestMoveIn: '2025-09-01', latestMoveIn: '2026-09-01', flexibleTiming: false },
mustHaveCriteria: ['ÖV-Anbindung'], mustHaveCriteria: ['ÖV-Anbindung'],
} }
const result = await run(MockAIService.generateFollowUpQuestions(criteria)) const result = await run(MockAIService.generateFollowUpQuestions(criteria))
@@ -116,7 +116,7 @@ describe('MockAIService provenance', () => {
describe('MockAIService.summarizeTradeOffs', () => { describe('MockAIService.summarizeTradeOffs', () => {
it('returns LOW risk when no HIGH severity trade-offs', async () => { it('returns LOW risk when no HIGH severity trade-offs', async () => {
const tradeoffs: TradeOffInput[] = [ const tradeoffs: TradeOffInput[] = [
{ concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' }, { criterion: 'Lage', concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' },
] ]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs)) const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('LOW') expect(result.data.overallRisk).toBe('LOW')
@@ -124,7 +124,7 @@ describe('MockAIService.summarizeTradeOffs', () => {
it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => { it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => {
const tradeoffs: TradeOffInput[] = [ const tradeoffs: TradeOffInput[] = [
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' }, { criterion: 'Fläche', concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
] ]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs)) const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('MEDIUM') expect(result.data.overallRisk).toBe('MEDIUM')
@@ -132,8 +132,8 @@ describe('MockAIService.summarizeTradeOffs', () => {
it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => { it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => {
const tradeoffs: TradeOffInput[] = [ const tradeoffs: TradeOffInput[] = [
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' }, { criterion: 'Fläche', concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
{ concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' }, { criterion: 'Budget', concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
] ]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs)) const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('HIGH') expect(result.data.overallRisk).toBe('HIGH')
@@ -155,6 +155,7 @@ describe('MockAIService.generateMatchExplanation', () => {
propertyCity: 'Zürich', propertyCity: 'Zürich',
positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }], positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }],
negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }], negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }],
needSummary: 'Bürofläche 200400 m² in Zürich',
} }
it('generates STARK headline for score >= 78', async () => { it('generates STARK headline for score >= 78', async () => {
-5
View File
@@ -4,16 +4,11 @@ import type { UnifiedMatchResult } from '../../../domain/unifiedResult'
import type { import type {
IAIService, IAIService,
AIResponse, AIResponse,
DecisionBrief,
ComparisonSummary,
CriteriaExtractionResult,
OfferEmailPayload, OfferEmailPayload,
MatchExplanationInput, MatchExplanationInput,
MatchExplanation,
TradeOffInput, TradeOffInput,
TradeOffSummary, TradeOffSummary,
DataQualityInput, DataQualityInput,
DataQualitySummary,
MarketSignalClassification, MarketSignalClassification,
} from '../IAIService' } from '../IAIService'
import { mockProvenance } from '../IAIService' import { mockProvenance } from '../IAIService'
@@ -316,7 +316,7 @@ export const OpenRouterAIService: IAIService = {
preferredLocations: ai.preferredLocations, preferredLocations: ai.preferredLocations,
budgetRange: ai.budgetRange ?? undefined, budgetRange: ai.budgetRange ?? undefined,
timing: ai.timing timing: ai.timing
? { ...ai.timing, latestMoveIn: ai.timing.latestMoveIn ?? undefined } ? { ...ai.timing, earliestMoveIn: ai.timing.earliestMoveIn ?? '', flexibleTiming: ai.timing.flexibleTiming ?? false }
: undefined, : undefined,
mustHaveCriteria: ai.mustHaveCriteria, mustHaveCriteria: ai.mustHaveCriteria,
} }
+1 -1
View File
@@ -219,7 +219,7 @@ export const aiTraceStore = new AITraceStore()
// window.__aiTraces.stats() → summary statistics // window.__aiTraces.stats() → summary statistics
// window.__aiTraces.clear() → clear all // window.__aiTraces.clear() → clear all
if (import.meta.env.DEV && typeof window !== 'undefined') { if (import.meta.env.DEV && typeof window !== 'undefined') {
;(window as Record<string, unknown>).__aiTraces = aiTraceStore ;(window as unknown as Record<string, unknown>).__aiTraces = aiTraceStore
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
+11
View File
@@ -0,0 +1,11 @@
import { MockupMarktHinweisProvider } from '../provider/MockupMarktHinweisProvider'
import type { MarktHinweis, CreateMarktHinweisInput } from '../domain/marktHinweis'
const provider = MockupMarktHinweisProvider
export const marktHinweisService = {
getAll: (): Promise<MarktHinweis[]> => provider.getAll(),
getById: (id: string): Promise<MarktHinweis | null> => provider.getById(id),
create: (data: CreateMarktHinweisInput): Promise<MarktHinweis> => provider.create(data),
update: (id: string, data: Partial<MarktHinweis>): Promise<MarktHinweis> => provider.update(id, data),
}
+2 -2
View File
@@ -175,8 +175,8 @@ export const matchService = {
rentPricePerSqm: p.rentPricePerSqm, rentPricePerSqm: p.rentPricePerSqm,
matchScore: score, matchScore: score,
imageUrl: p.images?.[0], imageUrl: p.images?.[0],
reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.label ?? '').filter(Boolean), reasons: (match?.positiveFactors ?? []).slice(0, 3).map(f => f.explanation ?? f.criterion ?? '').filter(Boolean),
topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.description topUncertainty: (match?.tradeoffs ?? match?.tradeOffs ?? [])[0]?.concern
?? (match?.negativeFactors ?? [])[0]?.explanation ?? (match?.negativeFactors ?? [])[0]?.explanation
?? undefined, ?? undefined,
})) }))
+9 -12
View File
@@ -1,6 +1,6 @@
import { MockupReminderProvider } from '../provider/MockupReminderProvider' import { MockupReminderProvider } from '../provider/MockupReminderProvider'
import type { Reminder } from '../domain/reminder' import type { Reminder } from '../domain/reminder'
import { ReminderPriority, ReminderStatus, ShadowMarketRisk } from '../domain/reminder' import { ReminderPriority, ReminderStatus, ReminderType } from '../domain/reminder'
const provider = MockupReminderProvider const provider = MockupReminderProvider
@@ -27,27 +27,23 @@ export const reminderService = {
const urgentCount = active.filter(r => r.priority === ReminderPriority.URGENT).length const urgentCount = active.filter(r => r.priority === ReminderPriority.URGENT).length
const endOfWeek = new Date(MOCK_TODAY) // Counts match getHorizon() in ReminderFeed — exclusive, non-overlapping buckets
endOfWeek.setDate(endOfWeek.getDate() + 7)
const dueThisWeek = active.filter(r => { const dueThisWeek = active.filter(r => {
const d = daysDiff(r.dueDate) const d = daysDiff(r.dueDate)
return d >= 0 && d <= 7 return d >= 1 && d <= 7
}).length }).length
const endOfMonth = new Date(MOCK_TODAY)
endOfMonth.setDate(endOfMonth.getDate() + 30)
const dueThisMonth = active.filter(r => { const dueThisMonth = active.filter(r => {
const d = daysDiff(r.dueDate) const d = daysDiff(r.dueDate)
return d >= 0 && d <= 30 return d >= 8 && d <= 30
}).length }).length
const schattenmarktReadyCount = reminders.filter( const schattenmarktReadyCount = active.filter(
r => r => r.type === ReminderType.SCHATTENMARKT_RELEASE,
r.status === ReminderStatus.ACTIVE &&
!r.schattenmarktEnabled &&
(r.shadowMarketRisk === ShadowMarketRisk.HIGH),
).length ).length
const overdueCount = active.filter(r => daysDiff(r.dueDate) <= 0).length
const activeDays = active const activeDays = active
.filter(r => daysDiff(r.dueDate) > 0) .filter(r => daysDiff(r.dueDate) > 0)
.map(r => daysDiff(r.dueDate)) .map(r => daysDiff(r.dueDate))
@@ -60,6 +56,7 @@ export const reminderService = {
dueThisWeek, dueThisWeek,
dueThisMonth, dueThisMonth,
schattenmarktReadyCount, schattenmarktReadyCount,
overdueCount,
avgDaysToAction, avgDaysToAction,
} }
}, },
+5
View File
@@ -6,22 +6,27 @@ const PROFILES: Record<string, WeightProfile> = {
OFFICE: { OFFICE: {
area: 0.20, location: 0.25, budget: 0.20, timing: 0.15, area: 0.20, location: 0.25, budget: 0.20, timing: 0.15,
prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02, prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02,
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
}, },
LOGISTICS: { LOGISTICS: {
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15, area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02, prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
}, },
RETAIL: { RETAIL: {
area: 0.15, location: 0.30, budget: 0.20, timing: 0.10, area: 0.15, location: 0.30, budget: 0.20, timing: 0.10,
prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02, prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
}, },
PRODUCTION: { PRODUCTION: {
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15, area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02, prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02,
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
}, },
DEFAULT: { DEFAULT: {
area: 0.25, location: 0.25, budget: 0.20, timing: 0.15, area: 0.25, location: 0.25, budget: 0.20, timing: 0.15,
prestige: 0.07, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.01, prestige: 0.07, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.01,
visibility: 0, talentAccess: 0, footfall: 0, esg: 0, taxEnvironment: 0,
}, },
} }
+13 -1
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { Attachment } from '../domain/inquiry' import type { Attachment } from '../domain/inquiry'
import type { ReportObjectFieldSelection } from '../domain/inquiryReport'
export type OfferStep = 'select_properties' | 'pdf_review' | 'checked' | 'send' export type OfferStep = 'select_properties' | 'select_fields' | 'pdf_review' | 'checked' | 'send'
interface OfferWizardState { interface OfferWizardState {
isOpen: boolean isOpen: boolean
@@ -11,6 +12,7 @@ interface OfferWizardState {
currentStep: OfferStep currentStep: OfferStep
offerDraftId: string | null offerDraftId: string | null
editableFields: Record<string, string> editableFields: Record<string, string>
fieldSelections: ReportObjectFieldSelection[]
pdfPreviewReady: boolean pdfPreviewReady: boolean
messageDraft: string messageDraft: string
messageSubject: string messageSubject: string
@@ -22,6 +24,8 @@ interface OfferWizardState {
setStep(step: OfferStep): void setStep(step: OfferStep): void
setOfferDraftId(id: string): void setOfferDraftId(id: string): void
updateField(fieldId: string, value: string): void updateField(fieldId: string, value: string): void
setFieldSelections(selections: ReportObjectFieldSelection[]): void
updateFieldSelection(propertyId: string, selection: ReportObjectFieldSelection): void
setPdfReady(): void setPdfReady(): void
setMessageDraft(text: string): void setMessageDraft(text: string): void
setMessageSubject(s: string): void setMessageSubject(s: string): void
@@ -38,6 +42,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
currentStep: 'select_properties', currentStep: 'select_properties',
offerDraftId: null, offerDraftId: null,
editableFields: {}, editableFields: {},
fieldSelections: [],
pdfPreviewReady: false, pdfPreviewReady: false,
messageDraft: '', messageDraft: '',
messageSubject: '', messageSubject: '',
@@ -51,6 +56,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
currentStep: 'select_properties', currentStep: 'select_properties',
offerDraftId: null, offerDraftId: null,
editableFields: {}, editableFields: {},
fieldSelections: [],
pdfPreviewReady: false, pdfPreviewReady: false,
messageDraft: '', messageDraft: '',
messageSubject: '', messageSubject: '',
@@ -68,6 +74,11 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
setOfferDraftId: (id) => set({ offerDraftId: id }), setOfferDraftId: (id) => set({ offerDraftId: id }),
updateField: (fieldId, value) => updateField: (fieldId, value) =>
set((s) => ({ editableFields: { ...s.editableFields, [fieldId]: value } })), set((s) => ({ editableFields: { ...s.editableFields, [fieldId]: value } })),
setFieldSelections: (fieldSelections) => set({ fieldSelections }),
updateFieldSelection: (propertyId, selection) =>
set((s) => ({
fieldSelections: s.fieldSelections.map(fs => fs.propertyId === propertyId ? selection : fs),
})),
setPdfReady: () => set({ pdfPreviewReady: true }), setPdfReady: () => set({ pdfPreviewReady: true }),
setMessageDraft: (messageDraft) => set({ messageDraft }), setMessageDraft: (messageDraft) => set({ messageDraft }),
setMessageSubject: (messageSubject) => set({ messageSubject }), setMessageSubject: (messageSubject) => set({ messageSubject }),
@@ -83,6 +94,7 @@ export const useOfferWizardStore = create<OfferWizardState>((set) => ({
currentStep: 'select_properties', currentStep: 'select_properties',
offerDraftId: null, offerDraftId: null,
editableFields: {}, editableFields: {},
fieldSelections: [],
pdfPreviewReady: false, pdfPreviewReady: false,
messageDraft: '', messageDraft: '',
messageSubject: '', messageSubject: '',
+9 -3
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { ReminderType, ReminderStatus, ReminderPriority } from '../domain/reminder' import type { ReminderType, ReminderStatus, ReminderPriority } from '../domain/reminder'
export type FilterHorizon = 'ALL' | 'OVERDUE' | 'THIS_WEEK' | 'THIS_MONTH' | 'LATER'
interface ReminderStore { interface ReminderStore {
selectedId: string | null selectedId: string | null
setSelectedId: (id: string | null) => void setSelectedId: (id: string | null) => void
@@ -8,10 +10,12 @@ interface ReminderStore {
setDrawerOpen: (open: boolean) => void setDrawerOpen: (open: boolean) => void
filterType: ReminderType | 'ALL' filterType: ReminderType | 'ALL'
setFilterType: (t: ReminderType | 'ALL') => void setFilterType: (t: ReminderType | 'ALL') => void
filterStatus: ReminderStatus | 'ALL' filterStatus: ReminderStatus | 'ALL' | 'ACTIVE'
setFilterStatus: (s: ReminderStatus | 'ALL') => void setFilterStatus: (s: ReminderStatus | 'ALL' | 'ACTIVE') => void
filterPriority: ReminderPriority | 'ALL' filterPriority: ReminderPriority | 'ALL'
setFilterPriority: (p: ReminderPriority | 'ALL') => void setFilterPriority: (p: ReminderPriority | 'ALL') => void
filterHorizon: FilterHorizon
setFilterHorizon: (h: FilterHorizon) => void
searchQuery: string searchQuery: string
setSearchQuery: (q: string) => void setSearchQuery: (q: string) => void
viewMode: 'list' | 'card' viewMode: 'list' | 'card'
@@ -25,10 +29,12 @@ export const useReminderStore = create<ReminderStore>((set) => ({
setDrawerOpen: (open) => set({ drawerOpen: open }), setDrawerOpen: (open) => set({ drawerOpen: open }),
filterType: 'ALL', filterType: 'ALL',
setFilterType: (t) => set({ filterType: t }), setFilterType: (t) => set({ filterType: t }),
filterStatus: 'ALL', filterStatus: 'ACTIVE',
setFilterStatus: (s) => set({ filterStatus: s }), setFilterStatus: (s) => set({ filterStatus: s }),
filterPriority: 'ALL', filterPriority: 'ALL',
setFilterPriority: (p) => set({ filterPriority: p }), setFilterPriority: (p) => set({ filterPriority: p }),
filterHorizon: 'ALL',
setFilterHorizon: (h) => set({ filterHorizon: h }),
searchQuery: '', searchQuery: '',
setSearchQuery: (q) => set({ searchQuery: q }), setSearchQuery: (q) => set({ searchQuery: q }),
viewMode: 'list', viewMode: 'list',
+6
View File
@@ -0,0 +1,6 @@
{
"buildCommand": "npx vite build",
"outputDirectory": "dist",
"framework": null,
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}