15 Commits

Author SHA1 Message Date
Benjamin Sutter ec5d567dae refine: Reminder Manager — align to premium design
Replace SaaS-generic KPI icon boxes with flat inline layout.
Replace MUI color-prop status chips with design-system colors
matching the rest of the supply workspace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 23:05:08 +02:00
Benjamin Sutter e64ae15d17 feat: anonymous pre-market release per unit
Add anonymous flag to schattenmarktRelease on PropertyUnit and Property.
When a unit is enabled in the pre-market panel, an Anonym toggle appears
inline — hides address and property name in the matching feed, showing
only area, type and region to prospective tenants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:58:02 +02:00
Benjamin Sutter ee229f8f4f refactor: unit-centric lease view — Einheiten & Mietverträge
Replace building-level Miet- & Mieterinformationen section with
unit-level lease display. Each unit row now shows tenant name and
lease end date directly; expand reveals full contract details
(Mietbeginn, Mietende, Laufzeit, Breakout, source system, doc link).

Floor grouping groups consecutive same-floor units under one label.
Domain model extended with Lease and Tenant types; deprecated
property-level tenant fields preserved via @deprecated for compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:52:18 +02:00
Benjamin Sutter d771029633 fix: correct ScoreInlineBreakdown prop names in MatchCardCompact
scoreFactors/score were stale prop names; the component expects
scoreBreakdown and allFactors. Added null-guard for scoreBreakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:00:20 +02:00
Benjamin Sutter 20dcc6b245 fix: remove FUTURE_SIGNAL from supply availability filter
FUTURE_SIGNAL is a demand/AI market intelligence concept — it applies
to properties not in the manager's portfolio. Own properties can only
be AVAILABLE_NOW, AVAILABLE_SOON, or OCCUPIED.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:52:19 +02:00
Benjamin Sutter bd9fd98d78 refine: sharper corners, minimal status indicator, no metric boxes
- theme: borderRadius 8→4 (cascades to all MUI components)
- PropertyIntelligenceCard: status pill → plain dot+text (no bg/border)
- OCCUPIED status: green → neutral gray (rented = expected, no signal needed)
- metric cells: remove box decoration, whitespace-only separation
- metric labels: lowercase, no uppercase/letter-spacing
- status placed inline next to location, saving vertical space

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:49:18 +02:00
Benjamin Sutter bae0cd31d5 refactor: remove ÖV chip from supply card — tenant metric, not manager metric
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:40:29 +02:00
Benjamin Sutter 5c886193d5 refactor: PropertyIntelligenceCard — property manager perspective
Primary element: occupancy status pill (Vermietet/Bald frei/Verfügbar)
with semantic color — this is what drives every manager action.

