feat: Reale Jahresbelastung, must-have fixes, fitOut data coverage
Reale Jahresbelastung (Change 6): - FitOutCostPanel zeigt Jahresmiete + amortisierte Ausbaukosten für alle fitOut-Werte - FULL/PREMIUM: Ausbau CHF 0 (bezugsfertig), SHELL/BASIC: CRB/BKP-Richtwerte amortisiert über 5 J. - Match-Card-Chip zeigt geschätzte Investition (orange wenn >100k) - FitOutInvestment-Typ + calcFitOutInvestment() in fitOutUtils.ts - BackendAIService + MockAIService mit generateFitOutAdvice (IAIService-Interface) Must-have Kriterien (Nicht prüfbar Fix): - ÖV-Anbindung: Minutengrenze aus Freitext extrahiert, gegen publicTransportMinutes geprüft - Mindestfläche: m²-Wert aus Freitext extrahiert, gegen areaSqm geprüft - Ausbaugrad: neues Keyword-Rule für FULL/PREMIUM fitOut-Datenpflege: - fitOut-Werte zu 32 fehlenden Properties ergänzt (Logistik=SHELL, Standard=BASIC, Modern=FULL) - MAB-Werte (200/150/250 CHF/m²) zu 3 BASIC-Objekten hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
|
||||
@@ -73,6 +73,44 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
|
||||
slotProps={{ htmlInput: { min: 0, max: 360, step: 12 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Eigenes Ausbaubudget (max. CHF/m²)</Typography>
|
||||
<TextField
|
||||
size="small" type="number" fullWidth placeholder="z.B. 200"
|
||||
value={c.fitOutBudgetMaxPerSqm ?? ''}
|
||||
onChange={e => set({ ...c, fitOutBudgetMaxPerSqm: parseInt(e.target.value) || undefined })}
|
||||
slotProps={{ htmlInput: { min: 0, max: 5000, step: 50 } }}
|
||||
helperText="Ihr Beitrag — exkl. MAB des Vermieters"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Divisibility */}
|
||||
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid #f1f5f9' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={c.requiresDivisibility ?? false}
|
||||
onChange={e => set({ ...c, requiresDivisibility: e.target.checked || undefined })}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="caption" sx={{ fontWeight: 600 }}>Fläche muss teilbar sein</Typography>}
|
||||
sx={{ mb: c.requiresDivisibility ? 1 : 0 }}
|
||||
/>
|
||||
{c.requiresDivisibility && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Mindesteinheit (m²)</Typography>
|
||||
<TextField
|
||||
size="small" type="number"
|
||||
placeholder="z.B. 150"
|
||||
value={c.minDivisibleUnit ?? ''}
|
||||
onChange={e => set({ ...c, minDivisibleUnit: parseInt(e.target.value) || undefined })}
|
||||
sx={{ width: 160 }}
|
||||
slotProps={{ htmlInput: { min: 10, max: 5000, step: 10 } }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material'
|
||||
import { Box, Card, Chip, FormControlLabel, Slider, Stack, Switch, TextField, Typography } from '@mui/material'
|
||||
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
|
||||
import { AssetType } from '../../domain/enums'
|
||||
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
|
||||
@@ -34,6 +34,8 @@ const BUDGET_PRESETS = [
|
||||
|
||||
const LOCATION_PRESETS = ['Zürich', 'Zürich-West', 'Zürich-Nord', 'Bern', 'Basel', 'Luzern']
|
||||
|
||||
const RADIUS_MARKS = [5, 10, 20, 30, 50, 100].map(v => ({ value: v, label: v === 5 || v === 100 ? `${v} km` : `${v}` }))
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>
|
||||
@@ -161,7 +163,27 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mb: (c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) > 0 ? 0 : 2.5 }} />
|
||||
{(c.preferredLocations?.filter(l => !LOCATION_PRESETS.includes(l)).length ?? 0) === 0 && <Box sx={{ mb: 1.5 }} />}
|
||||
|
||||
{/* Radius */}
|
||||
<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' }}>
|
||||
{c.searchRadius ?? 30} km
|
||||
</Typography>
|
||||
</Box>
|
||||
<Slider
|
||||
value={c.searchRadius ?? 30}
|
||||
min={1}
|
||||
max={100}
|
||||
marks={RADIUS_MARKS}
|
||||
step={1}
|
||||
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
|
||||
size="small"
|
||||
sx={{ color: '#1e3a5f', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Budget */}
|
||||
<FieldLabel>Budget (max CHF/m²/Jahr)</FieldLabel>
|
||||
@@ -220,6 +242,30 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Anonymous search */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, mt: 0.5, py: 1, px: 1.5, borderRadius: 1.5, bgcolor: c.isAnonymous ? 'rgba(124,58,237,0.07)' : '#f8fafc', border: `1px solid ${c.isAnonymous ? '#7c3aed' : '#e2e8f0'}` }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: c.isAnonymous ? '#7c3aed' : '#334155' }}>
|
||||
Anonyme Suche
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.65rem' }}>
|
||||
Firmenname wird nicht an Vermieter übermittelt
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={c.isAnonymous ?? false}
|
||||
onChange={e => set({ ...c, isAnonymous: e.target.checked })}
|
||||
sx={{ '& .MuiSwitch-thumb': { bgcolor: c.isAnonymous ? '#7c3aed' : undefined }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' } }}
|
||||
/>
|
||||
}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<NeedExtendedRequirements criteria={c} onChange={set} />
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -115,6 +115,35 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl, o
|
||||
{vm.availabilityLabel && (
|
||||
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 10, height: 20 }} />
|
||||
)}
|
||||
{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} m²`}
|
||||
size="small" variant="outlined"
|
||||
sx={{ fontSize: 10, height: 20, borderColor: '#7c3aed', color: '#7c3aed' }}
|
||||
/>
|
||||
)}
|
||||
{topRisk && (
|
||||
<Chip
|
||||
label={topRisk.level === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ResultType } from '../../domain/enums'
|
||||
import type { TradeOff, Risk, MissingDataItem, ScoreFactor } from '../../domain/match'
|
||||
import type { PropertyUnit } from '../../domain/property'
|
||||
import type { FitOutInvestment } from '../../lib/fitOutUtils'
|
||||
|
||||
export type MatchCardVariant = 'compact' | 'expanded' | 'review' | 'compare-mini'
|
||||
|
||||
@@ -67,6 +68,15 @@ export interface MatchCardViewModel {
|
||||
signalIsControlled?: boolean
|
||||
signalAreaSqmEstimate?: number
|
||||
|
||||
// Divisibility (Teilbarkeit)
|
||||
isDivisible?: boolean
|
||||
minDivisibleUnitSqm?: number
|
||||
|
||||
// Fit-out signal
|
||||
fitOutLabel?: string // e.g. "FULL — bezugsfertig", "BASIC + CHF 200 MAB"
|
||||
fitOutViable?: boolean // true when budget bridges the gap
|
||||
fitOutInvestment?: FitOutInvestment
|
||||
|
||||
// Unit-first display (primary)
|
||||
propertySubtitle?: string // building name shown below the unit title
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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'
|
||||
|
||||
const FIT_OUT_LABELS: Record<string, string> = {
|
||||
SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau',
|
||||
}
|
||||
|
||||
const AMORTIZATION_YEARS = 5
|
||||
const READY_TO_MOVE_IN = new Set(['FULL', 'PREMIUM'])
|
||||
|
||||
interface Props {
|
||||
fitOut: string
|
||||
areaSqm: number
|
||||
mabPerSqm: number
|
||||
rentPricePerSqm: number
|
||||
tenantBudgetPerSqm?: number
|
||||
}
|
||||
|
||||
function chf(value: number): string {
|
||||
return `CHF ${Math.round(value).toLocaleString('de-CH')}.–`
|
||||
}
|
||||
|
||||
function chfRange(min: number, max: number): string {
|
||||
if (Math.round(min) === Math.round(max)) return chf(min)
|
||||
return `${chf(min)} – ${chf(max)}`
|
||||
}
|
||||
|
||||
interface RowProps { label: string; value: string; sub?: string; isTotal?: boolean; isWarning?: boolean }
|
||||
|
||||
function Row({ label, value, sub, isTotal, isWarning }: RowProps) {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
|
||||
py: 0.875, borderBottom: isTotal ? 'none' : `1px solid ${DS_BORDER.default}`,
|
||||
bgcolor: isTotal ? (isWarning ? DS_SURFACE.warning.bg : DS_SURFACE.success.bg) : 'transparent',
|
||||
px: isTotal ? 1.5 : 0, mx: isTotal ? -1.5 : 0, borderRadius: isTotal ? 1 : 0,
|
||||
}}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 400 }}>{label}</Typography>
|
||||
{sub && <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>{sub}</Typography>}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: isTotal ? 700 : 500, color: isTotal ? DS_TEXT.primary : DS_TEXT.secondary, flexShrink: 0, ml: 2 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function FitOutCostPanel({ fitOut, areaSqm, mabPerSqm, rentPricePerSqm, tenantBudgetPerSqm = 0 }: Props) {
|
||||
const rentPerYear = rentPricePerSqm * areaSqm
|
||||
const isReadyToMoveIn = READY_TO_MOVE_IN.has(fitOut)
|
||||
const fitOutLabel = FIT_OUT_LABELS[fitOut] ?? fitOut
|
||||
|
||||
const investment = isReadyToMoveIn
|
||||
? null
|
||||
: calcFitOutInvestment(fitOut, areaSqm, mabPerSqm, tenantBudgetPerSqm)
|
||||
|
||||
const fitOutPerYear = investment && !investment.isFullyCovered ? {
|
||||
min: Math.round(investment.netTotal.min / AMORTIZATION_YEARS),
|
||||
max: Math.round(investment.netTotal.max / AMORTIZATION_YEARS),
|
||||
} : { min: 0, max: 0 }
|
||||
|
||||
const totalPerYear = {
|
||||
min: rentPerYear + fitOutPerYear.min,
|
||||
max: rentPerYear + fitOutPerYear.max,
|
||||
}
|
||||
const isWarning = !isReadyToMoveIn && totalPerYear.max > rentPerYear * 1.3
|
||||
const disclaimer = isReadyToMoveIn
|
||||
? null
|
||||
: 'Ausbaukosten nach CRB/BKP-Normen, amortisiert über 5 Jahre. Tatsächliche Kosten je nach Ausbauumfang.'
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<HardHat size={15} color={DS_TEXT.secondary} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Reale Jahresbelastung</Typography>
|
||||
{!isReadyToMoveIn && (
|
||||
<Chip
|
||||
label="CRB/BKP Richtwerte"
|
||||
size="small"
|
||||
sx={{ ml: 'auto', bgcolor: DS_SURFACE.blue.bg, color: DS_TEXT.signalDark, fontSize: 10, height: 20, border: `1px solid ${DS_SURFACE.blue.border}` }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 1.5, border: `1px solid ${DS_BORDER.default}`, borderRadius: 1.5, overflow: 'hidden' }}>
|
||||
<Row
|
||||
label="Jahresmiete"
|
||||
value={chf(rentPerYear)}
|
||||
sub={`CHF ${rentPricePerSqm}/m²/Jahr × ${areaSqm.toLocaleString('de-CH')} m²`}
|
||||
/>
|
||||
{isReadyToMoveIn ? (
|
||||
<Row
|
||||
label="Ausbau"
|
||||
value="CHF 0.–"
|
||||
sub={`${fitOutLabel} — bezugsfertig`}
|
||||
/>
|
||||
) : investment?.isFullyCovered ? (
|
||||
<Row
|
||||
label="Ausbau (amort.)"
|
||||
value="CHF 0.–"
|
||||
sub={`CHF ${mabPerSqm}/m² MAB deckt Ausbaukosten vollständig`}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label={`Ausbau (amort. ${AMORTIZATION_YEARS} J.)`}
|
||||
value={chfRange(fitOutPerYear.min, fitOutPerYear.max)}
|
||||
sub={
|
||||
`CHF ${investment?.grossPerSqm.min}–${investment?.grossPerSqm.max}/m² (${fitOutLabel})` +
|
||||
(mabPerSqm > 0 ? ` abzgl. CHF ${mabPerSqm} MAB` : '') +
|
||||
` × ${areaSqm.toLocaleString('de-CH')} m² ÷ ${AMORTIZATION_YEARS} J.`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Total/Jahr"
|
||||
value={chfRange(totalPerYear.min, totalPerYear.max)}
|
||||
isTotal
|
||||
isWarning={isWarning}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{disclaimer && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 1.25 }}>
|
||||
{disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,10 @@ interface MatchDetailHeroProps {
|
||||
keyFacts: Array<{ label: string; value: string }>
|
||||
onCompare: () => void
|
||||
onShortlist: () => void
|
||||
searchCenterLat?: number
|
||||
searchCenterLng?: number
|
||||
searchRadiusKm?: number
|
||||
searchLabel?: string
|
||||
}
|
||||
|
||||
export function MatchDetailHero({
|
||||
@@ -32,6 +36,10 @@ export function MatchDetailHero({
|
||||
keyFacts,
|
||||
onCompare,
|
||||
onShortlist,
|
||||
searchCenterLat,
|
||||
searchCenterLng,
|
||||
searchRadiusKm,
|
||||
searchLabel,
|
||||
}: MatchDetailHeroProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -51,6 +59,10 @@ export function MatchDetailHero({
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={340}
|
||||
searchCenterLat={searchCenterLat}
|
||||
searchCenterLng={searchCenterLng}
|
||||
searchRadiusKm={searchRadiusKm}
|
||||
searchLabel={searchLabel}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, Chip, Paper, Typography } from '@mui/material'
|
||||
import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||
import { Building2, Clock, ExternalLink, HardHat, Info, Layers, Tag, Train, TrendingUp } from 'lucide-react'
|
||||
import { useMatchDetail } from '../../hooks/useMatches'
|
||||
import { ResultType } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
@@ -63,6 +63,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp
|
||||
{property.breakoutOption && <KeyFactRow label="Break-out Option" value={property.breakoutOptionDate ? new Date(property.breakoutOptionDate).toLocaleDateString('de-CH', { month: 'long', year: 'numeric' }) : 'Ja'} />}
|
||||
{property.riskLevel && <KeyFactRow label="Risikoeinschätzung" value={RISK_LABELS[property.riskLevel] ?? property.riskLevel} />}
|
||||
{property.expansionPotentialSqm != null && <KeyFactRow label="Ausbaupotenzial" value={`+${property.expansionPotentialSqm.toLocaleString('de-CH')} m²`} />}
|
||||
{property.hardFacts?.fitOut && (() => {
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
|
||||
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
|
||||
return <KeyFactRow label="Ausbaustandard" value={mab ? `${base} + CHF ${mab}/m² MAB` : base} />
|
||||
})()}
|
||||
</Paper>
|
||||
|
||||
{/* Eigenschaften */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { Property } from '../../domain/property'
|
||||
import type { Need } from '../../domain/need'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
import { DS_TEXT, DS_BORDER, DS_BG } from '../../lib/ds'
|
||||
import { lookupCityCoords } from '../../lib/locationIntelligence'
|
||||
|
||||
type Match = NonNullable<ReturnType<typeof useMatchDetail>['data']>
|
||||
|
||||
@@ -40,6 +41,9 @@ export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown
|
||||
}: Props) {
|
||||
const [showFullAnalysis, setShowFullAnalysis] = useState(false)
|
||||
|
||||
const searchCenter = need?.preferredLocations?.[0] ? lookupCityCoords(need.preferredLocations[0]) : null
|
||||
const searchLabel = need?.preferredLocations?.[0]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Full analysis toggle ── */}
|
||||
@@ -77,6 +81,10 @@ export const MatchDetailScoreBreakdown = memo(function MatchDetailScoreBreakdown
|
||||
lng={property.location.coordinates.lng}
|
||||
label={property.title}
|
||||
height={220}
|
||||
searchCenterLat={searchCenter?.lat}
|
||||
searchCenterLng={searchCenter?.lng}
|
||||
searchRadiusKm={need?.searchRadius}
|
||||
searchLabel={searchLabel}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Chip, Paper, Typography } from '@mui/material'
|
||||
import { Banknote, Calendar, MapPin, Maximize2, Tag } from 'lucide-react'
|
||||
import { Banknote, Calendar, HardHat, MapPin, Maximize2, Tag } from 'lucide-react'
|
||||
import type { Match } from '../../domain/match'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
@@ -42,6 +42,16 @@ export function PropertyOverviewPanel({ match, property, signal }: Props) {
|
||||
{ icon: <Banknote size={15} />, label: 'Mietpreis', value: property?.rentPricePerSqm ? `CHF ${property.rentPricePerSqm}/m²/Jahr` : '–' },
|
||||
{ icon: <Calendar size={15} />, label: 'Verfügbar ab', value: property?.availabilityDate ?? '–' },
|
||||
{ icon: <Tag size={15} />, label: 'Objekttyp', value: property?.assetType ?? '–' },
|
||||
...(property?.hardFacts?.fitOut ? [{
|
||||
icon: <HardHat size={15} />,
|
||||
label: 'Ausbaustandard',
|
||||
value: (() => {
|
||||
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
|
||||
const base = LABELS[property.hardFacts!.fitOut!] ?? property.hardFacts!.fitOut!
|
||||
const mab = property.hardFacts!.mieterausbaubeitragPerSqm
|
||||
return mab ? `${base} + CHF ${mab}/m² MAB` : base
|
||||
})(),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,4 +12,5 @@ export { MissingInformationPanel } from './MissingInformationPanel'
|
||||
export { SourceProvenancePanel } from './SourceProvenancePanel'
|
||||
export { FutureAvailabilityContextPanel } from './FutureAvailabilityContextPanel'
|
||||
export { NextActionsPanel } from './NextActionsPanel'
|
||||
export { FitOutCostPanel } from './FitOutCostPanel'
|
||||
export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Card, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
|
||||
import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants'
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +10,12 @@ interface Props {
|
||||
onParkingChange: (v: string) => void
|
||||
ceilingHeight: string
|
||||
onCeilingHeightChange: (v: string) => void
|
||||
mieterausbaubeitrag: string
|
||||
onMieterausbaubeitragChange: (v: string) => void
|
||||
isFlexible: boolean
|
||||
onIsFlexibleChange: (v: boolean) => void
|
||||
minLettableSqm: string
|
||||
onMinLettableSqmChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function TechnicalDetailsSection({
|
||||
@@ -17,6 +23,9 @@ export function TechnicalDetailsSection({
|
||||
fitOut, onFitOutChange,
|
||||
parking, onParkingChange,
|
||||
ceilingHeight, onCeilingHeightChange,
|
||||
mieterausbaubeitrag, onMieterausbaubeitragChange,
|
||||
isFlexible, onIsFlexibleChange,
|
||||
minLettableSqm, onMinLettableSqmChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card sx={{ p: 3, mb: 3 }}>
|
||||
@@ -35,6 +44,14 @@ export function TechnicalDetailsSection({
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
value={mieterausbaubeitrag}
|
||||
onChange={e => onMieterausbaubeitragChange(e.target.value)}
|
||||
size="small" type="number" fullWidth
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters"
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze" value={parking}
|
||||
onChange={e => onParkingChange(e.target.value)}
|
||||
@@ -46,6 +63,40 @@ export function TechnicalDetailsSection({
|
||||
size="small" type="number" slotProps={{ htmlInput: { step: 0.1, min: 2 } }} fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Divisibility */}
|
||||
<Box sx={{ mt: 2.5, pt: 2, borderTop: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={isFlexible}
|
||||
onChange={e => onIsFlexibleChange(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>Fläche ist teilbar</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Mieter können auch nur einen Teil der Fläche mieten
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
sx={{ m: 0, flex: 1 }}
|
||||
/>
|
||||
{isFlexible && (
|
||||
<TextField
|
||||
label="Mindesteinheit (m²)"
|
||||
value={minLettableSqm}
|
||||
onChange={e => onMinLettableSqmChange(e.target.value)}
|
||||
size="small"
|
||||
type="number"
|
||||
sx={{ width: 180 }}
|
||||
slotProps={{ htmlInput: { min: 10, step: 10 } }}
|
||||
helperText="Kleinste vermietbare Einheit"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { MapContainer, TileLayer, Marker, Popup, Circle, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
@@ -12,11 +12,21 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href,
|
||||
})
|
||||
|
||||
const searchIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:14px;height:14px;border-radius:50%;background:#7c3aed;border:3px solid white;box-shadow:0 2px 6px rgba(124,58,237,0.5)"></div>`,
|
||||
iconAnchor: [7, 7],
|
||||
})
|
||||
|
||||
interface Props {
|
||||
lat: number
|
||||
lng: number
|
||||
label?: string
|
||||
height?: number
|
||||
searchCenterLat?: number
|
||||
searchCenterLng?: number
|
||||
searchRadiusKm?: number
|
||||
searchLabel?: string
|
||||
}
|
||||
|
||||
function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
||||
@@ -27,34 +37,85 @@ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
|
||||
return null
|
||||
}
|
||||
|
||||
export function PropertyMap({ lat, lng, label, height = 260 }: Props) {
|
||||
function FitToRadius({ propLat, propLng, searchLat, searchLng, radiusKm }: {
|
||||
propLat: number; propLng: number
|
||||
searchLat: number; searchLng: number; radiusKm: number
|
||||
}) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
const circleBounds = L.latLng(searchLat, searchLng).toBounds(radiusKm * 1000 * 2)
|
||||
const bounds = circleBounds.extend([propLat, propLng])
|
||||
map.fitBounds(bounds, { padding: [30, 30] })
|
||||
}, [map, propLat, propLng, searchLat, searchLng, radiusKm])
|
||||
return null
|
||||
}
|
||||
|
||||
export function PropertyMap({ lat, lng, label, height = 260, searchCenterLat, searchCenterLng, searchRadiusKm, searchLabel }: Props) {
|
||||
const hasSearch = searchCenterLat !== undefined && searchCenterLng !== undefined && searchRadiusKm !== undefined
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
// Ensure leaflet controls appear above MUI elements
|
||||
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
||||
'& .leaflet-control': { zIndex: 1 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', height, width: '100%', overflow: 'hidden', flexShrink: 0,
|
||||
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
|
||||
'& .leaflet-control': { zIndex: 1 },
|
||||
}}>
|
||||
<MapContainer
|
||||
center={[lat, lng]}
|
||||
zoom={14}
|
||||
zoom={hasSearch ? 10 : 14}
|
||||
scrollWheelZoom
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<RecenterOnChange lat={lat} lng={lng} />
|
||||
{hasSearch
|
||||
? <FitToRadius propLat={lat} propLng={lng} searchLat={searchCenterLat!} searchLng={searchCenterLng!} radiusKm={searchRadiusKm!} />
|
||||
: <RecenterOnChange lat={lat} lng={lng} />
|
||||
}
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Search radius circle */}
|
||||
{hasSearch && (
|
||||
<Circle
|
||||
center={[searchCenterLat!, searchCenterLng!]}
|
||||
radius={searchRadiusKm! * 1000}
|
||||
pathOptions={{ color: '#7c3aed', fillColor: '#7c3aed', fillOpacity: 0.07, weight: 2, dashArray: '6 4' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search center marker */}
|
||||
{hasSearch && (
|
||||
<Marker position={[searchCenterLat!, searchCenterLng!]} icon={searchIcon}>
|
||||
<Popup>{searchLabel ?? `Suchradius: ${searchRadiusKm} km`}</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* Property marker */}
|
||||
<Marker position={[lat, lng]}>
|
||||
{label && <Popup>{label}</Popup>}
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend overlay */}
|
||||
{hasSearch && (
|
||||
<Box sx={{
|
||||
position: 'absolute', bottom: 8, left: 8, zIndex: 500,
|
||||
bgcolor: 'rgba(255,255,255,0.92)', borderRadius: 1.5,
|
||||
px: 1.25, py: 0.625, display: 'flex', flexDirection: 'column', gap: 0.4,
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#7c3aed', flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.67rem', color: '#5b21b6', fontWeight: 700 }}>
|
||||
{searchLabel ?? 'Suchzentrum'} · {searchRadiusKm} km Radius
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#1e3a5f', flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: '0.67rem', color: '#334155' }}>Objekt</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Divider, LinearProgress, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Divider, LinearProgress, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { ExternalLink, FileText } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { PropertyMap } from '../shared'
|
||||
@@ -179,15 +179,59 @@ export function PropertyDetailOverview({ p, editing, draft, onDraftChange }: Pro
|
||||
|
||||
{/* Object details */}
|
||||
<SectionTitle title="Objekt & Lage" />
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
</FieldGrid>
|
||||
{editing ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5, mb: 2 }}>
|
||||
<TextField
|
||||
select label="Ausbaustandard"
|
||||
size="small" fullWidth
|
||||
value={draft.hardFacts?.fitOut ?? p.hardFacts?.fitOut ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), fitOut: (e.target.value || undefined) as 'SHELL' | 'BASIC' | 'FULL' | 'PREMIUM' | undefined } })}
|
||||
>
|
||||
{[
|
||||
{ value: '', label: 'Keine Angabe' },
|
||||
{ value: 'SHELL', label: 'Rohbau' },
|
||||
{ value: 'BASIC', label: 'Basisausbau' },
|
||||
{ value: 'FULL', label: 'Vollausbau' },
|
||||
{ value: 'PREMIUM', label: 'Premiumausbau' },
|
||||
].map(o => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Mieterausbaubeitrag (CHF/m²)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0, max: 1000, step: 25 } }}
|
||||
helperText="Beitrag des Vermieters zum Ausbau"
|
||||
value={draft.hardFacts?.mieterausbaubeitragPerSqm ?? p.hardFacts?.mieterausbaubeitragPerSqm ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), mieterausbaubeitragPerSqm: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Parkplätze"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
value={draft.hardFacts?.parking ?? p.hardFacts?.parking ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), parking: parseInt(e.target.value) || undefined } })}
|
||||
/>
|
||||
<TextField
|
||||
label="Deckenhöhe (m)"
|
||||
size="small" fullWidth type="number"
|
||||
slotProps={{ htmlInput: { step: 0.1, min: 2 } }}
|
||||
value={draft.hardFacts?.ceilingHeightM ?? p.hardFacts?.ceilingHeightM ?? ''}
|
||||
onChange={e => onDraftChange({ ...draft, hardFacts: { ...(draft.hardFacts ?? p.hardFacts ?? {}), ceilingHeightM: parseFloat(e.target.value) || undefined } })}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<FieldGrid>
|
||||
{p.propertyNumber && <Field label="Objekt-Nr." value={p.propertyNumber} />}
|
||||
<Field label="Objekttyp" value={getAssetTypeLabel(p.assetType)} />
|
||||
<Field label="Etage" value={p.hardFacts?.floor ?? p.floorLevel} />
|
||||
<Field label="Ausbaustandard" value={p.hardFacts?.fitOut} />
|
||||
{p.hardFacts?.mieterausbaubeitragPerSqm && (
|
||||
<Field label="Mieterausbaubeitrag" value={`CHF ${p.hardFacts.mieterausbaubeitragPerSqm}/m²`} />
|
||||
)}
|
||||
<Field label="Parkplätze" value={p.hardFacts?.parking ?? p.softFactors?.parkingSpots} />
|
||||
<Field label="ÖV (Min.)" value={p.softFactors?.publicTransportMinutes} />
|
||||
<Field label="Nebenkosten/m²" value={p.ancillaryCosts ? `CHF ${p.ancillaryCosts}` : undefined} />
|
||||
</FieldGrid>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{p.location.coordinates && (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, Checkbox, Chip, Collapse, Divider, IconButton, Switch, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { ChevronDown, ChevronUp, Layers, Users } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/property'
|
||||
import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches'
|
||||
import { useUpdateUnit } from '../../hooks/useProperties'
|
||||
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { floorLabel } from './PropertyDetailHelpers'
|
||||
|
||||
@@ -27,6 +28,9 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
const navigate = useNavigate()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [expandedUnit, setExpandedUnit] = useState<string | null>(null)
|
||||
const [editingFlexUnit, setEditingFlexUnit] = useState<string | null>(null)
|
||||
const [flexDraft, setFlexDraft] = useState<Record<string, number | undefined>>({})
|
||||
const updateUnit = useUpdateUnit(p.id)
|
||||
|
||||
const freeUnits = useMemo(() => (p.units ?? []).filter(u => u.available), [p.units])
|
||||
|
||||
@@ -114,9 +118,65 @@ export function UnitStructurePanel({ p }: { p: Property }) {
|
||||
{u.available ? (
|
||||
<>
|
||||
<Chip label="Frei" size="small" sx={{ height: 16, fontSize: '0.6rem', bgcolor: DS_SURFACE.success.bg, color: '#166534', border: `1px solid ${DS_SURFACE.success.border}` }} />
|
||||
{u.isFlexible && (
|
||||
<Chip label="Teilfläche möglich" size="small" sx={{ height: 16, fontSize: '0.58rem', bgcolor: DS_SURFACE.blue.bg, color: '#1d4ed8', border: `1px solid ${DS_SURFACE.blue.border}` }} />
|
||||
|
||||
{/* Teilbar toggle */}
|
||||
<Tooltip title={u.isFlexible ? 'Teilbar — Mindesteinheit bearbeiten' : 'Als teilbar markieren'}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={u.isFlexible ?? false}
|
||||
onChange={e => {
|
||||
const flexible = e.target.checked
|
||||
if (flexible) {
|
||||
setEditingFlexUnit(u.id)
|
||||
setFlexDraft(d => ({ ...d, [u.id]: u.minLettableSqm }))
|
||||
} else {
|
||||
updateUnit.mutate({ unitId: u.id, data: { isFlexible: false, minLettableSqm: undefined } })
|
||||
setEditingFlexUnit(null)
|
||||
}
|
||||
}}
|
||||
sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#1d4ed8' } }}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.58rem', color: u.isFlexible ? '#1d4ed8' : DS_TEXT.disabled, fontWeight: u.isFlexible ? 700 : 400 }}>
|
||||
Teilbar
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
{/* Inline min-unit editor */}
|
||||
{editingFlexUnit === u.id && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="Min m²"
|
||||
value={flexDraft[u.id] ?? ''}
|
||||
onChange={e => setFlexDraft(d => ({ ...d, [u.id]: parseInt(e.target.value) || undefined }))}
|
||||
sx={{ width: 72, '& .MuiInputBase-input': { fontSize: '0.68rem', py: 0.375, px: 0.75 } }}
|
||||
slotProps={{ htmlInput: { min: 10, max: u.areaSqm, step: 10 } }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }}
|
||||
onClick={() => {
|
||||
updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } })
|
||||
setEditingFlexUnit(null)
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
sx={{ fontSize: '0.58rem', py: 0.25, px: 0.5, minWidth: 0, color: DS_TEXT.muted }}
|
||||
onClick={() => setEditingFlexUnit(null)}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
|
||||
Reference in New Issue
Block a user