feat(agenten): Runde 4 — «Meine Agenten» mit fünf digitalen Mitarbeitenden

Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um.

Informationsarchitektur
- «Teamübersicht» heisst «Meine Agenten»; ihr bisheriger Inhalt (Kreisgrafik,
  Auswertung, Verbindungszusammenfassung) ist entfallen.
- Personalverwaltung, Bearbeitungsverlauf und Kanäle & Systeme sind jetzt
  Reiter derselben Seite (?section=), gestaltet wie die Auswahl im
  Bearbeitungsverlauf. Es wird nur der gewählte Bereich gerendert.
- Die alten Unterseitenpfade leiten um, damit Lesezeichen nicht brechen.

Agentenbestand
- Reto und Lea vollständig entfernt — aus Navigation, Dossiers, Protokoll,
  Verbindungen, Vorgängen und Porträtbestand.
- Retos Aufgaben liegen bei Bruno: Nachbereitung, WhatsApp-Anruf,
  Protokoll und Kundennotiz, Ablage im CRM.
- Livia ist «Exposé Master»: Lageberichte, Inserate, Angebotsbroschüren.
- Sidebar führt Ferdi, Bruno, Livia, Nora, Sina mit Porträt und Funktion.

Agentenseiten
- Ein gemeinsamer AgentWorkspaceHero auf allen fünf Seiten.
- Ferdi: Auswertungskarten, Priorität und Typfarben entfallen; Fälligkeit nur
  bei fünf Tagen oder weniger rot; Objektlinks nach «Meine Objekte»; neu die
  Terminplanung im verbundenen Kalender mit typgerechtem PDF-Ausschnitt.
- Sina: reduzierte, filter- und sortierbare Objektübersicht; Detailansicht
  direkt editierbar, leere Pflichtfelder rot umrandet.
- Nora: Signale ohne Prozentsätze und Konfidenzstufen, nur belegbare Angaben;
  Mehrfachauswahl leitet Objekte an Livia weiter.
- Livia: aktive und archivierte Leads, Arbeitsbereich gleitet an den oberen
  Rand; dreistufiger Exposé-Prozess Hochladen → Exposé → Export.
- Bruno: neue Seite mit Auftragsliste und Vor-/Nachbereitungs-Drawer;
  Glocke warnt bei Besichtigung unter 24 Stunden ohne Bericht.

Datenschicht
- Neu: Kalender, Exposé-Leads, Exposé-Entwürfe, Besichtigungsaufträge —
  je Domain, Provider, Service und Hook.
- IAIService um generateExposeText erweitert; der Entwurf nutzt ausschliesslich
  erfasste Objektdaten und meldet Lücken, statt sie zu füllen.
- Alle Objektverweise zeigen auf reale Einträge aus «Meine Objekte»; neue
  Detailroute /supply/properties/:propertyId.