Metrics: Fläche · Miete CHF/m²/J · Anfragen (not annual total)
- CHF/m²/J is the professional pricing metric, not the portal total
- Removed price overlay from image — clean photo, no text on top
- Image reduced to 150px (photo as context, not hero)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:39:25 +02:00
Benjamin Sutter e6c20958a5 feat: supply card metrics — replace Prestige with Anfragen count
Senior property manager perspective: the three key metrics are
Fläche (size), Frei ab (availability date), and Anfragen (active
inquiry count). Anfragen box highlights amber when > 0. Rename
Datenkonfidenz → Datenvollständigkeit (clearer for the manager).
Remove ÖV chip, keep fit-out and contract chips as property specs.
Wire inquiryCount from useActiveInquiries in Properties page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:36:45 +02:00
Benjamin Sutter 884bb468b8 refactor: PropertyIntelligenceCard — keep all info, premium visual treatment
Preserve metric boxes, ÖV/fit-out/contract chips and confidence bar.
Apply premium styling: DM Serif CHF on image, neutral dark asset-type
badge, warm gray metric boxes (#f9f8f6), muted border chips, navy
confidence bar with gold % when high.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:32:39 +02:00
Benjamin Sutter 29ee98a300 fix: Properties grid — wider min card width, more breathing room
Change minmax from 240px to 320px and gap from 1.5 to 2.5 so the
grid shows 3-4 columns instead of 9-10 on a wide screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:30:12 +02:00
Benjamin Sutter 161b2d609a refactor: PropertyIntelligenceCard — E&V-style minimal layout
Remove all image overlays, colored badges, metric boxes and confidence
bar. New structure: image → location (gray) → title → price large
(DM Serif) → single facts line. Single neutral dark tag for asset type.
Matches Engel & Völkers card pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:28:56 +02:00
Benjamin Sutter da50f3b5ea feat: premium redesign — DM Serif, refined palette, flat score badges
- Design tokens (ds.ts, theme.ts, scoreTheme.ts): new warm palette
  (#152642 navy, #f9f8f6 warm white, #e8e7e4 borders, #b8975a gold accent),
  flat score tier badges replacing CSS gradients, Inter + DM Serif Display typography
- Card components: white-background cards, DM Serif score numbers, max-2 badge
  chips with +N overflow tooltip, editorial score badge positioning
- Layout shell: gold left-accent nav active state, 64px top bar, outlined
  workspace chip, DM Serif page titles
- Shared atoms: GenericBadge (outlined/solid variants), ResultFilterBar
  (simplified chip styles), DecisionContextPanel (dot metrics, no left accent)
- Global replacement (118 files): #1e3a5f→#152642, #e2e8f0→#e8e7e4,
  #f4f6f9→#f9f8f6 — all handled via Node.js for proper UTF-8 safety

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:26:30 +02:00
Benjamin Sutter 96a2507979 fix: carry all unit data into + Inserat prefill
When clicking + Inserat in UnitStructurePanel, the new listing form
now pre-populates: floor label, fit-out standard, parking, ceiling
height, Mieterausbaubeitrag, Teilbar toggle, min lettable sqm, and
availability date from the unit and its parent property.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:32:54 +02:00
Benjamin Sutter 87ea4c4dfc fix: clear stale search criteria on text edit, fix Zürich false-positive, improve cost breakdown and fit-out labels
- AISearch: clear parsed criteria when user manually edits text input so stale locations (e.g. Zürich) no longer persist after retyping
- needParser: fix ZURICH_SIGNALS substring bug — "kreis N" now uses word-boundary regex so "umkreis 22" no longer matches "kreis 2"
- CompareTableBody: row 9 shows full cost breakdown (Miete + NK + amortised fit-out) using FITOUT_AMORTIZATION_YEARS from constants
- FitOutCostPanel: replace local AMORTIZATION_YEARS with FITOUT_AMORTIZATION_YEARS from constants.ts (single source of truth)
- constants.ts: add FITOUT_AMORTIZATION_YEARS = 5 — change here to affect all cost calculations
- PropertyIntelligenceCard: translate raw fitOut enum to German labels (Rohbau/Grundausbau/Vollausbau/Premium-Ausbau) with explanatory tooltips; add tooltip on contract duration chip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:32:25 +02:00
150 changed files with 1459 additions and 1194 deletions
@@ -23,7 +23,7 @@ const TYPE_LABELS: Record<AIOutputType, string> = {
}
const TYPE_COLORS: Record<AIOutputType, string> = {
NEED_PARSE: '#1e3a5f',
NEED_PARSE: '#152642',
FOLLOW_UP_QUESTIONS: '#0891b2',
MATCH_EXPLANATION: '#4f46e5',
COMPARE_SUMMARY: '#1a7a4a',
@@ -61,7 +61,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
variant="outlined"
onClick={onCopyJson}
startIcon={<Copy size={13} />}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e2e8f0', '&:hover': { bgcolor: '#f8fafc' } }}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e8e7e4', '&:hover': { bgcolor: '#f8fafc' } }}
>
JSON
</Button>
@@ -93,18 +93,18 @@ export function ActiveInquiriesTab() {
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Anfragen
</Typography>
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#1e3a5f', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{inquiries.length}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Listenansicht">
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#1e3a5f' : '#64748b' }}>
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}>
<ListIcon size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Kartenansicht">
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#1e3a5f' : '#64748b' }}>
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}>
<LayoutGrid size={16} />
</IconButton>
</Tooltip>
@@ -20,12 +20,12 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
p: 2,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
@@ -42,7 +42,7 @@ export function InquiryChat({
fontSize: '0.75rem',
borderColor: '#cbd5e1',
color: '#475569',
'&:hover': { borderColor: '#1e3a5f', color: '#1e3a5f', bgcolor: '#f0f4f8' },
'&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' },
}}
>
Angebot erstellen
@@ -85,7 +85,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
variant="outlined"
startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#1e3a5f', color: '#1e3a5f', '&:hover': { bgcolor: '#f0f4f8' } }}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#152642', color: '#152642', '&:hover': { bgcolor: '#f0f4f8' } }}
>
Vorbereitung starten
</Button>
@@ -20,7 +20,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
py: 1.5,
borderBottom: '1px solid #e2e8f0',
borderLeft: '3px solid',
borderLeftColor: selected ? '#1e3a5f' : hasUnread ? '#2563eb' : 'transparent',
borderLeftColor: selected ? '#152642' : hasUnread ? '#2563eb' : 'transparent',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'background-color 0.15s, border-color 0.15s',
@@ -38,7 +38,7 @@ export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
<Box
sx={{
maxWidth: '75%',
bgcolor: isTenant ? '#f1f5f9' : '#1e3a5f',
bgcolor: isTenant ? '#f1f5f9' : '#152642',
color: isTenant ? '#0f172a' : 'white',
px: 1.75,
py: 1.25,
@@ -166,7 +166,7 @@ export function InquiryReplyComposer({
}
onClick={handleSend}
disabled={sending || !body.trim()}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Antwort senden
</Button>
@@ -47,7 +47,7 @@ export function LatentInquiriesTab() {
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box
onClick={() => setMobileView('properties')}
sx={{ p: 1.25, bgcolor: '#1e3a5f', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
sx={{ p: 1.25, bgcolor: '#152642', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
>
Eigene Objekte anzeigen
</Box>
@@ -164,11 +164,11 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#1e3a5f', borderWidth: 1 }} />
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
</Box>
<Box
@@ -116,7 +116,7 @@ export function OfferChatComposer() {
py: 0.75,
}}
>
<FileText size={14} color="#1e3a5f" />
<FileText size={14} color="#152642" />
<Typography variant="caption" sx={{ fontSize: '0.8rem', fontWeight: 500 }}>
{a.fileName}
</Typography>
@@ -35,7 +35,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
onClick={() => open(needId, needTitle)}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
@@ -255,7 +255,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
variant="contained"
disabled={loading}
onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -264,7 +264,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button
variant="contained"
onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -273,7 +273,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button
variant="contained"
onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
PDF erstellen
</Button>
@@ -77,7 +77,7 @@ export function OfferFieldSelectionStep() {
<Button
variant="contained"
onClick={() => setStep('pdf_review')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -179,7 +179,7 @@ export function OfferPdfReviewStep() {
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleNext}
disabled={!pdfReady || markChecked.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
</Button>
@@ -141,7 +141,7 @@ export function OfferPropertySelectionStep() {
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -18,8 +18,8 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
PDF wird generiert
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
@@ -44,7 +44,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
overflow: 'hidden',
}}
>
<Box sx={{ bgcolor: '#1e3a5f', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ bgcolor: '#152642', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
@@ -53,7 +53,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
</Typography>
</Box>
<Box sx={{ flex: 1, p: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#1e3a5f', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', mb: 1 }}>
Angebotsschreiben
</Typography>
{draft?.editableFields.slice(0, 3).map(f => (
@@ -83,7 +83,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
variant="contained"
startIcon={<Download size={16} />}
onClick={onDownload}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
@@ -62,7 +62,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
flexShrink: 0,
}}
>
<Target size={14} color="#1e3a5f" />
<Target size={14} color="#152642" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
Eigene Objekte
</Typography>
@@ -72,7 +72,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#1e3a5f',
bgcolor: '#152642',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
@@ -159,8 +159,8 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#1e3a5f' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
Bericht wird erstellt
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
@@ -234,7 +234,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
fullWidth
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Herunterladen
</Button>
@@ -271,7 +271,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
variant="contained"
disabled={selectedIds.length === 0}
onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Weiter
</Button>
@@ -282,7 +282,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
disabled={finalizing}
onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
>
Finalisieren
</Button>
@@ -19,12 +19,12 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
p: 1.5,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#f1f5f9' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
borderColor: selected ? '#152642' : '#94a3b8',
boxShadow: '0 2px 6px rgba(15,23,42,0.06)',
},
display: 'flex',
@@ -174,7 +174,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
variant="caption"
sx={{
fontWeight: 700,
color: '#1e3a5f',
color: '#152642',
fontSize: '0.75rem',
bgcolor: '#e0e7ff',
px: 1,
@@ -189,7 +189,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
sx={{
height: 6,
borderRadius: 3,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
overflow: 'hidden',
}}
>
@@ -197,7 +197,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
sx={{
width: `${pct}%`,
height: '100%',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
transition: 'width 0.3s',
}}
/>
@@ -229,7 +229,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
disabled={disabled}
sx={{
textTransform: 'none',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
}}
@@ -47,7 +47,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#1e3a5f',
bgcolor: '#152642',
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
@@ -60,7 +60,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId }: RelatedPrope
height: 140,
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -203,7 +203,7 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
size="small"
endIcon={<ArrowRight size={11} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#1e3a5f' }}
sx={{ textTransform: 'none', fontSize: '0.7rem', mt: 0.5, p: 0, minWidth: 0, color: '#152642' }}
>
Ansehen
</Button>
@@ -31,12 +31,12 @@ export function SelectablePropertyMatchCard({
p: 1.25,
borderRadius: 1.5,
border: '1px solid',
borderColor: selected ? '#1e3a5f' : '#e2e8f0',
borderColor: selected ? '#152642' : '#e8e7e4',
bgcolor: selected ? '#eff6ff' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
borderColor: selected ? '#1e3a5f' : '#94a3b8',
borderColor: selected ? '#152642' : '#94a3b8',
},
display: 'flex',
gap: 1.25,
@@ -57,7 +57,7 @@ export function SelectablePropertyMatchCard({
height: 40,
flexShrink: 0,
borderRadius: 1,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -21,7 +21,7 @@ export function latentStatusBadge(status: 'public' | 'paused' | 'expired'): {
} {
if (status === 'public') return { label: 'Öffentlich', bg: '#dcfce7', fg: '#166534' }
if (status === 'paused') return { label: 'Pausiert', bg: '#fed7aa', fg: '#9a3412' }
return { label: 'Abgelaufen', bg: '#e2e8f0', fg: '#475569' }
return { label: 'Abgelaufen', bg: '#e8e7e4', fg: '#475569' }
}
// Deterministic pseudo-random match score from string IDs (range 6096)
@@ -9,7 +9,7 @@ interface Props {
}
const ACTION_COLORS: Record<string, string> = {
NAVIGATE: '#1e3a5f',
NAVIGATE: '#152642',
OPEN_REVIEW: '#7c3aed',
ADD_TO_SHORTLIST: '#1a7a4a',
REQUEST_DATA: '#d97706',
@@ -25,7 +25,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
px: 1.5,
py: 1,
borderRadius: isUser ? '8px 8px 2px 8px' : '2px 8px 8px 8px',
bgcolor: isUser ? '#1e3a5f' : '#f1f5f9',
bgcolor: isUser ? '#152642' : '#f1f5f9',
color: isUser ? 'white' : '#1e293b',
}}
>
@@ -237,7 +237,7 @@ export function GlobalAIAssistantDrawer() {
color: 'white',
flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' },
'&:disabled': { bgcolor: '#e2e8f0', color: '#94a3b8' },
'&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' },
}}
>
<Send size={16} />
+2 -2
View File
@@ -37,10 +37,10 @@ export function DemoRoleSwitcher() {
sx={{
fontSize: '0.7rem',
height: 22,
bgcolor: active ? '#1e3a5f' : 'transparent',
bgcolor: active ? '#152642' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
borderColor: active ? '#152642' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
+1 -1
View File
@@ -51,7 +51,7 @@ export function SessionExpired() {
<Button
variant="contained"
onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#1e3a5f', textTransform: 'none' }}
sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }}
>
Erneut anmelden
</Button>
+1 -1
View File
@@ -17,7 +17,7 @@ interface MetricCardProps {
sx?: SxProps<Theme>
}
export function MetricCard({ label, value, delta, icon, color = '#1e3a5f', sx }: MetricCardProps) {
export function MetricCard({ label, value, delta, icon, color = '#152642', sx }: MetricCardProps) {
return (
<Card sx={{ p: 2.5, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
+1 -1
View File
@@ -9,7 +9,7 @@ interface SourceTypeBadgeProps {
}
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#152642', Icon: ShieldCheck },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
}
@@ -67,7 +67,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
sx={{
fontWeight: 700, lineHeight: 1.3, mb: 0.25,
cursor: 'pointer',
'&:hover': { color: '#1e3a5f' },
'&:hover': { color: '#152642' },
}}
onClick={() => navigate(`/demand/results/${item.matchId}`)}
>
@@ -38,7 +38,7 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
{compareItems.map(item => (
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }} noWrap>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }} noWrap>
{getTitle(item)}
</Typography>
</TableCell>
+1 -1
View File
@@ -15,7 +15,7 @@ export function CompareEmptyState() {
Fügen Sie 24 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
</Typography>
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}>
Zu den Suchergebnissen
</Button>
</Box>
+60 -6
View File
@@ -20,6 +20,8 @@ import {
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
// ── Local helper ──────────────────────────────────────────────────────────────
@@ -148,18 +150,70 @@ export function CompareTableBody({
: <MissingDataCell />
}))}
{/* 9. Rent / Budget Fit */}
{row('9. Miete / Budget', compareItems.map(item => {
{/* 9. Rent / Budget Fit — full cost breakdown incl. amortised fit-out */}
{row('9. Kosten / Budget', compareItems.map(item => {
const prop = getProp(item)
if (!prop) return <MissingDataCell reason="Mietpreis nur für bestätigte Objekte verfügbar" />
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Grundausbau', FULL: 'Vollausbau', PREMIUM: 'Premium-Ausbau',
}
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
const monthlyRent = prop.totalRentMonthly
?? Math.round(prop.rentPricePerSqm * prop.areaSqm / 12)
// ancillaryCosts stored as CHF/m²/Monat
const monthlyNebenkosten = prop.ancillaryCosts != null
? Math.round(prop.ancillaryCosts * prop.areaSqm)
: null
const fitOut = prop.hardFacts?.fitOut
const fitOutLabel = fitOut ? (FIT_OUT_LABELS[fitOut] ?? fitOut) : null
const mabPerSqm = prop.hardFacts?.mieterausbaubeitragPerSqm ?? 0
// Amortised fit-out monthly cost (mid-range estimate)
let fitOutMonthly = 0
let fitOutMonthlyLabel: string | null = null
if (fitOut && !READY_TO_MOVE_IN.has(fitOut)) {
const inv = calcFitOutInvestment(fitOut, prop.areaSqm, mabPerSqm, 0)
if (inv && !inv.isFullyCovered) {
const months = FITOUT_AMORTIZATION_YEARS * 12
const midMin = Math.round(inv.netTotal.min / months)
const midMax = Math.round(inv.netTotal.max / months)
fitOutMonthly = Math.round((midMin + midMax) / 2)
fitOutMonthlyLabel = midMin === midMax
? `${midMin.toLocaleString('de-CH')}`
: `${midMin.toLocaleString('de-CH')}${midMax.toLocaleString('de-CH')}`
}
}
const totalMonthly = monthlyRent
+ (monthlyNebenkosten ?? 0)
+ fitOutMonthly
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.4 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
CHF {prop.rentPricePerSqm}/m²/Jahr
</Typography>
{prop.totalRentMonthly && (
<Typography variant="caption" color="text.secondary">
{prop.totalRentMonthly.toLocaleString('de-CH')} CHF/Monat
{monthlyRent.toLocaleString('de-CH')} CHF/Monat (Miete)
</Typography>
{monthlyNebenkosten != null && (
<Typography variant="caption" color="text.secondary">
+ {monthlyNebenkosten.toLocaleString('de-CH')} CHF/Monat (NK)
</Typography>
)}
{fitOutMonthlyLabel && (
<Typography variant="caption" color="text.secondary">
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau ÷ {FITOUT_AMORTIZATION_YEARS} J.)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
= {totalMonthly.toLocaleString('de-CH')} CHF/Monat
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
Ausbau: {fitOutLabel}{READY_TO_MOVE_IN.has(fitOut ?? '') ? ' (bezugsfertig)' : ''}
</Typography>
)}
</Box>
@@ -81,7 +81,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
Datenaktualisierung anfragen
</Button>
{q.missingCriticalFields.length > 0 && (
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#1e3a5f' }}>
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#152642' }}>
Fehlende Felder ergänzen
</Button>
)}
@@ -76,7 +76,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
@@ -110,7 +110,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
@@ -79,7 +79,7 @@ export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': { bgcolor: color },
}}
/>
+1 -1
View File
@@ -23,7 +23,7 @@ export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, on
sx={{
flex: 1,
py: 1.5,
bgcolor: '#1e3a5f',
bgcolor: '#152642',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
@@ -48,7 +48,7 @@ export function AISearchSavePreview({
<Box sx={{
maxWidth: 720, mx: 'auto', mt: 2,
p: 1.5, borderRadius: 1.5, border: '1px solid',
borderColor: isAnonymous ? '#7c3aed' : '#e2e8f0',
borderColor: isAnonymous ? '#7c3aed' : '#e8e7e4',
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
transition: 'all 0.15s',
}}>
@@ -86,7 +86,7 @@ export function AISearchSavePreview({
onClick={onSave}
disabled={isSaving}
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{isSaving
? 'Wird gespeichert…'
+1 -1
View File
@@ -66,7 +66,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onRepa
disabled={requiredUnanswered.length > 0}
onClick={onContinue}
endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Weiter zur Gewichtung
</Button>
@@ -22,7 +22,7 @@ export function NeedBuilderErrorState({ message, onRetry }: Props) {
variant="contained"
startIcon={<RotateCcw size={16} />}
onClick={onRetry}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Erneut versuchen
</Button>
+3 -3
View File
@@ -66,7 +66,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{c.assetType && (
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#1e3a5f', color: 'white' }} />
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#152642', color: 'white' }} />
)}
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
</Box>
@@ -143,7 +143,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
variant="determinate"
value={overallConfidence * 100}
sx={{
height: 6, borderRadius: 3, bgcolor: '#e2e8f0',
height: 6, borderRadius: 3, bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': {
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
},
@@ -171,7 +171,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
<LinearProgress
variant="determinate"
value={((weights[k] ?? 0) / maxWeight) * 100}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e8e7e4', '& .MuiLinearProgress-bar': { bgcolor: '#152642' } }}
/>
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
</Box>
+6 -6
View File
@@ -81,7 +81,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === opt.value
? { bgcolor: '#1e3a5f', color: 'white', '& .MuiChip-label': { color: 'white' } }
? { bgcolor: '#152642', color: 'white', '& .MuiChip-label': { color: 'white' } }
: {}}
/>
))}
@@ -139,7 +139,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
})
}}
sx={c.preferredLocations?.includes(loc)
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
}
/>
@@ -169,7 +169,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
<Box sx={{ mb: 2.5, px: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>Suchradius</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }}>
{c.searchRadius ?? 30} km
</Typography>
</Box>
@@ -181,7 +181,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
step={1}
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
size="small"
sx={{ color: '#1e3a5f', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
sx={{ color: '#152642', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
/>
</Box>
@@ -197,7 +197,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
sx={c.budgetRange?.maxPerSqm === p.value
? { fontSize: '0.68rem', height: 22, bgcolor: '#1e3a5f', color: 'white' }
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
}
/>
@@ -243,7 +243,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
)}
{/* Anonymous search */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e2e8f0'}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e8e7e4'}` }}>
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
Anonyme Suche
+5 -5
View File
@@ -77,7 +77,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
sx={{
p: 3,
border: '1px solid',
borderColor: isRecording ? '#dc2626' : '#e2e8f0',
borderColor: isRecording ? '#dc2626' : '#e8e7e4',
transition: 'border-color 0.2s',
bgcolor: isRecording ? '#fff5f5' : 'white',
}}
@@ -101,11 +101,11 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
/>
) : isAnalyzing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} sx={{ color: '#1e3a5f' }} />
<Typography variant="caption" sx={{ color: '#1e3a5f', fontWeight: 600 }}>Analysiert</Typography>
<CircularProgress size={14} sx={{ color: '#152642' }} />
<Typography variant="caption" sx={{ color: '#152642', fontWeight: 600 }}>Analysiert</Typography>
</Box>
) : isAutoGen && text ? (
<Typography variant="caption" sx={{ color: '#1e3a5f', fontSize: 11 }}> auto-synchronisiert</Typography>
<Typography variant="caption" sx={{ color: '#152642', fontSize: 11 }}> auto-synchronisiert</Typography>
) : null}
</Box>
@@ -154,7 +154,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
size="small"
disabled={isAnalyzing || !isSpeechSupported}
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e2e8f0' }, '&:disabled': { opacity: 0.35 } }}
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e8e7e4' }, '&:disabled': { opacity: 0.35 } }}
>
<Mic size={16} />
</IconButton>
+1 -1
View File
@@ -109,7 +109,7 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
</Box>
<Card sx={{ p: 3 }}>
<SliderGroup label="Harte Kriterien" keys={HARD_KEYS} raw={raw} onSlider={handleSlider} color="#1e3a5f" />
<SliderGroup label="Harte Kriterien" keys={HARD_KEYS} raw={raw} onSlider={handleSlider} color="#152642" />
<Divider sx={{ my: 2.5 }} />
<SliderGroup label="Weiche Kriterien" keys={SOFT_KEYS} raw={raw} onSlider={handleSlider} color="#7c3aed" />
</Card>
+1 -1
View File
@@ -39,7 +39,7 @@ export function PriorityChipGroup({ label, options, value, onChange, exclusive =
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{options.map(opt => {
const selected = value.includes(opt.value)
const accent = opt.color ?? '#1e3a5f'
const accent = opt.color ?? '#152642'
return (
<Chip
key={opt.value}
@@ -57,7 +57,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
onClick={() => set({ signalType: filters.signalType === t ? '' : t })}
sx={{
fontWeight: filters.signalType === t ? 700 : 400,
bgcolor: filters.signalType === t ? '#1e3a5f' : undefined,
bgcolor: filters.signalType === t ? '#152642' : undefined,
color: filters.signalType === t ? 'white' : undefined,
}}
/>
@@ -131,7 +131,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
label={`${activeCount} Filter aktiv`}
size="small"
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
sx={{ bgcolor: '#eff6ff', color: '#1e3a5f' }}
sx={{ bgcolor: '#eff6ff', color: '#152642' }}
/>
)}
<Typography variant="caption" color="text.secondary">
+12 -10
View File
@@ -13,7 +13,8 @@ export const SIDEBAR_BG = '#0f1923'
export const DIVIDER_COLOR = 'rgba(255,255,255,0.08)'
export const TEXT_MUTED = '#94a3b8'
export const TEXT_WHITE = '#ffffff'
export const NAV_ACTIVE_BG = 'rgba(255,255,255,0.12)'
export const NAV_ACTIVE_BG = 'transparent'
export const NAV_ACTIVE_ACCENT = '#b8975a'
export const NAV_HOVER_BG = 'rgba(255,255,255,0.06)'
// ---------------------------------------------------------------------------
@@ -188,16 +189,16 @@ export function Sidebar({
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: collapsed ? 0 : 1.5,
pl: collapsed ? 0 : isActive ? 1.25 : 1.5,
pr: collapsed ? 0 : 1.5,
py: 0.75,
mx: 0.5,
borderRadius: 1,
justifyContent: collapsed ? 'center' : 'flex-start',
backgroundColor: isActive ? NAV_ACTIVE_BG : 'transparent',
'&:hover': {
backgroundColor: isActive ? NAV_ACTIVE_BG : NAV_HOVER_BG,
},
transition: 'background-color 0.15s ease',
borderLeft: collapsed ? 'none' : `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
backgroundColor: 'transparent',
'&:hover': { backgroundColor: NAV_HOVER_BG },
transition: 'border-color 0.15s ease',
cursor: 'pointer',
}}
>
@@ -208,8 +209,9 @@ export function Sidebar({
variant="body2"
sx={{
color: isActive ? TEXT_WHITE : TEXT_MUTED,
fontWeight: isActive ? 500 : 400,
fontSize: '0.8125rem',
fontWeight: isActive ? 600 : 400,
fontSize: '0.875rem',
letterSpacing: '-0.01em',
whiteSpace: 'nowrap',
}}
>
@@ -263,7 +265,7 @@ export function Sidebar({
width: 32,
height: 32,
fontSize: '0.75rem',
bgcolor: '#1e3a5f',
bgcolor: '#b8975a',
flexShrink: 0,
}}
>
+5 -4
View File
@@ -31,10 +31,10 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Box
component="header"
sx={{
height: 56,
height: 64,
flexShrink: 0,
backgroundColor: '#ffffff',
borderBottom: '1px solid #e2e8f0',
borderBottom: '1px solid #e8e7e4',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
@@ -51,12 +51,13 @@ export function TopBar({ activeWorkspace, pathname, onMenuClick, isMobile }: Top
<Chip
label={config.label}
size="small"
variant="outlined"
sx={{
backgroundColor: config.chipColor,
color: '#ffffff',
color: '#152642',
fontWeight: 600,
fontSize: '0.7rem',
height: 22,
border: '1px solid #e8e7e4',
}}
/>
<Typography
+2 -2
View File
@@ -4,7 +4,7 @@ import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#1e3a5f',
VERIFIED_PORTFOLIO: '#152642',
MAISON_WORK: '#0369a1',
FUTURE_AVAILABILITY: '#7c3aed',
}
@@ -92,7 +92,7 @@ export function CompareTray() {
variant="contained"
size="small"
onClick={() => navigate('/demand/compare')}
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
>
Vergleich starten
</Button>
@@ -13,7 +13,7 @@ export function OrganizationContextBadge() {
variant="outlined"
icon={<Building2 size={12} color="#64748b" />}
label={currentUser.organizationName}
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e2e8f0' }}
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e8e7e4' }}
/>
)
}
+2 -2
View File
@@ -28,7 +28,7 @@ export function PageHeader({
sx,
}: PageHeaderProps) {
return (
<Box sx={{ px: 3, py: 2.5, borderBottom: '1px solid #e2e8f0', ...sx }}>
<Box sx={{ px: 3, py: 3, borderBottom: '1px solid #e8e7e4', ...sx }}>
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs separator="/" sx={{ mb: 1 }}>
{breadcrumbs.map((crumb) =>
@@ -52,7 +52,7 @@ export function PageHeader({
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3 }}>
<Typography variant="h6" sx={{ fontFamily: '"DM Serif Display", serif', fontWeight: 400, fontSize: '1.25rem', lineHeight: 1.3 }}>
{title}
</Typography>
{badge}
+1 -1
View File
@@ -45,7 +45,7 @@ export function UserMenu() {
return (
<>
<IconButton onClick={handleOpen} size="small" sx={{ p: 0 }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#1e3a5f' }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem', bgcolor: '#152642' }}>
{initials}
</Avatar>
</IconButton>
+1 -1
View File
@@ -44,7 +44,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
abbreviation: 'VW',
icon: Building2,
firstPath: '/supply/dashboard',
chipColor: '#1e3a5f',
chipColor: '#152642',
navItems: [
{ path: '/supply/dashboard', label: 'Übersicht', icon: LayoutDashboard },
{ path: '/supply/properties', label: 'Meine Objekte', icon: Building2 },
@@ -32,7 +32,6 @@ export const FutureAvailabilityCard = memo(function FutureAvailabilityCard({ vm
borderRadius: 2,
overflow: 'hidden',
border: `1px solid ${borderColor}`,
borderLeft: `3px solid ${accentColor}`,
background: 'white',
display: 'flex', flexDirection: 'column',
boxShadow: '0 1px 8px rgba(0,0,0,0.06)',
@@ -1,19 +1,15 @@
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import {
Building2,
CheckCircle2,
} from 'lucide-react'
import { Box, Button, Chip, Tooltip, Typography } from '@mui/material'
import { Building2, CheckCircle2 } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { LocationPreview } from '../shared/LocationPreview'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import type { MatchCardViewModel } from './MatchCardViewModel'
import { FutureAvailabilityCard } from './FutureAvailabilityCard'
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
vm: MatchCardViewModel
imageUrl?: string
@@ -30,52 +26,64 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
const { currentUser } = useSessionStore()
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' }
const isFuture = vm.resultType === 'FUTURE_AVAILABILITY'
const confPct = Math.round(vm.confidenceScore * 100)
// Future availability cards use the new design
if (isFuture) {
return <FutureAvailabilityCard vm={vm} />
}
// secondary chips — max 2 visible
const chips: Array<{ label: string; color?: string }> = []
if (vm.availabilityLabel) chips.push({ label: vm.availabilityLabel })
if (vm.fitOutLabel) chips.push({ label: vm.fitOutLabel })
if (vm.isDivisible && vm.minDivisibleUnitSqm) chips.push({ label: `Teilbar ab ${vm.minDivisibleUnitSqm}` })
const visibleChips = chips.slice(0, 2)
const hiddenChips = chips.slice(2)
return (
<Box sx={{
borderRadius: 2,
overflow: 'hidden',
boxShadow: `0 2px 16px rgba(0,0,0,0.07), 0 0 0 1px ${theme.cardBorder}`,
background: theme.cardBg,
border: `1px solid ${DS_BORDER.default}`,
bgcolor: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
display: 'flex',
flexDirection: 'column',
transition: 'box-shadow 0.15s, transform 0.1s',
'&:hover': {
boxShadow: `0 6px 28px rgba(0,0,0,0.12), 0 0 0 1px ${theme.cardBorder}`,
transform: 'translateY(-1px)',
},
transition: 'border-color 0.15s',
'&:hover': { borderColor: '#c8c7c4' },
}}>
{/* ── Hero zone ── */}
<Box sx={{ position: 'relative' }}>
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={200} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
{/* Score badge — bottom-left editorial */}
<Box sx={{
position: 'absolute', top: 10, left: 10,
background: theme.gradient, borderRadius: '10px',
px: 1.5, py: 0.5,
boxShadow: `0 4px 16px ${theme.glow}`,
position: 'absolute', bottom: 10, left: 12,
bgcolor: theme.gradient, borderRadius: '8px',
px: 1.25, py: 0.375,
border: `1px solid ${theme.border}`,
minWidth: 52, textAlign: 'center',
}}>
<Typography sx={{ fontWeight: 900, fontSize: '1.5rem', color: theme.text, lineHeight: 1 }}>
<Typography sx={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.625rem',
color: '#ffffff', lineHeight: 1,
}}>
{vm.matchScore}%
</Typography>
</Box>
<Box sx={{ position: 'absolute', top: 10, right: 44, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
{/* Top-right badge strip */}
<Box sx={{ position: 'absolute', top: 10, right: 10, display: 'flex', flexDirection: 'column', gap: 0.5, alignItems: 'flex-end' }}>
<Chip label={rt.label} size="small" sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip
icon={<Building2 size={9} color="#1e3a5f" />}
icon={<Building2 size={9} color="#152642" />}
label="Ihr Objekt"
size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
sx={{ bgcolor: 'rgba(21,38,66,0.85)', color: 'white', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }}
/>
)}
<HeatBadge propertyId={vm.propertyId} size="sm" />
@@ -83,24 +91,24 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
</Box>
{/* ── Card body ── */}
<Box sx={{ p: 2, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Unit-first title (floor + street) */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
<Box sx={{ p: 2.25, flex: 1, display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Title */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{vm.title}
</Typography>
{/* Building name as subtitle when a unit or floor range is in the title */}
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: '#475569', fontWeight: 500 }} noWrap>
<Typography variant="caption" sx={{ display: 'block', mt: 0.125, color: DS_TEXT.secondary, fontWeight: 500 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25 }}>
{[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')}
</Typography>
{/* Unit area + rent when a specific unit is matched */}
{/* Unit details */}
{vm.preMarketUnit && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', mt: 0.25 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, fontSize: '0.68rem', mt: 0.25 }}>
{vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m²
{vm.preMarketUnit.rentPricePerSqm
? ` · CHF ${Math.round(vm.preMarketUnit.rentPricePerSqm / 12).toLocaleString('de-CH')}/m²/Mt.`
@@ -108,43 +116,53 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
</Typography>
)}
{/* Regular explainability summary */}
{/* Confidence + secondary chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{visibleChips.map((c, i) => (
<Chip key={i} label={c.label} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
))}
{hiddenChips.length > 0 && (
<Tooltip title={hiddenChips.map(c => c.label).join(' · ')} arrow>
<Chip label={`+${hiddenChips.length}`} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
</Tooltip>
)}
</Box>
{/* Explainability */}
{vm.explainabilitySummary && (
<>
<Divider sx={{ my: 1.25 }} />
<Typography variant="body2" sx={{
fontWeight: 500, color: '#1e293b', lineHeight: 1.5, flex: 1,
fontWeight: 500, color: DS_TEXT.secondary, lineHeight: 1.5, flex: 1, mt: 1,
overflow: 'hidden', display: '-webkit-box',
WebkitLineClamp: 3, WebkitBoxOrient: 'vertical',
}}>
{vm.explainabilitySummary}
</Typography>
</>
)}
{vm.reasons.length > 0 && (
<>
<Divider sx={{ my: 1.25 }} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.625 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={13} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<CheckCircle2 size={13} color="#1d6342" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: DS_TEXT.primary, lineHeight: 1.3 }}>
{r.label}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: '#64748b', lineHeight: 1.3 }}>
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.muted, lineHeight: 1.3 }}>
{r.explanation}
</Typography>
</Box>
</Box>
))}
</Box>
</>
)}
{/* Actions */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<Button
size="small"
variant="contained"
@@ -158,7 +176,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
>
Anfrage
</Button>
@@ -169,9 +187,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overl
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick}
disabled={a.disabled}
sx={{
textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1,
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
>
{a.label}
</Button>
@@ -26,7 +26,7 @@ export function MatchActionToolbar({ actions }: Props) {
onClick={action.onClick}
sx={
action.variant === 'primary'
? { bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }
? { bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }
: action.variant === 'danger'
? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } }
: {}
+83 -108
View File
@@ -1,11 +1,11 @@
import { memo } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { Alert, Box, Button, Chip, Divider, Tooltip, Typography } from '@mui/material'
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useInquiryStore } from '../../stores/inquiryStore'
import { HeatBadge } from '../shared/HeatBadge'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { RESULT_TYPE_META } from '../../lib/ds'
import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds'
import { confidenceHex } from '../../lib/utils'
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
@@ -25,12 +25,20 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
const tier = getScoreTier(vm.matchScore)
const theme = SCORE_THEME[tier]
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const scoreBadgeBg = SCORE_THEME[tier].gradient
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' }
const confPct = Math.round(vm.confidenceScore * 100)
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
const leftAccent = vm.isCompareSelected ? '#5b3f8a' : vm.isSelected ? '#152642' : 'transparent'
const extraChips: string[] = []
if (vm.availabilityLabel) extraChips.push(vm.availabilityLabel)
if (vm.fitOutLabel) extraChips.push(vm.fitOutLabel)
if (vm.isDivisible && vm.minDivisibleUnitSqm) extraChips.push(`Teilbar ab ${vm.minDivisibleUnitSqm}`)
if (topRisk) extraChips.push(topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko')
const visibleExtra = extraChips.slice(0, 2)
const hiddenExtra = extraChips.slice(2)
return (
<Box sx={{
@@ -38,16 +46,17 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
mb: 1.5,
borderRadius: 2,
overflow: 'hidden',
border: `2px solid ${borderColor}`,
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
background: theme.cardBg,
border: `1px solid ${DS_BORDER.default}`,
borderLeft: `3px solid ${leftAccent}`,
bgcolor: '#ffffff',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
opacity: vm.isStaleData ? 0.75 : 1,
transition: 'box-shadow 0.15s',
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
transition: 'border-color 0.15s',
'&:hover': { borderColor: '#c8c7c4' },
}}>
{/* ── Image strip ── */}
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#f4f3f0' }}>
{imageUrl ? (
<img
src={imageUrl}
@@ -56,35 +65,37 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
/>
) : (
<Box sx={{
width: '100%', height: '100%', minHeight: 120,
width: '100%', height: '100%', minHeight: 140,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
bgcolor: '#f4f3f0',
}}>
<MapPin size={24} color="#7ba3bf" />
<MapPin size={24} color="#c4c4c4" />
</Box>
)}
{/* Score badge */}
<Box sx={{
position: 'absolute', top: 8, left: 8,
background: theme.gradient, borderRadius: '8px',
px: 1, py: 0.25,
border: `1px solid ${theme.border}`,
boxShadow: `0 2px 8px ${theme.glow}`,
bgcolor: scoreBadgeBg, borderRadius: '6px',
px: 0.875, py: 0.25,
border: `1px solid ${SCORE_THEME[tier].border}`,
}}>
<Typography sx={{
fontFamily: '"DM Serif Display", serif',
fontWeight: 400, fontSize: '1.125rem',
color: '#ffffff', lineHeight: 1,
}}>
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
{vm.matchScore}%
</Typography>
</Box>
{/* Asset type label */}
{overlayTypeLabel && (
<Box sx={{
position: 'absolute', bottom: 0, left: 0, right: 0,
px: 0.75, py: 0.5,
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, transparent 100%)',
}}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: 'white', lineHeight: 1.3 }}>
{overlayTypeLabel}
</Typography>
</Box>
@@ -92,133 +103,91 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
</Box>
{/* ── Content ── */}
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Box sx={{ flex: 1, p: 2, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Alerts */}
{vm.disclaimer && (
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
)}
{/* Badges row */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center', mb: 0.75 }}>
<HeatBadge propertyId={vm.propertyId} size="sm" />
<Chip label={rt.label} size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
<Chip icon={<Building2 size={9} color="#152642" />} label="Ihr Objekt" size="small"
sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: '#152642', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
)}
<Chip label={`${confPct}% Konfidenz`} size="small"
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
{vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
{visibleExtra.map((label, i) => (
<Chip key={i} label={label} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.secondary }} />
))}
{hiddenExtra.length > 0 && (
<Tooltip title={hiddenExtra.join(' · ')} arrow>
<Chip label={`+${hiddenExtra.length}`} size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: DS_BORDER.default, color: DS_TEXT.muted }} />
</Tooltip>
)}
{vm.fitOutLabel && (
<Chip
label={vm.fitOutLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutViable ? '#059669' : '#94a3b8',
color: vm.fitOutViable ? '#059669' : '#64748b',
}}
/>
)}
{vm.fitOutInvestment && !vm.fitOutInvestment.isFullyCovered && (
<Chip
label={vm.fitOutInvestment.shortLabel}
size="small" variant="outlined"
sx={{
fontSize: 10, height: 20,
borderColor: vm.fitOutInvestment.netTotal.max > 100000 ? '#d97706' : '#94a3b8',
color: vm.fitOutInvestment.netTotal.max > 100000 ? '#92400e' : '#64748b',
}}
/>
)}
{vm.isDivisible && vm.minDivisibleUnitSqm && (
<Chip
label={`Teilbar ab ${vm.minDivisibleUnitSqm}`}
size="small" variant="outlined"
sx={{ fontSize: 10, height: 20, borderColor: '#7c3aed', color: '#7c3aed' }}
/>
)}
{topRisk && (
<Chip
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
size="small" color="error" variant="outlined"
sx={{ fontSize: 10, height: 20 }}
/>
)}
</Box>
</Box>
{/* Title + location */}
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3, color: DS_TEXT.primary }} noWrap>
{vm.title}
</Typography>
{vm.propertySubtitle && (
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
{vm.propertySubtitle}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
<MapPin size={11} color="#64748b" />
<Typography variant="caption" color="text.secondary">
<MapPin size={11} color="#9a9a9a" />
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{vm.locationLabel}
</Typography>
</Box>
{/* Explainability summary */}
{vm.explainabilitySummary && (
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, fontWeight: 500, mt: 0.75, lineHeight: 1.5,
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{vm.explainabilitySummary}
</Typography>
)}
<Divider sx={{ my: 1 }} />
{/* Score breakdown */}
{vm.scoreBreakdown && (
<Box sx={{ mb: 1 }}>
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
</Box>
)}
{/* Reasons — all 3, same style as grid card */}
{vm.reasons.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{vm.reasons.slice(0, 3).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.35, mt: 0.75 }}>
{vm.reasons.slice(0, 2).map((r, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={11} color="#1d6342" style={{ flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.72rem', color: DS_TEXT.secondary, lineHeight: 1.3 }}>
{r.label}
</Typography>
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
{r.explanation}
</Typography>
</Box>
</Box>
))}
</Box>
)}
{/* Top tradeoff */}
{vm.tradeoffs.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
{vm.tradeoffs[0].concern}
</Typography>
</Box>
{vm.scoreBreakdown && vm.allFactors && vm.allFactors.length > 0 && (
<>
<Divider sx={{ my: 0.75 }} />
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} />
</>
)}
{topRisk && topRisk.level === 'CRITICAL' && (
<Alert icon={<AlertTriangle size={13} />} severity="error"
sx={{ mt: 0.75, py: 0.25, fontSize: '0.72rem' }}>
{topRisk.description}
</Alert>
)}
{/* Actions */}
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<Button size="small" variant="contained" onClick={e => {
<Box sx={{ display: 'flex', gap: 0.625, mt: 'auto', pt: 1, flexWrap: 'wrap' }}>
<Button
size="small"
variant="contained"
onClick={e => {
e.stopPropagation()
openInquiryDialog({
propertyTitle: vm.title,
@@ -228,13 +197,19 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
propertyId: vm.propertyId,
})
}}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }}
>
Anfrage
</Button>
{vm.actions.map(a => (
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick} disabled={a.disabled}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
<Button
key={a.id}
size="small"
variant={a.variant === 'primary' ? 'contained' : 'outlined'}
onClick={a.onClick}
disabled={a.disabled}
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}
>
{a.label}
</Button>
))}
@@ -4,7 +4,7 @@ import { MatchScoreDisplay } from './MatchScoreDisplay'
import type { MatchCardViewModel } from './MatchCardViewModel'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#1e3a5f' },
VERIFIED_PORTFOLIO: { label: 'Verified', color: '#152642' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' },
}
@@ -38,10 +38,10 @@ export function MatchCardHeader({ vm, compact }: Props) {
/>
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
<Chip
icon={<Building2 size={10} color="#1e3a5f" />}
icon={<Building2 size={10} color="#152642" />}
label="Ihr Objekt"
size="small"
sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#1e3a5f', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }}
sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#152642', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }}
/>
)}
{vm.assetType && (
@@ -25,7 +25,7 @@ const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#152642' }
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
@@ -93,15 +93,15 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact =
return (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Hart-Kriterien
</Typography>
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.6, borderRadius: 1, mb: softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#152642' }}>
{sb.hardMatchScore}/100 × 60%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{hardContrib} Pkt</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#152642' }}>{hardContrib} Pkt</Typography>
</Box>
{softFactors.length > 0 && (
@@ -10,7 +10,7 @@ export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'L
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{c.dots.map((filled, i) => (
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e2e8f0' }} />
<Box key={i} sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: filled ? c.color : '#e8e7e4' }} />
))}
<Typography sx={{ fontSize: '0.68rem', color: c.color, fontWeight: 600, ml: 0.25 }}>
{c.label}
@@ -74,7 +74,7 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr
size="small"
sx={need.isAnonymous
? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' }
: { bgcolor: '#eff6ff', color: '#1e3a5f', fontSize: 11, height: 20, fontWeight: 500 }
: { bgcolor: '#eff6ff', color: '#152642', fontSize: 11, height: 20, fontWeight: 500 }
}
/>
)}
@@ -5,7 +5,7 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
SHORTLISTED: { label: 'Shortlist', color: '#1e3a5f' },
SHORTLISTED: { label: 'Shortlist', color: '#152642' },
}
export function MatchStatusBadge({ status }: { status?: MatchStatus }) {
@@ -13,7 +13,7 @@ export function ExecutiveSummaryPanel({ match }: Props) {
const rows: { icon: React.ReactNode; label: string; text: string }[] = [
{
icon: <Target size={15} color="#1e3a5f" />,
icon: <Target size={15} color="#152642" />,
label: 'Gesamteignung',
text: match.explainabilitySummary || `${match.matchStrength}-Match mit ${match.matchScore} Punkten`,
},
@@ -2,12 +2,13 @@ import { Box, Chip, Paper, Typography } from '@mui/material'
import { HardHat } from 'lucide-react'
import { calcFitOutInvestment } from '../../lib/fitOutUtils'
import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds'
import { FITOUT_AMORTIZATION_YEARS } from '../../lib/constants'
const FIT_OUT_LABELS: Record<string, string> = {
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
}
const AMORTIZATION_YEARS = 5
const AMORTIZATION_YEARS = FITOUT_AMORTIZATION_YEARS
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
interface Props {
@@ -69,7 +70,7 @@ export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, t
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
const disclaimer = isReadyToMoveIn
? null
: 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.'
: `Ausbaukosten nach CRB/BKP-Normen, amortisiert über ${AMORTIZATION_YEARS} Jahre. Tatsächliche Kosten je nach Ausbauumfang.`
return (
<Paper sx={{ p: 2.5 }}>
@@ -82,7 +82,7 @@ export function InquiryQuickDialog() {
size="small"
disabled={!message.trim()}
startIcon={<Send size={14} />}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
@@ -6,7 +6,7 @@ import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal'
const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#1e3a5f' },
VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#152642' },
MAISON_WORK: { label: 'Maison Work', color: '#0369a1' },
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
}
@@ -101,7 +101,7 @@ export function MatchDetailHeader({ match, property, signal, onBack, onCompare,
size="small"
startIcon={<Columns2 size={14} />}
onClick={onCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Vergleichen
</Button>
@@ -46,7 +46,7 @@ export function MatchDetailHero({
{/* Hero: image first, map fallback */}
{!isFuture && (
property?.images?.[0] ? (
<Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e2e8f0', flexShrink: 0 }}>
<Box sx={{ width: '100%', height: 400, overflow: 'hidden', bgcolor: '#e8e7e4', flexShrink: 0 }}>
<img
src={property.images[0]}
alt={property.title}
@@ -105,7 +105,7 @@ export function MatchDetailHero({
size="small"
startIcon={<Columns2 size={14} />}
onClick={onCompare}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
Vergleichen
</Button>
@@ -28,7 +28,7 @@ export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Pr
variant="contained"
startIcon={<MessageSquare size={14} />}
onClick={onInquire}
sx={{ justifyContent: 'flex-start', bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ justifyContent: 'flex-start', bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Anfrage senden
</Button>
@@ -20,7 +20,7 @@ export function factorLabel(criterion: string): string {
// Convert normalised weight to 1-5 importance level relative to other factors in the same set
export function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#152642' }
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
+2 -2
View File
@@ -41,7 +41,7 @@ function Slide0() {
return (
<Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4">
<Building2 size={48} color="#1e3a5f" />
<Building2 size={48} color="#152642" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Willkommen bei Property Match
@@ -74,7 +74,7 @@ function Slide1() {
return (
<Box className="flex flex-col items-center text-center px-2">
<Box className="mb-4">
<Search size={48} color="#1e3a5f" />
<Search size={48} color="#152642" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
Bedarf beschreiben KI findet Matches
+1 -1
View File
@@ -18,7 +18,7 @@ export function DataCategoryBadgeList({ categories, max }: DataCategoryBadgeList
label={cat}
sx={{
bgcolor: 'rgba(30,58,95,0.07)',
color: '#1e3a5f',
color: '#152642',
border: 'none',
fontSize: '0.68rem',
}}
@@ -156,7 +156,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps
label={entity.value}
sx={{
bgcolor: 'rgba(30,58,95,0.08)',
color: '#1e3a5f',
color: '#152642',
border: 'none',
fontSize: '0.75rem',
cursor: 'default',
+2 -2
View File
@@ -48,10 +48,10 @@ export function MarketSignalFilterBar({ filters, onChange }: MarketSignalFilterB
sx={{
fontSize: '0.65rem',
height: 20,
bgcolor: active ? '#1e3a5f' : 'transparent',
bgcolor: active ? '#152642' : 'transparent',
color: active ? '#fff' : 'text.secondary',
border: '1px solid',
borderColor: active ? '#1e3a5f' : 'divider',
borderColor: active ? '#152642' : 'divider',
'&:hover': { bgcolor: active ? '#162d4a' : 'rgba(0,0,0,0.04)' },
}}
/>
+1 -1
View File
@@ -50,7 +50,7 @@ export function ReviewGatePanel({ gate }: ReviewGatePanelProps) {
{reviewerNotes.length > 0 && (
<Box sx={{ mt: 1, p: 1, bgcolor: 'rgba(30,58,95,0.04)', borderRadius: 1, border: '1px solid rgba(30,58,95,0.1)' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f', display: 'block', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#152642', display: 'block', mb: 0.5 }}>
Reviewer-Informationen
</Typography>
{reviewerNotes.map((note, i) => (
+1 -1
View File
@@ -50,7 +50,7 @@ export function SignalConversionPanel({ signal }: SignalConversionPanelProps) {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<ArrowRight size={16} color="#1e3a5f" />
<ArrowRight size={16} color="#152642" />
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
In Future Availability überführen
</Typography>
+1 -1
View File
@@ -43,7 +43,7 @@ export function SignalInbox({
<Chip
label={isLoading ? '…' : signals.length}
size="small"
sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }}
sx={{ bgcolor: '#152642', color: '#fff', fontSize: '0.7rem', height: 20 }}
/>
</Box>
+4 -4
View File
@@ -16,14 +16,14 @@ export function SignalPipelineStepper({ pipelineState }: SignalPipelineStepperPr
sx={{
mb: 1,
'& .MuiStepIcon-root': { fontSize: '1.6rem', color: '#cbd5e1' },
'& .MuiStepIcon-root.Mui-active': { color: '#1e3a5f' },
'& .MuiStepIcon-root.Mui-active': { color: '#152642' },
'& .MuiStepIcon-root.Mui-completed': { color: '#15803d' },
'& .MuiStepLabel-label': { fontSize: '0.68rem', mt: 0.5, color: '#64748b' },
'& .MuiStepLabel-label.Mui-active': { color: '#1e3a5f', fontWeight: 700 },
'& .MuiStepLabel-label.Mui-active': { color: '#152642', fontWeight: 700 },
'& .MuiStepLabel-label.Mui-completed': { color: '#15803d' },
'& .MuiStepConnector-line': { borderTopWidth: 2, borderColor: '#e2e8f0' },
'& .MuiStepConnector-line': { borderTopWidth: 2, borderColor: '#e8e7e4' },
'& .MuiStepConnector-root.Mui-completed .MuiStepConnector-line': { borderColor: '#15803d' },
'& .MuiStepConnector-root.Mui-active .MuiStepConnector-line': { borderColor: '#1e3a5f' },
'& .MuiStepConnector-root.Mui-active .MuiStepConnector-line': { borderColor: '#152642' },
}}
>
{PIPELINE_STAGE_ORDER.map((stage) => (
+1 -1
View File
@@ -123,7 +123,7 @@ export function SignalPipelineView({ signal }: SignalPipelineViewProps) {
sx={{
textTransform: 'none',
fontSize: '0.8rem',
bgcolor: '#1e3a5f',
bgcolor: '#152642',
'&:hover': { bgcolor: '#162d4a' },
}}
>
@@ -10,7 +10,7 @@ function getStageColor(entry: AuditTrailEntry): string {
const idx = PIPELINE_STAGE_ORDER.indexOf(entry.stage)
if (idx <= 0) return '#94a3b8'
if (idx >= 5) return '#15803d'
if (idx >= 3) return '#1e3a5f'
if (idx >= 3) return '#152642'
if (idx >= 2) return '#4f46e5'
return '#2563eb'
}
+1 -1
View File
@@ -67,7 +67,7 @@ export function SourceCard({ source, selected, onClick }: SourceCardProps) {
mt: 0.25,
}}
>
<Icon size={14} color="#1e3a5f" />
<Icon size={14} color="#152642" />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
+3 -3
View File
@@ -86,7 +86,7 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) {
flexShrink: 0,
}}
>
<SourceIcon size={18} color="#1e3a5f" />
<SourceIcon size={18} color="#152642" />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, lineHeight: 1.3, mb: 0.25 }}>
@@ -166,7 +166,7 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) {
key={a}
size="small"
label={a}
sx={{ bgcolor: 'rgba(30,58,95,0.06)', color: '#1e3a5f', border: 'none', fontSize: '0.72rem' }}
sx={{ bgcolor: 'rgba(30,58,95,0.06)', color: '#152642', border: 'none', fontSize: '0.72rem' }}
/>
))}
</Box>
@@ -194,7 +194,7 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) {
startIcon={isRunning ? <CircularProgress size={12} sx={{ color: '#fff' }} /> : <Play size={14} />}
disabled={isRunning}
onClick={() => triggerRun(source.id)}
sx={{ bgcolor: '#1e3a5f', textTransform: 'none', fontSize: '0.8rem', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', textTransform: 'none', fontSize: '0.8rem', '&:hover': { bgcolor: '#162d4a' } }}
>
{isRunning ? 'Läuft...' : 'Demo-Run starten'}
</Button>
+1 -1
View File
@@ -40,7 +40,7 @@ export function SourceList({
<Chip
label={isLoading ? '…' : sources.length}
size="small"
sx={{ bgcolor: '#1e3a5f', color: '#fff', fontSize: '0.7rem', height: 20 }}
sx={{ bgcolor: '#152642', color: '#fff', fontSize: '0.7rem', height: 20 }}
/>
</Box>
+6 -6
View File
@@ -55,7 +55,7 @@ export function DraggableCard({
boxShadow: isDragOverlay ? '0 8px 24px rgba(0,0,0,0.18)' : '0 1px 3px rgba(0,0,0,0.08)',
'&:hover': isDragOverlay ? {} : {
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
borderColor: isSelected ? '#1e3a5f' : '#bfdbfe',
borderColor: isSelected ? '#152642' : '#bfdbfe',
},
transition: isDragOverlay ? undefined : 'box-shadow 0.15s, border-color 0.15s',
userSelect: 'none',
@@ -83,7 +83,7 @@ export function DraggableCard({
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.125, flexShrink: 0 }}>
{item.inquiryId && onChatClick && (
<Tooltip title="Chat öffnen">
<IconButton size="small" onClick={onChatClick} sx={{ p: 0.25, color: '#1e3a5f', '&:hover': { bgcolor: '#eff6ff' } }}>
<IconButton size="small" onClick={onChatClick} sx={{ p: 0.25, color: '#152642', '&:hover': { bgcolor: '#eff6ff' } }}>
<MessageSquare size={13} />
</IconButton>
</Tooltip>
@@ -93,7 +93,7 @@ export function DraggableCard({
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); navigate(path) }}
sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#1e3a5f', bgcolor: '#eff6ff' } }}
sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#152642', bgcolor: '#eff6ff' } }}
>
<ExternalLink size={13} />
</IconButton>
@@ -154,8 +154,8 @@ export function DraggableCard({
}}
sx={{
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
borderColor: '#1e3a5f', color: '#1e3a5f',
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
borderColor: '#152642', color: '#152642',
'&:hover': { bgcolor: '#eff6ff', borderColor: '#152642' },
}}
>
Zur Konversation
@@ -180,7 +180,7 @@ export function DraggableCard({
}}
sx={{
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' },
}}
>
Anfrage senden
+16 -30
View File
@@ -16,10 +16,10 @@ interface Props {
isPropertyManager?: boolean
}
const FILTER_OPTIONS: { value: FilterSource; label: string; color: string }[] = [
{ value: 'ALL', label: 'Alle', color: '#1e3a5f' },
{ value: 'PLATFORM', label: 'Plattform', color: '#1e3a5f' },
{ value: 'MAISON_WORK', label: 'Maison Work', color: '#0369a1' },
const FILTER_OPTIONS: { value: FilterSource; label: string }[] = [
{ value: 'ALL', label: 'Alle' },
{ value: 'PLATFORM', label: 'Plattform' },
{ value: 'MAISON_WORK', label: 'Maison Work' },
]
const SORT_OPTIONS: { value: SortBy; label: string }[] = [
@@ -28,6 +28,9 @@ const SORT_OPTIONS: { value: SortBy; label: string }[] = [
{ value: 'rent', label: 'Mietpreis' },
]
const ACTIVE_CHIP = { bgcolor: '#152642', color: 'white', border: '1px solid #152642', fontWeight: 600 }
const INACTIVE_CHIP = { bgcolor: 'transparent', color: 'text.secondary', border: '1px solid #e8e7e4', fontWeight: 400 }
export function ResultFilterBar({
filterSource,
onFilterChange,
@@ -45,7 +48,7 @@ export function ResultFilterBar({
{/* Source filter + toggles */}
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.5 }}>
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5 }}>
{FILTER_OPTIONS.map(({ value, label, color }) => {
{FILTER_OPTIONS.map(({ value, label }) => {
const active = filterSource === value
return (
<Chip
@@ -54,12 +57,7 @@ export function ResultFilterBar({
size="small"
clickable
onClick={() => onFilterChange(value)}
sx={{
bgcolor: active ? color : 'transparent',
color: active ? 'white' : 'text.secondary',
border: `1px solid ${active ? color : '#e2e8f0'}`,
fontWeight: active ? 600 : 400,
}}
sx={active ? ACTIVE_CHIP : INACTIVE_CHIP}
/>
)
})}
@@ -67,36 +65,28 @@ export function ResultFilterBar({
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* Future Availability toggle */}
<Chip
size="small"
clickable
icon={<Zap size={11} color={showFutureAvailability ? '#7c3aed' : '#94a3b8'} />}
icon={<Zap size={11} color={showFutureAvailability ? '#5b3f8a' : '#c4c4c4'} />}
label="Future Availability"
onClick={() => onShowFutureAvailabilityChange(!showFutureAvailability)}
sx={{
bgcolor: showFutureAvailability ? '#f3e8ff' : 'transparent',
color: showFutureAvailability ? '#6d28d9' : 'text.disabled',
border: `1px solid ${showFutureAvailability ? '#c4b5fd' : '#e2e8f0'}`,
fontWeight: showFutureAvailability ? 600 : 400,
textDecoration: showFutureAvailability ? 'none' : 'line-through',
...(showFutureAvailability ? ACTIVE_CHIP : INACTIVE_CHIP),
bgcolor: showFutureAvailability ? '#152642' : 'transparent',
'& .MuiChip-icon': { ml: 0.75 },
}}
/>
{/* Eigene Objekte toggle — only for Property Managers */}
{isPropertyManager && (
<Chip
size="small"
clickable
icon={<Building2 size={11} color={showOwnProperties ? '#1e3a5f' : '#94a3b8'} />}
label="Eigene Objekte einblenden"
icon={<Building2 size={11} color={showOwnProperties ? 'white' : '#c4c4c4'} />}
label="Eigene Objekte"
onClick={() => onShowOwnPropertiesChange?.(!showOwnProperties)}
sx={{
bgcolor: showOwnProperties ? 'rgba(30,58,95,0.10)' : 'transparent',
color: showOwnProperties ? '#1e3a5f' : 'text.disabled',
border: `1px solid ${showOwnProperties ? '#1e3a5f' : '#e2e8f0'}`,
fontWeight: showOwnProperties ? 600 : 400,
...(showOwnProperties ? ACTIVE_CHIP : INACTIVE_CHIP),
'& .MuiChip-icon': { ml: 0.75 },
}}
/>
@@ -113,11 +103,7 @@ export function ResultFilterBar({
size="small"
clickable
onClick={() => onSortChange(value)}
sx={{
bgcolor: sortBy === value ? '#1e3a5f' : 'transparent',
color: sortBy === value ? 'white' : 'text.secondary',
border: `1px solid ${sortBy === value ? '#1e3a5f' : '#e2e8f0'}`,
}}
sx={sortBy === value ? ACTIVE_CHIP : INACTIVE_CHIP}
/>
))}
</Stack>
+1 -1
View File
@@ -121,7 +121,7 @@ export function ReviewDetailPanel({
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': {
bgcolor: task.confidenceScore >= 0.7 ? '#1a7a4a' : task.confidenceScore >= 0.5 ? '#d97706' : '#c0392b',
},
@@ -3,7 +3,7 @@ import type { ReviewEntityType } from '../../domain/review'
const CONFIG: Record<ReviewEntityType, { label: string; color: string }> = {
FUTURE_SIGNAL: { label: 'Zukunftssignal', color: '#7c3aed' },
MATCH_EXPLANATION: { label: 'Match-Begründung', color: '#1e3a5f' },
MATCH_EXPLANATION: { label: 'Match-Begründung', color: '#152642' },
LOW_CONFIDENCE_MATCH: { label: 'Niedr. Konfidenz', color: '#ea580c' },
CONTACT_RELEASE: { label: 'Kontaktfreigabe', color: '#0891b2' },
AI_OUTPUT: { label: 'AI-Output', color: '#4f46e5' },
+1 -1
View File
@@ -76,7 +76,7 @@ export function ReviewTaskCard({ task, isSelected, onSelect }: ReviewTaskCardPro
sx={{
height: 3,
borderRadius: 2,
bgcolor: '#e2e8f0',
bgcolor: '#e8e7e4',
'& .MuiLinearProgress-bar': {
bgcolor: task.confidenceScore >= 0.7 ? '#1a7a4a' : task.confidenceScore >= 0.5 ? '#d97706' : '#c0392b',
},
+1 -1
View File
@@ -16,7 +16,7 @@ interface ScoreBarProps {
}
export function ScoreBar({ label, value, weight, color, maxWidth = 200 }: ScoreBarProps) {
const resolved = color ?? COLOR_MAP[matchScoreColor(value)] ?? '#1e3a5f'
const resolved = color ?? COLOR_MAP[matchScoreColor(value)] ?? '#152642'
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
+10 -22
View File
@@ -6,26 +6,15 @@ export type BadgeSemanticVariant = keyof typeof BADGE_COLORS
export interface GenericBadgeProps {
label: string
/**
* Semantic variant key from BADGE_COLORS (preferred).
* Example: semanticVariant="confidenceHigh" instead of color="#1a7a4a"
*/
/** Semantic variant key from BADGE_COLORS (preferred). */
semanticVariant?: BadgeSemanticVariant
/**
* Raw hex color use only when no semantic variant fits.
* Prefer semanticVariant to avoid hardcoded colors.
*/
/** Raw hex color — use only when no semantic variant fits. */
color?: string
/** transparent: semi-opaque bg (color + 18% opacity) | solid: filled bg, white text */
variant?: 'transparent' | 'solid'
/** Adds a 1px border at color 25% opacity (transparent variant only) */
showBorder?: boolean
/** fontWeight 700 instead of 600 */
/** outlined: border + text, no bg | solid: filled bg, white text */
variant?: 'outlined' | 'solid'
bold?: boolean
/** Optional lucide / MUI icon element — sized by the caller */
icon?: ReactElement
size?: 'small' | 'medium'
/** Wraps the badge in a Tooltip */
tooltip?: string
ariaLabel?: string
}
@@ -34,15 +23,14 @@ export function GenericBadge({
label,
semanticVariant,
color: colorProp,
variant = 'transparent',
showBorder = false,
variant = 'outlined',
bold = false,
icon,
size = 'small',
tooltip,
ariaLabel,
}: GenericBadgeProps) {
const color = semanticVariant ? BADGE_COLORS[semanticVariant] : (colorProp ?? '#64748b')
const color = semanticVariant ? BADGE_COLORS[semanticVariant] : (colorProp ?? '#9a9a9a')
const chip = (
<Chip
@@ -51,11 +39,11 @@ export function GenericBadge({
icon={icon}
aria-label={ariaLabel}
sx={{
fontWeight: bold ? 700 : 600,
fontSize: size === 'small' ? '0.7rem' : '0.8125rem',
bgcolor: variant === 'solid' ? color : `${color}18`,
fontWeight: bold ? 700 : 500,
fontSize: size === 'small' ? '0.75rem' : '0.8125rem',
bgcolor: variant === 'solid' ? color : 'transparent',
color: variant === 'solid' ? 'white' : color,
border: variant === 'transparent' && showBorder ? `1px solid ${color}40` : 'none',
border: variant === 'outlined' ? `1px solid ${color}` : 'none',
'& .MuiChip-icon': { ml: 0.5 },
}}
/>
+1 -1
View File
@@ -114,7 +114,7 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
'&:hover': { bgcolor: 'white' },
}}
>
<Map size={14} color="#1e3a5f" />
<Map size={14} color="#152642" />
</IconButton>
</Tooltip>
</Box>
+1 -1
View File
@@ -111,7 +111,7 @@ export function PropertyMap({ lat, lng, label, height = 260, searchCenterLat, se
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#1e3a5f', flexShrink: 0 }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#152642', flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.67rem', color: '#334155' }}>Objekt</Typography>
</Box>
</Box>
+2 -2
View File
@@ -17,7 +17,7 @@ export function ViewToggle({ view, onChange }: Props) {
borderRadius: 0,
width: 32,
height: 32,
bgcolor: view === 'list' ? '#1e3a5f' : 'transparent',
bgcolor: view === 'list' ? '#152642' : 'transparent',
color: view === 'list' ? 'white' : '#64748b',
'&:hover': { bgcolor: view === 'list' ? '#162d4a' : '#f1f5f9' },
}}
@@ -33,7 +33,7 @@ export function ViewToggle({ view, onChange }: Props) {
borderRadius: 0,
width: 32,
height: 32,
bgcolor: view === 'grid' ? '#1e3a5f' : 'transparent',
bgcolor: view === 'grid' ? '#152642' : 'transparent',
color: view === 'grid' ? 'white' : '#64748b',
'&:hover': { bgcolor: view === 'grid' ? '#162d4a' : '#f1f5f9' },
}}
@@ -58,7 +58,7 @@ export function AddToPipelineDialog() {
onClick={handleConfirm}
disabled={isPending}
endIcon={isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Merken
</Button>
@@ -155,7 +155,7 @@ export function AddToShortlistDialog() {
onClick={handleConfirm}
disabled={!canConfirm}
endIcon={isBusy ? <CircularProgress size={14} color="inherit" /> : undefined}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Hinzufügen
</Button>
@@ -80,7 +80,7 @@ export function DecisionBriefDraftPanel({ shortlistId }: Props) {
size="small"
startIcon={<Sparkles size={14} />}
onClick={handleGenerate}
sx={{ bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
>
Brief generieren
</Button>

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