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:
@@ -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 (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',
|
||||
}
|
||||
|
||||
// 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)} 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) + '…'
|
||||
}
|
||||
Reference in New Issue
Block a user