feat(F020): frontend infrastructure foundation
- src/lib/constants.ts: centralized route paths, labels, thresholds - src/lib/utils.ts: formatting, color helpers, math utils - src/hooks/: useProperties, useMatches, useFutureSignals, useNeeds - src/components/scores/MatchScoreRing - src/components/data-quality/DataQualityBar - src/components/future-signals/SignalTypeBadge - src/components/cards/SourceTypeBadge - src/provider/AuthProvider (placeholder, swappable) - main.tsx: AuthProvider + centralized stale time Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import { ShieldCheck, Globe, Sparkles } from 'lucide-react'
|
||||||
|
import type { ResultType } from '../../domain/enums'
|
||||||
|
import { RESULT_TYPE_LABELS } from '../../lib/constants'
|
||||||
|
|
||||||
|
interface SourceTypeBadgeProps {
|
||||||
|
type: ResultType
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
|
||||||
|
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#1e3a5f', Icon: ShieldCheck },
|
||||||
|
EXTERNAL_MARKET: { bg: 'rgba(217,119,6,0.1)', color: '#b45309', Icon: Globe },
|
||||||
|
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceTypeBadge({ type, size = 'small' }: SourceTypeBadgeProps) {
|
||||||
|
const { bg, color, Icon } = CONFIG[type] ?? { bg: '#f1f5f9', color: '#475569', Icon: Globe }
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
icon={<Icon size={11} color={color} />}
|
||||||
|
label={RESULT_TYPE_LABELS[type] ?? type}
|
||||||
|
size={size}
|
||||||
|
sx={{ bgcolor: bg, color, fontWeight: 600, border: 'none', '& .MuiChip-icon': { ml: 0.5 } }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SourceTypeBadge } from './SourceTypeBadge'
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Box, LinearProgress, Tooltip, Typography, Chip } from '@mui/material'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { dataQualityColor, dataQualityHex, formatPercent } from '../../lib/utils'
|
||||||
|
import { FRESHNESS_LABELS } from '../../lib/constants'
|
||||||
|
import type { DataQuality } from '../../domain/property'
|
||||||
|
|
||||||
|
interface DataQualityBarProps {
|
||||||
|
quality: DataQuality
|
||||||
|
compact?: boolean
|
||||||
|
showWarnings?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataQualityBar({ quality, compact = false, showWarnings = true }: DataQualityBarProps) {
|
||||||
|
const color = dataQualityColor(quality.score)
|
||||||
|
const hex = dataQualityHex(quality.score)
|
||||||
|
const pct = Math.round(quality.score * 100)
|
||||||
|
|
||||||
|
const tooltipContent = (
|
||||||
|
<Box sx={{ p: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||||
|
Datenqualität {formatPercent(quality.score)}
|
||||||
|
</Typography>
|
||||||
|
{quality.missingCriticalFields.length > 0 && (
|
||||||
|
<Box sx={{ mb: 0.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: '#fca5a5', display: 'block' }}>Fehlende Pflichtfelder:</Typography>
|
||||||
|
{quality.missingCriticalFields.map(f => (
|
||||||
|
<Typography key={f} variant="caption" sx={{ display: 'block', pl: 1 }}>• {f}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{quality.warnings.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: '#fcd34d', display: 'block' }}>Warnungen:</Typography>
|
||||||
|
{quality.warnings.map((w, i) => (
|
||||||
|
<Typography key={i} variant="caption" sx={{ display: 'block', pl: 1 }}>• {w}</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mt: 0.5 }}>
|
||||||
|
Aktualität: {FRESHNESS_LABELS[quality.freshness]}
|
||||||
|
{quality.lastVerifiedAt ? ` · Geprüft: ${quality.lastVerifiedAt}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltipContent} arrow placement="top">
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, cursor: 'default' }}>
|
||||||
|
<Box sx={{ width: 56 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={pct}
|
||||||
|
color={color}
|
||||||
|
sx={{ height: 5, borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: hex, fontWeight: 600, fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{pct}%
|
||||||
|
</Typography>
|
||||||
|
{quality.missingCriticalFields.length > 0 && showWarnings && (
|
||||||
|
<AlertTriangle size={11} color="#d97706" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Tooltip title={tooltipContent} arrow placement="top">
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: 'default' }}>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 80 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={pct}
|
||||||
|
color={color}
|
||||||
|
sx={{ height: 6, borderRadius: 3 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: hex, fontWeight: 600, whiteSpace: 'nowrap' }}>
|
||||||
|
{pct}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
{showWarnings && quality.missingCriticalFields.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.25, mt: 0.5 }}>
|
||||||
|
{quality.missingCriticalFields.slice(0, 2).map(f => (
|
||||||
|
<Chip key={f} label={f} size="small" color="error" variant="outlined"
|
||||||
|
sx={{ height: 16, fontSize: '0.625rem' }} />
|
||||||
|
))}
|
||||||
|
{quality.missingCriticalFields.length > 2 && (
|
||||||
|
<Typography variant="caption" sx={{ color: 'error.main' }}>
|
||||||
|
+{quality.missingCriticalFields.length - 2}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DataQualityBar } from './DataQualityBar'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import type { SignalType } from '../../domain/enums'
|
||||||
|
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
|
||||||
|
|
||||||
|
interface SignalTypeBadgeProps {
|
||||||
|
type: SignalType
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_MAP: Record<string, { bg: string; color: string }> = {
|
||||||
|
EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: '#1a7a4a' },
|
||||||
|
POSSIBLE_MOVE_OUT: { bg: 'rgba(217,119,6,0.12)', color: '#d97706' },
|
||||||
|
CONSTRUCTION_PROJECT: { bg: 'rgba(37,99,235,0.12)', color: '#1d4ed8' },
|
||||||
|
RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: '#c2410c' },
|
||||||
|
PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: '#6d28d9' },
|
||||||
|
SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: '#475569' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalTypeBadge({ type, size = 'small' }: SignalTypeBadgeProps) {
|
||||||
|
const { bg, color } = COLOR_MAP[type] ?? { bg: '#f1f5f9', color: '#475569' }
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={SIGNAL_TYPE_LABELS[type] ?? type}
|
||||||
|
size={size}
|
||||||
|
sx={{ bgcolor: bg, color, fontWeight: 600, border: 'none' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SignalTypeBadge } from './SignalTypeBadge'
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { matchScoreColor } from '../../lib/utils'
|
||||||
|
import type { MatchStrength } from '../../domain/enums'
|
||||||
|
import { MATCH_STRENGTH_LABELS } from '../../lib/constants'
|
||||||
|
|
||||||
|
interface MatchScoreRingProps {
|
||||||
|
score: number
|
||||||
|
strength: MatchStrength
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
showLabel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_MAP = {
|
||||||
|
sm: { ring: 48, font: '0.875rem', label: '0.625rem' },
|
||||||
|
md: { ring: 64, font: '1.125rem', label: '0.75rem' },
|
||||||
|
lg: { ring: 80, font: '1.5rem', label: '0.8125rem' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_MAP = {
|
||||||
|
success: '#1a7a4a',
|
||||||
|
warning: '#d97706',
|
||||||
|
error: '#c0392b',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchScoreRing({ score, strength, size = 'md', showLabel = true }: MatchScoreRingProps) {
|
||||||
|
const dim = SIZE_MAP[size]
|
||||||
|
const colorKey = matchScoreColor(score)
|
||||||
|
const color = COLOR_MAP[colorKey]
|
||||||
|
const bgColor = colorKey === 'success' ? 'rgba(26,122,74,0.08)' : colorKey === 'warning' ? 'rgba(217,119,6,0.08)' : 'rgba(192,57,43,0.08)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: dim.ring,
|
||||||
|
height: dim.ring,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: `3px solid ${color}`,
|
||||||
|
bgcolor: bgColor,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: dim.font, fontWeight: 700, color, lineHeight: 1 }}>
|
||||||
|
{score}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{showLabel && (
|
||||||
|
<Typography sx={{ fontSize: dim.label, fontWeight: 600, color, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{MATCH_STRENGTH_LABELS[strength]}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MatchScoreRing } from './MatchScoreRing'
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { useProperties, useProperty } from './useProperties'
|
||||||
|
export { useMatches, useMatchesByNeed, useMatchesByProperty, useApproveMatch } from './useMatches'
|
||||||
|
export { useFutureSignals, useFutureSignalsByProperty, useVerifySignal } from './useFutureSignals'
|
||||||
|
export { useNeeds, useNeed } from './useNeeds'
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { futureSignalService } from '../services/futureSignalService'
|
||||||
|
import { STALE_SIGNALS } from '../lib/constants'
|
||||||
|
|
||||||
|
export function useFutureSignals() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['futureSignals'],
|
||||||
|
queryFn: () => futureSignalService.getAll(),
|
||||||
|
staleTime: STALE_SIGNALS,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFutureSignalsByProperty(propertyId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['futureSignals', 'property', propertyId],
|
||||||
|
queryFn: () => futureSignalService.getByProperty(propertyId),
|
||||||
|
staleTime: STALE_SIGNALS,
|
||||||
|
enabled: Boolean(propertyId),
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVerifySignal() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (signalId: string) => futureSignalService.verify(signalId, 'current-user'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { matchService } from '../services/matchService'
|
||||||
|
import { STALE_MATCHES } from '../lib/constants'
|
||||||
|
|
||||||
|
export function useMatches() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['matches'],
|
||||||
|
queryFn: () => matchService.getAll(),
|
||||||
|
staleTime: STALE_MATCHES,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMatchesByNeed(needId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['matches', 'need', needId],
|
||||||
|
queryFn: () => matchService.getByNeed(needId),
|
||||||
|
staleTime: STALE_MATCHES,
|
||||||
|
enabled: Boolean(needId),
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMatchesByProperty(propertyId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['matches', 'property', propertyId],
|
||||||
|
queryFn: () => matchService.getByProperty(propertyId),
|
||||||
|
staleTime: STALE_MATCHES,
|
||||||
|
enabled: Boolean(propertyId),
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveMatch() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (matchId: string) => matchService.approve(matchId, 'current-user'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['matches'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { needService } from '../services/needService'
|
||||||
|
|
||||||
|
export function useNeeds() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['needs'],
|
||||||
|
queryFn: () => needService.getAll(),
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNeed(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['need', id],
|
||||||
|
queryFn: () => needService.getById(id),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { propertyService } from '../services/propertyService'
|
||||||
|
import type { AssetType, ResultType } from '../domain/enums'
|
||||||
|
import { STALE_PROPERTIES } from '../lib/constants'
|
||||||
|
|
||||||
|
interface PropertyFilter {
|
||||||
|
assetType?: AssetType
|
||||||
|
resultType?: ResultType
|
||||||
|
city?: string
|
||||||
|
minAreaSqm?: number
|
||||||
|
maxRentPerSqm?: number
|
||||||
|
organizationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProperties(filter?: PropertyFilter) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['properties', filter ?? {}],
|
||||||
|
queryFn: () => propertyService.getAll(filter),
|
||||||
|
staleTime: STALE_PROPERTIES,
|
||||||
|
select: (res) => res.data ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProperty(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['property', id],
|
||||||
|
queryFn: () => propertyService.getById(id),
|
||||||
|
staleTime: STALE_PROPERTIES,
|
||||||
|
enabled: Boolean(id),
|
||||||
|
select: (res) => res.data ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Centralized app-wide constants — no magic strings anywhere else
|
||||||
|
|
||||||
|
export const APP_NAME = 'Property-Match'
|
||||||
|
export const APP_VERSION = '0.1.0'
|
||||||
|
|
||||||
|
// Organisation
|
||||||
|
export const DEFAULT_ORG_ID = 'org-wincasa'
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
export const DEFAULT_PAGE_SIZE = 25
|
||||||
|
export const MAX_COMPARE_ITEMS = 3
|
||||||
|
|
||||||
|
// Data quality thresholds
|
||||||
|
export const DQ_HIGH = 0.8
|
||||||
|
export const DQ_MEDIUM = 0.6
|
||||||
|
|
||||||
|
// Confidence thresholds
|
||||||
|
export const CONF_HIGH = 0.85
|
||||||
|
export const CONF_MEDIUM = 0.65
|
||||||
|
|
||||||
|
// Match score thresholds (0–100)
|
||||||
|
export const SCORE_STRONG = 80
|
||||||
|
export const SCORE_MODERATE = 60
|
||||||
|
|
||||||
|
// Probability thresholds (future signals)
|
||||||
|
export const PROB_HIGH = 0.7
|
||||||
|
export const PROB_MEDIUM = 0.5
|
||||||
|
|
||||||
|
// Query stale times (ms)
|
||||||
|
export const STALE_PROPERTIES = 5 * 60 * 1000
|
||||||
|
export const STALE_MATCHES = 2 * 60 * 1000
|
||||||
|
export const STALE_SIGNALS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
// Route paths — single source of truth
|
||||||
|
export const ROUTES = {
|
||||||
|
HOME: '/',
|
||||||
|
SUPPLY: {
|
||||||
|
DASHBOARD: '/supply/dashboard',
|
||||||
|
PROPERTIES: '/supply/properties',
|
||||||
|
MATCH_CENTER: '/supply/match-center',
|
||||||
|
FUTURE_AVAILABILITY: '/supply/future-availability',
|
||||||
|
DATA_QUALITY: '/supply/data-quality',
|
||||||
|
},
|
||||||
|
DEMAND: {
|
||||||
|
AI_SEARCH: '/demand/ai-search',
|
||||||
|
RESULTS: '/demand/results',
|
||||||
|
COMPARE: '/demand/compare',
|
||||||
|
SHORTLISTS: '/demand/shortlists',
|
||||||
|
},
|
||||||
|
OPS: {
|
||||||
|
REVIEW_QUEUE: '/ops/review-queue',
|
||||||
|
AI_MONITORING: '/ops/ai-monitoring',
|
||||||
|
GOVERNANCE: '/ops/governance',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Asset type display labels
|
||||||
|
export const ASSET_TYPE_LABELS: Record<string, string> = {
|
||||||
|
OFFICE: 'Büro',
|
||||||
|
RETAIL: 'Retail',
|
||||||
|
GASTRO: 'Gastronomie',
|
||||||
|
LOGISTICS: 'Logistik',
|
||||||
|
PRODUCTION: 'Produktion',
|
||||||
|
MIXED: 'Gemischt',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result type display labels
|
||||||
|
export const RESULT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
VERIFIED_PORTFOLIO: 'Verified Portfolio',
|
||||||
|
EXTERNAL_MARKET: 'Marktinserat',
|
||||||
|
FUTURE_AVAILABILITY: 'Zukunftssignal',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match strength display labels
|
||||||
|
export const MATCH_STRENGTH_LABELS: Record<string, string> = {
|
||||||
|
STRONG: 'Stark',
|
||||||
|
MODERATE: 'Mittel',
|
||||||
|
WEAK: 'Schwach',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Availability status display labels
|
||||||
|
export const AVAILABILITY_LABELS: Record<string, string> = {
|
||||||
|
AVAILABLE_NOW: 'Verfügbar',
|
||||||
|
AVAILABLE_SOON: 'Bald verfügbar',
|
||||||
|
FUTURE_SIGNAL: 'Zukunftssignal',
|
||||||
|
OCCUPIED: 'Belegt',
|
||||||
|
UNKNOWN: 'Unbekannt',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Risk level display labels
|
||||||
|
export const RISK_LABELS: Record<string, string> = {
|
||||||
|
LOW: 'Niedrig',
|
||||||
|
MEDIUM: 'Mittel',
|
||||||
|
HIGH: 'Hoch',
|
||||||
|
CRITICAL: 'Kritisch',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal type display labels
|
||||||
|
export const SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||||
|
EXPANSION: 'Expansion',
|
||||||
|
POSSIBLE_MOVE_OUT: 'Möglicher Auszug',
|
||||||
|
CONSTRUCTION_PROJECT: 'Bauprojekt',
|
||||||
|
RESTRUCTURING: 'Umstrukturierung',
|
||||||
|
PROJECT_DEVELOPMENT: 'Projektentwicklung',
|
||||||
|
SPACE_CONSOLIDATION: 'Flächenkonsolidierung',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data freshness display labels
|
||||||
|
export const FRESHNESS_LABELS: Record<string, string> = {
|
||||||
|
FRESH: 'Aktuell',
|
||||||
|
STALE: 'Veraltet',
|
||||||
|
OUTDATED: 'Abgelaufen',
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
DQ_HIGH, DQ_MEDIUM,
|
||||||
|
CONF_HIGH, CONF_MEDIUM,
|
||||||
|
SCORE_STRONG, SCORE_MODERATE,
|
||||||
|
PROB_HIGH, PROB_MEDIUM,
|
||||||
|
} from './constants'
|
||||||
|
|
||||||
|
// ── Formatting ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function formatCHF(amount: number, decimals = 0): string {
|
||||||
|
return new Intl.NumberFormat('de-CH', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CHF',
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatArea(sqm: number): string {
|
||||||
|
return `${new Intl.NumberFormat('de-CH').format(sqm)} m²`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number, decimals = 0): string {
|
||||||
|
return `${(value * 100).toFixed(decimals)} %`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(iso))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeDate(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const hours = Math.floor(diff / 3_600_000)
|
||||||
|
if (hours < 1) return 'Gerade eben'
|
||||||
|
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`
|
||||||
|
return formatDate(iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color helpers (return MUI color token strings) ────────────────────────────
|
||||||
|
|
||||||
|
export function dataQualityColor(score: number): 'success' | 'warning' | 'error' {
|
||||||
|
if (score >= DQ_HIGH) return 'success'
|
||||||
|
if (score >= DQ_MEDIUM) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confidenceColor(score: number): 'success' | 'primary' | 'warning' {
|
||||||
|
if (score >= CONF_HIGH) return 'success'
|
||||||
|
if (score >= CONF_MEDIUM) return 'primary'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchScoreColor(score: number): 'success' | 'warning' | 'error' {
|
||||||
|
if (score >= SCORE_STRONG) return 'success'
|
||||||
|
if (score >= SCORE_MODERATE) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probabilityColor(prob: number): 'success' | 'warning' | 'error' {
|
||||||
|
if (prob >= PROB_HIGH) return 'success'
|
||||||
|
if (prob >= PROB_MEDIUM) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex color variants for use in sx (when MUI color tokens aren't enough)
|
||||||
|
export function dataQualityHex(score: number): string {
|
||||||
|
if (score >= DQ_HIGH) return '#1a7a4a'
|
||||||
|
if (score >= DQ_MEDIUM) return '#d97706'
|
||||||
|
return '#c0392b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confidenceHex(score: number): string {
|
||||||
|
if (score >= CONF_HIGH) return '#1a7a4a'
|
||||||
|
if (score >= CONF_MEDIUM) return '#1e3a5f'
|
||||||
|
return '#d97706'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probabilityHex(prob: number): string {
|
||||||
|
if (prob >= PROB_HIGH) return '#1a7a4a'
|
||||||
|
if (prob >= PROB_MEDIUM) return '#d97706'
|
||||||
|
return '#c0392b'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Misc ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function average(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0
|
||||||
|
return values.reduce((a, b) => a + b, 0) / values.length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, maxLen: number): string {
|
||||||
|
return str.length <= maxLen ? str : str.slice(0, maxLen - 1) + '…'
|
||||||
|
}
|
||||||
+6
-2
@@ -4,13 +4,15 @@ import { BrowserRouter } from 'react-router'
|
|||||||
import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material'
|
import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { theme } from './lib/theme'
|
import { theme } from './lib/theme'
|
||||||
|
import { AuthProvider } from './provider/AuthProvider'
|
||||||
|
import { STALE_PROPERTIES } from './lib/constants'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: STALE_PROPERTIES,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -23,7 +25,9 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StyledEngineProvider injectFirst>
|
<StyledEngineProvider injectFirst>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StyledEngineProvider>
|
</StyledEngineProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react'
|
||||||
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
|
import type { MockUser } from '../stores/sessionStore'
|
||||||
|
|
||||||
|
// Placeholder AuthContext — swap for real auth (Supabase, Auth0, etc.) later.
|
||||||
|
// All call sites use this context; no component imports sessionStore directly.
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: MockUser | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
login: (user: MockUser) => void
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { currentUser, isAuthenticated, login, logout } = useSessionStore()
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
user: currentUser,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading: false, // always resolved in mock mode
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user