Offen: Chat, Kalender, CRM und DMS sind Frontend-Simulation ohne Anbindung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-08-05 14:43:16 +02:00
parent cc5582b7fd
commit d200d4e930
352 changed files with 8069 additions and 8512 deletions
+13
View File
@@ -20,6 +20,19 @@ export default defineConfig([
languageOptions: {
globals: globals.browser,
},
rules: {
// Der Unterstrich ist im Projekt die etablierte Kennzeichnung für
// bewusst ungenutzte Parameter — etwa wenn eine Schnittstelle ein
// Argument vorschreibt, das eine bestimmte Implementierung nicht
// braucht. Ohne diese Ausnahme meldete die Regel genau die Fälle,
// die der Autor bereits als «gewollt» markiert hat.
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
}],
},
},
// ── Design Token Enforcement ────────────────────────────────────────────────
// New hardcoded hex colors in components/pages are blocked (error).
+5 -1
View File
@@ -18,7 +18,11 @@ import { join, extname } from 'node:path'
// Hex literals allowed before CI blocks the build.
// This is a ratchet — lower it as migration progresses. Never raise it.
// Baseline after initial token migration (2026-05-24): 1958
const THRESHOLD = 1958
// Nach der Token-Migration aller Farb-Properties (bgcolor/color/borderColor/
// background/fill/stroke): 925. Die verbleibenden Treffer stehen in
// Farbverläufen, `rgba()`-Werten, Icon-Attributen und Datentabellen, die diese
// Zählung mitnimmt, die ESLint-Regel aber nicht erfasst.
const THRESHOLD = 925
const HEX_PATTERN = /#[0-9A-Fa-f]{3,8}\b/g
+34 -20
View File
@@ -5,6 +5,7 @@ import { AppShell } from './components/layout'
import { ProtectedRoute } from './components/auth'
import { WorkspaceType } from './domain/enums'
import { useSessionStore } from './stores/sessionStore'
import { AGENT_SECTIONS, AGENT_SECTION_PARAM, ROUTES } from './lib/constants'
// Die Flächensuche ist entfernt — es bleibt ein einziger erreichbarer Einstieg.
const WORKSPACE_HOME: Record<string, string> = {
@@ -28,12 +29,15 @@ const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings'))
const Besichtigungen = lazy(() => import('./pages/supply/Besichtigungen'))
// Property On — «Teamübersicht»
const Teamuebersicht = lazy(() => import('./pages/supply/Teamuebersicht'))
const Personalverwaltung = lazy(() => import('./pages/supply/Personalverwaltung'))
const Bearbeitungsverlauf = lazy(() => import('./pages/supply/Bearbeitungsverlauf'))
const KanaeleSysteme = lazy(() => import('./pages/supply/KanaeleSysteme'))
// Property On — «Meine Agenten»
const MeineAgenten = lazy(() => import('./pages/supply/MeineAgenten'))
/** Alter Unterseitenpfad → Reiter der Hauptseite. Hält Lesezeichen am Leben. */
function TeamSectionRedirect({ section }: { section: string }) {
return <Navigate to={`${ROUTES.SUPPLY.TEAM}?${AGENT_SECTION_PARAM}=${section}`} replace />
}
function App() {
return (
@@ -52,25 +56,35 @@ function App() {
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
<Route path="/supply/properties" element={<Properties />} />
{/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */}
<Route path="/supply/properties/:propertyId" element={<Properties />} />
<Route path="/supply/match-center" element={<MatchCenter />} />
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} />
{/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4).
Bewusst flache Geschwisterrouten statt <Outlet/>: die App kennt
keine einzige Feature-Route mit eigenem Outlet, ein Novum hier
würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */}
<Route path="/supply/team" element={<Teamuebersicht />} />
<Route path="/supply/team/personalverwaltung" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId/:tab" element={<Personalverwaltung />} />
<Route path="/supply/team/bearbeitungsverlauf" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/bearbeitungsverlauf/:tab" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/kanaele-systeme" element={<KanaeleSysteme />} />
{/* Die fünf Agentenseiten. Vier behalten den Pfad ihres Vorgängers. */}
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/besichtigungen" element={<Besichtigungen />} />
<Route path="/supply/my-listings" element={<MyListings />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
{/* Property On — «Meine Agenten». Die drei Verwaltungsbereiche sind
seit Runde 4 Reiter derselben Seite; die alten Unterseitenpfade
leiten dorthin um, damit bestehende Lesezeichen nicht brechen. */}
<Route path="/supply/team" element={<MeineAgenten />} />
<Route
path="/supply/team/personalverwaltung/*"
element={<TeamSectionRedirect section={AGENT_SECTIONS.PERSONNEL} />}
/>
<Route
path="/supply/team/bearbeitungsverlauf/*"
element={<TeamSectionRedirect section={AGENT_SECTIONS.HISTORY} />}
/>
<Route
path="/supply/team/kanaele-systeme"
element={<TeamSectionRedirect section={AGENT_SECTIONS.CHANNELS} />}
/>
</Route>
</Route>
</Route>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

@@ -1,15 +1,16 @@
import { GenericBadge } from '../shared/GenericBadge'
import type { AIOutputError } from '../../domain/aiOutput'
import { DS_ACCENT } from '../../lib/ds'
const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
SCHEMA_VALIDATION: { label: 'Schema', color: '#ea580c' },
PROVIDER_TIMEOUT: { label: 'Timeout', color: '#c0392b' },
INVALID_JSON: { label: 'JSON', color: '#c0392b' },
EMPTY_RESPONSE: { label: 'Leer', color: '#d97706' },
RATE_LIMIT: { label: 'Rate Limit', color: '#7c3aed' },
SCHEMA_VALIDATION: { label: 'Schema', color: DS_ACCENT.warning.strong },
PROVIDER_TIMEOUT: { label: 'Timeout', color: DS_ACCENT.danger.main },
INVALID_JSON: { label: 'JSON', color: DS_ACCENT.danger.main },
EMPTY_RESPONSE: { label: 'Leer', color: DS_ACCENT.warning.main },
RATE_LIMIT: { label: 'Rate Limit', color: DS_ACCENT.violet.main },
}
export function AIErrorBadge({ error }: { error: AIOutputError }) {
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' }
const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: DS_ACCENT.danger.main }
return <GenericBadge label={label} color={color} bold tooltip={error.message} />
}
@@ -1,5 +1,6 @@
import { Box, Typography } from '@mui/material'
import { Bot, Filter, MousePointer } from 'lucide-react'
import { DS_SLATE } from '../../lib/ds'
interface Props {
context: 'no-outputs' | 'filtered-empty' | 'no-selection'
@@ -8,19 +9,19 @@ interface Props {
const CONFIG = {
'no-outputs': {
icon: Bot,
color: '#94a3b8',
color: DS_SLATE[400],
title: 'Keine AI-Outputs',
desc: 'Es wurden noch keine AI-Outputs generiert.',
},
'filtered-empty': {
icon: Filter,
color: '#94a3b8',
color: DS_SLATE[400],
title: 'Keine Ergebnisse',
desc: 'Kein AI-Output entspricht den aktiven Filtern.',
},
'no-selection': {
icon: MousePointer,
color: '#94a3b8',
color: DS_SLATE[400],
title: 'Output auswählen',
desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.',
},
@@ -31,7 +32,7 @@ export function AIMonitoringEmptyState({ context }: Props) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200, p: 4, textAlign: 'center' }}>
<Icon size={36} color={color} style={{ marginBottom: 12, opacity: 0.6 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: '#1e293b' }}>{title}</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5, color: DS_SLATE[800] }}>{title}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 260 }}>{desc}</Typography>
</Box>
)
@@ -6,6 +6,7 @@ import { AIErrorBadge } from './AIErrorBadge'
import { AIReviewActionToolbar } from './AIReviewActionToolbar'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
const TYPE_LABELS: Record<AIOutputType, string> = {
NEED_PARSE: 'Bedarf-Parsing',
@@ -81,22 +82,22 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1.5 }}>
{/* Metadata */}
<MetaRow label="Output-ID">
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569' }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: DS_SLATE[600] }}>
{output.id}
</Typography>
</MetaRow>
<MetaRow label="Erstellt">
<Typography variant="caption" sx={{ color: '#334155' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[700] }}>
{new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
</Typography>
</MetaRow>
<MetaRow label="Modell">
<Typography variant="caption" sx={{ fontWeight: 600, color: '#334155' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_SLATE[700] }}>
{MODEL_LABELS[output.model] ?? output.model}
</Typography>
</MetaRow>
<MetaRow label="Provider">
<Typography variant="caption" sx={{ color: '#334155', textTransform: 'capitalize' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[700], textTransform: 'capitalize' }}>
{output.provider}
</Typography>
</MetaRow>
@@ -104,14 +105,14 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
</MetaRow>
<MetaRow label="Input-Hash">
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: DS_SLATE[400] }}>
{output.inputHash}
</Typography>
</MetaRow>
<MetaRow label="Bezug">
<Typography variant="caption" sx={{ color: '#334155' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[700] }}>
{ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '}
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#94a3b8' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: DS_SLATE[400] }}>
{output.relatedEntityId}
</span>
</Typography>
@@ -125,7 +126,7 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
)}
{output.costEstimate != null && (
<MetaRow label="Kostenschätzung">
<Typography variant="caption" sx={{ color: '#334155' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[700] }}>
${output.costEstimate.toFixed(4)}
</Typography>
</MetaRow>
@@ -144,7 +145,7 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
<br />
{output.error.message}
{output.error.recoverable && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: '#92400e' }}>
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: DS_TEXT.warningDark }}>
Wiederholbar kann erneut ausgelöst werden.
</Typography>
)}
@@ -154,18 +155,18 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
{/* Output preview */}
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#64748b', display: 'block', mb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: DS_SLATE[500], display: 'block', mb: 0.5 }}>
Output-Vorschau
</Typography>
<Box
sx={{
p: 1.25,
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
borderRadius: 1,
border: '1px solid #e2e8f0',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: '#334155',
color: DS_SLATE[700],
lineHeight: 1.6,
overflowX: 'auto',
whiteSpace: 'pre-wrap',
@@ -1,15 +1,16 @@
import { GenericBadge } from '../shared/GenericBadge'
import type { ReviewStatus } from '../../domain/enums'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
FLAGGED: { label: 'Markiert', color: '#ea580c' },
UNREVIEWED: { label: 'Ungeprüft', color: DS_SLATE[400] },
IN_REVIEW: { label: 'In Prüfung', color: DS_ACCENT.warning.main },
APPROVED: { label: 'Genehmigt', color: DS_ACCENT.success.main },
REJECTED: { label: 'Abgelehnt', color: DS_ACCENT.danger.main },
FLAGGED: { label: 'Markiert', color: DS_ACCENT.warning.strong },
}
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) {
const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' }
const { label, color } = CONFIG[status] ?? { label: status, color: DS_SLATE[500] }
return <GenericBadge label={label} color={color} />
}
@@ -12,6 +12,7 @@ import { PromptVersionBadge } from './PromptVersionBadge'
import { AIErrorBadge } from './AIErrorBadge'
import { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import { DS_SLATE } from '../../lib/ds'
const TYPE_LABELS: Record<AIOutputType, string> = {
NEED_PARSE: 'Bedarf-Parsing',
@@ -59,7 +60,7 @@ export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props)
return (
<Table size="small" stickyHeader>
<TableHead>
<TableRow sx={{ '& th': { bgcolor: '#f8fafc', fontSize: '0.7rem', fontWeight: 700, color: '#64748b', py: 0.75, textTransform: 'uppercase', letterSpacing: 0.4 } }}>
<TableRow sx={{ '& th': { bgcolor: DS_SLATE[50], fontSize: '0.7rem', fontWeight: 700, color: DS_SLATE[500], py: 0.75, textTransform: 'uppercase', letterSpacing: 0.4 } }}>
<TableCell sx={{ minWidth: 110 }}>Zeitpunkt</TableCell>
<TableCell sx={{ minWidth: 130 }}>Typ</TableCell>
<TableCell sx={{ minWidth: 100 }}>Modell</TableCell>
@@ -108,7 +109,7 @@ export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props)
</Box>
</TableCell>
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 500, fontSize: '0.7rem', color: '#334155' }}>
<Typography variant="caption" sx={{ fontWeight: 500, fontSize: '0.7rem', color: DS_SLATE[700] }}>
{MODEL_SHORT[output.model] ?? output.model}
</Typography>
</TableCell>
@@ -2,6 +2,7 @@ import { Box, Button, Tooltip } from '@mui/material'
import { CheckCircle, XCircle, Send, Copy } from 'lucide-react'
import type { AIOutput } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
import { DS_ACCENT, DS_BG, DS_SLATE } from '../../lib/ds'
interface Props {
output: AIOutput
@@ -26,7 +27,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
disabled={isSubmitting}
startIcon={<Send size={13} />}
onClick={() => onUpdateStatus('IN_REVIEW')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#d97706', borderColor: '#d97706', '&:hover': { bgcolor: '#fffbeb', borderColor: '#b45309' } }}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: DS_ACCENT.warning.main, borderColor: DS_ACCENT.warning.main, '&:hover': { bgcolor: DS_ACCENT.warning.bg, borderColor: DS_ACCENT.warning.dark } }}
>
Zur Prüfung
</Button>
@@ -38,7 +39,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
disabled={isSubmitting}
startIcon={<CheckCircle size={13} />}
onClick={() => onUpdateStatus('APPROVED')}
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#155f3a' } }}
sx={{ textTransform: 'none', fontSize: '0.75rem', bgcolor: DS_ACCENT.success.main, '&:hover': { bgcolor: DS_ACCENT.success.dark } }}
>
Genehmigen
</Button>
@@ -50,7 +51,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
disabled={isSubmitting}
startIcon={<XCircle size={13} />}
onClick={() => onUpdateStatus('REJECTED')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#c0392b', borderColor: '#c0392b', '&:hover': { bgcolor: '#fef2f2', borderColor: '#a93226' } }}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: DS_ACCENT.danger.main, borderColor: DS_ACCENT.danger.main, '&:hover': { bgcolor: DS_ACCENT.danger.bg, borderColor: DS_ACCENT.danger.dark } }}
>
Ablehnen
</Button>
@@ -61,7 +62,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
variant="outlined"
onClick={onCopyJson}
startIcon={<Copy size={13} />}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#64748b', borderColor: '#e8e7e4', '&:hover': { bgcolor: '#f8fafc' } }}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: DS_SLATE[500], borderColor: DS_BG.muted, '&:hover': { bgcolor: DS_SLATE[50] } }}
>
JSON
</Button>
@@ -1,4 +1,5 @@
import { Box, Tooltip, Typography } from '@mui/material'
import { DS_SLATE } from '../../lib/ds'
interface Props {
promptVersion: string
@@ -14,7 +15,7 @@ export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
gap: 0.5,
px: 0.75,
py: 0.2,
bgcolor: '#f1f5f9',
bgcolor: DS_SLATE[100],
borderRadius: 1,
border: '1px solid #e2e8f0',
cursor: schemaVersion ? 'default' : undefined,
@@ -22,16 +23,16 @@ export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
>
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', fontWeight: 600, lineHeight: 1.4 }}
sx={{ fontFamily: 'monospace', fontSize: '0.65rem', color: DS_SLATE[600], fontWeight: 600, lineHeight: 1.4 }}
>
{promptVersion}
</Typography>
{schemaVersion && (
<>
<Box sx={{ width: '1px', height: 10, bgcolor: '#cbd5e1', flexShrink: 0 }} />
<Box sx={{ width: '1px', height: 10, bgcolor: DS_SLATE[300], flexShrink: 0 }} />
<Typography
variant="caption"
sx={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#94a3b8', lineHeight: 1.4 }}
sx={{ fontFamily: 'monospace', fontSize: '0.6rem', color: DS_SLATE[400], lineHeight: 1.4 }}
>
{schemaVersion}
</Typography>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries'
@@ -7,6 +7,7 @@ import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid'
import { InquiryDetailPanel } from './InquiryDetailPanel'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
type ViewMode = 'list' | 'grid'
@@ -26,23 +27,22 @@ interface Props {
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [chosenId, setChosenId] = useState<string | null>(null)
const [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
useEffect(() => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(VIEW_STORAGE_KEY, view)
// Die Ansichtswahl gehört ins Ereignis, nicht in einen Effekt — sie ändert
// sich nur durch einen Klick, und der weiss selbst, wann er stattfindet.
function changeView(next: ViewMode) {
setView(next)
if (typeof window !== 'undefined') window.localStorage.setItem(VIEW_STORAGE_KEY, next)
}
}, [view])
// Auto-select first inquiry on desktop only
useEffect(() => {
if (!isMobile && !selectedId && inquiries.length > 0) {
setSelectedId(inquiries[0].id)
}
}, [inquiries, selectedId, isMobile])
// Auf grossen Schirmen steht ohne eigene Wahl die erste Anfrage offen —
// abgeleitet statt in einem Effekt nachgetragen.
const selectedId = chosenId ?? (isMobile ? null : inquiries[0]?.id ?? null)
const setSelectedId = setChosenId
if (!isLoading && inquiries.length === 0) {
return (
@@ -97,21 +97,21 @@ export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Pr
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900] }}>
Anfragen
</Typography>
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
<Box sx={{ px: 1, py: 0.125, borderRadius: 1, bgcolor: DS_BRAND.main, color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{inquiries.length}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Listenansicht">
<IconButton size="small" onClick={() => setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}>
<IconButton size="small" onClick={() => changeView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}>
<ListIcon size={16} />
</IconButton>
</Tooltip>
<Tooltip title="Kartenansicht">
<IconButton size="small" onClick={() => setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}>
<IconButton size="small" onClick={() => changeView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}>
<LayoutGrid size={16} />
</IconButton>
</Tooltip>
@@ -127,7 +127,7 @@ export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Pr
{/* Right column: detail (desktop only) */}
{!isMobile && (
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: DS_SLATE[50] }}>
{selectedId ? (
<InquiryDetailPanel inquiryId={selectedId} />
) : (
@@ -1,6 +1,7 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { useGenerateOfferEmail } from '../../hooks/useAI'
import { DS_ACCENT } from '../../lib/ds'
interface AiOfferEmailButtonProps {
needTitle: string
@@ -37,9 +38,9 @@ export function AiOfferEmailButton({
disabled={generateOfferEmail.isPending}
sx={{
textTransform: 'none',
color: '#7c3aed',
borderColor: '#c4b5fd',
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#7c3aed' },
color: DS_ACCENT.violet.main,
borderColor: DS_ACCENT.violet.border,
'&:hover': { bgcolor: DS_ACCENT.violet.bgAlt, borderColor: DS_ACCENT.violet.main },
}}
variant="outlined"
>
@@ -1,5 +1,6 @@
import { Box, TextField, Typography } from '@mui/material'
import type { OfferEditableField } from '../../domain/offer'
import { DS_SLATE } from '../../lib/ds'
interface EditableOfferFieldListProps {
fields: OfferEditableField[]
@@ -17,7 +18,7 @@ export function EditableOfferFieldList({ fields, values, onChange }: EditableOff
<Typography
variant="caption"
sx={{
color: '#64748b',
color: DS_SLATE[500],
fontWeight: 600,
fontSize: '0.7rem',
textTransform: 'uppercase',
@@ -3,6 +3,7 @@ import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface EmbeddedPropertyCardProps {
property: Property
@@ -48,7 +49,7 @@ export function EmbeddedPropertyCard({
height: 72,
flexShrink: 0,
borderRadius: 1.5,
bgcolor: '#e8e7e4',
bgcolor: DS_BG.muted,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -62,7 +63,7 @@ export function EmbeddedPropertyCard({
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem', lineHeight: 1.3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_SLATE[900], fontSize: '0.9rem', lineHeight: 1.3 }}>
{property.title}
</Typography>
<Box
@@ -81,13 +82,13 @@ export function EmbeddedPropertyCard({
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, color: DS_SLATE[500] }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#15803d', fontWeight: 600, display: 'block', mt: 0.5 }}>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: DS_ACCENT.success.strong, fontWeight: 600, display: 'block', mt: 0.5 }}>
{reason}
</Typography>
</Box>
@@ -102,7 +103,7 @@ export function EmbeddedPropertyCard({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#fafafa',
bgcolor: DS_NEUTRAL.offWhite,
}}
>
<Box
@@ -117,7 +118,7 @@ export function EmbeddedPropertyCard({
disabled={disabled}
sx={{ p: 0.5 }}
/>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: '#475569', fontWeight: 500 }}>
<Typography variant="caption" sx={{ fontSize: '0.775rem', color: DS_SLATE[600], fontWeight: 500 }}>
Für Angebot auswählen
</Typography>
</Box>
@@ -130,12 +131,12 @@ export function EmbeddedPropertyCard({
textTransform: 'none',
fontSize: '0.775rem',
fontWeight: 600,
borderColor: '#152642',
color: '#152642',
borderColor: DS_BRAND.main,
color: DS_BRAND.main,
py: 0.5,
px: 1.5,
minWidth: 0,
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' },
'&:hover': { bgcolor: DS_BRAND.main, color: 'white', borderColor: DS_BRAND.main },
}}
>
Angebot
+11 -8
View File
@@ -1,7 +1,9 @@
import { Box, Paper, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
import { formatInquiryDate } from './inquiryUtils'
import { usePropertyLookup } from '../../hooks/usePropertyLookup'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface InquiryCardProps {
inquiry: Inquiry
@@ -10,6 +12,7 @@ interface InquiryCardProps {
}
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
const { propertyTitle } = usePropertyLookup()
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
@@ -39,7 +42,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
variant="body2"
sx={{
fontWeight: hasUnread ? 700 : 600,
color: '#0f172a',
color: DS_SLATE[900],
fontSize: '0.85rem',
lineHeight: 1.3,
}}
@@ -53,7 +56,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
minWidth: 20,
height: 20,
borderRadius: '50%',
bgcolor: '#2563eb',
bgcolor: DS_ACCENT.blue.strong,
color: 'white',
fontSize: '0.65rem',
fontWeight: 700,
@@ -73,7 +76,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
variant="body2"
sx={{
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
color: DS_SLATE[800],
fontSize: '0.85rem',
display: '-webkit-box',
WebkitLineClamp: 2,
@@ -84,19 +87,19 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
{inquiry.subject}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: DS_SLATE[500] }}>
<Building2 size={13} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)}
{propertyTitle(inquiry.propertyId)}
</Typography>
</Box>
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.7rem' }}>
{formatInquiryDate(inquiry.createdAt)}
</Typography>
{inquiry.matchScore !== undefined && (
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: DS_BRAND.main }}>
{inquiry.matchScore}%
</Typography>
)}
@@ -4,6 +4,7 @@ import { FileText } from 'lucide-react'
import type { Inquiry, Attachment } from '../../domain/inquiry'
import { InquiryMessageBubble } from './InquiryMessageBubble'
import { InquiryReplyComposer } from './InquiryReplyComposer'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface InquiryChatProps {
inquiry: Inquiry
@@ -40,9 +41,9 @@ export function InquiryChat({
sx={{
textTransform: 'none',
fontSize: '0.75rem',
borderColor: '#cbd5e1',
color: '#475569',
'&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' },
borderColor: DS_SLATE[300],
color: DS_SLATE[600],
'&:hover': { borderColor: DS_BRAND.main, color: DS_BRAND.main, bgcolor: DS_NEUTRAL.paperTint },
}}
>
Angebot erstellen
@@ -67,7 +67,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.75rem' }}>
{inquiry.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
@@ -76,7 +76,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
variant="body1"
sx={{
fontWeight: 600,
color: '#0f172a',
color: DS_SLATE[900],
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -92,7 +92,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
variant="outlined"
startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: '#152642', color: '#152642', '&:hover': { bgcolor: '#f0f4f8' } }}
sx={{ textTransform: 'none', whiteSpace: 'nowrap', borderColor: DS_BRAND.main, color: DS_BRAND.main, '&:hover': { bgcolor: DS_NEUTRAL.paperTint } }}
>
Vorbereitung starten
</Button>
@@ -105,7 +105,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
px: 2,
py: 1,
borderBottom: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
display: 'flex',
alignItems: 'center',
gap: 1.5,
@@ -119,7 +119,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
borderRadius: 1,
overflow: 'hidden',
flexShrink: 0,
bgcolor: '#e8e7e4',
bgcolor: DS_BG.muted,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -138,11 +138,11 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
sx={{ fontWeight: 600, color: DS_SLATE[900], fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.3 }}
>
{property?.title ?? '—'}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: DS_SLATE[500] }}>
<MapPin size={11} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{property?.location.city}
@@ -155,7 +155,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
size="small"
endIcon={<ArrowRight size={12} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: '#152642', whiteSpace: 'nowrap', flexShrink: 0 }}
sx={{ textTransform: 'none', fontSize: '0.75rem', color: DS_BRAND.main, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Objekt ansehen
</Button>
@@ -220,6 +220,7 @@ const OfferCreationWizardComponent = lazy(() =>
)
import type { Inquiry } from '../../domain/inquiry'
import { DS_BG, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) {
return (
@@ -1,7 +1,9 @@
import { Box, Typography } from '@mui/material'
import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry'
import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils'
import { formatInquiryDate } from './inquiryUtils'
import { usePropertyLookup } from '../../hooks/usePropertyLookup'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface InquiryListRowProps {
inquiry: Inquiry
@@ -10,6 +12,7 @@ interface InquiryListRowProps {
}
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
const { propertyTitle } = usePropertyLookup()
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return (
@@ -40,7 +43,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
minWidth: 18,
height: 18,
borderRadius: '50%',
bgcolor: '#2563eb',
bgcolor: DS_ACCENT.blue.strong,
color: 'white',
fontSize: '0.6rem',
fontWeight: 700,
@@ -59,7 +62,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
variant="body2"
sx={{
fontWeight: hasUnread ? 600 : 500,
color: '#1e293b',
color: DS_SLATE[800],
fontSize: '0.8125rem',
mb: 0.5,
overflow: 'hidden',
@@ -69,18 +72,18 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
>
{inquiry.subject}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#64748b', fontSize: '0.75rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: DS_SLATE[500], fontSize: '0.75rem' }}>
<Building2 size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)}
{propertyTitle(inquiry.propertyId)}
</Typography>
{inquiry.matchScore !== undefined && (
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: '#152642' }}>
<Typography sx={{ ml: 'auto', fontSize: '0.75rem', fontWeight: 700, color: DS_BRAND.main }}>
{inquiry.matchScore}%
</Typography>
)}
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.7rem', mt: 0.5, display: 'block' }}>
{formatInquiryDate(inquiry.createdAt)}
</Typography>
</Box>
@@ -2,6 +2,7 @@ import { Box, Typography } from '@mui/material'
import { Paperclip } from 'lucide-react'
import type { InquiryMessage } from '../../domain/inquiry'
import { formatInquiryDate, formatFileSize } from './inquiryUtils'
import { DS_SLATE } from '../../lib/ds'
interface InquiryMessageBubbleProps {
message: InquiryMessage
@@ -18,9 +19,9 @@ export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
<Typography
variant="caption"
sx={{
color: '#64748b',
color: DS_SLATE[500],
fontStyle: 'italic',
bgcolor: '#f1f5f9',
bgcolor: DS_SLATE[100],
px: 2,
py: 0.5,
borderRadius: 4,
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import {
Box,
Button,
@@ -12,6 +12,7 @@ import { useSendInquiryReply } from '../../hooks/useInquiries'
import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry'
import { formatFileSize } from './inquiryUtils'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface InquiryReplyComposerProps {
inquiryId: string
@@ -34,21 +35,24 @@ export function InquiryReplyComposer({
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
)
const [body, setBody] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [ownAttachments, setOwnAttachments] = useState<Attachment[]>([])
const [isAIDraft, setIsAIDraft] = useState(false)
const sendReply = useSendInquiryReply()
const showToast = useToastStore(s => s.showToast)
useEffect(() => {
if (pendingAttachment) {
setAttachments(prev => {
if (prev.find(a => a.id === pendingAttachment.id)) return prev
return [...prev, pendingAttachment]
})
onPendingAttachmentConsumed?.()
}
}, [pendingAttachment, onPendingAttachmentConsumed])
/**
* Der übergebene Anhang wird nur noch angezeigt, nicht mehr in den eigenen
* Zustand kopiert. Vorher tat das ein Effekt — und weil er beim Rendern
* `setState` rief, lief jeder Anhang durch zwei Renderdurchgänge, in deren
* erstem er noch fehlte. Abgemeldet wird er jetzt dort, wo er tatsächlich
* verbraucht ist: beim Entfernen oder beim Senden.
*/
const attachments = useMemo(() => {
if (!pendingAttachment) return ownAttachments
if (ownAttachments.some(a => a.id === pendingAttachment.id)) return ownAttachments
return [...ownAttachments, pendingAttachment]
}, [ownAttachments, pendingAttachment])
const sending = sendReply.isPending
@@ -62,7 +66,7 @@ export function InquiryReplyComposer({
const handleAddMockAttachment = () => {
const name = `Anhang_${attachments.length + 1}.pdf`
setAttachments(prev => [
setOwnAttachments(prev => [
...prev,
{
id: crypto.randomUUID(),
@@ -74,7 +78,8 @@ export function InquiryReplyComposer({
}
const handleRemoveAttachment = (id: string) => {
setAttachments(prev => prev.filter(a => a.id !== id))
if (id === pendingAttachment?.id) onPendingAttachmentConsumed?.()
else setOwnAttachments(prev => prev.filter(a => a.id !== id))
}
const handleSend = async () => {
@@ -92,7 +97,8 @@ export function InquiryReplyComposer({
}
showToast('Antwort gesendet', 'success')
setBody('')
setAttachments([])
setOwnAttachments([])
onPendingAttachmentConsumed?.()
setIsAIDraft(false)
onSent?.()
}
@@ -101,7 +107,7 @@ export function InquiryReplyComposer({
<Box
sx={{
borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
p: 2,
display: 'flex',
flexDirection: 'column',
@@ -119,7 +125,7 @@ export function InquiryReplyComposer({
{isAIDraft && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Sparkles size={12} color="#7c3aed" />
<Typography variant="caption" sx={{ color: '#7c3aed', fontSize: '0.7rem', fontWeight: 500 }}>
<Typography variant="caption" sx={{ color: DS_ACCENT.violet.main, fontSize: '0.7rem', fontWeight: 500 }}>
KI-Entwurf bitte prüfen und anpassen
</Typography>
</Box>
@@ -134,7 +140,7 @@ export function InquiryReplyComposer({
rows={7}
placeholder="Antwort verfassen..."
fullWidth
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: DS_ACCENT.violet.borderSoft }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}}
/>
{attachments.length > 0 && (
@@ -160,7 +166,7 @@ export function InquiryReplyComposer({
{a.fileName}
</Typography>
{a.fileSize && (
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#64748b' }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: DS_SLATE[500] }}>
{formatFileSize(a.fileSize)}
</Typography>
)}
@@ -181,9 +187,9 @@ export function InquiryReplyComposer({
sx={{
textTransform: 'none',
fontSize: '0.75rem',
color: '#7c3aed',
borderColor: '#ddd6fe',
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' },
color: DS_ACCENT.violet.main,
borderColor: DS_ACCENT.violet.borderSoft,
'&:hover': { bgcolor: DS_ACCENT.violet.bgAlt, borderColor: DS_ACCENT.violet.border },
}}
variant="outlined"
>
@@ -206,7 +212,7 @@ export function InquiryReplyComposer({
}
onClick={handleSend}
disabled={sending || !body.trim()}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Antwort senden
</Button>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Sparkles } from 'lucide-react'
import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
@@ -6,23 +6,25 @@ import { EmptyState } from '../ui'
import { PublicNeedList } from './PublicNeedList'
import { PublicNeedDetail } from './PublicNeedDetail'
import { OwnPropertyMatchList } from './OwnPropertyMatchList'
import { DS_BRAND } from '../../lib/ds'
type MobileView = 'list' | 'detail' | 'properties'
export function LatentInquiriesTab() {
const { data: needs = [] } = usePublicNeeds()
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(null)
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg'))
const [mobileView, setMobileView] = useState<MobileView>('list')
useEffect(() => {
if (!isMobile && !selectedNeedId && needs.length > 0) {
setSelectedNeedId(needs[0].id)
}
}, [needs, selectedNeedId, isMobile])
// Ohne eigene Wahl steht auf grossen Schirmen der erste Eintrag offen —
// abgeleitet statt in einem Effekt nachgetragen. Der Effekt löste einen
// zweiten Renderdurchgang aus, in dem die Detailspalte noch leer war.
const [chosenNeedId, setChosenNeedId] = useState<string | null>(null)
const selectedNeedId = chosenNeedId ?? (isMobile ? null : needs[0]?.id ?? null)
const setSelectedNeedId = setChosenNeedId
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
const handleSelectNeed = (id: string) => {
setSelectedNeedId(id)
@@ -47,7 +49,7 @@ export function LatentInquiriesTab() {
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box
onClick={() => setMobileView('properties')}
sx={{ p: 1.25, bgcolor: '#152642', color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
sx={{ p: 1.25, bgcolor: DS_BRAND.main, color: 'white', borderRadius: 1, textAlign: 'center', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600 }}
>
Eigene Objekte anzeigen
</Box>
@@ -1,9 +1,10 @@
import { Box, Divider, Typography } from '@mui/material'
import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { mockProperties } from '../../mock-data/properties'
import { usePropertyLookup } from '../../hooks/usePropertyLookup'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface MockPdfPreviewProps {
fields: Record<string, string>
@@ -79,7 +80,8 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
const needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds)
const properties = mockProperties.filter(p => propertyIds.includes(p.id))
const { findProperties } = usePropertyLookup()
const properties = findProperties(propertyIds)
const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
@@ -98,21 +100,21 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
}}
>
{/* ── Page 1 — Offer letter ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3, color: DS_SLATE[500] }}>
<FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
Angebotsvorschau (PDF)
</Typography>
</Box>
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right' }}>
<Typography variant="caption" sx={{ display: 'block', color: DS_SLATE[400], textAlign: 'right' }}>
Wincasa AG · Zürich
</Typography>
<Typography variant="caption" sx={{ display: 'block', color: '#94a3b8', textAlign: 'right', mb: 4 }}>
<Typography variant="caption" sx={{ display: 'block', color: DS_SLATE[400], textAlign: 'right', mb: 4 }}>
{new Date().toLocaleDateString('de-CH')}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', mb: 2, fontSize: '1.05rem' }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_SLATE[900], mb: 2, fontSize: '1.05rem' }}>
Angebot: {needTitle}
</Typography>
@@ -146,7 +148,7 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
</Typography>
))}
{properties.length === 0 && (
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}>
<Typography variant="body2" sx={{ color: DS_SLATE[400], fontStyle: 'italic' }}>
Keine Objekte ausgewählt.
</Typography>
)}
@@ -164,11 +166,11 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
{hasPropertyPage && (
<>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
<Divider sx={{ flex: 1, borderColor: DS_BRAND.main, borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails
</Typography>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} />
<Divider sx={{ flex: 1, borderColor: DS_BRAND.main, borderWidth: 1 }} />
</Box>
<Box
@@ -203,19 +205,19 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }}
/>
) : (
<Box sx={{ height: 90, bgcolor: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ height: 90, bgcolor: DS_SLATE[100], display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Building2 size={22} color="#94a3b8" />
</Box>
)}
{/* Info */}
<Box sx={{ p: 1.25, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: '#0f172a', lineHeight: 1.3, mb: 0.4 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.78rem', color: DS_SLATE[900], lineHeight: 1.3, mb: 0.4 }}>
{p.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
<MapPin size={10} color="#94a3b8" />
<Typography sx={{ fontSize: '0.68rem', color: '#64748b' }}>
<Typography sx={{ fontSize: '0.68rem', color: DS_SLATE[500] }}>
{p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography>
</Box>
@@ -228,10 +230,10 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
if (!val) return null
return (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', color: '#94a3b8' }}>
<Typography sx={{ fontSize: '0.62rem', color: DS_SLATE[400] }}>
{FIELD_LABELS[key] ?? key}
</Typography>
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: '#0f172a', textAlign: 'right' }}>
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: DS_SLATE[900], textAlign: 'right' }}>
{val}
</Typography>
</Box>
@@ -7,9 +7,10 @@ import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties'
import { usePropertyLookup } from '../../hooks/usePropertyLookup'
import { deterministicMatchScore } from './latentNeedUtils'
import { formatFileSize } from './inquiryUtils'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export function OfferChatComposer() {
const subject = useOfferWizardStore(s => s.messageSubject)
@@ -32,10 +33,8 @@ export function OfferChatComposer() {
const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => {
const p = mockProperties.find(pp => pp.id === id)
return p?.title ?? id
})
const { propertyTitle } = usePropertyLookup()
const propertyTitles = selectedPropertyIds.map(propertyTitle)
const scores = needId
? selectedPropertyIds.map(id => deterministicMatchScore(id, needId))
: []
@@ -85,13 +84,13 @@ export function OfferChatComposer() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, bgcolor: DS_SLATE[50] }}>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Empfänger-Kontext
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#1e293b', mt: 0.5 }}>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: DS_SLATE[800], mt: 0.5 }}>
Bedarf: <strong>{needTitle}</strong> · {selectedPropertyIds.length} Objekt
{selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot
</Typography>
@@ -117,7 +116,7 @@ export function OfferChatComposer() {
{attachments.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, mb: 0.75, display: 'block' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 600, mb: 0.75, display: 'block' }}>
Anhänge
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
@@ -144,8 +143,8 @@ export function OfferChatComposer() {
sx={{
px: 0.75,
py: 0.125,
bgcolor: '#e0e7ff',
color: '#3730a3',
bgcolor: DS_ACCENT.indigo.border,
color: DS_ACCENT.indigo.dark,
fontWeight: 700,
fontSize: '0.65rem',
borderRadius: 1,
@@ -154,7 +153,7 @@ export function OfferChatComposer() {
PDF
</Box>
)}
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: '#64748b', ml: 'auto' }}>
<Typography variant="caption" sx={{ fontSize: '0.75rem', color: DS_SLATE[500], ml: 'auto' }}>
{formatFileSize(a.fileSize)}
</Typography>
<IconButton size="small" onClick={() => removeAttachment(a.id)} sx={{ p: 0.25 }}>
@@ -206,9 +205,9 @@ export function OfferChatComposer() {
disabled={sendOffer.isPending || !body.trim()}
sx={{
textTransform: 'none',
bgcolor: '#16a34a',
bgcolor: DS_ACCENT.success.bright,
fontWeight: 600,
'&:hover': { bgcolor: '#15803d' },
'&:hover': { bgcolor: DS_ACCENT.success.strong },
}}
>
Absenden
@@ -3,6 +3,7 @@ import { CheckCircle2 } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useMarkOfferChecked } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export function OfferCheckedAction() {
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
@@ -45,7 +46,7 @@ export function OfferCheckedAction() {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
p: 4,
textAlign: 'center',
}}
@@ -55,7 +56,7 @@ export function OfferCheckedAction() {
width: 96,
height: 96,
borderRadius: '50%',
bgcolor: '#dcfce7',
bgcolor: DS_ACCENT.success.bgAlt,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -64,10 +65,10 @@ export function OfferCheckedAction() {
>
<CheckCircle2 size={48} color="#16a34a" />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_SLATE[900], mb: 1 }}>
Bereit zur Prüfung
</Typography>
<Typography variant="body2" sx={{ color: '#475569', maxWidth: 480, mb: 4 }}>
<Typography variant="body2" sx={{ color: DS_SLATE[600], maxWidth: 480, mb: 4 }}>
Bestätigen Sie das geprüfte Angebot. Das PDF wird automatisch als Anhang für den Chat
vorbereitet, damit Sie es direkt versenden können.
</Typography>
@@ -81,12 +82,12 @@ export function OfferCheckedAction() {
disabled={markChecked.isPending}
sx={{
textTransform: 'none',
bgcolor: '#16a34a',
bgcolor: DS_ACCENT.success.bright,
fontSize: '1rem',
fontWeight: 700,
px: 4,
py: 1.5,
'&:hover': { bgcolor: '#15803d' },
'&:hover': { bgcolor: DS_ACCENT.success.strong },
}}
>
ANGEBOT GEPRÜFT
@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/material'
import { FileText } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface OfferCreationPanelProps {
selectedCount: number
@@ -16,7 +17,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
<Box
sx={{
borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
p: 1.5,
display: 'flex',
flexDirection: 'column',
@@ -24,7 +25,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a' }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: DS_SLATE[900] }}>
{selectedCount} Objekt{selectedCount === 1 ? '' : 'e'} ausgewählt
</Typography>
<Button
@@ -35,9 +36,9 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
onClick={() => open(needId, needTitle)}
sx={{
textTransform: 'none',
bgcolor: '#152642',
bgcolor: DS_BRAND.main,
fontWeight: 600,
'&:hover': { bgcolor: '#16304d' },
'&:hover': { bgcolor: DS_BRAND.hover },
}}
>
Angebot erstellen
@@ -10,6 +10,7 @@ import { useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf }
import { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore'
import { OfferWizardPdfStep } from './OfferWizardPdfStep'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props {
inquiryId: string
@@ -135,7 +136,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Box sx={{ height: 120, backgroundImage: `url(${property.images[0]})`, backgroundSize: 'cover', backgroundPosition: 'center', borderRadius: 1, mb: 1.5 }} />
)}
<Typography variant="body2" sx={{ fontWeight: 700 }}>{property.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
{property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
@@ -146,7 +147,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ color: '#64748b', lineHeight: 1.7 }}>
<Typography variant="body2" sx={{ color: DS_SLATE[500], lineHeight: 1.7 }}>
Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt.
Im nächsten Schritt können Sie alle Felder anpassen.
</Typography>
@@ -175,7 +176,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
{/* Step 2: Viewing appointments */}
{!loading && step === 2 && draft && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
<Typography variant="body2" sx={{ color: DS_SLATE[500], mb: 2 }}>
Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen:
</Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}>
@@ -205,7 +206,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
onChange={e => updateAppointment(a.id, 'contactPerson', e.target.value)}
sx={{ flex: 1 }}
/>
<IconButton size="small" onClick={() => removeAppointment(a.id)} sx={{ color: '#ef4444' }}>
<IconButton size="small" onClick={() => removeAppointment(a.id)} sx={{ color: DS_ACCENT.danger.bright }}>
<Trash2 size={16} />
</IconButton>
</Box>
@@ -241,7 +242,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
{/* Footer */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: DS_SLATE[500] }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
@@ -255,7 +256,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
variant="contained"
disabled={loading}
onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Weiter
</Button>
@@ -264,7 +265,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button
variant="contained"
onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Weiter
</Button>
@@ -273,7 +274,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button
variant="contained"
onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
PDF erstellen
</Button>
@@ -5,6 +5,7 @@ import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useProperties } from '../../hooks/useProperties'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
@@ -41,7 +42,7 @@ export function OfferFieldSelectionStep() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2.5 }}>
<Typography variant="body2" sx={{ color: DS_SLATE[500], mb: 2.5 }}>
Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
</Typography>
@@ -77,7 +78,7 @@ export function OfferFieldSelectionStep() {
<Button
variant="contained"
onClick={() => setStep('pdf_review')}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Weiter
</Button>
@@ -7,6 +7,7 @@ import { useToastStore } from '../../stores/toastStore'
import { MockPdfPreview } from './MockPdfPreview'
import { EditableOfferFieldList } from './EditableOfferFieldList'
import type { OfferDraft, OfferEditableField } from '../../domain/offer'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
export function OfferPdfReviewStep() {
const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
@@ -108,7 +109,7 @@ export function OfferPdfReviewStep() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden', bgcolor: '#f1f5f9' }}>
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden', bgcolor: DS_SLATE[100] }}>
<Box sx={{ flex: 1.4, p: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{!pdfReady ? (
<Box
@@ -145,7 +146,7 @@ export function OfferPdfReviewStep() {
overflowY: 'auto',
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1.5, fontSize: '0.9rem' }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_SLATE[900], mb: 1.5, fontSize: '0.9rem' }}>
Inhalte bearbeiten
</Typography>
<EditableOfferFieldList
@@ -179,7 +180,7 @@ export function OfferPdfReviewStep() {
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleNext}
disabled={!pdfReady || markChecked.isPending}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
{markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
</Button>
@@ -9,6 +9,7 @@ import { useCreateOfferDraft } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore'
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
if (propertyAssetType === needAssetType) {
@@ -87,24 +88,24 @@ export function OfferPropertySelectionStep() {
<Box
sx={{
p: 2.5,
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}
>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Bedarf
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.1rem', mt: 0.25 }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: DS_SLATE[900], fontSize: '1.1rem', mt: 0.25 }}>
{need.title}
</Typography>
<Typography variant="caption" sx={{ color: '#475569', fontSize: '0.8rem', mt: 0.5, display: 'block' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[600], fontSize: '0.8rem', mt: 0.5, display: 'block' }}>
{assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}{need.sizeRange.max} m²
</Typography>
</Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 1.5 }}>
Wählen Sie passende Objekte aus Ihrem Portfolio
</Typography>
{isLoading && <CircularProgress size={20} />}
@@ -133,7 +134,7 @@ export function OfferPropertySelectionStep() {
flexShrink: 0,
}}
>
<Typography variant="body2" sx={{ color: '#64748b' }}>
<Typography variant="body2" sx={{ color: DS_SLATE[500] }}>
{selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt
</Typography>
<Button
@@ -141,7 +142,7 @@ export function OfferPropertySelectionStep() {
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Weiter
</Button>
@@ -16,6 +16,7 @@ import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer'
import { DS_SLATE } from '../../lib/ds'
// 'checked' is internal — merged into pdf_review step; 4 visible steps
const STEPS: { key: OfferStep; label: string }[] = [
@@ -75,14 +76,14 @@ export function OfferWizard() {
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.7rem', textTransform: 'uppercase', fontWeight: 600, letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.7rem', textTransform: 'uppercase', fontWeight: 600, letterSpacing: 0.5 }}>
Angebot erstellen
</Typography>
<Typography
variant="body1"
sx={{
fontWeight: 700,
color: '#0f172a',
color: DS_SLATE[900],
fontSize: '0.95rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -98,7 +99,7 @@ export function OfferWizard() {
</Box>
{/* Stepper */}
<Box sx={{ px: 3, py: 1.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', flexShrink: 0 }}>
<Box sx={{ px: 3, py: 1.5, borderBottom: '1px solid #e2e8f0', bgcolor: DS_SLATE[50], flexShrink: 0 }}>
<Stepper activeStep={activeIndex} alternativeLabel>
{STEPS.map(s => (
<Step key={s.key}>
@@ -2,6 +2,7 @@ import { Box, Button, CircularProgress, LinearProgress, Typography } from '@mui/
import { Download, Send } from 'lucide-react'
import type { OfferReportDraft } from '../../domain/offerReport'
import type { Property } from '../../domain/property'
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface OfferWizardPdfStepProps {
generating: boolean
@@ -18,13 +19,13 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
<CircularProgress size={48} sx={{ color: DS_BRAND.main }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: DS_BRAND.main }}>
PDF wird generiert
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
@@ -36,7 +37,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
width: '100%',
maxWidth: 520,
height: 380,
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
border: '1px solid #e2e8f0',
borderRadius: 1.5,
display: 'flex',
@@ -44,33 +45,33 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
overflow: 'hidden',
}}
>
<Box sx={{ bgcolor: '#152642', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} />
<Box sx={{ bgcolor: DS_BRAND.main, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: DS_ACCENT.danger.bright }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: DS_ACCENT.warning.amber }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: DS_ACCENT.success.emerald }} />
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', ml: 1, fontSize: '0.7rem' }}>
Angebot_{property?.title ?? 'Objekt'}.pdf
</Typography>
</Box>
<Box sx={{ flex: 1, p: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_BRAND.main, mb: 1 }}>
Angebotsschreiben
</Typography>
{draft?.editableFields.slice(0, 3).map(f => (
<Box key={f.id} sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.65rem', display: 'block' }}>{f.label}</Typography>
<Typography variant="caption" sx={{ color: '#374151', fontSize: '0.75rem', display: 'block' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.65rem', display: 'block' }}>{f.label}</Typography>
<Typography variant="caption" sx={{ color: DS_NEUTRAL.graphite, fontSize: '0.75rem', display: 'block' }}>
{f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''}
</Typography>
</Box>
))}
{(draft?.viewingAppointments.length ?? 0) > 0 && (
<Box sx={{ mt: 1.5, p: 1, bgcolor: '#f1f5f9', borderRadius: 1 }}>
<Box sx={{ mt: 1.5, p: 1, bgcolor: DS_SLATE[100], borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', display: 'block', mb: 0.5 }}>
Besichtigungstermine
</Typography>
{draft!.viewingAppointments.map(a => (
<Typography key={a.id} variant="caption" sx={{ fontSize: '0.7rem', display: 'block', color: '#475569' }}>
<Typography key={a.id} variant="caption" sx={{ fontSize: '0.7rem', display: 'block', color: DS_SLATE[600] }}>
{new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot}
</Typography>
))}
@@ -83,7 +84,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
variant="contained"
startIcon={<Download size={16} />}
onClick={onDownload}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Herunterladen
</Button>
@@ -8,6 +8,7 @@ import { deterministicMatchScore } from './latentNeedUtils'
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
import { OfferCreationPanel } from './OfferCreationPanel'
import type { LatentNeed } from '../../domain/latentNeed'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface OwnPropertyMatchListProps {
need: LatentNeed
@@ -63,7 +64,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
}}
>
<Target size={14} color="#152642" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900] }}>
Eigene Objekte
</Typography>
<Box
@@ -72,7 +73,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#152642',
bgcolor: DS_BRAND.main,
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
@@ -89,7 +90,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
</Box>
)}
{!isLoading && scored.length === 0 && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem', textAlign: 'center', p: 2 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.8rem', textAlign: 'center', p: 2 }}>
Keine Portfolio-Objekte vorhanden
</Typography>
)}
@@ -13,6 +13,7 @@ import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props {
inquiryId: string
@@ -126,7 +127,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
{/* Step 0: Select properties */}
{step === 0 && (
<Box>
<Typography variant="body2" sx={{ color: '#64748b', mb: 2 }}>
<Typography variant="body2" sx={{ color: DS_SLATE[500], mb: 2 }}>
Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten:
</Typography>
<Stack spacing={1}>
@@ -142,7 +143,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
{p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
@@ -159,13 +160,13 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? (
<>
<CircularProgress size={48} sx={{ color: '#152642' }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}>
<CircularProgress size={48} sx={{ color: DS_BRAND.main }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: DS_BRAND.main }}>
Bericht wird erstellt
</Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', textAlign: 'center', mt: 1 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], display: 'block', textAlign: 'center', mt: 1 }}>
{Math.round(progress)}%
</Typography>
</Box>
@@ -234,7 +235,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
fullWidth
startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Herunterladen
</Button>
@@ -257,7 +258,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
{/* Footer navigation */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: '#64748b' }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: DS_SLATE[500] }}>
Abbrechen
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
@@ -271,7 +272,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
variant="contained"
disabled={selectedIds.length === 0}
onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Weiter
</Button>
@@ -282,7 +283,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
disabled={finalizing}
onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Finalisieren
</Button>
@@ -2,6 +2,7 @@ import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface PublicNeedCardProps {
need: LatentNeed
@@ -39,7 +40,7 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.85rem', lineHeight: 1.3 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], fontSize: '0.85rem', lineHeight: 1.3 }}>
{need.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
@@ -53,7 +54,7 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
</Box>
{need.tenantCompany && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.75rem' }}>
{need.tenantCompany}
</Typography>
)}
@@ -63,15 +64,15 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
size="small"
sx={{
alignSelf: 'flex-start',
bgcolor: '#e0e7ff',
color: '#3730a3',
bgcolor: DS_ACCENT.indigo.border,
color: DS_ACCENT.indigo.dark,
fontWeight: 600,
fontSize: '0.7rem',
height: 20,
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#475569', fontSize: '0.75rem' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: DS_SLATE[600], fontSize: '0.75rem' }}>
<MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.desiredLocation}
@@ -11,6 +11,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useProperties } from '../../hooks/useProperties'
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface PublicNeedDetailProps {
need: LatentNeed
@@ -57,7 +58,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
}
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: DS_SLATE[50] }}>
{/* Scrollable content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
@@ -65,29 +66,29 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip label={assetTypeLabel(need.assetType)} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
sx={{ bgcolor: DS_ACCENT.indigo.border, color: DS_ACCENT.indigo.dark, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.35rem', mb: 0.5 }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_SLATE[900], fontSize: '1.35rem', mb: 0.5 }}>
{need.title}
</Typography>
{need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[500] }}>{need.tenantCompany}</Typography>
)}
</Box>
{/* KI-Entscheidungsbrief */}
{need.aiSummary && (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: '#bfdbfe', bgcolor: '#eff6ff' }}>
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2, borderColor: DS_ACCENT.blue.border, bgcolor: DS_ACCENT.blue.bg }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Sparkles size={16} color="#1d4ed8" />
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#1d4ed8', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
sx={{ fontWeight: 700, color: DS_ACCENT.blue.main, textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}>
KI-Entscheidungsbrief
</Typography>
</Box>
<Typography variant="body1" sx={{ color: '#1e3a8a', lineHeight: 1.7, fontSize: '0.9375rem' }}>
<Typography variant="body1" sx={{ color: DS_ACCENT.blue.darkest, lineHeight: 1.7, fontSize: '0.9375rem' }}>
{need.aiSummary}
</Typography>
</Paper>
@@ -95,7 +96,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
{/* Suchprofil */}
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Suchprofil
</Typography>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}>
@@ -115,14 +116,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
{/* Must-haves */}
{need.mustHaveCriteria.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Must-have Kriterien
</Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: DS_SLATE[800] }}>{c}</Typography>
</Box>
))}
</Box>
@@ -135,7 +136,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
</AccordionSummary>
@@ -144,19 +145,19 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
{need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100)
return (
<Box key={idx} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box key={idx} sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
<Typography variant="caption"
sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, py: 0.125, borderRadius: 1 }}>
sx={{ fontWeight: 700, color: DS_BRAND.main, fontSize: '0.75rem', bgcolor: DS_ACCENT.indigo.border, px: 1, py: 0.125, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
<Box sx={{ height: 6, borderRadius: 3, bgcolor: DS_BG.muted, overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: DS_BRAND.main, transition: 'width 0.3s' }} />
</Box>
{p.description && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.75rem', mt: 0.5, display: 'block' }}>
{p.description}
</Typography>
)}
@@ -173,14 +174,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
<Divider sx={{ mb: 2.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Target size={14} color="#152642" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Ihre Top Empfehlungen
</Typography>
</Box>
{propertiesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
) : scored.length === 0 ? (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.8rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.8rem' }}>
Keine Portfolio-Objekte vorhanden
</Typography>
) : (
@@ -204,12 +205,12 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
py: 1.25,
textAlign: 'center',
cursor: 'pointer',
color: '#64748b',
color: DS_SLATE[500],
fontSize: '0.8rem',
fontWeight: 500,
border: '1px dashed #e2e8f0',
borderRadius: 1.5,
'&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' },
'&:hover': { color: DS_BRAND.main, borderColor: DS_BRAND.main, bgcolor: DS_SLATE[50] },
transition: 'all 0.15s',
}}
>
@@ -229,7 +230,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
</Alert>
)}
{selectedIds.length > 0 && !disabled && (
<Typography variant="caption" sx={{ color: '#475569', display: 'block', mb: 1, textAlign: 'center' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[600], display: 'block', mb: 1, textAlign: 'center' }}>
{selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
</Typography>
)}
@@ -239,7 +240,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
fullWidth
onClick={() => openWizard(need.id, need.title)}
disabled={disabled}
sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, fontWeight: 600, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Angebot erstellen
</Button>
@@ -251,13 +252,13 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: DS_SLATE[500], mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: DS_SLATE[900], fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
@@ -3,6 +3,7 @@ import { Sparkles } from 'lucide-react'
import { usePublicNeeds } from '../../hooks/useLatentNeeds'
import { PublicNeedCard } from './PublicNeedCard'
import { EmptyState } from '../ui'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface PublicNeedListProps {
selectedNeedId: string | null
@@ -38,7 +39,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
}}
>
<Sparkles size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900] }}>
Latente Bedarfe
</Typography>
<Box
@@ -47,7 +48,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
px: 1,
py: 0.125,
borderRadius: 1,
bgcolor: '#152642',
bgcolor: DS_BRAND.main,
color: 'white',
fontWeight: 600,
fontSize: '0.7rem',
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties'
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface RelatedPropertyCardPanelProps {
propertyId: string
@@ -32,7 +33,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
<Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
<Typography
variant="caption"
sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
sx={{ color: DS_SLATE[500], fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem', display: 'block', mb: 1 }}
>
Weitere passende Objekte
</Typography>
@@ -42,7 +43,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
<CircularProgress size={16} />
</Box>
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.72rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.72rem' }}>
Keine weiteren Matches
</Typography>
) : (
@@ -53,7 +54,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
overflowX: 'auto',
pb: 0.5,
'&::-webkit-scrollbar': { height: 3 },
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 },
'&::-webkit-scrollbar-thumb': { bgcolor: DS_SLATE[300], borderRadius: 2 },
}}
>
{additionalMatches.map(m => (
@@ -85,7 +86,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
gap: 1.5,
p: 2,
borderLeft: '1px solid #e2e8f0',
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
height: '100%',
overflowY: 'auto',
}}
@@ -95,7 +96,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
aspectRatio: '3/2',
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: '#f4f3f0',
bgcolor: DS_BG.subtle,
}}
>
{image ? (
@@ -108,25 +109,25 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
)}
</Box>
<Typography variant="body1" sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.95rem' }}>
<Typography variant="body1" sx={{ fontWeight: 600, color: DS_SLATE[900], fontSize: '0.95rem' }}>
{property.title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: DS_SLATE[600] }}>
<MapPin size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.location.city}
{property.location.district ? `, ${property.location.district}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: DS_SLATE[600] }}>
<Ruler size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: '#475569' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: DS_SLATE[600] }}>
<Calendar size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
@@ -135,7 +136,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
</Box>
{property.description && (
<Typography variant="body2" sx={{ color: '#64748b', fontSize: '0.8125rem', lineHeight: 1.5 }}>
<Typography variant="body2" sx={{ color: DS_SLATE[500], fontSize: '0.8125rem', lineHeight: 1.5 }}>
{property.description}
</Typography>
)}
@@ -151,14 +152,14 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
</Button>
<Divider sx={{ my: 0.5 }} />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.68rem' }}>
Weitere passende Objekte
</Typography>
{loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} />
) : additionalMatches.length === 0 ? (
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.75rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.75rem' }}>
Keine weiteren Matches
</Typography>
) : (
@@ -187,13 +188,13 @@ function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
bgcolor: 'white',
transition: 'border-color 0.15s, box-shadow 0.15s',
'&:hover': {
borderColor: '#b0aead',
borderColor: DS_NEUTRAL.stone,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
},
}}
>
{/* Image — 16:9 crop, compact supplementary card */}
<Box sx={{ position: 'relative', height: 60, bgcolor: '#f4f3f0', overflow: 'hidden' }}>
<Box sx={{ position: 'relative', height: 60, bgcolor: DS_BG.subtle, overflow: 'hidden' }}>
{match.imageUrl ? (
<Box
component="img"
@@ -228,19 +229,19 @@ function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
<Box sx={{ p: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
<Typography
sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
sx={{ fontWeight: 600, color: DS_SLATE[900], fontSize: '0.75rem', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, mr: 0.5 }}
>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: DS_BRAND.main, flexShrink: 0 }}>
{match.matchScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.68rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{match.location}
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
<Typography variant="caption" sx={{ color: DS_ACCENT.success.strong, fontSize: '0.67rem', display: 'block', lineHeight: 1.4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', mt: 0.2 }}>
+ {match.reasons[0]}
</Typography>
)}
@@ -260,18 +261,18 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}>
<Typography sx={{ fontWeight: 600, color: '#0f172a', fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
<Typography sx={{ fontWeight: 600, color: DS_SLATE[900], fontSize: '0.8rem', lineHeight: 1.3, mr: 0.5 }}>
{match.title}
</Typography>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: '#152642', flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 700, color: DS_BRAND.main, flexShrink: 0 }}>
{match.matchScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.72rem', display: 'block', mb: 0.375 }}>
{match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography>
{match.reasons[0] && (
<Typography variant="caption" sx={{ color: '#15803d', fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
<Typography variant="caption" sx={{ color: DS_ACCENT.success.strong, fontSize: '0.7rem', display: 'block', mb: 0.375, lineHeight: 1.4 }}>
+ {match.reasons[0]}
</Typography>
)}
@@ -279,7 +280,7 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
size="small"
endIcon={<ArrowRight size={10} />}
onClick={() => navigate('/supply/properties')}
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: '#152642' }}
sx={{ textTransform: 'none', fontSize: '0.7rem', p: 0, minWidth: 0, color: DS_BRAND.main }}
>
Ansehen
</Button>
@@ -1,6 +1,7 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material'
import { ChevronDown } from 'lucide-react'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { DS_SLATE } from '../../lib/ds'
interface Props {
propertyTitle: string
@@ -104,10 +105,10 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
return (
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 1 }}>
Felder für: {propertyTitle}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 1.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], display: 'block', mb: 1.5 }}>
Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos
</Typography>
@@ -119,7 +120,7 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>
{group.label}
</Typography>
<Typography variant="caption" sx={{ ml: 1, color: '#64748b', alignSelf: 'center' }}>
<Typography variant="caption" sx={{ ml: 1, color: DS_SLATE[500], alignSelf: 'center' }}>
({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length})
</Typography>
</AccordionSummary>
@@ -3,6 +3,7 @@ import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils'
import { DS_BG, DS_SLATE } from '../../lib/ds'
interface SelectablePropertyMatchCardProps {
property: Property
@@ -57,7 +58,7 @@ export function SelectablePropertyMatchCard({
height: 40,
flexShrink: 0,
borderRadius: 1,
bgcolor: '#e8e7e4',
bgcolor: DS_BG.muted,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -75,7 +76,7 @@ export function SelectablePropertyMatchCard({
variant="body2"
sx={{
fontWeight: 600,
color: '#0f172a',
color: DS_SLATE[900],
fontSize: '0.8125rem',
lineHeight: 1.3,
overflow: 'hidden',
@@ -103,13 +104,13 @@ export function SelectablePropertyMatchCard({
{matchScore}%
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.25, color: DS_SLATE[500] }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography>
</Box>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: '#475569', display: 'block', mt: 0.25 }}>
<Typography variant="caption" sx={{ fontSize: '0.7rem', color: DS_SLATE[600], display: 'block', mt: 0.25 }}>
{reason}
</Typography>
</Box>
@@ -1,12 +1,6 @@
import { mockProperties } from '../../mock-data/properties'
const propertyMap: Record<string, string> = Object.fromEntries(
mockProperties.map(p => [p.id, p.title]),
)
export function propertyLabelFromId(id: string): string {
return propertyMap[id] ?? id
}
// Der Objektnachschlag ist nach `hooks/usePropertyLookup.ts` gewandert — er las
// hier direkt aus den Mockdaten und übersprang damit die Datenschicht.
// Übrig bleibt reine Formatierung ohne Datenzugriff.
export function formatInquiryDate(iso: string): string {
const d = new Date(iso)
@@ -2,6 +2,7 @@ import { Box, Button, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router'
import type { AssistantAction } from '../../domain/assistant'
import { DS_SLATE } from '../../lib/ds'
interface Props {
actions: AssistantAction[]
@@ -32,7 +33,7 @@ export function AssistantActionCards({ actions, onExecute }: Props) {
return (
<Box sx={{ px: 2, pt: 0.75, pb: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: '#94a3b8', display: 'block', mb: 0.625, fontSize: '0.6rem' }}>
<Typography variant="caption" sx={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, color: DS_SLATE[400], display: 'block', mb: 0.625, fontSize: '0.6rem' }}>
Vorgeschlagene Aktionen
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
@@ -1,5 +1,6 @@
import { Box, Chip, Typography } from '@mui/material'
import type { AssistantContext } from '../../domain/assistant'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const PAGE_LABELS: Record<string, string> = {
'/supply/dashboard': 'Übersicht',
@@ -36,18 +37,18 @@ export function AssistantContextSummary({ context }: Props) {
const pageLabel = resolvePageLabel(context.currentRoute)
return (
<Box sx={{ px: 2, py: 1, bgcolor: '#f8fafc', borderBottom: '1px solid #e2e8f0' }}>
<Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip
label={pageLabel}
size="small"
sx={{ bgcolor: '#e0f2fe', color: '#075985', fontWeight: 600, fontSize: '0.7rem', height: 20 }}
sx={{ bgcolor: DS_ACCENT.cyan.bg, color: DS_ACCENT.cyan.dark, fontWeight: 600, fontSize: '0.7rem', height: 20 }}
/>
{context.selectedEntityType && context.selectedEntityId && (
<Chip
label={`${ENTITY_LABELS[context.selectedEntityType] ?? context.selectedEntityType}: ${context.selectedEntityId}`}
size="small"
sx={{ bgcolor: '#f1f5f9', color: '#475569', fontWeight: 500, fontSize: '0.65rem', height: 20, fontFamily: 'monospace' }}
sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontWeight: 500, fontSize: '0.65rem', height: 20, fontFamily: 'monospace' }}
/>
)}
{context.visibleScores?.quality !== undefined && (
@@ -65,7 +66,7 @@ export function AssistantContextSummary({ context }: Props) {
<Chip
label={`Match: ${context.visibleScores.matchScore}%`}
size="small"
sx={{ bgcolor: '#ede9fe', color: '#5b21b6', fontWeight: 600, fontSize: '0.65rem', height: 20 }}
sx={{ bgcolor: DS_ACCENT.violet.tint, color: DS_ACCENT.violet.dark, fontWeight: 600, fontSize: '0.65rem', height: 20 }}
/>
)}
</Box>
@@ -1,4 +1,5 @@
import { Box, Typography } from '@mui/material'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export function AssistantLoadingState() {
return (
@@ -8,7 +9,7 @@ export function AssistantLoadingState() {
width: 28,
height: 28,
borderRadius: '50%',
bgcolor: '#4f46e5',
bgcolor: DS_ACCENT.indigo.main,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -19,7 +20,7 @@ export function AssistantLoadingState() {
</Box>
<Box
sx={{
bgcolor: '#f1f5f9',
bgcolor: DS_SLATE[100],
borderRadius: '0 8px 8px 8px',
px: 1.5,
py: 1,
@@ -35,7 +36,7 @@ export function AssistantLoadingState() {
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: '#94a3b8',
bgcolor: DS_SLATE[400],
animation: 'bounce 1.2s ease-in-out infinite',
animationDelay: `${i * 0.2}s`,
'@keyframes bounce': {
@@ -1,6 +1,7 @@
import { Box, Typography } from '@mui/material'
import { AssistantActionCards } from './AssistantActionCards'
import type { AssistantMessage } from '../../domain/assistant'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
function MessageBubble({ message }: { message: AssistantMessage }) {
const isUser = message.role === 'user'
@@ -10,7 +11,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
{!isUser && (
<Box
sx={{
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
width: 28, height: 28, borderRadius: '50%', bgcolor: DS_ACCENT.indigo.main,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, mt: 0.25,
}}
>
@@ -50,12 +51,12 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
{!isUser && (message.confidence !== undefined || (message.sources && message.sources.length > 0)) && (
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
{message.confidence !== undefined && (
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8' }}>
<Typography variant="caption" sx={{ fontSize: '0.65rem', color: DS_SLATE[400] }}>
Konfidenz: {Math.round(message.confidence * 100)}%
</Typography>
)}
{message.sources?.map(s => (
<Typography key={s} variant="caption" sx={{ fontSize: '0.65rem', color: '#94a3b8', bgcolor: '#f8fafc', px: 0.5, py: 0.125, borderRadius: 0.5, border: '1px solid #e2e8f0' }}>
<Typography key={s} variant="caption" sx={{ fontSize: '0.65rem', color: DS_SLATE[400], bgcolor: DS_SLATE[50], px: 0.5, py: 0.125, borderRadius: 0.5, border: '1px solid #e2e8f0' }}>
{s}
</Typography>
))}
@@ -63,7 +64,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
)}
{/* Timestamp */}
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.6rem', color: '#cbd5e1', mt: 0.25, textAlign: isUser ? 'right' : 'left' }}>
<Typography variant="caption" sx={{ display: 'block', fontSize: '0.6rem', color: DS_SLATE[300], mt: 0.25, textAlign: isUser ? 'right' : 'left' }}>
{new Date(message.createdAt).toLocaleTimeString('de-CH', { timeStyle: 'short' })}
</Typography>
</Box>
@@ -1,6 +1,6 @@
import { Box, Chip, Typography } from '@mui/material'
import type { SuggestedQuestion } from '../../domain/assistant'
import { DS_TEXT, DS_BORDER, DS_SURFACE, BADGE_COLORS } from '../../lib/ds'
import { BADGE_COLORS, DS_BORDER, DS_SLATE, DS_SURFACE, DS_TEXT } from '../../lib/ds'
interface Props {
suggestions: SuggestedQuestion[]
@@ -74,7 +74,7 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontSize: '0.8rem', color: '#334155', lineHeight: 1.4 }}>
<Typography variant="caption" sx={{ fontSize: '0.8rem', color: DS_SLATE[700], lineHeight: 1.4 }}>
{s.question}
</Typography>
<Chip
@@ -1,6 +1,7 @@
import { Box, IconButton, Tooltip } from '@mui/material'
import { Sparkles } from 'lucide-react'
import { useAssistantStore } from '../../stores/assistantStore'
import { DS_ACCENT } from '../../lib/ds'
export function GlobalAIAssistantButton() {
const isOpen = useAssistantStore(s => s.isOpen)
@@ -22,10 +23,10 @@ export function GlobalAIAssistantButton() {
sx={{
width: 48,
height: 48,
bgcolor: '#4f46e5',
bgcolor: DS_ACCENT.indigo.main,
color: 'white',
boxShadow: '0 4px 16px rgba(79,70,229,0.4)',
'&:hover': { bgcolor: '#4338ca' },
'&:hover': { bgcolor: DS_ACCENT.indigo.strong },
}}
>
<Sparkles size={20} />
@@ -13,6 +13,7 @@ import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext } from '../../domain/assistant'
import type { WorkspaceType } from '../../domain/enums'
import { DS_ACCENT, DS_BG, DS_SLATE } from '../../lib/ds'
function resolveWorkspace(pathname: string): WorkspaceType | null {
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
@@ -43,7 +44,7 @@ export function GlobalAIAssistantDrawer() {
organizationId: currentUser?.organizationId ?? '',
}
setContext(ctx)
}, [isOpen, location.pathname])
}, [isOpen, location.pathname, currentUser?.role, currentUser?.organizationId, setContext])
// Auto-scroll on new messages
useEffect(() => {
@@ -129,7 +130,7 @@ export function GlobalAIAssistantDrawer() {
<Box sx={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 28, height: 28, borderRadius: '50%', bgcolor: '#4f46e5',
width: 28, height: 28, borderRadius: '50%', bgcolor: DS_ACCENT.indigo.main,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
>
@@ -144,11 +145,11 @@ export function GlobalAIAssistantDrawer() {
</Typography>
</Box>
<Tooltip title="Gespräch zurücksetzen">
<IconButton size="small" onClick={handleClear} disabled={messages.length === 0} sx={{ color: '#94a3b8' }}>
<IconButton size="small" onClick={handleClear} disabled={messages.length === 0} sx={{ color: DS_SLATE[400] }}>
<RotateCcw size={14} />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={close} sx={{ color: '#94a3b8' }}>
<IconButton size="small" onClick={close} sx={{ color: DS_SLATE[400] }}>
<X size={16} />
</IconButton>
</Box>
@@ -164,8 +165,8 @@ export function GlobalAIAssistantDrawer() {
{/* Welcome message */}
{messages.length === 0 && !isLoading && (
<Box sx={{ px: 2, py: 1.5 }}>
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}>
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: '#334155', lineHeight: 1.6 }}>
<Box sx={{ bgcolor: DS_SLATE[50], borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}>
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: DS_SLATE[700], lineHeight: 1.6 }}>
Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten keine erfundenen Fakten.
</Typography>
</Box>
@@ -233,11 +234,11 @@ export function GlobalAIAssistantDrawer() {
onClick={() => handleQuestion(inputText)}
disabled={!inputText.trim() || isLoading}
sx={{
bgcolor: '#4f46e5',
bgcolor: DS_ACCENT.indigo.main,
color: 'white',
flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' },
'&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' },
'&:hover': { bgcolor: DS_ACCENT.indigo.strong },
'&:disabled': { bgcolor: DS_BG.muted, color: DS_SLATE[400] },
}}
>
<Send size={16} />
+3 -2
View File
@@ -2,6 +2,7 @@ import { Box, Button, Typography } from '@mui/material'
import { Clock } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useSessionStore } from '../../stores/sessionStore'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
export function SessionExpired() {
const { logout } = useSessionStore()
@@ -21,7 +22,7 @@ export function SessionExpired() {
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
bgcolor: '#f8fafc',
bgcolor: DS_SLATE[50],
p: 4,
}}
>
@@ -51,7 +52,7 @@ export function SessionExpired() {
<Button
variant="contained"
onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }}
sx={{ mt: 1, bgcolor: DS_BRAND.main, textTransform: 'none' }}
>
Erneut anmelden
</Button>
+5 -4
View File
@@ -2,6 +2,7 @@ import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums'
import { RESULT_TYPE_LABELS } from '../../lib/constants'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface SourceTypeBadgeProps {
type: ResultType
@@ -9,13 +10,13 @@ interface SourceTypeBadgeProps {
}
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#152642', Icon: ShieldCheck },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles },
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: DS_BRAND.main, Icon: ShieldCheck },
MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: DS_ACCENT.cyan.deep, Icon: Building2 },
FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: DS_ACCENT.violet.strong, Icon: Sparkles },
}
export function SourceTypeBadge({ type, size = 'small' }: SourceTypeBadgeProps) {
const { bg, color, Icon } = CONFIG[type] ?? { bg: '#f1f5f9', color: '#475569', Icon: Globe }
const { bg, color, Icon } = CONFIG[type] ?? { bg: '#f1f5f9', color: DS_SLATE[600], Icon: Globe }
return (
<Chip
icon={<Icon size={11} color={color} />}
+6 -5
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { Box, Tooltip, Typography } from '@mui/material'
import { Info } from 'lucide-react'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none'
@@ -12,10 +13,10 @@ interface Props {
}
const HIGHLIGHT_SX: Record<CellHighlight, object> = {
best: { bgcolor: '#f0fdf4', borderLeft: '3px solid #1a7a4a' },
worst: { bgcolor: '#fef3c7', borderLeft: '3px solid #d97706' },
critical: { bgcolor: '#fef2f2', borderLeft: '3px solid #c0392b' },
future: { bgcolor: '#faf5ff', borderLeft: '3px solid #7c3aed' },
best: { bgcolor: DS_ACCENT.success.bg, borderLeft: '3px solid #1a7a4a' },
worst: { bgcolor: DS_ACCENT.warning.borderSoft, borderLeft: '3px solid #d97706' },
critical: { bgcolor: DS_ACCENT.danger.bg, borderLeft: '3px solid #c0392b' },
future: { bgcolor: DS_ACCENT.violet.bg, borderLeft: '3px solid #7c3aed' },
none: {},
}
@@ -40,7 +41,7 @@ export function MissingDataCell({ reason }: { reason?: string }) {
<Info size={12} color="#94a3b8" />
</Box>
</Tooltip>
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}>
<Typography variant="body2" sx={{ color: DS_SLATE[400], fontStyle: 'italic' }}>
Nicht verfügbar
</Typography>
</Box>
@@ -3,7 +3,7 @@ import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-re
import { useNavigate } from 'react-router'
import { usePipelineItems } from '../../hooks/usePipeline'
import { usePipelineStore } from '../../stores/pipelineStore'
import { RESULT_TYPE_META } from '../../lib/ds'
import { DS_ACCENT, DS_BRAND, DS_SLATE, RESULT_TYPE_META } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -17,9 +17,9 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
const { data: pipelineItems = [] } = usePipelineItems()
const { openSavedDialog } = usePipelineStore()
const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null
const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: DS_SLATE[500] }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? item.property : null
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? item.signal : null
const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? ''
const subtitle = prop?.location?.city ?? sig?.locationHint ?? ''
@@ -55,7 +55,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Chip label={meta.label} size="small" sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }} />
<Tooltip title="Aus Vergleich entfernen">
<IconButton size="small" onClick={onRemove} sx={{ p: 0.375, color: '#94a3b8', '&:hover': { color: '#c0392b' } }}>
<IconButton size="small" onClick={onRemove} sx={{ p: 0.375, color: DS_SLATE[400], '&:hover': { color: DS_ACCENT.danger.main } }}>
<X size={15} />
</IconButton>
</Tooltip>
@@ -67,7 +67,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
sx={{
fontWeight: 700, lineHeight: 1.3, mb: 0.25,
cursor: 'pointer',
'&:hover': { color: '#152642' },
'&:hover': { color: DS_BRAND.main },
}}
onClick={() => navigate(`/demand/results/${item.matchId}`)}
>
@@ -108,8 +108,8 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
</Box>
{item.resultType === 'FUTURE_AVAILABILITY' && sig && (
<Box sx={{ mb: 1.25, p: 0.75, bgcolor: '#faf5ff', borderRadius: 1, border: '1px solid #e9d5ff' }}>
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, display: 'block' }}>
<Box sx={{ mb: 1.25, p: 0.75, bgcolor: DS_ACCENT.violet.bg, borderRadius: 1, border: '1px solid #e9d5ff' }}>
<Typography variant="caption" sx={{ color: DS_ACCENT.violet.main, fontWeight: 600, display: 'block' }}>
Probabilistisches Signal
</Typography>
<Typography variant="caption" color="text.secondary">
@@ -135,7 +135,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
variant="outlined"
startIcon={<BookmarkCheck size={12} />}
disabled
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5, color: '#1a7a4a', borderColor: '#86efac' }}
sx={{ flex: 1, textTransform: 'none', fontSize: '0.72rem', py: 0.5, color: DS_ACCENT.success.main, borderColor: DS_ACCENT.success.border }}
>
In Pipeline
</Button>
+11 -10
View File
@@ -4,6 +4,7 @@ import { LABEL_SX, DATA_SX, CRITERION_ALIASES, getTitle, scoreBar } from './comp
import type { WeightingKey } from '../../domain/needBuilder'
import { MissingDataCell } from './CompareCell'
import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import { DS_ACCENT, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
compareItems: UnifiedMatchResult[]
@@ -15,18 +16,18 @@ interface Props {
export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWinnerIdx, activeNeed }: Props) {
return (
<Card sx={{ mb: 3, overflow: 'hidden' }}>
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc', display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ p: 2.5, borderBottom: '1px solid #e2e8f0', bgcolor: DS_SLATE[50], display: 'flex', alignItems: 'center', gap: 2 }}>
<Trophy size={18} color="#d4920e" />
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Vergleich nach Suchkriterien</Typography>
<Typography variant="caption" color="text.secondary">Basierend auf der Suche: {activeNeed.companyName}</Typography>
</Box>
{overallWinnerIdx !== -1 && (
<Box sx={{ ml: 'auto', bgcolor: '#fffbeb', border: '1px solid #fbbf24', borderRadius: 2, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ ml: 'auto', bgcolor: DS_ACCENT.warning.bg, border: '1px solid #fbbf24', borderRadius: 2, px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Trophy size={16} color="#d4920e" />
<Box>
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#7a4f00' }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark, fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: DS_ACCENT.warning.darkest }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
</Box>
</Box>
)}
@@ -34,11 +35,11 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
<Box sx={{ overflowX: 'auto' }}>
<Table sx={{ tableLayout: 'auto' }}>
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell>
<TableRow sx={{ bgcolor: DS_SLATE[50] }}>
<TableCell sx={{ ...LABEL_SX, bgcolor: DS_SLATE[50], fontSize: 11 }}>Kriterium</TableCell>
{compareItems.map(item => (
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }} noWrap>
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: DS_SLATE[50] }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main }} noWrap>
{getTitle(item)}
</Typography>
</TableCell>
@@ -74,8 +75,8 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
{idx === winnerIdx && (
<Chip label="Besser" size="small"
icon={<CheckCircle2 size={10} />}
sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534',
'& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 },
sx={{ height: 18, fontSize: 9, bgcolor: DS_ACCENT.success.bg, color: DS_TEXT.successDark,
'& .MuiChip-icon': { color: DS_ACCENT.success.main, ml: 0.5 },
'& .MuiChip-label': { px: 0.75 } }} />
)}
</Box>
+2 -1
View File
@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/material'
import { Columns2 } from 'lucide-react'
import { useNavigate } from 'react-router'
import { DS_BRAND } from '../../lib/ds'
export function CompareEmptyState() {
const navigate = useNavigate()
@@ -15,7 +16,7 @@ export function CompareEmptyState() {
Fügen Sie 24 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
</Typography>
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}>
sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}>
Zu den Suchergebnissen
</Button>
</Box>
@@ -1,24 +0,0 @@
import { memo } from 'react'
import type { ReactNode } from 'react'
import { TableCell, TableRow } from '@mui/material'
import { LABEL_SX, DATA_SX } from './compareUtils'
// ── Props ─────────────────────────────────────────────────────────────────────
export interface CompareMetricRowProps {
label: string
cells: ReactNode[]
}
// ── Component ─────────────────────────────────────────────────────────────────
export const CompareMetricRow = memo(function CompareMetricRow({ label, cells }: CompareMetricRowProps) {
return (
<TableRow hover>
<TableCell sx={LABEL_SX}>{label}</TableCell>
{cells.map((cell, i) => (
<TableCell key={i} sx={DATA_SX}>{cell}</TableCell>
))}
</TableRow>
)
})
+7 -7
View File
@@ -18,7 +18,7 @@ import {
scoreBar,
} from './compareUtils'
import { CompareCell, MissingDataCell } from './index'
import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds'
import { DS_ACCENT, DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT, RESULT_TYPE_META } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
@@ -63,7 +63,7 @@ export function CompareTableBody({
{/* 1. Result Type */}
{row('1. Result-Typ', compareItems.map(item => {
const m = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' }
const m = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: DS_SLATE[500] }
return <Chip label={m.label} size="small" sx={{ bgcolor: m.color, color: 'white', fontWeight: 600, fontSize: 11 }} />
}))}
@@ -195,11 +195,11 @@ export function CompareTableBody({
+ {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography>
)}
<Typography variant="body2" sx={{ fontWeight: 700, color: '#152642', borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: DS_BRAND.main, borderTop: '1px solid #e2e8f0', pt: 0.5, mt: 0.25 }}>
= {totalMonthly.toLocaleString('de-CH')} CHF/Monat
</Typography>
{fitOutLabel && (
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.25 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], mt: 0.25 }}>
Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography>
)}
@@ -238,7 +238,7 @@ export function CompareTableBody({
{hardMatches.map(f => (
<Chip key={f.criterion} label={f.criterion} size="small"
icon={<CheckCircle2 size={10} />}
sx={{ fontSize: 10, bgcolor: '#f0fdf4', color: '#166534', '& .MuiChip-icon': { color: '#1a7a4a' } }} />
sx={{ fontSize: 10, bgcolor: DS_ACCENT.success.bg, color: DS_TEXT.successDark, '& .MuiChip-icon': { color: DS_ACCENT.success.main } }} />
))}
</Box>
</Box>
@@ -255,7 +255,7 @@ export function CompareTableBody({
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{softFactors.map(f => (
<Chip key={f.criterion} label={f.criterion} size="small"
sx={{ fontSize: 10, bgcolor: '#eff6ff', color: '#1e40af' }} />
sx={{ fontSize: 10, bgcolor: DS_ACCENT.blue.bg, color: DS_ACCENT.blue.dark }} />
))}
</Box>
)
@@ -322,7 +322,7 @@ export function CompareTableBody({
if (total === 0) return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={14} color="#1a7a4a" />
<Typography variant="body2" sx={{ color: '#1a7a4a' }}>Vollständig</Typography>
<Typography variant="body2" sx={{ color: DS_ACCENT.success.main }}>Vollständig</Typography>
</Box>
)
return (
+2 -1
View File
@@ -3,6 +3,7 @@ import { Box, LinearProgress, Typography } from '@mui/material'
import type { WeightingKey } from '../../domain/needBuilder'
import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
import { dataQualityHex } from '../../lib/utils'
import { DS_SLATE } from '../../lib/ds'
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -53,7 +54,7 @@ export const LABEL_SX = {
zIndex: 1,
width: 200,
minWidth: 200,
color: '#64748b',
color: DS_SLATE[500],
fontSize: 13,
fontWeight: 600,
borderRight: '1px solid #e2e8f0',
@@ -3,6 +3,7 @@ import { AlertTriangle } from 'lucide-react'
import { dataQualityColor, dataQualityHex, formatPercent } from '../../lib/utils'
import { FRESHNESS_LABELS } from '../../lib/constants'
import type { DataQuality } from '../../domain/property'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface DataQualityBarProps {
quality: DataQuality
@@ -22,7 +23,7 @@ export function DataQualityBar({ quality, compact = false, showWarnings = true }
</Typography>
{quality.missingCriticalFields.length > 0 && (
<Box sx={{ mb: 0.5 }}>
<Typography variant="caption" sx={{ color: '#fca5a5', display: 'block' }}>Fehlende Pflichtfelder:</Typography>
<Typography variant="caption" sx={{ color: DS_ACCENT.danger.border, display: 'block' }}>Fehlende Pflichtfelder:</Typography>
{quality.missingCriticalFields.map(f => (
<Typography key={f} variant="caption" sx={{ display: 'block', pl: 1 }}> {f}</Typography>
))}
@@ -30,13 +31,13 @@ export function DataQualityBar({ quality, compact = false, showWarnings = true }
)}
{quality.warnings.length > 0 && (
<Box>
<Typography variant="caption" sx={{ color: '#fcd34d', display: 'block' }}>Warnungen:</Typography>
<Typography variant="caption" sx={{ color: DS_ACCENT.warning.border, 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 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[400], display: 'block', mt: 0.5 }}>
Aktualität: {FRESHNESS_LABELS[quality.freshness]}
{quality.lastVerifiedAt ? ` · Geprüft: ${quality.lastVerifiedAt}` : ''}
</Typography>
@@ -6,6 +6,7 @@ import { ProvenancePanel } from './ProvenancePanel'
import { DataQualityBadge } from './DataQualityBadge'
import { getRecommendedActions } from '../../services/dataQualityService'
import type { Property } from '../../domain/property'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface DataQualityPanelProps {
property: Property
@@ -17,7 +18,7 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: '#64748b',
color: DS_SLATE[500],
display: 'block',
mb: 1,
}}>
@@ -33,7 +34,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
return (
<Box>
{/* ── Score & Dimensions ─────────────────────────────────────────── */}
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: '#fafafa' }}>
<Paper variant="outlined" sx={{ p: 2, mb: 2, bgcolor: DS_NEUTRAL.offWhite }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Datenqualität</Typography>
@@ -81,7 +82,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
Datenaktualisierung anfragen
</Button>
{q.missingCriticalFields.length > 0 && (
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: '#152642' }}>
<Button variant="contained" size="small" sx={{ textTransform: 'none', bgcolor: DS_BRAND.main }}>
Fehlende Felder ergänzen
</Button>
)}
@@ -2,6 +2,7 @@ import { Box, LinearProgress, Typography } from '@mui/material'
import { dataQualityHex } from '../../lib/utils'
import { FreshnessStatus } from '../../domain/enums'
import type { Property } from '../../domain/property'
import { DS_BG } from '../../lib/ds'
interface Dimension {
label: string
@@ -76,7 +77,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#e8e7e4',
bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
@@ -110,7 +111,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e8e7e4',
bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': { bgcolor: d.color },
}}
/>
@@ -3,6 +3,7 @@ import { CheckCircle, Clock, AlertTriangle } from 'lucide-react'
import { FreshnessStatus } from '../../domain/enums'
import { FRESHNESS_LABELS } from '../../lib/constants'
import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums'
import { DS_ACCENT } from '../../lib/ds'
interface FreshnessIndicatorProps {
freshness: FreshnessStatusType
@@ -11,9 +12,9 @@ interface FreshnessIndicatorProps {
}
const CONFIG: Record<FreshnessStatusType, { color: string; icon: typeof CheckCircle }> = {
[FreshnessStatus.FRESH]: { color: '#1a7a4a', icon: CheckCircle },
[FreshnessStatus.STALE]: { color: '#d97706', icon: Clock },
[FreshnessStatus.OUTDATED]: { color: '#c0392b', icon: AlertTriangle },
[FreshnessStatus.FRESH]: { color: DS_ACCENT.success.main, icon: CheckCircle },
[FreshnessStatus.STALE]: { color: DS_ACCENT.warning.main, icon: Clock },
[FreshnessStatus.OUTDATED]: { color: DS_ACCENT.danger.main, icon: AlertTriangle },
}
export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) {
@@ -1,6 +1,7 @@
import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import { AlertTriangle, Info } from 'lucide-react'
import type { RecommendedAction } from '../../services/dataQualityService'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface MissingDataListProps {
criticalFields: string[]
@@ -17,9 +18,9 @@ export function MissingDataList({
}: MissingDataListProps) {
if (criticalFields.length === 0 && optionalFields.length === 0) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, px: 2, bgcolor: '#f0fdf4', borderRadius: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, px: 2, bgcolor: DS_ACCENT.success.bg, borderRadius: 1, mb: 2 }}>
<Info size={14} color="#1a7a4a" />
<Typography variant="body2" sx={{ color: '#1a7a4a', fontWeight: 500 }}>
<Typography variant="body2" sx={{ color: DS_ACCENT.success.main, fontWeight: 500 }}>
Alle wichtigen Felder sind vollständig.
</Typography>
</Box>
@@ -35,7 +36,7 @@ export function MissingDataList({
<Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<AlertTriangle size={13} color="#c0392b" />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#c0392b', textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_ACCENT.danger.main, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Pflichtfelder ({criticalFields.length})
</Typography>
</Box>
@@ -49,7 +50,7 @@ export function MissingDataList({
px: 1.5,
py: 0.75,
mb: 0.5,
bgcolor: '#fff1f2',
bgcolor: DS_ACCENT.danger.bgAlt,
border: '1px solid #fecdd3',
borderRadius: 1,
}}
@@ -82,7 +83,7 @@ export function MissingDataList({
<>
{criticalFields.length > 0 && <Divider sx={{ mb: 1.5 }} />}
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75 }}>
Optionale Felder ({optionalFields.length})
</Typography>
{otherActions.map(a => (
@@ -95,7 +96,7 @@ export function MissingDataList({
px: 1.5,
py: 0.75,
mb: 0.5,
bgcolor: '#fffbeb',
bgcolor: DS_ACCENT.warning.bg,
border: '1px solid #fde68a',
borderRadius: 1,
}}
@@ -2,6 +2,7 @@ import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
import { ExternalLink, Shield, ShieldAlert } from 'lucide-react'
import { FreshnessIndicator } from './FreshnessIndicator'
import type { Property } from '../../domain/property'
import { DS_BG } from '../../lib/ds'
interface ProvenancePanelProps {
property: Property
@@ -79,7 +80,7 @@ export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
sx={{
height: 5,
borderRadius: 3,
bgcolor: '#e8e7e4',
bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': { bgcolor: color },
}}
/>
@@ -1,63 +0,0 @@
import { Alert, Box, Button, CircularProgress, Divider } from '@mui/material'
import { ArrowRight, Bookmark, Search } from 'lucide-react'
interface Props {
canProceed: boolean
isSearching: boolean
isSavingProfile: boolean
onSearch: () => void
onSaveProfile: () => void
}
export function AISearchActionBar({ canProceed, isSearching, isSavingProfile, onSearch, onSaveProfile }: Props) {
const isProcessing = isSearching || isSavingProfile
return (
<>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="contained"
size="large"
disabled={!canProceed || isProcessing}
onClick={onSearch}
endIcon={isSearching ? <CircularProgress size={18} color="inherit" /> : <Search size={18} />}
sx={{
flex: 1,
py: 1.5,
bgcolor: '#152642',
'&:hover': { bgcolor: '#162d4a' },
fontSize: 15,
fontWeight: 600,
textTransform: 'none',
}}
>
{isSearching ? 'Sucht…' : 'Jetzt suchen'}
</Button>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Button
variant="outlined"
size="large"
disabled={!canProceed || isProcessing}
onClick={onSaveProfile}
startIcon={<Bookmark size={16} />}
endIcon={isSavingProfile ? <CircularProgress size={18} color="inherit" /> : <ArrowRight size={18} />}
sx={{
py: 1.5,
fontSize: 15,
fontWeight: 500,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{isSavingProfile ? 'Analysiert…' : 'Als Suchprofil speichern'}
</Button>
</Box>
<Alert severity="info" sx={{ mt: -1 }}>
<strong>Jetzt suchen</strong> liefert sofortige Ergebnisse.{' '}
<strong>Als Suchprofil speichern</strong> legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird auch in Zukunft.
</Alert>
</>
)
}
@@ -1,100 +0,0 @@
import { Alert, Box, Button, CircularProgress, FormControlLabel, Switch, Typography } from '@mui/material'
import { Save } from 'lucide-react'
import { NeedCardPreview } from './NeedCardPreview'
import type { ParseNeedResult, ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder'
interface Props {
criteria: ParsedNeedCriteria
weights: Record<WeightingKey, number>
parseResult: ParseNeedResult
needTitle: string
overallConfidence: number
isSaving: boolean
isAnonymous: boolean
onNeedTitleChange: (t: string) => void
onAnonymousChange: (v: boolean) => void
onBack: () => void
onSave: () => void
}
export function AISearchSavePreview({
criteria,
weights,
parseResult,
needTitle,
overallConfidence,
isSaving,
isAnonymous,
onNeedTitleChange,
onAnonymousChange,
onBack,
onSave,
}: Props) {
return (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
<Alert severity="success" sx={{ mb: 3 }}>
Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung.
</Alert>
<NeedCardPreview
criteria={criteria}
weights={weights}
confidenceByField={parseResult.confidenceByField}
missingFields={parseResult.missingFields}
needTitle={needTitle}
onNeedTitleChange={onNeedTitleChange}
/>
{/* Anonymity option */}
<Box sx={{
maxWidth: 720, mx: 'auto', mt: 2,
p: 1.5, borderRadius: 1.5, border: '1px solid',
borderColor: isAnonymous ? '#7c3aed' : '#e8e7e4',
bgcolor: isAnonymous ? '#f5f3ff' : '#f8fafc',
transition: 'all 0.15s',
}}>
<FormControlLabel
control={
<Switch
size="small"
checked={isAnonymous}
onChange={e => onAnonymousChange(e.target.checked)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' },
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontWeight: 700, color: isAnonymous ? '#6d28d9' : 'text.primary' }}>
Anonymes Suchprofil
</Typography>
<Typography variant="caption" sx={{ color: isAnonymous ? '#7c3aed' : 'text.secondary' }}>
Firmenname wird nicht an Vermieter übermittelt Verwalter sehen nur Branche und Flächenbedarf
</Typography>
</Box>
}
/>
</Box>
<Box sx={{ maxWidth: 720, mx: 'auto', display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Button variant="outlined" onClick={onBack} disabled={isSaving}>
Zurück
</Button>
<Button
variant="contained"
onClick={onSave}
disabled={isSaving}
endIcon={isSaving ? <CircularProgress size={16} color="inherit" /> : <Save size={16} />}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }}
>
{isSaving
? 'Wird gespeichert…'
: overallConfidence < 0.6
? 'Als Entwurf speichern'
: 'Suchprofil speichern & Matching starten'}
</Button>
</Box>
</Box>
)
}
@@ -1,103 +0,0 @@
import { Box, Chip, Typography } from '@mui/material'
import { Kanban } from 'lucide-react'
import { INQUIRY_STATUS_META, DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { Inquiry } from '../../domain/inquiry'
interface AnfragenInquiryItemProps {
inq: Inquiry
isSelected: boolean
hasPipeline: boolean
perspective?: 'demand' | 'supply'
onSelect: (id: string) => void
}
export function AnfragenInquiryItem({ inq, isSelected, hasPipeline, perspective = 'supply', onSelect }: AnfragenInquiryItemProps) {
const cfg = INQUIRY_STATUS_META[inq.status ?? 'new']
const displayDate = new Date(inq.updatedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
const lastMsg = inq.thread[inq.thread.length - 1]
const unreadColor = cfg?.fg ?? DS_TEXT.danger
const primaryTitle = perspective === 'demand'
? (inq.propertyAddress ?? inq.subject)
: inq.tenantName
const secondaryLine = perspective === 'demand'
? (inq.propertyManagerCompany ?? inq.propertyManagerName ?? '')
: (inq.tenantCompany ?? '')
const lastMsgPrefix = (() => {
if (!lastMsg) return ''
if (perspective === 'demand') {
return lastMsg.senderType === 'tenant'
? 'Sie: '
: `${inq.propertyManagerName ?? 'Verwalter'}: `
}
return lastMsg.senderType === 'tenant' ? 'Sie: ' : `${lastMsg.senderName}: `
})()
return (
<Box onClick={() => onSelect(inq.id)} sx={{
px: 2, py: 1.5,
borderBottom: `1px solid ${DS_BORDER.muted}`,
cursor: 'pointer',
bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : !inq.isRead ? 'rgba(220,38,38,0.03)' : 'transparent',
borderLeft: isSelected ? '3px solid' : '3px solid transparent',
borderLeftColor: isSelected ? 'primary.main' : 'transparent',
'&:hover': { bgcolor: isSelected ? DS_COLORS.futureCard.signal.headerBg : DS_BG.page },
transition: 'background-color 0.1s ease',
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.375 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0 }}>
{!inq.isRead && <Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: unreadColor, flexShrink: 0 }} />}
<Typography variant="body2" sx={{ fontWeight: !inq.isRead ? 700 : 500, fontSize: '0.8125rem' }} noWrap>
{primaryTitle}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{inq.unreadCount > 0 && (
<Box sx={{ width: 18, height: 18, borderRadius: '50%', bgcolor: unreadColor, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography sx={{ color: 'white', fontSize: '0.6rem', fontWeight: 700 }}>{inq.unreadCount}</Typography>
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>{displayDate}</Typography>
</Box>
</Box>
{secondaryLine && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', fontSize: '0.7rem', mb: 0.25 }} noWrap>
{secondaryLine}
</Typography>
)}
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block', mb: 0.5, fontWeight: !inq.isRead ? 600 : 400, fontSize: '0.75rem' }} noWrap>
{inq.subject}
</Typography>
{lastMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontSize: '0.7rem' }} noWrap>
{lastMsgPrefix}
{lastMsg.body.split('\n')[0]}
</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label={cfg?.label ?? inq.status} size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: cfg?.bg, color: cfg?.fg, fontWeight: 600 }} />
{inq.matchScore && (
<Typography variant="caption" sx={{ color: inq.matchScore >= 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, fontSize: '0.7rem' }}>
{inq.matchScore}%
</Typography>
)}
{hasPipeline && (
<Chip
icon={<Kanban size={9} />}
label="Pipeline"
size="small"
sx={{
height: 16, fontSize: '0.6rem', fontWeight: 600,
bgcolor: DS_COLORS.futureCard.signal.headerBg,
color: 'primary.main',
'& .MuiChip-icon': { color: 'primary.main' },
}}
/>
)}
</Box>
</Box>
)
}
@@ -1,71 +0,0 @@
import { Box, Typography } from '@mui/material'
import { Bot, Building2, FileText } from 'lucide-react'
import { DS_COLORS, DS_TEXT, DS_BG, DS_BORDER } from '../../lib/ds'
import type { InquiryMessage } from '../../domain/inquiry'
export function AnfragenMessageBubble({ msg, currentUserName }: { msg: InquiryMessage; currentUserName?: string }) {
const isOwnMessage = msg.senderType === 'tenant'
const isAI = msg.senderType === 'ai'
const time = new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
const date = new Date(msg.createdAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' })
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: isOwnMessage ? 'flex-end' : 'flex-start', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.375 }}>
{!isOwnMessage && isAI && <Bot size={11} color={DS_TEXT.signal} />}
{!isOwnMessage && msg.senderType === 'supply_user' && <Building2 size={11} color={DS_TEXT.muted} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
{isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time}
</Typography>
</Box>
<Box
sx={{
maxWidth: { xs: '88%', md: '72%' },
px: 2, py: 1.25,
borderRadius: isOwnMessage ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
bgcolor: isOwnMessage ? 'primary.main' : isAI ? DS_COLORS.futureCard.controlled.headerBg : 'white',
border: isOwnMessage ? 'none' : isAI ? `1px solid ${DS_COLORS.futureCard.controlled.border}` : `1px solid ${DS_BORDER.default}`,
boxShadow: isOwnMessage ? '0 2px 6px rgba(30,58,95,0.2)' : '0 1px 2px rgba(0,0,0,0.06)',
}}
>
<Typography
variant="body2"
sx={{
color: isOwnMessage ? 'white' : isAI ? DS_COLORS.futureCard.controlled.badgeText : DS_TEXT.primary,
whiteSpace: 'pre-wrap', lineHeight: 1.65, fontSize: '0.875rem',
}}
>
{msg.body}
</Typography>
{msg.attachments.length > 0 && (
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{msg.attachments.map(att => (
<Box
key={att.id}
sx={{
display: 'flex', alignItems: 'center', gap: 1,
px: 1.5, py: 0.75,
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.12)' : DS_BG.subtle,
borderRadius: 1.5, cursor: 'pointer',
'&:hover': { bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : DS_BG.muted },
}}
>
<FileText size={13} color={isOwnMessage ? 'rgba(255,255,255,0.75)' : DS_TEXT.muted} />
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.9)' : DS_TEXT.secondary, flex: 1 }} noWrap>
{att.fileName}
</Typography>
{att.fileSize && (
<Typography variant="caption" sx={{ color: isOwnMessage ? 'rgba(255,255,255,0.5)' : DS_TEXT.disabled, flexShrink: 0 }}>
{att.fileSize < 1024 * 1024
? `${Math.round(att.fileSize / 1024)} KB`
: `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`}
</Typography>
)}
</Box>
))}
</Box>
)}
</Box>
</Box>
)
}
@@ -1,6 +1,8 @@
import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
import type { ParseNeedResult, ParsedNeedCriteria } from '../../domain/needBuilder'
import { ExtractedFieldRow } from './ExtractedFieldRow'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { DS_SLATE } from '../../lib/ds'
interface Props {
result: ParseNeedResult
@@ -8,16 +10,12 @@ interface Props {
onCriteriaChange: (c: ParsedNeedCriteria) => void
}
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
MIXED: 'Gemischt', UNKNOWN: 'Unbekannt',
}
// ── Parse helpers ──────────────────────────────────────────────────────────────
function parseAreaRange(s: string): ParsedNeedCriteria['areaRange'] {
const m = s.match(/(\d+)\s*[\-]\s*(\d+)/)
const m = s.match(/(\d+)\s*[-]\s*(\d+)/)
if (m) return { min: parseInt(m[1]), max: parseInt(m[2]) }
const n = s.match(/(\d+)/)
if (n) { const v = parseInt(n[1]); return { min: Math.round(v * 0.8), max: Math.round(v * 1.2) } }
@@ -50,7 +48,7 @@ function displayTiming(v: ParsedNeedCriteria['timing']): string {
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Box sx={{ mb: 2 }}>
<Typography variant="overline" sx={{ fontWeight: 700, color: '#64748b', fontSize: 10, letterSpacing: 1 }}>
<Typography variant="overline" sx={{ fontWeight: 700, color: DS_SLATE[500], fontSize: 10, letterSpacing: 1 }}>
{title}
</Typography>
{children}
@@ -76,7 +74,7 @@ export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set
<Section title="Hard Facts">
<ExtractedFieldRow
label="Nutzungstyp"
value={c.assetType ? (ASSET_LABELS[c.assetType] ?? c.assetType) : ''}
value={c.assetType ? (ASSET_TYPE_LABELS[c.assetType] ?? c.assetType) : ''}
confidence={conf.assetType ?? 0.2}
missing={!c.assetType}
onEdit={v => set({ ...c, assetType: (v.toUpperCase() as ParsedNeedCriteria['assetType']) })}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Box, Button, Card, Typography } from '@mui/material'
import { ArrowRight, RefreshCw } from 'lucide-react'
import type { FollowUpQuestion } from '../../domain/needBuilder'
import { FollowUpQuestionCard } from './FollowUpQuestionCard'
import { DS_BRAND } from '../../lib/ds'
interface Props {
questions: FollowUpQuestion[]
@@ -66,7 +67,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onRepa
disabled={requiredUnanswered.length > 0}
onClick={onContinue}
endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
>
Weiter zur Gewichtung
</Button>
@@ -1,5 +1,6 @@
import { Box, Button, Typography } from '@mui/material'
import { AlertTriangle, RotateCcw } from 'lucide-react'
import { DS_BRAND } from '../../lib/ds'
interface Props {
message: string
@@ -22,7 +23,7 @@ export function NeedBuilderErrorState({ message, onRetry }: Props) {
variant="contained"
startIcon={<RotateCcw size={16} />}
onClick={onRetry}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
>
Erneut versuchen
</Button>
@@ -1,6 +1,7 @@
import { Box, Stepper, Step, StepLabel } from '@mui/material'
import type { NeedBuilderStep } from '../../domain/needBuilder'
import { NeedBuilderStep as S } from '../../domain/needBuilder'
import { DS_SLATE } from '../../lib/ds'
interface Props {
step: NeedBuilderStep
@@ -21,7 +22,7 @@ function toStepIndex(step: NeedBuilderStep): number {
export function NeedBuilderProgress({ step }: Props) {
if (step === S.IDLE) return null
return (
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: '#f8fafc' }}>
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: DS_SLATE[50] }}>
<Stepper activeStep={toStepIndex(step)} alternativeLabel>
{STEPS.map(label => (
<Step key={label}>
+6 -7
View File
@@ -3,6 +3,8 @@ import { MapPin, Ruler, Wallet, Clock, CheckSquare, ShieldAlert } from 'lucide-r
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { DS_BG, DS_BRAND } from '../../lib/ds'
interface Props {
criteria: ParsedNeedCriteria
@@ -13,10 +15,7 @@ interface Props {
onNeedTitleChange: (v: string) => void
}
const ASSET_LABELS: Record<string, string> = {
OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail',
PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe',
}
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
@@ -66,7 +65,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{c.assetType && (
<Chip label={ASSET_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: '#152642', color: 'white' }} />
<Chip label={ASSET_TYPE_LABELS[c.assetType] ?? c.assetType} size="small" sx={{ bgcolor: DS_BRAND.main, color: 'white' }} />
)}
<Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
</Box>
@@ -143,7 +142,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
variant="determinate"
value={overallConfidence * 100}
sx={{
height: 6, borderRadius: 3, bgcolor: '#e8e7e4',
height: 6, borderRadius: 3, bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': {
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
},
@@ -171,7 +170,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
<LinearProgress
variant="determinate"
value={((weights[k] ?? 0) / maxWeight) * 100}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e8e7e4', '& .MuiLinearProgress-bar': { bgcolor: '#152642' } }}
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: DS_BG.muted, '& .MuiLinearProgress-bar': { bgcolor: DS_BRAND.main } }}
/>
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
</Box>
@@ -1,6 +1,7 @@
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'
import { DS_SLATE } from '../../lib/ds'
interface Props {
criteria: ParsedNeedCriteria
@@ -11,7 +12,7 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
return (
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ChevronDown size={16} />} sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#64748b' }}>Erweiterte Anforderungen</Typography>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_SLATE[500] }}>Erweiterte Anforderungen</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 1.5 }}>
+9 -8
View File
@@ -3,6 +3,7 @@ import { Box, Card, Chip, Slider, Stack, TextField, Typography } from '@mui/mate
import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props {
criteria: ParsedNeedCriteria
@@ -81,7 +82,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === opt.value
? { bgcolor: '#152642', color: 'white', '& .MuiChip-label': { color: 'white' } }
? { bgcolor: DS_BRAND.main, color: 'white', '& .MuiChip-label': { color: 'white' } }
: {}}
/>
))}
@@ -116,7 +117,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
variant="outlined"
clickable
onClick={() => set({ ...c, areaRange: { min: p.min, max: p.max } })}
sx={{ fontSize: '0.68rem', height: 20, color: '#64748b', borderColor: '#cbd5e1' }}
sx={{ fontSize: '0.68rem', height: 20, color: DS_SLATE[500], borderColor: DS_SLATE[300] }}
/>
))}
</Stack>
@@ -139,8 +140,8 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
})
}}
sx={c.preferredLocations?.includes(loc)
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
? { fontSize: '0.68rem', height: 22, bgcolor: DS_BRAND.main, color: 'white' }
: { fontSize: '0.68rem', height: 22, color: DS_SLATE[500], borderColor: DS_SLATE[300] }
}
/>
))}
@@ -169,7 +170,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
<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: '#152642' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main }}>
{c.searchRadius ?? 30} km
</Typography>
</Box>
@@ -181,7 +182,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
step={1}
onChange={(_, v) => set({ ...c, searchRadius: v as number })}
size="small"
sx={{ color: '#152642', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
sx={{ color: DS_BRAND.main, '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
/>
</Box>
@@ -197,8 +198,8 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
sx={c.budgetRange?.maxPerSqm === p.value
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' }
? { fontSize: '0.68rem', height: 22, bgcolor: DS_BRAND.main, color: 'white' }
: { fontSize: '0.68rem', height: 22, color: DS_SLATE[500], borderColor: DS_SLATE[300] }
}
/>
))}
@@ -1,104 +0,0 @@
import { useState } from 'react'
import {
Alert,
Box,
Button,
Paper,
TextField,
Typography,
} from '@mui/material'
import { Calendar, CheckCircle2, Mail } from 'lucide-react'
import type { PropertyUnit } from '../../domain/property'
interface PropertyContactFormProps {
propertyTitle: string
highlightUnitId?: string | null
units?: PropertyUnit[]
}
export function PropertyContactForm({ propertyTitle, highlightUnitId, units }: PropertyContactFormProps) {
const [inquiryName, setInquiryName] = useState('')
const [inquiryText, setInquiryText] = useState('')
const [sent, setSent] = useState(false)
function handleSendInquiry() {
if (!inquiryName.trim() || !inquiryText.trim()) return
setSent(true)
}
return (
<Paper sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Mail size={15} color="#374151" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>Verwaltung kontaktieren</Typography>
</Box>
{sent ? (
<Alert
icon={<CheckCircle2 size={18} />}
severity="success"
sx={{ borderRadius: 1 }}
>
Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze.
</Alert>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihr Name</Typography>
<TextField
size="small"
fullWidth
placeholder="Max Muster"
value={inquiryName}
onChange={e => setInquiryName(e.target.value)}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Bezug</Typography>
<TextField
size="small"
fullWidth
value={
highlightUnitId
? (units?.find(u => u.id === highlightUnitId)?.unitLabel ?? 'Einheit')
: propertyTitle
}
slotProps={{ input: { readOnly: true } }}
sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }}
/>
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>Ihre Nachricht</Typography>
<TextField
size="small"
fullWidth
multiline
rows={4}
placeholder="Wir interessieren uns für die Fläche und möchten gerne einen Besichtigungstermin vereinbaren..."
value={inquiryText}
onChange={e => setInquiryText(e.target.value)}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Calendar size={12} color="#64748b" />
<Typography variant="caption" color="text.secondary">Antwortzeit: typisch 12 Werktage</Typography>
</Box>
<Button
variant="contained"
size="small"
disabled={!inquiryName.trim() || !inquiryText.trim()}
onClick={handleSendInquiry}
startIcon={<Mail size={14} />}
sx={{ bgcolor: '#7c3aed', '&:hover': { bgcolor: '#6d28d9' }, textTransform: 'none' }}
>
Anfrage senden
</Button>
</Box>
</Box>
)}
</Paper>
)
}
+6 -5
View File
@@ -9,6 +9,7 @@ import { MatchStrength, ResultType } from '../../domain/enums'
import { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineStore } from '../../stores/pipelineStore'
import { useCompareStore } from '../../stores/compareStore'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props {
property: Property
@@ -99,15 +100,15 @@ export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score
<Box component="img" src={property.images[0]} alt=""
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} />
) : (
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: '#e0e7ff', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ width: 52, height: 52, borderRadius: 1, bgcolor: DS_ACCENT.indigo.border, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Ruler size={18} color="#3730a3" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: '#0f172a' }} noWrap>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.825rem', color: DS_SLATE[900] }} noWrap>
{property.title}
</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.75rem' }}>
{property.location.city} · {property.areaSqm} m²
</Typography>
</Box>
@@ -149,8 +150,8 @@ function ActionButton({ children, onClick, primary, disabled }: {
px: 1,
minWidth: 0,
...(primary
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }),
? { bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }
: { borderColor: DS_SLATE[300], color: DS_SLATE[600], '&:hover': { borderColor: DS_BRAND.main, color: DS_BRAND.main } }),
}}
>
{children}
+6 -5
View File
@@ -3,6 +3,7 @@ import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react'
import type { Need } from '../../domain/need'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface Props {
need: Need
@@ -43,29 +44,29 @@ export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, match
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
sx={{ bgcolor: DS_ACCENT.indigo.border, color: DS_ACCENT.indigo.dark, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
</Box>
{matchCount > 0 && (
<Box sx={{ bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
<Box sx={{ bgcolor: DS_ACCENT.success.bgAlt, color: DS_ACCENT.success.strong, borderRadius: 1, px: 0.75, py: 0.125, fontSize: '0.68rem', fontWeight: 700, flexShrink: 0 }}>
{matchCount} Treffer
</Box>
)}
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: '#0f172a', lineHeight: 1.3 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem', color: DS_SLATE[900], lineHeight: 1.3 }}>
{need.companyName}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: '#64748b' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: DS_SLATE[500] }}>
<MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
</Box>
{topScore > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: '#f1f5f9', overflow: 'hidden' }}>
<Box sx={{ flex: 1, height: 4, borderRadius: 2, bgcolor: DS_SLATE[100], overflow: 'hidden' }}>
<Box sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
</Box>
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}>
+29 -28
View File
@@ -10,6 +10,7 @@ import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { confidenceHex } from '../../lib/utils'
import { useMatchesByNeed } from '../../hooks/useMatches'
import { PropertyMatchRow } from './PropertyMatchRow'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE } from '../../lib/ds'
const WEIGHT_LABELS: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
@@ -77,21 +78,21 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
)
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: '#f8fafc' }}>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: DS_SLATE[50] }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small"
sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
sx={{ bgcolor: DS_ACCENT.indigo.border, color: DS_ACCENT.indigo.dark, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
<Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#0f172a', fontSize: '1.25rem' }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: DS_SLATE[900], fontSize: '1.25rem' }}>
{need.companyName}
</Typography>
{need.contactName && (
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.25 }}>{need.contactName}</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[500], mt: 0.25 }}>{need.contactName}</Typography>
)}
</Box>
@@ -113,7 +114,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
</Typography>
</Box>
<LinearProgress variant="determinate" value={conf * 100} sx={{
height: 6, borderRadius: 3, bgcolor: '#f1f5f9',
height: 6, borderRadius: 3, bgcolor: DS_SLATE[100],
'& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
}} />
</Box>
@@ -125,7 +126,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
{need.mustCriteriaText!.map((c, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" />
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#1e293b' }}>{c}</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: DS_SLATE[800] }}>{c}</Typography>
</Box>
))}
</Box>
@@ -139,7 +140,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
}}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Gewichtete Präferenzen
</Typography>
</AccordionSummary>
@@ -148,17 +149,17 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
{topWeights.map(([key, val]) => {
const pct = Math.round(val * 100)
return (
<Box key={key} sx={{ bgcolor: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box key={key} sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{WEIGHT_LABELS[key] ?? key}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.75rem', bgcolor: '#e0e7ff', px: 1, borderRadius: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, fontSize: '0.75rem', bgcolor: DS_ACCENT.indigo.border, px: 1, borderRadius: 1 }}>
{pct}%
</Typography>
</Box>
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} />
<Box sx={{ height: 5, borderRadius: 3, bgcolor: DS_BG.muted, overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: DS_BRAND.main, transition: 'width 0.3s' }} />
</Box>
</Box>
)
@@ -176,11 +177,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Benachrichtigungen
</Typography>
{notif.enabled && (
<Box sx={{ bgcolor: '#e0e7ff', color: '#3730a3', fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
<Box sx={{ bgcolor: DS_ACCENT.indigo.border, color: DS_ACCENT.indigo.dark, fontSize: '0.65rem', fontWeight: 700, px: 0.75, borderRadius: 0.75 }}>
ab {notif.minScore}%
</Box>
)}
@@ -191,7 +192,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography>
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.75rem' }}>
Neue Treffer oberhalb der Schwelle melden
</Typography>
</Box>
@@ -208,7 +209,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642', fontSize: '0.8rem', bgcolor: '#e0e7ff', px: 0.75, borderRadius: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, fontSize: '0.8rem', bgcolor: DS_ACCENT.indigo.border, px: 0.75, borderRadius: 0.75 }}>
{notif.minScore}%
</Typography>
</Box>
@@ -216,11 +217,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
value={notif.minScore}
min={60} max={95} step={5}
onChange={(_, v) => handleNotifChange({ minScore: v as number })}
sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }}
sx={{ color: DS_BRAND.main, '& .MuiSlider-thumb': { width: 14, height: 14 } }}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>95%</Typography>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.7rem' }}>60%</Typography>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.7rem' }}>95%</Typography>
</Box>
</Box>
@@ -247,7 +248,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
variant="contained"
size="small"
onClick={handleNotifSave}
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }}
sx={{ textTransform: 'none', fontWeight: 600, bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover }, alignSelf: 'flex-end' }}
>
Speichern
</Button>
@@ -262,10 +263,10 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Target size={14} color="#15803d" />
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Passende Objekte
</Typography>
<Box sx={{ ml: 'auto', bgcolor: '#dcfce7', color: '#15803d', borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
<Box sx={{ ml: 'auto', bgcolor: DS_ACCENT.success.bgAlt, color: DS_ACCENT.success.strong, borderRadius: 1, px: 1, py: 0.125, fontWeight: 700, fontSize: '0.72rem' }}>
{matchCount} Treffer
</Box>
</Box>
@@ -277,8 +278,8 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
{matchCount > 3 && (
<Box
onClick={onStart}
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600,
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }}
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: DS_BRAND.main, fontSize: '0.8rem', fontWeight: 600,
py: 1, border: '1px dashed #cbd5e1', borderRadius: 1.5, '&:hover': { bgcolor: DS_SLATE[100], borderColor: DS_BRAND.main }, transition: 'all 0.15s' }}
>
Alle {matchCount} Ergebnisse anzeigen
</Box>
@@ -290,11 +291,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
<Box sx={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', px: 3, py: 2, bgcolor: 'white', display: 'flex', gap: 1.5 }}>
<Button variant="outlined" size="large" onClick={onEdit}
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: '#cbd5e1', color: '#475569' }}>
sx={{ flex: 1, textTransform: 'none', fontWeight: 600, borderColor: DS_SLATE[300], color: DS_SLATE[600] }}>
Bearbeiten
</Button>
<Button variant="contained" size="large" onClick={onStart}
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }}>
sx={{ flex: 2, textTransform: 'none', fontWeight: 600, bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}>
Suche starten
</Button>
</Box>
@@ -304,7 +305,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
<Typography variant="caption" sx={{ color: DS_SLATE[500], fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{children}
</Typography>
)
@@ -313,13 +314,13 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: '#64748b', mb: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: DS_SLATE[500], mb: 0.25 }}>
{icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label}
</Typography>
</Box>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: '#0f172a', fontWeight: 500 }}>{value}</Typography>
<Typography variant="body2" sx={{ fontSize: '0.85rem', color: DS_SLATE[900], fontWeight: 500 }}>{value}</Typography>
</Box>
)
}
+9 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Plus, Search } from 'lucide-react'
import { useNavigate } from 'react-router'
@@ -10,6 +10,7 @@ import { ROUTES } from '../../lib/constants'
import { EmptyState } from '../ui'
import { SavedNeedCard } from './SavedNeedCard'
import { SavedNeedDetail } from './SavedNeedDetail'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props {
onNewSearch: (prefillNeedId?: string) => void
@@ -54,11 +55,10 @@ export function SavedProfilesTab({ onNewSearch }: Props) {
return { matchCounts: counts, topScores: tops }
}, [visible, allMatches])
useEffect(() => {
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id)
}, [visible, selectedId, isMobile])
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
// Ohne eigene Wahl steht auf grossen Schirmen das erste Profil offen —
// abgeleitet statt in einem Effekt nachgetragen.
const effectiveId = selectedId ?? (isMobile ? null : visible[0]?.id ?? null)
const selectedNeed = visible.find(n => n.id === effectiveId) ?? null
function handleSelect(id: string) {
setSelectedId(id)
@@ -92,8 +92,8 @@ export function SavedProfilesTab({ onNewSearch }: Props) {
<Box sx={{ width: 280, minWidth: 280, flexShrink: 0, borderRight: '1px solid #e2e8f0', bgcolor: 'white', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Search size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography>
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900] }}>Suchprofile</Typography>
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: DS_BRAND.main, color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{visible.length}
</Box>
</Box>
@@ -102,7 +102,7 @@ export function SavedProfilesTab({ onNewSearch }: Props) {
<SavedNeedCard
key={n.id}
need={n}
selected={n.id === selectedId}
selected={n.id === effectiveId}
matchCount={matchCounts[n.id] ?? 0}
topScore={topScores[n.id] ?? 0}
onClick={() => handleSelect(n.id)}
+27 -28
View File
@@ -1,6 +1,13 @@
import { useRef, useState } from 'react'
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import { Mic, MicOff, Sparkles, X } from 'lucide-react'
import {
createSpeechRecognizer,
isSpeechRecognitionSupported,
splitTranscript,
} from '../../lib/speechRecognition'
import type { SpeechRecognizer } from '../../lib/speechRecognition'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
text: string
@@ -16,44 +23,36 @@ const EXAMPLES = [
'Lagerhalle 20003000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
]
const isSpeechSupported = typeof window !== 'undefined' &&
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
const isSpeechSupported = isSpeechRecognitionSupported()
/** Ab dieser Textlänge lohnt die automatische Analyse — darunter fehlt der Kontext. */
const MIN_CHARS_FOR_AUTO_SUBMIT = 15
export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
const [isRecording, setIsRecording] = useState(false)
const [interimText, setInterimText] = useState('')
const recognitionRef = useRef<any>(null)
const recognitionRef = useRef<SpeechRecognizer | null>(null)
const accumulatedRef = useRef('')
function startRecording() {
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition
if (!SpeechAPI) return
const rec = createSpeechRecognizer('de-DE')
if (!rec) return
accumulatedRef.current = text
const rec = new SpeechAPI()
rec.lang = 'de-DE'
rec.continuous = true
rec.interimResults = true
rec.onresult = (e: any) => {
let finalPart = ''
let interimPart = ''
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript
if (e.results[i].isFinal) finalPart += t
else interimPart += t
}
if (finalPart) {
accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim()
rec.onresult = (event) => {
const { final, interim } = splitTranscript(event)
if (final) {
accumulatedRef.current = (accumulatedRef.current + ' ' + final).trim()
onTextChange(accumulatedRef.current)
}
setInterimText(interimPart)
setInterimText(interim)
}
rec.onend = () => {
setIsRecording(false)
setInterimText('')
if (accumulatedRef.current.length >= 15) onAiSubmit()
if (accumulatedRef.current.length >= MIN_CHARS_FOR_AUTO_SUBMIT) onAiSubmit()
}
rec.onerror = () => { setIsRecording(false); setInterimText('') }
@@ -97,15 +96,15 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
size="small"
label="🎤 Aufnahme läuft…"
onClick={stopRecording}
sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
sx={{ bgcolor: DS_ACCENT.danger.bg, color: DS_ACCENT.danger.strong, fontWeight: 600, fontSize: 11, cursor: 'pointer' }}
/>
) : isAnalyzing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} sx={{ color: '#152642' }} />
<Typography variant="caption" sx={{ color: '#152642', fontWeight: 600 }}>Analysiert</Typography>
<CircularProgress size={14} sx={{ color: DS_BRAND.main }} />
<Typography variant="caption" sx={{ color: DS_BRAND.main, fontWeight: 600 }}>Analysiert</Typography>
</Box>
) : isAutoGen && text ? (
<Typography variant="caption" sx={{ color: '#152642', fontSize: 11 }}> auto-synchronisiert</Typography>
<Typography variant="caption" sx={{ color: DS_BRAND.main, fontSize: 11 }}> auto-synchronisiert</Typography>
) : null}
</Box>
@@ -138,7 +137,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
onClick={stopRecording}
size="small"
sx={{
bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' },
bgcolor: DS_ACCENT.danger.strong, color: 'white', '&:hover': { bgcolor: DS_TEXT.error },
animation: 'micPulse 1.2s ease-in-out infinite',
'@keyframes micPulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' },
@@ -154,7 +153,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
size="small"
disabled={isAnalyzing || !isSpeechSupported}
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'}
sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e8e7e4' }, '&:disabled': { opacity: 0.35 } }}
sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], '&:hover': { bgcolor: DS_BG.muted }, '&:disabled': { opacity: 0.35 } }}
>
<Mic size={16} />
</IconButton>
@@ -181,7 +180,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
{text && !isRecording && (
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}>
<IconButton size="small" onClick={() => onTextChange('')} sx={{ color: DS_SLATE[400], p: 0.5 }}>
<X size={14} />
</IconButton>
)}
+138
View File
@@ -0,0 +1,138 @@
import { Box, Chip, MenuItem, TextField, Typography } from '@mui/material'
import type { ExposeField, ExposeSection } from '../../lib/exposeFields'
import type { ExposeValues } from '../../domain/expose'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
section: ExposeSection
values: ExposeValues
missing: Set<string>
onChange: (key: string, value: string) => void
/** Zusatzinhalt am Ende des Abschnitts, z. B. der KI-Entwurfsknopf. */
footer?: React.ReactNode
}
/** Mehrfachauswahl als Wert: kommagetrennt, damit ein flaches Wörterbuch reicht. */
function toggleChip(current: string, option: string): string {
const set = new Set(current.split(',').map(s => s.trim()).filter(Boolean))
if (set.has(option)) set.delete(option)
else set.add(option)
return [...set].join(', ')
}
function isSelected(current: string, option: string): boolean {
return current.split(',').map(s => s.trim()).includes(option)
}
function FieldControl({
field, value, isMissing, onChange,
}: {
field: ExposeField
value: string
isMissing: boolean
onChange: (value: string) => void
}) {
const label = field.suffix ? `${field.label} (${field.suffix})` : field.label
if (field.type === 'chips') {
return (
<Box>
<Typography
variant="caption"
sx={{ display: 'block', mb: 0.75, color: DS_TEXT.secondary, textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.65rem' }}
>
{label}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{(field.options ?? []).map(o => {
const selected = isSelected(value, o)
return (
<Chip
key={o}
label={o}
size="small"
variant={selected ? 'filled' : 'outlined'}
onClick={() => onChange(toggleChip(value, o))}
sx={{
cursor: 'pointer',
bgcolor: selected ? '#152642' : 'transparent',
color: selected ? 'white' : DS_TEXT.secondary,
borderColor: DS_SLATE[200],
}}
/>
)
})}
</Box>
</Box>
)
}
return (
<TextField
select={field.type === 'select'}
multiline={field.type === 'multiline'}
rows={field.type === 'multiline' ? 3 : undefined}
type={field.type === 'number' ? 'number' : 'text'}
label={field.required ? `${label} *` : label}
size="small"
fullWidth
value={value}
onChange={e => onChange(e.target.value)}
sx={{
'& .MuiOutlinedInput-notchedOutline': isMissing
? { borderColor: DS_TEXT.error, borderWidth: 2 }
: {},
}}
>
{field.type === 'select' &&
(field.options ?? []).map(o => <MenuItem key={o} value={o}>{o}</MenuItem>)}
</TextField>
)
}
/**
* Ein fachlicher Bereich des Exposé-Dossiers.
*
* Rendert generisch aus `lib/exposeFields.ts` die Bereiche sind Daten, keine
* elf handgeschriebenen Formulare. Leere Pflichtfelder sind rot umrandet
* (Runde 4, §8.5.2); sie werden nicht mit plausiblen Werten gefüllt.
*/
export function ExposeFieldGroup({ section, values, missing, onChange, footer }: Props) {
return (
<Box component="section" sx={{ mb: 4 }}>
<Typography sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary }}>
{section.title}
</Typography>
{section.description && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 1.5 }}>
{section.description}
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'repeat(3, minmax(0, 1fr))' },
gap: 1.5,
mt: section.description ? 0 : 1.5,
}}
>
{section.fields.map(field => (
<Box
key={field.key}
sx={{ gridColumn: field.wide || field.type === 'chips' || field.type === 'multiline' ? '1 / -1' : 'auto' }}
>
<FieldControl
field={field}
value={values[field.key] ?? ''}
isMissing={missing.has(field.key)}
onChange={v => onChange(field.key, v)}
/>
</Box>
))}
</Box>
{footer && <Box sx={{ mt: 1.5 }}>{footer}</Box>}
</Box>
)
}
@@ -0,0 +1,221 @@
import { useCallback, useRef } from 'react'
import {
Box, Button, Checkbox, FormControlLabel, IconButton, MenuItem, TextField, Tooltip, Typography,
} from '@mui/material'
import { ArrowDown, ArrowUp, Star, Upload } from 'lucide-react'
import type { ExposeImage } from '../../domain/expose'
import { ExposeImageCategory } from '../../domain/expose'
import { EXPOSE_IMAGE_CATEGORY_LABELS } from '../../lib/constants'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
images: ExposeImage[]
onChange: (next: ExposeImage[]) => void
}
/**
* Schritt 1 «Hochladen» und die Medien-Kuration in einem (Runde 4, §8.5.1).
*
* Livia importiert die Objektbilder aus «Meine Objekte» selbst; das Uploadfeld
* ist ausdrücklich nur für Bilder gedacht, die dort noch nicht liegen. Deshalb
* steht die Liste der bereits importierten Bilder gleichberechtigt neben der
* Ablagefläche und nicht darunter versteckt.
*
* Kategorie, Bildunterschrift, Sichtbarkeit, Reihenfolge und Titelbild lassen
* sich je Bild setzen. Genau ein Bild ist Titelbild das Setzen eines neuen
* nimmt dem alten die Markierung, statt zwei Titelbilder zuzulassen.
*/
export function ExposeMediaManager({ images, onChange }: Props) {
const fileInput = useRef<HTMLInputElement>(null)
const patch = useCallback(
(id: string, changes: Partial<ExposeImage>) => {
onChange(images.map(img => (img.id === id ? { ...img, ...changes } : img)))
},
[images, onChange],
)
const setCover = useCallback(
(id: string) => onChange(images.map(img => ({ ...img, isCover: img.id === id }))),
[images, onChange],
)
const move = useCallback(
(index: number, delta: number) => {
const target = index + delta
if (target < 0 || target >= images.length) return
const next = [...images]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
onChange(next)
},
[images, onChange],
)
/**
* Zusätzliche Bilder. Doppelte werden über den Dateinamen abgewiesen die
* häufigste Verwechslung ist, ein bereits importiertes Bild nochmals von der
* Festplatte zu wählen.
*/
const handleFiles = useCallback(
(files: FileList | null) => {
if (!files) return
const known = new Set(images.map(i => i.fileName))
const added: ExposeImage[] = []
for (const file of Array.from(files)) {
if (known.has(file.name)) continue
known.add(file.name)
added.push({
id: `img-upload-${file.name}-${added.length}`,
url: URL.createObjectURL(file),
fileName: file.name,
category: ExposeImageCategory.OTHER,
caption: '',
visible: true,
isCover: false,
imported: false,
})
}
if (added.length > 0) onChange([...images, ...added])
},
[images, onChange],
)
const importedCount = images.filter(i => i.imported).length
return (
<Box>
{/* Uploadfläche */}
<Box
onClick={() => fileInput.current?.click()}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); handleFiles(e.dataTransfer.files) }}
sx={{
border: '1px dashed #cbd5e1',
borderRadius: 2,
py: 5,
px: 3,
textAlign: 'center',
cursor: 'pointer',
bgcolor: DS_SLATE[50],
'&:hover': { bgcolor: DS_SLATE[100] },
}}
>
<Upload size={22} color="#64748b" />
<Typography variant="body2" sx={{ mt: 1, fontWeight: 600, color: DS_TEXT.primary }}>
Zusätzliche Bilder hierher ziehen oder auswählen
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Nur für Bilder, die noch nicht in «Meine Objekte» hinterlegt sind JPG oder PNG
</Typography>
<input
ref={fileInput}
type="file"
accept="image/*"
multiple
hidden
onChange={e => handleFiles(e.target.files)}
/>
</Box>
<Typography variant="caption" sx={{ display: 'block', mt: 1.5, mb: 1, color: DS_TEXT.secondary }}>
{importedCount} Bild(er) automatisch aus «Meine Objekte» importiert · {images.length} insgesamt
</Typography>
{/* Medien-Kuration */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{images.map((img, index) => (
<Box
key={img.id}
sx={{
display: 'flex',
gap: 1.5,
p: 1.25,
border: '1px solid #e2e8f0',
borderRadius: 1.5,
bgcolor: 'white',
}}
>
{/* Reihenfolge */}
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
<IconButton size="small" onClick={() => move(index, -1)} disabled={index === 0} aria-label="Nach oben">
<ArrowUp size={13} />
</IconButton>
<IconButton size="small" onClick={() => move(index, 1)} disabled={index === images.length - 1} aria-label="Nach unten">
<ArrowDown size={13} />
</IconButton>
</Box>
<Box
component="img"
src={img.url}
alt={img.caption || img.fileName}
sx={{ width: 96, height: 72, objectFit: 'cover', borderRadius: 1, flexShrink: 0 }}
/>
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.primary }} noWrap>
{img.fileName}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{img.imported ? 'aus «Meine Objekte»' : 'hochgeladen'}
</Typography>
<Tooltip title="Als Titelbild">
<IconButton
size="small"
onClick={() => setCover(img.id)}
aria-label="Als Titelbild"
sx={{ ml: 'auto', color: img.isCover ? '#b8975a' : '#cbd5e1' }}
>
<Star size={14} fill={img.isCover ? '#b8975a' : 'none'} />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
select size="small" label="Kategorie"
value={img.category}
onChange={e => patch(img.id, { category: e.target.value as ExposeImage['category'] })}
sx={{ width: 170, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
>
{Object.values(ExposeImageCategory).map(c => (
<MenuItem key={c} value={c}>{EXPOSE_IMAGE_CATEGORY_LABELS[c]}</MenuItem>
))}
</TextField>
<TextField
size="small" label="Bildunterschrift"
value={img.caption}
onChange={e => patch(img.id, { caption: e.target.value })}
sx={{ flex: 1, minWidth: 180, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
/>
<FormControlLabel
control={
<Checkbox
size="small"
checked={img.visible}
onChange={e => patch(img.id, { visible: e.target.checked })}
/>
}
label={<Typography variant="caption">Im Exposé sichtbar</Typography>}
/>
</Box>
</Box>
</Box>
))}
</Box>
{images.length === 0 && (
<Button
variant="outlined"
size="small"
onClick={() => fileInput.current?.click()}
sx={{ mt: 1, textTransform: 'none' }}
>
Bilder auswählen
</Button>
)}
</Box>
)
}
+316
View File
@@ -0,0 +1,316 @@
import { useCallback, useMemo, useState } from 'react'
import {
Alert, Box, Button, Divider, MenuItem, Step, StepButton, Stepper, TextField, Typography,
} from '@mui/material'
import { Download, Eye, FileText, Sparkles } from 'lucide-react'
import type { ExposeDraft, ExposeValues } from '../../domain/expose'
import { ExposeBranding } from '../../domain/expose'
import type { Property } from '../../domain/property'
import { ExposeMediaManager } from './ExposeMediaManager'
import { ExposeFieldGroup } from './ExposeFieldGroup'
import { PanelLoadingState } from '../ui'
import { useExposeDraft, useGenerateExpose, useGenerateExposeText, useSaveExposeDraft } from '../../hooks/useExpose'
import { textFactsFrom } from '../../services/exposeService'
import { downloadExposePdf, downloadExposeWord, openExposePreview } from '../../services/exposeExport'
import { EXPOSE_SECTIONS, missingRequiredExposeKeys } from '../../lib/exposeFields'
import { EXPOSE_STEPS, EXPOSE_TONALITY_LABELS } from '../../lib/constants'
import { ExposeTextSection } from '../../services/ai/IAIService'
import type { ExposeTonality } from '../../services/ai/IAIService'
import { useToastStore } from '../../stores/toastStore'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
/** Welcher Textabschnitt gehört zu welchem Feld? Basis für «KI-Entwurf erstellen». */
const TEXT_FIELD_SECTIONS: { key: string; section: ExposeTextSection; label: string }[] = [
{ key: 'exposeTitel', section: ExposeTextSection.TITLE, label: 'Exposé-Titel' },
{ key: 'kurzbeschrieb', section: ExposeTextSection.TEASER, label: 'Kurzbeschrieb' },
{ key: 'objektbeschrieb', section: ExposeTextSection.OBJECT, label: 'Objektbeschrieb' },
{ key: 'lagebeschrieb', section: ExposeTextSection.LOCATION, label: 'Lagebeschrieb' },
{ key: 'gemeindebeschrieb', section: ExposeTextSection.MUNICIPALITY, label: 'Gemeindebeschrieb' },
{ key: 'ausstattungsbeschrieb',section: ExposeTextSection.FEATURES, label: 'Ausstattungsbeschrieb' },
{ key: 'highlights', section: ExposeTextSection.HIGHLIGHTS, label: 'Highlights' },
]
interface Props {
leadId: string
property: Property
}
/**
* Der dreistufige Exposé-Prozess: Hochladen Exposé Export (Runde 4, §8.5).
*
* Lädt den Entwurf und übergibt ihn als Startwert an den Arbeitsbereich. Die
* Trennung in Lader und Arbeitsbereich hat einen Grund: der lokale Entwurf soll
* genau einmal aus dem gespeicherten Stand entstehen. Über `key` löst der
* Wechsel auf ein anderes Objekt einen frischen Arbeitsbereich aus, statt einen
* Effekt zu brauchen, der den lokalen Stand nachträglich überschreibt.
*/
export function ExposeWizard({ leadId, property }: Props) {
const { data: stored, isLoading } = useExposeDraft(leadId, property)
if (isLoading || !stored) return <PanelLoadingState />
return <ExposeWizardBody key={stored.id} initialDraft={stored} />
}
/**
* Der Entwurf lebt lokal, solange gearbeitet wird, und geht erst beim
* Speichern in den Service. Das ist Absicht: bei jedem Tastendruck zu
* speichern hiesse, jeden Zwischenstand zum gültigen Stand zu erklären.
*
* «Speichern» und «Erstellen» sind getrennt Speichern hält den Zwischenstand
* fest, Erstellen prüft die Vollständigkeit und schaltet den Export frei.
*/
function ExposeWizardBody({ initialDraft }: { initialDraft: ExposeDraft }) {
const saveDraft = useSaveExposeDraft()
const generate = useGenerateExpose()
const generateText = useGenerateExposeText()
const showToast = useToastStore(s => s.showToast)
const [step, setStep] = useState(0)
const [draft, setDraft] = useState<ExposeDraft>(initialDraft)
const missing = useMemo(
() => new Set(missingRequiredExposeKeys(draft.values)),
[draft],
)
const setValue = useCallback((key: string, value: string) => {
setDraft(prev => ({ ...prev, values: { ...prev.values, [key]: value } }))
}, [])
const setValues = useCallback((patch: ExposeValues) => {
setDraft(prev => ({ ...prev, values: { ...prev.values, ...patch } }))
}, [])
const runTextDraft = useCallback(
async (target: { key: string; section: ExposeTextSection; label: string }) => {
const result = await generateText.mutateAsync({
section: target.section,
tonality: draft.tonality,
facts: textFactsFrom(draft.values),
})
if (result.missingFacts.length > 0) {
showToast(
`${target.label}: Es fehlen belegte Angaben (${result.missingFacts.join(', ')}) — bitte zuerst erfassen.`,
'warning',
)
return
}
setValues({ [target.key]: result.text })
},
[draft, generateText, setValues, showToast],
)
const runAllTextDrafts = useCallback(async () => {
for (const target of TEXT_FIELD_SECTIONS) {
await runTextDraft(target)
}
}, [runTextDraft])
const isGenerated = Boolean(draft.generatedAt)
return (
<Box sx={{ bgcolor: DS_NEUTRAL.paper, border: '1px solid #e8e7e4', borderRadius: 2 }}>
{/* Schrittleiste */}
<Box sx={{ px: 3, pt: 2.5, pb: 1, borderBottom: '1px solid #e8e7e4' }}>
<Stepper nonLinear activeStep={step}>
{EXPOSE_STEPS.map((label, index) => (
<Step key={label} completed={index < step}>
<StepButton onClick={() => setStep(index)} sx={{ textTransform: 'none' }}>
{label}
</StepButton>
</Step>
))}
</Stepper>
</Box>
<Box sx={{ px: 3, py: 2.5, maxHeight: '65vh', overflowY: 'auto' }}>
{/* ── Schritt 1: Hochladen ── */}
{step === 0 && (
<ExposeMediaManager
images={draft.images}
onChange={images => setDraft({ ...draft, images })}
/>
)}
{/* ── Schritt 2: Exposé ── */}
{step === 1 && (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2.5, flexWrap: 'wrap' }}>
<TextField
select size="small" label="Tonalität für KI-Entwürfe"
value={draft.tonality}
onChange={e => setDraft({ ...draft, tonality: e.target.value as ExposeTonality })}
sx={{ width: 210 }}
>
{Object.entries(EXPOSE_TONALITY_LABELS).map(([value, label]) => (
<MenuItem key={value} value={value}>{label}</MenuItem>
))}
</TextField>
<Button
size="small"
variant="outlined"
startIcon={<Sparkles size={14} />}
onClick={runAllTextDrafts}
disabled={generateText.isPending}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Alle KI-Entwürfe erstellen
</Button>
{missing.size > 0 && (
<Typography variant="caption" sx={{ color: DS_TEXT.error, fontWeight: 600 }}>
{missing.size} Pflichtangabe(n) fehlen
</Typography>
)}
</Box>
{EXPOSE_SECTIONS.map(section => (
<ExposeFieldGroup
key={section.id}
section={section}
values={draft.values}
missing={missing}
onChange={setValue}
footer={
section.id === 'texte' ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{TEXT_FIELD_SECTIONS.map(t => (
<Button
key={t.key}
size="small"
startIcon={<Sparkles size={13} />}
onClick={() => runTextDraft(t)}
disabled={generateText.isPending}
sx={{ textTransform: 'none', color: DS_TEXT.secondary, fontSize: '0.75rem' }}
>
{t.label}
</Button>
))}
</Box>
) : undefined
}
/>
))}
{/* Medien-Kuration bleibt aus Schritt 1 erhalten und wird hier nur
verlinkt dieselbe Liste zweimal zu rendern wäre Duplikat. */}
<Divider sx={{ my: 3 }} />
<Typography sx={{ fontWeight: 700, fontSize: '1rem', color: DS_TEXT.primary, mb: 0.5 }}>
Kontakte & CI
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 1.5, mb: 2 }}>
<TextField
size="small" label="Ansprechperson fürs Exposé"
value={draft.contactPerson}
onChange={e => setDraft({ ...draft, contactPerson: e.target.value })}
/>
<TextField
select size="small" label="Farben"
value={draft.branding}
onChange={e => setDraft({ ...draft, branding: e.target.value as ExposeBranding })}
>
<MenuItem value={ExposeBranding.FROM_PROFILE}>Aus Maklerprofil übernehmen</MenuItem>
<MenuItem value={ExposeBranding.MANUAL}>Manuell setzen</MenuItem>
</TextField>
{draft.branding === ExposeBranding.MANUAL && (
<TextField
size="small" label="Farbwert (Hex)"
placeholder="#152642"
value={draft.brandColor ?? ''}
onChange={e => setDraft({ ...draft, brandColor: e.target.value })}
/>
)}
</Box>
</Box>
)}
{/* ── Schritt 3: Export ── */}
{step === 2 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{!isGenerated && (
<Alert severity="info" sx={{ fontSize: '0.85rem' }}>
Das Exposé ist noch nicht erstellt. Vorschau und Download zeigen den aktuellen Stand.
</Alert>
)}
{missing.size > 0 && (
<Alert severity="warning" sx={{ fontSize: '0.85rem' }}>
Es fehlen noch {missing.size} Pflichtangabe(n) im Schritt «Exposé» rot umrandet.
</Alert>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
<Button
variant="contained"
startIcon={<Eye size={15} />}
onClick={() => {
if (!openExposePreview(draft)) {
showToast('Die Vorschau wurde vom Browser blockiert — bitte Pop-ups erlauben.', 'error')
}
}}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Vorschau im neuen Tab
</Button>
<Button
variant="outlined"
startIcon={<FileText size={15} />}
onClick={() => {
if (!downloadExposePdf(draft)) {
showToast('Der PDF-Export wurde vom Browser blockiert — bitte Pop-ups erlauben.', 'error')
}
}}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Als PDF
</Button>
<Button
variant="outlined"
startIcon={<Download size={15} />}
onClick={() => downloadExposeWord(draft)}
sx={{ textTransform: 'none', borderColor: DS_SLATE[200], color: DS_TEXT.primary }}
>
Als Word
</Button>
</Box>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Der PDF-Export nutzt den Druckdialog des Browsers («Als PDF speichern»); CI, Bilder,
Texte und Anhänge folgen der Exposé-Konfiguration.
</Typography>
</Box>
)}
</Box>
{/* Speichern und Erstellen sind klar getrennt (§8.5.2). */}
<Box
sx={{
px: 3, py: 1.75,
borderTop: '1px solid #e8e7e4',
display: 'flex', alignItems: 'center', gap: 1,
}}
>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, flex: 1 }}>
{isGenerated
? `Erstellt am ${new Date(draft.generatedAt!).toLocaleString('de-CH')}`
: 'Noch kein Exposé erstellt.'}
</Typography>
<Button
size="small"
onClick={() => saveDraft.mutate(draft)}
disabled={saveDraft.isPending}
sx={{ textTransform: 'none', color: DS_TEXT.secondary }}
>
Speichern
</Button>
<Button
size="small"
variant="contained"
onClick={() => generate.mutate(draft, { onSuccess: () => setStep(2) })}
disabled={generate.isPending}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Speichern &amp; erstellen
</Button>
</Box>
</Box>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { memo, useCallback, useRef } from 'react'
import { Box, Typography } from '@mui/material'
import type { ExposeLead } from '../../domain/exposeLead'
import type { Property } from '../../domain/property'
import { ObjectDeepLink } from '../team'
import { LeadWorkspace } from './LeadWorkspace'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
export const LEAD_COLS = '110px 1.2fr 1.4fr 1fr 1.4fr'
const COLUMN_LABELS = ['Datum', 'Möglicher Interessent', 'Kontaktdaten', 'Raum', 'Objektempfehlung']
interface RowProps {
lead: ExposeLead
properties: Property[]
selected: boolean
onSelect: (id: string, row: HTMLElement | null) => void
}
const LeadRow = memo(function LeadRow({ lead, properties, selected, onSelect }: RowProps) {
const ref = useRef<HTMLDivElement>(null)
const recommended = lead.propertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is Property => Boolean(p))
return (
<Box
ref={ref}
onClick={() => onSelect(lead.id, ref.current)}
sx={{
display: 'grid',
gridTemplateColumns: LEAD_COLS,
alignItems: 'flex-start',
gap: 1,
px: 2, py: 1.5,
borderBottom: '1px solid #f1f5f9',
bgcolor: selected ? '#f8fafc' : 'white',
cursor: 'pointer',
'&:hover': { bgcolor: DS_SLATE[50] },
transition: 'background-color 0.1s',
}}
>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, pt: 0.25 }}>
{new Date(lead.receivedAt).toLocaleDateString('de-CH')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>
{lead.prospect}
</Typography>
<Box sx={{ minWidth: 0 }}>
{lead.contacts.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}></Typography>
) : (
lead.contacts.map(c => (
<Typography
key={c}
variant="caption"
sx={{ display: 'block', color: DS_TEXT.secondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{c}
</Typography>
))
)}
</Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{lead.locationHint}
</Typography>
{/* Mehrere Objekte je Lead — jedes verlinkt nach «Meine Objekte». */}
<Box sx={{ minWidth: 0 }} onClick={e => e.stopPropagation()}>
{recommended.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.disabled }}></Typography>
) : (
recommended.map(p => (
<ObjectDeepLink key={p.id} propertyId={p.id} label={p.title} />
))
)}
</Box>
</Box>
)
})
interface Props {
leads: ExposeLead[]
properties: Property[]
selectedId: string | null
onSelect: (id: string, row: HTMLElement | null) => void
/** Arbeitsbereich nur bei aktiven Leads — Archiviertes wird nicht bearbeitet. */
withWorkspace: boolean
emptyText: string
}
/**
* Livias Leadliste im Grunddesign der Ferdi-Liste (Runde 4, §8.3).
*
* Der Arbeitsbereich öffnet sich zwischen der gewählten Zeile und den übrigen
* Leads nicht in einem Drawer. Das hält den Bezug zum Lead sichtbar, während
* gearbeitet wird.
*/
export function LeadTable({ leads, properties, selectedId, onSelect, withWorkspace, emptyText }: Props) {
const handleSelect = useCallback(
(id: string, row: HTMLElement | null) => onSelect(id, row),
[onSelect],
)
if (leads.length === 0) {
return (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>{emptyText}</Typography>
</Box>
)
}
return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: LEAD_COLS,
gap: 1,
px: 2, py: 1,
bgcolor: DS_SLATE[50],
borderBottom: '1px solid #e2e8f0',
}}
>
{COLUMN_LABELS.map(h => (
<Typography key={h} variant="caption" sx={{ fontWeight: 600, color: DS_TEXT.secondary, fontSize: '0.7rem' }}>
{h}
</Typography>
))}
</Box>
{leads.map(lead => (
<Box key={lead.id}>
<LeadRow
lead={lead}
properties={properties}
selected={lead.id === selectedId}
onSelect={handleSelect}
/>
{withWorkspace && lead.id === selectedId && <LeadWorkspace lead={lead} />}
</Box>
))}
</Box>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { Alert, Box, Tab, Tabs } from '@mui/material'
import type { ExposeLead } from '../../domain/exposeLead'
import { useProperties } from '../../hooks/useProperties'
import { ExposeWizard } from './ExposeWizard'
import { PanelLoadingState } from '../ui'
import { DS_SLATE } from '../../lib/ds'
interface Props {
lead: ExposeLead
}
/**
* Arbeitsbereich zur Exposé-Erstellung, der sich unter dem gewählten Lead öffnet.
*
* Ein Lead kann mehrere Objekte empfehlen; jedes bekommt sein eigenes Exposé.
* Deshalb steht hier eine Objektauswahl darüber und nicht eine gemeinsame
* Broschüre für alles ein Exposé beschreibt genau ein Objekt.
*/
export function LeadWorkspace({ lead }: Props) {
const { data: properties = [], isLoading } = useProperties()
// Kein Rücksetz-Effekt beim Leadwechsel nötig: die Liste rendert je Lead eine
// eigene Zeile, ein anderer Lead ist also ein neuer Arbeitsbereich.
const [propertyId, setPropertyId] = useState<string>(lead.propertyIds[0] ?? '')
if (isLoading) return <PanelLoadingState />
// Objektempfehlungen sind auf reale Einträge aus «Meine Objekte» beschränkt.
const recommended = lead.propertyIds
.map(id => properties.find(p => p.id === id))
.filter((p): p is NonNullable<typeof p> => Boolean(p))
if (recommended.length === 0) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
Zu diesem Lead ist kein Objekt aus «Meine Objekte» hinterlegt ohne Objekt lässt sich kein Exposé erstellen.
</Alert>
</Box>
)
}
const active = recommended.find(p => p.id === propertyId) ?? recommended[0]
return (
<Box sx={{ px: 3, py: 2.5, bgcolor: DS_SLATE[50], borderTop: '1px solid #e8e7e4', borderBottom: '1px solid #e8e7e4' }}>
{recommended.length > 1 && (
<Tabs
value={active.id}
onChange={(_, v: string) => setPropertyId(v)}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontSize: '0.8125rem' } }}
>
{recommended.map(p => (
<Tab key={p.id} value={p.id} label={p.title} />
))}
</Tabs>
)}
<ExposeWizard leadId={lead.id} property={active} />
</Box>
)
}
+7
View File
@@ -0,0 +1,7 @@
// Property On — Livia, Exposé Master. Barrel-Export (CLAUDE.md §13).
export { LeadTable, LEAD_COLS } from './LeadTable'
export { LeadWorkspace } from './LeadWorkspace'
export { ExposeWizard } from './ExposeWizard'
export { ExposeMediaManager } from './ExposeMediaManager'
export { ExposeFieldGroup } from './ExposeFieldGroup'
@@ -4,15 +4,10 @@ import { SensitivityBadge } from './SensitivityBadge'
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import type { FutureSignal } from '../../domain/futureSignal'
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
import { DS_SLATE } from '../../lib/ds'
const SOURCE_LABELS: Record<string, string> = {
PRESS: 'Presse',
CONSTRUCTION_PERMIT: 'Baubewilligung',
JOB_POSTING: 'Stelleninserat',
COMPANY_REPORT: 'Geschäftsbericht',
MARKET_DATA: 'Marktdaten',
MANUAL: 'Manuell',
}
function probColor(p: number): string {
return p >= 0.7 ? '#1a7a4a' : p >= 0.5 ? '#d97706' : '#c0392b'
@@ -74,7 +69,7 @@ export function FutureSignalCard({ signal, isSelected, onSelect }: Props) {
sx={{
height: 4,
borderRadius: 2,
bgcolor: '#f1f5f9',
bgcolor: DS_SLATE[100],
'& .MuiLinearProgress-bar': { bgcolor: probColor(signal.probability) },
}}
/>
@@ -87,7 +82,7 @@ export function FutureSignalCard({ signal, isSelected, onSelect }: Props) {
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')}`} size="small" sx={{ fontSize: 10, height: 18 }} />
)}
<Chip
label={SOURCE_LABELS[signal.source.type] ?? signal.source.type}
label={SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type}
size="small"
variant="outlined"
sx={{ fontSize: 10, height: 18 }}
@@ -11,20 +11,15 @@ import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore'
import { ReviewStatus } from '../../domain/enums'
import type { FutureSignal } from '../../domain/futureSignal'
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const SOURCE_LABELS: Record<string, string> = {
PRESS: 'Pressebericht',
CONSTRUCTION_PERMIT: 'Baubewilligung',
JOB_POSTING: 'Stelleninserat',
COMPANY_REPORT: 'Geschäftsbericht',
MARKET_DATA: 'Marktdaten',
MANUAL: 'Manuell erfasst',
}
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hoch', color: '#1a7a4a' },
MEDIUM: { label: 'Mittel', color: '#d97706' },
LOW: { label: 'Niedrig', color: '#c0392b' },
HIGH: { label: 'Hoch', color: DS_ACCENT.success.main },
MEDIUM: { label: 'Mittel', color: DS_ACCENT.warning.main },
LOW: { label: 'Niedrig', color: DS_ACCENT.danger.main },
}
function BarRow({ label, value }: { label: string; value: number }) {
@@ -38,7 +33,7 @@ function BarRow({ label, value }: { label: string; value: number }) {
<LinearProgress
variant="determinate"
value={value * 100}
sx={{ height: 5, borderRadius: 2, bgcolor: '#f1f5f9', '& .MuiLinearProgress-bar': { bgcolor: color } }}
sx={{ height: 5, borderRadius: 2, bgcolor: DS_SLATE[100], '& .MuiLinearProgress-bar': { bgcolor: color } }}
/>
</Box>
)
@@ -92,12 +87,12 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
title: signal.title ?? signal.companyName ?? signal.locationHint,
matchScore: Math.round(signal.confidenceScore * 100),
confidenceScore: signal.confidenceScore,
sourceLabel: SOURCE_LABELS[signal.source.type] ?? signal.source.type,
sourceLabel: SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type,
addedBy: 'admin@ideal-sharing.ch',
})
}
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? { label: signal.source.credibility, color: '#64748b' }
const credMeta = CREDIBILITY_META[signal.source.credibility] ?? { label: signal.source.credibility, color: DS_SLATE[500] }
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
@@ -112,7 +107,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
<Typography variant="caption" color="text.secondary">{signal.locationHint}</Typography>
)}
</Box>
<IconButton size="small" onClick={onClose} sx={{ color: '#94a3b8', mt: -0.5 }}>
<IconButton size="small" onClick={onClose} sx={{ color: DS_SLATE[400], mt: -0.5 }}>
<X size={16} />
</IconButton>
</Box>
@@ -121,7 +116,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
<SensitivityBadge level={signal.sensitivityLevel} />
<SignalReviewStatusBadge status={signal.reviewStatus} />
{signal.isVerified && (
<Chip label="Verifiziert" size="small" sx={{ bgcolor: '#1a7a4a', color: 'white', fontSize: 10 }} />
<Chip label="Verifiziert" size="small" sx={{ bgcolor: DS_ACCENT.success.main, color: 'white', fontSize: 10 }} />
)}
</Box>
</Box>
@@ -169,7 +164,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Typ</Typography>
<Typography variant="caption">{SOURCE_LABELS[signal.source.type] ?? signal.source.type}</Typography>
<Typography variant="caption">{SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Glaubwürdigkeit</Typography>
@@ -188,7 +183,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
<>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Evidenz</Typography>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: '#f8fafc' }}>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: DS_SLATE[50] }}>
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
</Paper>
</>
@@ -222,7 +217,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
<Button
fullWidth size="small" variant="outlined"
onClick={handleSendReview}
sx={{ justifyContent: 'flex-start', color: '#d97706', borderColor: '#d97706' }}
sx={{ justifyContent: 'flex-start', color: DS_ACCENT.warning.main, borderColor: DS_ACCENT.warning.main }}
>
Zur Prüfung senden
</Button>
@@ -232,7 +227,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
fullWidth size="small" variant="contained"
disabled={updateStatus.isPending}
onClick={() => handleStatus(ReviewStatus.APPROVED)}
sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' }, justifyContent: 'flex-start' }}
sx={{ bgcolor: DS_ACCENT.success.main, '&:hover': { bgcolor: DS_ACCENT.success.darkAlt }, justifyContent: 'flex-start' }}
endIcon={updateStatus.isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
>
Genehmigen
@@ -243,7 +238,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
fullWidth size="small" variant="outlined"
disabled={updateStatus.isPending}
onClick={() => handleStatus(ReviewStatus.REJECTED)}
sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }}
sx={{ justifyContent: 'flex-start', color: DS_ACCENT.danger.main, borderColor: DS_ACCENT.danger.main }}
>
Ablehnen
</Button>
@@ -1,22 +1,10 @@
import { Box, Chip, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material'
import { SignalType } from '../../domain/enums'
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
import { DEFAULT_SIGNAL_FILTERS } from './signalFilterState'
import type { SignalFilterState } from './signalFilterState'
import { DS_ACCENT, DS_BRAND } from '../../lib/ds'
export interface SignalFilterState {
signalType: string
minConfidence: number
sensitivityLevel: string
reviewStatus: string
timeHorizon: string
}
export const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
signalType: '',
minConfidence: 0,
sensitivityLevel: '',
reviewStatus: '',
timeHorizon: '',
}
interface Props {
filters: SignalFilterState
@@ -131,7 +119,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
label={`${activeCount} Filter aktiv`}
size="small"
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)}
sx={{ bgcolor: '#eff6ff', color: '#152642' }}
sx={{ bgcolor: DS_ACCENT.blue.bg, color: DS_BRAND.main }}
/>
)}
<Typography variant="caption" color="text.secondary">
@@ -1,14 +1,15 @@
import { Chip } from '@mui/material'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const SENSITIVITY_META: Record<string, { label: string; color: string }> = {
PUBLIC: { label: 'Öffentlich', color: '#64748b' },
INTERNAL: { label: 'Intern', color: '#d97706' },
CONFIDENTIAL: { label: 'Vertraulich', color: '#c0392b' },
RESTRICTED: { label: 'Eingeschränkt', color: '#7c3aed' },
PUBLIC: { label: 'Öffentlich', color: DS_SLATE[500] },
INTERNAL: { label: 'Intern', color: DS_ACCENT.warning.main },
CONFIDENTIAL: { label: 'Vertraulich', color: DS_ACCENT.danger.main },
RESTRICTED: { label: 'Eingeschränkt', color: DS_ACCENT.violet.main },
}
export function SensitivityBadge({ level }: { level: string }) {
const meta = SENSITIVITY_META[level] ?? { label: level, color: '#64748b' }
const meta = SENSITIVITY_META[level] ?? { label: level, color: DS_SLATE[500] }
return (
<Chip
label={meta.label}
@@ -1,17 +1,18 @@
import { Chip } from '@mui/material'
import type { ReviewStatus } from '../../domain/enums'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const STATUS_META: Record<string, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' },
FLAGGED: { label: 'Markiert', color: '#ea580c' },
UNREVIEWED: { label: 'Ungeprüft', color: DS_SLATE[400] },
IN_REVIEW: { label: 'In Prüfung', color: DS_ACCENT.warning.main },
APPROVED: { label: 'Genehmigt', color: DS_ACCENT.success.main },
REJECTED: { label: 'Abgelehnt', color: DS_ACCENT.danger.main },
FLAGGED: { label: 'Markiert', color: DS_ACCENT.warning.strong },
}
export function SignalReviewStatusBadge({ status }: { status?: ReviewStatus | null }) {
const key = status ?? 'UNREVIEWED'
const meta = STATUS_META[key] ?? { label: key, color: '#94a3b8' }
const meta = STATUS_META[key] ?? { label: key, color: DS_SLATE[400] }
return (
<Chip
label={meta.label}
@@ -1,6 +1,7 @@
import { Chip } from '@mui/material'
import type { SignalType } from '../../domain/enums'
import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface SignalTypeBadgeProps {
type: SignalType
@@ -8,16 +9,16 @@ interface SignalTypeBadgeProps {
}
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' },
EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: DS_ACCENT.success.main },
POSSIBLE_MOVE_OUT: { bg: 'rgba(217,119,6,0.12)', color: DS_ACCENT.warning.main },
CONSTRUCTION_PROJECT: { bg: 'rgba(37,99,235,0.12)', color: DS_ACCENT.blue.main },
RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: DS_ACCENT.warning.burnt },
PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: DS_ACCENT.violet.strong },
SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: DS_SLATE[600] },
}
export function SignalTypeBadge({ type, size = 'small' }: SignalTypeBadgeProps) {
const { bg, color } = COLOR_MAP[type] ?? { bg: '#f1f5f9', color: '#475569' }
const { bg, color } = COLOR_MAP[type] ?? { bg: '#f1f5f9', color: DS_SLATE[600] }
return (
<Chip
label={SIGNAL_TYPE_LABELS[type] ?? type}
+2 -2
View File
@@ -6,5 +6,5 @@ export { FutureSignalCard } from './FutureSignalCard'
export { FutureSignalFilterBar } from './FutureSignalFilterBar'
export { FutureSignalDetailPanel } from './FutureSignalDetailPanel'
export { FutureSignalEmptyState } from './FutureSignalEmptyState'
export type { SignalFilterState } from './FutureSignalFilterBar'
export { DEFAULT_SIGNAL_FILTERS } from './FutureSignalFilterBar'
export type { SignalFilterState } from './signalFilterState'
export { DEFAULT_SIGNAL_FILTERS } from './signalFilterState'
@@ -0,0 +1,22 @@
/**
* Filterzustand der Signalliste.
*
* Getrennt von der Filterleiste, weil beide Seiten ihn brauchen: die Leiste
* zum Anzeigen, die Seite zum Halten. Läge er in der `.tsx`-Datei, mischte
* diese Bauteile und Daten und verlöre bei jeder Änderung ihren Zustand.
*/
export interface SignalFilterState {
signalType: string
minConfidence: number
sensitivityLevel: string
reviewStatus: string
timeHorizon: string
}
export const DEFAULT_SIGNAL_FILTERS: SignalFilterState = {
signalType: '',
minConfidence: 0,
sensitivityLevel: '',
reviewStatus: '',
timeHorizon: '',
}
+12 -7
View File
@@ -29,8 +29,13 @@ export function AppShell() {
const isCompact = useMediaQuery(theme.breakpoints.down('lg'))
const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [location.pathname])
// Das mobile Menü ist nur offen, solange die Route dieselbe bleibt. Statt es
// nach dem Routenwechsel per Effekt zu schliessen, hängt der Zustand am Pfad:
// ein Wechsel erzeugt einen neuen Schlüssel und damit einen frischen Zustand.
const [openForPath, setOpenForPath] = useState(location.pathname)
const isMobileOpen = mobileOpen && openForPath === location.pathname
const openMobileMenu = () => { setOpenForPath(location.pathname); setMobileOpen(true) }
const closeMobileMenu = () => setMobileOpen(false)
// Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
@@ -74,8 +79,8 @@ export function AppShell() {
activeWorkspace={activeWorkspace}
allowedWorkspaces={allowedWorkspaces}
onWorkspaceClick={handleWorkspaceClick}
onToggle={isMobile ? () => setMobileOpen(false) : toggleSidebar}
onClose={isMobile ? () => setMobileOpen(false) : undefined}
onToggle={isMobile ? closeMobileMenu : toggleSidebar}
onClose={isMobile ? closeMobileMenu : undefined}
userName={userName}
orgName={orgName}
/>
@@ -89,8 +94,8 @@ export function AppShell() {
{/* Mobile: temporary drawer */}
{isMobile && (
<Drawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
open={isMobileOpen}
onClose={closeMobileMenu}
variant="temporary"
ModalProps={{ keepMounted: true }}
slotProps={{ paper: { sx: { width: 264, bgcolor: 'transparent', boxShadow: 'none' } } }}
@@ -103,7 +108,7 @@ export function AppShell() {
<TopBar
pathname={location.pathname}
isMobile={isMobile}
onMenuClick={() => setMobileOpen(true)}
onMenuClick={openMobileMenu}
/>
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
+43 -8
View File
@@ -3,7 +3,9 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
import { NavLink, useLocation } from 'react-router'
import { WorkspaceType } from '../../domain/enums'
import { useCompareStore } from '../../stores/compareStore'
import { AgentAvatar } from '../team'
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig'
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
// ---------------------------------------------------------------------------
// Visual constants
@@ -97,7 +99,7 @@ export function Sidebar({
<Typography
variant="caption"
sx={{
color: '#64b5f6',
color: DS_BRAND.light,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
@@ -221,7 +223,7 @@ export function Sidebar({
{item.path === '/demand/compare' && compareCount > 0 && (
<Box sx={{
minWidth: 18, height: 18, borderRadius: '9px',
bgcolor: '#d97706', color: 'white',
bgcolor: DS_ACCENT.warning.main, color: 'white',
fontSize: '0.65rem', fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
px: 0.5, flexShrink: 0,
@@ -237,9 +239,14 @@ export function Sidebar({
)
// Subreiter bleiben sichtbar, solange der Nutzer im Funktionsbereich ist.
// Seit Runde 4 liegen die Agentenseiten nicht mehr unterhalb des
// Basispfads — die Liste muss deshalb auch auf einer Agentenseite
// offen bleiben, sonst verschwände die Navigation beim Hinklicken.
// Eingeklappt entfallen sie — dort ist kein Platz für eine zweite Ebene.
const subItems = item.children ?? []
const inSection = subItems.length > 0 && pathname.startsWith(item.path)
const inSection =
subItems.length > 0 &&
(pathname.startsWith(item.path) || subItems.some(c => pathname.startsWith(c.path)))
return collapsed ? (
<Tooltip key={item.path} title={item.label} placement="right">
@@ -260,9 +267,12 @@ export function Sidebar({
{({ isActive }) => (
<Box
sx={{
pl: 4.75,
display: 'flex',
alignItems: 'center',
gap: 1,
pl: child.agentId ? 2.5 : 4.75,
pr: 1.5,
py: 0.5,
py: child.agentId ? 0.625 : 0.5,
mx: 0.5,
borderRadius: 1,
borderLeft: `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
@@ -271,6 +281,13 @@ export function Sidebar({
cursor: 'pointer',
}}
>
{child.agentId && (
<AgentAvatar
agent={{ id: child.agentId, name: child.label, role: child.role ?? '' }}
size={26}
/>
)}
<Box sx={{ minWidth: 0 }}>
<Typography
variant="body2"
sx={{
@@ -279,10 +296,28 @@ export function Sidebar({
fontSize: '0.8125rem',
letterSpacing: '-0.01em',
whiteSpace: 'nowrap',
lineHeight: 1.3,
}}
>
{child.label}
</Typography>
{child.role && (
<Typography
variant="caption"
sx={{
display: 'block',
color: isActive ? TEXT_MUTED : '#64748b',
fontSize: '0.6875rem',
lineHeight: 1.3,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{child.role}
</Typography>
)}
</Box>
</Box>
)}
</NavLink>
@@ -315,7 +350,7 @@ export function Sidebar({
width: 32,
height: 32,
fontSize: '0.75rem',
bgcolor: '#b8975a',
bgcolor: DS_NEUTRAL.gold,
flexShrink: 0,
}}
>
@@ -338,7 +373,7 @@ export function Sidebar({
<Typography
variant="caption"
sx={{
color: '#64748b',
color: DS_SLATE[500],
fontSize: '0.7rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
@@ -355,7 +390,7 @@ export function Sidebar({
<IconButton
onClick={onToggle}
size="small"
sx={{ color: '#64748b', flexShrink: 0 }}
sx={{ color: DS_SLATE[500], flexShrink: 0 }}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</IconButton>
+9 -7
View File
@@ -2,6 +2,7 @@ import { useNavigate, useLocation } from 'react-router'
import { Box, Button, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore'
import { DS_BRAND, DS_NEUTRAL } from '../../lib/ds'
const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#152642',
@@ -17,11 +18,12 @@ export function CompareTray() {
if (!isDemand) return null
// Die Verzweigung über `resultType` genügt der Union — kein Cast nötig.
const getTitle = (item: (typeof compareItems)[number]) => {
if (item.resultType === 'FUTURE_AVAILABILITY') {
return (item as any).signal?.companyName ?? (item as any).signal?.locationHint ?? 'Signal'
return item.signal.companyName ?? item.signal.locationHint
}
return (item as any).property?.title ?? `Score ${item.matchScore}`
return item.property.title
}
return (
@@ -33,7 +35,7 @@ export function CompareTray() {
right: 0,
zIndex: 1300,
height: 56,
bgcolor: '#0f1923',
bgcolor: DS_NEUTRAL.sidebar,
display: 'flex',
alignItems: 'center',
px: 3,
@@ -42,7 +44,7 @@ export function CompareTray() {
transition: 'transform 0.25s ease',
}}
>
<Typography variant="caption" sx={{ color: '#fff', flexShrink: 0 }}>
<Typography variant="caption" sx={{ color: DS_NEUTRAL.white, flexShrink: 0 }}>
Vergleich ({compareItems.length}/4)
</Typography>
@@ -62,7 +64,7 @@ export function CompareTray() {
}}
>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: TYPE_DOT[item.resultType] ?? '#64748b', flexShrink: 0 }} />
<Typography variant="caption" sx={{ color: '#fff', maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ color: DS_NEUTRAL.white, maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{getTitle(item)}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', flexShrink: 0 }}>
@@ -71,7 +73,7 @@ export function CompareTray() {
<IconButton
size="small"
onClick={() => removeFromCompare(item.matchId)}
sx={{ p: 0.25, color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#fff' } }}
sx={{ p: 0.25, color: 'rgba(255,255,255,0.5)', '&:hover': { color: DS_NEUTRAL.white } }}
>
<X size={12} />
</IconButton>
@@ -92,7 +94,7 @@ export function CompareTray() {
variant="contained"
size="small"
onClick={() => navigate('/demand/compare')}
sx={{ bgcolor: '#152642', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }}
sx={{ bgcolor: DS_BRAND.main, textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
>
Vergleich starten
</Button>
+44 -9
View File
@@ -1,10 +1,12 @@
import { useMemo, useState } from 'react'
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material'
import { Bell } from 'lucide-react'
import { AlertTriangle, Bell } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useNeedProfiles } from '../../hooks/useNeeds'
import { useMatches } from '../../hooks/useMatches'
import { useVisitReportWarnings } from '../../hooks/useVisitAssignments'
import { ROUTES } from '../../lib/constants'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
function loadSeenCounts(): Record<string, number> {
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
@@ -22,6 +24,9 @@ export function NotificationButton() {
const { data: needs = [] } = useNeedProfiles()
const { data: allMatches = [] } = useMatches()
// Bruno warnt über die Glocke, wenn eine Besichtigung in weniger als 24
// Stunden stattfindet und noch kein Bericht angefordert wurde (Runde 4, §9.3.1).
const { data: visitWarnings = [] } = useVisitReportWarnings()
const profilesWithMatches = useMemo(() => {
const active = needs.filter(n =>
@@ -45,7 +50,7 @@ export function NotificationButton() {
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
[profilesWithMatches, seenCounts],
)
const badgeCount = newProfiles.length
const badgeCount = newProfiles.length + visitWarnings.length
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
@@ -67,7 +72,7 @@ export function NotificationButton() {
return (
<>
<IconButton size="small" sx={{ color: '#64748b' }} onClick={handleOpen}>
<IconButton size="small" sx={{ color: DS_SLATE[500] }} onClick={handleOpen}>
<Badge badgeContent={badgeCount || null} color="error">
<Bell size={20} />
</Badge>
@@ -81,7 +86,37 @@ export function NotificationButton() {
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 300, p: 2 } } }}
>
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: '#0f172a' }}>
{visitWarnings.length > 0 && (
<>
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: DS_SLATE[900] }}>
Besichtigungen
</Typography>
{visitWarnings.map(a => (
<Box
key={a.id}
onClick={() => { navigate(ROUTES.SUPPLY.AGENT_BRUNO); handleClose() }}
sx={{
display: 'flex', alignItems: 'flex-start', gap: 1,
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
'&:hover': { bgcolor: DS_SLATE[50] }, transition: 'background 0.1s',
}}
>
<AlertTriangle size={14} color="#b91c1c" style={{ marginTop: 2, flexShrink: 0 }} />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: DS_SLATE[800] }} noWrap>
{a.propertyTitle}
</Typography>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.7rem' }}>
In weniger als 24 h Bericht noch nicht angefordert
</Typography>
</Box>
</Box>
))}
<Divider sx={{ my: 1.25 }} />
</>
)}
<Typography variant="subtitle2" sx={{ mb: 1.25, fontWeight: 700, color: DS_SLATE[900] }}>
Neue Treffer
</Typography>
@@ -98,18 +133,18 @@ export function NotificationButton() {
sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer',
'&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s',
'&:hover': { bgcolor: DS_SLATE[50] }, transition: 'background 0.1s',
}}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: '#1e293b' }} noWrap>
<Typography variant="body2" sx={{ fontSize: '0.825rem', color: DS_SLATE[800] }} noWrap>
{need.companyName}
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>
<Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.7rem' }}>
ab {minScore}%
</Typography>
</Box>
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
<Typography variant="caption" sx={{ color: DS_ACCENT.success.strong, fontWeight: 700, flexShrink: 0, ml: 1.5, fontSize: '0.78rem' }}>
{count} Treffer
</Typography>
</Box>
@@ -117,7 +152,7 @@ export function NotificationButton() {
<Divider sx={{ my: 1.25 }} />
<Box
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }}
sx={{ textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: '#16304d' } }}
sx={{ textAlign: 'center', cursor: 'pointer', color: DS_BRAND.main, fontSize: '0.8rem', fontWeight: 600, py: 0.25, '&:hover': { color: DS_BRAND.hover } }}
>
Alle Suchprofile anzeigen
</Box>
@@ -1,6 +1,7 @@
import { Chip } from '@mui/material'
import { Building2 } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { DS_BG, DS_SLATE } from '../../lib/ds'
export function OrganizationContextBadge() {
const { currentUser } = useSessionStore()
@@ -13,7 +14,7 @@ export function OrganizationContextBadge() {
variant="outlined"
icon={<Building2 size={12} color="#64748b" />}
label={currentUser.organizationName}
sx={{ fontSize: '0.7rem', height: 22, color: '#64748b', borderColor: '#e8e7e4' }}
sx={{ fontSize: '0.7rem', height: 22, color: DS_SLATE[500], borderColor: DS_BG.muted }}
/>
)
}

Some files were not shown because too many files have changed in this diff Show More