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: { languageOptions: {
globals: globals.browser, 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 ──────────────────────────────────────────────── // ── Design Token Enforcement ────────────────────────────────────────────────
// New hardcoded hex colors in components/pages are blocked (error). // 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. // Hex literals allowed before CI blocks the build.
// This is a ratchet — lower it as migration progresses. Never raise it. // This is a ratchet — lower it as migration progresses. Never raise it.
// Baseline after initial token migration (2026-05-24): 1958 // 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 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 { ProtectedRoute } from './components/auth'
import { WorkspaceType } from './domain/enums' import { WorkspaceType } from './domain/enums'
import { useSessionStore } from './stores/sessionStore' 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. // Die Flächensuche ist entfernt — es bleibt ein einziger erreichbarer Einstieg.
const WORKSPACE_HOME: Record<string, string> = { 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 MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
const NewListing = lazy(() => import('./pages/supply/NewListing')) const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings')) const MyListings = lazy(() => import('./pages/supply/MyListings'))
const Besichtigungen = lazy(() => import('./pages/supply/Besichtigungen'))
// Property On — «Teamübersicht» // Property On — «Meine Agenten»
const Teamuebersicht = lazy(() => import('./pages/supply/Teamuebersicht')) const MeineAgenten = lazy(() => import('./pages/supply/MeineAgenten'))
const Personalverwaltung = lazy(() => import('./pages/supply/Personalverwaltung'))
const Bearbeitungsverlauf = lazy(() => import('./pages/supply/Bearbeitungsverlauf')) /** Alter Unterseitenpfad → Reiter der Hauptseite. Hält Lesezeichen am Leben. */
const KanaeleSysteme = lazy(() => import('./pages/supply/KanaeleSysteme')) function TeamSectionRedirect({ section }: { section: string }) {
return <Navigate to={`${ROUTES.SUPPLY.TEAM}?${AGENT_SECTION_PARAM}=${section}`} replace />
}
function App() { function App() {
return ( return (
@@ -52,25 +56,35 @@ function App() {
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}> <Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
<Route path="/supply/dashboard" element={<SupplyDashboard />} /> <Route path="/supply/dashboard" element={<SupplyDashboard />} />
<Route path="/supply/properties" element={<Properties />} /> <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/match-center" element={<MatchCenter />} />
<Route path="/supply/future-availability" element={<FutureAvailability />} /> <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/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} />
{/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4). {/* Die fünf Agentenseiten. Vier behalten den Pfad ihres Vorgängers. */}
Bewusst flache Geschwisterrouten statt <Outlet/>: die App kennt <Route path="/supply/reminder-manager" element={<ReminderManager />} />
keine einzige Feature-Route mit eigenem Outlet, ein Novum hier <Route path="/supply/besichtigungen" element={<Besichtigungen />} />
würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */} <Route path="/supply/my-listings" element={<MyListings />} />
<Route path="/supply/team" element={<Teamuebersicht />} /> <Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/team/personalverwaltung" element={<Personalverwaltung />} /> <Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/team/personalverwaltung/:agentId" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId/:tab" element={<Personalverwaltung />} /> {/* Property On — «Meine Agenten». Die drei Verwaltungsbereiche sind
<Route path="/supply/team/bearbeitungsverlauf" element={<Bearbeitungsverlauf />} /> seit Runde 4 Reiter derselben Seite; die alten Unterseitenpfade
<Route path="/supply/team/bearbeitungsverlauf/:tab" element={<Bearbeitungsverlauf />} /> leiten dorthin um, damit bestehende Lesezeichen nicht brechen. */}
<Route path="/supply/team/kanaele-systeme" element={<KanaeleSysteme />} /> <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> </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 { GenericBadge } from '../shared/GenericBadge'
import type { AIOutputError } from '../../domain/aiOutput' import type { AIOutputError } from '../../domain/aiOutput'
import { DS_ACCENT } from '../../lib/ds'
const ERROR_CONFIG: Record<string, { label: string; color: string }> = { const ERROR_CONFIG: Record<string, { label: string; color: string }> = {
SCHEMA_VALIDATION: { label: 'Schema', color: '#ea580c' }, SCHEMA_VALIDATION: { label: 'Schema', color: DS_ACCENT.warning.strong },
PROVIDER_TIMEOUT: { label: 'Timeout', color: '#c0392b' }, PROVIDER_TIMEOUT: { label: 'Timeout', color: DS_ACCENT.danger.main },
INVALID_JSON: { label: 'JSON', color: '#c0392b' }, INVALID_JSON: { label: 'JSON', color: DS_ACCENT.danger.main },
EMPTY_RESPONSE: { label: 'Leer', color: '#d97706' }, EMPTY_RESPONSE: { label: 'Leer', color: DS_ACCENT.warning.main },
RATE_LIMIT: { label: 'Rate Limit', color: '#7c3aed' }, RATE_LIMIT: { label: 'Rate Limit', color: DS_ACCENT.violet.main },
} }
export function AIErrorBadge({ error }: { error: AIOutputError }) { 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} /> return <GenericBadge label={label} color={color} bold tooltip={error.message} />
} }
@@ -1,5 +1,6 @@
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { Bot, Filter, MousePointer } from 'lucide-react' import { Bot, Filter, MousePointer } from 'lucide-react'
import { DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
context: 'no-outputs' | 'filtered-empty' | 'no-selection' context: 'no-outputs' | 'filtered-empty' | 'no-selection'
@@ -8,19 +9,19 @@ interface Props {
const CONFIG = { const CONFIG = {
'no-outputs': { 'no-outputs': {
icon: Bot, icon: Bot,
color: '#94a3b8', color: DS_SLATE[400],
title: 'Keine AI-Outputs', title: 'Keine AI-Outputs',
desc: 'Es wurden noch keine AI-Outputs generiert.', desc: 'Es wurden noch keine AI-Outputs generiert.',
}, },
'filtered-empty': { 'filtered-empty': {
icon: Filter, icon: Filter,
color: '#94a3b8', color: DS_SLATE[400],
title: 'Keine Ergebnisse', title: 'Keine Ergebnisse',
desc: 'Kein AI-Output entspricht den aktiven Filtern.', desc: 'Kein AI-Output entspricht den aktiven Filtern.',
}, },
'no-selection': { 'no-selection': {
icon: MousePointer, icon: MousePointer,
color: '#94a3b8', color: DS_SLATE[400],
title: 'Output auswählen', title: 'Output auswählen',
desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.', desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.',
}, },
@@ -31,7 +32,7 @@ export function AIMonitoringEmptyState({ context }: Props) {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200, p: 4, textAlign: 'center' }}> <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 }} /> <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> <Typography variant="body2" color="text.secondary" sx={{ maxWidth: 260 }}>{desc}</Typography>
</Box> </Box>
) )
@@ -6,6 +6,7 @@ import { AIErrorBadge } from './AIErrorBadge'
import { AIReviewActionToolbar } from './AIReviewActionToolbar' import { AIReviewActionToolbar } from './AIReviewActionToolbar'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput' import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums' import type { ReviewStatus } from '../../domain/enums'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
const TYPE_LABELS: Record<AIOutputType, string> = { const TYPE_LABELS: Record<AIOutputType, string> = {
NEED_PARSE: 'Bedarf-Parsing', 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 }}> <Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1.5 }}>
{/* Metadata */} {/* Metadata */}
<MetaRow label="Output-ID"> <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} {output.id}
</Typography> </Typography>
</MetaRow> </MetaRow>
<MetaRow label="Erstellt"> <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' })} {new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
</Typography> </Typography>
</MetaRow> </MetaRow>
<MetaRow label="Modell"> <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} {MODEL_LABELS[output.model] ?? output.model}
</Typography> </Typography>
</MetaRow> </MetaRow>
<MetaRow label="Provider"> <MetaRow label="Provider">
<Typography variant="caption" sx={{ color: '#334155', textTransform: 'capitalize' }}> <Typography variant="caption" sx={{ color: DS_SLATE[700], textTransform: 'capitalize' }}>
{output.provider} {output.provider}
</Typography> </Typography>
</MetaRow> </MetaRow>
@@ -104,14 +105,14 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
<PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} /> <PromptVersionBadge promptVersion={output.promptVersion} schemaVersion={output.schemaVersion} />
</MetaRow> </MetaRow>
<MetaRow label="Input-Hash"> <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} {output.inputHash}
</Typography> </Typography>
</MetaRow> </MetaRow>
<MetaRow label="Bezug"> <MetaRow label="Bezug">
<Typography variant="caption" sx={{ color: '#334155' }}> <Typography variant="caption" sx={{ color: DS_SLATE[700] }}>
{ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '} {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} {output.relatedEntityId}
</span> </span>
</Typography> </Typography>
@@ -125,7 +126,7 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
)} )}
{output.costEstimate != null && ( {output.costEstimate != null && (
<MetaRow label="Kostenschätzung"> <MetaRow label="Kostenschätzung">
<Typography variant="caption" sx={{ color: '#334155' }}> <Typography variant="caption" sx={{ color: DS_SLATE[700] }}>
${output.costEstimate.toFixed(4)} ${output.costEstimate.toFixed(4)}
</Typography> </Typography>
</MetaRow> </MetaRow>
@@ -144,7 +145,7 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
<br /> <br />
{output.error.message} {output.error.message}
{output.error.recoverable && ( {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. Wiederholbar kann erneut ausgelöst werden.
</Typography> </Typography>
)} )}
@@ -154,18 +155,18 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
{/* Output preview */} {/* Output preview */}
<Box sx={{ mb: 1.5 }}> <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 Output-Vorschau
</Typography> </Typography>
<Box <Box
sx={{ sx={{
p: 1.25, p: 1.25,
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
borderRadius: 1, borderRadius: 1,
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '0.75rem', fontSize: '0.75rem',
color: '#334155', color: DS_SLATE[700],
lineHeight: 1.6, lineHeight: 1.6,
overflowX: 'auto', overflowX: 'auto',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
@@ -1,15 +1,16 @@
import { GenericBadge } from '../shared/GenericBadge' import { GenericBadge } from '../shared/GenericBadge'
import type { ReviewStatus } from '../../domain/enums' import type { ReviewStatus } from '../../domain/enums'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const CONFIG: Record<ReviewStatus, { label: string; color: string }> = { const CONFIG: Record<ReviewStatus, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' }, UNREVIEWED: { label: 'Ungeprüft', color: DS_SLATE[400] },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' }, IN_REVIEW: { label: 'In Prüfung', color: DS_ACCENT.warning.main },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' }, APPROVED: { label: 'Genehmigt', color: DS_ACCENT.success.main },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' }, REJECTED: { label: 'Abgelehnt', color: DS_ACCENT.danger.main },
FLAGGED: { label: 'Markiert', color: '#ea580c' }, FLAGGED: { label: 'Markiert', color: DS_ACCENT.warning.strong },
} }
export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) { 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} /> return <GenericBadge label={label} color={color} />
} }
@@ -12,6 +12,7 @@ import { PromptVersionBadge } from './PromptVersionBadge'
import { AIErrorBadge } from './AIErrorBadge' import { AIErrorBadge } from './AIErrorBadge'
import { AIMonitoringEmptyState } from './AIMonitoringEmptyState' import { AIMonitoringEmptyState } from './AIMonitoringEmptyState'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput' import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import { DS_SLATE } from '../../lib/ds'
const TYPE_LABELS: Record<AIOutputType, string> = { const TYPE_LABELS: Record<AIOutputType, string> = {
NEED_PARSE: 'Bedarf-Parsing', NEED_PARSE: 'Bedarf-Parsing',
@@ -59,7 +60,7 @@ export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props)
return ( return (
<Table size="small" stickyHeader> <Table size="small" stickyHeader>
<TableHead> <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: 110 }}>Zeitpunkt</TableCell>
<TableCell sx={{ minWidth: 130 }}>Typ</TableCell> <TableCell sx={{ minWidth: 130 }}>Typ</TableCell>
<TableCell sx={{ minWidth: 100 }}>Modell</TableCell> <TableCell sx={{ minWidth: 100 }}>Modell</TableCell>
@@ -108,7 +109,7 @@ export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props)
</Box> </Box>
</TableCell> </TableCell>
<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} {MODEL_SHORT[output.model] ?? output.model}
</Typography> </Typography>
</TableCell> </TableCell>
@@ -2,6 +2,7 @@ import { Box, Button, Tooltip } from '@mui/material'
import { CheckCircle, XCircle, Send, Copy } from 'lucide-react' import { CheckCircle, XCircle, Send, Copy } from 'lucide-react'
import type { AIOutput } from '../../domain/aiOutput' import type { AIOutput } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums' import type { ReviewStatus } from '../../domain/enums'
import { DS_ACCENT, DS_BG, DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
output: AIOutput output: AIOutput
@@ -26,7 +27,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
disabled={isSubmitting} disabled={isSubmitting}
startIcon={<Send size={13} />} startIcon={<Send size={13} />}
onClick={() => onUpdateStatus('IN_REVIEW')} 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 Zur Prüfung
</Button> </Button>
@@ -38,7 +39,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
disabled={isSubmitting} disabled={isSubmitting}
startIcon={<CheckCircle size={13} />} startIcon={<CheckCircle size={13} />}
onClick={() => onUpdateStatus('APPROVED')} 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 Genehmigen
</Button> </Button>
@@ -50,7 +51,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
disabled={isSubmitting} disabled={isSubmitting}
startIcon={<XCircle size={13} />} startIcon={<XCircle size={13} />}
onClick={() => onUpdateStatus('REJECTED')} 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 Ablehnen
</Button> </Button>
@@ -61,7 +62,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu
variant="outlined" variant="outlined"
onClick={onCopyJson} onClick={onCopyJson}
startIcon={<Copy size={13} />} 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 JSON
</Button> </Button>
@@ -1,4 +1,5 @@
import { Box, Tooltip, Typography } from '@mui/material' import { Box, Tooltip, Typography } from '@mui/material'
import { DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
promptVersion: string promptVersion: string
@@ -14,7 +15,7 @@ export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
gap: 0.5, gap: 0.5,
px: 0.75, px: 0.75,
py: 0.2, py: 0.2,
bgcolor: '#f1f5f9', bgcolor: DS_SLATE[100],
borderRadius: 1, borderRadius: 1,
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
cursor: schemaVersion ? 'default' : undefined, cursor: schemaVersion ? 'default' : undefined,
@@ -22,16 +23,16 @@ export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) {
> >
<Typography <Typography
variant="caption" 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} {promptVersion}
</Typography> </Typography>
{schemaVersion && ( {schemaVersion && (
<> <>
<Box sx={{ width: '1px', height: 10, bgcolor: '#cbd5e1', flexShrink: 0 }} /> <Box sx={{ width: '1px', height: 10, bgcolor: DS_SLATE[300], flexShrink: 0 }} />
<Typography <Typography
variant="caption" 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} {schemaVersion}
</Typography> </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 { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react' import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react'
import { useActiveInquiries } from '../../hooks/useInquiries' import { useActiveInquiries } from '../../hooks/useInquiries'
@@ -7,6 +7,7 @@ import { EmptyState } from '../ui'
import { InquiryList } from './InquiryList' import { InquiryList } from './InquiryList'
import { InquiryCardGrid } from './InquiryCardGrid' import { InquiryCardGrid } from './InquiryCardGrid'
import { InquiryDetailPanel } from './InquiryDetailPanel' import { InquiryDetailPanel } from './InquiryDetailPanel'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
type ViewMode = 'list' | 'grid' type ViewMode = 'list' | 'grid'
@@ -26,23 +27,22 @@ interface Props {
export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) { export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) {
const { data: inquiries = [], isLoading } = useActiveInquiries(filters) 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 [view, setView] = useState<ViewMode>(loadViewMode())
const theme = useTheme() const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md')) const isMobile = useMediaQuery(theme.breakpoints.down('md'))
useEffect(() => { // Die Ansichtswahl gehört ins Ereignis, nicht in einen Effekt — sie ändert
if (typeof window !== 'undefined') { // sich nur durch einen Klick, und der weiss selbst, wann er stattfindet.
window.localStorage.setItem(VIEW_STORAGE_KEY, view) function changeView(next: ViewMode) {
} setView(next)
}, [view]) if (typeof window !== 'undefined') window.localStorage.setItem(VIEW_STORAGE_KEY, next)
}
// Auto-select first inquiry on desktop only // Auf grossen Schirmen steht ohne eigene Wahl die erste Anfrage offen —
useEffect(() => { // abgeleitet statt in einem Effekt nachgetragen.
if (!isMobile && !selectedId && inquiries.length > 0) { const selectedId = chosenId ?? (isMobile ? null : inquiries[0]?.id ?? null)
setSelectedId(inquiries[0].id) const setSelectedId = setChosenId
}
}, [inquiries, selectedId, isMobile])
if (!isLoading && inquiries.length === 0) { if (!isLoading && inquiries.length === 0) {
return ( return (
@@ -97,21 +97,21 @@ export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Pr
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <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 Anfragen
</Typography> </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} {inquiries.length}
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Listenansicht"> <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} /> <ListIcon size={16} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Kartenansicht"> <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} /> <LayoutGrid size={16} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -127,7 +127,7 @@ export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Pr
{/* Right column: detail (desktop only) */} {/* Right column: detail (desktop only) */}
{!isMobile && ( {!isMobile && (
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: '#f8fafc' }}> <Box sx={{ flex: 1, overflow: 'hidden', bgcolor: DS_SLATE[50] }}>
{selectedId ? ( {selectedId ? (
<InquiryDetailPanel inquiryId={selectedId} /> <InquiryDetailPanel inquiryId={selectedId} />
) : ( ) : (
@@ -1,6 +1,7 @@
import { Box, Button, CircularProgress, Typography } from '@mui/material' import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { useGenerateOfferEmail } from '../../hooks/useAI' import { useGenerateOfferEmail } from '../../hooks/useAI'
import { DS_ACCENT } from '../../lib/ds'
interface AiOfferEmailButtonProps { interface AiOfferEmailButtonProps {
needTitle: string needTitle: string
@@ -37,9 +38,9 @@ export function AiOfferEmailButton({
disabled={generateOfferEmail.isPending} disabled={generateOfferEmail.isPending}
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
color: '#7c3aed', color: DS_ACCENT.violet.main,
borderColor: '#c4b5fd', borderColor: DS_ACCENT.violet.border,
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#7c3aed' }, '&:hover': { bgcolor: DS_ACCENT.violet.bgAlt, borderColor: DS_ACCENT.violet.main },
}} }}
variant="outlined" variant="outlined"
> >
@@ -1,5 +1,6 @@
import { Box, TextField, Typography } from '@mui/material' import { Box, TextField, Typography } from '@mui/material'
import type { OfferEditableField } from '../../domain/offer' import type { OfferEditableField } from '../../domain/offer'
import { DS_SLATE } from '../../lib/ds'
interface EditableOfferFieldListProps { interface EditableOfferFieldListProps {
fields: OfferEditableField[] fields: OfferEditableField[]
@@ -17,7 +18,7 @@ export function EditableOfferFieldList({ fields, values, onChange }: EditableOff
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
color: '#64748b', color: DS_SLATE[500],
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
textTransform: 'uppercase', textTransform: 'uppercase',
@@ -3,6 +3,7 @@ import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils' import { assetTypeLabel } from './latentNeedUtils'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface EmbeddedPropertyCardProps { interface EmbeddedPropertyCardProps {
property: Property property: Property
@@ -48,7 +49,7 @@ export function EmbeddedPropertyCard({
height: 72, height: 72,
flexShrink: 0, flexShrink: 0,
borderRadius: 1.5, borderRadius: 1.5,
bgcolor: '#e8e7e4', bgcolor: DS_BG.muted,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -62,7 +63,7 @@ export function EmbeddedPropertyCard({
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}> <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} {property.title}
</Typography> </Typography>
<Box <Box
@@ -81,13 +82,13 @@ export function EmbeddedPropertyCard({
{matchScore}% {matchScore}%
</Box> </Box>
</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} /> <MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}> <Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m² {property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography> </Typography>
</Box> </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} {reason}
</Typography> </Typography>
</Box> </Box>
@@ -102,7 +103,7 @@ export function EmbeddedPropertyCard({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
bgcolor: '#fafafa', bgcolor: DS_NEUTRAL.offWhite,
}} }}
> >
<Box <Box
@@ -117,7 +118,7 @@ export function EmbeddedPropertyCard({
disabled={disabled} disabled={disabled}
sx={{ p: 0.5 }} 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 Für Angebot auswählen
</Typography> </Typography>
</Box> </Box>
@@ -130,12 +131,12 @@ export function EmbeddedPropertyCard({
textTransform: 'none', textTransform: 'none',
fontSize: '0.775rem', fontSize: '0.775rem',
fontWeight: 600, fontWeight: 600,
borderColor: '#152642', borderColor: DS_BRAND.main,
color: '#152642', color: DS_BRAND.main,
py: 0.5, py: 0.5,
px: 1.5, px: 1.5,
minWidth: 0, minWidth: 0,
'&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' }, '&:hover': { bgcolor: DS_BRAND.main, color: 'white', borderColor: DS_BRAND.main },
}} }}
> >
Angebot Angebot
+11 -8
View File
@@ -1,7 +1,9 @@
import { Box, Paper, Typography } from '@mui/material' import { Box, Paper, Typography } from '@mui/material'
import { Building2 } from 'lucide-react' import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry' 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 { interface InquiryCardProps {
inquiry: Inquiry inquiry: Inquiry
@@ -10,6 +12,7 @@ interface InquiryCardProps {
} }
export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) { export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
const { propertyTitle } = usePropertyLookup()
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0 const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return ( return (
@@ -39,7 +42,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
variant="body2" variant="body2"
sx={{ sx={{
fontWeight: hasUnread ? 700 : 600, fontWeight: hasUnread ? 700 : 600,
color: '#0f172a', color: DS_SLATE[900],
fontSize: '0.85rem', fontSize: '0.85rem',
lineHeight: 1.3, lineHeight: 1.3,
}} }}
@@ -53,7 +56,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
minWidth: 20, minWidth: 20,
height: 20, height: 20,
borderRadius: '50%', borderRadius: '50%',
bgcolor: '#2563eb', bgcolor: DS_ACCENT.blue.strong,
color: 'white', color: 'white',
fontSize: '0.65rem', fontSize: '0.65rem',
fontWeight: 700, fontWeight: 700,
@@ -73,7 +76,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
variant="body2" variant="body2"
sx={{ sx={{
fontWeight: hasUnread ? 600 : 500, fontWeight: hasUnread ? 600 : 500,
color: '#1e293b', color: DS_SLATE[800],
fontSize: '0.85rem', fontSize: '0.85rem',
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 2, WebkitLineClamp: 2,
@@ -84,19 +87,19 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) {
{inquiry.subject} {inquiry.subject}
</Typography> </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} /> <Building2 size={13} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}> <Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)} {propertyTitle(inquiry.propertyId)}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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)} {formatInquiryDate(inquiry.createdAt)}
</Typography> </Typography>
{inquiry.matchScore !== undefined && ( {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}% {inquiry.matchScore}%
</Typography> </Typography>
)} )}
@@ -4,6 +4,7 @@ import { FileText } from 'lucide-react'
import type { Inquiry, Attachment } from '../../domain/inquiry' import type { Inquiry, Attachment } from '../../domain/inquiry'
import { InquiryMessageBubble } from './InquiryMessageBubble' import { InquiryMessageBubble } from './InquiryMessageBubble'
import { InquiryReplyComposer } from './InquiryReplyComposer' import { InquiryReplyComposer } from './InquiryReplyComposer'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface InquiryChatProps { interface InquiryChatProps {
inquiry: Inquiry inquiry: Inquiry
@@ -40,9 +41,9 @@ export function InquiryChat({
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
fontSize: '0.75rem', fontSize: '0.75rem',
borderColor: '#cbd5e1', borderColor: DS_SLATE[300],
color: '#475569', color: DS_SLATE[600],
'&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' }, '&:hover': { borderColor: DS_BRAND.main, color: DS_BRAND.main, bgcolor: DS_NEUTRAL.paperTint },
}} }}
> >
Angebot erstellen Angebot erstellen
@@ -67,7 +67,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
}} }}
> >
<Box sx={{ flex: 1, minWidth: 0 }}> <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.tenantName}
{inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''}
{inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''} {inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''}
@@ -76,7 +76,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
variant="body1" variant="body1"
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
color: '#0f172a', color: DS_SLATE[900],
fontSize: '0.95rem', fontSize: '0.95rem',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
@@ -92,7 +92,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
variant="outlined" variant="outlined"
startIcon={<ClipboardList size={14} />} startIcon={<ClipboardList size={14} />}
onClick={() => setPreparationOpen(true)} 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 Vorbereitung starten
</Button> </Button>
@@ -105,7 +105,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
px: 2, px: 2,
py: 1, py: 1,
borderBottom: '1px solid #e2e8f0', borderBottom: '1px solid #e2e8f0',
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 1.5, gap: 1.5,
@@ -119,7 +119,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
flexShrink: 0, flexShrink: 0,
bgcolor: '#e8e7e4', bgcolor: DS_BG.muted,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -138,11 +138,11 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
</Box> </Box>
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography <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 ?? '—'} {property?.title ?? '—'}
</Typography> </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 }} /> <MapPin size={11} style={{ flexShrink: 0 }} />
<Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <Typography variant="caption" sx={{ fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{property?.location.city} {property?.location.city}
@@ -155,7 +155,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) {
size="small" size="small"
endIcon={<ArrowRight size={12} />} endIcon={<ArrowRight size={12} />}
onClick={() => navigate('/supply/properties')} 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 Objekt ansehen
</Button> </Button>
@@ -220,6 +220,7 @@ const OfferCreationWizardComponent = lazy(() =>
) )
import type { Inquiry } from '../../domain/inquiry' 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 }) { function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) {
return ( return (
@@ -1,7 +1,9 @@
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { Building2 } from 'lucide-react' import { Building2 } from 'lucide-react'
import type { Inquiry } from '../../domain/inquiry' 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 { interface InquiryListRowProps {
inquiry: Inquiry inquiry: Inquiry
@@ -10,6 +12,7 @@ interface InquiryListRowProps {
} }
export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) { export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) {
const { propertyTitle } = usePropertyLookup()
const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0 const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0
return ( return (
@@ -40,7 +43,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
minWidth: 18, minWidth: 18,
height: 18, height: 18,
borderRadius: '50%', borderRadius: '50%',
bgcolor: '#2563eb', bgcolor: DS_ACCENT.blue.strong,
color: 'white', color: 'white',
fontSize: '0.6rem', fontSize: '0.6rem',
fontWeight: 700, fontWeight: 700,
@@ -59,7 +62,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
variant="body2" variant="body2"
sx={{ sx={{
fontWeight: hasUnread ? 600 : 500, fontWeight: hasUnread ? 600 : 500,
color: '#1e293b', color: DS_SLATE[800],
fontSize: '0.8125rem', fontSize: '0.8125rem',
mb: 0.5, mb: 0.5,
overflow: 'hidden', overflow: 'hidden',
@@ -69,18 +72,18 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro
> >
{inquiry.subject} {inquiry.subject}
</Typography> </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} /> <Building2 size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}> <Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{propertyLabelFromId(inquiry.propertyId)} {propertyTitle(inquiry.propertyId)}
</Typography> </Typography>
{inquiry.matchScore !== undefined && ( {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}% {inquiry.matchScore}%
</Typography> </Typography>
)} )}
</Box> </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)} {formatInquiryDate(inquiry.createdAt)}
</Typography> </Typography>
</Box> </Box>
@@ -2,6 +2,7 @@ import { Box, Typography } from '@mui/material'
import { Paperclip } from 'lucide-react' import { Paperclip } from 'lucide-react'
import type { InquiryMessage } from '../../domain/inquiry' import type { InquiryMessage } from '../../domain/inquiry'
import { formatInquiryDate, formatFileSize } from './inquiryUtils' import { formatInquiryDate, formatFileSize } from './inquiryUtils'
import { DS_SLATE } from '../../lib/ds'
interface InquiryMessageBubbleProps { interface InquiryMessageBubbleProps {
message: InquiryMessage message: InquiryMessage
@@ -18,9 +19,9 @@ export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) {
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
color: '#64748b', color: DS_SLATE[500],
fontStyle: 'italic', fontStyle: 'italic',
bgcolor: '#f1f5f9', bgcolor: DS_SLATE[100],
px: 2, px: 2,
py: 0.5, py: 0.5,
borderRadius: 4, borderRadius: 4,
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useMemo, useState } from 'react'
import { import {
Box, Box,
Button, Button,
@@ -12,6 +12,7 @@ import { useSendInquiryReply } from '../../hooks/useInquiries'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import type { Attachment } from '../../domain/inquiry' import type { Attachment } from '../../domain/inquiry'
import { formatFileSize } from './inquiryUtils' import { formatFileSize } from './inquiryUtils'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface InquiryReplyComposerProps { interface InquiryReplyComposerProps {
inquiryId: string inquiryId: string
@@ -34,21 +35,24 @@ export function InquiryReplyComposer({
defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`, defaultSubject.startsWith('Re:') ? defaultSubject : `Re: ${defaultSubject}`,
) )
const [body, setBody] = useState('') const [body, setBody] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([]) const [ownAttachments, setOwnAttachments] = useState<Attachment[]>([])
const [isAIDraft, setIsAIDraft] = useState(false) const [isAIDraft, setIsAIDraft] = useState(false)
const sendReply = useSendInquiryReply() const sendReply = useSendInquiryReply()
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
useEffect(() => { /**
if (pendingAttachment) { * Der übergebene Anhang wird nur noch angezeigt, nicht mehr in den eigenen
setAttachments(prev => { * Zustand kopiert. Vorher tat das ein Effekt — und weil er beim Rendern
if (prev.find(a => a.id === pendingAttachment.id)) return prev * `setState` rief, lief jeder Anhang durch zwei Renderdurchgänge, in deren
return [...prev, pendingAttachment] * erstem er noch fehlte. Abgemeldet wird er jetzt dort, wo er tatsächlich
}) * verbraucht ist: beim Entfernen oder beim Senden.
onPendingAttachmentConsumed?.() */
} const attachments = useMemo(() => {
}, [pendingAttachment, onPendingAttachmentConsumed]) if (!pendingAttachment) return ownAttachments
if (ownAttachments.some(a => a.id === pendingAttachment.id)) return ownAttachments
return [...ownAttachments, pendingAttachment]
}, [ownAttachments, pendingAttachment])
const sending = sendReply.isPending const sending = sendReply.isPending
@@ -62,7 +66,7 @@ export function InquiryReplyComposer({
const handleAddMockAttachment = () => { const handleAddMockAttachment = () => {
const name = `Anhang_${attachments.length + 1}.pdf` const name = `Anhang_${attachments.length + 1}.pdf`
setAttachments(prev => [ setOwnAttachments(prev => [
...prev, ...prev,
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -74,7 +78,8 @@ export function InquiryReplyComposer({
} }
const handleRemoveAttachment = (id: string) => { 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 () => { const handleSend = async () => {
@@ -92,7 +97,8 @@ export function InquiryReplyComposer({
} }
showToast('Antwort gesendet', 'success') showToast('Antwort gesendet', 'success')
setBody('') setBody('')
setAttachments([]) setOwnAttachments([])
onPendingAttachmentConsumed?.()
setIsAIDraft(false) setIsAIDraft(false)
onSent?.() onSent?.()
} }
@@ -101,7 +107,7 @@ export function InquiryReplyComposer({
<Box <Box
sx={{ sx={{
borderTop: '1px solid #e2e8f0', borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
p: 2, p: 2,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -119,7 +125,7 @@ export function InquiryReplyComposer({
{isAIDraft && ( {isAIDraft && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Sparkles size={12} color="#7c3aed" /> <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 KI-Entwurf bitte prüfen und anpassen
</Typography> </Typography>
</Box> </Box>
@@ -134,7 +140,7 @@ export function InquiryReplyComposer({
rows={7} rows={7}
placeholder="Antwort verfassen..." placeholder="Antwort verfassen..."
fullWidth 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 && ( {attachments.length > 0 && (
@@ -160,7 +166,7 @@ export function InquiryReplyComposer({
{a.fileName} {a.fileName}
</Typography> </Typography>
{a.fileSize && ( {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)} {formatFileSize(a.fileSize)}
</Typography> </Typography>
)} )}
@@ -181,9 +187,9 @@ export function InquiryReplyComposer({
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
fontSize: '0.75rem', fontSize: '0.75rem',
color: '#7c3aed', color: DS_ACCENT.violet.main,
borderColor: '#ddd6fe', borderColor: DS_ACCENT.violet.borderSoft,
'&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' }, '&:hover': { bgcolor: DS_ACCENT.violet.bgAlt, borderColor: DS_ACCENT.violet.border },
}} }}
variant="outlined" variant="outlined"
> >
@@ -206,7 +212,7 @@ export function InquiryReplyComposer({
} }
onClick={handleSend} onClick={handleSend}
disabled={sending || !body.trim()} 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 Antwort senden
</Button> </Button>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material' import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Sparkles } from 'lucide-react' import { ArrowLeft, Sparkles } from 'lucide-react'
import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds' import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds'
@@ -6,23 +6,25 @@ import { EmptyState } from '../ui'
import { PublicNeedList } from './PublicNeedList' import { PublicNeedList } from './PublicNeedList'
import { PublicNeedDetail } from './PublicNeedDetail' import { PublicNeedDetail } from './PublicNeedDetail'
import { OwnPropertyMatchList } from './OwnPropertyMatchList' import { OwnPropertyMatchList } from './OwnPropertyMatchList'
import { DS_BRAND } from '../../lib/ds'
type MobileView = 'list' | 'detail' | 'properties' type MobileView = 'list' | 'detail' | 'properties'
export function LatentInquiriesTab() { export function LatentInquiriesTab() {
const { data: needs = [] } = usePublicNeeds() const { data: needs = [] } = usePublicNeeds()
const [selectedNeedId, setSelectedNeedId] = useState<string | null>(null)
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
const theme = useTheme() const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md')) const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg')) const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg'))
const [mobileView, setMobileView] = useState<MobileView>('list') const [mobileView, setMobileView] = useState<MobileView>('list')
useEffect(() => { // Ohne eigene Wahl steht auf grossen Schirmen der erste Eintrag offen —
if (!isMobile && !selectedNeedId && needs.length > 0) { // abgeleitet statt in einem Effekt nachgetragen. Der Effekt löste einen
setSelectedNeedId(needs[0].id) // zweiten Renderdurchgang aus, in dem die Detailspalte noch leer war.
} const [chosenNeedId, setChosenNeedId] = useState<string | null>(null)
}, [needs, selectedNeedId, isMobile]) const selectedNeedId = chosenNeedId ?? (isMobile ? null : needs[0]?.id ?? null)
const setSelectedNeedId = setChosenNeedId
const { data: selectedNeed } = useLatentNeedById(selectedNeedId)
const handleSelectNeed = (id: string) => { const handleSelectNeed = (id: string) => {
setSelectedNeedId(id) setSelectedNeedId(id)
@@ -47,7 +49,7 @@ export function LatentInquiriesTab() {
<Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}> <Box sx={{ px: 2, py: 1.5, borderTop: '1px solid #e2e8f0', flexShrink: 0 }}>
<Box <Box
onClick={() => setMobileView('properties')} 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 Eigene Objekte anzeigen
</Box> </Box>
@@ -1,9 +1,10 @@
import { Box, Divider, Typography } from '@mui/material' import { Box, Divider, Typography } from '@mui/material'
import { Building2, FileText, MapPin } from 'lucide-react' import { Building2, FileText, MapPin } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore' 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 { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface MockPdfPreviewProps { interface MockPdfPreviewProps {
fields: Record<string, string> fields: Record<string, string>
@@ -79,7 +80,8 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
const needTitle = useOfferWizardStore(s => s.needTitle) const needTitle = useOfferWizardStore(s => s.needTitle)
const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds) 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 hasPropertyPage = fieldSelections.length > 0 && properties.length > 0
const v = (id: string) => fields[id] ?? fallbackFields[id] ?? '' const v = (id: string) => fields[id] ?? fallbackFields[id] ?? ''
@@ -98,21 +100,21 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
}} }}
> >
{/* ── Page 1 — Offer letter ── */} {/* ── 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} /> <FileText size={16} />
<Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}> <Typography variant="caption" sx={{ textTransform: 'uppercase', letterSpacing: 1, fontSize: '0.7rem' }}>
Angebotsvorschau (PDF) Angebotsvorschau (PDF)
</Typography> </Typography>
</Box> </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 Wincasa AG · Zürich
</Typography> </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')} {new Date().toLocaleDateString('de-CH')}
</Typography> </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} Angebot: {needTitle}
</Typography> </Typography>
@@ -146,7 +148,7 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
</Typography> </Typography>
))} ))}
{properties.length === 0 && ( {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. Keine Objekte ausgewählt.
</Typography> </Typography>
)} )}
@@ -164,11 +166,11 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
{hasPropertyPage && ( {hasPropertyPage && (
<> <>
<Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ my: 3, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} /> <Divider sx={{ flex: 1, borderColor: DS_BRAND.main, borderWidth: 1 }} />
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}> <Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.68rem', whiteSpace: 'nowrap', letterSpacing: 0.5, textTransform: 'uppercase' }}>
Seite 2 Objektdetails Seite 2 Objektdetails
</Typography> </Typography>
<Divider sx={{ flex: 1, borderColor: '#152642', borderWidth: 1 }} /> <Divider sx={{ flex: 1, borderColor: DS_BRAND.main, borderWidth: 1 }} />
</Box> </Box>
<Box <Box
@@ -203,19 +205,19 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
sx={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }} 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" /> <Building2 size={22} color="#94a3b8" />
</Box> </Box>
)} )}
{/* Info */} {/* Info */}
<Box sx={{ p: 1.25, flex: 1 }}> <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} {p.title}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, mb: 1 }}>
<MapPin size={10} color="#94a3b8" /> <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}` : ''} {p.location.city}{p.location.district ? ` · ${p.location.district}` : ''}
</Typography> </Typography>
</Box> </Box>
@@ -228,10 +230,10 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock
if (!val) return null if (!val) return null
return ( return (
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', gap: 0.5 }}> <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} {FIELD_LABELS[key] ?? key}
</Typography> </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} {val}
</Typography> </Typography>
</Box> </Box>
@@ -7,9 +7,10 @@ import { useLatentNeedById } from '../../hooks/useLatentNeeds'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { AiOfferEmailButton } from './AiOfferEmailButton' import { AiOfferEmailButton } from './AiOfferEmailButton'
import { mockProperties } from '../../mock-data/properties' import { usePropertyLookup } from '../../hooks/usePropertyLookup'
import { deterministicMatchScore } from './latentNeedUtils' import { deterministicMatchScore } from './latentNeedUtils'
import { formatFileSize } from './inquiryUtils' import { formatFileSize } from './inquiryUtils'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export function OfferChatComposer() { export function OfferChatComposer() {
const subject = useOfferWizardStore(s => s.messageSubject) const subject = useOfferWizardStore(s => s.messageSubject)
@@ -32,10 +33,8 @@ export function OfferChatComposer() {
const currentUser = useSessionStore(s => s.currentUser) const currentUser = useSessionStore(s => s.currentUser)
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const propertyTitles = selectedPropertyIds.map(id => { const { propertyTitle } = usePropertyLookup()
const p = mockProperties.find(pp => pp.id === id) const propertyTitles = selectedPropertyIds.map(propertyTitle)
return p?.title ?? id
})
const scores = needId const scores = needId
? selectedPropertyIds.map(id => deterministicMatchScore(id, needId)) ? selectedPropertyIds.map(id => deterministicMatchScore(id, needId))
: [] : []
@@ -85,13 +84,13 @@ export function OfferChatComposer() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <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 sx={{ maxWidth: 720, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box> <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 Empfänger-Kontext
</Typography> </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 Bedarf: <strong>{needTitle}</strong> · {selectedPropertyIds.length} Objekt
{selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot {selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot
</Typography> </Typography>
@@ -117,7 +116,7 @@ export function OfferChatComposer() {
{attachments.length > 0 && ( {attachments.length > 0 && (
<Box> <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 Anhänge
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
@@ -144,8 +143,8 @@ export function OfferChatComposer() {
sx={{ sx={{
px: 0.75, px: 0.75,
py: 0.125, py: 0.125,
bgcolor: '#e0e7ff', bgcolor: DS_ACCENT.indigo.border,
color: '#3730a3', color: DS_ACCENT.indigo.dark,
fontWeight: 700, fontWeight: 700,
fontSize: '0.65rem', fontSize: '0.65rem',
borderRadius: 1, borderRadius: 1,
@@ -154,7 +153,7 @@ export function OfferChatComposer() {
PDF PDF
</Box> </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)} {formatFileSize(a.fileSize)}
</Typography> </Typography>
<IconButton size="small" onClick={() => removeAttachment(a.id)} sx={{ p: 0.25 }}> <IconButton size="small" onClick={() => removeAttachment(a.id)} sx={{ p: 0.25 }}>
@@ -206,9 +205,9 @@ export function OfferChatComposer() {
disabled={sendOffer.isPending || !body.trim()} disabled={sendOffer.isPending || !body.trim()}
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
bgcolor: '#16a34a', bgcolor: DS_ACCENT.success.bright,
fontWeight: 600, fontWeight: 600,
'&:hover': { bgcolor: '#15803d' }, '&:hover': { bgcolor: DS_ACCENT.success.strong },
}} }}
> >
Absenden Absenden
@@ -3,6 +3,7 @@ import { CheckCircle2 } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useMarkOfferChecked } from '../../hooks/useOffers' import { useMarkOfferChecked } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export function OfferCheckedAction() { export function OfferCheckedAction() {
const offerDraftId = useOfferWizardStore(s => s.offerDraftId) const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
@@ -45,7 +46,7 @@ export function OfferCheckedAction() {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
p: 4, p: 4,
textAlign: 'center', textAlign: 'center',
}} }}
@@ -55,7 +56,7 @@ export function OfferCheckedAction() {
width: 96, width: 96,
height: 96, height: 96,
borderRadius: '50%', borderRadius: '50%',
bgcolor: '#dcfce7', bgcolor: DS_ACCENT.success.bgAlt,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -64,10 +65,10 @@ export function OfferCheckedAction() {
> >
<CheckCircle2 size={48} color="#16a34a" /> <CheckCircle2 size={48} color="#16a34a" />
</Box> </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 Bereit zur Prüfung
</Typography> </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 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. vorbereitet, damit Sie es direkt versenden können.
</Typography> </Typography>
@@ -81,12 +82,12 @@ export function OfferCheckedAction() {
disabled={markChecked.isPending} disabled={markChecked.isPending}
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
bgcolor: '#16a34a', bgcolor: DS_ACCENT.success.bright,
fontSize: '1rem', fontSize: '1rem',
fontWeight: 700, fontWeight: 700,
px: 4, px: 4,
py: 1.5, py: 1.5,
'&:hover': { bgcolor: '#15803d' }, '&:hover': { bgcolor: DS_ACCENT.success.strong },
}} }}
> >
ANGEBOT GEPRÜFT ANGEBOT GEPRÜFT
@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/material' import { Box, Button, Typography } from '@mui/material'
import { FileText } from 'lucide-react' import { FileText } from 'lucide-react'
import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface OfferCreationPanelProps { interface OfferCreationPanelProps {
selectedCount: number selectedCount: number
@@ -16,7 +17,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
<Box <Box
sx={{ sx={{
borderTop: '1px solid #e2e8f0', borderTop: '1px solid #e2e8f0',
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
p: 1.5, p: 1.5,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -24,7 +25,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
flexShrink: 0, 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 {selectedCount} Objekt{selectedCount === 1 ? '' : 'e'} ausgewählt
</Typography> </Typography>
<Button <Button
@@ -35,9 +36,9 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr
onClick={() => open(needId, needTitle)} onClick={() => open(needId, needTitle)}
sx={{ sx={{
textTransform: 'none', textTransform: 'none',
bgcolor: '#152642', bgcolor: DS_BRAND.main,
fontWeight: 600, fontWeight: 600,
'&:hover': { bgcolor: '#16304d' }, '&:hover': { bgcolor: DS_BRAND.hover },
}} }}
> >
Angebot erstellen Angebot erstellen
@@ -10,6 +10,7 @@ import { useCreateOfferReport, useUpdateOfferReport, useGenerateOfferReportPdf }
import { usePropertyById } from '../../hooks/useProperties' import { usePropertyById } from '../../hooks/useProperties'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { OfferWizardPdfStep } from './OfferWizardPdfStep' import { OfferWizardPdfStep } from './OfferWizardPdfStep'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
inquiryId: string 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 }} /> <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="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 {property.location.city} · {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography> </Typography>
</Box> </Box>
@@ -146,7 +147,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
</Box> </Box>
</Box> </Box>
<Box sx={{ flex: 1 }}> <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. Das Angebot wird auf Basis der Objekt- und Anfragedaten vorausgefüllt.
Im nächsten Schritt können Sie alle Felder anpassen. Im nächsten Schritt können Sie alle Felder anpassen.
</Typography> </Typography>
@@ -175,7 +176,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
{/* Step 2: Viewing appointments */} {/* Step 2: Viewing appointments */}
{!loading && step === 2 && draft && ( {!loading && step === 2 && draft && (
<Box> <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: Fügen Sie Besichtigungstermine hinzu, die dem Interessenten angeboten werden sollen:
</Typography> </Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}> <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)} onChange={e => updateAppointment(a.id, 'contactPerson', e.target.value)}
sx={{ flex: 1 }} 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} /> <Trash2 size={16} />
</IconButton> </IconButton>
</Box> </Box>
@@ -241,7 +242,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
{/* Footer */} {/* Footer */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}> <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 Abbrechen
</Button> </Button>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
@@ -255,7 +256,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
variant="contained" variant="contained"
disabled={loading} disabled={loading}
onClick={() => setStep(1)} onClick={() => setStep(1)}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
> >
Weiter Weiter
</Button> </Button>
@@ -264,7 +265,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button <Button
variant="contained" variant="contained"
onClick={() => setStep(2)} onClick={() => setStep(2)}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
> >
Weiter Weiter
</Button> </Button>
@@ -273,7 +274,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose
<Button <Button
variant="contained" variant="contained"
onClick={handleGeneratePdf} onClick={handleGeneratePdf}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
> >
PDF erstellen PDF erstellen
</Button> </Button>
@@ -5,6 +5,7 @@ import { useOfferWizardStore } from '../../stores/offerWizardStore'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { ReportObjectFieldSelector } from './ReportObjectFieldSelector' import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport' import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate'] const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate']
const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images'] const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images']
@@ -41,7 +42,7 @@ export function OfferFieldSelectionStep() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<Box sx={{ flex: 1, overflowY: 'auto', p: 3 }}> <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. Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen.
Pflichtfelder (Titel, Standort, Foto) sind immer enthalten. Pflichtfelder (Titel, Standort, Foto) sind immer enthalten.
</Typography> </Typography>
@@ -77,7 +78,7 @@ export function OfferFieldSelectionStep() {
<Button <Button
variant="contained" variant="contained"
onClick={() => setStep('pdf_review')} 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 Weiter
</Button> </Button>
@@ -7,6 +7,7 @@ import { useToastStore } from '../../stores/toastStore'
import { MockPdfPreview } from './MockPdfPreview' import { MockPdfPreview } from './MockPdfPreview'
import { EditableOfferFieldList } from './EditableOfferFieldList' import { EditableOfferFieldList } from './EditableOfferFieldList'
import type { OfferDraft, OfferEditableField } from '../../domain/offer' import type { OfferDraft, OfferEditableField } from '../../domain/offer'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
export function OfferPdfReviewStep() { export function OfferPdfReviewStep() {
const offerDraftId = useOfferWizardStore(s => s.offerDraftId) const offerDraftId = useOfferWizardStore(s => s.offerDraftId)
@@ -108,7 +109,7 @@ export function OfferPdfReviewStep() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <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' }}> <Box sx={{ flex: 1.4, p: 2, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{!pdfReady ? ( {!pdfReady ? (
<Box <Box
@@ -145,7 +146,7 @@ export function OfferPdfReviewStep() {
overflowY: 'auto', 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 Inhalte bearbeiten
</Typography> </Typography>
<EditableOfferFieldList <EditableOfferFieldList
@@ -179,7 +180,7 @@ export function OfferPdfReviewStep() {
endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />} endIcon={markChecked.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <Send size={14} />}
onClick={handleNext} onClick={handleNext}
disabled={!pdfReady || markChecked.isPending} 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 →'} {markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'}
</Button> </Button>
@@ -9,6 +9,7 @@ import { useCreateOfferDraft } from '../../hooks/useOffers'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard' import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils' import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string { function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string {
if (propertyAssetType === needAssetType) { if (propertyAssetType === needAssetType) {
@@ -87,24 +88,24 @@ export function OfferPropertySelectionStep() {
<Box <Box
sx={{ sx={{
p: 2.5, p: 2.5,
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
borderBottom: '1px solid #e2e8f0', borderBottom: '1px solid #e2e8f0',
flexShrink: 0, 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 Bedarf
</Typography> </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} {need.title}
</Typography> </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² {assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}{need.sizeRange.max} m²
</Typography> </Typography>
</Box> </Box>
<Box sx={{ flex: 1, overflowY: 'auto', p: 2 }}> <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 Wählen Sie passende Objekte aus Ihrem Portfolio
</Typography> </Typography>
{isLoading && <CircularProgress size={20} />} {isLoading && <CircularProgress size={20} />}
@@ -133,7 +134,7 @@ export function OfferPropertySelectionStep() {
flexShrink: 0, 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 {selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt
</Typography> </Typography>
<Button <Button
@@ -141,7 +142,7 @@ export function OfferPropertySelectionStep() {
endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />} endIcon={createDraft.isPending ? <CircularProgress size={14} sx={{ color: 'white' }} /> : <ArrowRight size={14} />}
onClick={handleNext} onClick={handleNext}
disabled={selectedIds.length === 0 || createDraft.isPending} 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 Weiter
</Button> </Button>
@@ -16,6 +16,7 @@ import { OfferFieldSelectionStep } from './OfferFieldSelectionStep'
import { OfferPdfReviewStep } from './OfferPdfReviewStep' import { OfferPdfReviewStep } from './OfferPdfReviewStep'
import { OfferCheckedAction } from './OfferCheckedAction' import { OfferCheckedAction } from './OfferCheckedAction'
import { OfferChatComposer } from './OfferChatComposer' import { OfferChatComposer } from './OfferChatComposer'
import { DS_SLATE } from '../../lib/ds'
// 'checked' is internal — merged into pdf_review step; 4 visible steps // 'checked' is internal — merged into pdf_review step; 4 visible steps
const STEPS: { key: OfferStep; label: string }[] = [ const STEPS: { key: OfferStep; label: string }[] = [
@@ -75,14 +76,14 @@ export function OfferWizard() {
}} }}
> >
<Box sx={{ minWidth: 0 }}> <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 Angebot erstellen
</Typography> </Typography>
<Typography <Typography
variant="body1" variant="body1"
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
color: '#0f172a', color: DS_SLATE[900],
fontSize: '0.95rem', fontSize: '0.95rem',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
@@ -98,7 +99,7 @@ export function OfferWizard() {
</Box> </Box>
{/* Stepper */} {/* 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> <Stepper activeStep={activeIndex} alternativeLabel>
{STEPS.map(s => ( {STEPS.map(s => (
<Step key={s.key}> <Step key={s.key}>
@@ -2,6 +2,7 @@ import { Box, Button, CircularProgress, LinearProgress, Typography } from '@mui/
import { Download, Send } from 'lucide-react' import { Download, Send } from 'lucide-react'
import type { OfferReportDraft } from '../../domain/offerReport' import type { OfferReportDraft } from '../../domain/offerReport'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface OfferWizardPdfStepProps { interface OfferWizardPdfStepProps {
generating: boolean 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 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? ( {generating ? (
<> <>
<CircularProgress size={48} sx={{ color: '#152642' }} /> <CircularProgress size={48} sx={{ color: DS_BRAND.main }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}> <Typography variant="body1" sx={{ fontWeight: 600, color: DS_BRAND.main }}>
PDF wird generiert PDF wird generiert
</Typography> </Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}> <Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} /> <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)}% {Math.round(progress)}%
</Typography> </Typography>
</Box> </Box>
@@ -36,7 +37,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
width: '100%', width: '100%',
maxWidth: 520, maxWidth: 520,
height: 380, height: 380,
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
borderRadius: 1.5, borderRadius: 1.5,
display: 'flex', display: 'flex',
@@ -44,33 +45,33 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
<Box sx={{ bgcolor: '#152642', px: 2, py: 1, display: 'flex', alignItems: 'center', gap: 1 }}> <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: '#ef4444' }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: DS_ACCENT.danger.bright }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#f59e0b' }} /> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: DS_ACCENT.warning.amber }} />
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#10b981' }} /> <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' }}> <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', ml: 1, fontSize: '0.7rem' }}>
Angebot_{property?.title ?? 'Objekt'}.pdf Angebot_{property?.title ?? 'Objekt'}.pdf
</Typography> </Typography>
</Box> </Box>
<Box sx={{ flex: 1, p: 3 }}> <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 Angebotsschreiben
</Typography> </Typography>
{draft?.editableFields.slice(0, 3).map(f => ( {draft?.editableFields.slice(0, 3).map(f => (
<Box key={f.id} sx={{ mb: 1 }}> <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: DS_SLATE[400], 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_NEUTRAL.graphite, fontSize: '0.75rem', display: 'block' }}>
{f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''} {f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''}
</Typography> </Typography>
</Box> </Box>
))} ))}
{(draft?.viewingAppointments.length ?? 0) > 0 && ( {(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 }}> <Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', display: 'block', mb: 0.5 }}>
Besichtigungstermine Besichtigungstermine
</Typography> </Typography>
{draft!.viewingAppointments.map(a => ( {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} {new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot}
</Typography> </Typography>
))} ))}
@@ -83,7 +84,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert
variant="contained" variant="contained"
startIcon={<Download size={16} />} startIcon={<Download size={16} />}
onClick={onDownload} onClick={onDownload}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
> >
Herunterladen Herunterladen
</Button> </Button>
@@ -8,6 +8,7 @@ import { deterministicMatchScore } from './latentNeedUtils'
import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard' import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard'
import { OfferCreationPanel } from './OfferCreationPanel' import { OfferCreationPanel } from './OfferCreationPanel'
import type { LatentNeed } from '../../domain/latentNeed' import type { LatentNeed } from '../../domain/latentNeed'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface OwnPropertyMatchListProps { interface OwnPropertyMatchListProps {
need: LatentNeed need: LatentNeed
@@ -63,7 +64,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
}} }}
> >
<Target size={14} color="#152642" /> <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 Eigene Objekte
</Typography> </Typography>
<Box <Box
@@ -72,7 +73,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
px: 1, px: 1,
py: 0.125, py: 0.125,
borderRadius: 1, borderRadius: 1,
bgcolor: '#152642', bgcolor: DS_BRAND.main,
color: 'white', color: 'white',
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
@@ -89,7 +90,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) {
</Box> </Box>
)} )}
{!isLoading && scored.length === 0 && ( {!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 Keine Portfolio-Objekte vorhanden
</Typography> </Typography>
)} )}
@@ -13,6 +13,7 @@ import { ReportObjectFieldSelector } from './ReportObjectFieldSelector'
import { LatentInquiryReportPreview } from './LatentInquiryReportPreview' import { LatentInquiryReportPreview } from './LatentInquiryReportPreview'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { ResultType } from '../../domain/enums' import { ResultType } from '../../domain/enums'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
inquiryId: string inquiryId: string
@@ -126,7 +127,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
{/* Step 0: Select properties */} {/* Step 0: Select properties */}
{step === 0 && ( {step === 0 && (
<Box> <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: Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten:
</Typography> </Typography>
<Stack spacing={1}> <Stack spacing={1}>
@@ -142,7 +143,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
label={ label={
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{p.title}</Typography> <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 {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr
</Typography> </Typography>
</Box> </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 }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 3 }}>
{generating ? ( {generating ? (
<> <>
<CircularProgress size={48} sx={{ color: '#152642' }} /> <CircularProgress size={48} sx={{ color: DS_BRAND.main }} />
<Typography variant="body1" sx={{ fontWeight: 600, color: '#152642' }}> <Typography variant="body1" sx={{ fontWeight: 600, color: DS_BRAND.main }}>
Bericht wird erstellt Bericht wird erstellt
</Typography> </Typography>
<Box sx={{ width: '100%', maxWidth: 400 }}> <Box sx={{ width: '100%', maxWidth: 400 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} /> <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)}% {Math.round(progress)}%
</Typography> </Typography>
</Box> </Box>
@@ -234,7 +235,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
fullWidth fullWidth
startIcon={<Download size={16} />} startIcon={<Download size={16} />}
onClick={() => showToast('PDF wird heruntergeladen…', 'info')} 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 Herunterladen
</Button> </Button>
@@ -257,7 +258,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
{/* Footer navigation */} {/* Footer navigation */}
<Box sx={{ px: 3, py: 2, borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', flexShrink: 0 }}> <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 Abbrechen
</Button> </Button>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
@@ -271,7 +272,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
variant="contained" variant="contained"
disabled={selectedIds.length === 0} disabled={selectedIds.length === 0}
onClick={handleGenerate} onClick={handleGenerate}
sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
> >
Weiter Weiter
</Button> </Button>
@@ -282,7 +283,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) {
disabled={finalizing} disabled={finalizing}
onClick={handleFinalize} onClick={handleFinalize}
startIcon={finalizing ? <CircularProgress size={14} sx={{ color: 'white' }} /> : undefined} 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 Finalisieren
</Button> </Button>
@@ -2,6 +2,7 @@ import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react' import { MapPin } from 'lucide-react'
import type { LatentNeed } from '../../domain/latentNeed' import type { LatentNeed } from '../../domain/latentNeed'
import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils' import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface PublicNeedCardProps { interface PublicNeedCardProps {
need: LatentNeed 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' }}> <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} {need.title}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
@@ -53,7 +54,7 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
</Box> </Box>
{need.tenantCompany && ( {need.tenantCompany && (
<Typography variant="caption" sx={{ color: '#64748b', fontSize: '0.75rem' }}> <Typography variant="caption" sx={{ color: DS_SLATE[500], fontSize: '0.75rem' }}>
{need.tenantCompany} {need.tenantCompany}
</Typography> </Typography>
)} )}
@@ -63,15 +64,15 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps)
size="small" size="small"
sx={{ sx={{
alignSelf: 'flex-start', alignSelf: 'flex-start',
bgcolor: '#e0e7ff', bgcolor: DS_ACCENT.indigo.border,
color: '#3730a3', color: DS_ACCENT.indigo.dark,
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
height: 20, 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} /> <MapPin size={12} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}> <Typography variant="caption" sx={{ fontSize: '0.75rem' }}>
{need.desiredLocation} {need.desiredLocation}
@@ -11,6 +11,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useProperties } from '../../hooks/useProperties' import { useProperties } from '../../hooks/useProperties'
import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils' import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils'
import { EmbeddedPropertyCard } from './EmbeddedPropertyCard' import { EmbeddedPropertyCard } from './EmbeddedPropertyCard'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface PublicNeedDetailProps { interface PublicNeedDetailProps {
need: LatentNeed need: LatentNeed
@@ -57,7 +58,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
} }
return ( 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 */} {/* Scrollable content */}
<Box sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}> <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>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Chip label={assetTypeLabel(need.assetType)} size="small" <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" <Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} /> sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box> </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} {need.title}
</Typography> </Typography>
{need.tenantCompany && ( {need.tenantCompany && (
<Typography variant="body2" sx={{ color: '#64748b' }}>{need.tenantCompany}</Typography> <Typography variant="body2" sx={{ color: DS_SLATE[500] }}>{need.tenantCompany}</Typography>
)} )}
</Box> </Box>
{/* KI-Entscheidungsbrief */} {/* KI-Entscheidungsbrief */}
{need.aiSummary && ( {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 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Sparkles size={16} color="#1d4ed8" /> <Sparkles size={16} color="#1d4ed8" />
<Typography variant="caption" <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 KI-Entscheidungsbrief
</Typography> </Typography>
</Box> </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} {need.aiSummary}
</Typography> </Typography>
</Paper> </Paper>
@@ -95,7 +96,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
{/* Suchprofil */} {/* Suchprofil */}
<Box> <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 Suchprofil
</Typography> </Typography>
<Box sx={{ mt: 1, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 1.5 }}> <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 */} {/* Must-haves */}
{need.mustHaveCriteria.length > 0 && ( {need.mustHaveCriteria.length > 0 && (
<Box> <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 Must-have Kriterien
</Typography> </Typography>
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{need.mustHaveCriteria.map((c, idx) => ( {need.mustHaveCriteria.map((c, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" /> <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>
))} ))}
</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' }}> sx={{ border: '1px solid #e2e8f0', borderRadius: '8px !important', overflow: 'hidden', '&:before': { display: 'none' }, bgcolor: 'white' }}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />} <AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}> 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 Gewichtete Präferenzen
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
@@ -144,19 +145,19 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
{need.weightedPreferences.map((p, idx) => { {need.weightedPreferences.map((p, idx) => {
const pct = Math.round(p.weight * 100) const pct = Math.round(p.weight * 100)
return ( 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 }}> <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="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>{p.criterion}</Typography>
<Typography variant="caption" <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}% {pct}%
</Typography> </Typography>
</Box> </Box>
<Box sx={{ height: 6, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}> <Box sx={{ height: 6, borderRadius: 3, bgcolor: DS_BG.muted, overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} /> <Box sx={{ width: `${pct}%`, height: '100%', bgcolor: DS_BRAND.main, transition: 'width 0.3s' }} />
</Box> </Box>
{p.description && ( {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} {p.description}
</Typography> </Typography>
)} )}
@@ -173,14 +174,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
<Divider sx={{ mb: 2.5 }} /> <Divider sx={{ mb: 2.5 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Target size={14} color="#152642" /> <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 Ihre Top Empfehlungen
</Typography> </Typography>
</Box> </Box>
{propertiesLoading ? ( {propertiesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box> <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress size={20} /></Box>
) : scored.length === 0 ? ( ) : 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 Keine Portfolio-Objekte vorhanden
</Typography> </Typography>
) : ( ) : (
@@ -204,12 +205,12 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
py: 1.25, py: 1.25,
textAlign: 'center', textAlign: 'center',
cursor: 'pointer', cursor: 'pointer',
color: '#64748b', color: DS_SLATE[500],
fontSize: '0.8rem', fontSize: '0.8rem',
fontWeight: 500, fontWeight: 500,
border: '1px dashed #e2e8f0', border: '1px dashed #e2e8f0',
borderRadius: 1.5, 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', transition: 'all 0.15s',
}} }}
> >
@@ -229,7 +230,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
</Alert> </Alert>
)} )}
{selectedIds.length > 0 && !disabled && ( {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 {selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt
</Typography> </Typography>
)} )}
@@ -239,7 +240,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
fullWidth fullWidth
onClick={() => openWizard(need.id, need.title)} onClick={() => openWizard(need.id, need.title)}
disabled={disabled} 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 Angebot erstellen
</Button> </Button>
@@ -251,13 +252,13 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) {
function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return ( return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}> <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} {icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}> <Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label} {label}
</Typography> </Typography>
</Box> </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> </Box>
) )
} }
@@ -3,6 +3,7 @@ import { Sparkles } from 'lucide-react'
import { usePublicNeeds } from '../../hooks/useLatentNeeds' import { usePublicNeeds } from '../../hooks/useLatentNeeds'
import { PublicNeedCard } from './PublicNeedCard' import { PublicNeedCard } from './PublicNeedCard'
import { EmptyState } from '../ui' import { EmptyState } from '../ui'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface PublicNeedListProps { interface PublicNeedListProps {
selectedNeedId: string | null selectedNeedId: string | null
@@ -38,7 +39,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
}} }}
> >
<Sparkles size={14} color="#7c3aed" /> <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 Latente Bedarfe
</Typography> </Typography>
<Box <Box
@@ -47,7 +48,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe
px: 1, px: 1,
py: 0.125, py: 0.125,
borderRadius: 1, borderRadius: 1,
bgcolor: '#152642', bgcolor: DS_BRAND.main,
color: 'white', color: 'white',
fontWeight: 600, fontWeight: 600,
fontSize: '0.7rem', fontSize: '0.7rem',
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router'
import { usePropertyById } from '../../hooks/useProperties' import { usePropertyById } from '../../hooks/useProperties'
import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches' import { useAdditionalMatchesForInquiry } from '../../hooks/useMatches'
import type { AdditionalPropertyMatch } from '../../domain/additionalMatch' import type { AdditionalPropertyMatch } from '../../domain/additionalMatch'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface RelatedPropertyCardPanelProps { interface RelatedPropertyCardPanelProps {
propertyId: string 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' }}> <Box sx={{ px: 1.5, pt: 1.25, pb: 1.5, bgcolor: 'white' }}>
<Typography <Typography
variant="caption" 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 Weitere passende Objekte
</Typography> </Typography>
@@ -42,7 +43,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
<CircularProgress size={16} /> <CircularProgress size={16} />
</Box> </Box>
) : additionalMatches.length === 0 ? ( ) : 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 Keine weiteren Matches
</Typography> </Typography>
) : ( ) : (
@@ -53,7 +54,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
overflowX: 'auto', overflowX: 'auto',
pb: 0.5, pb: 0.5,
'&::-webkit-scrollbar': { height: 3 }, '&::-webkit-scrollbar': { height: 3 },
'&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 }, '&::-webkit-scrollbar-thumb': { bgcolor: DS_SLATE[300], borderRadius: 2 },
}} }}
> >
{additionalMatches.map(m => ( {additionalMatches.map(m => (
@@ -85,7 +86,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
gap: 1.5, gap: 1.5,
p: 2, p: 2,
borderLeft: '1px solid #e2e8f0', borderLeft: '1px solid #e2e8f0',
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
height: '100%', height: '100%',
overflowY: 'auto', overflowY: 'auto',
}} }}
@@ -95,7 +96,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
aspectRatio: '3/2', aspectRatio: '3/2',
borderRadius: 1.5, borderRadius: 1.5,
overflow: 'hidden', overflow: 'hidden',
bgcolor: '#f4f3f0', bgcolor: DS_BG.subtle,
}} }}
> >
{image ? ( {image ? (
@@ -108,25 +109,25 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
)} )}
</Box> </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} {property.title}
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}> <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} /> <MapPin size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}> <Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.location.city} {property.location.city}
{property.location.district ? `, ${property.location.district}` : ''} {property.location.district ? `, ${property.location.district}` : ''}
</Typography> </Typography>
</Box> </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} /> <Ruler size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}> <Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
{property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr
</Typography> </Typography>
</Box> </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} /> <Calendar size={14} />
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}> <Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>
Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')} Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')}
@@ -135,7 +136,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
</Box> </Box>
{property.description && ( {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} {property.description}
</Typography> </Typography>
)} )}
@@ -151,14 +152,14 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel
</Button> </Button>
<Divider sx={{ my: 0.5 }} /> <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 Weitere passende Objekte
</Typography> </Typography>
{loadingMatches ? ( {loadingMatches ? (
<CircularProgress size={16} sx={{ alignSelf: 'center' }} /> <CircularProgress size={16} sx={{ alignSelf: 'center' }} />
) : additionalMatches.length === 0 ? ( ) : 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 Keine weiteren Matches
</Typography> </Typography>
) : ( ) : (
@@ -187,13 +188,13 @@ function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
bgcolor: 'white', bgcolor: 'white',
transition: 'border-color 0.15s, box-shadow 0.15s', transition: 'border-color 0.15s, box-shadow 0.15s',
'&:hover': { '&:hover': {
borderColor: '#b0aead', borderColor: DS_NEUTRAL.stone,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)', boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
}, },
}} }}
> >
{/* Image — 16:9 crop, compact supplementary card */} {/* 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 ? ( {match.imageUrl ? (
<Box <Box
component="img" component="img"
@@ -228,19 +229,19 @@ function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) {
<Box sx={{ p: 0.75 }}> <Box sx={{ p: 0.75 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.2 }}>
<Typography <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} {match.title}
</Typography> </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}% {match.matchScore}%
</Typography> </Typography>
</Box> </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} {match.location}
</Typography> </Typography>
{match.reasons[0] && ( {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]} + {match.reasons[0]}
</Typography> </Typography>
)} )}
@@ -260,18 +261,18 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
}} }}
> >
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.25 }}> <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} {match.title}
</Typography> </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}% {match.matchScore}%
</Typography> </Typography>
</Box> </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² {match.location} · {match.areaSqm.toLocaleString('de-CH')} m²
</Typography> </Typography>
{match.reasons[0] && ( {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]} + {match.reasons[0]}
</Typography> </Typography>
)} )}
@@ -279,7 +280,7 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) {
size="small" size="small"
endIcon={<ArrowRight size={10} />} endIcon={<ArrowRight size={10} />}
onClick={() => navigate('/supply/properties')} 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 Ansehen
</Button> </Button>
@@ -1,6 +1,7 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material' import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport' import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport'
import { DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
propertyTitle: string propertyTitle: string
@@ -104,10 +105,10 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
return ( return (
<Box> <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} Felder für: {propertyTitle}
</Typography> </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 Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos
</Typography> </Typography>
@@ -119,7 +120,7 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}> <Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8125rem' }}>
{group.label} {group.label}
</Typography> </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}) ({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length})
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
@@ -3,6 +3,7 @@ import { Building2, MapPin } from 'lucide-react'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme'
import { assetTypeLabel } from './latentNeedUtils' import { assetTypeLabel } from './latentNeedUtils'
import { DS_BG, DS_SLATE } from '../../lib/ds'
interface SelectablePropertyMatchCardProps { interface SelectablePropertyMatchCardProps {
property: Property property: Property
@@ -57,7 +58,7 @@ export function SelectablePropertyMatchCard({
height: 40, height: 40,
flexShrink: 0, flexShrink: 0,
borderRadius: 1, borderRadius: 1,
bgcolor: '#e8e7e4', bgcolor: DS_BG.muted,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -75,7 +76,7 @@ export function SelectablePropertyMatchCard({
variant="body2" variant="body2"
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
color: '#0f172a', color: DS_SLATE[900],
fontSize: '0.8125rem', fontSize: '0.8125rem',
lineHeight: 1.3, lineHeight: 1.3,
overflow: 'hidden', overflow: 'hidden',
@@ -103,13 +104,13 @@ export function SelectablePropertyMatchCard({
{matchScore}% {matchScore}%
</Box> </Box>
</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} /> <MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.7rem' }}> <Typography variant="caption" sx={{ fontSize: '0.7rem' }}>
{property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m² {property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m²
</Typography> </Typography>
</Box> </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} {reason}
</Typography> </Typography>
</Box> </Box>
@@ -1,12 +1,6 @@
import { mockProperties } from '../../mock-data/properties' // Der Objektnachschlag ist nach `hooks/usePropertyLookup.ts` gewandert — er las
// hier direkt aus den Mockdaten und übersprang damit die Datenschicht.
const propertyMap: Record<string, string> = Object.fromEntries( // Übrig bleibt reine Formatierung ohne Datenzugriff.
mockProperties.map(p => [p.id, p.title]),
)
export function propertyLabelFromId(id: string): string {
return propertyMap[id] ?? id
}
export function formatInquiryDate(iso: string): string { export function formatInquiryDate(iso: string): string {
const d = new Date(iso) const d = new Date(iso)
@@ -2,6 +2,7 @@ import { Box, Button, Typography } from '@mui/material'
import { ArrowRight } from 'lucide-react' import { ArrowRight } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import type { AssistantAction } from '../../domain/assistant' import type { AssistantAction } from '../../domain/assistant'
import { DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
actions: AssistantAction[] actions: AssistantAction[]
@@ -32,7 +33,7 @@ export function AssistantActionCards({ actions, onExecute }: Props) {
return ( return (
<Box sx={{ px: 2, pt: 0.75, pb: 0.5 }}> <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 Vorgeschlagene Aktionen
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
@@ -1,5 +1,6 @@
import { Box, Chip, Typography } from '@mui/material' import { Box, Chip, Typography } from '@mui/material'
import type { AssistantContext } from '../../domain/assistant' import type { AssistantContext } from '../../domain/assistant'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const PAGE_LABELS: Record<string, string> = { const PAGE_LABELS: Record<string, string> = {
'/supply/dashboard': 'Übersicht', '/supply/dashboard': 'Übersicht',
@@ -36,18 +37,18 @@ export function AssistantContextSummary({ context }: Props) {
const pageLabel = resolvePageLabel(context.currentRoute) const pageLabel = resolvePageLabel(context.currentRoute)
return ( 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' }}> <Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip <Chip
label={pageLabel} label={pageLabel}
size="small" 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 && ( {context.selectedEntityType && context.selectedEntityId && (
<Chip <Chip
label={`${ENTITY_LABELS[context.selectedEntityType] ?? context.selectedEntityType}: ${context.selectedEntityId}`} label={`${ENTITY_LABELS[context.selectedEntityType] ?? context.selectedEntityType}: ${context.selectedEntityId}`}
size="small" 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 && ( {context.visibleScores?.quality !== undefined && (
@@ -65,7 +66,7 @@ export function AssistantContextSummary({ context }: Props) {
<Chip <Chip
label={`Match: ${context.visibleScores.matchScore}%`} label={`Match: ${context.visibleScores.matchScore}%`}
size="small" 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> </Box>
@@ -1,4 +1,5 @@
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export function AssistantLoadingState() { export function AssistantLoadingState() {
return ( return (
@@ -8,7 +9,7 @@ export function AssistantLoadingState() {
width: 28, width: 28,
height: 28, height: 28,
borderRadius: '50%', borderRadius: '50%',
bgcolor: '#4f46e5', bgcolor: DS_ACCENT.indigo.main,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -19,7 +20,7 @@ export function AssistantLoadingState() {
</Box> </Box>
<Box <Box
sx={{ sx={{
bgcolor: '#f1f5f9', bgcolor: DS_SLATE[100],
borderRadius: '0 8px 8px 8px', borderRadius: '0 8px 8px 8px',
px: 1.5, px: 1.5,
py: 1, py: 1,
@@ -35,7 +36,7 @@ export function AssistantLoadingState() {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: '50%', borderRadius: '50%',
bgcolor: '#94a3b8', bgcolor: DS_SLATE[400],
animation: 'bounce 1.2s ease-in-out infinite', animation: 'bounce 1.2s ease-in-out infinite',
animationDelay: `${i * 0.2}s`, animationDelay: `${i * 0.2}s`,
'@keyframes bounce': { '@keyframes bounce': {
@@ -1,6 +1,7 @@
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { AssistantActionCards } from './AssistantActionCards' import { AssistantActionCards } from './AssistantActionCards'
import type { AssistantMessage } from '../../domain/assistant' import type { AssistantMessage } from '../../domain/assistant'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
function MessageBubble({ message }: { message: AssistantMessage }) { function MessageBubble({ message }: { message: AssistantMessage }) {
const isUser = message.role === 'user' const isUser = message.role === 'user'
@@ -10,7 +11,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
{!isUser && ( {!isUser && (
<Box <Box
sx={{ 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, 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)) && ( {!isUser && (message.confidence !== undefined || (message.sources && message.sources.length > 0)) && (
<Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 0.75, mt: 0.5, flexWrap: 'wrap', alignItems: 'center' }}>
{message.confidence !== undefined && ( {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)}% Konfidenz: {Math.round(message.confidence * 100)}%
</Typography> </Typography>
)} )}
{message.sources?.map(s => ( {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} {s}
</Typography> </Typography>
))} ))}
@@ -63,7 +64,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) {
)} )}
{/* Timestamp */} {/* 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' })} {new Date(message.createdAt).toLocaleTimeString('de-CH', { timeStyle: 'short' })}
</Typography> </Typography>
</Box> </Box>
@@ -1,6 +1,6 @@
import { Box, Chip, Typography } from '@mui/material' import { Box, Chip, Typography } from '@mui/material'
import type { SuggestedQuestion } from '../../domain/assistant' 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 { interface Props {
suggestions: SuggestedQuestion[] suggestions: SuggestedQuestion[]
@@ -74,7 +74,7 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }:
}} }}
/> />
<Box sx={{ flex: 1, minWidth: 0 }}> <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} {s.question}
</Typography> </Typography>
<Chip <Chip
@@ -1,6 +1,7 @@
import { Box, IconButton, Tooltip } from '@mui/material' import { Box, IconButton, Tooltip } from '@mui/material'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { useAssistantStore } from '../../stores/assistantStore' import { useAssistantStore } from '../../stores/assistantStore'
import { DS_ACCENT } from '../../lib/ds'
export function GlobalAIAssistantButton() { export function GlobalAIAssistantButton() {
const isOpen = useAssistantStore(s => s.isOpen) const isOpen = useAssistantStore(s => s.isOpen)
@@ -22,10 +23,10 @@ export function GlobalAIAssistantButton() {
sx={{ sx={{
width: 48, width: 48,
height: 48, height: 48,
bgcolor: '#4f46e5', bgcolor: DS_ACCENT.indigo.main,
color: 'white', color: 'white',
boxShadow: '0 4px 16px rgba(79,70,229,0.4)', boxShadow: '0 4px 16px rgba(79,70,229,0.4)',
'&:hover': { bgcolor: '#4338ca' }, '&:hover': { bgcolor: DS_ACCENT.indigo.strong },
}} }}
> >
<Sparkles size={20} /> <Sparkles size={20} />
@@ -13,6 +13,7 @@ import { AssistantLoadingState } from './AssistantLoadingState'
import { AssistantErrorState } from './AssistantErrorState' import { AssistantErrorState } from './AssistantErrorState'
import type { AssistantContext } from '../../domain/assistant' import type { AssistantContext } from '../../domain/assistant'
import type { WorkspaceType } from '../../domain/enums' import type { WorkspaceType } from '../../domain/enums'
import { DS_ACCENT, DS_BG, DS_SLATE } from '../../lib/ds'
function resolveWorkspace(pathname: string): WorkspaceType | null { function resolveWorkspace(pathname: string): WorkspaceType | null {
if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType
@@ -43,7 +44,7 @@ export function GlobalAIAssistantDrawer() {
organizationId: currentUser?.organizationId ?? '', organizationId: currentUser?.organizationId ?? '',
} }
setContext(ctx) setContext(ctx)
}, [isOpen, location.pathname]) }, [isOpen, location.pathname, currentUser?.role, currentUser?.organizationId, setContext])
// Auto-scroll on new messages // Auto-scroll on new messages
useEffect(() => { 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={{ px: 2, py: 1.5, borderBottom: '1px solid #e2e8f0', flexShrink: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
<Box <Box
sx={{ 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, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}} }}
> >
@@ -144,11 +145,11 @@ export function GlobalAIAssistantDrawer() {
</Typography> </Typography>
</Box> </Box>
<Tooltip title="Gespräch zurücksetzen"> <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} /> <RotateCcw size={14} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<IconButton size="small" onClick={close} sx={{ color: '#94a3b8' }}> <IconButton size="small" onClick={close} sx={{ color: DS_SLATE[400] }}>
<X size={16} /> <X size={16} />
</IconButton> </IconButton>
</Box> </Box>
@@ -164,8 +165,8 @@ export function GlobalAIAssistantDrawer() {
{/* Welcome message */} {/* Welcome message */}
{messages.length === 0 && !isLoading && ( {messages.length === 0 && !isLoading && (
<Box sx={{ px: 2, py: 1.5 }}> <Box sx={{ px: 2, py: 1.5 }}>
<Box sx={{ bgcolor: '#f8fafc', borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}> <Box sx={{ bgcolor: DS_SLATE[50], borderRadius: 2, p: 1.5, border: '1px solid #e2e8f0' }}>
<Typography variant="body2" sx={{ fontSize: '0.8125rem', color: '#334155', lineHeight: 1.6 }}> <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. Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten keine erfundenen Fakten.
</Typography> </Typography>
</Box> </Box>
@@ -233,11 +234,11 @@ export function GlobalAIAssistantDrawer() {
onClick={() => handleQuestion(inputText)} onClick={() => handleQuestion(inputText)}
disabled={!inputText.trim() || isLoading} disabled={!inputText.trim() || isLoading}
sx={{ sx={{
bgcolor: '#4f46e5', bgcolor: DS_ACCENT.indigo.main,
color: 'white', color: 'white',
flexShrink: 0, flexShrink: 0,
'&:hover': { bgcolor: '#4338ca' }, '&:hover': { bgcolor: DS_ACCENT.indigo.strong },
'&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' }, '&:disabled': { bgcolor: DS_BG.muted, color: DS_SLATE[400] },
}} }}
> >
<Send size={16} /> <Send size={16} />
+3 -2
View File
@@ -2,6 +2,7 @@ import { Box, Button, Typography } from '@mui/material'
import { Clock } from 'lucide-react' import { Clock } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
export function SessionExpired() { export function SessionExpired() {
const { logout } = useSessionStore() const { logout } = useSessionStore()
@@ -21,7 +22,7 @@ export function SessionExpired() {
justifyContent: 'center', justifyContent: 'center',
minHeight: '100vh', minHeight: '100vh',
gap: 2, gap: 2,
bgcolor: '#f8fafc', bgcolor: DS_SLATE[50],
p: 4, p: 4,
}} }}
> >
@@ -51,7 +52,7 @@ export function SessionExpired() {
<Button <Button
variant="contained" variant="contained"
onClick={handleRelogin} onClick={handleRelogin}
sx={{ mt: 1, bgcolor: '#152642', textTransform: 'none' }} sx={{ mt: 1, bgcolor: DS_BRAND.main, textTransform: 'none' }}
> >
Erneut anmelden Erneut anmelden
</Button> </Button>
+5 -4
View File
@@ -2,6 +2,7 @@ import { Chip } from '@mui/material'
import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react' import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react'
import type { ResultType } from '../../domain/enums' import type { ResultType } from '../../domain/enums'
import { RESULT_TYPE_LABELS } from '../../lib/constants' import { RESULT_TYPE_LABELS } from '../../lib/constants'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface SourceTypeBadgeProps { interface SourceTypeBadgeProps {
type: ResultType type: ResultType
@@ -9,13 +10,13 @@ interface SourceTypeBadgeProps {
} }
const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = { const CONFIG: Record<string, { bg: string; color: string; Icon: React.ElementType }> = {
VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#152642', Icon: ShieldCheck }, 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: '#0369a1', Icon: Building2 }, 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: '#6d28d9', Icon: Sparkles }, FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: DS_ACCENT.violet.strong, Icon: Sparkles },
} }
export function SourceTypeBadge({ type, size = 'small' }: SourceTypeBadgeProps) { 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 ( return (
<Chip <Chip
icon={<Icon size={11} color={color} />} icon={<Icon size={11} color={color} />}
+6 -5
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Box, Tooltip, Typography } from '@mui/material' import { Box, Tooltip, Typography } from '@mui/material'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none' export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none'
@@ -12,10 +13,10 @@ interface Props {
} }
const HIGHLIGHT_SX: Record<CellHighlight, object> = { const HIGHLIGHT_SX: Record<CellHighlight, object> = {
best: { bgcolor: '#f0fdf4', borderLeft: '3px solid #1a7a4a' }, best: { bgcolor: DS_ACCENT.success.bg, borderLeft: '3px solid #1a7a4a' },
worst: { bgcolor: '#fef3c7', borderLeft: '3px solid #d97706' }, worst: { bgcolor: DS_ACCENT.warning.borderSoft, borderLeft: '3px solid #d97706' },
critical: { bgcolor: '#fef2f2', borderLeft: '3px solid #c0392b' }, critical: { bgcolor: DS_ACCENT.danger.bg, borderLeft: '3px solid #c0392b' },
future: { bgcolor: '#faf5ff', borderLeft: '3px solid #7c3aed' }, future: { bgcolor: DS_ACCENT.violet.bg, borderLeft: '3px solid #7c3aed' },
none: {}, none: {},
} }
@@ -40,7 +41,7 @@ export function MissingDataCell({ reason }: { reason?: string }) {
<Info size={12} color="#94a3b8" /> <Info size={12} color="#94a3b8" />
</Box> </Box>
</Tooltip> </Tooltip>
<Typography variant="body2" sx={{ color: '#94a3b8', fontStyle: 'italic' }}> <Typography variant="body2" sx={{ color: DS_SLATE[400], fontStyle: 'italic' }}>
Nicht verfügbar Nicht verfügbar
</Typography> </Typography>
</Box> </Box>
@@ -3,7 +3,7 @@ import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-re
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { usePipelineItems } from '../../hooks/usePipeline' import { usePipelineItems } from '../../hooks/usePipeline'
import { usePipelineStore } from '../../stores/pipelineStore' 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 { matchScoreHex } from '../../lib/utils'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
@@ -17,9 +17,9 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
const { data: pipelineItems = [] } = usePipelineItems() const { data: pipelineItems = [] } = usePipelineItems()
const { openSavedDialog } = usePipelineStore() const { openSavedDialog } = usePipelineStore()
const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: DS_SLATE[500] }
const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? item.property : null
const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null const sig = item.resultType === 'FUTURE_AVAILABILITY' ? item.signal : null
const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? '' const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? ''
const subtitle = prop?.location?.city ?? 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 }}> <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 }} /> <Chip label={meta.label} size="small" sx={{ bgcolor: meta.color, color: 'white', fontWeight: 600, fontSize: 10 }} />
<Tooltip title="Aus Vergleich entfernen"> <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} /> <X size={15} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -67,7 +67,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
sx={{ sx={{
fontWeight: 700, lineHeight: 1.3, mb: 0.25, fontWeight: 700, lineHeight: 1.3, mb: 0.25,
cursor: 'pointer', cursor: 'pointer',
'&:hover': { color: '#152642' }, '&:hover': { color: DS_BRAND.main },
}} }}
onClick={() => navigate(`/demand/results/${item.matchId}`)} onClick={() => navigate(`/demand/results/${item.matchId}`)}
> >
@@ -108,8 +108,8 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
</Box> </Box>
{item.resultType === 'FUTURE_AVAILABILITY' && sig && ( {item.resultType === 'FUTURE_AVAILABILITY' && sig && (
<Box sx={{ mb: 1.25, p: 0.75, bgcolor: '#faf5ff', borderRadius: 1, border: '1px solid #e9d5ff' }}> <Box sx={{ mb: 1.25, p: 0.75, bgcolor: DS_ACCENT.violet.bg, borderRadius: 1, border: '1px solid #e9d5ff' }}>
<Typography variant="caption" sx={{ color: '#7c3aed', fontWeight: 600, display: 'block' }}> <Typography variant="caption" sx={{ color: DS_ACCENT.violet.main, fontWeight: 600, display: 'block' }}>
Probabilistisches Signal Probabilistisches Signal
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -135,7 +135,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) {
variant="outlined" variant="outlined"
startIcon={<BookmarkCheck size={12} />} startIcon={<BookmarkCheck size={12} />}
disabled 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 In Pipeline
</Button> </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 type { WeightingKey } from '../../domain/needBuilder'
import { MissingDataCell } from './CompareCell' import { MissingDataCell } from './CompareCell'
import type { UnifiedMatchResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult } from '../../domain/unifiedResult'
import { DS_ACCENT, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props { interface Props {
compareItems: UnifiedMatchResult[] compareItems: UnifiedMatchResult[]
@@ -15,18 +16,18 @@ interface Props {
export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWinnerIdx, activeNeed }: Props) { export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWinnerIdx, activeNeed }: Props) {
return ( return (
<Card sx={{ mb: 3, overflow: 'hidden' }}> <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" /> <Trophy size={18} color="#d4920e" />
<Box> <Box>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>Vergleich nach Suchkriterien</Typography> <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> <Typography variant="caption" color="text.secondary">Basierend auf der Suche: {activeNeed.companyName}</Typography>
</Box> </Box>
{overallWinnerIdx !== -1 && ( {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" /> <Trophy size={16} color="#d4920e" />
<Box> <Box>
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography> <Typography variant="caption" sx={{ color: DS_TEXT.warningDark, fontWeight: 700, display: 'block', lineHeight: 1 }}>Gesamtsieger</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, color: '#7a4f00' }}>{getTitle(compareItems[overallWinnerIdx])}</Typography> <Typography variant="body2" sx={{ fontWeight: 800, color: DS_ACCENT.warning.darkest }}>{getTitle(compareItems[overallWinnerIdx])}</Typography>
</Box> </Box>
</Box> </Box>
)} )}
@@ -34,11 +35,11 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Table sx={{ tableLayout: 'auto' }}> <Table sx={{ tableLayout: 'auto' }}>
<TableHead> <TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}> <TableRow sx={{ bgcolor: DS_SLATE[50] }}>
<TableCell sx={{ ...LABEL_SX, bgcolor: '#f8fafc', fontSize: 11 }}>Kriterium</TableCell> <TableCell sx={{ ...LABEL_SX, bgcolor: DS_SLATE[50], fontSize: 11 }}>Kriterium</TableCell>
{compareItems.map(item => ( {compareItems.map(item => (
<TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: '#f8fafc' }}> <TableCell key={item.matchId} sx={{ ...DATA_SX, bgcolor: DS_SLATE[50] }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#152642' }} noWrap> <Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main }} noWrap>
{getTitle(item)} {getTitle(item)}
</Typography> </Typography>
</TableCell> </TableCell>
@@ -74,8 +75,8 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
{idx === winnerIdx && ( {idx === winnerIdx && (
<Chip label="Besser" size="small" <Chip label="Besser" size="small"
icon={<CheckCircle2 size={10} />} icon={<CheckCircle2 size={10} />}
sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534', sx={{ height: 18, fontSize: 9, bgcolor: DS_ACCENT.success.bg, color: DS_TEXT.successDark,
'& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 }, '& .MuiChip-icon': { color: DS_ACCENT.success.main, ml: 0.5 },
'& .MuiChip-label': { px: 0.75 } }} /> '& .MuiChip-label': { px: 0.75 } }} />
)} )}
</Box> </Box>
+2 -1
View File
@@ -1,6 +1,7 @@
import { Box, Button, Typography } from '@mui/material' import { Box, Button, Typography } from '@mui/material'
import { Columns2 } from 'lucide-react' import { Columns2 } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { DS_BRAND } from '../../lib/ds'
export function CompareEmptyState() { export function CompareEmptyState() {
const navigate = useNavigate() 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. Fügen Sie 24 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
</Typography> </Typography>
<Button variant="contained" size="small" onClick={() => navigate('/demand/results')} <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 Zu den Suchergebnissen
</Button> </Button>
</Box> </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, scoreBar,
} from './compareUtils' } from './compareUtils'
import { CompareCell, MissingDataCell } from './index' 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 { matchScoreHex } from '../../lib/utils'
import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils' import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils'
import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants' import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants'
@@ -63,7 +63,7 @@ export function CompareTableBody({
{/* 1. Result Type */} {/* 1. Result Type */}
{row('1. Result-Typ', compareItems.map(item => { {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 }} /> 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)}%) + {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%)
</Typography> </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 = {totalMonthly.toLocaleString('de-CH')} CHF/Monat
</Typography> </Typography>
{fitOutLabel && ( {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)' : ''} Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''}
</Typography> </Typography>
)} )}
@@ -238,7 +238,7 @@ export function CompareTableBody({
{hardMatches.map(f => ( {hardMatches.map(f => (
<Chip key={f.criterion} label={f.criterion} size="small" <Chip key={f.criterion} label={f.criterion} size="small"
icon={<CheckCircle2 size={10} />} 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>
</Box> </Box>
@@ -255,7 +255,7 @@ export function CompareTableBody({
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{softFactors.map(f => ( {softFactors.map(f => (
<Chip key={f.criterion} label={f.criterion} size="small" <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> </Box>
) )
@@ -322,7 +322,7 @@ export function CompareTableBody({
if (total === 0) return ( if (total === 0) return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CheckCircle2 size={14} color="#1a7a4a" /> <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> </Box>
) )
return ( return (
+2 -1
View File
@@ -3,6 +3,7 @@ import { Box, LinearProgress, Typography } from '@mui/material'
import type { WeightingKey } from '../../domain/needBuilder' import type { WeightingKey } from '../../domain/needBuilder'
import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult' import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult'
import { dataQualityHex } from '../../lib/utils' import { dataQualityHex } from '../../lib/utils'
import { DS_SLATE } from '../../lib/ds'
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -53,7 +54,7 @@ export const LABEL_SX = {
zIndex: 1, zIndex: 1,
width: 200, width: 200,
minWidth: 200, minWidth: 200,
color: '#64748b', color: DS_SLATE[500],
fontSize: 13, fontSize: 13,
fontWeight: 600, fontWeight: 600,
borderRight: '1px solid #e2e8f0', borderRight: '1px solid #e2e8f0',
@@ -3,6 +3,7 @@ import { AlertTriangle } from 'lucide-react'
import { dataQualityColor, dataQualityHex, formatPercent } from '../../lib/utils' import { dataQualityColor, dataQualityHex, formatPercent } from '../../lib/utils'
import { FRESHNESS_LABELS } from '../../lib/constants' import { FRESHNESS_LABELS } from '../../lib/constants'
import type { DataQuality } from '../../domain/property' import type { DataQuality } from '../../domain/property'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface DataQualityBarProps { interface DataQualityBarProps {
quality: DataQuality quality: DataQuality
@@ -22,7 +23,7 @@ export function DataQualityBar({ quality, compact = false, showWarnings = true }
</Typography> </Typography>
{quality.missingCriticalFields.length > 0 && ( {quality.missingCriticalFields.length > 0 && (
<Box sx={{ mb: 0.5 }}> <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 => ( {quality.missingCriticalFields.map(f => (
<Typography key={f} variant="caption" sx={{ display: 'block', pl: 1 }}> {f}</Typography> <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 && ( {quality.warnings.length > 0 && (
<Box> <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) => ( {quality.warnings.map((w, i) => (
<Typography key={i} variant="caption" sx={{ display: 'block', pl: 1 }}> {w}</Typography> <Typography key={i} variant="caption" sx={{ display: 'block', pl: 1 }}> {w}</Typography>
))} ))}
</Box> </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]} Aktualität: {FRESHNESS_LABELS[quality.freshness]}
{quality.lastVerifiedAt ? ` · Geprüft: ${quality.lastVerifiedAt}` : ''} {quality.lastVerifiedAt ? ` · Geprüft: ${quality.lastVerifiedAt}` : ''}
</Typography> </Typography>
@@ -6,6 +6,7 @@ import { ProvenancePanel } from './ProvenancePanel'
import { DataQualityBadge } from './DataQualityBadge' import { DataQualityBadge } from './DataQualityBadge'
import { getRecommendedActions } from '../../services/dataQualityService' import { getRecommendedActions } from '../../services/dataQualityService'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
interface DataQualityPanelProps { interface DataQualityPanelProps {
property: Property property: Property
@@ -17,7 +18,7 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
fontWeight: 700, fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
color: '#64748b', color: DS_SLATE[500],
display: 'block', display: 'block',
mb: 1, mb: 1,
}}> }}>
@@ -33,7 +34,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
return ( return (
<Box> <Box>
{/* ── Score & Dimensions ─────────────────────────────────────────── */} {/* ── 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 sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1.5 }}>
<Box> <Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Datenqualität</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>Datenqualität</Typography>
@@ -81,7 +82,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) {
Datenaktualisierung anfragen Datenaktualisierung anfragen
</Button> </Button>
{q.missingCriticalFields.length > 0 && ( {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 Fehlende Felder ergänzen
</Button> </Button>
)} )}
@@ -2,6 +2,7 @@ import { Box, LinearProgress, Typography } from '@mui/material'
import { dataQualityHex } from '../../lib/utils' import { dataQualityHex } from '../../lib/utils'
import { FreshnessStatus } from '../../domain/enums' import { FreshnessStatus } from '../../domain/enums'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { DS_BG } from '../../lib/ds'
interface Dimension { interface Dimension {
label: string label: string
@@ -76,7 +77,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{ sx={{
height: 4, height: 4,
borderRadius: 2, borderRadius: 2,
bgcolor: '#e8e7e4', bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': { bgcolor: d.color }, '& .MuiLinearProgress-bar': { bgcolor: d.color },
}} }}
/> />
@@ -110,7 +111,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr
sx={{ sx={{
height: 5, height: 5,
borderRadius: 3, borderRadius: 3,
bgcolor: '#e8e7e4', bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': { bgcolor: d.color }, '& .MuiLinearProgress-bar': { bgcolor: d.color },
}} }}
/> />
@@ -3,6 +3,7 @@ import { CheckCircle, Clock, AlertTriangle } from 'lucide-react'
import { FreshnessStatus } from '../../domain/enums' import { FreshnessStatus } from '../../domain/enums'
import { FRESHNESS_LABELS } from '../../lib/constants' import { FRESHNESS_LABELS } from '../../lib/constants'
import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums' import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums'
import { DS_ACCENT } from '../../lib/ds'
interface FreshnessIndicatorProps { interface FreshnessIndicatorProps {
freshness: FreshnessStatusType freshness: FreshnessStatusType
@@ -11,9 +12,9 @@ interface FreshnessIndicatorProps {
} }
const CONFIG: Record<FreshnessStatusType, { color: string; icon: typeof CheckCircle }> = { const CONFIG: Record<FreshnessStatusType, { color: string; icon: typeof CheckCircle }> = {
[FreshnessStatus.FRESH]: { color: '#1a7a4a', icon: CheckCircle }, [FreshnessStatus.FRESH]: { color: DS_ACCENT.success.main, icon: CheckCircle },
[FreshnessStatus.STALE]: { color: '#d97706', icon: Clock }, [FreshnessStatus.STALE]: { color: DS_ACCENT.warning.main, icon: Clock },
[FreshnessStatus.OUTDATED]: { color: '#c0392b', icon: AlertTriangle }, [FreshnessStatus.OUTDATED]: { color: DS_ACCENT.danger.main, icon: AlertTriangle },
} }
export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) { export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) {
@@ -1,6 +1,7 @@
import { Box, Button, Chip, Divider, Typography } from '@mui/material' import { Box, Button, Chip, Divider, Typography } from '@mui/material'
import { AlertTriangle, Info } from 'lucide-react' import { AlertTriangle, Info } from 'lucide-react'
import type { RecommendedAction } from '../../services/dataQualityService' import type { RecommendedAction } from '../../services/dataQualityService'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface MissingDataListProps { interface MissingDataListProps {
criticalFields: string[] criticalFields: string[]
@@ -17,9 +18,9 @@ export function MissingDataList({
}: MissingDataListProps) { }: MissingDataListProps) {
if (criticalFields.length === 0 && optionalFields.length === 0) { if (criticalFields.length === 0 && optionalFields.length === 0) {
return ( 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" /> <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. Alle wichtigen Felder sind vollständig.
</Typography> </Typography>
</Box> </Box>
@@ -35,7 +36,7 @@ export function MissingDataList({
<Box sx={{ mb: 1.5 }}> <Box sx={{ mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.75 }}>
<AlertTriangle size={13} color="#c0392b" /> <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}) Pflichtfelder ({criticalFields.length})
</Typography> </Typography>
</Box> </Box>
@@ -49,7 +50,7 @@ export function MissingDataList({
px: 1.5, px: 1.5,
py: 0.75, py: 0.75,
mb: 0.5, mb: 0.5,
bgcolor: '#fff1f2', bgcolor: DS_ACCENT.danger.bgAlt,
border: '1px solid #fecdd3', border: '1px solid #fecdd3',
borderRadius: 1, borderRadius: 1,
}} }}
@@ -82,7 +83,7 @@ export function MissingDataList({
<> <>
{criticalFields.length > 0 && <Divider sx={{ mb: 1.5 }} />} {criticalFields.length > 0 && <Divider sx={{ mb: 1.5 }} />}
<Box> <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}) Optionale Felder ({optionalFields.length})
</Typography> </Typography>
{otherActions.map(a => ( {otherActions.map(a => (
@@ -95,7 +96,7 @@ export function MissingDataList({
px: 1.5, px: 1.5,
py: 0.75, py: 0.75,
mb: 0.5, mb: 0.5,
bgcolor: '#fffbeb', bgcolor: DS_ACCENT.warning.bg,
border: '1px solid #fde68a', border: '1px solid #fde68a',
borderRadius: 1, borderRadius: 1,
}} }}
@@ -2,6 +2,7 @@ import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material'
import { ExternalLink, Shield, ShieldAlert } from 'lucide-react' import { ExternalLink, Shield, ShieldAlert } from 'lucide-react'
import { FreshnessIndicator } from './FreshnessIndicator' import { FreshnessIndicator } from './FreshnessIndicator'
import type { Property } from '../../domain/property' import type { Property } from '../../domain/property'
import { DS_BG } from '../../lib/ds'
interface ProvenancePanelProps { interface ProvenancePanelProps {
property: Property property: Property
@@ -79,7 +80,7 @@ export function ProvenancePanel({ property: p }: ProvenancePanelProps) {
sx={{ sx={{
height: 5, height: 5,
borderRadius: 3, borderRadius: 3,
bgcolor: '#e8e7e4', bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': { bgcolor: color }, '& .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 { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material'
import type { ParseNeedResult, ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParseNeedResult, ParsedNeedCriteria } from '../../domain/needBuilder'
import { ExtractedFieldRow } from './ExtractedFieldRow' import { ExtractedFieldRow } from './ExtractedFieldRow'
import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
result: ParseNeedResult result: ParseNeedResult
@@ -8,16 +10,12 @@ interface Props {
onCriteriaChange: (c: ParsedNeedCriteria) => void 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 ────────────────────────────────────────────────────────────── // ── Parse helpers ──────────────────────────────────────────────────────────────
function parseAreaRange(s: string): ParsedNeedCriteria['areaRange'] { 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]) } if (m) return { min: parseInt(m[1]), max: parseInt(m[2]) }
const n = s.match(/(\d+)/) 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) } } 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 }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<Box sx={{ mb: 2 }}> <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} {title}
</Typography> </Typography>
{children} {children}
@@ -76,7 +74,7 @@ export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set
<Section title="Hard Facts"> <Section title="Hard Facts">
<ExtractedFieldRow <ExtractedFieldRow
label="Nutzungstyp" 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} confidence={conf.assetType ?? 0.2}
missing={!c.assetType} missing={!c.assetType}
onEdit={v => set({ ...c, assetType: (v.toUpperCase() as ParsedNeedCriteria['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 { ArrowRight, RefreshCw } from 'lucide-react'
import type { FollowUpQuestion } from '../../domain/needBuilder' import type { FollowUpQuestion } from '../../domain/needBuilder'
import { FollowUpQuestionCard } from './FollowUpQuestionCard' import { FollowUpQuestionCard } from './FollowUpQuestionCard'
import { DS_BRAND } from '../../lib/ds'
interface Props { interface Props {
questions: FollowUpQuestion[] questions: FollowUpQuestion[]
@@ -66,7 +67,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onRepa
disabled={requiredUnanswered.length > 0} disabled={requiredUnanswered.length > 0}
onClick={onContinue} onClick={onContinue}
endIcon={<ArrowRight size={16} />} endIcon={<ArrowRight size={16} />}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
> >
Weiter zur Gewichtung Weiter zur Gewichtung
</Button> </Button>
@@ -1,5 +1,6 @@
import { Box, Button, Typography } from '@mui/material' import { Box, Button, Typography } from '@mui/material'
import { AlertTriangle, RotateCcw } from 'lucide-react' import { AlertTriangle, RotateCcw } from 'lucide-react'
import { DS_BRAND } from '../../lib/ds'
interface Props { interface Props {
message: string message: string
@@ -22,7 +23,7 @@ export function NeedBuilderErrorState({ message, onRetry }: Props) {
variant="contained" variant="contained"
startIcon={<RotateCcw size={16} />} startIcon={<RotateCcw size={16} />}
onClick={onRetry} onClick={onRetry}
sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }} sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
> >
Erneut versuchen Erneut versuchen
</Button> </Button>
@@ -1,6 +1,7 @@
import { Box, Stepper, Step, StepLabel } from '@mui/material' import { Box, Stepper, Step, StepLabel } from '@mui/material'
import type { NeedBuilderStep } from '../../domain/needBuilder' import type { NeedBuilderStep } from '../../domain/needBuilder'
import { NeedBuilderStep as S } from '../../domain/needBuilder' import { NeedBuilderStep as S } from '../../domain/needBuilder'
import { DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
step: NeedBuilderStep step: NeedBuilderStep
@@ -21,7 +22,7 @@ function toStepIndex(step: NeedBuilderStep): number {
export function NeedBuilderProgress({ step }: Props) { export function NeedBuilderProgress({ step }: Props) {
if (step === S.IDLE) return null if (step === S.IDLE) return null
return ( 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> <Stepper activeStep={toStepIndex(step)} alternativeLabel>
{STEPS.map(label => ( {STEPS.map(label => (
<Step key={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 type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder' import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } 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 { interface Props {
criteria: ParsedNeedCriteria criteria: ParsedNeedCriteria
@@ -13,10 +15,7 @@ interface Props {
onNeedTitleChange: (v: string) => void 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'] const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
@@ -66,7 +65,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
{c.assetType && ( {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" /> <Chip label={isLowConfidence ? 'ENTWURF (needs_review)' : 'ENTWURF'} size="small" variant="outlined" />
</Box> </Box>
@@ -143,7 +142,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
variant="determinate" variant="determinate"
value={overallConfidence * 100} value={overallConfidence * 100}
sx={{ sx={{
height: 6, borderRadius: 3, bgcolor: '#e8e7e4', height: 6, borderRadius: 3, bgcolor: DS_BG.muted,
'& .MuiLinearProgress-bar': { '& .MuiLinearProgress-bar': {
bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b', bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b',
}, },
@@ -171,7 +170,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={((weights[k] ?? 0) / maxWeight) * 100} 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> <Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography>
</Box> </Box>
@@ -1,6 +1,7 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material' import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
criteria: ParsedNeedCriteria criteria: ParsedNeedCriteria
@@ -11,7 +12,7 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props)
return ( return (
<Accordion disableGutters elevation={0} sx={{ border: '1px solid #e2e8f0', borderRadius: 1, mt: 1, '&:before': { display: 'none' } }}> <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 } }}> <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> </AccordionSummary>
<AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}> <AccordionDetails sx={{ px: 1.5, pt: 0, pb: 1.5 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.5, mb: 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 type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums' import { AssetType } from '../../domain/enums'
import { NeedExtendedRequirements } from './NeedExtendedRequirements' import { NeedExtendedRequirements } from './NeedExtendedRequirements'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
criteria: ParsedNeedCriteria criteria: ParsedNeedCriteria
@@ -81,7 +82,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable clickable
onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })} onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })}
sx={c.assetType === 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" variant="outlined"
clickable clickable
onClick={() => set({ ...c, areaRange: { min: p.min, max: p.max } })} 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> </Stack>
@@ -139,8 +140,8 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
}) })
}} }}
sx={c.preferredLocations?.includes(loc) sx={c.preferredLocations?.includes(loc)
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' } ? { fontSize: '0.68rem', height: 22, bgcolor: DS_BRAND.main, color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' } : { 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={{ mb: 2.5, px: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 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" 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 {c.searchRadius ?? 30} km
</Typography> </Typography>
</Box> </Box>
@@ -181,7 +182,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
step={1} step={1}
onChange={(_, v) => set({ ...c, searchRadius: v as number })} onChange={(_, v) => set({ ...c, searchRadius: v as number })}
size="small" size="small"
sx={{ color: '#152642', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }} sx={{ color: DS_BRAND.main, '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }}
/> />
</Box> </Box>
@@ -197,8 +198,8 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
clickable clickable
onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })} onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })}
sx={c.budgetRange?.maxPerSqm === p.value sx={c.budgetRange?.maxPerSqm === p.value
? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' } ? { fontSize: '0.68rem', height: 22, bgcolor: DS_BRAND.main, color: 'white' }
: { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' } : { 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 { useInquiryStore } from '../../stores/inquiryStore'
import { usePipelineStore } from '../../stores/pipelineStore' import { usePipelineStore } from '../../stores/pipelineStore'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
property: Property property: Property
@@ -99,15 +100,15 @@ export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score
<Box component="img" src={property.images[0]} alt="" <Box component="img" src={property.images[0]} alt=""
sx={{ width: 52, height: 52, borderRadius: 1, objectFit: 'cover', flexShrink: 0 }} /> 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" /> <Ruler size={18} color="#3730a3" />
</Box> </Box>
)} )}
<Box sx={{ flex: 1, minWidth: 0 }}> <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} {property.title}
</Typography> </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² {property.location.city} · {property.areaSqm} m²
</Typography> </Typography>
</Box> </Box>
@@ -149,8 +150,8 @@ function ActionButton({ children, onClick, primary, disabled }: {
px: 1, px: 1,
minWidth: 0, minWidth: 0,
...(primary ...(primary
? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } } ? { bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }
: { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }), : { borderColor: DS_SLATE[300], color: DS_SLATE[600], '&:hover': { borderColor: DS_BRAND.main, color: DS_BRAND.main } }),
}} }}
> >
{children} {children}
+6 -5
View File
@@ -3,6 +3,7 @@ import { Box, Chip, Paper, Typography } from '@mui/material'
import { MapPin } from 'lucide-react' import { MapPin } from 'lucide-react'
import type { Need } from '../../domain/need' import type { Need } from '../../domain/need'
import { ASSET_TYPE_LABELS } from '../../lib/constants' import { ASSET_TYPE_LABELS } from '../../lib/constants'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
need: Need 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', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small" <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" <Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} /> sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.65rem', height: 20 }} />
</Box> </Box>
{matchCount > 0 && ( {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 {matchCount} Treffer
</Box> </Box>
)} )}
</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} {need.companyName}
</Typography> </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} /> <MapPin size={11} />
<Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography> <Typography variant="caption" sx={{ fontSize: '0.75rem' }}>{locationText || '—'}</Typography>
</Box> </Box>
{topScore > 0 && ( {topScore > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <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 sx={{ width: `${topScore}%`, height: '100%', bgcolor: scoreColor }} />
</Box> </Box>
<Typography variant="caption" sx={{ fontSize: '0.72rem', fontWeight: 700, color: scoreColor, flexShrink: 0 }}> <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 { confidenceHex } from '../../lib/utils'
import { useMatchesByNeed } from '../../hooks/useMatches' import { useMatchesByNeed } from '../../hooks/useMatches'
import { PropertyMatchRow } from './PropertyMatchRow' import { PropertyMatchRow } from './PropertyMatchRow'
import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE } from '../../lib/ds'
const WEIGHT_LABELS: Record<string, string> = { const WEIGHT_LABELS: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing', area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing',
@@ -77,21 +78,21 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
) )
return ( 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 sx={{ flex: 1, overflowY: 'auto', p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip label={ASSET_TYPE_LABELS[need.assetType] ?? need.assetType} size="small" <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" <Chip label={statusCfg.label} size="small"
sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} /> sx={{ bgcolor: statusCfg.bg, color: statusCfg.fg, fontWeight: 600, fontSize: '0.7rem', height: 22 }} />
</Box> </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} {need.companyName}
</Typography> </Typography>
{need.contactName && ( {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> </Box>
@@ -113,7 +114,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
</Typography> </Typography>
</Box> </Box>
<LinearProgress variant="determinate" value={conf * 100} sx={{ <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 }, '& .MuiLinearProgress-bar': { bgcolor: confColor, borderRadius: 3 },
}} /> }} />
</Box> </Box>
@@ -125,7 +126,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
{need.mustCriteriaText!.map((c, i) => ( {need.mustCriteriaText!.map((c, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircle2 size={14} color="#16a34a" /> <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>
))} ))}
</Box> </Box>
@@ -139,7 +140,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
}}> }}>
<AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />} <AccordionSummary expandIcon={<ChevronDown size={16} color="#64748b" />}
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}> 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 Gewichtete Präferenzen
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
@@ -148,17 +149,17 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
{topWeights.map(([key, val]) => { {topWeights.map(([key, val]) => {
const pct = Math.round(val * 100) const pct = Math.round(val * 100)
return ( 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 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}> <Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>
{WEIGHT_LABELS[key] ?? key} {WEIGHT_LABELS[key] ?? key}
</Typography> </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}% {pct}%
</Typography> </Typography>
</Box> </Box>
<Box sx={{ height: 5, borderRadius: 3, bgcolor: '#e8e7e4', overflow: 'hidden' }}> <Box sx={{ height: 5, borderRadius: 3, bgcolor: DS_BG.muted, overflow: 'hidden' }}>
<Box sx={{ width: `${pct}%`, height: '100%', bgcolor: '#152642', transition: 'width 0.3s' }} /> <Box sx={{ width: `${pct}%`, height: '100%', bgcolor: DS_BRAND.main, transition: 'width 0.3s' }} />
</Box> </Box>
</Box> </Box>
) )
@@ -176,11 +177,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}> sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Bell size={13} color={notif.enabled ? '#152642' : '#94a3b8'} /> <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 Benachrichtigungen
</Typography> </Typography>
{notif.enabled && ( {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}% ab {notif.minScore}%
</Box> </Box>
)} )}
@@ -191,7 +192,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box> <Box>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Benachrichtigung aktiv</Typography> <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 Neue Treffer oberhalb der Schwelle melden
</Typography> </Typography>
</Box> </Box>
@@ -208,7 +209,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.85rem' }}>Match-Schwelle</Typography> <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}% {notif.minScore}%
</Typography> </Typography>
</Box> </Box>
@@ -216,11 +217,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
value={notif.minScore} value={notif.minScore}
min={60} max={95} step={5} min={60} max={95} step={5}
onChange={(_, v) => handleNotifChange({ minScore: v as number })} 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' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}>60%</Typography> <Typography variant="caption" sx={{ color: DS_SLATE[400], 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' }}>95%</Typography>
</Box> </Box>
</Box> </Box>
@@ -247,7 +248,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
variant="contained" variant="contained"
size="small" size="small"
onClick={handleNotifSave} 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 Speichern
</Button> </Button>
@@ -262,10 +263,10 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.25 }}>
<Target size={14} color="#15803d" /> <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 Passende Objekte
</Typography> </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 {matchCount} Treffer
</Box> </Box>
</Box> </Box>
@@ -277,8 +278,8 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
{matchCount > 3 && ( {matchCount > 3 && (
<Box <Box
onClick={onStart} onClick={onStart}
sx={{ mt: 1.25, textAlign: 'center', cursor: 'pointer', color: '#152642', fontSize: '0.8rem', fontWeight: 600, 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: '#f1f5f9', borderColor: '#152642' }, transition: 'all 0.15s' }} 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 Alle {matchCount} Ergebnisse anzeigen
</Box> </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 }}> <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} <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 Bearbeiten
</Button> </Button>
<Button variant="contained" size="large" onClick={onStart} <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 Suche starten
</Button> </Button>
</Box> </Box>
@@ -304,7 +305,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties,
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( 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} {children}
</Typography> </Typography>
) )
@@ -313,13 +314,13 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return ( return (
<Box sx={{ bgcolor: 'white', border: '1px solid #e2e8f0', borderRadius: 1.5, px: 1.5, py: 1 }}> <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} {icon}
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}> <Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{label} {label}
</Typography> </Typography>
</Box> </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> </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 { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material'
import { ArrowLeft, Plus, Search } from 'lucide-react' import { ArrowLeft, Plus, Search } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
@@ -10,6 +10,7 @@ import { ROUTES } from '../../lib/constants'
import { EmptyState } from '../ui' import { EmptyState } from '../ui'
import { SavedNeedCard } from './SavedNeedCard' import { SavedNeedCard } from './SavedNeedCard'
import { SavedNeedDetail } from './SavedNeedDetail' import { SavedNeedDetail } from './SavedNeedDetail'
import { DS_BRAND, DS_SLATE } from '../../lib/ds'
interface Props { interface Props {
onNewSearch: (prefillNeedId?: string) => void onNewSearch: (prefillNeedId?: string) => void
@@ -54,11 +55,10 @@ export function SavedProfilesTab({ onNewSearch }: Props) {
return { matchCounts: counts, topScores: tops } return { matchCounts: counts, topScores: tops }
}, [visible, allMatches]) }, [visible, allMatches])
useEffect(() => { // Ohne eigene Wahl steht auf grossen Schirmen das erste Profil offen —
if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id) // abgeleitet statt in einem Effekt nachgetragen.
}, [visible, selectedId, isMobile]) const effectiveId = selectedId ?? (isMobile ? null : visible[0]?.id ?? null)
const selectedNeed = visible.find(n => n.id === effectiveId) ?? null
const selectedNeed = visible.find(n => n.id === selectedId) ?? null
function handleSelect(id: string) { function handleSelect(id: string) {
setSelectedId(id) 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={{ 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 }}> <Box sx={{ px: 2, py: 1.25, borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<Search size={14} color="#7c3aed" /> <Search size={14} color="#7c3aed" />
<Typography variant="body2" sx={{ fontWeight: 600, color: '#0f172a' }}>Suchprofile</Typography> <Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900] }}>Suchprofile</Typography>
<Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: '#152642', color: 'white', fontWeight: 600, fontSize: '0.7rem' }}> <Box sx={{ ml: 'auto', px: 1, py: 0.125, borderRadius: 1, bgcolor: DS_BRAND.main, color: 'white', fontWeight: 600, fontSize: '0.7rem' }}>
{visible.length} {visible.length}
</Box> </Box>
</Box> </Box>
@@ -102,7 +102,7 @@ export function SavedProfilesTab({ onNewSearch }: Props) {
<SavedNeedCard <SavedNeedCard
key={n.id} key={n.id}
need={n} need={n}
selected={n.id === selectedId} selected={n.id === effectiveId}
matchCount={matchCounts[n.id] ?? 0} matchCount={matchCounts[n.id] ?? 0}
topScore={topScores[n.id] ?? 0} topScore={topScores[n.id] ?? 0}
onClick={() => handleSelect(n.id)} onClick={() => handleSelect(n.id)}
+27 -28
View File
@@ -1,6 +1,13 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material' import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import { Mic, MicOff, Sparkles, X } from 'lucide-react' 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 { interface Props {
text: string text: string
@@ -16,44 +23,36 @@ const EXAMPLES = [
'Lagerhalle 20003000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²', 'Lagerhalle 20003000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
] ]
const isSpeechSupported = typeof window !== 'undefined' && const isSpeechSupported = isSpeechRecognitionSupported()
('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
/** 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) { export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) {
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
const [interimText, setInterimText] = useState('') const [interimText, setInterimText] = useState('')
const recognitionRef = useRef<any>(null) const recognitionRef = useRef<SpeechRecognizer | null>(null)
const accumulatedRef = useRef('') const accumulatedRef = useRef('')
function startRecording() { function startRecording() {
const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition const rec = createSpeechRecognizer('de-DE')
if (!SpeechAPI) return if (!rec) return
accumulatedRef.current = text accumulatedRef.current = text
const rec = new SpeechAPI()
rec.lang = 'de-DE'
rec.continuous = true
rec.interimResults = true
rec.onresult = (e: any) => { rec.onresult = (event) => {
let finalPart = '' const { final, interim } = splitTranscript(event)
let interimPart = '' if (final) {
for (let i = e.resultIndex; i < e.results.length; i++) { accumulatedRef.current = (accumulatedRef.current + ' ' + final).trim()
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()
onTextChange(accumulatedRef.current) onTextChange(accumulatedRef.current)
} }
setInterimText(interimPart) setInterimText(interim)
} }
rec.onend = () => { rec.onend = () => {
setIsRecording(false) setIsRecording(false)
setInterimText('') setInterimText('')
if (accumulatedRef.current.length >= 15) onAiSubmit() if (accumulatedRef.current.length >= MIN_CHARS_FOR_AUTO_SUBMIT) onAiSubmit()
} }
rec.onerror = () => { setIsRecording(false); setInterimText('') } rec.onerror = () => { setIsRecording(false); setInterimText('') }
@@ -97,15 +96,15 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
size="small" size="small"
label="🎤 Aufnahme läuft…" label="🎤 Aufnahme läuft…"
onClick={stopRecording} 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 ? ( ) : isAnalyzing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} sx={{ color: '#152642' }} /> <CircularProgress size={14} sx={{ color: DS_BRAND.main }} />
<Typography variant="caption" sx={{ color: '#152642', fontWeight: 600 }}>Analysiert</Typography> <Typography variant="caption" sx={{ color: DS_BRAND.main, fontWeight: 600 }}>Analysiert</Typography>
</Box> </Box>
) : isAutoGen && text ? ( ) : 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} ) : null}
</Box> </Box>
@@ -138,7 +137,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
onClick={stopRecording} onClick={stopRecording}
size="small" size="small"
sx={{ 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', animation: 'micPulse 1.2s ease-in-out infinite',
'@keyframes micPulse': { '@keyframes micPulse': {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' }, '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" size="small"
disabled={isAnalyzing || !isSpeechSupported} disabled={isAnalyzing || !isSpeechSupported}
title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'} 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} /> <Mic size={16} />
</IconButton> </IconButton>
@@ -181,7 +180,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}> <Box sx={{ display: 'flex', gap: 1, flexShrink: 0, ml: 1 }}>
{text && !isRecording && ( {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} /> <X size={14} />
</IconButton> </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 { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer' import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import type { FutureSignal } from '../../domain/futureSignal' 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 { function probColor(p: number): string {
return p >= 0.7 ? '#1a7a4a' : p >= 0.5 ? '#d97706' : '#c0392b' return p >= 0.7 ? '#1a7a4a' : p >= 0.5 ? '#d97706' : '#c0392b'
@@ -74,7 +69,7 @@ export function FutureSignalCard({ signal, isSelected, onSelect }: Props) {
sx={{ sx={{
height: 4, height: 4,
borderRadius: 2, borderRadius: 2,
bgcolor: '#f1f5f9', bgcolor: DS_SLATE[100],
'& .MuiLinearProgress-bar': { bgcolor: probColor(signal.probability) }, '& .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={`~${signal.areaSqmEstimate.toLocaleString('de-CH')}`} size="small" sx={{ fontSize: 10, height: 18 }} />
)} )}
<Chip <Chip
label={SOURCE_LABELS[signal.source.type] ?? signal.source.type} label={SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type}
size="small" size="small"
variant="outlined" variant="outlined"
sx={{ fontSize: 10, height: 18 }} sx={{ fontSize: 10, height: 18 }}
@@ -11,20 +11,15 @@ import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { ReviewStatus } from '../../domain/enums' import { ReviewStatus } from '../../domain/enums'
import type { FutureSignal } from '../../domain/futureSignal' 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 }> = { const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
HIGH: { label: 'Hoch', color: '#1a7a4a' }, HIGH: { label: 'Hoch', color: DS_ACCENT.success.main },
MEDIUM: { label: 'Mittel', color: '#d97706' }, MEDIUM: { label: 'Mittel', color: DS_ACCENT.warning.main },
LOW: { label: 'Niedrig', color: '#c0392b' }, LOW: { label: 'Niedrig', color: DS_ACCENT.danger.main },
} }
function BarRow({ label, value }: { label: string; value: number }) { function BarRow({ label, value }: { label: string; value: number }) {
@@ -38,7 +33,7 @@ function BarRow({ label, value }: { label: string; value: number }) {
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={value * 100} 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> </Box>
) )
@@ -92,12 +87,12 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
title: signal.title ?? signal.companyName ?? signal.locationHint, title: signal.title ?? signal.companyName ?? signal.locationHint,
matchScore: Math.round(signal.confidenceScore * 100), matchScore: Math.round(signal.confidenceScore * 100),
confidenceScore: signal.confidenceScore, 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', 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <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> <Typography variant="caption" color="text.secondary">{signal.locationHint}</Typography>
)} )}
</Box> </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} /> <X size={16} />
</IconButton> </IconButton>
</Box> </Box>
@@ -121,7 +116,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
<SensitivityBadge level={signal.sensitivityLevel} /> <SensitivityBadge level={signal.sensitivityLevel} />
<SignalReviewStatusBadge status={signal.reviewStatus} /> <SignalReviewStatusBadge status={signal.reviewStatus} />
{signal.isVerified && ( {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>
</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', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Typ</Typography> <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>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Glaubwürdigkeit</Typography> <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 }} /> <Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Evidenz</Typography> <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> <Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
</Paper> </Paper>
</> </>
@@ -222,7 +217,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
<Button <Button
fullWidth size="small" variant="outlined" fullWidth size="small" variant="outlined"
onClick={handleSendReview} 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 Zur Prüfung senden
</Button> </Button>
@@ -232,7 +227,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
fullWidth size="small" variant="contained" fullWidth size="small" variant="contained"
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
onClick={() => handleStatus(ReviewStatus.APPROVED)} 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} endIcon={updateStatus.isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
> >
Genehmigen Genehmigen
@@ -243,7 +238,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
fullWidth size="small" variant="outlined" fullWidth size="small" variant="outlined"
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
onClick={() => handleStatus(ReviewStatus.REJECTED)} 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 Ablehnen
</Button> </Button>
@@ -1,22 +1,10 @@
import { Box, Chip, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material' import { Box, Chip, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material'
import { SignalType } from '../../domain/enums' import { SignalType } from '../../domain/enums'
import { SIGNAL_TYPE_LABELS } from '../../lib/constants' 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 { interface Props {
filters: SignalFilterState filters: SignalFilterState
@@ -131,7 +119,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC
label={`${activeCount} Filter aktiv`} label={`${activeCount} Filter aktiv`}
size="small" size="small"
onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)} 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"> <Typography variant="caption" color="text.secondary">
@@ -1,14 +1,15 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const SENSITIVITY_META: Record<string, { label: string; color: string }> = { const SENSITIVITY_META: Record<string, { label: string; color: string }> = {
PUBLIC: { label: 'Öffentlich', color: '#64748b' }, PUBLIC: { label: 'Öffentlich', color: DS_SLATE[500] },
INTERNAL: { label: 'Intern', color: '#d97706' }, INTERNAL: { label: 'Intern', color: DS_ACCENT.warning.main },
CONFIDENTIAL: { label: 'Vertraulich', color: '#c0392b' }, CONFIDENTIAL: { label: 'Vertraulich', color: DS_ACCENT.danger.main },
RESTRICTED: { label: 'Eingeschränkt', color: '#7c3aed' }, RESTRICTED: { label: 'Eingeschränkt', color: DS_ACCENT.violet.main },
} }
export function SensitivityBadge({ level }: { level: string }) { 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 ( return (
<Chip <Chip
label={meta.label} label={meta.label}
@@ -1,17 +1,18 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import type { ReviewStatus } from '../../domain/enums' import type { ReviewStatus } from '../../domain/enums'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
const STATUS_META: Record<string, { label: string; color: string }> = { const STATUS_META: Record<string, { label: string; color: string }> = {
UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' }, UNREVIEWED: { label: 'Ungeprüft', color: DS_SLATE[400] },
IN_REVIEW: { label: 'In Prüfung', color: '#d97706' }, IN_REVIEW: { label: 'In Prüfung', color: DS_ACCENT.warning.main },
APPROVED: { label: 'Genehmigt', color: '#1a7a4a' }, APPROVED: { label: 'Genehmigt', color: DS_ACCENT.success.main },
REJECTED: { label: 'Abgelehnt', color: '#c0392b' }, REJECTED: { label: 'Abgelehnt', color: DS_ACCENT.danger.main },
FLAGGED: { label: 'Markiert', color: '#ea580c' }, FLAGGED: { label: 'Markiert', color: DS_ACCENT.warning.strong },
} }
export function SignalReviewStatusBadge({ status }: { status?: ReviewStatus | null }) { export function SignalReviewStatusBadge({ status }: { status?: ReviewStatus | null }) {
const key = status ?? 'UNREVIEWED' 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 ( return (
<Chip <Chip
label={meta.label} label={meta.label}
@@ -1,6 +1,7 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import type { SignalType } from '../../domain/enums' import type { SignalType } from '../../domain/enums'
import { SIGNAL_TYPE_LABELS } from '../../lib/constants' import { SIGNAL_TYPE_LABELS } from '../../lib/constants'
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
interface SignalTypeBadgeProps { interface SignalTypeBadgeProps {
type: SignalType type: SignalType
@@ -8,16 +9,16 @@ interface SignalTypeBadgeProps {
} }
const COLOR_MAP: Record<string, { bg: string; color: string }> = { const COLOR_MAP: Record<string, { bg: string; color: string }> = {
EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: '#1a7a4a' }, EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: DS_ACCENT.success.main },
POSSIBLE_MOVE_OUT: { bg: 'rgba(217,119,6,0.12)', color: '#d97706' }, 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: '#1d4ed8' }, CONSTRUCTION_PROJECT: { bg: 'rgba(37,99,235,0.12)', color: DS_ACCENT.blue.main },
RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: '#c2410c' }, RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: DS_ACCENT.warning.burnt },
PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: '#6d28d9' }, PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: DS_ACCENT.violet.strong },
SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: '#475569' }, SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: DS_SLATE[600] },
} }
export function SignalTypeBadge({ type, size = 'small' }: SignalTypeBadgeProps) { 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 ( return (
<Chip <Chip
label={SIGNAL_TYPE_LABELS[type] ?? type} label={SIGNAL_TYPE_LABELS[type] ?? type}
+2 -2
View File
@@ -6,5 +6,5 @@ export { FutureSignalCard } from './FutureSignalCard'
export { FutureSignalFilterBar } from './FutureSignalFilterBar' export { FutureSignalFilterBar } from './FutureSignalFilterBar'
export { FutureSignalDetailPanel } from './FutureSignalDetailPanel' export { FutureSignalDetailPanel } from './FutureSignalDetailPanel'
export { FutureSignalEmptyState } from './FutureSignalEmptyState' export { FutureSignalEmptyState } from './FutureSignalEmptyState'
export type { SignalFilterState } from './FutureSignalFilterBar' export type { SignalFilterState } from './signalFilterState'
export { DEFAULT_SIGNAL_FILTERS } from './FutureSignalFilterBar' 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 isCompact = useMediaQuery(theme.breakpoints.down('lg'))
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
// Close mobile menu on route change // Das mobile Menü ist nur offen, solange die Route dieselbe bleibt. Statt es
useEffect(() => { setMobileOpen(false) }, [location.pathname]) // 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 // Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account
useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed]) useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed])
@@ -74,8 +79,8 @@ export function AppShell() {
activeWorkspace={activeWorkspace} activeWorkspace={activeWorkspace}
allowedWorkspaces={allowedWorkspaces} allowedWorkspaces={allowedWorkspaces}
onWorkspaceClick={handleWorkspaceClick} onWorkspaceClick={handleWorkspaceClick}
onToggle={isMobile ? () => setMobileOpen(false) : toggleSidebar} onToggle={isMobile ? closeMobileMenu : toggleSidebar}
onClose={isMobile ? () => setMobileOpen(false) : undefined} onClose={isMobile ? closeMobileMenu : undefined}
userName={userName} userName={userName}
orgName={orgName} orgName={orgName}
/> />
@@ -89,8 +94,8 @@ export function AppShell() {
{/* Mobile: temporary drawer */} {/* Mobile: temporary drawer */}
{isMobile && ( {isMobile && (
<Drawer <Drawer
open={mobileOpen} open={isMobileOpen}
onClose={() => setMobileOpen(false)} onClose={closeMobileMenu}
variant="temporary" variant="temporary"
ModalProps={{ keepMounted: true }} ModalProps={{ keepMounted: true }}
slotProps={{ paper: { sx: { width: 264, bgcolor: 'transparent', boxShadow: 'none' } } }} slotProps={{ paper: { sx: { width: 264, bgcolor: 'transparent', boxShadow: 'none' } } }}
@@ -103,7 +108,7 @@ export function AppShell() {
<TopBar <TopBar
pathname={location.pathname} pathname={location.pathname}
isMobile={isMobile} isMobile={isMobile}
onMenuClick={() => setMobileOpen(true)} onMenuClick={openMobileMenu}
/> />
<Box component="main" sx={{ flex: 1, overflowY: 'auto' }}> <Box component="main" sx={{ flex: 1, overflowY: 'auto' }}>
+55 -20
View File
@@ -3,7 +3,9 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
import { NavLink, useLocation } from 'react-router' import { NavLink, useLocation } from 'react-router'
import { WorkspaceType } from '../../domain/enums' import { WorkspaceType } from '../../domain/enums'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { AgentAvatar } from '../team'
import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig' import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig'
import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Visual constants // Visual constants
@@ -97,7 +99,7 @@ export function Sidebar({
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
color: '#64b5f6', color: DS_BRAND.light,
fontWeight: 600, fontWeight: 600,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1,
@@ -221,7 +223,7 @@ export function Sidebar({
{item.path === '/demand/compare' && compareCount > 0 && ( {item.path === '/demand/compare' && compareCount > 0 && (
<Box sx={{ <Box sx={{
minWidth: 18, height: 18, borderRadius: '9px', minWidth: 18, height: 18, borderRadius: '9px',
bgcolor: '#d97706', color: 'white', bgcolor: DS_ACCENT.warning.main, color: 'white',
fontSize: '0.65rem', fontWeight: 700, fontSize: '0.65rem', fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
px: 0.5, flexShrink: 0, px: 0.5, flexShrink: 0,
@@ -237,9 +239,14 @@ export function Sidebar({
) )
// Subreiter bleiben sichtbar, solange der Nutzer im Funktionsbereich ist. // 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. // Eingeklappt entfallen sie — dort ist kein Platz für eine zweite Ebene.
const subItems = item.children ?? [] 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 ? ( return collapsed ? (
<Tooltip key={item.path} title={item.label} placement="right"> <Tooltip key={item.path} title={item.label} placement="right">
@@ -260,9 +267,12 @@ export function Sidebar({
{({ isActive }) => ( {({ isActive }) => (
<Box <Box
sx={{ sx={{
pl: 4.75, display: 'flex',
alignItems: 'center',
gap: 1,
pl: child.agentId ? 2.5 : 4.75,
pr: 1.5, pr: 1.5,
py: 0.5, py: child.agentId ? 0.625 : 0.5,
mx: 0.5, mx: 0.5,
borderRadius: 1, borderRadius: 1,
borderLeft: `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`, borderLeft: `3px solid ${isActive ? NAV_ACTIVE_ACCENT : 'transparent'}`,
@@ -271,18 +281,43 @@ export function Sidebar({
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
<Typography {child.agentId && (
variant="body2" <AgentAvatar
sx={{ agent={{ id: child.agentId, name: child.label, role: child.role ?? '' }}
color: isActive ? TEXT_WHITE : TEXT_MUTED, size={26}
fontWeight: isActive ? 600 : 400, />
fontSize: '0.8125rem', )}
letterSpacing: '-0.01em', <Box sx={{ minWidth: 0 }}>
whiteSpace: 'nowrap', <Typography
}} variant="body2"
> sx={{
{child.label} color: isActive ? TEXT_WHITE : TEXT_MUTED,
</Typography> fontWeight: isActive ? 600 : 400,
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> </Box>
)} )}
</NavLink> </NavLink>
@@ -315,7 +350,7 @@ export function Sidebar({
width: 32, width: 32,
height: 32, height: 32,
fontSize: '0.75rem', fontSize: '0.75rem',
bgcolor: '#b8975a', bgcolor: DS_NEUTRAL.gold,
flexShrink: 0, flexShrink: 0,
}} }}
> >
@@ -338,7 +373,7 @@ export function Sidebar({
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
color: '#64748b', color: DS_SLATE[500],
fontSize: '0.7rem', fontSize: '0.7rem',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
@@ -355,7 +390,7 @@ export function Sidebar({
<IconButton <IconButton
onClick={onToggle} onClick={onToggle}
size="small" size="small"
sx={{ color: '#64748b', flexShrink: 0 }} sx={{ color: DS_SLATE[500], flexShrink: 0 }}
> >
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />} {collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</IconButton> </IconButton>
+9 -7
View File
@@ -2,6 +2,7 @@ import { useNavigate, useLocation } from 'react-router'
import { Box, Button, IconButton, Typography } from '@mui/material' import { Box, Button, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useCompareStore } from '../../stores/compareStore' import { useCompareStore } from '../../stores/compareStore'
import { DS_BRAND, DS_NEUTRAL } from '../../lib/ds'
const TYPE_DOT: Record<string, string> = { const TYPE_DOT: Record<string, string> = {
VERIFIED_PORTFOLIO: '#152642', VERIFIED_PORTFOLIO: '#152642',
@@ -17,11 +18,12 @@ export function CompareTray() {
if (!isDemand) return null if (!isDemand) return null
// Die Verzweigung über `resultType` genügt der Union — kein Cast nötig.
const getTitle = (item: (typeof compareItems)[number]) => { const getTitle = (item: (typeof compareItems)[number]) => {
if (item.resultType === 'FUTURE_AVAILABILITY') { 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 ( return (
@@ -33,7 +35,7 @@ export function CompareTray() {
right: 0, right: 0,
zIndex: 1300, zIndex: 1300,
height: 56, height: 56,
bgcolor: '#0f1923', bgcolor: DS_NEUTRAL.sidebar,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
px: 3, px: 3,
@@ -42,7 +44,7 @@ export function CompareTray() {
transition: 'transform 0.25s ease', 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) Vergleich ({compareItems.length}/4)
</Typography> </Typography>
@@ -62,7 +64,7 @@ export function CompareTray() {
}} }}
> >
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: TYPE_DOT[item.resultType] ?? '#64748b', flexShrink: 0 }} /> <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)} {getTitle(item)}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', flexShrink: 0 }}> <Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', flexShrink: 0 }}>
@@ -71,7 +73,7 @@ export function CompareTray() {
<IconButton <IconButton
size="small" size="small"
onClick={() => removeFromCompare(item.matchId)} 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} /> <X size={12} />
</IconButton> </IconButton>
@@ -92,7 +94,7 @@ export function CompareTray() {
variant="contained" variant="contained"
size="small" size="small"
onClick={() => navigate('/demand/compare')} 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 Vergleich starten
</Button> </Button>
+44 -9
View File
@@ -1,10 +1,12 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material' 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 { useNavigate } from 'react-router'
import { useNeedProfiles } from '../../hooks/useNeeds' import { useNeedProfiles } from '../../hooks/useNeeds'
import { useMatches } from '../../hooks/useMatches' import { useMatches } from '../../hooks/useMatches'
import { useVisitReportWarnings } from '../../hooks/useVisitAssignments'
import { ROUTES } from '../../lib/constants' import { ROUTES } from '../../lib/constants'
import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds'
function loadSeenCounts(): Record<string, number> { function loadSeenCounts(): Record<string, number> {
try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') } try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') }
@@ -22,6 +24,9 @@ export function NotificationButton() {
const { data: needs = [] } = useNeedProfiles() const { data: needs = [] } = useNeedProfiles()
const { data: allMatches = [] } = useMatches() 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 profilesWithMatches = useMemo(() => {
const active = needs.filter(n => const active = needs.filter(n =>
@@ -45,7 +50,7 @@ export function NotificationButton() {
() => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)), () => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)),
[profilesWithMatches, seenCounts], [profilesWithMatches, seenCounts],
) )
const badgeCount = newProfiles.length const badgeCount = newProfiles.length + visitWarnings.length
function handleOpen(e: React.MouseEvent<HTMLElement>) { function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget) setAnchorEl(e.currentTarget)
@@ -67,7 +72,7 @@ export function NotificationButton() {
return ( 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"> <Badge badgeContent={badgeCount || null} color="error">
<Bell size={20} /> <Bell size={20} />
</Badge> </Badge>
@@ -81,7 +86,37 @@ export function NotificationButton() {
transformOrigin={{ vertical: 'top', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 300, p: 2 } } }} 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 Neue Treffer
</Typography> </Typography>
@@ -98,18 +133,18 @@ export function NotificationButton() {
sx={{ sx={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer', 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 }}> <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} {need.companyName}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.7rem' }}> <Typography variant="caption" sx={{ color: DS_SLATE[400], fontSize: '0.7rem' }}>
ab {minScore}% ab {minScore}%
</Typography> </Typography>
</Box> </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 {count} Treffer
</Typography> </Typography>
</Box> </Box>
@@ -117,7 +152,7 @@ export function NotificationButton() {
<Divider sx={{ my: 1.25 }} /> <Divider sx={{ my: 1.25 }} />
<Box <Box
onClick={() => { navigate(ROUTES.DEMAND.AI_SEARCH); handleClose() }} 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 Alle Suchprofile anzeigen
</Box> </Box>
@@ -1,6 +1,7 @@
import { Chip } from '@mui/material' import { Chip } from '@mui/material'
import { Building2 } from 'lucide-react' import { Building2 } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { DS_BG, DS_SLATE } from '../../lib/ds'
export function OrganizationContextBadge() { export function OrganizationContextBadge() {
const { currentUser } = useSessionStore() const { currentUser } = useSessionStore()
@@ -13,7 +14,7 @@ export function OrganizationContextBadge() {
variant="outlined" variant="outlined"
icon={<Building2 size={12} color="#64748b" />} icon={<Building2 size={12} color="#64748b" />}
label={currentUser.organizationName} 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