feat: remove Administration workspace — keep only Verwaltung + Suche

- Delete all ops page components (ReviewQueue, AIMonitoring, Governance,
  SourceMonitoring, ActivityTimeline, SignalPipeline)
- Remove OPERATIONS workspace from AppShell config, nav order, path detection
- Remove all /ops/* routes from App.tsx
- Remove WorkspaceType.OPERATIONS from allowedWorkspaces in authService,
  sessionStore, permissions
- Keep MarketIntelligence page (already moved to /supply/market-intelligence)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 20:32:41 +02:00
parent d22e72f945
commit d15a13e485
378 changed files with 35441 additions and 42 deletions
@@ -0,0 +1,130 @@
// 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 (0100)
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',
}
// Confidence level display labels
export const CONFIDENCE_LABELS: Record<string, string> = {
VERY_HIGH: 'Sehr hoch',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
VERY_LOW: 'Sehr niedrig',
}
// Data quality level display labels
export const DATA_QUALITY_LABELS: Record<string, string> = {
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
INCOMPLETE: 'Unvollständig',
}
@@ -0,0 +1,61 @@
import type { ConfidenceLevel, DataQualityLevel } from '../domain/enums'
import { CONF_HIGH, CONF_MEDIUM, DQ_HIGH, DQ_MEDIUM } from './constants'
// ── Semantic color tokens ─────────────────────────────────────────────────────
// Single source of truth for badge/score colors. All badge components read from here.
export const DS_COLORS = {
resultType: {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
EXTERNAL_MARKET: { bg: 'rgba(180,83,9,0.10)', fg: '#b45309' },
FUTURE_AVAILABILITY: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
},
confidence: {
VERY_HIGH: { bg: 'rgba(26,122,74,0.12)', fg: '#1a7a4a' },
HIGH: { bg: 'rgba(26,122,74,0.09)', fg: '#1a7a4a' },
MEDIUM: { bg: 'rgba(30,58,95,0.10)', fg: '#1e3a5f' },
LOW: { bg: 'rgba(217,119,6,0.12)', fg: '#d97706' },
VERY_LOW: { bg: 'rgba(192,57,43,0.12)', fg: '#c0392b' },
},
risk: {
LOW: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
MEDIUM: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
HIGH: { bg: 'rgba(192,57,43,0.10)', fg: '#c0392b' },
CRITICAL: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
},
availability: {
AVAILABLE_NOW: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
AVAILABLE_SOON: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
FUTURE_SIGNAL: { bg: 'rgba(109,40,217,0.10)', fg: '#6d28d9' },
OCCUPIED: { bg: 'rgba(100,116,139,0.10)', fg: '#475569' },
UNKNOWN: { bg: '#f1f5f9', fg: '#94a3b8' },
},
freshness: {
FRESH: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
STALE: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
OUTDATED: { bg: 'rgba(192,57,43,0.10)', fg: '#c0392b' },
},
dataQuality: {
HIGH: { bg: 'rgba(26,122,74,0.10)', fg: '#1a7a4a' },
MEDIUM: { bg: 'rgba(217,119,6,0.10)', fg: '#d97706' },
LOW: { bg: 'rgba(192,57,43,0.10)', fg: '#c0392b' },
INCOMPLETE: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
},
} as const
// ── Score → qualitative level helpers ────────────────────────────────────────
export function scoreToConfidenceLevel(score: number): ConfidenceLevel {
if (score >= CONF_HIGH) return 'VERY_HIGH'
if (score >= 0.75) return 'HIGH'
if (score >= CONF_MEDIUM) return 'MEDIUM'
if (score >= 0.35) return 'LOW'
return 'VERY_LOW'
}
export function scoreToDataQualityLevel(score: number): DataQualityLevel {
if (score >= DQ_HIGH) return 'HIGH'
if (score >= DQ_MEDIUM) return 'MEDIUM'
if (score > 0) return 'LOW'
return 'INCOMPLETE'
}
@@ -0,0 +1,139 @@
// Static location intelligence data per city — mock values based on Swiss market context
export interface CityIntelligence {
vacancyRatePct: number // Leerstandsquote %
rentTrend12m: number // Mietpreisveränderung % (letztes Jahr)
purchasingPowerIndex: number // Kaufkraft-Index (CH = 100)
dominantIndustryClusters: string[]
plannedInfrastructure: { project: string; timeline: string; impact: string }[]
medianRentOffice: number // CHF/m² für Bürofläche
medianRentLogistics: number
medianRentRetail: number
avgDaysOnMarket: number // Durchschnittliche Tage bis Vermietung
demandStrength: 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
taxIndexCanton: number // Steuerindex 100 = CH-Mittel
}
export const CITY_INTELLIGENCE: Record<string, CityIntelligence> = {
'Zürich': {
vacancyRatePct: 2.8,
rentTrend12m: +4.2,
purchasingPowerIndex: 128,
dominantIndustryClusters: ['Finanz & Banking', 'Tech & Startups', 'Medien & Kreativ', 'Pharma & Life Science'],
plannedInfrastructure: [
{ project: 'Tram Hardbrücke-Verlängerung', timeline: '2027', impact: 'Bessere ÖV-Anbindung Industriequartier' },
{ project: 'Rosengarten-Tunnel', timeline: '2030', impact: 'Entlastung Kreis 5/6, weniger Durchgangsverkehr' },
],
medianRentOffice: 42,
medianRentLogistics: 14,
medianRentRetail: 180,
avgDaysOnMarket: 38,
demandStrength: 'VERY_HIGH',
taxIndexCanton: 100,
},
'Basel': {
vacancyRatePct: 4.1,
rentTrend12m: +1.8,
purchasingPowerIndex: 112,
dominantIndustryClusters: ['Pharma & Chemie', 'Logistik & Handel', 'Medizintechnik', 'Finanzdienstleistungen'],
plannedInfrastructure: [
{ project: 'Basel SBB Südeingang Neubau', timeline: '2026', impact: 'Aufwertung Bahnhofumgebung' },
{ project: 'Regio-S-Bahn Ausbau', timeline: '2028', impact: 'Bessere Grenzpendler-Anbindung' },
],
medianRentOffice: 32,
medianRentLogistics: 11,
medianRentRetail: 120,
avgDaysOnMarket: 52,
demandStrength: 'HIGH',
taxIndexCanton: 98,
},
'Bern': {
vacancyRatePct: 3.5,
rentTrend12m: +2.1,
purchasingPowerIndex: 108,
dominantIndustryClusters: ['Bundesverwaltung & NPO', 'Gesundheit', 'Bildung & Forschung', 'Versicherungen'],
plannedInfrastructure: [
{ project: 'Bernmobil Netzausbau West', timeline: '2026', impact: 'Erschliessung Entwicklungsgebiet Ausserholligen' },
],
medianRentOffice: 28,
medianRentLogistics: 10,
medianRentRetail: 95,
avgDaysOnMarket: 61,
demandStrength: 'MEDIUM',
taxIndexCanton: 112,
},
'Zug': {
vacancyRatePct: 1.9,
rentTrend12m: +5.1,
purchasingPowerIndex: 148,
dominantIndustryClusters: ['Rohstoffhandel', 'Crypto & Blockchain', 'Holding & Finanzen', 'Tech-Unternehmen'],
plannedInfrastructure: [
{ project: 'Metrobahn Zug-Luzern', timeline: '2029', impact: 'Direktverbindung Luzern in 18 Min.' },
],
medianRentOffice: 38,
medianRentLogistics: 13,
medianRentRetail: 140,
avgDaysOnMarket: 24,
demandStrength: 'VERY_HIGH',
taxIndexCanton: 60,
},
'Winterthur': {
vacancyRatePct: 5.8,
rentTrend12m: +0.9,
purchasingPowerIndex: 98,
dominantIndustryClusters: ['Industrie & Maschinenbau', 'Logistik', 'Gesundheit & Soziales'],
plannedInfrastructure: [
{ project: 'Stadtraum HB Winterthur', timeline: '2027', impact: 'Aufwertung Bahnhofsumgebung, mehr Frequenz' },
],
medianRentOffice: 22,
medianRentLogistics: 9,
medianRentRetail: 75,
avgDaysOnMarket: 74,
demandStrength: 'MEDIUM',
taxIndexCanton: 119,
},
'Geneva': {
vacancyRatePct: 2.2,
rentTrend12m: +3.6,
purchasingPowerIndex: 135,
dominantIndustryClusters: ['Internationale Organisationen', 'Luxusgüter', 'Banking & Private Equity', 'Uhrenindustrie'],
plannedInfrastructure: [
{ project: 'CEVA Linie Verlängerung', timeline: '2026', impact: 'Bessere Verbindung Lancy-Pont-Rouge' },
],
medianRentOffice: 55,
medianRentLogistics: 18,
medianRentRetail: 220,
avgDaysOnMarket: 31,
demandStrength: 'HIGH',
taxIndexCanton: 125,
},
'St.Gallen': {
vacancyRatePct: 6.2,
rentTrend12m: -0.5,
purchasingPowerIndex: 95,
dominantIndustryClusters: ['Textil & Mode', 'KMU', 'Logistik', 'Gesundheit'],
plannedInfrastructure: [],
medianRentOffice: 19,
medianRentLogistics: 8,
medianRentRetail: 65,
avgDaysOnMarket: 88,
demandStrength: 'LOW',
taxIndexCanton: 107,
},
}
export function getCityIntelligence(city: string): CityIntelligence | null {
// Try exact match first, then partial
if (CITY_INTELLIGENCE[city]) return CITY_INTELLIGENCE[city]
const key = Object.keys(CITY_INTELLIGENCE).find(k => city.toLowerCase().includes(k.toLowerCase()))
return key ? CITY_INTELLIGENCE[key] : null
}
export function getMarketRent(city: string, assetType: string): number | null {
const intel = getCityIntelligence(city)
if (!intel) return null
if (assetType === 'OFFICE') return intel.medianRentOffice
if (assetType === 'LOGISTICS' || assetType === 'LIGHT_INDUSTRIAL') return intel.medianRentLogistics
if (assetType === 'RETAIL') return intel.medianRentRetail
return intel.medianRentOffice
}
@@ -0,0 +1 @@
export const mockDelay = (ms = 150) => new Promise<void>(res => setTimeout(res, ms))
@@ -0,0 +1,133 @@
import type { MockUser } from '../stores/sessionStore'
import { UserRole, WorkspaceType, ResultType } from '../domain/enums'
// ── Permission Strings ────────────────────────────────────────────────────────
export const Permission = {
SUPPLY_VIEW: 'supply:view',
SUPPLY_EDIT: 'supply:edit',
DEMAND_VIEW: 'demand:view',
DEMAND_EDIT: 'demand:edit',
DEMAND_REQUEST_CONTACT: 'demand:request_contact',
OPS_VIEW: 'ops:view',
OPS_REVIEW: 'ops:review',
OPS_APPROVE: 'ops:approve',
FUTURE_SIGNAL_VIEW: 'future_signal:view',
FUTURE_SIGNAL_REVIEW: 'future_signal:review',
CONTACT_RELEASE_REQUEST: 'contact_release:request',
CONTACT_RELEASE_APPROVE: 'contact_release:approve',
} as const
export type Permission = typeof Permission[keyof typeof Permission]
// ── Role → Permission Matrix ──────────────────────────────────────────────────
const ALL_PERMISSIONS = Object.values(Permission)
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
[UserRole.SUPER_ADMIN]: ALL_PERMISSIONS,
[UserRole.ORGANIZATION_ADMIN]: ALL_PERMISSIONS,
[UserRole.PROPERTY_MANAGER]: [
Permission.SUPPLY_VIEW,
Permission.SUPPLY_EDIT,
Permission.DEMAND_VIEW,
Permission.FUTURE_SIGNAL_VIEW,
Permission.FUTURE_SIGNAL_REVIEW,
Permission.CONTACT_RELEASE_APPROVE,
],
[UserRole.REVIEWER]: [
Permission.OPS_VIEW,
Permission.OPS_REVIEW,
Permission.FUTURE_SIGNAL_VIEW,
Permission.FUTURE_SIGNAL_REVIEW,
],
[UserRole.OWNER_VIEWER]: [
Permission.SUPPLY_VIEW,
Permission.CONTACT_RELEASE_APPROVE,
],
[UserRole.DEMAND_USER]: [
Permission.DEMAND_VIEW,
Permission.DEMAND_EDIT,
Permission.DEMAND_REQUEST_CONTACT,
Permission.CONTACT_RELEASE_REQUEST,
],
}
// ── Role → Workspace Access ───────────────────────────────────────────────────
const WORKSPACE_ROLES: Record<WorkspaceType, UserRole[]> = {
[WorkspaceType.SUPPLY]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.PROPERTY_MANAGER,
UserRole.OWNER_VIEWER,
],
[WorkspaceType.DEMAND]: [
UserRole.SUPER_ADMIN,
UserRole.ORGANIZATION_ADMIN,
UserRole.PROPERTY_MANAGER,
UserRole.DEMAND_USER,
],
[WorkspaceType.OPERATIONS]: [
UserRole.SUPER_ADMIN,
UserRole.REVIEWER,
],
}
// ── Core Functions ────────────────────────────────────────────────────────────
export function getPermissions(user: MockUser): Permission[] {
return ROLE_PERMISSIONS[user.role] ?? []
}
export function hasPermission(user: MockUser, permission: Permission): boolean {
return getPermissions(user).includes(permission)
}
export function canAccessWorkspace(user: MockUser, workspace: WorkspaceType): boolean {
return WORKSPACE_ROLES[workspace].includes(user.role)
}
export function getAccessibleWorkspaces(role: UserRole): WorkspaceType[] {
return Object.entries(WORKSPACE_ROLES)
.filter(([, roles]) => roles.includes(role))
.map(([ws]) => ws as WorkspaceType)
}
// ── Domain Permission Functions ───────────────────────────────────────────────
export function canViewProperty(
user: MockUser,
property: { organizationId: string; resultType: ResultType },
): boolean {
if (user.role === UserRole.SUPER_ADMIN) return true
if (property.resultType === ResultType.VERIFIED_PORTFOLIO) {
return property.organizationId === user.organizationId
}
if (property.resultType === ResultType.EXTERNAL_MARKET) return true
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
return hasPermission(user, Permission.FUTURE_SIGNAL_VIEW)
}
return false
}
export function canViewMatch(
user: MockUser,
match: { organizationId?: string },
): boolean {
if (user.role === UserRole.SUPER_ADMIN || user.role === UserRole.ORGANIZATION_ADMIN) return true
if (user.role === UserRole.PROPERTY_MANAGER || user.role === UserRole.REVIEWER) return true
if (user.role === UserRole.DEMAND_USER) return match.organizationId === user.organizationId
return false // OWNER_VIEWER: only released matches — handled at component level
}
export function canReviewFutureSignal(user: MockUser): boolean {
return hasPermission(user, Permission.FUTURE_SIGNAL_REVIEW)
}
export function canApproveContactRelease(user: MockUser): boolean {
return hasPermission(user, Permission.CONTACT_RELEASE_APPROVE)
}
export function canRequestContactRelease(user: MockUser): boolean {
return hasPermission(user, Permission.CONTACT_RELEASE_REQUEST)
}
@@ -0,0 +1,110 @@
import { createTheme } from '@mui/material/styles'
export const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1e3a5f',
light: '#2d5290',
dark: '#142a45',
contrastText: '#ffffff',
},
secondary: {
main: '#2d6a8f',
light: '#4a8ab0',
dark: '#1e4d68',
contrastText: '#ffffff',
},
background: {
default: '#f4f6f9',
paper: '#ffffff',
},
text: {
primary: '#1a2332',
secondary: '#4a5568',
},
error: { main: '#c0392b' },
warning: { main: '#d97706' },
success: { main: '#1a7a4a' },
info: { main: '#2563eb' },
divider: '#e2e8f0',
},
typography: {
fontFamily: '"Inter", "Segoe UI", system-ui, -apple-system, sans-serif',
h1: { fontSize: '1.75rem', fontWeight: 700, letterSpacing: '-0.02em' },
h2: { fontSize: '1.5rem', fontWeight: 600, letterSpacing: '-0.01em' },
h3: { fontSize: '1.25rem', fontWeight: 600 },
h4: { fontSize: '1.125rem', fontWeight: 600 },
h5: { fontSize: '1rem', fontWeight: 600 },
h6: { fontSize: '0.9375rem', fontWeight: 600 },
subtitle1: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.5 },
subtitle2: { fontSize: '0.8125rem', fontWeight: 600, lineHeight: 1.4 },
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
body2: { fontSize: '0.8125rem', lineHeight: 1.5 },
caption: { fontSize: '0.75rem', lineHeight: 1.4, color: '#64748b' },
overline: { fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' },
button: { fontSize: '0.8125rem', fontWeight: 600, textTransform: 'none' },
},
shape: { borderRadius: 6 },
shadows: [
'none',
'0 1px 2px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.08)',
'0 1px 4px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.08)',
'0 2px 6px rgba(0,0,0,0.07), 0 4px 10px rgba(0,0,0,0.08)',
'0 3px 8px rgba(0,0,0,0.08), 0 6px 14px rgba(0,0,0,0.08)',
'0 4px 10px rgba(0,0,0,0.08), 0 8px 18px rgba(0,0,0,0.08)',
'0 5px 12px rgba(0,0,0,0.09)', '0 6px 14px rgba(0,0,0,0.09)',
'0 7px 16px rgba(0,0,0,0.09)', '0 8px 18px rgba(0,0,0,0.09)',
'0 9px 20px rgba(0,0,0,0.09)', '0 10px 22px rgba(0,0,0,0.10)',
'0 11px 24px rgba(0,0,0,0.10)', '0 12px 26px rgba(0,0,0,0.10)',
'0 13px 28px rgba(0,0,0,0.10)', '0 14px 30px rgba(0,0,0,0.10)',
'0 15px 32px rgba(0,0,0,0.10)', '0 16px 34px rgba(0,0,0,0.10)',
'0 17px 36px rgba(0,0,0,0.10)', '0 18px 38px rgba(0,0,0,0.10)',
'0 19px 40px rgba(0,0,0,0.10)', '0 20px 42px rgba(0,0,0,0.10)',
'0 21px 44px rgba(0,0,0,0.10)', '0 22px 46px rgba(0,0,0,0.10)',
'0 24px 50px rgba(0,0,0,0.12)',
],
components: {
MuiCard: {
defaultProps: { elevation: 1 },
styleOverrides: {
root: {
border: '1px solid #e2e8f0',
'&:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.10)' },
transition: 'box-shadow 0.15s ease',
},
},
},
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: { borderRadius: 6, padding: '6px 16px' },
contained: {
'&.MuiButton-containedPrimary': {
background: '#1e3a5f',
'&:hover': { background: '#142a45' },
},
},
},
},
MuiChip: {
styleOverrides: {
root: { borderRadius: 4, fontSize: '0.75rem', fontWeight: 600, height: 22 },
},
},
MuiTableCell: {
styleOverrides: {
head: { fontWeight: 600, fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', padding: '8px 12px' },
body: { fontSize: '0.8125rem', padding: '10px 12px' },
},
},
MuiTooltip: {
defaultProps: { arrow: true },
},
MuiPaper: {
styleOverrides: {
root: { backgroundImage: 'none' },
},
},
},
})
@@ -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)}`
}
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) + '…'
}