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:
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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 →
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 2–4 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu.
|
Fügen Sie 2–4 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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,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}>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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 1–2 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 2000–3000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²',
|
'Lagerhalle 2000–3000 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 & erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')} m²`} size="small" sx={{ fontSize: 10, height: 18 }} />
|
<Chip label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} 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}
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
}
|
||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user