feat: Grundriss-Feature, Listenansicht mit Bildern, Gewerbe-Label, Image-Pool
- Grundriss (FloorPlanSection): PropertyUnit.floorPlanUrl?, Property.floorPlanUrl?; FloorPlanSection in MatchDetail + PropertyDetail; FloorPlanUrlSection in NewListing-Formular - Listenansicht (MatchCardCompact): horizontales Layout mit 120px Bildstreifen, Score-Badge, Asset-Label-Overlay, alle 3 grünen Punkte, Anfrage-Button - Light Industrial → "Gewerbe" überall (NeedInput, NeedCardPreview, CriteriaReviewPanel, newListingConstants, MyListings, propertyHelpers) - "Zum Originalinserat"-Button nur bei Maison-Work-Objekten - Image-Pool: propertyImageResolver mit sequentiellem Pool-Index (keine doppelten Bilder), nur Innenaufnahmen, rotate()-Trick für Sub-Pools - Overlay-Labels (Objekttyp + Stadtteil) in LocationPreview + IntelligenceMatchCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { Bot, Building2, FileText } from 'lucide-react'
|
||||
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
|
||||
import type { InquiryMessage } from '../../domain/inquiry'
|
||||
|
||||
export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
||||
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
|
||||
const isOwnMessage = msg.senderType === 'tenant'
|
||||
const isAI = msg.senderType === 'ai'
|
||||
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
@@ -15,7 +15,7 @@ export function AnfragenMessageBubble({ msg }: { msg: InquiryMessage }) {
|
||||
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
|
||||
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||
{msg.senderName} · {date} {time}
|
||||
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
|
||||
const ASSET_LABELS: Record<string, string> = {
|
||||
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Light Industrial',
|
||||
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||
}
|
||||
|
||||
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
|
||||
|
||||
@@ -14,7 +14,7 @@ const ASSET_OPTIONS = [
|
||||
{ label: 'Retail', value: AssetType.RETAIL },
|
||||
{ label: 'Logistik', value: AssetType.LOGISTICS },
|
||||
{ label: 'Produktion', value: AssetType.PRODUCTION },
|
||||
{ label: 'Light Industrial', value: AssetType.LIGHT_INDUSTRIAL },
|
||||
{ label: 'Gewerbe', value: AssetType.LIGHT_INDUSTRIAL },
|
||||
{ label: 'Gemischt', value: AssetType.MIXED },
|
||||
]
|
||||
|
||||
|
||||
@@ -20,9 +20,11 @@ interface Props {
|
||||
lat?: number
|
||||
lng?: number
|
||||
cityLabel?: string
|
||||
overlayTypeLabel?: string
|
||||
overlayLocationLabel?: string
|
||||
}
|
||||
|
||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Props) {
|
||||
export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||
const tier = getScoreTier(vm.matchScore)
|
||||
const theme = SCORE_THEME[tier]
|
||||
const { currentUser } = useSessionStore()
|
||||
@@ -53,7 +55,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
|
||||
{/* ── Hero zone ── */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} />
|
||||
<LocationPreview imageUrl={imageUrl} lat={lat} lng={lng} cityLabel={cityLabel} height={175} overlayTypeLabel={overlayTypeLabel} overlayLocationLabel={overlayLocationLabel} />
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 10, left: 10,
|
||||
background: theme.gradient, borderRadius: '10px',
|
||||
@@ -142,7 +144,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1.5, pt: 1.25, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
|
||||
@@ -1,119 +1,216 @@
|
||||
import { Alert, Box, Card, Divider, Typography } from '@mui/material'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { MatchCardHeader } from './MatchCardHeader'
|
||||
import { MatchReasonList } from './MatchReasonList'
|
||||
import { TradeoffList } from './TradeoffList'
|
||||
import { MatchDataQualitySummary } from './MatchDataQualitySummary'
|
||||
import { MatchActionToolbar } from './MatchActionToolbar'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import { memo } from 'react'
|
||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||
import { MapPin, Building2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { HeatBadge } from '../shared/HeatBadge'
|
||||
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
|
||||
import { RESULT_TYPE_META } from '../../lib/ds'
|
||||
import { confidenceHex } from '../../lib/utils'
|
||||
import { ScoreInlineBreakdown } from './ScoreInlineBreakdown'
|
||||
import { MatchCardRestrictedState } from './MatchCardRestrictedState'
|
||||
import type { MatchCardViewModel } from './MatchCardViewModel'
|
||||
|
||||
interface Props {
|
||||
vm: MatchCardViewModel
|
||||
imageUrl?: string
|
||||
overlayTypeLabel?: string
|
||||
}
|
||||
|
||||
export function MatchCardCompact({ vm }: Props) {
|
||||
export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, overlayTypeLabel }: Props) {
|
||||
if (vm.isRestricted) return <MatchCardRestrictedState />
|
||||
|
||||
const borderColor = vm.isCompareSelected
|
||||
? '#7c3aed'
|
||||
: vm.isSelected
|
||||
? '#1e3a5f'
|
||||
: 'transparent'
|
||||
const { currentUser } = useSessionStore()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN'
|
||||
|
||||
const tier = getScoreTier(vm.matchScore)
|
||||
const theme = SCORE_THEME[tier]
|
||||
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
|
||||
const confPct = Math.round(vm.confidenceScore * 100)
|
||||
const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH')
|
||||
|
||||
const borderColor = vm.isCompareSelected ? '#7c3aed' : vm.isSelected ? '#1e3a5f' : 'transparent'
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
p: 2.5,
|
||||
mb: 1.5,
|
||||
border: `2px solid ${borderColor}`,
|
||||
opacity: vm.isStaleData ? 0.75 : 1,
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{/* FUTURE_AVAILABILITY disclaimer — mandatory, non-dismissable */}
|
||||
{vm.disclaimer && (
|
||||
<Alert severity="warning" sx={{ mb: 1.5, py: 0.5 }}>
|
||||
{vm.disclaimer}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
mb: 1.5,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: `2px solid ${borderColor}`,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
||||
background: theme.cardBg,
|
||||
opacity: vm.isStaleData ? 0.75 : 1,
|
||||
transition: 'box-shadow 0.15s',
|
||||
'&:hover': { boxShadow: '0 4px 20px rgba(0,0,0,0.11)' },
|
||||
}}>
|
||||
|
||||
{vm.isReviewRequired && (
|
||||
<Alert severity="info" sx={{ mb: 1.5, py: 0.5 }}>
|
||||
Manuelle Überprüfung erforderlich
|
||||
</Alert>
|
||||
)}
|
||||
{/* ── Image strip ── */}
|
||||
<Box sx={{ width: 120, flexShrink: 0, position: 'relative', bgcolor: '#dce8f2' }}>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{
|
||||
width: '100%', height: '100%', minHeight: 120,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'linear-gradient(160deg,#dce8f2 0%,#b8cfe0 100%)',
|
||||
}}>
|
||||
<MapPin size={24} color="#7ba3bf" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Header: score → type → confidence → availability → risk */}
|
||||
<MatchCardHeader vm={vm} compact />
|
||||
{/* Score badge */}
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 8, left: 8,
|
||||
background: theme.gradient, borderRadius: '8px',
|
||||
px: 1, py: 0.25,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: `0 2px 8px ${theme.glow}`,
|
||||
}}>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: '1rem', color: theme.text, lineHeight: 1 }}>
|
||||
{vm.matchScore}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Title + location (max 2 lines) */}
|
||||
<Box sx={{ mt: 1.25, mb: 1.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
|
||||
{/* Asset type label */}
|
||||
{overlayTypeLabel && (
|
||||
<Box sx={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
px: 0.75, py: 0.5,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.65) 0%, transparent 100%)',
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.6rem', fontWeight: 700, color: 'white', letterSpacing: 0.3, lineHeight: 1.3 }}>
|
||||
{overlayTypeLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<Box sx={{ flex: 1, p: 1.75, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
|
||||
{/* Alerts */}
|
||||
{vm.disclaimer && (
|
||||
<Alert severity="warning" sx={{ mb: 1, py: 0.25, fontSize: '0.72rem' }}>{vm.disclaimer}</Alert>
|
||||
)}
|
||||
|
||||
|
||||
{/* Badges row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.625, flexWrap: 'wrap', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||
<HeatBadge propertyId={vm.propertyId} size="sm" />
|
||||
<Chip label={rt.label} size="small"
|
||||
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 10, height: 20 }} />
|
||||
{vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && (
|
||||
<Chip icon={<Building2 size={9} color="#1e3a5f" />} label="Ihr Objekt" size="small"
|
||||
sx={{ bgcolor: 'rgba(30,58,95,0.10)', color: '#1e3a5f', fontWeight: 700, fontSize: 9, height: 18, '& .MuiChip-icon': { ml: 0.5 } }} />
|
||||
)}
|
||||
<Chip label={`${confPct}% Konfidenz`} size="small"
|
||||
sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} />
|
||||
{vm.availabilityLabel && (
|
||||
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||
)}
|
||||
{topRisk && (
|
||||
<Chip
|
||||
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
|
||||
size="small" color="error" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Title + location */}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }} noWrap>
|
||||
{vm.title}
|
||||
</Typography>
|
||||
{vm.propertySubtitle && (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b', fontWeight: 500, mt: 0.125 }} noWrap>
|
||||
<Typography variant="caption" sx={{ color: '#475569', fontWeight: 500, display: 'block', mt: 0.1 }} noWrap>
|
||||
{vm.propertySubtitle}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
||||
<MapPin size={13} color="#64748b" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4, mt: 0.25 }}>
|
||||
<MapPin size={11} color="#64748b" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{vm.locationLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Explainability summary */}
|
||||
{vm.explainabilitySummary && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: '#374151', fontWeight: 500, mt: 0.5, lineHeight: 1.45,
|
||||
overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{vm.explainabilitySummary}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Score breakdown */}
|
||||
{vm.scoreBreakdown && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Reasons — all 3, same style as grid card */}
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{vm.reasons.slice(0, 3).map((r, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.75 }}>
|
||||
<CheckCircle2 size={12} color="#1a7a4a" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: '#1e293b', lineHeight: 1.3 }}>
|
||||
{r.label}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.69rem', color: '#64748b', lineHeight: 1.3 }}>
|
||||
{r.explanation}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Top tradeoff */}
|
||||
{vm.tradeoffs.length > 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5, mt: 0.5 }}>
|
||||
<AlertTriangle size={12} color="#d97706" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.69rem', color: '#92400e', lineHeight: 1.3 }}>
|
||||
{vm.tradeoffs[0].description}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mt: 1.25, pt: 1, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<Button size="small" variant="contained" onClick={e => {
|
||||
e.stopPropagation()
|
||||
openInquiryDialog({
|
||||
propertyTitle: vm.title,
|
||||
location: vm.locationLabel ?? '',
|
||||
matchScore: vm.matchScore,
|
||||
matchId: vm.id,
|
||||
propertyId: vm.propertyId,
|
||||
})
|
||||
}}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' } }}>
|
||||
Anfrage
|
||||
</Button>
|
||||
{vm.actions.map(a => (
|
||||
<Button key={a.id} size="small" variant={a.variant === 'primary' ? 'contained' : 'outlined'}
|
||||
onClick={a.onClick} disabled={a.disabled}
|
||||
sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1 }}>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1.25 }} />
|
||||
|
||||
{/* Score formula — always visible */}
|
||||
{vm.scoreBreakdown && (
|
||||
<Box sx={{ mb: 1.25 }}>
|
||||
<ScoreInlineBreakdown scoreBreakdown={vm.scoreBreakdown} allFactors={vm.allFactors} compact />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Top reason only */}
|
||||
{vm.reasons.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<MatchReasonList reasons={vm.reasons.slice(0, 1)} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Top tradeoff only (compact) */}
|
||||
{vm.tradeoffs.length > 0 && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<TradeoffList tradeoffs={vm.tradeoffs.slice(0, 1)} compact />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Data quality — only critical warning in compact mode */}
|
||||
<MatchDataQualitySummary
|
||||
dataQualityScore={vm.dataQualityScore}
|
||||
missingData={vm.missingData}
|
||||
compact
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<MatchActionToolbar actions={vm.actions} compact />
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Paper, Typography } from '@mui/material'
|
||||
import { FileImage } from 'lucide-react'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface FloorPlan {
|
||||
url: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
plans: FloorPlan[]
|
||||
mb?: number | string
|
||||
}
|
||||
|
||||
export function FloorPlanSection({ plans, mb }: Props) {
|
||||
if (plans.length === 0) return null
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5, ...(mb !== undefined ? { mb } : {}) }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<FileImage size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Grundriss</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{plans.map((p, i) => (
|
||||
<Box key={i}>
|
||||
{p.label && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.75, fontWeight: 600 }}>
|
||||
{p.label}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
component="img"
|
||||
src={p.url}
|
||||
alt={p.label ?? 'Grundriss'}
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: 1,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
maxHeight: 480,
|
||||
bgcolor: '#f8fafc',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { Send } from 'lucide-react'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
import { useMoveStage } from '../../hooks/usePipeline'
|
||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string {
|
||||
@@ -19,6 +20,7 @@ export function InquiryQuickDialog() {
|
||||
const pendingInquiry = useInquiryStore(s => s.pendingInquiry)
|
||||
const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog)
|
||||
const addInquiry = useInquiryStore(s => s.addInquiry)
|
||||
const { mutate: moveStage } = useMoveStage()
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
@@ -31,6 +33,9 @@ export function InquiryQuickDialog() {
|
||||
function handleSend() {
|
||||
if (!pendingInquiry) return
|
||||
addInquiry(pendingInquiry, message)
|
||||
if (pendingInquiry.pipelineItemId) {
|
||||
moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' })
|
||||
}
|
||||
showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success')
|
||||
closeInquiryDialog()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails'
|
||||
import { FloorPlanSection } from './FloorPlanSection'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL, DS_PRE_MARKET } from '../../lib/ds'
|
||||
|
||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||
@@ -132,6 +134,24 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Grundriss */}
|
||||
{(() => {
|
||||
const plans: Array<{ url: string; label?: string }> = []
|
||||
if (matchedUnit?.floorPlanUrl) {
|
||||
const lbl = matchedUnit.unitLabel
|
||||
? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}`
|
||||
: FLOOR_LABEL(matchedUnit.floorLevel)
|
||||
plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined })
|
||||
} else {
|
||||
units.filter(u => u.floorPlanUrl).forEach(u => {
|
||||
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||
plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||
})
|
||||
}
|
||||
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||
return <FloorPlanSection plans={plans} />
|
||||
})()}
|
||||
|
||||
{/* Beschreibung */}
|
||||
{property.description && (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
@@ -157,7 +177,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{property.dataQuality.lastVerifiedAt && (
|
||||
<KeyFactRow label="Zuletzt verifiziert" value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })} />
|
||||
)}
|
||||
{property.sourceUrl && (
|
||||
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Button size="small" variant="outlined" endIcon={<ExternalLink size={12} />} href={property.sourceUrl} target="_blank" rel="noopener noreferrer" sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_BORDER.strong, color: DS_TEXT.primary }}>
|
||||
Zum Originalinserat
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Train,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
import {
|
||||
ASSET_LABELS,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
SOURCE_LABELS,
|
||||
UnitRow,
|
||||
} from './MatchDetailPropertyDetails'
|
||||
import { FloorPlanSection } from './FloorPlanSection'
|
||||
import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds'
|
||||
|
||||
interface PropertyDetailPublicSectionsProps {
|
||||
@@ -238,6 +240,18 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* ── Grundriss ── */}
|
||||
{(() => {
|
||||
const allUnits = property.units ?? []
|
||||
const plans: Array<{ url: string; label?: string }> = []
|
||||
allUnits.filter(u => u.floorPlanUrl).forEach(u => {
|
||||
const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel)
|
||||
plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined })
|
||||
})
|
||||
if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl })
|
||||
return <FloorPlanSection plans={plans} mb={2} />
|
||||
})()}
|
||||
|
||||
{/* ── Beschreibung ── */}
|
||||
{property.description && (
|
||||
<Paper sx={{ mb: 2, p: 2.5 }}>
|
||||
@@ -270,7 +284,7 @@ export function PropertyDetailPublicSections({ property, highlightUnitId }: Prop
|
||||
value={new Date(property.dataQuality.lastVerifiedAt).toLocaleDateString('de-CH', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
/>
|
||||
)}
|
||||
{property.sourceUrl && (
|
||||
{property.sourceUrl && property.resultType === ResultType.MAISON_WORK && (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Box, Card, TextField, Typography } from '@mui/material'
|
||||
|
||||
interface Props {
|
||||
floorPlanUrl: string
|
||||
onFloorPlanUrlChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Grundriss (optional)</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
URL zu einem Grundrissbild — hilft Interessenten, sich die Fläche vorzustellen.
|
||||
</Typography>
|
||||
<TextField
|
||||
label="Grundriss-URL"
|
||||
value={floorPlanUrl}
|
||||
onChange={e => onFloorPlanUrlChange(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="https://…"
|
||||
/>
|
||||
{floorPlanUrl && (
|
||||
<Box
|
||||
component="img"
|
||||
src={floorPlanUrl}
|
||||
alt="Grundriss Vorschau"
|
||||
sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export { TechnicalDetailsSection } from './TechnicalDetailsSection'
|
||||
export { ImageUrlSection } from './ImageUrlSection'
|
||||
export { ContactSection } from './ContactSection'
|
||||
export { CreatedScreen } from './CreatedScreen'
|
||||
export { FloorPlanUrlSection } from './FloorPlanUrlSection'
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Box, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, Card, Chip, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { ExternalLink, MapPin, MessageSquare } from 'lucide-react'
|
||||
import { ExternalLink, MapPin, MessageSquare, Send, MessagesSquare } from 'lucide-react'
|
||||
import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay'
|
||||
import { HeatBadge } from '../shared'
|
||||
import type { PipelineItem } from '../../domain/pipeline'
|
||||
import { STAGES, RESULT_TYPE_META } from './pipelineConstants'
|
||||
import { detailPath } from './pipelineUtils'
|
||||
import { useInquiryStore } from '../../stores/inquiryStore'
|
||||
|
||||
// ── DraggableCard ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,8 +26,9 @@ export function DraggableCard({
|
||||
onChatClick?: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog)
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: item.id })
|
||||
const stageConfig = STAGES.find(s => s.key === item.stage)!
|
||||
const stageConfig = STAGES.find(s => s.key === item.stage) ?? STAGES[0]
|
||||
const path = detailPath(item)
|
||||
|
||||
const style = !isDragOverlay ? {
|
||||
@@ -87,7 +89,7 @@ export function DraggableCard({
|
||||
</Tooltip>
|
||||
)}
|
||||
{path && (
|
||||
<Tooltip title="Objekt öffnen">
|
||||
<Tooltip title="Match öffnen">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(path) }}
|
||||
@@ -135,6 +137,57 @@ export function DraggableCard({
|
||||
{item.notes}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Row 6: Anfrage / Konversation */}
|
||||
{!isDragOverlay && (
|
||||
<Box sx={{ mt: 1, pt: 0.75, borderTop: '1px solid #f1f5f9' }}>
|
||||
{item.stage !== 'SAVED' || item.inquiryId ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<MessagesSquare size={11} />}
|
||||
fullWidth
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
navigate(`/demand/anfragen?inquiry=${item.inquiryId}`)
|
||||
}}
|
||||
sx={{
|
||||
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||
borderColor: '#1e3a5f', color: '#1e3a5f',
|
||||
'&:hover': { bgcolor: '#eff6ff', borderColor: '#1e3a5f' },
|
||||
}}
|
||||
>
|
||||
Zur Konversation
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<Send size={11} />}
|
||||
fullWidth
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
openInquiryDialog({
|
||||
propertyTitle: item.title,
|
||||
location: item.propertyAddress ?? item.location,
|
||||
areaLabel: item.areaLabel,
|
||||
rentLabel: item.rentLabel,
|
||||
matchScore: item.matchScore,
|
||||
pipelineItemId: item.id,
|
||||
})
|
||||
}}
|
||||
sx={{
|
||||
fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600,
|
||||
bgcolor: '#1e3a5f', '&:hover': { bgcolor: '#162d4a' },
|
||||
}}
|
||||
>
|
||||
Anfrage senden
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,19 +4,17 @@ export { RESULT_TYPE_META } from '../../lib/ds'
|
||||
// ── Stage config ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const STAGES = [
|
||||
{ key: 'SAVED' as PipelineStage, label: 'Gemerkt', color: '#475569', bgColor: '#f8fafc' },
|
||||
{ key: 'DISCOVERED' as PipelineStage, label: 'Entdeckt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||
{ key: 'QUALIFIED' as PipelineStage, label: 'Qualifiziert', color: '#1e3a5f', bgColor: '#eff6ff' },
|
||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
|
||||
{ key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' },
|
||||
{ key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' },
|
||||
{ key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' },
|
||||
{ key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' },
|
||||
{ key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' },
|
||||
{ key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' },
|
||||
] as const
|
||||
|
||||
export const NEXT_STAGE: Partial<Record<PipelineStage, { key: PipelineStage; label: string }>> = {
|
||||
SAVED: { key: 'DISCOVERED', label: 'Als entdeckt markieren' },
|
||||
DISCOVERED: { key: 'QUALIFIED', label: 'Qualifizieren' },
|
||||
QUALIFIED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||
SAVED: { key: 'CONTACTED', label: 'Anfrage senden' },
|
||||
CONTACTED: { key: 'VISITED', label: 'Besichtigung planen' },
|
||||
VISITED: { key: 'NEGOTIATION', label: 'Verhandlung starten' },
|
||||
NEGOTIATION: { key: 'CLOSED_WON', label: 'Als gewonnen markieren' },
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { PipelineItem } from '../../domain/pipeline'
|
||||
export { matchScoreHex as scoreColor } from '../../lib/utils'
|
||||
|
||||
export function detailPath(item: PipelineItem): string | null {
|
||||
// propertyId is always stable across sessions — prefer it
|
||||
// Both 'match-XXX' (static mock) and 'm__...' (deterministic dynamic) IDs are stable across sessions
|
||||
const isStableMatchId = item.matchId?.startsWith('match-') || item.matchId?.startsWith('m__')
|
||||
if (isStableMatchId) return `/demand/results/${item.matchId}`
|
||||
if (item.propertyId) return `/demand/property/${item.propertyId}`
|
||||
// matchId / UUID only works in the same session (matchStore is ephemeral)
|
||||
if (item.matchId) return `/demand/results/${item.matchId}`
|
||||
if (item.id.startsWith('match-')) return `/demand/results/${item.id}`
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router'
|
||||
import { MatchCardCompact } from '../match-card/MatchCardCompact'
|
||||
import { IntelligenceMatchCard } from '../match-card/IntelligenceMatchCard'
|
||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||
import { resolveImageLabel } from '../../lib/propertyImageResolver'
|
||||
import { useCompareStore } from '../../stores/compareStore'
|
||||
import { usePipelineStore } from '../../stores/pipelineStore'
|
||||
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
|
||||
@@ -97,6 +98,17 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
||||
? result.property.location.city
|
||||
: result.signal.locationHint ?? undefined
|
||||
|
||||
const overlayLabels = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? resolveImageLabel({
|
||||
assetType: result.property.assetType,
|
||||
city: result.property.location.city,
|
||||
district: result.property.location.district,
|
||||
prestige: result.property.softFactors?.prestige,
|
||||
floorLevel: result.property.floorLevel,
|
||||
propertyId: result.property.id,
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<IntelligenceMatchCard
|
||||
vm={vm}
|
||||
@@ -104,9 +116,25 @@ export function UnifiedResultCard({ result, view = 'list' }: Props) {
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
cityLabel={cityLabel}
|
||||
overlayTypeLabel={overlayLabels?.type}
|
||||
overlayLocationLabel={overlayLabels?.location}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <MatchCardCompact vm={vm} />
|
||||
const listImageUrl = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? result.property.images?.[0]
|
||||
: undefined
|
||||
const listOverlay = result.resultType !== 'FUTURE_AVAILABILITY'
|
||||
? resolveImageLabel({
|
||||
assetType: result.property.assetType,
|
||||
city: result.property.location.city,
|
||||
district: result.property.location.district,
|
||||
prestige: result.property.softFactors?.prestige,
|
||||
floorLevel: result.property.floorLevel,
|
||||
propertyId: result.property.id,
|
||||
})
|
||||
: null
|
||||
|
||||
return <MatchCardCompact vm={vm} imageUrl={listImageUrl} overlayTypeLabel={listOverlay?.type} />
|
||||
}
|
||||
|
||||
@@ -9,11 +9,10 @@ interface Props {
|
||||
address?: string
|
||||
cityLabel?: string
|
||||
height?: number
|
||||
overlayTypeLabel?: string
|
||||
overlayLocationLabel?: string
|
||||
}
|
||||
|
||||
// TODO: replace placeholder with Google Maps Static API:
|
||||
// https://maps.googleapis.com/maps/api/staticmap?center={lat},{lng}&zoom=15&size=800x400&key=YOUR_KEY
|
||||
|
||||
function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
||||
if (lat != null && lng != null) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||
@@ -24,7 +23,7 @@ function buildMapsUrl(lat?: number, lng?: number, address?: string): string {
|
||||
return 'https://www.google.com/maps'
|
||||
}
|
||||
|
||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180 }: Props) {
|
||||
export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height = 180, overlayTypeLabel, overlayLocationLabel }: Props) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const mapsUrl = buildMapsUrl(lat, lng, address)
|
||||
const showImage = !!imageUrl && !imgError
|
||||
@@ -60,6 +59,42 @@ export function LocationPreview({ imageUrl, lat, lng, address, cityLabel, height
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Bottom-left overlay: type + location */}
|
||||
{(overlayTypeLabel || overlayLocationLabel) && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
px: 1.25,
|
||||
py: 0.75,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.58) 0%, rgba(0,0,0,0) 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
{overlayTypeLabel && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.68rem', fontWeight: 700, color: 'white',
|
||||
bgcolor: 'rgba(255,255,255,0.18)', backdropFilter: 'blur(4px)',
|
||||
px: 0.75, py: 0.25, borderRadius: 0.75, lineHeight: 1.4,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{overlayTypeLabel}
|
||||
</Typography>
|
||||
)}
|
||||
{overlayLocationLabel && (
|
||||
<Typography sx={{ fontSize: '0.68rem', color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.4 }}>
|
||||
{overlayLocationLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Google Maps button */}
|
||||
<Tooltip title="In Google Maps öffnen" placement="top">
|
||||
<IconButton
|
||||
|
||||
@@ -10,7 +10,7 @@ export function getAssetTypeLabel(type: AssetType): string {
|
||||
GASTRO: 'Gastro',
|
||||
PRODUCTION: 'Produktion',
|
||||
MIXED: 'Gemischt',
|
||||
LIGHT_INDUSTRIAL: 'Leichtindustrie',
|
||||
LIGHT_INDUSTRIAL: 'Gewerbe',
|
||||
UNKNOWN: 'Unbekannt',
|
||||
}
|
||||
return labels[type] ?? type
|
||||
|
||||
Reference in New Issue
Block a user