diff --git a/eslint.config.js b/eslint.config.js index c81f6f3..c79497c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,19 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + // Der Unterstrich ist im Projekt die etablierte Kennzeichnung für + // bewusst ungenutzte Parameter — etwa wenn eine Schnittstelle ein + // Argument vorschreibt, das eine bestimmte Implementierung nicht + // braucht. Ohne diese Ausnahme meldete die Regel genau die Fälle, + // die der Autor bereits als «gewollt» markiert hat. + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + }], + }, }, // ── Design Token Enforcement ──────────────────────────────────────────────── // New hardcoded hex colors in components/pages are blocked (error). diff --git a/scripts/check-tokens.js b/scripts/check-tokens.js index 2f21541..7d95b8f 100644 --- a/scripts/check-tokens.js +++ b/scripts/check-tokens.js @@ -18,7 +18,11 @@ import { join, extname } from 'node:path' // Hex literals allowed before CI blocks the build. // This is a ratchet — lower it as migration progresses. Never raise it. // Baseline after initial token migration (2026-05-24): 1958 -const THRESHOLD = 1958 +// Nach der Token-Migration aller Farb-Properties (bgcolor/color/borderColor/ +// background/fill/stroke): 925. Die verbleibenden Treffer stehen in +// Farbverläufen, `rgba()`-Werten, Icon-Attributen und Datentabellen, die diese +// Zählung mitnimmt, die ESLint-Regel aber nicht erfasst. +const THRESHOLD = 925 const HEX_PATTERN = /#[0-9A-Fa-f]{3,8}\b/g diff --git a/src/App.tsx b/src/App.tsx index 0f6ab45..481fc4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AppShell } from './components/layout' import { ProtectedRoute } from './components/auth' import { WorkspaceType } from './domain/enums' import { useSessionStore } from './stores/sessionStore' +import { AGENT_SECTIONS, AGENT_SECTION_PARAM, ROUTES } from './lib/constants' // Die Flächensuche ist entfernt — es bleibt ein einziger erreichbarer Einstieg. const WORKSPACE_HOME: Record = { @@ -28,12 +29,15 @@ const ReminderManager = lazy(() => import('./pages/supply/ReminderManager')) const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence')) const NewListing = lazy(() => import('./pages/supply/NewListing')) const MyListings = lazy(() => import('./pages/supply/MyListings')) +const Besichtigungen = lazy(() => import('./pages/supply/Besichtigungen')) -// Property On — «Teamübersicht» -const Teamuebersicht = lazy(() => import('./pages/supply/Teamuebersicht')) -const Personalverwaltung = lazy(() => import('./pages/supply/Personalverwaltung')) -const Bearbeitungsverlauf = lazy(() => import('./pages/supply/Bearbeitungsverlauf')) -const KanaeleSysteme = lazy(() => import('./pages/supply/KanaeleSysteme')) +// Property On — «Meine Agenten» +const MeineAgenten = lazy(() => import('./pages/supply/MeineAgenten')) + +/** Alter Unterseitenpfad → Reiter der Hauptseite. Hält Lesezeichen am Leben. */ +function TeamSectionRedirect({ section }: { section: string }) { + return +} function App() { return ( @@ -52,25 +56,35 @@ function App() { }> } /> } /> + {/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */} + } /> } /> } /> - } /> - } /> - } /> } /> - } /> - {/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4). - Bewusst flache Geschwisterrouten statt : die App kennt - keine einzige Feature-Route mit eigenem Outlet, ein Novum hier - würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Die fünf Agentenseiten. Vier behalten den Pfad ihres Vorgängers. */} + } /> + } /> + } /> + } /> + } /> + + {/* Property On — «Meine Agenten». Die drei Verwaltungsbereiche sind + seit Runde 4 Reiter derselben Seite; die alten Unterseitenpfade + leiten dorthin um, damit bestehende Lesezeichen nicht brechen. */} + } /> + } + /> + } + /> + } + /> diff --git a/src/assets/team/lea.jpg b/src/assets/team/lea.jpg deleted file mode 100644 index 4c284da..0000000 Binary files a/src/assets/team/lea.jpg and /dev/null differ diff --git a/src/assets/team/reto.jpg b/src/assets/team/reto.jpg deleted file mode 100644 index 2caea47..0000000 Binary files a/src/assets/team/reto.jpg and /dev/null differ diff --git a/src/components/ai-monitoring/AIErrorBadge.tsx b/src/components/ai-monitoring/AIErrorBadge.tsx index dfe8b31..5c0b688 100644 --- a/src/components/ai-monitoring/AIErrorBadge.tsx +++ b/src/components/ai-monitoring/AIErrorBadge.tsx @@ -1,15 +1,16 @@ import { GenericBadge } from '../shared/GenericBadge' import type { AIOutputError } from '../../domain/aiOutput' +import { DS_ACCENT } from '../../lib/ds' const ERROR_CONFIG: Record = { - SCHEMA_VALIDATION: { label: 'Schema', color: '#ea580c' }, - PROVIDER_TIMEOUT: { label: 'Timeout', color: '#c0392b' }, - INVALID_JSON: { label: 'JSON', color: '#c0392b' }, - EMPTY_RESPONSE: { label: 'Leer', color: '#d97706' }, - RATE_LIMIT: { label: 'Rate Limit', color: '#7c3aed' }, + SCHEMA_VALIDATION: { label: 'Schema', color: DS_ACCENT.warning.strong }, + PROVIDER_TIMEOUT: { label: 'Timeout', color: DS_ACCENT.danger.main }, + INVALID_JSON: { label: 'JSON', color: DS_ACCENT.danger.main }, + EMPTY_RESPONSE: { label: 'Leer', color: DS_ACCENT.warning.main }, + RATE_LIMIT: { label: 'Rate Limit', color: DS_ACCENT.violet.main }, } export function AIErrorBadge({ error }: { error: AIOutputError }) { - const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: '#c0392b' } + const { label, color } = ERROR_CONFIG[error.type] ?? { label: error.type, color: DS_ACCENT.danger.main } return } diff --git a/src/components/ai-monitoring/AIMonitoringEmptyState.tsx b/src/components/ai-monitoring/AIMonitoringEmptyState.tsx index 3c598d7..bdabd19 100644 --- a/src/components/ai-monitoring/AIMonitoringEmptyState.tsx +++ b/src/components/ai-monitoring/AIMonitoringEmptyState.tsx @@ -1,5 +1,6 @@ import { Box, Typography } from '@mui/material' import { Bot, Filter, MousePointer } from 'lucide-react' +import { DS_SLATE } from '../../lib/ds' interface Props { context: 'no-outputs' | 'filtered-empty' | 'no-selection' @@ -8,19 +9,19 @@ interface Props { const CONFIG = { 'no-outputs': { icon: Bot, - color: '#94a3b8', + color: DS_SLATE[400], title: 'Keine AI-Outputs', desc: 'Es wurden noch keine AI-Outputs generiert.', }, 'filtered-empty': { icon: Filter, - color: '#94a3b8', + color: DS_SLATE[400], title: 'Keine Ergebnisse', desc: 'Kein AI-Output entspricht den aktiven Filtern.', }, 'no-selection': { icon: MousePointer, - color: '#94a3b8', + color: DS_SLATE[400], title: 'Output auswählen', desc: 'Klicken Sie auf eine Zeile, um Details und Aktionen anzuzeigen.', }, @@ -31,7 +32,7 @@ export function AIMonitoringEmptyState({ context }: Props) { return ( - {title} + {title} {desc} ) diff --git a/src/components/ai-monitoring/AIOutputDetailPanel.tsx b/src/components/ai-monitoring/AIOutputDetailPanel.tsx index d5869f5..a659684 100644 --- a/src/components/ai-monitoring/AIOutputDetailPanel.tsx +++ b/src/components/ai-monitoring/AIOutputDetailPanel.tsx @@ -6,6 +6,7 @@ import { AIErrorBadge } from './AIErrorBadge' import { AIReviewActionToolbar } from './AIReviewActionToolbar' import type { AIOutput, AIOutputType } from '../../domain/aiOutput' import type { ReviewStatus } from '../../domain/enums' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' const TYPE_LABELS: Record = { NEED_PARSE: 'Bedarf-Parsing', @@ -81,22 +82,22 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt {/* Metadata */} - + {output.id} - + {new Date(output.createdAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })} - + {MODEL_LABELS[output.model] ?? output.model} - + {output.provider} @@ -104,14 +105,14 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt - + {output.inputHash} - + {ENTITY_TYPE_LABELS[output.relatedEntityType] ?? output.relatedEntityType}{' '} - + {output.relatedEntityId} @@ -125,7 +126,7 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt )} {output.costEstimate != null && ( - + ${output.costEstimate.toFixed(4)} @@ -144,7 +145,7 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt
{output.error.message} {output.error.recoverable && ( - + Wiederholbar — kann erneut ausgelöst werden. )} @@ -154,18 +155,18 @@ export function AIOutputDetailPanel({ output, onClose, onUpdateStatus, isSubmitt {/* Output preview */} - + Output-Vorschau = { - UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' }, - IN_REVIEW: { label: 'In Prüfung', color: '#d97706' }, - APPROVED: { label: 'Genehmigt', color: '#1a7a4a' }, - REJECTED: { label: 'Abgelehnt', color: '#c0392b' }, - FLAGGED: { label: 'Markiert', color: '#ea580c' }, + UNREVIEWED: { label: 'Ungeprüft', color: DS_SLATE[400] }, + IN_REVIEW: { label: 'In Prüfung', color: DS_ACCENT.warning.main }, + APPROVED: { label: 'Genehmigt', color: DS_ACCENT.success.main }, + REJECTED: { label: 'Abgelehnt', color: DS_ACCENT.danger.main }, + FLAGGED: { label: 'Markiert', color: DS_ACCENT.warning.strong }, } export function AIOutputStatusBadge({ status }: { status: ReviewStatus }) { - const { label, color } = CONFIG[status] ?? { label: status, color: '#64748b' } + const { label, color } = CONFIG[status] ?? { label: status, color: DS_SLATE[500] } return } diff --git a/src/components/ai-monitoring/AIOutputTable.tsx b/src/components/ai-monitoring/AIOutputTable.tsx index f6b3672..e89272a 100644 --- a/src/components/ai-monitoring/AIOutputTable.tsx +++ b/src/components/ai-monitoring/AIOutputTable.tsx @@ -12,6 +12,7 @@ import { PromptVersionBadge } from './PromptVersionBadge' import { AIErrorBadge } from './AIErrorBadge' import { AIMonitoringEmptyState } from './AIMonitoringEmptyState' import type { AIOutput, AIOutputType } from '../../domain/aiOutput' +import { DS_SLATE } from '../../lib/ds' const TYPE_LABELS: Record = { NEED_PARSE: 'Bedarf-Parsing', @@ -59,7 +60,7 @@ export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props) return ( - + Zeitpunkt Typ Modell @@ -108,7 +109,7 @@ export function AIOutputTable({ outputs, selectedId, onSelect, isEmpty }: Props) - + {MODEL_SHORT[output.model] ?? output.model} diff --git a/src/components/ai-monitoring/AIReviewActionToolbar.tsx b/src/components/ai-monitoring/AIReviewActionToolbar.tsx index 1e8ae69..87f174f 100644 --- a/src/components/ai-monitoring/AIReviewActionToolbar.tsx +++ b/src/components/ai-monitoring/AIReviewActionToolbar.tsx @@ -2,6 +2,7 @@ import { Box, Button, Tooltip } from '@mui/material' import { CheckCircle, XCircle, Send, Copy } from 'lucide-react' import type { AIOutput } from '../../domain/aiOutput' import type { ReviewStatus } from '../../domain/enums' +import { DS_ACCENT, DS_BG, DS_SLATE } from '../../lib/ds' interface Props { output: AIOutput @@ -26,7 +27,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu disabled={isSubmitting} startIcon={} 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 @@ -38,7 +39,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu disabled={isSubmitting} startIcon={} 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 @@ -50,7 +51,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu disabled={isSubmitting} startIcon={} 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 @@ -61,7 +62,7 @@ export function AIReviewActionToolbar({ output, onUpdateStatus, onCopyJson, isSu variant="outlined" onClick={onCopyJson} startIcon={} - 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 diff --git a/src/components/ai-monitoring/PromptVersionBadge.tsx b/src/components/ai-monitoring/PromptVersionBadge.tsx index f675db7..ee8ad4e 100644 --- a/src/components/ai-monitoring/PromptVersionBadge.tsx +++ b/src/components/ai-monitoring/PromptVersionBadge.tsx @@ -1,4 +1,5 @@ import { Box, Tooltip, Typography } from '@mui/material' +import { DS_SLATE } from '../../lib/ds' interface Props { promptVersion: string @@ -14,7 +15,7 @@ export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) { gap: 0.5, px: 0.75, py: 0.2, - bgcolor: '#f1f5f9', + bgcolor: DS_SLATE[100], borderRadius: 1, border: '1px solid #e2e8f0', cursor: schemaVersion ? 'default' : undefined, @@ -22,16 +23,16 @@ export function PromptVersionBadge({ promptVersion, schemaVersion }: Props) { > {promptVersion} {schemaVersion && ( <> - + {schemaVersion} diff --git a/src/components/anfragencenter/ActiveInquiriesTab.tsx b/src/components/anfragencenter/ActiveInquiriesTab.tsx index 66114ad..6780b65 100644 --- a/src/components/anfragencenter/ActiveInquiriesTab.tsx +++ b/src/components/anfragencenter/ActiveInquiriesTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Box, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material' import { ArrowLeft, LayoutGrid, List as ListIcon, Inbox } from 'lucide-react' import { useActiveInquiries } from '../../hooks/useInquiries' @@ -7,6 +7,7 @@ import { EmptyState } from '../ui' import { InquiryList } from './InquiryList' import { InquiryCardGrid } from './InquiryCardGrid' import { InquiryDetailPanel } from './InquiryDetailPanel' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' type ViewMode = 'list' | 'grid' @@ -26,23 +27,22 @@ interface Props { export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Props = {}) { const { data: inquiries = [], isLoading } = useActiveInquiries(filters) - const [selectedId, setSelectedId] = useState(null) + const [chosenId, setChosenId] = useState(null) const [view, setView] = useState(loadViewMode()) const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - useEffect(() => { - if (typeof window !== 'undefined') { - window.localStorage.setItem(VIEW_STORAGE_KEY, view) - } - }, [view]) + // Die Ansichtswahl gehört ins Ereignis, nicht in einen Effekt — sie ändert + // sich nur durch einen Klick, und der weiss selbst, wann er stattfindet. + function changeView(next: ViewMode) { + setView(next) + if (typeof window !== 'undefined') window.localStorage.setItem(VIEW_STORAGE_KEY, next) + } - // Auto-select first inquiry on desktop only - useEffect(() => { - if (!isMobile && !selectedId && inquiries.length > 0) { - setSelectedId(inquiries[0].id) - } - }, [inquiries, selectedId, isMobile]) + // Auf grossen Schirmen steht ohne eigene Wahl die erste Anfrage offen — + // abgeleitet statt in einem Effekt nachgetragen. + const selectedId = chosenId ?? (isMobile ? null : inquiries[0]?.id ?? null) + const setSelectedId = setChosenId if (!isLoading && inquiries.length === 0) { return ( @@ -97,21 +97,21 @@ export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Pr }} > - + Anfragen - + {inquiries.length} - setView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}> + changeView('list')} sx={{ bgcolor: view === 'list' ? '#e0e7ff' : 'transparent', color: view === 'list' ? '#152642' : '#64748b' }}> - setView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}> + changeView('grid')} sx={{ bgcolor: view === 'grid' ? '#e0e7ff' : 'transparent', color: view === 'grid' ? '#152642' : '#64748b' }}> @@ -127,7 +127,7 @@ export function ActiveInquiriesTab({ filters, emptyTitle, emptyDescription }: Pr {/* Right column: detail (desktop only) */} {!isMobile && ( - + {selectedId ? ( ) : ( diff --git a/src/components/anfragencenter/AiOfferEmailButton.tsx b/src/components/anfragencenter/AiOfferEmailButton.tsx index 48cd1e4..9f96370 100644 --- a/src/components/anfragencenter/AiOfferEmailButton.tsx +++ b/src/components/anfragencenter/AiOfferEmailButton.tsx @@ -1,6 +1,7 @@ import { Box, Button, CircularProgress, Typography } from '@mui/material' import { Sparkles } from 'lucide-react' import { useGenerateOfferEmail } from '../../hooks/useAI' +import { DS_ACCENT } from '../../lib/ds' interface AiOfferEmailButtonProps { needTitle: string @@ -37,9 +38,9 @@ export function AiOfferEmailButton({ disabled={generateOfferEmail.isPending} sx={{ textTransform: 'none', - color: '#7c3aed', - borderColor: '#c4b5fd', - '&:hover': { bgcolor: '#f5f3ff', borderColor: '#7c3aed' }, + color: DS_ACCENT.violet.main, + borderColor: DS_ACCENT.violet.border, + '&:hover': { bgcolor: DS_ACCENT.violet.bgAlt, borderColor: DS_ACCENT.violet.main }, }} variant="outlined" > diff --git a/src/components/anfragencenter/EditableOfferFieldList.tsx b/src/components/anfragencenter/EditableOfferFieldList.tsx index 5929339..9a574c9 100644 --- a/src/components/anfragencenter/EditableOfferFieldList.tsx +++ b/src/components/anfragencenter/EditableOfferFieldList.tsx @@ -1,5 +1,6 @@ import { Box, TextField, Typography } from '@mui/material' import type { OfferEditableField } from '../../domain/offer' +import { DS_SLATE } from '../../lib/ds' interface EditableOfferFieldListProps { fields: OfferEditableField[] @@ -17,7 +18,7 @@ export function EditableOfferFieldList({ fields, values, onChange }: EditableOff - + {property.title} - + {property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m² - + {reason} @@ -102,7 +103,7 @@ export function EmbeddedPropertyCard({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - bgcolor: '#fafafa', + bgcolor: DS_NEUTRAL.offWhite, }} > - + Für Angebot auswählen @@ -130,12 +131,12 @@ export function EmbeddedPropertyCard({ textTransform: 'none', fontSize: '0.775rem', fontWeight: 600, - borderColor: '#152642', - color: '#152642', + borderColor: DS_BRAND.main, + color: DS_BRAND.main, py: 0.5, px: 1.5, minWidth: 0, - '&:hover': { bgcolor: '#152642', color: 'white', borderColor: '#152642' }, + '&:hover': { bgcolor: DS_BRAND.main, color: 'white', borderColor: DS_BRAND.main }, }} > Angebot → diff --git a/src/components/anfragencenter/InquiryCard.tsx b/src/components/anfragencenter/InquiryCard.tsx index 46776fd..1248dc5 100644 --- a/src/components/anfragencenter/InquiryCard.tsx +++ b/src/components/anfragencenter/InquiryCard.tsx @@ -1,7 +1,9 @@ import { Box, Paper, Typography } from '@mui/material' import { Building2 } from 'lucide-react' import type { Inquiry } from '../../domain/inquiry' -import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils' +import { formatInquiryDate } from './inquiryUtils' +import { usePropertyLookup } from '../../hooks/usePropertyLookup' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' interface InquiryCardProps { inquiry: Inquiry @@ -10,6 +12,7 @@ interface InquiryCardProps { } export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) { + const { propertyTitle } = usePropertyLookup() const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0 return ( @@ -39,7 +42,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) { variant="body2" sx={{ fontWeight: hasUnread ? 700 : 600, - color: '#0f172a', + color: DS_SLATE[900], fontSize: '0.85rem', lineHeight: 1.3, }} @@ -53,7 +56,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) { minWidth: 20, height: 20, borderRadius: '50%', - bgcolor: '#2563eb', + bgcolor: DS_ACCENT.blue.strong, color: 'white', fontSize: '0.65rem', fontWeight: 700, @@ -73,7 +76,7 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) { variant="body2" sx={{ fontWeight: hasUnread ? 600 : 500, - color: '#1e293b', + color: DS_SLATE[800], fontSize: '0.85rem', display: '-webkit-box', WebkitLineClamp: 2, @@ -84,19 +87,19 @@ export function InquiryCard({ inquiry, selected, onClick }: InquiryCardProps) { {inquiry.subject} - + - {propertyLabelFromId(inquiry.propertyId)} + {propertyTitle(inquiry.propertyId)} - + {formatInquiryDate(inquiry.createdAt)} {inquiry.matchScore !== undefined && ( - + {inquiry.matchScore}% )} diff --git a/src/components/anfragencenter/InquiryChat.tsx b/src/components/anfragencenter/InquiryChat.tsx index 3ce0e0c..7651fec 100644 --- a/src/components/anfragencenter/InquiryChat.tsx +++ b/src/components/anfragencenter/InquiryChat.tsx @@ -4,6 +4,7 @@ import { FileText } from 'lucide-react' import type { Inquiry, Attachment } from '../../domain/inquiry' import { InquiryMessageBubble } from './InquiryMessageBubble' import { InquiryReplyComposer } from './InquiryReplyComposer' +import { DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' interface InquiryChatProps { inquiry: Inquiry @@ -40,9 +41,9 @@ export function InquiryChat({ sx={{ textTransform: 'none', fontSize: '0.75rem', - borderColor: '#cbd5e1', - color: '#475569', - '&:hover': { borderColor: '#152642', color: '#152642', bgcolor: '#f0f4f8' }, + borderColor: DS_SLATE[300], + color: DS_SLATE[600], + '&:hover': { borderColor: DS_BRAND.main, color: DS_BRAND.main, bgcolor: DS_NEUTRAL.paperTint }, }} > Angebot erstellen diff --git a/src/components/anfragencenter/InquiryDetailPanel.tsx b/src/components/anfragencenter/InquiryDetailPanel.tsx index 1253a6e..8528278 100644 --- a/src/components/anfragencenter/InquiryDetailPanel.tsx +++ b/src/components/anfragencenter/InquiryDetailPanel.tsx @@ -67,7 +67,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { }} > - + {inquiry.tenantName} {inquiry.tenantCompany ? ` · ${inquiry.tenantCompany}` : ''} {inquiry.tenantEmail ? ` · ${inquiry.tenantEmail}` : ''} @@ -76,7 +76,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { variant="body1" sx={{ fontWeight: 600, - color: '#0f172a', + color: DS_SLATE[900], fontSize: '0.95rem', overflow: 'hidden', textOverflow: 'ellipsis', @@ -92,7 +92,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { variant="outlined" startIcon={} 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 @@ -105,7 +105,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { px: 2, py: 1, borderBottom: '1px solid #e2e8f0', - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], display: 'flex', alignItems: 'center', gap: 1.5, @@ -119,7 +119,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { borderRadius: 1, overflow: 'hidden', flexShrink: 0, - bgcolor: '#e8e7e4', + bgcolor: DS_BG.muted, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -138,11 +138,11 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { {property?.title ?? '—'} - + {property?.location.city} @@ -155,7 +155,7 @@ export function InquiryDetailPanel({ inquiryId }: InquiryDetailPanelProps) { size="small" endIcon={} 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 @@ -220,6 +220,7 @@ const OfferCreationWizardComponent = lazy(() => ) import type { Inquiry } from '../../domain/inquiry' +import { DS_BG, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' function PreparationWizardLazy(props: { inquiryId: string; inquiry: Inquiry; onClose: () => void }) { return ( diff --git a/src/components/anfragencenter/InquiryListRow.tsx b/src/components/anfragencenter/InquiryListRow.tsx index cc93dae..221d199 100644 --- a/src/components/anfragencenter/InquiryListRow.tsx +++ b/src/components/anfragencenter/InquiryListRow.tsx @@ -1,7 +1,9 @@ import { Box, Typography } from '@mui/material' import { Building2 } from 'lucide-react' import type { Inquiry } from '../../domain/inquiry' -import { formatInquiryDate, propertyLabelFromId } from './inquiryUtils' +import { formatInquiryDate } from './inquiryUtils' +import { usePropertyLookup } from '../../hooks/usePropertyLookup' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' interface InquiryListRowProps { inquiry: Inquiry @@ -10,6 +12,7 @@ interface InquiryListRowProps { } export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowProps) { + const { propertyTitle } = usePropertyLookup() const hasUnread = !inquiry.isRead && inquiry.unreadCount > 0 return ( @@ -40,7 +43,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro minWidth: 18, height: 18, borderRadius: '50%', - bgcolor: '#2563eb', + bgcolor: DS_ACCENT.blue.strong, color: 'white', fontSize: '0.6rem', fontWeight: 700, @@ -59,7 +62,7 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro variant="body2" sx={{ fontWeight: hasUnread ? 600 : 500, - color: '#1e293b', + color: DS_SLATE[800], fontSize: '0.8125rem', mb: 0.5, overflow: 'hidden', @@ -69,18 +72,18 @@ export function InquiryListRow({ inquiry, selected, onClick }: InquiryListRowPro > {inquiry.subject} - + - {propertyLabelFromId(inquiry.propertyId)} + {propertyTitle(inquiry.propertyId)} {inquiry.matchScore !== undefined && ( - + {inquiry.matchScore}% )} - + {formatInquiryDate(inquiry.createdAt)} diff --git a/src/components/anfragencenter/InquiryMessageBubble.tsx b/src/components/anfragencenter/InquiryMessageBubble.tsx index 170ae91..9575e43 100644 --- a/src/components/anfragencenter/InquiryMessageBubble.tsx +++ b/src/components/anfragencenter/InquiryMessageBubble.tsx @@ -2,6 +2,7 @@ import { Box, Typography } from '@mui/material' import { Paperclip } from 'lucide-react' import type { InquiryMessage } from '../../domain/inquiry' import { formatInquiryDate, formatFileSize } from './inquiryUtils' +import { DS_SLATE } from '../../lib/ds' interface InquiryMessageBubbleProps { message: InquiryMessage @@ -18,9 +19,9 @@ export function InquiryMessageBubble({ message }: InquiryMessageBubbleProps) { ([]) + const [ownAttachments, setOwnAttachments] = useState([]) const [isAIDraft, setIsAIDraft] = useState(false) const sendReply = useSendInquiryReply() const showToast = useToastStore(s => s.showToast) - useEffect(() => { - if (pendingAttachment) { - setAttachments(prev => { - if (prev.find(a => a.id === pendingAttachment.id)) return prev - return [...prev, pendingAttachment] - }) - onPendingAttachmentConsumed?.() - } - }, [pendingAttachment, onPendingAttachmentConsumed]) + /** + * Der übergebene Anhang wird nur noch angezeigt, nicht mehr in den eigenen + * Zustand kopiert. Vorher tat das ein Effekt — und weil er beim Rendern + * `setState` rief, lief jeder Anhang durch zwei Renderdurchgänge, in deren + * erstem er noch fehlte. Abgemeldet wird er jetzt dort, wo er tatsächlich + * verbraucht ist: beim Entfernen oder beim Senden. + */ + const attachments = useMemo(() => { + if (!pendingAttachment) return ownAttachments + if (ownAttachments.some(a => a.id === pendingAttachment.id)) return ownAttachments + return [...ownAttachments, pendingAttachment] + }, [ownAttachments, pendingAttachment]) const sending = sendReply.isPending @@ -62,7 +66,7 @@ export function InquiryReplyComposer({ const handleAddMockAttachment = () => { const name = `Anhang_${attachments.length + 1}.pdf` - setAttachments(prev => [ + setOwnAttachments(prev => [ ...prev, { id: crypto.randomUUID(), @@ -74,7 +78,8 @@ export function InquiryReplyComposer({ } const handleRemoveAttachment = (id: string) => { - setAttachments(prev => prev.filter(a => a.id !== id)) + if (id === pendingAttachment?.id) onPendingAttachmentConsumed?.() + else setOwnAttachments(prev => prev.filter(a => a.id !== id)) } const handleSend = async () => { @@ -92,7 +97,8 @@ export function InquiryReplyComposer({ } showToast('Antwort gesendet', 'success') setBody('') - setAttachments([]) + setOwnAttachments([]) + onPendingAttachmentConsumed?.() setIsAIDraft(false) onSent?.() } @@ -101,7 +107,7 @@ export function InquiryReplyComposer({ - + KI-Entwurf — bitte prüfen und anpassen @@ -134,7 +140,7 @@ export function InquiryReplyComposer({ rows={7} placeholder="Antwort verfassen..." fullWidth - sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: '#ddd6fe' }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}} + sx={isAIDraft ? { '& .MuiOutlinedInput-root': { borderColor: DS_ACCENT.violet.borderSoft }, '& fieldset': { borderColor: '#ddd6fe !important' } } : {}} /> {attachments.length > 0 && ( @@ -160,7 +166,7 @@ export function InquiryReplyComposer({ {a.fileName} {a.fileSize && ( - + {formatFileSize(a.fileSize)} )} @@ -181,9 +187,9 @@ export function InquiryReplyComposer({ sx={{ textTransform: 'none', fontSize: '0.75rem', - color: '#7c3aed', - borderColor: '#ddd6fe', - '&:hover': { bgcolor: '#f5f3ff', borderColor: '#c4b5fd' }, + color: DS_ACCENT.violet.main, + borderColor: DS_ACCENT.violet.borderSoft, + '&:hover': { bgcolor: DS_ACCENT.violet.bgAlt, borderColor: DS_ACCENT.violet.border }, }} variant="outlined" > @@ -206,7 +212,7 @@ export function InquiryReplyComposer({ } onClick={handleSend} disabled={sending || !body.trim()} - sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }} > Antwort senden diff --git a/src/components/anfragencenter/LatentInquiriesTab.tsx b/src/components/anfragencenter/LatentInquiriesTab.tsx index 644869d..fe8edd3 100644 --- a/src/components/anfragencenter/LatentInquiriesTab.tsx +++ b/src/components/anfragencenter/LatentInquiriesTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Box, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material' import { ArrowLeft, Sparkles } from 'lucide-react' import { usePublicNeeds, useLatentNeedById } from '../../hooks/useLatentNeeds' @@ -6,23 +6,25 @@ import { EmptyState } from '../ui' import { PublicNeedList } from './PublicNeedList' import { PublicNeedDetail } from './PublicNeedDetail' import { OwnPropertyMatchList } from './OwnPropertyMatchList' +import { DS_BRAND } from '../../lib/ds' type MobileView = 'list' | 'detail' | 'properties' export function LatentInquiriesTab() { const { data: needs = [] } = usePublicNeeds() - const [selectedNeedId, setSelectedNeedId] = useState(null) - const { data: selectedNeed } = useLatentNeedById(selectedNeedId) const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('md')) const isTablet = useMediaQuery(theme.breakpoints.between('md', 'lg')) const [mobileView, setMobileView] = useState('list') - useEffect(() => { - if (!isMobile && !selectedNeedId && needs.length > 0) { - setSelectedNeedId(needs[0].id) - } - }, [needs, selectedNeedId, isMobile]) + // Ohne eigene Wahl steht auf grossen Schirmen der erste Eintrag offen — + // abgeleitet statt in einem Effekt nachgetragen. Der Effekt löste einen + // zweiten Renderdurchgang aus, in dem die Detailspalte noch leer war. + const [chosenNeedId, setChosenNeedId] = useState(null) + const selectedNeedId = chosenNeedId ?? (isMobile ? null : needs[0]?.id ?? null) + const setSelectedNeedId = setChosenNeedId + + const { data: selectedNeed } = useLatentNeedById(selectedNeedId) const handleSelectNeed = (id: string) => { setSelectedNeedId(id) @@ -47,7 +49,7 @@ export function LatentInquiriesTab() { 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 → diff --git a/src/components/anfragencenter/MockPdfPreview.tsx b/src/components/anfragencenter/MockPdfPreview.tsx index a4d4415..7a697f4 100644 --- a/src/components/anfragencenter/MockPdfPreview.tsx +++ b/src/components/anfragencenter/MockPdfPreview.tsx @@ -1,9 +1,10 @@ import { Box, Divider, Typography } from '@mui/material' import { Building2, FileText, MapPin } from 'lucide-react' import { useOfferWizardStore } from '../../stores/offerWizardStore' -import { mockProperties } from '../../mock-data/properties' +import { usePropertyLookup } from '../../hooks/usePropertyLookup' import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport' import type { Property } from '../../domain/property' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' interface MockPdfPreviewProps { fields: Record @@ -79,7 +80,8 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock const needTitle = useOfferWizardStore(s => s.needTitle) const propertyIds = useOfferWizardStore(s => s.selectedPropertyIds) - const properties = mockProperties.filter(p => propertyIds.includes(p.id)) + const { findProperties } = usePropertyLookup() + const properties = findProperties(propertyIds) const hasPropertyPage = fieldSelections.length > 0 && properties.length > 0 const v = (id: string) => fields[id] ?? fallbackFields[id] ?? '' @@ -98,21 +100,21 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock }} > {/* ── Page 1 — Offer letter ── */} - + Angebotsvorschau (PDF) - + Wincasa AG · Zürich - + {new Date().toLocaleDateString('de-CH')} - + Angebot: {needTitle} @@ -146,7 +148,7 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock ))} {properties.length === 0 && ( - + Keine Objekte ausgewählt. )} @@ -164,11 +166,11 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock {hasPropertyPage && ( <> - - + + Seite 2 — Objektdetails - + ) : ( - + )} {/* Info */} - + {p.title} - + {p.location.city}{p.location.district ? ` · ${p.location.district}` : ''} @@ -228,10 +230,10 @@ export function MockPdfPreview({ fields, fallbackFields, fieldSelections }: Mock if (!val) return null return ( - + {FIELD_LABELS[key] ?? key} - + {val} diff --git a/src/components/anfragencenter/OfferChatComposer.tsx b/src/components/anfragencenter/OfferChatComposer.tsx index c1a9849..1547ff3 100644 --- a/src/components/anfragencenter/OfferChatComposer.tsx +++ b/src/components/anfragencenter/OfferChatComposer.tsx @@ -7,9 +7,10 @@ import { useLatentNeedById } from '../../hooks/useLatentNeeds' import { useSessionStore } from '../../stores/sessionStore' import { useToastStore } from '../../stores/toastStore' import { AiOfferEmailButton } from './AiOfferEmailButton' -import { mockProperties } from '../../mock-data/properties' +import { usePropertyLookup } from '../../hooks/usePropertyLookup' import { deterministicMatchScore } from './latentNeedUtils' import { formatFileSize } from './inquiryUtils' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' export function OfferChatComposer() { const subject = useOfferWizardStore(s => s.messageSubject) @@ -32,10 +33,8 @@ export function OfferChatComposer() { const currentUser = useSessionStore(s => s.currentUser) const showToast = useToastStore(s => s.showToast) - const propertyTitles = selectedPropertyIds.map(id => { - const p = mockProperties.find(pp => pp.id === id) - return p?.title ?? id - }) + const { propertyTitle } = usePropertyLookup() + const propertyTitles = selectedPropertyIds.map(propertyTitle) const scores = needId ? selectedPropertyIds.map(id => deterministicMatchScore(id, needId)) : [] @@ -85,13 +84,13 @@ export function OfferChatComposer() { return ( - + - + Empfänger-Kontext - + Bedarf: {needTitle} · {selectedPropertyIds.length} Objekt {selectedPropertyIds.length === 1 ? '' : 'e'} im Angebot @@ -117,7 +116,7 @@ export function OfferChatComposer() { {attachments.length > 0 && ( - + Anhänge @@ -144,8 +143,8 @@ export function OfferChatComposer() { sx={{ px: 0.75, py: 0.125, - bgcolor: '#e0e7ff', - color: '#3730a3', + bgcolor: DS_ACCENT.indigo.border, + color: DS_ACCENT.indigo.dark, fontWeight: 700, fontSize: '0.65rem', borderRadius: 1, @@ -154,7 +153,7 @@ export function OfferChatComposer() { PDF )} - + {formatFileSize(a.fileSize)} removeAttachment(a.id)} sx={{ p: 0.25 }}> @@ -206,9 +205,9 @@ export function OfferChatComposer() { disabled={sendOffer.isPending || !body.trim()} sx={{ textTransform: 'none', - bgcolor: '#16a34a', + bgcolor: DS_ACCENT.success.bright, fontWeight: 600, - '&:hover': { bgcolor: '#15803d' }, + '&:hover': { bgcolor: DS_ACCENT.success.strong }, }} > Absenden diff --git a/src/components/anfragencenter/OfferCheckedAction.tsx b/src/components/anfragencenter/OfferCheckedAction.tsx index f442410..d51055a 100644 --- a/src/components/anfragencenter/OfferCheckedAction.tsx +++ b/src/components/anfragencenter/OfferCheckedAction.tsx @@ -3,6 +3,7 @@ import { CheckCircle2 } from 'lucide-react' import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useMarkOfferChecked } from '../../hooks/useOffers' import { useToastStore } from '../../stores/toastStore' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' export function OfferCheckedAction() { const offerDraftId = useOfferWizardStore(s => s.offerDraftId) @@ -45,7 +46,7 @@ export function OfferCheckedAction() { flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], p: 4, textAlign: 'center', }} @@ -55,7 +56,7 @@ export function OfferCheckedAction() { width: 96, height: 96, borderRadius: '50%', - bgcolor: '#dcfce7', + bgcolor: DS_ACCENT.success.bgAlt, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -64,10 +65,10 @@ export function OfferCheckedAction() { > - + Bereit zur Prüfung - + 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. @@ -81,12 +82,12 @@ export function OfferCheckedAction() { disabled={markChecked.isPending} sx={{ textTransform: 'none', - bgcolor: '#16a34a', + bgcolor: DS_ACCENT.success.bright, fontSize: '1rem', fontWeight: 700, px: 4, py: 1.5, - '&:hover': { bgcolor: '#15803d' }, + '&:hover': { bgcolor: DS_ACCENT.success.strong }, }} > ANGEBOT GEPRÜFT diff --git a/src/components/anfragencenter/OfferCreationPanel.tsx b/src/components/anfragencenter/OfferCreationPanel.tsx index 0421034..1a0612c 100644 --- a/src/components/anfragencenter/OfferCreationPanel.tsx +++ b/src/components/anfragencenter/OfferCreationPanel.tsx @@ -1,6 +1,7 @@ import { Box, Button, Typography } from '@mui/material' import { FileText } from 'lucide-react' import { useOfferWizardStore } from '../../stores/offerWizardStore' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' interface OfferCreationPanelProps { selectedCount: number @@ -16,7 +17,7 @@ export function OfferCreationPanel({ selectedCount, needId, needTitle }: OfferCr - + {selectedCount} Objekt{selectedCount === 1 ? '' : 'e'} ausgewählt @@ -255,7 +256,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose variant="contained" disabled={loading} onClick={() => setStep(1)} - sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }} > Weiter @@ -264,7 +265,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose @@ -273,7 +274,7 @@ export function OfferCreationWizard({ inquiryId, propertyId, tenantName, onClose diff --git a/src/components/anfragencenter/OfferFieldSelectionStep.tsx b/src/components/anfragencenter/OfferFieldSelectionStep.tsx index af19a71..acd1b33 100644 --- a/src/components/anfragencenter/OfferFieldSelectionStep.tsx +++ b/src/components/anfragencenter/OfferFieldSelectionStep.tsx @@ -5,6 +5,7 @@ import { useOfferWizardStore } from '../../stores/offerWizardStore' import { useProperties } from '../../hooks/useProperties' import { ReportObjectFieldSelector } from './ReportObjectFieldSelector' import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' const DEFAULT_FIELDS: ReportObjectFieldKey[] = ['areaSqm', 'rentPricePerSqm', 'availabilityDate'] const MANDATORY_FIELDS: ReportObjectFieldKey[] = ['title', 'location', 'mapImageUrl', 'images'] @@ -41,7 +42,7 @@ export function OfferFieldSelectionStep() { return ( - + Wählen Sie die Datenfelder, die auf der Objektseite des Angebots angezeigt werden sollen. Pflichtfelder (Titel, Standort, Foto) sind immer enthalten. @@ -77,7 +78,7 @@ export function OfferFieldSelectionStep() { diff --git a/src/components/anfragencenter/OfferPdfReviewStep.tsx b/src/components/anfragencenter/OfferPdfReviewStep.tsx index ad20ded..cf15206 100644 --- a/src/components/anfragencenter/OfferPdfReviewStep.tsx +++ b/src/components/anfragencenter/OfferPdfReviewStep.tsx @@ -7,6 +7,7 @@ import { useToastStore } from '../../stores/toastStore' import { MockPdfPreview } from './MockPdfPreview' import { EditableOfferFieldList } from './EditableOfferFieldList' import type { OfferDraft, OfferEditableField } from '../../domain/offer' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' export function OfferPdfReviewStep() { const offerDraftId = useOfferWizardStore(s => s.offerDraftId) @@ -108,7 +109,7 @@ export function OfferPdfReviewStep() { return ( - + {!pdfReady ? ( - + Inhalte bearbeiten : } onClick={handleNext} disabled={!pdfReady || markChecked.isPending} - sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }} > {markChecked.isPending ? 'Wird geprüft…' : 'Angebot senden →'} diff --git a/src/components/anfragencenter/OfferPropertySelectionStep.tsx b/src/components/anfragencenter/OfferPropertySelectionStep.tsx index 2e29dff..7ced074 100644 --- a/src/components/anfragencenter/OfferPropertySelectionStep.tsx +++ b/src/components/anfragencenter/OfferPropertySelectionStep.tsx @@ -9,6 +9,7 @@ import { useCreateOfferDraft } from '../../hooks/useOffers' import { useToastStore } from '../../stores/toastStore' import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard' import { deterministicMatchScore, assetTypeLabel } from './latentNeedUtils' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' function buildReason(propertyAssetType: string, needAssetType: string, city: string, location: string): string { if (propertyAssetType === needAssetType) { @@ -87,24 +88,24 @@ export function OfferPropertySelectionStep() { - + Bedarf - + {need.title} - + {assetTypeLabel(need.assetType)} · {need.desiredLocation} · {need.sizeRange.min}–{need.sizeRange.max} m² - + Wählen Sie passende Objekte aus Ihrem Portfolio {isLoading && } @@ -133,7 +134,7 @@ export function OfferPropertySelectionStep() { flexShrink: 0, }} > - + {selectedIds.length} Objekt{selectedIds.length === 1 ? '' : 'e'} ausgewählt diff --git a/src/components/anfragencenter/OfferWizard.tsx b/src/components/anfragencenter/OfferWizard.tsx index d2284f4..ab68d7b 100644 --- a/src/components/anfragencenter/OfferWizard.tsx +++ b/src/components/anfragencenter/OfferWizard.tsx @@ -16,6 +16,7 @@ import { OfferFieldSelectionStep } from './OfferFieldSelectionStep' import { OfferPdfReviewStep } from './OfferPdfReviewStep' import { OfferCheckedAction } from './OfferCheckedAction' import { OfferChatComposer } from './OfferChatComposer' +import { DS_SLATE } from '../../lib/ds' // 'checked' is internal — merged into pdf_review step; 4 visible steps const STEPS: { key: OfferStep; label: string }[] = [ @@ -75,14 +76,14 @@ export function OfferWizard() { }} > - + Angebot erstellen {/* Stepper */} - + {STEPS.map(s => ( diff --git a/src/components/anfragencenter/OfferWizardPdfStep.tsx b/src/components/anfragencenter/OfferWizardPdfStep.tsx index e03f1b4..92aa4eb 100644 --- a/src/components/anfragencenter/OfferWizardPdfStep.tsx +++ b/src/components/anfragencenter/OfferWizardPdfStep.tsx @@ -2,6 +2,7 @@ import { Box, Button, CircularProgress, LinearProgress, Typography } from '@mui/ import { Download, Send } from 'lucide-react' import type { OfferReportDraft } from '../../domain/offerReport' import type { Property } from '../../domain/property' +import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' interface OfferWizardPdfStepProps { generating: boolean @@ -18,13 +19,13 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert {generating ? ( <> - - + + PDF wird generiert… - + {Math.round(progress)}% @@ -36,7 +37,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert width: '100%', maxWidth: 520, height: 380, - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, display: 'flex', @@ -44,33 +45,33 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert overflow: 'hidden', }} > - - - - + + + + Angebot_{property?.title ?? 'Objekt'}.pdf - + Angebotsschreiben {draft?.editableFields.slice(0, 3).map(f => ( - {f.label} - + {f.label} + {f.value.slice(0, 80)}{f.value.length > 80 ? '…' : ''} ))} {(draft?.viewingAppointments.length ?? 0) > 0 && ( - + Besichtigungstermine {draft!.viewingAppointments.map(a => ( - + {new Date(a.date).toLocaleDateString('de-CH')} · {a.timeSlot} ))} @@ -83,7 +84,7 @@ export function OfferWizardPdfStep({ generating, progress, ready, draft, propert variant="contained" startIcon={} onClick={onDownload} - sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }} > Herunterladen diff --git a/src/components/anfragencenter/OwnPropertyMatchList.tsx b/src/components/anfragencenter/OwnPropertyMatchList.tsx index 87bd35a..44b59aa 100644 --- a/src/components/anfragencenter/OwnPropertyMatchList.tsx +++ b/src/components/anfragencenter/OwnPropertyMatchList.tsx @@ -8,6 +8,7 @@ import { deterministicMatchScore } from './latentNeedUtils' import { SelectablePropertyMatchCard } from './SelectablePropertyMatchCard' import { OfferCreationPanel } from './OfferCreationPanel' import type { LatentNeed } from '../../domain/latentNeed' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' interface OwnPropertyMatchListProps { need: LatentNeed @@ -63,7 +64,7 @@ export function OwnPropertyMatchList({ need }: OwnPropertyMatchListProps) { }} > - + Eigene Objekte )} {!isLoading && scored.length === 0 && ( - + Keine Portfolio-Objekte vorhanden )} diff --git a/src/components/anfragencenter/PreparationWizard.tsx b/src/components/anfragencenter/PreparationWizard.tsx index 2506c16..219f9ef 100644 --- a/src/components/anfragencenter/PreparationWizard.tsx +++ b/src/components/anfragencenter/PreparationWizard.tsx @@ -13,6 +13,7 @@ import { ReportObjectFieldSelector } from './ReportObjectFieldSelector' import { LatentInquiryReportPreview } from './LatentInquiryReportPreview' import { useProperties } from '../../hooks/useProperties' import { ResultType } from '../../domain/enums' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' interface Props { inquiryId: string @@ -126,7 +127,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { {/* Step 0: Select properties */} {step === 0 && ( - + Wählen Sie die Objekte aus Ihrem Portfolio, die Sie dem Interessenten vorstellen möchten: @@ -142,7 +143,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { label={ {p.title} - + {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/Jahr @@ -159,13 +160,13 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { {generating ? ( <> - - + + Bericht wird erstellt… - + {Math.round(progress)}% @@ -234,7 +235,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { fullWidth startIcon={} 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 @@ -257,7 +258,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { {/* Footer navigation */} - @@ -271,7 +272,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { variant="contained" disabled={selectedIds.length === 0} onClick={handleGenerate} - sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }} > Weiter @@ -282,7 +283,7 @@ export function PreparationWizard({ inquiryId, inquiry, onClose }: Props) { disabled={finalizing} onClick={handleFinalize} startIcon={finalizing ? : undefined} - sx={{ textTransform: 'none', bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }} > Finalisieren diff --git a/src/components/anfragencenter/PublicNeedCard.tsx b/src/components/anfragencenter/PublicNeedCard.tsx index 8b54d75..db7f14f 100644 --- a/src/components/anfragencenter/PublicNeedCard.tsx +++ b/src/components/anfragencenter/PublicNeedCard.tsx @@ -2,6 +2,7 @@ import { Box, Chip, Paper, Typography } from '@mui/material' import { MapPin } from 'lucide-react' import type { LatentNeed } from '../../domain/latentNeed' import { assetTypeLabel, latentStatusBadge } from './latentNeedUtils' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface PublicNeedCardProps { need: LatentNeed @@ -39,7 +40,7 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) }} > - + {need.title} @@ -53,7 +54,7 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) {need.tenantCompany && ( - + {need.tenantCompany} )} @@ -63,15 +64,15 @@ export function PublicNeedCard({ need, selected, onClick }: PublicNeedCardProps) size="small" sx={{ alignSelf: 'flex-start', - bgcolor: '#e0e7ff', - color: '#3730a3', + bgcolor: DS_ACCENT.indigo.border, + color: DS_ACCENT.indigo.dark, fontWeight: 600, fontSize: '0.7rem', height: 20, }} /> - + {need.desiredLocation} diff --git a/src/components/anfragencenter/PublicNeedDetail.tsx b/src/components/anfragencenter/PublicNeedDetail.tsx index 1c858e7..4235846 100644 --- a/src/components/anfragencenter/PublicNeedDetail.tsx +++ b/src/components/anfragencenter/PublicNeedDetail.tsx @@ -11,6 +11,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useProperties } from '../../hooks/useProperties' import { assetTypeLabel, deterministicMatchScore, latentStatusBadge } from './latentNeedUtils' import { EmbeddedPropertyCard } from './EmbeddedPropertyCard' +import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE } from '../../lib/ds' interface PublicNeedDetailProps { need: LatentNeed @@ -57,7 +58,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { } return ( - + {/* Scrollable content */} @@ -65,29 +66,29 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { + sx={{ bgcolor: DS_ACCENT.indigo.border, color: DS_ACCENT.indigo.dark, fontWeight: 600, fontSize: '0.7rem', height: 22 }} /> - + {need.title} {need.tenantCompany && ( - {need.tenantCompany} + {need.tenantCompany} )} {/* KI-Entscheidungsbrief */} {need.aiSummary && ( - + + sx={{ fontWeight: 700, color: DS_ACCENT.blue.main, textTransform: 'uppercase', letterSpacing: 0.8, fontSize: '0.7rem' }}> KI-Entscheidungsbrief - + {need.aiSummary} @@ -95,7 +96,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { {/* Suchprofil */} - + Suchprofil @@ -115,14 +116,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { {/* Must-haves */} {need.mustHaveCriteria.length > 0 && ( - + Must-have Kriterien {need.mustHaveCriteria.map((c, idx) => ( - {c} + {c} ))} @@ -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={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}> - + Gewichtete Präferenzen @@ -144,19 +145,19 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { {need.weightedPreferences.map((p, idx) => { const pct = Math.round(p.weight * 100) return ( - + {p.criterion} + sx={{ fontWeight: 700, color: DS_BRAND.main, fontSize: '0.75rem', bgcolor: DS_ACCENT.indigo.border, px: 1, py: 0.125, borderRadius: 1 }}> {pct}% - - + + {p.description && ( - + {p.description} )} @@ -173,14 +174,14 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { - + Ihre Top Empfehlungen {propertiesLoading ? ( ) : scored.length === 0 ? ( - + Keine Portfolio-Objekte vorhanden ) : ( @@ -204,12 +205,12 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { py: 1.25, textAlign: 'center', cursor: 'pointer', - color: '#64748b', + color: DS_SLATE[500], fontSize: '0.8rem', fontWeight: 500, border: '1px dashed #e2e8f0', borderRadius: 1.5, - '&:hover': { color: '#152642', borderColor: '#152642', bgcolor: '#f8fafc' }, + '&:hover': { color: DS_BRAND.main, borderColor: DS_BRAND.main, bgcolor: DS_SLATE[50] }, transition: 'all 0.15s', }} > @@ -229,7 +230,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { )} {selectedIds.length > 0 && !disabled && ( - + {selectedIds.length} Objekt{selectedIds.length !== 1 ? 'e' : ''} ausgewählt )} @@ -239,7 +240,7 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { fullWidth onClick={() => openWizard(need.id, need.title)} disabled={disabled} - sx={{ textTransform: 'none', bgcolor: '#152642', fontWeight: 600, '&:hover': { bgcolor: '#16304d' } }} + sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, fontWeight: 600, '&:hover': { bgcolor: DS_BRAND.hover } }} > Angebot erstellen @@ -251,13 +252,13 @@ export function PublicNeedDetail({ need }: PublicNeedDetailProps) { function ProfileBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( - + {icon} {label} - {value} + {value} ) } diff --git a/src/components/anfragencenter/PublicNeedList.tsx b/src/components/anfragencenter/PublicNeedList.tsx index 03a5a0d..986f53b 100644 --- a/src/components/anfragencenter/PublicNeedList.tsx +++ b/src/components/anfragencenter/PublicNeedList.tsx @@ -3,6 +3,7 @@ import { Sparkles } from 'lucide-react' import { usePublicNeeds } from '../../hooks/useLatentNeeds' import { PublicNeedCard } from './PublicNeedCard' import { EmptyState } from '../ui' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' interface PublicNeedListProps { selectedNeedId: string | null @@ -38,7 +39,7 @@ export function PublicNeedList({ selectedNeedId, onSelect, fullWidth }: PublicNe }} > - + Latente Bedarfe Weitere passende Objekte @@ -42,7 +43,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel ) : additionalMatches.length === 0 ? ( - + Keine weiteren Matches ) : ( @@ -53,7 +54,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel overflowX: 'auto', pb: 0.5, '&::-webkit-scrollbar': { height: 3 }, - '&::-webkit-scrollbar-thumb': { bgcolor: '#cbd5e1', borderRadius: 2 }, + '&::-webkit-scrollbar-thumb': { bgcolor: DS_SLATE[300], borderRadius: 2 }, }} > {additionalMatches.map(m => ( @@ -85,7 +86,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel gap: 1.5, p: 2, borderLeft: '1px solid #e2e8f0', - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], height: '100%', overflowY: 'auto', }} @@ -95,7 +96,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel aspectRatio: '3/2', borderRadius: 1.5, overflow: 'hidden', - bgcolor: '#f4f3f0', + bgcolor: DS_BG.subtle, }} > {image ? ( @@ -108,25 +109,25 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel )} - + {property.title} - + {property.location.city} {property.location.district ? `, ${property.location.district}` : ''} - + {property.areaSqm.toLocaleString('de-CH')} m² · CHF {property.rentPricePerSqm}/m²/Jahr - + Verfügbar ab {new Date(property.availabilityDate).toLocaleDateString('de-CH')} @@ -135,7 +136,7 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel {property.description && ( - + {property.description} )} @@ -151,14 +152,14 @@ export function RelatedPropertyCardPanel({ propertyId, inquiryId, compact }: Rel - + Weitere passende Objekte {loadingMatches ? ( ) : additionalMatches.length === 0 ? ( - + Keine weiteren Matches ) : ( @@ -187,13 +188,13 @@ function MatchSliderCard({ match }: { match: AdditionalPropertyMatch }) { bgcolor: 'white', transition: 'border-color 0.15s, box-shadow 0.15s', '&:hover': { - borderColor: '#b0aead', + borderColor: DS_NEUTRAL.stone, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', }, }} > {/* Image — 16:9 crop, compact supplementary card */} - + {match.imageUrl ? ( {match.title} - + {match.matchScore}% - + {match.location} {match.reasons[0] && ( - + + {match.reasons[0]} )} @@ -260,18 +261,18 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) { }} > - + {match.title} - + {match.matchScore}% - + {match.location} · {match.areaSqm.toLocaleString('de-CH')} m² {match.reasons[0] && ( - + + {match.reasons[0]} )} @@ -279,7 +280,7 @@ function AdditionalMatchCard({ match }: { match: AdditionalPropertyMatch }) { size="small" endIcon={} 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 diff --git a/src/components/anfragencenter/ReportObjectFieldSelector.tsx b/src/components/anfragencenter/ReportObjectFieldSelector.tsx index 44265ed..c32d088 100644 --- a/src/components/anfragencenter/ReportObjectFieldSelector.tsx +++ b/src/components/anfragencenter/ReportObjectFieldSelector.tsx @@ -1,6 +1,7 @@ import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, Typography, Button } from '@mui/material' import { ChevronDown } from 'lucide-react' import type { ReportObjectFieldKey, ReportObjectFieldSelection } from '../../domain/inquiryReport' +import { DS_SLATE } from '../../lib/ds' interface Props { propertyTitle: string @@ -104,10 +105,10 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr return ( - + Felder für: {propertyTitle} - + Pflichtfelder (immer enthalten): Titel, Standort, Karte, Fotos @@ -119,7 +120,7 @@ export function ReportObjectFieldSelector({ propertyTitle, value, onChange }: Pr {group.label} - + ({groupKeys.filter(k => value.selectedOptionalFields.includes(k)).length}/{groupKeys.length}) diff --git a/src/components/anfragencenter/SelectablePropertyMatchCard.tsx b/src/components/anfragencenter/SelectablePropertyMatchCard.tsx index 3a9b8b5..f426892 100644 --- a/src/components/anfragencenter/SelectablePropertyMatchCard.tsx +++ b/src/components/anfragencenter/SelectablePropertyMatchCard.tsx @@ -3,6 +3,7 @@ import { Building2, MapPin } from 'lucide-react' import type { Property } from '../../domain/property' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' import { assetTypeLabel } from './latentNeedUtils' +import { DS_BG, DS_SLATE } from '../../lib/ds' interface SelectablePropertyMatchCardProps { property: Property @@ -57,7 +58,7 @@ export function SelectablePropertyMatchCard({ height: 40, flexShrink: 0, borderRadius: 1, - bgcolor: '#e8e7e4', + bgcolor: DS_BG.muted, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -75,7 +76,7 @@ export function SelectablePropertyMatchCard({ variant="body2" sx={{ fontWeight: 600, - color: '#0f172a', + color: DS_SLATE[900], fontSize: '0.8125rem', lineHeight: 1.3, overflow: 'hidden', @@ -103,13 +104,13 @@ export function SelectablePropertyMatchCard({ {matchScore}% - + {property.location.city} · {assetTypeLabel(property.assetType)} · {property.areaSqm.toLocaleString('de-CH')} m² - + {reason} diff --git a/src/components/anfragencenter/inquiryUtils.ts b/src/components/anfragencenter/inquiryUtils.ts index 32ace8d..e7333b0 100644 --- a/src/components/anfragencenter/inquiryUtils.ts +++ b/src/components/anfragencenter/inquiryUtils.ts @@ -1,12 +1,6 @@ -import { mockProperties } from '../../mock-data/properties' - -const propertyMap: Record = Object.fromEntries( - mockProperties.map(p => [p.id, p.title]), -) - -export function propertyLabelFromId(id: string): string { - return propertyMap[id] ?? id -} +// Der Objektnachschlag ist nach `hooks/usePropertyLookup.ts` gewandert — er las +// hier direkt aus den Mockdaten und übersprang damit die Datenschicht. +// Übrig bleibt reine Formatierung ohne Datenzugriff. export function formatInquiryDate(iso: string): string { const d = new Date(iso) diff --git a/src/components/assistant/AssistantActionCards.tsx b/src/components/assistant/AssistantActionCards.tsx index 7db3520..51de6be 100644 --- a/src/components/assistant/AssistantActionCards.tsx +++ b/src/components/assistant/AssistantActionCards.tsx @@ -2,6 +2,7 @@ import { Box, Button, Typography } from '@mui/material' import { ArrowRight } from 'lucide-react' import { useNavigate } from 'react-router' import type { AssistantAction } from '../../domain/assistant' +import { DS_SLATE } from '../../lib/ds' interface Props { actions: AssistantAction[] @@ -32,7 +33,7 @@ export function AssistantActionCards({ actions, onExecute }: Props) { return ( - + Vorgeschlagene Aktionen diff --git a/src/components/assistant/AssistantContextSummary.tsx b/src/components/assistant/AssistantContextSummary.tsx index a6637cf..2dfab82 100644 --- a/src/components/assistant/AssistantContextSummary.tsx +++ b/src/components/assistant/AssistantContextSummary.tsx @@ -1,5 +1,6 @@ import { Box, Chip, Typography } from '@mui/material' import type { AssistantContext } from '../../domain/assistant' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' const PAGE_LABELS: Record = { '/supply/dashboard': 'Übersicht', @@ -36,18 +37,18 @@ export function AssistantContextSummary({ context }: Props) { const pageLabel = resolvePageLabel(context.currentRoute) return ( - + {context.selectedEntityType && context.selectedEntityId && ( )} {context.visibleScores?.quality !== undefined && ( @@ -65,7 +66,7 @@ export function AssistantContextSummary({ context }: Props) { )} diff --git a/src/components/assistant/AssistantLoadingState.tsx b/src/components/assistant/AssistantLoadingState.tsx index 67514b4..bbddb5d 100644 --- a/src/components/assistant/AssistantLoadingState.tsx +++ b/src/components/assistant/AssistantLoadingState.tsx @@ -1,4 +1,5 @@ import { Box, Typography } from '@mui/material' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' export function AssistantLoadingState() { return ( @@ -8,7 +9,7 @@ export function AssistantLoadingState() { width: 28, height: 28, borderRadius: '50%', - bgcolor: '#4f46e5', + bgcolor: DS_ACCENT.indigo.main, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -19,7 +20,7 @@ export function AssistantLoadingState() { @@ -50,12 +51,12 @@ function MessageBubble({ message }: { message: AssistantMessage }) { {!isUser && (message.confidence !== undefined || (message.sources && message.sources.length > 0)) && ( {message.confidence !== undefined && ( - + Konfidenz: {Math.round(message.confidence * 100)}% )} {message.sources?.map(s => ( - + {s} ))} @@ -63,7 +64,7 @@ function MessageBubble({ message }: { message: AssistantMessage }) { )} {/* Timestamp */} - + {new Date(message.createdAt).toLocaleTimeString('de-CH', { timeStyle: 'short' })} diff --git a/src/components/assistant/AssistantPromptSuggestions.tsx b/src/components/assistant/AssistantPromptSuggestions.tsx index d063188..aa8a622 100644 --- a/src/components/assistant/AssistantPromptSuggestions.tsx +++ b/src/components/assistant/AssistantPromptSuggestions.tsx @@ -1,6 +1,6 @@ import { Box, Chip, Typography } from '@mui/material' import type { SuggestedQuestion } from '../../domain/assistant' -import { DS_TEXT, DS_BORDER, DS_SURFACE, BADGE_COLORS } from '../../lib/ds' +import { BADGE_COLORS, DS_BORDER, DS_SLATE, DS_SURFACE, DS_TEXT } from '../../lib/ds' interface Props { suggestions: SuggestedQuestion[] @@ -74,7 +74,7 @@ export function AssistantPromptSuggestions({ suggestions, onSelect, disabled }: }} /> - + {s.question} s.isOpen) @@ -22,10 +23,10 @@ export function GlobalAIAssistantButton() { sx={{ width: 48, height: 48, - bgcolor: '#4f46e5', + bgcolor: DS_ACCENT.indigo.main, color: 'white', boxShadow: '0 4px 16px rgba(79,70,229,0.4)', - '&:hover': { bgcolor: '#4338ca' }, + '&:hover': { bgcolor: DS_ACCENT.indigo.strong }, }} > diff --git a/src/components/assistant/GlobalAIAssistantDrawer.tsx b/src/components/assistant/GlobalAIAssistantDrawer.tsx index 1f74608..78112df 100644 --- a/src/components/assistant/GlobalAIAssistantDrawer.tsx +++ b/src/components/assistant/GlobalAIAssistantDrawer.tsx @@ -13,6 +13,7 @@ import { AssistantLoadingState } from './AssistantLoadingState' import { AssistantErrorState } from './AssistantErrorState' import type { AssistantContext } from '../../domain/assistant' import type { WorkspaceType } from '../../domain/enums' +import { DS_ACCENT, DS_BG, DS_SLATE } from '../../lib/ds' function resolveWorkspace(pathname: string): WorkspaceType | null { if (pathname.startsWith('/supply')) return 'SUPPLY' as WorkspaceType @@ -43,7 +44,7 @@ export function GlobalAIAssistantDrawer() { organizationId: currentUser?.organizationId ?? '', } setContext(ctx) - }, [isOpen, location.pathname]) + }, [isOpen, location.pathname, currentUser?.role, currentUser?.organizationId, setContext]) // Auto-scroll on new messages useEffect(() => { @@ -129,7 +130,7 @@ export function GlobalAIAssistantDrawer() { @@ -144,11 +145,11 @@ export function GlobalAIAssistantDrawer() { - + - + @@ -164,8 +165,8 @@ export function GlobalAIAssistantDrawer() { {/* Welcome message */} {messages.length === 0 && !isLoading && ( - - + + Ich helfe Ihnen mit kontextbezogenen Fragen zu dieser Seite. Meine Antworten basieren auf strukturierten Daten — keine erfundenen Fakten. @@ -233,11 +234,11 @@ export function GlobalAIAssistantDrawer() { onClick={() => handleQuestion(inputText)} disabled={!inputText.trim() || isLoading} sx={{ - bgcolor: '#4f46e5', + bgcolor: DS_ACCENT.indigo.main, color: 'white', flexShrink: 0, - '&:hover': { bgcolor: '#4338ca' }, - '&:disabled': { bgcolor: '#e8e7e4', color: '#94a3b8' }, + '&:hover': { bgcolor: DS_ACCENT.indigo.strong }, + '&:disabled': { bgcolor: DS_BG.muted, color: DS_SLATE[400] }, }} > diff --git a/src/components/auth/SessionExpired.tsx b/src/components/auth/SessionExpired.tsx index 7755c97..51dcb3d 100644 --- a/src/components/auth/SessionExpired.tsx +++ b/src/components/auth/SessionExpired.tsx @@ -2,6 +2,7 @@ import { Box, Button, Typography } from '@mui/material' import { Clock } from 'lucide-react' import { useNavigate } from 'react-router' import { useSessionStore } from '../../stores/sessionStore' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' export function SessionExpired() { const { logout } = useSessionStore() @@ -21,7 +22,7 @@ export function SessionExpired() { justifyContent: 'center', minHeight: '100vh', gap: 2, - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], p: 4, }} > @@ -51,7 +52,7 @@ export function SessionExpired() { diff --git a/src/components/cards/SourceTypeBadge.tsx b/src/components/cards/SourceTypeBadge.tsx index e0af70a..f19f5f3 100644 --- a/src/components/cards/SourceTypeBadge.tsx +++ b/src/components/cards/SourceTypeBadge.tsx @@ -2,6 +2,7 @@ import { Chip } from '@mui/material' import { ShieldCheck, Globe, Building2, Sparkles } from 'lucide-react' import type { ResultType } from '../../domain/enums' import { RESULT_TYPE_LABELS } from '../../lib/constants' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' interface SourceTypeBadgeProps { type: ResultType @@ -9,13 +10,13 @@ interface SourceTypeBadgeProps { } const CONFIG: Record = { - VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: '#152642', Icon: ShieldCheck }, - MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: '#0369a1', Icon: Building2 }, - FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: '#6d28d9', Icon: Sparkles }, + VERIFIED_PORTFOLIO: { bg: 'rgba(30,58,95,0.1)', color: DS_BRAND.main, Icon: ShieldCheck }, + MAISON_WORK: { bg: 'rgba(3,105,161,0.1)', color: DS_ACCENT.cyan.deep, Icon: Building2 }, + FUTURE_AVAILABILITY: { bg: 'rgba(124,58,237,0.1)', color: DS_ACCENT.violet.strong, Icon: Sparkles }, } export function SourceTypeBadge({ type, size = 'small' }: SourceTypeBadgeProps) { - const { bg, color, Icon } = CONFIG[type] ?? { bg: '#f1f5f9', color: '#475569', Icon: Globe } + const { bg, color, Icon } = CONFIG[type] ?? { bg: '#f1f5f9', color: DS_SLATE[600], Icon: Globe } return ( } diff --git a/src/components/compare/CompareCell.tsx b/src/components/compare/CompareCell.tsx index 6272b98..5dfdc6d 100644 --- a/src/components/compare/CompareCell.tsx +++ b/src/components/compare/CompareCell.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { Box, Tooltip, Typography } from '@mui/material' import { Info } from 'lucide-react' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' export type CellHighlight = 'best' | 'worst' | 'critical' | 'future' | 'none' @@ -12,10 +13,10 @@ interface Props { } const HIGHLIGHT_SX: Record = { - best: { bgcolor: '#f0fdf4', borderLeft: '3px solid #1a7a4a' }, - worst: { bgcolor: '#fef3c7', borderLeft: '3px solid #d97706' }, - critical: { bgcolor: '#fef2f2', borderLeft: '3px solid #c0392b' }, - future: { bgcolor: '#faf5ff', borderLeft: '3px solid #7c3aed' }, + best: { bgcolor: DS_ACCENT.success.bg, borderLeft: '3px solid #1a7a4a' }, + worst: { bgcolor: DS_ACCENT.warning.borderSoft, borderLeft: '3px solid #d97706' }, + critical: { bgcolor: DS_ACCENT.danger.bg, borderLeft: '3px solid #c0392b' }, + future: { bgcolor: DS_ACCENT.violet.bg, borderLeft: '3px solid #7c3aed' }, none: {}, } @@ -40,7 +41,7 @@ export function MissingDataCell({ reason }: { reason?: string }) { - + Nicht verfügbar diff --git a/src/components/compare/CompareColumnHeader.tsx b/src/components/compare/CompareColumnHeader.tsx index 776d46f..1a684e2 100644 --- a/src/components/compare/CompareColumnHeader.tsx +++ b/src/components/compare/CompareColumnHeader.tsx @@ -3,7 +3,7 @@ import { X, AlertTriangle, BookmarkCheck, ExternalLink, Kanban } from 'lucide-re import { useNavigate } from 'react-router' import { usePipelineItems } from '../../hooks/usePipeline' import { usePipelineStore } from '../../stores/pipelineStore' -import { RESULT_TYPE_META } from '../../lib/ds' +import { DS_ACCENT, DS_BRAND, DS_SLATE, RESULT_TYPE_META } from '../../lib/ds' import { matchScoreHex } from '../../lib/utils' import type { UnifiedMatchResult } from '../../domain/unifiedResult' @@ -17,9 +17,9 @@ export function CompareColumnHeader({ item, onRemove }: Props) { const { data: pipelineItems = [] } = usePipelineItems() const { openSavedDialog } = usePipelineStore() - const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } - const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? (item as any).property : null - const sig = item.resultType === 'FUTURE_AVAILABILITY' ? (item as any).signal : null + const meta = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: DS_SLATE[500] } + const prop = item.resultType !== 'FUTURE_AVAILABILITY' ? item.property : null + const sig = item.resultType === 'FUTURE_AVAILABILITY' ? item.signal : null const title = prop?.title ?? sig?.companyName ?? sig?.locationHint ?? '–' const subtitle = prop?.location?.city ?? sig?.locationHint ?? '–' @@ -55,7 +55,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) { - + @@ -67,7 +67,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) { sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.25, cursor: 'pointer', - '&:hover': { color: '#152642' }, + '&:hover': { color: DS_BRAND.main }, }} onClick={() => navigate(`/demand/results/${item.matchId}`)} > @@ -108,8 +108,8 @@ export function CompareColumnHeader({ item, onRemove }: Props) { {item.resultType === 'FUTURE_AVAILABILITY' && sig && ( - - + + Probabilistisches Signal @@ -135,7 +135,7 @@ export function CompareColumnHeader({ item, onRemove }: Props) { variant="outlined" startIcon={} 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 diff --git a/src/components/compare/CompareCriteriaCard.tsx b/src/components/compare/CompareCriteriaCard.tsx index 5403113..2b72ad8 100644 --- a/src/components/compare/CompareCriteriaCard.tsx +++ b/src/components/compare/CompareCriteriaCard.tsx @@ -4,6 +4,7 @@ import { LABEL_SX, DATA_SX, CRITERION_ALIASES, getTitle, scoreBar } from './comp import type { WeightingKey } from '../../domain/needBuilder' import { MissingDataCell } from './CompareCell' import type { UnifiedMatchResult } from '../../domain/unifiedResult' +import { DS_ACCENT, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' interface Props { compareItems: UnifiedMatchResult[] @@ -15,18 +16,18 @@ interface Props { export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWinnerIdx, activeNeed }: Props) { return ( - + Vergleich nach Suchkriterien Basierend auf der Suche: {activeNeed.companyName} {overallWinnerIdx !== -1 && ( - + - Gesamtsieger - {getTitle(compareItems[overallWinnerIdx])} + Gesamtsieger + {getTitle(compareItems[overallWinnerIdx])} )} @@ -34,11 +35,11 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin
- - Kriterium + + Kriterium {compareItems.map(item => ( - - + + {getTitle(item)} @@ -74,8 +75,8 @@ export function CompareCriteriaCard({ compareItems, relevantCriteria, overallWin {idx === winnerIdx && ( } - sx={{ height: 18, fontSize: 9, bgcolor: '#f0fdf4', color: '#166534', - '& .MuiChip-icon': { color: '#1a7a4a', ml: 0.5 }, + sx={{ height: 18, fontSize: 9, bgcolor: DS_ACCENT.success.bg, color: DS_TEXT.successDark, + '& .MuiChip-icon': { color: DS_ACCENT.success.main, ml: 0.5 }, '& .MuiChip-label': { px: 0.75 } }} /> )} diff --git a/src/components/compare/CompareEmptyState.tsx b/src/components/compare/CompareEmptyState.tsx index bb85998..ede928a 100644 --- a/src/components/compare/CompareEmptyState.tsx +++ b/src/components/compare/CompareEmptyState.tsx @@ -1,6 +1,7 @@ import { Box, Button, Typography } from '@mui/material' import { Columns2 } from 'lucide-react' import { useNavigate } from 'react-router' +import { DS_BRAND } from '../../lib/ds' export function CompareEmptyState() { const navigate = useNavigate() @@ -15,7 +16,7 @@ export function CompareEmptyState() { Fügen Sie 2–4 Ergebnisse aus dem Feed, Match Detail oder Match Center zum Vergleich hinzu. diff --git a/src/components/compare/CompareMetricRow.tsx b/src/components/compare/CompareMetricRow.tsx deleted file mode 100644 index 889caf4..0000000 --- a/src/components/compare/CompareMetricRow.tsx +++ /dev/null @@ -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 ( - - {label} - {cells.map((cell, i) => ( - {cell} - ))} - - ) -}) diff --git a/src/components/compare/CompareTableBody.tsx b/src/components/compare/CompareTableBody.tsx index 0c462ef..67eebcc 100644 --- a/src/components/compare/CompareTableBody.tsx +++ b/src/components/compare/CompareTableBody.tsx @@ -18,7 +18,7 @@ import { scoreBar, } from './compareUtils' import { CompareCell, MissingDataCell } from './index' -import { RESULT_TYPE_META, DS_COLORS } from '../../lib/ds' +import { DS_ACCENT, DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT, RESULT_TYPE_META } from '../../lib/ds' import { matchScoreHex } from '../../lib/utils' import { effectiveAnnualBurdenPerSqm } from '../../lib/fitOutUtils' import { FITOUT_AMORTIZATION_YEARS, FITOUT_ANNUITY_RATE, FIT_OUT_LABELS } from '../../lib/constants' @@ -63,7 +63,7 @@ export function CompareTableBody({ {/* 1. Result Type */} {row('1. Result-Typ', compareItems.map(item => { - const m = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: '#64748b' } + const m = RESULT_TYPE_META[item.resultType] ?? { label: item.resultType, color: DS_SLATE[500] } return }))} @@ -195,11 +195,11 @@ export function CompareTableBody({ + {fitOutMonthlyLabel} CHF/Monat (Ausbau annuit. {FITOUT_AMORTIZATION_YEARS} J. / {Math.round(FITOUT_ANNUITY_RATE * 100)}%) )} - + = {totalMonthly.toLocaleString('de-CH')} CHF/Monat {fitOutLabel && ( - + Ausbau: {fitOutLabel}{fitOutByLandlord ? ' (im Mietzins)' : fitOutMonthly === 0 ? ' (bezugsfertig)' : ''} )} @@ -238,7 +238,7 @@ export function CompareTableBody({ {hardMatches.map(f => ( } - 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 } }} /> ))} @@ -255,7 +255,7 @@ export function CompareTableBody({ {softFactors.map(f => ( + sx={{ fontSize: 10, bgcolor: DS_ACCENT.blue.bg, color: DS_ACCENT.blue.dark }} /> ))} ) @@ -322,7 +322,7 @@ export function CompareTableBody({ if (total === 0) return ( - Vollständig + Vollständig ) return ( diff --git a/src/components/compare/compareUtils.tsx b/src/components/compare/compareUtils.tsx index c1abb8e..97bf847 100644 --- a/src/components/compare/compareUtils.tsx +++ b/src/components/compare/compareUtils.tsx @@ -3,6 +3,7 @@ import { Box, LinearProgress, Typography } from '@mui/material' import type { WeightingKey } from '../../domain/needBuilder' import type { UnifiedMatchResult, VerifiedPortfolioResult, ExternalMarketResult, FutureAvailabilityResult } from '../../domain/unifiedResult' import { dataQualityHex } from '../../lib/utils' +import { DS_SLATE } from '../../lib/ds' // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -53,7 +54,7 @@ export const LABEL_SX = { zIndex: 1, width: 200, minWidth: 200, - color: '#64748b', + color: DS_SLATE[500], fontSize: 13, fontWeight: 600, borderRight: '1px solid #e2e8f0', diff --git a/src/components/data-quality/DataQualityBar.tsx b/src/components/data-quality/DataQualityBar.tsx index b4bb345..0638172 100644 --- a/src/components/data-quality/DataQualityBar.tsx +++ b/src/components/data-quality/DataQualityBar.tsx @@ -3,6 +3,7 @@ import { AlertTriangle } from 'lucide-react' import { dataQualityColor, dataQualityHex, formatPercent } from '../../lib/utils' import { FRESHNESS_LABELS } from '../../lib/constants' import type { DataQuality } from '../../domain/property' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface DataQualityBarProps { quality: DataQuality @@ -22,7 +23,7 @@ export function DataQualityBar({ quality, compact = false, showWarnings = true } {quality.missingCriticalFields.length > 0 && ( - Fehlende Pflichtfelder: + Fehlende Pflichtfelder: {quality.missingCriticalFields.map(f => ( • {f} ))} @@ -30,13 +31,13 @@ export function DataQualityBar({ quality, compact = false, showWarnings = true } )} {quality.warnings.length > 0 && ( - Warnungen: + Warnungen: {quality.warnings.map((w, i) => ( • {w} ))} )} - + Aktualität: {FRESHNESS_LABELS[quality.freshness]} {quality.lastVerifiedAt ? ` · Geprüft: ${quality.lastVerifiedAt}` : ''} diff --git a/src/components/data-quality/DataQualityPanel.tsx b/src/components/data-quality/DataQualityPanel.tsx index 5690c92..b89b0f8 100644 --- a/src/components/data-quality/DataQualityPanel.tsx +++ b/src/components/data-quality/DataQualityPanel.tsx @@ -6,6 +6,7 @@ import { ProvenancePanel } from './ProvenancePanel' import { DataQualityBadge } from './DataQualityBadge' import { getRecommendedActions } from '../../services/dataQualityService' import type { Property } from '../../domain/property' +import { DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' interface DataQualityPanelProps { property: Property @@ -17,7 +18,7 @@ function SectionLabel({ children }: { children: React.ReactNode }) { fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5, - color: '#64748b', + color: DS_SLATE[500], display: 'block', mb: 1, }}> @@ -33,7 +34,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) { return ( {/* ── Score & Dimensions ─────────────────────────────────────────── */} - + Datenqualität @@ -81,7 +82,7 @@ export function DataQualityPanel({ property }: DataQualityPanelProps) { Datenaktualisierung anfragen {q.missingCriticalFields.length > 0 && ( - )} diff --git a/src/components/data-quality/DataQualityProgress.tsx b/src/components/data-quality/DataQualityProgress.tsx index 42f6ebd..fd716ec 100644 --- a/src/components/data-quality/DataQualityProgress.tsx +++ b/src/components/data-quality/DataQualityProgress.tsx @@ -2,6 +2,7 @@ import { Box, LinearProgress, Typography } from '@mui/material' import { dataQualityHex } from '../../lib/utils' import { FreshnessStatus } from '../../domain/enums' import type { Property } from '../../domain/property' +import { DS_BG } from '../../lib/ds' interface Dimension { label: string @@ -76,7 +77,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr sx={{ height: 4, borderRadius: 2, - bgcolor: '#e8e7e4', + bgcolor: DS_BG.muted, '& .MuiLinearProgress-bar': { bgcolor: d.color }, }} /> @@ -110,7 +111,7 @@ export function DataQualityProgress({ property, compact = false }: DataQualityPr sx={{ height: 5, borderRadius: 3, - bgcolor: '#e8e7e4', + bgcolor: DS_BG.muted, '& .MuiLinearProgress-bar': { bgcolor: d.color }, }} /> diff --git a/src/components/data-quality/FreshnessIndicator.tsx b/src/components/data-quality/FreshnessIndicator.tsx index 8c0e88d..5c6dbd4 100644 --- a/src/components/data-quality/FreshnessIndicator.tsx +++ b/src/components/data-quality/FreshnessIndicator.tsx @@ -3,6 +3,7 @@ import { CheckCircle, Clock, AlertTriangle } from 'lucide-react' import { FreshnessStatus } from '../../domain/enums' import { FRESHNESS_LABELS } from '../../lib/constants' import type { FreshnessStatus as FreshnessStatusType } from '../../domain/enums' +import { DS_ACCENT } from '../../lib/ds' interface FreshnessIndicatorProps { freshness: FreshnessStatusType @@ -11,9 +12,9 @@ interface FreshnessIndicatorProps { } const CONFIG: Record = { - [FreshnessStatus.FRESH]: { color: '#1a7a4a', icon: CheckCircle }, - [FreshnessStatus.STALE]: { color: '#d97706', icon: Clock }, - [FreshnessStatus.OUTDATED]: { color: '#c0392b', icon: AlertTriangle }, + [FreshnessStatus.FRESH]: { color: DS_ACCENT.success.main, icon: CheckCircle }, + [FreshnessStatus.STALE]: { color: DS_ACCENT.warning.main, icon: Clock }, + [FreshnessStatus.OUTDATED]: { color: DS_ACCENT.danger.main, icon: AlertTriangle }, } export function FreshnessIndicator({ freshness, lastUpdated, size = 'small' }: FreshnessIndicatorProps) { diff --git a/src/components/data-quality/MissingDataList.tsx b/src/components/data-quality/MissingDataList.tsx index 0707133..e2aeb28 100644 --- a/src/components/data-quality/MissingDataList.tsx +++ b/src/components/data-quality/MissingDataList.tsx @@ -1,6 +1,7 @@ import { Box, Button, Chip, Divider, Typography } from '@mui/material' import { AlertTriangle, Info } from 'lucide-react' import type { RecommendedAction } from '../../services/dataQualityService' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface MissingDataListProps { criticalFields: string[] @@ -17,9 +18,9 @@ export function MissingDataList({ }: MissingDataListProps) { if (criticalFields.length === 0 && optionalFields.length === 0) { return ( - + - + Alle wichtigen Felder sind vollständig. @@ -35,7 +36,7 @@ export function MissingDataList({ - + Pflichtfelder ({criticalFields.length}) @@ -49,7 +50,7 @@ export function MissingDataList({ px: 1.5, py: 0.75, mb: 0.5, - bgcolor: '#fff1f2', + bgcolor: DS_ACCENT.danger.bgAlt, border: '1px solid #fecdd3', borderRadius: 1, }} @@ -82,7 +83,7 @@ export function MissingDataList({ <> {criticalFields.length > 0 && } - + Optionale Felder ({optionalFields.length}) {otherActions.map(a => ( @@ -95,7 +96,7 @@ export function MissingDataList({ px: 1.5, py: 0.75, mb: 0.5, - bgcolor: '#fffbeb', + bgcolor: DS_ACCENT.warning.bg, border: '1px solid #fde68a', borderRadius: 1, }} diff --git a/src/components/data-quality/ProvenancePanel.tsx b/src/components/data-quality/ProvenancePanel.tsx index 7d3a412..313cbe7 100644 --- a/src/components/data-quality/ProvenancePanel.tsx +++ b/src/components/data-quality/ProvenancePanel.tsx @@ -2,6 +2,7 @@ import { Box, Button, Chip, LinearProgress, Typography } from '@mui/material' import { ExternalLink, Shield, ShieldAlert } from 'lucide-react' import { FreshnessIndicator } from './FreshnessIndicator' import type { Property } from '../../domain/property' +import { DS_BG } from '../../lib/ds' interface ProvenancePanelProps { property: Property @@ -79,7 +80,7 @@ export function ProvenancePanel({ property: p }: ProvenancePanelProps) { sx={{ height: 5, borderRadius: 3, - bgcolor: '#e8e7e4', + bgcolor: DS_BG.muted, '& .MuiLinearProgress-bar': { bgcolor: color }, }} /> diff --git a/src/components/demand/AISearchActionBar.tsx b/src/components/demand/AISearchActionBar.tsx deleted file mode 100644 index 29aa41e..0000000 --- a/src/components/demand/AISearchActionBar.tsx +++ /dev/null @@ -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 ( - <> - - - - - - - - - - Jetzt suchen liefert sofortige Ergebnisse.{' '} - Als Suchprofil speichern legt einen dauerhaften Bedarf an, der automatisch mit neuen Angeboten abgeglichen wird — auch in Zukunft. - - - ) -} diff --git a/src/components/demand/AISearchSavePreview.tsx b/src/components/demand/AISearchSavePreview.tsx deleted file mode 100644 index 69cfd40..0000000 --- a/src/components/demand/AISearchSavePreview.tsx +++ /dev/null @@ -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 - 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 ( - - - Dieses Suchprofil wird als aktiver Bedarf gespeichert und erscheint automatisch im Match Center der Verwaltung. - - - - {/* Anonymity option */} - - onAnonymousChange(e.target.checked)} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: '#7c3aed' }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#7c3aed' }, - }} - /> - } - label={ - - - Anonymes Suchprofil - - - Firmenname wird nicht an Vermieter übermittelt — Verwalter sehen nur Branche und Flächenbedarf - - - } - /> - - - - - - - - ) -} diff --git a/src/components/demand/AnfragenInquiryItem.tsx b/src/components/demand/AnfragenInquiryItem.tsx deleted file mode 100644 index 13b7603..0000000 --- a/src/components/demand/AnfragenInquiryItem.tsx +++ /dev/null @@ -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 ( - 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', - }}> - - - {!inq.isRead && } - - {primaryTitle} - - - - {inq.unreadCount > 0 && ( - - {inq.unreadCount} - - )} - {displayDate} - - - {secondaryLine && ( - - {secondaryLine} - - )} - - {inq.subject} - - {lastMsg && ( - - {lastMsgPrefix} - {lastMsg.body.split('\n')[0]} - - )} - - - {inq.matchScore && ( - = 80 ? DS_TEXT.success : DS_TEXT.warning, fontWeight: 700, fontSize: '0.7rem' }}> - {inq.matchScore}% - - )} - {hasPipeline && ( - } - 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' }, - }} - /> - )} - - - ) -} diff --git a/src/components/demand/AnfragenMessageBubble.tsx b/src/components/demand/AnfragenMessageBubble.tsx deleted file mode 100644 index d34c258..0000000 --- a/src/components/demand/AnfragenMessageBubble.tsx +++ /dev/null @@ -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 ( - - - {!isOwnMessage && isAI && } - {!isOwnMessage && msg.senderType === 'supply_user' && } - - {isOwnMessage && currentUserName ? currentUserName : msg.senderName} · {date} {time} - - - - - {msg.body} - - {msg.attachments.length > 0 && ( - - {msg.attachments.map(att => ( - - - - {att.fileName} - - {att.fileSize && ( - - {att.fileSize < 1024 * 1024 - ? `${Math.round(att.fileSize / 1024)} KB` - : `${(att.fileSize / 1024 / 1024).toFixed(1)} MB`} - - )} - - ))} - - )} - - - ) -} diff --git a/src/components/demand/CriteriaReviewPanel.tsx b/src/components/demand/CriteriaReviewPanel.tsx index ec046e3..68626c7 100644 --- a/src/components/demand/CriteriaReviewPanel.tsx +++ b/src/components/demand/CriteriaReviewPanel.tsx @@ -1,6 +1,8 @@ import { Box, Card, Typography, Alert, Stack, Chip } from '@mui/material' import type { ParseNeedResult, ParsedNeedCriteria } from '../../domain/needBuilder' import { ExtractedFieldRow } from './ExtractedFieldRow' +import { ASSET_TYPE_LABELS } from '../../lib/constants' +import { DS_SLATE } from '../../lib/ds' interface Props { result: ParseNeedResult @@ -8,16 +10,12 @@ interface Props { onCriteriaChange: (c: ParsedNeedCriteria) => void } -const ASSET_LABELS: Record = { - OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail', - PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe', - MIXED: 'Gemischt', UNKNOWN: 'Unbekannt', -} + // ── Parse helpers ────────────────────────────────────────────────────────────── function parseAreaRange(s: string): ParsedNeedCriteria['areaRange'] { - const m = s.match(/(\d+)\s*[–\-]\s*(\d+)/) + const m = s.match(/(\d+)\s*[–-]\s*(\d+)/) if (m) return { min: parseInt(m[1]), max: parseInt(m[2]) } const n = s.match(/(\d+)/) if (n) { const v = parseInt(n[1]); return { min: Math.round(v * 0.8), max: Math.round(v * 1.2) } } @@ -50,7 +48,7 @@ function displayTiming(v: ParsedNeedCriteria['timing']): string { function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( - + {title} {children} @@ -76,7 +74,7 @@ export function CriteriaReviewPanel({ result, criteria: c, onCriteriaChange: set
set({ ...c, assetType: (v.toUpperCase() as ParsedNeedCriteria['assetType']) })} diff --git a/src/components/demand/FollowUpPanel.tsx b/src/components/demand/FollowUpPanel.tsx index e7bd678..dadf66a 100644 --- a/src/components/demand/FollowUpPanel.tsx +++ b/src/components/demand/FollowUpPanel.tsx @@ -2,6 +2,7 @@ import { Box, Button, Card, Typography } from '@mui/material' import { ArrowRight, RefreshCw } from 'lucide-react' import type { FollowUpQuestion } from '../../domain/needBuilder' import { FollowUpQuestionCard } from './FollowUpQuestionCard' +import { DS_BRAND } from '../../lib/ds' interface Props { questions: FollowUpQuestion[] @@ -66,7 +67,7 @@ export function FollowUpPanel({ questions, answers, onAnswer, onContinue, onRepa disabled={requiredUnanswered.length > 0} onClick={onContinue} endIcon={} - sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }} + sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }} > Weiter zur Gewichtung diff --git a/src/components/demand/NeedBuilderErrorState.tsx b/src/components/demand/NeedBuilderErrorState.tsx index 5ed8083..fbb61ef 100644 --- a/src/components/demand/NeedBuilderErrorState.tsx +++ b/src/components/demand/NeedBuilderErrorState.tsx @@ -1,5 +1,6 @@ import { Box, Button, Typography } from '@mui/material' import { AlertTriangle, RotateCcw } from 'lucide-react' +import { DS_BRAND } from '../../lib/ds' interface Props { message: string @@ -22,7 +23,7 @@ export function NeedBuilderErrorState({ message, onRetry }: Props) { variant="contained" startIcon={} onClick={onRetry} - sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }} + sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }} > Erneut versuchen diff --git a/src/components/demand/NeedBuilderProgress.tsx b/src/components/demand/NeedBuilderProgress.tsx index 9ea95e9..b5db27c 100644 --- a/src/components/demand/NeedBuilderProgress.tsx +++ b/src/components/demand/NeedBuilderProgress.tsx @@ -1,6 +1,7 @@ import { Box, Stepper, Step, StepLabel } from '@mui/material' import type { NeedBuilderStep } from '../../domain/needBuilder' import { NeedBuilderStep as S } from '../../domain/needBuilder' +import { DS_SLATE } from '../../lib/ds' interface Props { step: NeedBuilderStep @@ -21,7 +22,7 @@ function toStepIndex(step: NeedBuilderStep): number { export function NeedBuilderProgress({ step }: Props) { if (step === S.IDLE) return null return ( - + {STEPS.map(label => ( diff --git a/src/components/demand/NeedCardPreview.tsx b/src/components/demand/NeedCardPreview.tsx index 7cd0ab7..c8aa4b9 100644 --- a/src/components/demand/NeedCardPreview.tsx +++ b/src/components/demand/NeedCardPreview.tsx @@ -3,6 +3,8 @@ import { MapPin, Ruler, Wallet, Clock, CheckSquare, ShieldAlert } from 'lucide-r import type { ParsedNeedCriteria } from '../../domain/needBuilder' import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder' import type { WeightingKey } from '../../domain/needBuilder' +import { ASSET_TYPE_LABELS } from '../../lib/constants' +import { DS_BG, DS_BRAND } from '../../lib/ds' interface Props { criteria: ParsedNeedCriteria @@ -13,10 +15,7 @@ interface Props { onNeedTitleChange: (v: string) => void } -const ASSET_LABELS: Record = { - OFFICE: 'Büro', LOGISTICS: 'Logistik / Lager', RETAIL: 'Retail', - PRODUCTION: 'Produktion', GASTRO: 'Gastro / F&B', LIGHT_INDUSTRIAL: 'Gewerbe', -} + const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing'] @@ -66,7 +65,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi {/* Header */} {c.assetType && ( - + )} @@ -143,7 +142,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi variant="determinate" value={overallConfidence * 100} sx={{ - height: 6, borderRadius: 3, bgcolor: '#e8e7e4', + height: 6, borderRadius: 3, bgcolor: DS_BG.muted, '& .MuiLinearProgress-bar': { bgcolor: overallConfidence >= 0.7 ? '#1a7a4a' : overallConfidence >= 0.5 ? '#d97706' : '#c0392b', }, @@ -171,7 +170,7 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi {pct}% diff --git a/src/components/demand/NeedExtendedRequirements.tsx b/src/components/demand/NeedExtendedRequirements.tsx index 8a338eb..b8d3913 100644 --- a/src/components/demand/NeedExtendedRequirements.tsx +++ b/src/components/demand/NeedExtendedRequirements.tsx @@ -1,6 +1,7 @@ import { Accordion, AccordionDetails, AccordionSummary, Box, Checkbox, FormControlLabel, MenuItem, Switch, TextField, Typography } from '@mui/material' import { ChevronDown } from 'lucide-react' import type { ParsedNeedCriteria } from '../../domain/needBuilder' +import { DS_SLATE } from '../../lib/ds' interface Props { criteria: ParsedNeedCriteria @@ -11,7 +12,7 @@ export function NeedExtendedRequirements({ criteria: c, onChange: set }: Props) return ( } sx={{ minHeight: 36, px: 1.5, '& .MuiAccordionSummary-content': { my: 0.5 } }}> - Erweiterte Anforderungen + Erweiterte Anforderungen diff --git a/src/components/demand/NeedInput.tsx b/src/components/demand/NeedInput.tsx index 840fdad..9101812 100644 --- a/src/components/demand/NeedInput.tsx +++ b/src/components/demand/NeedInput.tsx @@ -3,6 +3,7 @@ import { Box, Card, Chip, Slider, Stack, TextField, Typography } from '@mui/mate import type { ParsedNeedCriteria } from '../../domain/needBuilder' import { AssetType } from '../../domain/enums' import { NeedExtendedRequirements } from './NeedExtendedRequirements' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' interface Props { criteria: ParsedNeedCriteria @@ -81,7 +82,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { clickable onClick={() => set({ ...c, assetType: c.assetType === opt.value ? undefined : opt.value })} sx={c.assetType === opt.value - ? { bgcolor: '#152642', color: 'white', '& .MuiChip-label': { color: 'white' } } + ? { bgcolor: DS_BRAND.main, color: 'white', '& .MuiChip-label': { color: 'white' } } : {}} /> ))} @@ -116,7 +117,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { variant="outlined" clickable onClick={() => set({ ...c, areaRange: { min: p.min, max: p.max } })} - sx={{ fontSize: '0.68rem', height: 20, color: '#64748b', borderColor: '#cbd5e1' }} + sx={{ fontSize: '0.68rem', height: 20, color: DS_SLATE[500], borderColor: DS_SLATE[300] }} /> ))} @@ -139,8 +140,8 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { }) }} sx={c.preferredLocations?.includes(loc) - ? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' } - : { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' } + ? { fontSize: '0.68rem', height: 22, bgcolor: DS_BRAND.main, color: 'white' } + : { fontSize: '0.68rem', height: 22, color: DS_SLATE[500], borderColor: DS_SLATE[300] } } /> ))} @@ -169,7 +170,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { Suchradius - + {c.searchRadius ?? 30} km @@ -181,7 +182,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { step={1} onChange={(_, v) => set({ ...c, searchRadius: v as number })} size="small" - sx={{ color: '#152642', '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }} + sx={{ color: DS_BRAND.main, '& .MuiSlider-markLabel': { fontSize: '0.62rem' } }} /> @@ -197,8 +198,8 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) { clickable onClick={() => set({ ...c, budgetRange: { maxPerSqm: p.value, currency: 'CHF' } })} sx={c.budgetRange?.maxPerSqm === p.value - ? { fontSize: '0.68rem', height: 22, bgcolor: '#152642', color: 'white' } - : { fontSize: '0.68rem', height: 22, color: '#64748b', borderColor: '#cbd5e1' } + ? { fontSize: '0.68rem', height: 22, bgcolor: DS_BRAND.main, color: 'white' } + : { fontSize: '0.68rem', height: 22, color: DS_SLATE[500], borderColor: DS_SLATE[300] } } /> ))} diff --git a/src/components/demand/PropertyContactForm.tsx b/src/components/demand/PropertyContactForm.tsx deleted file mode 100644 index 79b126a..0000000 --- a/src/components/demand/PropertyContactForm.tsx +++ /dev/null @@ -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 ( - - - - Verwaltung kontaktieren - - - {sent ? ( - } - severity="success" - sx={{ borderRadius: 1 }} - > - Ihre Anfrage wurde übermittelt. Die Verwaltung meldet sich in Kürze. - - ) : ( - - - - Ihr Name - setInquiryName(e.target.value)} - /> - - - Bezug - u.id === highlightUnitId)?.unitLabel ?? 'Einheit') - : propertyTitle - } - slotProps={{ input: { readOnly: true } }} - sx={{ '& .MuiInputBase-input': { color: 'text.secondary', fontSize: '0.85rem' } }} - /> - - - - Ihre Nachricht - setInquiryText(e.target.value)} - /> - - - - - Antwortzeit: typisch 1–2 Werktage - - - - - )} - - ) -} diff --git a/src/components/demand/PropertyMatchRow.tsx b/src/components/demand/PropertyMatchRow.tsx index 735c4bf..c6bac86 100644 --- a/src/components/demand/PropertyMatchRow.tsx +++ b/src/components/demand/PropertyMatchRow.tsx @@ -9,6 +9,7 @@ import { MatchStrength, ResultType } from '../../domain/enums' import { useInquiryStore } from '../../stores/inquiryStore' import { usePipelineStore } from '../../stores/pipelineStore' import { useCompareStore } from '../../stores/compareStore' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' interface Props { property: Property @@ -99,15 +100,15 @@ export const PropertyMatchRow = memo(function PropertyMatchRow({ property, score ) : ( - + )} - + {property.title} - + {property.location.city} · {property.areaSqm} m² @@ -149,8 +150,8 @@ function ActionButton({ children, onClick, primary, disabled }: { px: 1, minWidth: 0, ...(primary - ? { bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' } } - : { borderColor: '#cbd5e1', color: '#475569', '&:hover': { borderColor: '#152642', color: '#152642' } }), + ? { bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } } + : { borderColor: DS_SLATE[300], color: DS_SLATE[600], '&:hover': { borderColor: DS_BRAND.main, color: DS_BRAND.main } }), }} > {children} diff --git a/src/components/demand/SavedNeedCard.tsx b/src/components/demand/SavedNeedCard.tsx index 8d16bc5..85a934e 100644 --- a/src/components/demand/SavedNeedCard.tsx +++ b/src/components/demand/SavedNeedCard.tsx @@ -3,6 +3,7 @@ import { Box, Chip, Paper, Typography } from '@mui/material' import { MapPin } from 'lucide-react' import type { Need } from '../../domain/need' import { ASSET_TYPE_LABELS } from '../../lib/constants' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface Props { need: Need @@ -43,29 +44,29 @@ export const SavedNeedCard = memo(function SavedNeedCard({ need, selected, match + sx={{ bgcolor: DS_ACCENT.indigo.border, color: DS_ACCENT.indigo.dark, fontWeight: 600, fontSize: '0.65rem', height: 20 }} /> {matchCount > 0 && ( - + {matchCount} Treffer )} - + {need.companyName} - + {locationText || '—'} {topScore > 0 && ( - + diff --git a/src/components/demand/SavedNeedDetail.tsx b/src/components/demand/SavedNeedDetail.tsx index 2da54a9..2b09e7e 100644 --- a/src/components/demand/SavedNeedDetail.tsx +++ b/src/components/demand/SavedNeedDetail.tsx @@ -10,6 +10,7 @@ import { ASSET_TYPE_LABELS } from '../../lib/constants' import { confidenceHex } from '../../lib/utils' import { useMatchesByNeed } from '../../hooks/useMatches' import { PropertyMatchRow } from './PropertyMatchRow' +import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE } from '../../lib/ds' const WEIGHT_LABELS: Record = { area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Timing', @@ -77,21 +78,21 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, ) return ( - + + sx={{ bgcolor: DS_ACCENT.indigo.border, color: DS_ACCENT.indigo.dark, fontWeight: 600, fontSize: '0.7rem', height: 22 }} /> - + {need.companyName} {need.contactName && ( - {need.contactName} + {need.contactName} )} @@ -113,7 +114,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, @@ -125,7 +126,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, {need.mustCriteriaText!.map((c, i) => ( - {c} + {c} ))} @@ -139,7 +140,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, }}> } sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}> - + Gewichtete Präferenzen @@ -148,17 +149,17 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, {topWeights.map(([key, val]) => { const pct = Math.round(val * 100) return ( - + {WEIGHT_LABELS[key] ?? key} - + {pct}% - - + + ) @@ -176,11 +177,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, sx={{ px: 2, minHeight: 44, '& .MuiAccordionSummary-content': { my: 0 } }}> - + Benachrichtigungen {notif.enabled && ( - + ab {notif.minScore}% )} @@ -191,7 +192,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, Benachrichtigung aktiv - + Neue Treffer oberhalb der Schwelle melden @@ -208,7 +209,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, Match-Schwelle - + {notif.minScore}% @@ -216,11 +217,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, value={notif.minScore} min={60} max={95} step={5} onChange={(_, v) => handleNotifChange({ minScore: v as number })} - sx={{ color: '#152642', '& .MuiSlider-thumb': { width: 14, height: 14 } }} + sx={{ color: DS_BRAND.main, '& .MuiSlider-thumb': { width: 14, height: 14 } }} /> - 60% - 95% + 60% + 95% @@ -247,7 +248,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, variant="contained" size="small" onClick={handleNotifSave} - sx={{ textTransform: 'none', fontWeight: 600, bgcolor: '#152642', '&:hover': { bgcolor: '#16304d' }, alignSelf: 'flex-end' }} + sx={{ textTransform: 'none', fontWeight: 600, bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover }, alignSelf: 'flex-end' }} > Speichern @@ -262,10 +263,10 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, - + Passende Objekte - + {matchCount} Treffer @@ -277,8 +278,8 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, {matchCount > 3 && ( Alle {matchCount} Ergebnisse anzeigen → @@ -290,11 +291,11 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, @@ -304,7 +305,7 @@ export const SavedNeedDetail = memo(function SavedNeedDetail({ need, properties, function SectionLabel({ children }: { children: React.ReactNode }) { return ( - + {children} ) @@ -313,13 +314,13 @@ function SectionLabel({ children }: { children: React.ReactNode }) { function CriteriaBox({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { return ( - + {icon} {label} - {value} + {value} ) } diff --git a/src/components/demand/SavedProfilesTab.tsx b/src/components/demand/SavedProfilesTab.tsx index 7b0af07..5f981fc 100644 --- a/src/components/demand/SavedProfilesTab.tsx +++ b/src/components/demand/SavedProfilesTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { Box, Button, CircularProgress, IconButton, Typography, useMediaQuery, useTheme } from '@mui/material' import { ArrowLeft, Plus, Search } from 'lucide-react' import { useNavigate } from 'react-router' @@ -10,6 +10,7 @@ import { ROUTES } from '../../lib/constants' import { EmptyState } from '../ui' import { SavedNeedCard } from './SavedNeedCard' import { SavedNeedDetail } from './SavedNeedDetail' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' interface Props { onNewSearch: (prefillNeedId?: string) => void @@ -54,11 +55,10 @@ export function SavedProfilesTab({ onNewSearch }: Props) { return { matchCounts: counts, topScores: tops } }, [visible, allMatches]) - useEffect(() => { - if (!isMobile && !selectedId && visible.length > 0) setSelectedId(visible[0].id) - }, [visible, selectedId, isMobile]) - - const selectedNeed = visible.find(n => n.id === selectedId) ?? null + // Ohne eigene Wahl steht auf grossen Schirmen das erste Profil offen — + // abgeleitet statt in einem Effekt nachgetragen. + const effectiveId = selectedId ?? (isMobile ? null : visible[0]?.id ?? null) + const selectedNeed = visible.find(n => n.id === effectiveId) ?? null function handleSelect(id: string) { setSelectedId(id) @@ -92,8 +92,8 @@ export function SavedProfilesTab({ onNewSearch }: Props) { - Suchprofile - + Suchprofile + {visible.length} @@ -102,7 +102,7 @@ export function SavedProfilesTab({ onNewSearch }: Props) { handleSelect(n.id)} diff --git a/src/components/demand/VoiceNeedInput.tsx b/src/components/demand/VoiceNeedInput.tsx index 7a2169c..eb01b0c 100644 --- a/src/components/demand/VoiceNeedInput.tsx +++ b/src/components/demand/VoiceNeedInput.tsx @@ -1,6 +1,13 @@ import { useRef, useState } from 'react' import { Box, Button, Card, Chip, CircularProgress, IconButton, TextField, Typography } from '@mui/material' import { Mic, MicOff, Sparkles, X } from 'lucide-react' +import { + createSpeechRecognizer, + isSpeechRecognitionSupported, + splitTranscript, +} from '../../lib/speechRecognition' +import type { SpeechRecognizer } from '../../lib/speechRecognition' +import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' interface Props { text: string @@ -16,44 +23,36 @@ const EXAMPLES = [ 'Lagerhalle 2000–3000 m² Basel, Rampe, 12 m Deckenhöhe, max. CHF 15/m²', ] -const isSpeechSupported = typeof window !== 'undefined' && - ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) +const isSpeechSupported = isSpeechRecognitionSupported() + +/** Ab dieser Textlänge lohnt die automatische Analyse — darunter fehlt der Kontext. */ +const MIN_CHARS_FOR_AUTO_SUBMIT = 15 export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, isAutoGen }: Props) { const [isRecording, setIsRecording] = useState(false) const [interimText, setInterimText] = useState('') - const recognitionRef = useRef(null) + const recognitionRef = useRef(null) const accumulatedRef = useRef('') function startRecording() { - const SpeechAPI = (window as any).SpeechRecognition ?? (window as any).webkitSpeechRecognition - if (!SpeechAPI) return + const rec = createSpeechRecognizer('de-DE') + if (!rec) return accumulatedRef.current = text - const rec = new SpeechAPI() - rec.lang = 'de-DE' - rec.continuous = true - rec.interimResults = true - rec.onresult = (e: any) => { - let finalPart = '' - let interimPart = '' - for (let i = e.resultIndex; i < e.results.length; i++) { - const t = e.results[i][0].transcript - if (e.results[i].isFinal) finalPart += t - else interimPart += t - } - if (finalPart) { - accumulatedRef.current = (accumulatedRef.current + ' ' + finalPart).trim() + rec.onresult = (event) => { + const { final, interim } = splitTranscript(event) + if (final) { + accumulatedRef.current = (accumulatedRef.current + ' ' + final).trim() onTextChange(accumulatedRef.current) } - setInterimText(interimPart) + setInterimText(interim) } rec.onend = () => { setIsRecording(false) setInterimText('') - if (accumulatedRef.current.length >= 15) onAiSubmit() + if (accumulatedRef.current.length >= MIN_CHARS_FOR_AUTO_SUBMIT) onAiSubmit() } rec.onerror = () => { setIsRecording(false); setInterimText('') } @@ -97,15 +96,15 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is size="small" label="🎤 Aufnahme läuft…" onClick={stopRecording} - sx={{ bgcolor: '#fef2f2', color: '#dc2626', fontWeight: 600, fontSize: 11, cursor: 'pointer' }} + sx={{ bgcolor: DS_ACCENT.danger.bg, color: DS_ACCENT.danger.strong, fontWeight: 600, fontSize: 11, cursor: 'pointer' }} /> ) : isAnalyzing ? ( - - Analysiert… + + Analysiert… ) : isAutoGen && text ? ( - ⚡ auto-synchronisiert + ⚡ auto-synchronisiert ) : null} @@ -138,7 +137,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is onClick={stopRecording} size="small" sx={{ - bgcolor: '#dc2626', color: 'white', '&:hover': { bgcolor: '#b91c1c' }, + bgcolor: DS_ACCENT.danger.strong, color: 'white', '&:hover': { bgcolor: DS_TEXT.error }, animation: 'micPulse 1.2s ease-in-out infinite', '@keyframes micPulse': { '0%, 100%': { boxShadow: '0 0 0 0 rgba(220,38,38,0.4)' }, @@ -154,7 +153,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is size="small" disabled={isAnalyzing || !isSpeechSupported} title={isSpeechSupported ? 'Spracheingabe starten' : 'Spracheingabe nicht verfügbar'} - sx={{ bgcolor: '#f1f5f9', color: '#475569', '&:hover': { bgcolor: '#e8e7e4' }, '&:disabled': { opacity: 0.35 } }} + sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], '&:hover': { bgcolor: DS_BG.muted }, '&:disabled': { opacity: 0.35 } }} > @@ -181,7 +180,7 @@ export function VoiceNeedInput({ text, onTextChange, onAiSubmit, isAnalyzing, is {text && !isRecording && ( - onTextChange('')} sx={{ color: '#94a3b8', p: 0.5 }}> + onTextChange('')} sx={{ color: DS_SLATE[400], p: 0.5 }}> )} diff --git a/src/components/expose/ExposeFieldGroup.tsx b/src/components/expose/ExposeFieldGroup.tsx new file mode 100644 index 0000000..1c292ea --- /dev/null +++ b/src/components/expose/ExposeFieldGroup.tsx @@ -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 + 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 ( + + + {label} + + + {(field.options ?? []).map(o => { + const selected = isSelected(value, o) + return ( + onChange(toggleChip(value, o))} + sx={{ + cursor: 'pointer', + bgcolor: selected ? '#152642' : 'transparent', + color: selected ? 'white' : DS_TEXT.secondary, + borderColor: DS_SLATE[200], + }} + /> + ) + })} + + + ) + } + + return ( + onChange(e.target.value)} + sx={{ + '& .MuiOutlinedInput-notchedOutline': isMissing + ? { borderColor: DS_TEXT.error, borderWidth: 2 } + : {}, + }} + > + {field.type === 'select' && + (field.options ?? []).map(o => {o})} + + ) +} + +/** + * 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 ( + + + {section.title} + + {section.description && ( + + {section.description} + + )} + + + {section.fields.map(field => ( + + onChange(field.key, v)} + /> + + ))} + + + {footer && {footer}} + + ) +} diff --git a/src/components/expose/ExposeMediaManager.tsx b/src/components/expose/ExposeMediaManager.tsx new file mode 100644 index 0000000..31cf3f3 --- /dev/null +++ b/src/components/expose/ExposeMediaManager.tsx @@ -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(null) + + const patch = useCallback( + (id: string, changes: Partial) => { + 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 ( + + {/* Uploadfläche */} + 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] }, + }} + > + + + Zusätzliche Bilder hierher ziehen oder auswählen + + + Nur für Bilder, die noch nicht in «Meine Objekte» hinterlegt sind — JPG oder PNG + + handleFiles(e.target.files)} + /> + + + + {importedCount} Bild(er) automatisch aus «Meine Objekte» importiert · {images.length} insgesamt + + + {/* Medien-Kuration */} + + {images.map((img, index) => ( + + {/* Reihenfolge */} + + move(index, -1)} disabled={index === 0} aria-label="Nach oben"> + + + move(index, 1)} disabled={index === images.length - 1} aria-label="Nach unten"> + + + + + + + + + + {img.fileName} + + + {img.imported ? 'aus «Meine Objekte»' : 'hochgeladen'} + + + setCover(img.id)} + aria-label="Als Titelbild" + sx={{ ml: 'auto', color: img.isCover ? '#b8975a' : '#cbd5e1' }} + > + + + + + + + patch(img.id, { category: e.target.value as ExposeImage['category'] })} + sx={{ width: 170, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + > + {Object.values(ExposeImageCategory).map(c => ( + {EXPOSE_IMAGE_CATEGORY_LABELS[c]} + ))} + + patch(img.id, { caption: e.target.value })} + sx={{ flex: 1, minWidth: 180, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> + patch(img.id, { visible: e.target.checked })} + /> + } + label={Im Exposé sichtbar} + /> + + + + ))} + + + {images.length === 0 && ( + + )} + + ) +} diff --git a/src/components/expose/ExposeWizard.tsx b/src/components/expose/ExposeWizard.tsx new file mode 100644 index 0000000..cc180e1 --- /dev/null +++ b/src/components/expose/ExposeWizard.tsx @@ -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 + return +} + +/** + * 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(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 ( + + {/* Schrittleiste */} + + + {EXPOSE_STEPS.map((label, index) => ( + + setStep(index)} sx={{ textTransform: 'none' }}> + {label} + + + ))} + + + + + {/* ── Schritt 1: Hochladen ── */} + {step === 0 && ( + setDraft({ ...draft, images })} + /> + )} + + {/* ── Schritt 2: Exposé ── */} + {step === 1 && ( + + + setDraft({ ...draft, tonality: e.target.value as ExposeTonality })} + sx={{ width: 210 }} + > + {Object.entries(EXPOSE_TONALITY_LABELS).map(([value, label]) => ( + {label} + ))} + + + {missing.size > 0 && ( + + {missing.size} Pflichtangabe(n) fehlen + + )} + + + {EXPOSE_SECTIONS.map(section => ( + + {TEXT_FIELD_SECTIONS.map(t => ( + + ))} + + ) : undefined + } + /> + ))} + + {/* Medien-Kuration bleibt aus Schritt 1 erhalten und wird hier nur + verlinkt — dieselbe Liste zweimal zu rendern wäre Duplikat. */} + + + Kontakte & CI + + + setDraft({ ...draft, contactPerson: e.target.value })} + /> + setDraft({ ...draft, branding: e.target.value as ExposeBranding })} + > + Aus Maklerprofil übernehmen + Manuell setzen + + {draft.branding === ExposeBranding.MANUAL && ( + setDraft({ ...draft, brandColor: e.target.value })} + /> + )} + + + )} + + {/* ── Schritt 3: Export ── */} + {step === 2 && ( + + {!isGenerated && ( + + Das Exposé ist noch nicht erstellt. Vorschau und Download zeigen den aktuellen Stand. + + )} + {missing.size > 0 && ( + + Es fehlen noch {missing.size} Pflichtangabe(n) — im Schritt «Exposé» rot umrandet. + + )} + + + + + + + + + Der PDF-Export nutzt den Druckdialog des Browsers («Als PDF speichern»); CI, Bilder, + Texte und Anhänge folgen der Exposé-Konfiguration. + + + )} + + + {/* Speichern und Erstellen sind klar getrennt (§8.5.2). */} + + + {isGenerated + ? `Erstellt am ${new Date(draft.generatedAt!).toLocaleString('de-CH')}` + : 'Noch kein Exposé erstellt.'} + + + + + + ) +} diff --git a/src/components/expose/LeadTable.tsx b/src/components/expose/LeadTable.tsx new file mode 100644 index 0000000..fce792e --- /dev/null +++ b/src/components/expose/LeadTable.tsx @@ -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(null) + + const recommended = lead.propertyIds + .map(id => properties.find(p => p.id === id)) + .filter((p): p is Property => Boolean(p)) + + return ( + 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', + }} + > + + {new Date(lead.receivedAt).toLocaleDateString('de-CH')} + + + + {lead.prospect} + + + + {lead.contacts.length === 0 ? ( + + ) : ( + lead.contacts.map(c => ( + + {c} + + )) + )} + + + + {lead.locationHint} + + + {/* Mehrere Objekte je Lead — jedes verlinkt nach «Meine Objekte». */} + e.stopPropagation()}> + {recommended.length === 0 ? ( + + ) : ( + recommended.map(p => ( + + )) + )} + + + ) +}) + +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 ( + + {emptyText} + + ) + } + + return ( + + + {COLUMN_LABELS.map(h => ( + + {h} + + ))} + + + {leads.map(lead => ( + + + {withWorkspace && lead.id === selectedId && } + + ))} + + ) +} diff --git a/src/components/expose/LeadWorkspace.tsx b/src/components/expose/LeadWorkspace.tsx new file mode 100644 index 0000000..9d97672 --- /dev/null +++ b/src/components/expose/LeadWorkspace.tsx @@ -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(lead.propertyIds[0] ?? '') + + if (isLoading) return + + // 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 => Boolean(p)) + + if (recommended.length === 0) { + return ( + + + Zu diesem Lead ist kein Objekt aus «Meine Objekte» hinterlegt — ohne Objekt lässt sich kein Exposé erstellen. + + + ) + } + + const active = recommended.find(p => p.id === propertyId) ?? recommended[0] + + return ( + + {recommended.length > 1 && ( + setPropertyId(v)} + variant="scrollable" + scrollButtons="auto" + sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontSize: '0.8125rem' } }} + > + {recommended.map(p => ( + + ))} + + )} + + + + ) +} diff --git a/src/components/expose/index.ts b/src/components/expose/index.ts new file mode 100644 index 0000000..f38eb7f --- /dev/null +++ b/src/components/expose/index.ts @@ -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' diff --git a/src/components/future-signals/FutureSignalCard.tsx b/src/components/future-signals/FutureSignalCard.tsx index e24bf62..6794965 100644 --- a/src/components/future-signals/FutureSignalCard.tsx +++ b/src/components/future-signals/FutureSignalCard.tsx @@ -4,15 +4,10 @@ import { SensitivityBadge } from './SensitivityBadge' import { SignalReviewStatusBadge } from './SignalReviewStatusBadge' import { FutureSignalDisclaimer } from './FutureSignalDisclaimer' import type { FutureSignal } from '../../domain/futureSignal' +import { SOURCE_TYPE_LABELS } from '../../lib/constants' +import { DS_SLATE } from '../../lib/ds' + -const SOURCE_LABELS: Record = { - PRESS: 'Presse', - CONSTRUCTION_PERMIT: 'Baubewilligung', - JOB_POSTING: 'Stelleninserat', - COMPANY_REPORT: 'Geschäftsbericht', - MARKET_DATA: 'Marktdaten', - MANUAL: 'Manuell', -} function probColor(p: number): string { return p >= 0.7 ? '#1a7a4a' : p >= 0.5 ? '#d97706' : '#c0392b' @@ -74,7 +69,7 @@ export function FutureSignalCard({ signal, isSelected, onSelect }: Props) { sx={{ height: 4, borderRadius: 2, - bgcolor: '#f1f5f9', + bgcolor: DS_SLATE[100], '& .MuiLinearProgress-bar': { bgcolor: probColor(signal.probability) }, }} /> @@ -87,7 +82,7 @@ export function FutureSignalCard({ signal, isSelected, onSelect }: Props) { )} = { - PRESS: 'Pressebericht', - CONSTRUCTION_PERMIT: 'Baubewilligung', - JOB_POSTING: 'Stelleninserat', - COMPANY_REPORT: 'Geschäftsbericht', - MARKET_DATA: 'Marktdaten', - MANUAL: 'Manuell erfasst', -} const CREDIBILITY_META: Record = { - HIGH: { label: 'Hoch', color: '#1a7a4a' }, - MEDIUM: { label: 'Mittel', color: '#d97706' }, - LOW: { label: 'Niedrig', color: '#c0392b' }, + HIGH: { label: 'Hoch', color: DS_ACCENT.success.main }, + MEDIUM: { label: 'Mittel', color: DS_ACCENT.warning.main }, + LOW: { label: 'Niedrig', color: DS_ACCENT.danger.main }, } function BarRow({ label, value }: { label: string; value: number }) { @@ -38,7 +33,7 @@ function BarRow({ label, value }: { label: string; value: number }) { ) @@ -92,12 +87,12 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { title: signal.title ?? signal.companyName ?? signal.locationHint, matchScore: Math.round(signal.confidenceScore * 100), confidenceScore: signal.confidenceScore, - sourceLabel: SOURCE_LABELS[signal.source.type] ?? signal.source.type, + sourceLabel: SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type, addedBy: 'admin@ideal-sharing.ch', }) } - const credMeta = CREDIBILITY_META[signal.source.credibility] ?? { label: signal.source.credibility, color: '#64748b' } + const credMeta = CREDIBILITY_META[signal.source.credibility] ?? { label: signal.source.credibility, color: DS_SLATE[500] } return ( @@ -112,7 +107,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { {signal.locationHint} )} - + @@ -121,7 +116,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { {signal.isVerified && ( - + )} @@ -169,7 +164,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { Typ - {SOURCE_LABELS[signal.source.type] ?? signal.source.type} + {SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type} Glaubwürdigkeit @@ -188,7 +183,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { <> Evidenz - + {signal.evidence.summary} @@ -222,7 +217,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { @@ -232,7 +227,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { fullWidth size="small" variant="contained" disabled={updateStatus.isPending} onClick={() => handleStatus(ReviewStatus.APPROVED)} - sx={{ bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' }, justifyContent: 'flex-start' }} + sx={{ bgcolor: DS_ACCENT.success.main, '&:hover': { bgcolor: DS_ACCENT.success.darkAlt }, justifyContent: 'flex-start' }} endIcon={updateStatus.isPending ? : undefined} > Genehmigen @@ -243,7 +238,7 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) { fullWidth size="small" variant="outlined" disabled={updateStatus.isPending} onClick={() => handleStatus(ReviewStatus.REJECTED)} - sx={{ justifyContent: 'flex-start', color: '#c0392b', borderColor: '#c0392b' }} + sx={{ justifyContent: 'flex-start', color: DS_ACCENT.danger.main, borderColor: DS_ACCENT.danger.main }} > Ablehnen diff --git a/src/components/future-signals/FutureSignalFilterBar.tsx b/src/components/future-signals/FutureSignalFilterBar.tsx index cb2ed10..3fbd3a8 100644 --- a/src/components/future-signals/FutureSignalFilterBar.tsx +++ b/src/components/future-signals/FutureSignalFilterBar.tsx @@ -1,22 +1,10 @@ import { Box, Chip, FormControl, InputLabel, MenuItem, Select, Typography } from '@mui/material' import { SignalType } from '../../domain/enums' import { SIGNAL_TYPE_LABELS } from '../../lib/constants' +import { DEFAULT_SIGNAL_FILTERS } from './signalFilterState' +import type { SignalFilterState } from './signalFilterState' +import { DS_ACCENT, DS_BRAND } from '../../lib/ds' -export interface SignalFilterState { - signalType: string - minConfidence: number - sensitivityLevel: string - reviewStatus: string - timeHorizon: string -} - -export const DEFAULT_SIGNAL_FILTERS: SignalFilterState = { - signalType: '', - minConfidence: 0, - sensitivityLevel: '', - reviewStatus: '', - timeHorizon: '', -} interface Props { filters: SignalFilterState @@ -131,7 +119,7 @@ export function FutureSignalFilterBar({ filters, onChange, totalCount, filteredC label={`${activeCount} Filter aktiv`} size="small" onDelete={() => onChange(DEFAULT_SIGNAL_FILTERS)} - sx={{ bgcolor: '#eff6ff', color: '#152642' }} + sx={{ bgcolor: DS_ACCENT.blue.bg, color: DS_BRAND.main }} /> )} diff --git a/src/components/future-signals/SensitivityBadge.tsx b/src/components/future-signals/SensitivityBadge.tsx index 2ab119a..cad87f3 100644 --- a/src/components/future-signals/SensitivityBadge.tsx +++ b/src/components/future-signals/SensitivityBadge.tsx @@ -1,14 +1,15 @@ import { Chip } from '@mui/material' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' const SENSITIVITY_META: Record = { - PUBLIC: { label: 'Öffentlich', color: '#64748b' }, - INTERNAL: { label: 'Intern', color: '#d97706' }, - CONFIDENTIAL: { label: 'Vertraulich', color: '#c0392b' }, - RESTRICTED: { label: 'Eingeschränkt', color: '#7c3aed' }, + PUBLIC: { label: 'Öffentlich', color: DS_SLATE[500] }, + INTERNAL: { label: 'Intern', color: DS_ACCENT.warning.main }, + CONFIDENTIAL: { label: 'Vertraulich', color: DS_ACCENT.danger.main }, + RESTRICTED: { label: 'Eingeschränkt', color: DS_ACCENT.violet.main }, } export function SensitivityBadge({ level }: { level: string }) { - const meta = SENSITIVITY_META[level] ?? { label: level, color: '#64748b' } + const meta = SENSITIVITY_META[level] ?? { label: level, color: DS_SLATE[500] } return ( = { - UNREVIEWED: { label: 'Ungeprüft', color: '#94a3b8' }, - IN_REVIEW: { label: 'In Prüfung', color: '#d97706' }, - APPROVED: { label: 'Genehmigt', color: '#1a7a4a' }, - REJECTED: { label: 'Abgelehnt', color: '#c0392b' }, - FLAGGED: { label: 'Markiert', color: '#ea580c' }, + UNREVIEWED: { label: 'Ungeprüft', color: DS_SLATE[400] }, + IN_REVIEW: { label: 'In Prüfung', color: DS_ACCENT.warning.main }, + APPROVED: { label: 'Genehmigt', color: DS_ACCENT.success.main }, + REJECTED: { label: 'Abgelehnt', color: DS_ACCENT.danger.main }, + FLAGGED: { label: 'Markiert', color: DS_ACCENT.warning.strong }, } export function SignalReviewStatusBadge({ status }: { status?: ReviewStatus | null }) { const key = status ?? 'UNREVIEWED' - const meta = STATUS_META[key] ?? { label: key, color: '#94a3b8' } + const meta = STATUS_META[key] ?? { label: key, color: DS_SLATE[400] } return ( = { - EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: '#1a7a4a' }, - POSSIBLE_MOVE_OUT: { bg: 'rgba(217,119,6,0.12)', color: '#d97706' }, - CONSTRUCTION_PROJECT: { bg: 'rgba(37,99,235,0.12)', color: '#1d4ed8' }, - RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: '#c2410c' }, - PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: '#6d28d9' }, - SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: '#475569' }, + EXPANSION: { bg: 'rgba(26,122,74,0.12)', color: DS_ACCENT.success.main }, + POSSIBLE_MOVE_OUT: { bg: 'rgba(217,119,6,0.12)', color: DS_ACCENT.warning.main }, + CONSTRUCTION_PROJECT: { bg: 'rgba(37,99,235,0.12)', color: DS_ACCENT.blue.main }, + RESTRUCTURING: { bg: 'rgba(234,88,12,0.12)', color: DS_ACCENT.warning.burnt }, + PROJECT_DEVELOPMENT: { bg: 'rgba(124,58,237,0.12)', color: DS_ACCENT.violet.strong }, + SPACE_CONSOLIDATION: { bg: 'rgba(100,116,139,0.12)',color: DS_SLATE[600] }, } export function SignalTypeBadge({ type, size = 'small' }: SignalTypeBadgeProps) { - const { bg, color } = COLOR_MAP[type] ?? { bg: '#f1f5f9', color: '#475569' } + const { bg, color } = COLOR_MAP[type] ?? { bg: '#f1f5f9', color: DS_SLATE[600] } return ( { setMobileOpen(false) }, [location.pathname]) + // Das mobile Menü ist nur offen, solange die Route dieselbe bleibt. Statt es + // nach dem Routenwechsel per Effekt zu schliessen, hängt der Zustand am Pfad: + // ein Wechsel erzeugt einen neuen Schlüssel und damit einen frischen Zustand. + const [openForPath, setOpenForPath] = useState(location.pathname) + const isMobileOpen = mobileOpen && openForPath === location.pathname + const openMobileMenu = () => { setOpenForPath(location.pathname); setMobileOpen(true) } + const closeMobileMenu = () => setMobileOpen(false) // Auto-collapse sidebar only on smaller screens (< 1200px); laptops keep the full nav + account useEffect(() => { setSidebarCollapsed(isCompact) }, [isCompact, setSidebarCollapsed]) @@ -74,8 +79,8 @@ export function AppShell() { activeWorkspace={activeWorkspace} allowedWorkspaces={allowedWorkspaces} onWorkspaceClick={handleWorkspaceClick} - onToggle={isMobile ? () => setMobileOpen(false) : toggleSidebar} - onClose={isMobile ? () => setMobileOpen(false) : undefined} + onToggle={isMobile ? closeMobileMenu : toggleSidebar} + onClose={isMobile ? closeMobileMenu : undefined} userName={userName} orgName={orgName} /> @@ -89,8 +94,8 @@ export function AppShell() { {/* Mobile: temporary drawer */} {isMobile && ( setMobileOpen(false)} + open={isMobileOpen} + onClose={closeMobileMenu} variant="temporary" ModalProps={{ keepMounted: true }} slotProps={{ paper: { sx: { width: 264, bgcolor: 'transparent', boxShadow: 'none' } } }} @@ -103,7 +108,7 @@ export function AppShell() { setMobileOpen(true)} + onMenuClick={openMobileMenu} /> diff --git a/src/components/layout/AppShellSidebar.tsx b/src/components/layout/AppShellSidebar.tsx index fafef54..d06cef1 100644 --- a/src/components/layout/AppShellSidebar.tsx +++ b/src/components/layout/AppShellSidebar.tsx @@ -3,7 +3,9 @@ import { ChevronLeft, ChevronRight } from 'lucide-react' import { NavLink, useLocation } from 'react-router' import { WorkspaceType } from '../../domain/enums' import { useCompareStore } from '../../stores/compareStore' +import { AgentAvatar } from '../team' import { WORKSPACE_CONFIG, WORKSPACE_ORDER, getUserInitials } from './appShellConfig' +import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' // --------------------------------------------------------------------------- // Visual constants @@ -97,7 +99,7 @@ export function Sidebar({ 0 && ( 0 && pathname.startsWith(item.path) + const inSection = + subItems.length > 0 && + (pathname.startsWith(item.path) || subItems.some(c => pathname.startsWith(c.path))) return collapsed ? ( @@ -260,9 +267,12 @@ export function Sidebar({ {({ isActive }) => ( - - {child.label} - + {child.agentId && ( + + )} + + + {child.label} + + {child.role && ( + + {child.role} + + )} + )} @@ -315,7 +350,7 @@ export function Sidebar({ width: 32, height: 32, fontSize: '0.75rem', - bgcolor: '#b8975a', + bgcolor: DS_NEUTRAL.gold, flexShrink: 0, }} > @@ -338,7 +373,7 @@ export function Sidebar({ {collapsed ? : } diff --git a/src/components/layout/CompareTray.tsx b/src/components/layout/CompareTray.tsx index b19a6dc..62bf4a4 100644 --- a/src/components/layout/CompareTray.tsx +++ b/src/components/layout/CompareTray.tsx @@ -2,6 +2,7 @@ import { useNavigate, useLocation } from 'react-router' import { Box, Button, IconButton, Typography } from '@mui/material' import { X } from 'lucide-react' import { useCompareStore } from '../../stores/compareStore' +import { DS_BRAND, DS_NEUTRAL } from '../../lib/ds' const TYPE_DOT: Record = { VERIFIED_PORTFOLIO: '#152642', @@ -17,11 +18,12 @@ export function CompareTray() { if (!isDemand) return null + // Die Verzweigung über `resultType` genügt der Union — kein Cast nötig. const getTitle = (item: (typeof compareItems)[number]) => { if (item.resultType === 'FUTURE_AVAILABILITY') { - return (item as any).signal?.companyName ?? (item as any).signal?.locationHint ?? 'Signal' + return item.signal.companyName ?? item.signal.locationHint } - return (item as any).property?.title ?? `Score ${item.matchScore}` + return item.property.title } return ( @@ -33,7 +35,7 @@ export function CompareTray() { right: 0, zIndex: 1300, height: 56, - bgcolor: '#0f1923', + bgcolor: DS_NEUTRAL.sidebar, display: 'flex', alignItems: 'center', px: 3, @@ -42,7 +44,7 @@ export function CompareTray() { transition: 'transform 0.25s ease', }} > - + Vergleich ({compareItems.length}/4) @@ -62,7 +64,7 @@ export function CompareTray() { }} > - + {getTitle(item)} @@ -71,7 +73,7 @@ export function CompareTray() { 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 } }} > @@ -92,7 +94,7 @@ export function CompareTray() { variant="contained" size="small" onClick={() => navigate('/demand/compare')} - sx={{ bgcolor: '#152642', textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: '#162d4a' } }} + sx={{ bgcolor: DS_BRAND.main, textTransform: 'none', flexShrink: 0, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }} > Vergleich starten diff --git a/src/components/layout/NotificationButton.tsx b/src/components/layout/NotificationButton.tsx index cb2fa2c..de7ec4b 100644 --- a/src/components/layout/NotificationButton.tsx +++ b/src/components/layout/NotificationButton.tsx @@ -1,10 +1,12 @@ import { useMemo, useState } from 'react' import { Badge, Box, Divider, IconButton, Popover, Typography } from '@mui/material' -import { Bell } from 'lucide-react' +import { AlertTriangle, Bell } from 'lucide-react' import { useNavigate } from 'react-router' import { useNeedProfiles } from '../../hooks/useNeeds' import { useMatches } from '../../hooks/useMatches' +import { useVisitReportWarnings } from '../../hooks/useVisitAssignments' import { ROUTES } from '../../lib/constants' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' function loadSeenCounts(): Record { try { return JSON.parse(localStorage.getItem('notif-seen-counts') ?? '{}') } @@ -22,6 +24,9 @@ export function NotificationButton() { const { data: needs = [] } = useNeedProfiles() const { data: allMatches = [] } = useMatches() + // Bruno warnt über die Glocke, wenn eine Besichtigung in weniger als 24 + // Stunden stattfindet und noch kein Bericht angefordert wurde (Runde 4, §9.3.1). + const { data: visitWarnings = [] } = useVisitReportWarnings() const profilesWithMatches = useMemo(() => { const active = needs.filter(n => @@ -45,7 +50,7 @@ export function NotificationButton() { () => profilesWithMatches.filter(x => x.count > (seenCounts[x.need.id] ?? 0)), [profilesWithMatches, seenCounts], ) - const badgeCount = newProfiles.length + const badgeCount = newProfiles.length + visitWarnings.length function handleOpen(e: React.MouseEvent) { setAnchorEl(e.currentTarget) @@ -67,7 +72,7 @@ export function NotificationButton() { return ( <> - + @@ -81,7 +86,37 @@ export function NotificationButton() { transformOrigin={{ vertical: 'top', horizontal: 'right' }} slotProps={{ paper: { sx: { width: 300, p: 2 } } }} > - + {visitWarnings.length > 0 && ( + <> + + Besichtigungen + + {visitWarnings.map(a => ( + { 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', + }} + > + + + + {a.propertyTitle} + + + In weniger als 24 h — Bericht noch nicht angefordert + + + + ))} + + + )} + + Neue Treffer @@ -98,18 +133,18 @@ export function NotificationButton() { sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1, py: 0.875, borderRadius: 1, cursor: 'pointer', - '&:hover': { bgcolor: '#f8fafc' }, transition: 'background 0.1s', + '&:hover': { bgcolor: DS_SLATE[50] }, transition: 'background 0.1s', }} > - + {need.companyName} - + ab {minScore}% - + {count} Treffer → @@ -117,7 +152,7 @@ export function NotificationButton() { { 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 → diff --git a/src/components/layout/OrganizationContextBadge.tsx b/src/components/layout/OrganizationContextBadge.tsx index bf0f83c..d1c13c7 100644 --- a/src/components/layout/OrganizationContextBadge.tsx +++ b/src/components/layout/OrganizationContextBadge.tsx @@ -1,6 +1,7 @@ import { Chip } from '@mui/material' import { Building2 } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' +import { DS_BG, DS_SLATE } from '../../lib/ds' export function OrganizationContextBadge() { const { currentUser } = useSessionStore() @@ -13,7 +14,7 @@ export function OrganizationContextBadge() { variant="outlined" icon={} 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 }} /> ) } diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx index 1412d73..914df8b 100644 --- a/src/components/layout/PageHeader.tsx +++ b/src/components/layout/PageHeader.tsx @@ -2,6 +2,7 @@ import { Box, Breadcrumbs, Typography } from '@mui/material' import type { SxProps, Theme } from '@mui/material' import type { ReactNode } from 'react' import { NavLink } from 'react-router' +import { DS_SLATE } from '../../lib/ds' interface BreadcrumbItem { label: string @@ -36,7 +37,7 @@ export function PageHeader({ {crumb.label} @@ -52,7 +53,7 @@ export function PageHeader({ - + {title} {badge} diff --git a/src/components/layout/RightContextPanel.tsx b/src/components/layout/RightContextPanel.tsx index d67c5b3..be77ed9 100644 --- a/src/components/layout/RightContextPanel.tsx +++ b/src/components/layout/RightContextPanel.tsx @@ -2,6 +2,7 @@ import { Box, Divider, IconButton, Typography } from '@mui/material' import { X } from 'lucide-react' import { useLayoutStore } from '../../stores/layoutStore' import type { RightPanelContentType } from '../../stores/layoutStore' +import { DS_NEUTRAL, DS_SLATE } from '../../lib/ds' const PANEL_TITLES: Record = { ai_context: 'KI Kontext', @@ -35,7 +36,7 @@ export function RightContextPanel() { width: { xs: '85vw', sm: 300, lg: 320 }, transform: isRightPanelOpen ? 'translateX(0)' : 'translateX(110%)', transition: 'transform 0.25s ease', - bgcolor: '#fff', + bgcolor: DS_NEUTRAL.white, borderLeft: '1px solid #e2e8f0', boxShadow: '-4px 0 16px rgba(0,0,0,0.08)', zIndex: 1200, @@ -55,7 +56,7 @@ export function RightContextPanel() { }} > {title} - + diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index e0cb38d..1dc45f3 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -5,6 +5,7 @@ import { HelpCircle, LogOut, Settings, User } from 'lucide-react' import { useSessionStore } from '../../stores/sessionStore' import { useToastStore } from '../../stores/toastStore' import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher' +import { DS_BRAND } from '../../lib/ds' const ROLE_LABELS: Record = { PROPERTY_MANAGER: 'Property Manager', @@ -45,7 +46,7 @@ export function UserMenu() { return ( <> - + {initials} diff --git a/src/components/layout/__tests__/appShellConfig.test.ts b/src/components/layout/__tests__/appShellConfig.test.ts index 5ec0e5e..75e130b 100644 --- a/src/components/layout/__tests__/appShellConfig.test.ts +++ b/src/components/layout/__tests__/appShellConfig.test.ts @@ -1,69 +1,85 @@ /** - * Property On — Position und Struktur des Navigationseintrags (§3.3, §3.4). + * Property On — Position und Struktur des Navigationseintrags «Meine Agenten». * - * Die Spezifikation ist hier wörtlich: «Teamübersicht» steht direkt unter - * «Meine Objekte» und oberhalb von «Reminder Manager». Eine spätere Umsortierung - * der Navigation würde das still brechen — dieser Test hält es fest. + * Seit Runde 4 ist «Meine Agenten» der einzige Eintrag mit Unterpunkten, und + * diese Unterpunkte sind die fünf digitalen Mitarbeitenden in verbindlicher + * Reihenfolge. Eine spätere Umsortierung würde das still brechen — dieser Test + * hält es fest. */ import { describe, it, expect } from 'vitest' import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig' import { WorkspaceType } from '../../../domain/enums' -import { ROUTES } from '../../../lib/constants' +import { MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants' +import { AGENT_WORKSPACES } from '../../../lib/agentWorkspaces' const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems -describe('Navigationseintrag «Teamübersicht»', () => { - it('steht direkt zwischen «Meine Objekte» und «Reminder Manager»', () => { +describe('Navigationseintrag «Meine Agenten»', () => { + it('steht direkt unter «Meine Objekte»', () => { const labels = supplyNav.map(item => item.label) const objekte = labels.indexOf('Meine Objekte') - const team = labels.indexOf('Teamübersicht') - const reminder = labels.indexOf('Reminder Manager') + const agenten = labels.indexOf(MY_AGENTS_LABEL) expect(objekte).toBeGreaterThanOrEqual(0) - expect(team).toBe(objekte + 1) - expect(reminder).toBe(team + 1) + expect(agenten).toBe(objekte + 1) }) it('führt auf den Basispfad des Funktionsbereichs', () => { - const team = supplyNav.find(item => item.label === 'Teamübersicht') - expect(team?.path).toBe(ROUTES.SUPPLY.TEAM) + const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) + expect(agenten?.path).toBe(ROUTES.SUPPLY.TEAM) }) - it('trägt die drei Subreiter in verbindlicher Reihenfolge', () => { - const team = supplyNav.find(item => item.label === 'Teamübersicht') - expect(team?.children?.map(c => c.label)).toEqual([ - 'Personalverwaltung', - 'Bearbeitungsverlauf', - 'Kanäle & Systeme', - ]) + it('trägt die fünf Agenten in verbindlicher Reihenfolge', () => { + const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) + expect(agenten?.children?.map(c => c.label)).toEqual(['Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina']) }) - it('verweist mit jedem Subreiter auf einen Unterpfad der Teamübersicht', () => { - const team = supplyNav.find(item => item.label === 'Teamübersicht') - for (const child of team?.children ?? []) { - expect(child.path.startsWith(`${ROUTES.SUPPLY.TEAM}/`)).toBe(true) + it('gibt jedem Agenten Porträt-ID und Funktionsbezeichnung mit', () => { + const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) + for (const child of agenten?.children ?? []) { + expect(child.agentId).toBeTruthy() + expect(child.role).toBeTruthy() } }) + it('verweist mit jedem Agenten auf eine eigene Seite', () => { + const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) + const paths = agenten?.children?.map(c => c.path) ?? [] + expect(paths).toEqual(AGENT_WORKSPACES.map(a => a.path)) + expect(new Set(paths).size).toBe(paths.length) + }) + it('ist der einzige Hauptreiter mit Subreitern — sonst wäre die Sidebar uneinheitlich', () => { const withChildren = supplyNav.filter(item => (item.children?.length ?? 0) > 0) - expect(withChildren.map(item => item.label)).toEqual(['Teamübersicht']) + expect(withChildren.map(item => item.label)).toEqual([MY_AGENTS_LABEL]) + }) + + it('führt Reto und Lea nicht mehr', () => { + const labels = supplyNav.flatMap(item => [item.label, ...(item.children ?? []).map(c => c.label)]) + expect(labels).not.toContain('Reto') + expect(labels).not.toContain('Lea') }) }) describe('Seitentitel aus dem Pfad', () => { - it('löst Haupt- und Subreiter über die Navigation auf', () => { - expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM)).toBe('Teamübersicht') - expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_PERSONNEL)).toBe('Personalverwaltung') - expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_HISTORY)).toBe('Bearbeitungsverlauf') - expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_CONNECTIONS)).toBe('Kanäle & Systeme') + it('löst Haupt- und Agentenreiter über die Navigation auf', () => { + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM)).toBe(MY_AGENTS_LABEL) + expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_FERDI)).toBe('Ferdi') + expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_BRUNO)).toBe('Bruno') + expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_LIVIA)).toBe('Livia') + expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_NORA)).toBe('Nora') + expect(getPageNameFromPath(ROUTES.SUPPLY.AGENT_SINA)).toBe('Sina') }) - it('fängt Deep-Links ab, statt in den Segment-Fallback zu fallen', () => { - // Ohne eigene Regel ergäbe der Fallback «Erledigte auftraege» bzw. «Ferdi». - expect(getPageNameFromPath(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/ferdi/aufgaben`)).toBe('Personalblatt') - expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_HISTORY_DONE)).toBe('Bearbeitungsverlauf') - expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_HISTORY_PENDING)).toBe('Bearbeitungsverlauf') + it('trägt die alten Unterseitenpfade bis zur Umleitung unter «Meine Agenten»', () => { + // Ohne eigene Regel ergäbe der Fallback «Personalverwaltung» bzw. «Erledigte auftraege». + expect(getPageNameFromPath(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/ferdi/aufgaben`)).toBe(MY_AGENTS_LABEL) + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_HISTORY_DONE)).toBe(MY_AGENTS_LABEL) + expect(getPageNameFromPath(ROUTES.SUPPLY.TEAM_CONNECTIONS)).toBe(MY_AGENTS_LABEL) + }) + + it('erkennt die Objekt-Detailroute', () => { + expect(getPageNameFromPath(`${ROUTES.SUPPLY.PROPERTIES}/prop-001`)).toBe('Objekt Detail') }) }) diff --git a/src/components/layout/appShellConfig.ts b/src/components/layout/appShellConfig.ts index 4b9b7e7..46de82a 100644 --- a/src/components/layout/appShellConfig.ts +++ b/src/components/layout/appShellConfig.ts @@ -1,5 +1,6 @@ import { WorkspaceType } from '../../domain/enums' -import { ROUTES } from '../../lib/constants' +import { MY_AGENTS_LABEL, ROUTES } from '../../lib/constants' +import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces' import type { LucideIcon } from 'lucide-react' import { LayoutDashboard, @@ -7,8 +8,6 @@ import { CheckSquare, Search, ClipboardList, - Radar, - BellRing, Settings, Users, } from 'lucide-react' @@ -18,13 +17,21 @@ import { // --------------------------------------------------------------------------- /** - * Eingerückter Subreiter unterhalb eines Hauptreiters. Bewusst ohne Icon: die - * Einrückung und der schmalere Schriftgrad machen die Hierarchie deutlich, - * ein zweites Icon-Raster würde die Sidebar unruhig machen. + * Eingerückter Subreiter unterhalb eines Hauptreiters. + * + * Ohne `agentId` bleibt der Eintrag eine reine Textzeile — Einrückung und + * schmalerer Schriftgrad machen die Hierarchie deutlich. Mit `agentId` zeigt + * die Sidebar stattdessen Porträt, Name und Funktion: die fünf digitalen + * Mitarbeitenden sind Personen, keine Menüpunkte (Runde 4, §2.2). Bewusst kein + * Autonomiegrad und kein technisches Kurzlabel. */ export interface NavSubItem { path: string label: string + /** Agenten-ID für Porträt und Funktionszeile. */ + agentId?: string + /** Funktionsbezeichnung unter dem Namen. Nur zusammen mit `agentId`. */ + role?: string } export interface NavItem { @@ -63,18 +70,17 @@ export const WORKSPACE_CONFIG: Record = { { path: '/supply/properties', label: 'Meine Objekte', icon: Building2 }, { path: ROUTES.SUPPLY.TEAM, - label: 'Teamübersicht', + label: MY_AGENTS_LABEL, icon: Users, - children: [ - { path: ROUTES.SUPPLY.TEAM_PERSONNEL, label: 'Personalverwaltung' }, - { path: ROUTES.SUPPLY.TEAM_HISTORY, label: 'Bearbeitungsverlauf' }, - { path: ROUTES.SUPPLY.TEAM_CONNECTIONS, label: 'Kanäle & Systeme' }, - ], + // Die drei Verwaltungsbereiche sind Reiter der Hauptseite geworden; + // hier stehen ab Runde 4 die fünf digitalen Mitarbeitenden selbst. + children: AGENT_WORKSPACES.map(a => ({ + path: a.path, + label: a.name, + agentId: a.id, + role: a.role, + })), }, - { path: '/supply/reminder-manager', label: 'Reminder Manager', icon: BellRing }, - { path: '/supply/data-quality', label: 'Datenpflege', icon: CheckSquare }, - { path: '/supply/market-intelligence', label: 'Marktchancen', icon: Radar }, - { path: '/supply/my-listings', label: 'Inserate', icon: ClipboardList }, ], }, /** @@ -129,9 +135,12 @@ export function getPageNameFromPath(pathname: string): string { } // Property On: Deep-Links auf Personalblatt und Verlaufsreiter. Ohne diese Fälle // fiele der Titel in den Segment-Fallback und ergäbe «Personalverwaltung» statt - // des Mitarbeiternamens bzw. «Erledigte auftraege». - if (/^\/supply\/team\/personalverwaltung\/.+/.test(pathname)) return 'Personalblatt' - if (/^\/supply\/team\/bearbeitungsverlauf\/.+/.test(pathname)) return 'Bearbeitungsverlauf' + // des Mitarbeiternamens bzw. «Erledigte auftraege». Die Basispfade der drei + // Verwaltungsbereiche leiten auf `/supply/team?section=…` um, führen aber bis + // zum Abschluss der Umleitung noch kurz durch den Seitentitel. + if (/^\/supply\/team\/personalverwaltung(\/.+)?$/.test(pathname)) return MY_AGENTS_LABEL + if (/^\/supply\/team\/bearbeitungsverlauf(\/.+)?$/.test(pathname)) return MY_AGENTS_LABEL + if (/^\/supply\/team\/kanaele-systeme$/.test(pathname)) return MY_AGENTS_LABEL if (/^\/supply\/properties\/.+/.test(pathname)) return 'Objekt Detail' const segment = pathname.split('/').filter(Boolean).pop() ?? '' return segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ') diff --git a/src/components/market-leads/LeadDetail.tsx b/src/components/market-leads/LeadDetail.tsx new file mode 100644 index 0000000..7539c06 --- /dev/null +++ b/src/components/market-leads/LeadDetail.tsx @@ -0,0 +1,219 @@ +import { useCallback, useMemo, useState } from 'react' +import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material' +import { CheckCircle2, ExternalLink, Forward, MapPin, Ruler } from 'lucide-react' +import type { MarketLead } from '../../hooks/useMarketLeads' +import { useCreateExposeLead } from '../../hooks/useExposeLeads' +import { useToastStore } from '../../stores/toastStore' +import { AgentAvatar } from '../team' +import { agentWorkspaceById } from '../../lib/agentWorkspaces' +import { SOURCE_TYPE_LABELS } from '../../lib/constants' +import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers' +import { ContactRow, SignalPropertyRow } from './MarketLeadPanels' +import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' + +const NORA = agentWorkspaceById('nora')! + +/** + * Detailansicht eines erkannten Nachfragesignals. + * + * Eigene Datei — die Liste links und das Detail rechts teilen sich nur die + * Hilfsfunktionen, nicht den Aufbau. + */ + +// ── Right pane detail ──────────────────────────────────────────────────────── +// `MIN_MATCH_PCT` und `confirmableContacts` liegen in `marketLeadHelpers.tsx`. + +/** + * Wrapper, der die Detailansicht über die Signal-ID neu aufbaut. + * + * Damit ist die Objektauswahl an das Signal gebunden, ohne sie in einem Effekt + * zurücksetzen zu müssen — ein neues Signal ist eine neue Komponente, und die + * startet ohnehin ohne Auswahl. + */ +function LeadDetail({ lead }: { lead: MarketLead }) { + return +} + +function LeadDetailBody({ lead }: { lead: MarketLead }) { + const { signal, matchingProperties } = lead + const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type + const companyName = signal.companyName ?? signal.locationHint + + const forwardLead = useCreateExposeLead() + const showToast = useToastStore(s => s.showToast) + const [selectedIds, setSelectedIds] = useState([]) + + // Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt. + const relevant = useMemo( + () => + matchingProperties + .map(p => ({ p, fit: signal.areaSqmEstimate ? areaFitPct(p.areaSqm, signal.areaSqmEstimate) : null })) + .filter(({ fit }) => fit === null || fit >= MIN_MATCH_PCT), + [matchingProperties, signal.areaSqmEstimate], + ) + + const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts]) + + const toggle = useCallback((id: string) => { + setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) + }, []) + + function handleForward() { + const count = selectedIds.length + forwardLead.mutate( + { + prospect: companyName, + contacts: contacts.map(c => c.value), + locationHint: signal.locationHint, + propertyIds: selectedIds, + sourceSignalId: signal.id, + }, + { + onSuccess: () => { + setSelectedIds([]) + showToast(`An Livia weitergeleitet — ${count} Objekt(e) für ${companyName}.`, 'success') + }, + }, + ) + } + + return ( + + {/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */} + + + {companyName} + + {signal.title && ( + + {signal.title} + + )} + {/* Stats row */} + + } label={signal.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} /> + {signal.areaSqmEstimate && ( + } label={`~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²`} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} /> + )} + + + {/* Source link */} + + + Quelle: {sourceLabel} + {signal.source.publishedAt && ` · ${new Date(signal.source.publishedAt).toLocaleDateString('de-CH')}`} + + {signal.source.url && ( + + Quelle öffnen + + )} + + + + + {/* «Das meint Nora» — das Analyse-Kästchen bleibt, es trägt jetzt aber + ihr Gesicht statt eines generischen KI-Symbols (§7.3). */} + {(signal.aiSummary ?? signal.strategicInterpretation) && ( + + + + + Das meint Nora + + + + {signal.aiSummary ?? signal.strategicInterpretation} + + + )} + + {/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */} + {(signal.confirmedFacts?.length ?? 0) > 0 && ( + + + Bestätigte Angaben + + + {signal.confirmedFacts!.map((f, i) => ( + + + {f} + + ))} + + + )} + + + + {/* Extrahierte Kontaktdaten */} + + + Extrahierte Kontaktdaten + + {contacts.length === 0 ? ( + + Keine gesicherten Kontaktdaten vorhanden — manuelle Recherche empfohlen + + ) : ( + + {contacts.map((c, i) => )} + + )} + + + + + {/* Passende Objekte im Portfolio */} + + + Passende Objekte im Portfolio ({relevant.length}) + + {relevant.length === 0 ? ( + + Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen + + ) : ( + <> + {relevant.map(({ p, fit }) => ( + toggle(p.id)} + /> + ))} + + + )} + + + {signal.disclaimer && ( + + {signal.disclaimer} + + )} + + + ) +} + + +export { LeadDetail } diff --git a/src/components/market-leads/MarketLeadPanels.tsx b/src/components/market-leads/MarketLeadPanels.tsx new file mode 100644 index 0000000..be2f484 --- /dev/null +++ b/src/components/market-leads/MarketLeadPanels.tsx @@ -0,0 +1,162 @@ +import { memo } from 'react' +import { Box, Checkbox, Chip, Typography } from '@mui/material' +import { Building2, ExternalLink } from 'lucide-react' +import type { MarketLead } from '../../hooks/useMarketLeads' +import type { Property } from '../../domain/property' +import type { ExtractedContact } from '../../domain/futureSignal' +import { ObjectDeepLink } from '../team' +import { CONTACT_ICONS } from './marketLeadHelpers' +import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' + +/** + * Signalliste und Signaldetail der Nora-Seite. + * + * Ausgezogen aus `MarketIntelligence.tsx` — die Seite hält jetzt nur noch + * Reiter, Auswahl und Rahmen. + */ + +// ── Left pane list item ────────────────────────────────────────────────────── + +/** + * Kompakte Signalkarte (Runde 4, §7.2). + * + * Prozentsatz und Objektanzahl sind entfallen: beide Zahlen wurden auf der + * Karte gelesen, ohne dass man etwas mit ihnen anfangen konnte — die + * Entscheidung fällt in der Detailansicht. Übrig bleiben die Kerninformationen. + */ +const LeadListItem = memo(function LeadListItem({ + lead, selected, onClick, +}: { + lead: MarketLead + selected: boolean + onClick: () => void +}) { + const { signal } = lead + + return ( + + + {signal.companyName ?? signal.locationHint} + + {signal.title && ( + + {signal.title} + + )} + + {signal.locationHint} · {signal.timeHorizonMonths} Mo. + + + ) +}) + +// ── Contact row ────────────────────────────────────────────────────────────── + +/** + * Extrahierte Kontaktdaten (Runde 4, §7.3). + * + * Ohne Konfidenzstufe und ohne Farbpunkt: gezeigt wird nur noch, was klar + * belegbar ist — siehe `confirmableContacts`. Ein Wert mit dem Hinweis + * «spekulativ» hilft niemandem; entweder man kann ihn anschreiben oder nicht. + */ +function ContactRow({ contact }: { contact: ExtractedContact }) { + const icon = CONTACT_ICONS[contact.type] + const isClickable = contact.type === 'WEBSITE' || contact.type === 'LINKEDIN' || contact.type === 'EMAIL' + const href = contact.type === 'EMAIL' ? `mailto:${contact.value}` : contact.type === 'WEBSITE' || contact.type === 'LINKEDIN' ? `https://${contact.value.replace(/^https?:\/\//, '')}` : undefined + + return ( + + {icon} + + {isClickable && href ? ( + + {contact.value} + + + ) : ( + + {contact.value} + + )} + {contact.label && ( + + {contact.label} + + )} + + + ) +} + +// ── Objektzeile mit Mehrfachauswahl ────────────────────────────────────────── + +/** + * Ein passendes Portfolioobjekt zu einem Signal. + * + * Der Anschreiben-Entwurf und die Reminder-Aktion sind entfallen (Runde 4, §7.3): + * Nora erkennt die Nachfrage, das Dokument macht Livia. Geblieben ist die + * Auswahl — mehrere Objekte lassen sich gemeinsam für dasselbe Signal + * weiterreichen, statt für jedes einzeln denselben Weg zu gehen. + */ +function SignalPropertyRow({ + p, fit, selected, onToggle, +}: { + p: Property + fit: number | null + selected: boolean + onToggle: () => void +}) { + return ( + + + + + + + {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J + + + {fit !== null && ( + + )} + + ) +} + + +export { LeadListItem, ContactRow, SignalPropertyRow } diff --git a/src/components/market-leads/index.ts b/src/components/market-leads/index.ts new file mode 100644 index 0000000..bf4e17a --- /dev/null +++ b/src/components/market-leads/index.ts @@ -0,0 +1,5 @@ +// Nora — KI-Signale und Leads. Barrel-Export (CLAUDE.md §13). + +export { LeadListItem } from './MarketLeadPanels' +export { LeadDetail } from './LeadDetail' +export { CONTACT_ICONS, MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers' diff --git a/src/components/market-leads/marketLeadHelpers.tsx b/src/components/market-leads/marketLeadHelpers.tsx new file mode 100644 index 0000000..b30b6ca --- /dev/null +++ b/src/components/market-leads/marketLeadHelpers.tsx @@ -0,0 +1,35 @@ +import { Globe, Linkedin, Mail, Phone, User } from 'lucide-react' +import type { ExtractedContact } from '../../domain/futureSignal' + +/** + * Gemeinsame Bausteine der Signalansicht auf der Nora-Seite. + * + * Ausgelagert aus `MarketIntelligence.tsx`, die mit über 1100 Zeilen zwei + * eigenständige Funktionsbereiche in einer Datei führte. + */ + +export const CONTACT_ICONS: Record = { + EMAIL: , + PHONE: , + WEBSITE: , + LINKEDIN: , + CONTACT_PERSON: , +} + +/** Ab hier gilt ein Objekt als passend genug, um es vorzuschlagen (Runde 4, §7.3). */ +export const MIN_MATCH_PCT = 70 + +export function areaFitPct(propArea: number, signalArea: number): number { + return Math.max(0, Math.round(100 - (Math.abs(propArea - signalArea) / signalArea) * 100)) +} + +/** + * Nur klar bestätigbare Kontaktangaben (Runde 4, §7.3). + * + * Alles unterhalb von «bestätigt» wird weggelassen statt eingefärbt: eine + * geschätzte E-Mail-Adresse mit gelbem Punkt ist keine halbe Information, + * sondern eine falsche. + */ +export function confirmableContacts(contacts: ExtractedContact[] | undefined): ExtractedContact[] { + return (contacts ?? []).filter(c => c.confidence === 'HIGH') +} diff --git a/src/components/markt-hinweise/HinweisErfassenDialog.tsx b/src/components/markt-hinweise/HinweisErfassenDialog.tsx new file mode 100644 index 0000000..a628ca1 --- /dev/null +++ b/src/components/markt-hinweise/HinweisErfassenDialog.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react' +import { + Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, + FormControl, FormControlLabel, InputLabel, MenuItem, Select as MuiSelect, + Switch, TextField, ToggleButton, ToggleButtonGroup, Typography, +} from '@mui/material' +import { Building2, Globe, Lock, Search } from 'lucide-react' +import type { + MarktHinweis, HinweisVisibility, HinweisDirection, HinweisQuelle, CreateMarktHinweisInput, +} from '../../domain/marktHinweis' +import { useCreateMarktHinweis } from '../../hooks/useMarktHinweise' +import { QUELLE_LABELS } from './hinweisHelpers' +import { DS_BRAND, DS_SLATE } from '../../lib/ds' + +/** + * Erfassung eines Netzwerk-Hinweises. + * + * Eigene Datei, weil der Dialog mit über 230 Zeilen für sich steht: er teilt + * mit den Panels nur die Beschriftungstabelle, nicht den Zustand. + */ + +// ── Netzwerk: HinweisErfassenDialog ───────────────────────────────────────── + +function HinweisErfassenDialog({ + open, + onClose, + onCreated, +}: { + open: boolean + onClose: () => void + onCreated: (id: string) => void +}) { + const mutation = useCreateMarktHinweis() + + const [direction, setDirection] = useState('SUCHE') + const [assetType, setAssetType] = useState('') + const [locationHint, setLocationHint] = useState('') + const [areaSqmMin, setAreaSqmMin] = useState('') + const [areaSqmMax, setAreaSqmMax] = useState('') + const [companyName, setCompanyName] = useState('') + const [isAnonymized, setIsAnonymized] = useState(false) + const [quelle, setQuelle] = useState('NETZWERKEVENT') + const [note, setNote] = useState('') + const [visibility, setVisibility] = useState('INTERN') + + const isValid = assetType.trim() !== '' && locationHint.trim() !== '' + + function resetForm() { + setDirection('SUCHE') + setAssetType('') + setLocationHint('') + setAreaSqmMin('') + setAreaSqmMax('') + setCompanyName('') + setIsAnonymized(false) + setQuelle('NETZWERKEVENT') + setNote('') + setVisibility('INTERN') + } + + function handleSave() { + if (!isValid) return + const input: CreateMarktHinweisInput = { + direction, + assetType: assetType as MarktHinweis['assetType'], + locationHint, + areaSqmMin: areaSqmMin ? Number(areaSqmMin) : undefined, + areaSqmMax: areaSqmMax ? Number(areaSqmMax) : undefined, + companyName: companyName.trim() || undefined, + isAnonymized, + quelle, + note: note.trim() || undefined, + visibility, + status: 'OFFEN', + } + mutation.mutate(input, { + onSuccess: (result) => { + onCreated(result.id) + resetForm() + }, + }) + } + + function handleClose() { + resetForm() + onClose() + } + + return ( + + Hinweis erfassen + + {/* Richtung */} + + + Richtung + + { if (v) setDirection(v as HinweisDirection) }} + size="small" + sx={{ '& .MuiToggleButton-root': { textTransform: 'none', fontSize: '0.8rem' } }} + > + + Jemand sucht + + + Wird verfügbar + + + + + {/* Asset-Typ */} + + Asset-Typ * + setAssetType(e.target.value)} + > + Büro + Einzelhandel + Gewerbe + Logistik + Produktion + + + + {/* Stadt / Region */} + setLocationHint(e.target.value)} + /> + + {/* Fläche */} + + setAreaSqmMin(e.target.value)} + sx={{ flex: 1 }} + /> + setAreaSqmMax(e.target.value)} + sx={{ flex: 1 }} + /> + + + {/* Firma / Name */} + setCompanyName(e.target.value)} + /> + + {/* Quelle */} + + Quelle + setQuelle(e.target.value as HinweisQuelle)} + > + {Object.entries(QUELLE_LABELS).map(([k, v]) => ( + {v} + ))} + + + + {/* Notiz */} + setNote(e.target.value)} + /> + + {/* Sichtbarkeit */} + + + Sichtbarkeit + + + setVisibility('INTERN')} + sx={{ + flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer', + display: 'flex', flexDirection: 'column', gap: 0.5, + border: visibility === 'INTERN' ? '2px solid #4338ca' : '1px solid #e2e8f0', + bgcolor: visibility === 'INTERN' ? '#eef2ff' : 'white', + }} + > + + + Intern + + Nur für Ihr Team sichtbar + + setVisibility('PLATTFORM')} + sx={{ + flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer', + display: 'flex', flexDirection: 'column', gap: 0.5, + border: visibility === 'PLATTFORM' ? '2px solid #7c3aed' : '1px solid #e2e8f0', + bgcolor: visibility === 'PLATTFORM' ? '#faf5ff' : 'white', + }} + > + + + Plattform + + Für alle Verwaltungen auf Property Match sichtbar + + + + + {/* Anonymisieren (only for PLATTFORM) */} + {visibility === 'PLATTFORM' && ( + setIsAnonymized(e.target.checked)} + size="small" + /> + } + label={Firmenname anonymisieren} + /> + )} + + + + + + + + ) +} + + +export { HinweisErfassenDialog } diff --git a/src/components/markt-hinweise/MarktHinweisPanels.tsx b/src/components/markt-hinweise/MarktHinweisPanels.tsx new file mode 100644 index 0000000..32c4af7 --- /dev/null +++ b/src/components/markt-hinweise/MarktHinweisPanels.tsx @@ -0,0 +1,301 @@ +import { memo, useCallback, useMemo, useState } from 'react' +import { Alert, Box, Button, Chip, Divider, TextField, Typography } from '@mui/material' +import { Building2, CheckCircle2, Copy, MapPin, Ruler, Search } from 'lucide-react' +import type { MarktHinweis } from '../../domain/marktHinweis' +import type { Property } from '../../domain/property' +import { useProperties } from '../../hooks/useProperties' +import { ASSET_TYPE_LABELS } from '../../lib/constants' +import { areaFitPct } from '../market-leads' +import { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers' +import { DS_ACCENT, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' + +/** + * Der Netzwerk-Bereich der Nora-Seite: menschlich erfasste Marktinformationen. + * + * Ausgezogen aus `MarketIntelligence.tsx`, die mit über 1100 Zeilen zwei + * eigenständige Funktionsbereiche in einer Datei führte — bei einem harten + * Limit von 300 Zeilen je Seite. + */ + +// ── Netzwerk: HinweisPropertyCard ──────────────────────────────────────────── + +const HinweisPropertyCard = memo(function HinweisPropertyCard({ + p, + hinweis, +}: { + p: Property + hinweis: MarktHinweis +}) { + const [open, setOpen] = useState(false) + const [copied, setCopied] = useState(false) + const companyName = hinweis.isAnonymized ? 'Interessent' : (hinweis.companyName ?? 'Interessent') + const areaEstimate = hinweis.areaSqmMax ?? hinweis.areaSqmMin + const [draft, setDraft] = useState(() => + buildAnschreiben(companyName, hinweis.locationHint, p, areaEstimate) + ) + const fit = areaEstimate != null ? areaFitPct(p.areaSqm, areaEstimate) : null + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(draft) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [draft]) + + return ( + + {/* Property header */} + + + + + {p.title} + + + {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J + + + {fit !== null && ( + = 75 ? '#dcfce7' : fit >= 50 ? '#fef9c3' : '#fee2e2', + color: fit >= 75 ? '#16a34a' : fit >= 50 ? '#92400e' : '#dc2626', + }} + /> + )} + + + {/* Anschreiben toggle */} + + + + + {/* Anschreiben composer */} + {open && ( + + + + Anschreiben-Entwurf + + + + setDraft(e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { fontSize: '0.8rem', bgcolor: 'white' }, + '& textarea': { lineHeight: 1.6 }, + }} + /> + + Text bearbeitbar — dann kopieren und per E-Mail versenden + + + )} + + ) +}) + +// ── Netzwerk: HinweisListItem ──────────────────────────────────────────────── + +const HinweisListItem = memo(function HinweisListItem({ + hinweis, selected, onClick, +}: { + hinweis: MarktHinweis + selected: boolean + onClick: () => void +}) { + const displayName = !hinweis.isAnonymized && hinweis.companyName + ? hinweis.companyName + : `Anonym · ${ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType}` + + const areaStr = useMemo(() => { + if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) { + return ` · ${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²` + } + if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax} m²` + if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin} m²` + return '' + }, [hinweis.areaSqmMin, hinweis.areaSqmMax]) + + const visibilityBadge = useMemo(() => { + if (hinweis.visibility === 'INTERN') { + return + } + if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) { + return + } + return + }, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName]) + + return ( + + + + {hinweis.direction === 'SUCHE' + ? + : + } + + + {displayName} + + {visibilityBadge} + + + {hinweis.locationHint}{areaStr} + + + {hinweis.direction === 'SUCHE' + ? + : + } + + + ) +}) + +// ── Netzwerk: HinweisDetail ────────────────────────────────────────────────── + +function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) { + const { data: properties = [] } = useProperties() + + const matchingProperties = useMemo( + () => properties.filter(p => p.assetType === hinweis.assetType).slice(0, 3), + [properties, hinweis.assetType], + ) + + const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : 'Anonym' + + const areaStr = useMemo(() => { + if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) { + return `${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²` + } + if (hinweis.areaSqmMax != null) return `${hinweis.areaSqmMax} m²` + if (hinweis.areaSqmMin != null) return `${hinweis.areaSqmMin} m²` + return null + }, [hinweis.areaSqmMin, hinweis.areaSqmMax]) + + return ( + + {/* Header */} + + + {displayName} + + + {/* Badge row */} + + {hinweis.direction === 'SUCHE' + ? + : + } + {hinweis.visibility === 'INTERN' + ? + : hinweis.verwaltungId === OWN_VERWALTUNG_ID + ? + : + } + + + {/* Stat chips */} + + } label={hinweis.locationHint} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} /> + {areaStr && } label={areaStr} size="small" sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[600], fontSize: '0.75rem' }} />} + + + + {/* Quelle row */} + + + Quelle: {QUELLE_LABELS[hinweis.quelle] ?? hinweis.quelle} + {' · '}{hinweis.createdBy} + {' · '}{formatDate(hinweis.createdAt)} + + + + + {/* Body */} + + {hinweis.note && ( + + + Notiz + + + {hinweis.note} + + + )} + + + + {hinweis.direction === 'SUCHE' ? ( + + + Passende Objekte im Portfolio ({matchingProperties.length}) + + {matchingProperties.length === 0 ? ( + + Kein passendes Portfolioobjekt gefunden + + ) : ( + matchingProperties.map(p => ( + + )) + )} + + ) : ( + + Verfügbarkeitssignal — wird auf der Plattform für andere Verwaltungen sichtbar gemacht + + )} + + + ) +} + + +export { HinweisPropertyCard, HinweisListItem, HinweisDetail } diff --git a/src/components/markt-hinweise/hinweisHelpers.ts b/src/components/markt-hinweise/hinweisHelpers.ts new file mode 100644 index 0000000..c2f4307 --- /dev/null +++ b/src/components/markt-hinweise/hinweisHelpers.ts @@ -0,0 +1,48 @@ +import type { Property } from '../../domain/property' + +/** Eigene Verwaltung — unterscheidet «geteilt» von «fremd» in der Sichtbarkeitsmarke. */ +export const OWN_VERWALTUNG_ID = 'v-001' + +export const QUELLE_LABELS: Record = { + NETZWERKEVENT: 'Netzwerkevent', + TELEFONAT: 'Telefonat', + BESICHTIGUNG: 'Besichtigung', + MESSE: 'Messe', + EMAIL: 'E-Mail', + SONSTIGES: 'Sonstiges', +} + +export function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('de-CH') +} + +/** + * Anschreiben-Entwurf zu einem Netzwerk-Hinweis. + * + * Bleibt bewusst ein Entwurf zum Kopieren: es wird nichts versendet, und der + * Text nennt nur Angaben, die im Objektdatensatz stehen. + */ +export function buildAnschreiben( + company: string, + location: string, + p: Property, + areaSqmEstimate?: number, +): string { + const areaLine = areaSqmEstimate + ? `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m² (Sie suchen ca. ${areaSqmEstimate.toLocaleString('de-CH')} m²)` + : `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m²` + return `Sehr geehrte Damen und Herren, + +wir haben erkannt, dass ${company} nach Gewerbeflächen im Raum ${location} sucht. Gerne möchten wir Ihnen eine passende Option aus unserem Portfolio vorstellen: + +Objekt: ${p.title} +Lage: ${p.location.city} +${areaLine} +Mietpreis: CHF ${p.rentPricePerSqm.toLocaleString('de-CH')} / m² / Jahr + +Wir würden uns freuen, Ihnen das Objekt in einer unverbindlichen Besichtigung vorzustellen und Ihre konkreten Anforderungen zu besprechen. + +Mit freundlichen Grüssen +Wincasa AG +Immobilienverwaltung` +} diff --git a/src/components/markt-hinweise/index.ts b/src/components/markt-hinweise/index.ts new file mode 100644 index 0000000..c1f10a9 --- /dev/null +++ b/src/components/markt-hinweise/index.ts @@ -0,0 +1,5 @@ +// Netzwerk-Hinweise auf der Nora-Seite. Barrel-Export (CLAUDE.md §13). + +export { HinweisListItem, HinweisDetail } from './MarktHinweisPanels' +export { HinweisErfassenDialog } from './HinweisErfassenDialog' +export { OWN_VERWALTUNG_ID, QUELLE_LABELS, buildAnschreiben, formatDate } from './hinweisHelpers' diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 3a7d2d8..75eaa6e 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -5,7 +5,7 @@ import { useInquiryStore } from '../../stores/inquiryStore' import { LocationPreview } from '../shared/LocationPreview' import { HeatBadge } from '../shared/HeatBadge' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' -import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds' +import { DS_BORDER, DS_BRAND, DS_NEUTRAL, DS_TEXT, RESULT_TYPE_META } from '../../lib/ds' import { confidenceHex } from '../../lib/utils' import type { MatchCardViewModel } from './MatchCardViewModel' import { FutureAvailabilityCard } from './FutureAvailabilityCard' @@ -24,7 +24,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro const { currentUser } = useSessionStore() const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog) const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' - const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' } + const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: DS_TEXT.muted } const isFuture = vm.resultType === 'FUTURE_AVAILABILITY' const confPct = Math.round(vm.confidenceScore * 100) @@ -40,7 +40,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro borderRadius: 2, overflow: 'hidden', border: `1px solid ${DS_BORDER.default}`, - bgcolor: '#ffffff', + bgcolor: DS_NEUTRAL.white, boxShadow: '0 1px 3px rgba(0,0,0,0.06)', display: 'flex', flexDirection: 'column', @@ -48,7 +48,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro zIndex: 0, transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.15s', '&:hover': { - borderColor: '#b0aead', + borderColor: DS_NEUTRAL.stone, transform: 'scale(1.025)', boxShadow: '0 10px 32px rgba(0,0,0,0.13)', zIndex: 1, @@ -68,7 +68,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro }}> {vm.matchScore}% @@ -84,7 +84,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro backdropFilter: 'blur(4px)', }}> - + {vm.locationLabel} @@ -184,7 +184,7 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro propertyId: vm.propertyId, }) }} - sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }} + sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.dark } }} > Anfrage diff --git a/src/components/match-card/MatchActionToolbar.tsx b/src/components/match-card/MatchActionToolbar.tsx index f365f3e..43b5dbf 100644 --- a/src/components/match-card/MatchActionToolbar.tsx +++ b/src/components/match-card/MatchActionToolbar.tsx @@ -1,5 +1,6 @@ import { Box, Button } from '@mui/material' import type { MatchCardAction } from './MatchCardViewModel' +import { DS_ACCENT, DS_BRAND } from '../../lib/ds' const MUI_VARIANT: Record = { primary: 'contained', @@ -26,9 +27,9 @@ export function MatchActionToolbar({ actions }: Props) { onClick={action.onClick} sx={ action.variant === 'primary' - ? { bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } } + ? { bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } } : action.variant === 'danger' - ? { color: '#c0392b', borderColor: '#c0392b', '&:hover': { borderColor: '#c0392b' } } + ? { color: DS_ACCENT.danger.main, borderColor: DS_ACCENT.danger.main, '&:hover': { borderColor: DS_ACCENT.danger.main } } : {} } > diff --git a/src/components/match-card/MatchCardCompact.tsx b/src/components/match-card/MatchCardCompact.tsx index 6ea18b6..8c7cd2b 100644 --- a/src/components/match-card/MatchCardCompact.tsx +++ b/src/components/match-card/MatchCardCompact.tsx @@ -5,7 +5,7 @@ import { useSessionStore } from '../../stores/sessionStore' import { useInquiryStore } from '../../stores/inquiryStore' import { HeatBadge } from '../shared/HeatBadge' import { getScoreTier, SCORE_THEME } from '../shared/scoreTheme' -import { RESULT_TYPE_META, DS_TEXT, DS_BORDER } from '../../lib/ds' +import { DS_BG, DS_BORDER, DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT, RESULT_TYPE_META } from '../../lib/ds' import { confidenceHex } from '../../lib/utils' import { MatchCardRestrictedState } from './MatchCardRestrictedState' import type { MatchCardViewModel } from './MatchCardViewModel' @@ -16,15 +16,20 @@ interface Props { } export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: Props) { + // Hooks stehen vor jeder bedingten Rückgabe. Vorher lag der Restricted-Guard + // darüber: wechselte eine Karte zur Laufzeit zwischen gesperrt und offen, + // rief React beim zweiten Rendern eine andere Anzahl Hooks auf und warf + // «Rendered fewer hooks than expected». + const currentUser = useSessionStore(s => s.currentUser) + const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog) + if (vm.isRestricted) return - const { currentUser } = useSessionStore() - const openInquiryDialog = useInquiryStore(s => s.openInquiryDialog) const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' const tier = getScoreTier(vm.matchScore) const scoreBadgeBg = SCORE_THEME[tier].gradient - const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#9a9a9a' } + const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: DS_TEXT.muted } const confPct = Math.round(vm.confidenceScore * 100) const topRisk = vm.risks.find(r => r.level === 'CRITICAL' || r.level === 'HIGH') @@ -48,14 +53,14 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: overflow: 'hidden', border: `1px solid ${DS_BORDER.default}`, borderLeft: `3px solid ${leftAccent}`, - bgcolor: '#ffffff', + bgcolor: DS_NEUTRAL.white, boxShadow: '0 1px 3px rgba(0,0,0,0.06)', opacity: vm.isStaleData ? 0.75 : 1, transition: 'border-color 0.15s, transform 0.18s ease, box-shadow 0.18s ease', position: 'relative', zIndex: 0, '&:hover': { - borderColor: '#b0aead', + borderColor: DS_NEUTRAL.stone, transform: 'scale(1.012)', boxShadow: '0 6px 20px rgba(0,0,0,0.10)', zIndex: 1, @@ -63,7 +68,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: }}> {/* ── Image strip — 180px, Ginesta proportion ── */} - + {imageUrl ? ( @@ -89,7 +94,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: }}> {vm.matchScore}% @@ -105,7 +110,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: backdropFilter: 'blur(4px)', }}> - + {vm.locationLabel} @@ -127,7 +132,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: sx={{ bgcolor: confidenceHex(vm.confidenceScore), color: 'white', fontSize: 10, height: 20 }} /> {vm.resultType === 'VERIFIED_PORTFOLIO' && isPortfolioOwner && ( + sx={{ bgcolor: 'rgba(21,38,66,0.10)', color: DS_BRAND.main, fontWeight: 700, fontSize: 9, height: 18 }} /> )} @@ -143,7 +148,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: {/* Metric line — Ginesta-style */} {metricLine && ( - + {metricLine} )} @@ -181,7 +186,7 @@ export const MatchCardCompact = memo(function MatchCardCompact({ vm, imageUrl }: propertyId: vm.propertyId, }) }} - sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' } }} + sx={{ textTransform: 'none', fontSize: '0.7rem', py: 0.375, px: 1, bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.dark } }} > Anfrage diff --git a/src/components/match-card/MatchCardCompareMini.tsx b/src/components/match-card/MatchCardCompareMini.tsx index e83798e..6b704e8 100644 --- a/src/components/match-card/MatchCardCompareMini.tsx +++ b/src/components/match-card/MatchCardCompareMini.tsx @@ -2,11 +2,12 @@ import { Box, Card, Chip, Divider, IconButton, Typography } from '@mui/material' import { X } from 'lucide-react' import { MatchScoreDisplay } from './MatchScoreDisplay' import type { MatchCardViewModel } from './MatchCardViewModel' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' const RESULT_TYPE_META: Record = { - VERIFIED_PORTFOLIO: { label: 'Verified', color: '#152642' }, - MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, - FUTURE_AVAILABILITY: { label: 'Signal', color: '#7c3aed' }, + VERIFIED_PORTFOLIO: { label: 'Verified', color: DS_BRAND.main }, + MAISON_WORK: { label: 'Maison Work', color: DS_ACCENT.cyan.deep }, + FUTURE_AVAILABILITY: { label: 'Signal', color: DS_ACCENT.violet.main }, } interface Props { @@ -15,7 +16,7 @@ interface Props { } export function MatchCardCompareMini({ vm, onRemove }: Props) { - const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' } + const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: DS_SLATE[500] } const topReason = vm.reasons[0] return ( diff --git a/src/components/match-card/MatchCardExpanded.tsx b/src/components/match-card/MatchCardExpanded.tsx index 79d6072..109cafe 100644 --- a/src/components/match-card/MatchCardExpanded.tsx +++ b/src/components/match-card/MatchCardExpanded.tsx @@ -8,6 +8,7 @@ import { MatchActionToolbar } from './MatchActionToolbar' import { MatchCardRestrictedState } from './MatchCardRestrictedState' import { ScoreInlineBreakdown } from './ScoreInlineBreakdown' import type { MatchCardViewModel } from './MatchCardViewModel' +import { DS_SLATE } from '../../lib/ds' interface Props { vm: MatchCardViewModel @@ -51,7 +52,7 @@ export function MatchCardExpanded({ vm }: Props) { {vm.explainabilitySummary} @@ -63,7 +64,7 @@ export function MatchCardExpanded({ vm }: Props) { {/* Score breakdown — full criteria with weights */} {vm.scoreBreakdown && ( - + Bewertungsherleitung diff --git a/src/components/match-card/MatchCardHeader.tsx b/src/components/match-card/MatchCardHeader.tsx index edadd40..af30927 100644 --- a/src/components/match-card/MatchCardHeader.tsx +++ b/src/components/match-card/MatchCardHeader.tsx @@ -3,7 +3,7 @@ import { Building2 } from 'lucide-react' import { MatchScoreDisplay } from './MatchScoreDisplay' import { HeatBadge } from '../shared' import { useSessionStore } from '../../stores/sessionStore' -import { RESULT_TYPE_META } from '../../lib/ds' +import { DS_BRAND, DS_SLATE, RESULT_TYPE_META } from '../../lib/ds' import { confidenceHex } from '../../lib/utils' import type { MatchCardViewModel } from './MatchCardViewModel' @@ -15,7 +15,7 @@ interface Props { export function MatchCardHeader({ vm, compact }: Props) { const { currentUser } = useSessionStore() const isPortfolioOwner = currentUser?.role === 'PROPERTY_MANAGER' || currentUser?.role === 'ORGANIZATION_ADMIN' - const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' } + const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: DS_SLATE[500] } const confPct = Math.round(vm.confidenceScore * 100) const topRisk = vm.risks.length > 0 ? vm.risks[0].level : undefined @@ -41,7 +41,7 @@ export function MatchCardHeader({ vm, compact }: Props) { icon={} label="Ihr Objekt" size="small" - sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: '#152642', fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }} + sx={{ bgcolor: 'rgba(30,58,95,0.12)', color: DS_BRAND.main, fontWeight: 700, fontSize: 11, '& .MuiChip-icon': { ml: 0.5 } }} /> )} {vm.assetType && ( diff --git a/src/components/match-card/MatchCardRestrictedState.tsx b/src/components/match-card/MatchCardRestrictedState.tsx index 91033f2..7cc9087 100644 --- a/src/components/match-card/MatchCardRestrictedState.tsx +++ b/src/components/match-card/MatchCardRestrictedState.tsx @@ -1,5 +1,6 @@ import { Box, Card, Typography } from '@mui/material' import { Lock } from 'lucide-react' +import { DS_SLATE } from '../../lib/ds' interface Props { title?: string @@ -8,7 +9,7 @@ interface Props { export function MatchCardRestrictedState({ title, message }: Props) { return ( - + diff --git a/src/components/match-card/MatchCardReview.tsx b/src/components/match-card/MatchCardReview.tsx index fe63ee4..3d3c06c 100644 --- a/src/components/match-card/MatchCardReview.tsx +++ b/src/components/match-card/MatchCardReview.tsx @@ -6,6 +6,7 @@ import { MatchDataQualitySummary } from './MatchDataQualitySummary' import { MatchActionToolbar } from './MatchActionToolbar' import type { MatchCardViewModel } from './MatchCardViewModel' import type { Risk } from '../../domain/match' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' function riskChipColor(level: Risk['level']): 'error' | 'warning' | 'success' { if (level === 'CRITICAL' || level === 'HIGH') return 'error' @@ -19,7 +20,7 @@ interface Props { export function MatchCardReview({ vm }: Props) { return ( - + {vm.disclaimer && ( {vm.disclaimer} @@ -54,7 +55,7 @@ export function MatchCardReview({ vm }: Props) { {/* Risks — prominent in review context */} {vm.risks.length > 0 && ( - + Risiken diff --git a/src/components/match-card/MatchDataQualitySummary.tsx b/src/components/match-card/MatchDataQualitySummary.tsx index efb44ac..11a35dc 100644 --- a/src/components/match-card/MatchDataQualitySummary.tsx +++ b/src/components/match-card/MatchDataQualitySummary.tsx @@ -1,5 +1,6 @@ import { Alert, Box, LinearProgress, Typography } from '@mui/material' import type { MissingDataItem } from '../../domain/match' +import { DS_SLATE } from '../../lib/ds' interface Props { dataQualityScore: number @@ -24,7 +25,7 @@ export function MatchDataQualitySummary({ dataQualityScore, missingData, compact return ( - + Datenqualität {hasCritical && ( diff --git a/src/components/match-card/MatchReasonList.tsx b/src/components/match-card/MatchReasonList.tsx index 7c6171b..b09a22a 100644 --- a/src/components/match-card/MatchReasonList.tsx +++ b/src/components/match-card/MatchReasonList.tsx @@ -1,6 +1,7 @@ import { Box, Typography } from '@mui/material' import { CheckCircle2 } from 'lucide-react' import type { MatchCardReason } from './MatchCardViewModel' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface Props { reasons: MatchCardReason[] @@ -13,7 +14,7 @@ export function MatchReasonList({ reasons, maxItems = 3 }: Props) { return ( - + Warum dieses Match @@ -21,7 +22,7 @@ export function MatchReasonList({ reasons, maxItems = 3 }: Props) { - + {r.label} {r.explanation && ( diff --git a/src/components/match-card/ScoreInlineBreakdown.tsx b/src/components/match-card/ScoreInlineBreakdown.tsx index f769b8b..bd0cace 100644 --- a/src/components/match-card/ScoreInlineBreakdown.tsx +++ b/src/components/match-card/ScoreInlineBreakdown.tsx @@ -1,6 +1,7 @@ import { Box, Divider, LinearProgress, Typography } from '@mui/material' import type { ScoreFactor } from '../../domain/match' import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' interface ScoreBreakdownData { hardMatchScore: number @@ -25,11 +26,11 @@ const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing']) function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } { const ratio = maxWeight > 0 ? weight / maxWeight : 0 - if (ratio >= 0.85) return { label: 'Entscheidend', color: '#152642' } - if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' } - if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' } - if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' } - return { label: 'Unwichtig', color: '#cbd5e1' } + if (ratio >= 0.85) return { label: 'Entscheidend', color: DS_BRAND.main } + if (ratio >= 0.65) return { label: 'Sehr wichtig', color: DS_ACCENT.blue.main } + if (ratio >= 0.40) return { label: 'Wichtig', color: DS_SLATE[600] } + if (ratio >= 0.20) return { label: 'Wenig wichtig',color: DS_SLATE[400] } + return { label: 'Unwichtig', color: DS_SLATE[300] } } function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) { @@ -42,15 +43,15 @@ function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: numb - {label} + {label} {imp.label} - {pct}% + {pct}% {factor.score}/100 - + {factor.contribution.toFixed(1)} Pkt @@ -68,11 +69,11 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact = const softContrib = Math.round(sb.softFactorScore * 0.40 * 10) / 10 const formulaBox = ( - - + + Berechnung - + Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40% {' = '}{hardContrib} + {softContrib}{' = '} {sb.totalScore} @@ -93,29 +94,29 @@ export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact = return ( - + Hart-Kriterien {hardFactors.map((f, i) => )} - 0 ? 2 : 1.5 }}> - + 0 ? 2 : 1.5 }}> + {sb.hardMatchScore}/100 × 60% - {hardContrib} Pkt + {hardContrib} Pkt {softFactors.length > 0 && ( <> - + Soft-Faktoren {softFactors.map((f, i) => )} - - + + {sb.softFactorScore}/100 × 40% - {softContrib} Pkt + {softContrib} Pkt )} diff --git a/src/components/match-card/SignalQualityDots.tsx b/src/components/match-card/SignalQualityDots.tsx index 9d42aea..d874c4d 100644 --- a/src/components/match-card/SignalQualityDots.tsx +++ b/src/components/match-card/SignalQualityDots.tsx @@ -1,16 +1,17 @@ import { Box, Typography } from '@mui/material' +import { DS_ACCENT, DS_BG } from '../../lib/ds' export function SignalQualityDots({ quality }: { quality: 'HIGH' | 'MEDIUM' | 'LOW' | undefined }) { const config = { - HIGH: { dots: [1, 1, 1, 1], color: '#1a7a4a', label: 'Hohe Signalqualität' }, - MEDIUM: { dots: [1, 1, 1, 0], color: '#d97706', label: 'Mittlere Signalqualität' }, - LOW: { dots: [1, 1, 0, 0], color: '#c0392b', label: 'Niedrige Signalqualität' }, + HIGH: { dots: [1, 1, 1, 1], color: DS_ACCENT.success.main, label: 'Hohe Signalqualität' }, + MEDIUM: { dots: [1, 1, 1, 0], color: DS_ACCENT.warning.main, label: 'Mittlere Signalqualität' }, + LOW: { dots: [1, 1, 0, 0], color: DS_ACCENT.danger.main, label: 'Niedrige Signalqualität' }, } const c = quality ? config[quality] : config.LOW return ( {c.dots.map((filled, i) => ( - + ))} {c.label} diff --git a/src/components/match-card/TradeoffList.tsx b/src/components/match-card/TradeoffList.tsx index 6dae321..06df783 100644 --- a/src/components/match-card/TradeoffList.tsx +++ b/src/components/match-card/TradeoffList.tsx @@ -1,6 +1,7 @@ import { Box, Typography } from '@mui/material' import { ArrowLeftRight } from 'lucide-react' import type { TradeOff } from '../../domain/match' +import { DS_SLATE } from '../../lib/ds' function severityColor(severity: TradeOff['severity']): string { if (severity === 'HIGH') return '#d97706' @@ -20,7 +21,7 @@ export function TradeoffList({ tradeoffs, maxItems = 3, compact }: Props) { return ( - + Abwägungen diff --git a/src/components/match-center/MatchListCard.tsx b/src/components/match-center/MatchListCard.tsx index 006c85e..53bc327 100644 --- a/src/components/match-center/MatchListCard.tsx +++ b/src/components/match-center/MatchListCard.tsx @@ -4,11 +4,12 @@ import type { Match } from '../../domain/match' import type { Property } from '../../domain/property' import type { Need } from '../../domain/need' import { matchScoreHex } from '../../lib/utils' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' const STRENGTH_META: Record = { - STRONG: { label: 'Stark', bg: '#f0fdf4', color: '#1a7a4a' }, - MODERATE: { label: 'Mittel', bg: '#fefce8', color: '#d97706' }, - WEAK: { label: 'Schwach', bg: '#fff1f2', color: '#c0392b' }, + STRONG: { label: 'Stark', bg: '#f0fdf4', color: DS_ACCENT.success.main }, + MODERATE: { label: 'Mittel', bg: '#fefce8', color: DS_ACCENT.warning.main }, + WEAK: { label: 'Schwach', bg: '#fff1f2', color: DS_ACCENT.danger.main }, } interface Props { @@ -20,7 +21,7 @@ interface Props { } export function MatchListCard({ match, property, need, onSelect, onApprove }: Props) { - const strength = STRENGTH_META[match.matchStrength] ?? { label: match.matchStrength, bg: '#f1f5f9', color: '#64748b' } + const strength = STRENGTH_META[match.matchStrength] ?? { label: match.matchStrength, bg: '#f1f5f9', color: DS_SLATE[500] } const summary = match.explainabilitySummary ?? '' return ( @@ -35,7 +36,7 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr borderBottom: '1px solid #e2e8f0', bgcolor: 'white', cursor: 'pointer', - '&:hover': { bgcolor: '#f8fafc' }, + '&:hover': { bgcolor: DS_SLATE[50] }, }} > {/* Score bubble */} @@ -73,8 +74,8 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr label={need.isAnonymous ? 'Anonyme Anfrage' : need.companyName} size="small" sx={need.isAnonymous - ? { bgcolor: '#f5f3ff', color: '#6d28d9', fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' } - : { bgcolor: '#eff6ff', color: '#152642', fontSize: 11, height: 20, fontWeight: 500 } + ? { bgcolor: DS_ACCENT.violet.bgAlt, color: DS_ACCENT.violet.strong, fontSize: 11, height: 20, fontWeight: 600, border: '1px solid #ddd6fe' } + : { bgcolor: DS_ACCENT.blue.bg, color: DS_BRAND.main, fontSize: 11, height: 20, fontWeight: 500 } } /> )} @@ -110,7 +111,7 @@ export function MatchListCard({ match, property, need, onSelect, onApprove }: Pr size="small" variant="contained" onClick={onApprove} - sx={{ textTransform: 'none', bgcolor: '#1a7a4a', '&:hover': { bgcolor: '#15643c' } }} + sx={{ textTransform: 'none', bgcolor: DS_ACCENT.success.main, '&:hover': { bgcolor: DS_ACCENT.success.darkAlt } }} > Genehmigen diff --git a/src/components/match-center/MatchStatusBadge.tsx b/src/components/match-center/MatchStatusBadge.tsx index 8fa1d09..3f9fec2 100644 --- a/src/components/match-center/MatchStatusBadge.tsx +++ b/src/components/match-center/MatchStatusBadge.tsx @@ -1,15 +1,16 @@ import { GenericBadge } from '../shared/GenericBadge' import type { MatchStatus } from '../../domain/enums' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' const STATUS_META: Record = { - PENDING_REVIEW: { label: 'Ausstehend', color: '#d97706' }, - APPROVED: { label: 'Genehmigt', color: '#1a7a4a' }, - REJECTED: { label: 'Abgelehnt', color: '#c0392b' }, - SHORTLISTED: { label: 'Shortlist', color: '#152642' }, + PENDING_REVIEW: { label: 'Ausstehend', color: DS_ACCENT.warning.main }, + APPROVED: { label: 'Genehmigt', color: DS_ACCENT.success.main }, + REJECTED: { label: 'Abgelehnt', color: DS_ACCENT.danger.main }, + SHORTLISTED: { label: 'Shortlist', color: DS_BRAND.main }, } export function MatchStatusBadge({ status }: { status?: MatchStatus }) { if (!status) return null - const { label, color } = STATUS_META[status] ?? { label: status, color: '#64748b' } + const { label, color } = STATUS_META[status] ?? { label: status, color: DS_SLATE[500] } return } diff --git a/src/components/match-center/NeedSelectionPanel.tsx b/src/components/match-center/NeedSelectionPanel.tsx index 96e3ef7..16ce968 100644 --- a/src/components/match-center/NeedSelectionPanel.tsx +++ b/src/components/match-center/NeedSelectionPanel.tsx @@ -4,6 +4,7 @@ import { useMatchCenterStore } from '../../stores/matchCenterStore' import { MatchCenterSkeleton } from './MatchCenterSkeleton' import type { Match } from '../../domain/match' import type { Need } from '../../domain/need' +import { DS_ACCENT } from '../../lib/ds' interface Props { matches: Match[] } @@ -37,12 +38,12 @@ export function NeedSelectionPanel({ matches }: Props) { {need.isAnonymous ? 'Anonyme Anfrage' : need.companyName} {need.isAnonymous && ( - + )} {pendingCount > 0 && ( + sx={{ bgcolor: DS_ACCENT.warning.main, color: 'white', fontSize: 10, height: 18, minWidth: 22 }} /> )} diff --git a/src/components/match-center/PropertySelectionPanel.tsx b/src/components/match-center/PropertySelectionPanel.tsx index 3bb13e7..cb35731 100644 --- a/src/components/match-center/PropertySelectionPanel.tsx +++ b/src/components/match-center/PropertySelectionPanel.tsx @@ -3,6 +3,7 @@ import { useProperties } from '../../hooks/useProperties' import { useMatchCenterStore } from '../../stores/matchCenterStore' import { MatchCenterSkeleton } from './MatchCenterSkeleton' import type { Match } from '../../domain/match' +import { DS_ACCENT } from '../../lib/ds' interface Props { matches: Match[] } @@ -36,7 +37,7 @@ export function PropertySelectionPanel({ matches }: Props) { {pendingCount > 0 && ( + sx={{ bgcolor: DS_ACCENT.warning.main, color: 'white', fontSize: 10, height: 18, minWidth: 22 }} /> )} diff --git a/src/components/match-detail/CriterionRow.tsx b/src/components/match-detail/CriterionRow.tsx index eecd4a2..bff5ae4 100644 --- a/src/components/match-detail/CriterionRow.tsx +++ b/src/components/match-detail/CriterionRow.tsx @@ -2,6 +2,7 @@ import { Box, LinearProgress, Typography } from '@mui/material' import type { ScoreFactor } from '../../domain/match' import { factorLabel, importanceLabel } from './scoreBreakdownConstants' import { criterionScoreColor, criterionScoreTextColor } from '../../lib/utils' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) { const label = factorLabel(factor.criterion) @@ -13,16 +14,16 @@ export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWe - {label} + {label} {imp.label} {factor.estimated && ( - + Schätzung )} - + {pct}% @@ -30,7 +31,7 @@ export function CriterionRow({ factor, maxWeight }: { factor: ScoreFactor; maxWe {factor.score}/100 - + {factor.contribution.toFixed(1)} Pkt diff --git a/src/components/match-detail/ExecutiveSummaryPanel.tsx b/src/components/match-detail/ExecutiveSummaryPanel.tsx index 8e85e71..3adbd39 100644 --- a/src/components/match-detail/ExecutiveSummaryPanel.tsx +++ b/src/components/match-detail/ExecutiveSummaryPanel.tsx @@ -1,6 +1,7 @@ import { Box, Paper, Typography } from '@mui/material' import { CheckCircle2, AlertTriangle, ArrowRight, Target } from 'lucide-react' import type { Match } from '../../domain/match' +import { DS_SLATE } from '../../lib/ds' interface Props { match: Match @@ -35,14 +36,14 @@ export function ExecutiveSummaryPanel({ match }: Props) { ] return ( - + Executive Summary {rows.map((row, i) => ( {row.icon} - + {row.label} diff --git a/src/components/match-detail/FloorPlanSection.tsx b/src/components/match-detail/FloorPlanSection.tsx index 499de04..96d1f65 100644 --- a/src/components/match-detail/FloorPlanSection.tsx +++ b/src/components/match-detail/FloorPlanSection.tsx @@ -1,6 +1,6 @@ import { Box, Paper, Typography } from '@mui/material' import { FileImage } from 'lucide-react' -import { DS_TEXT } from '../../lib/ds' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' interface FloorPlan { url: string @@ -40,7 +40,7 @@ export function FloorPlanSection({ plans, mb }: Props) { display: 'block', objectFit: 'contain', maxHeight: 480, - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], }} /> diff --git a/src/components/match-detail/InquiryQuickDialog.tsx b/src/components/match-detail/InquiryQuickDialog.tsx deleted file mode 100644 index b584021..0000000 --- a/src/components/match-detail/InquiryQuickDialog.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useState, useEffect } from 'react' -import { - Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, - TextField, Typography, -} from '@mui/material' -import { Send } from 'lucide-react' -import { useToastStore } from '../../stores/toastStore' -import { useInquiryStore } from '../../stores/inquiryStore' -import { useSessionStore } from '../../stores/sessionStore' -import { useMoveStage } from '../../hooks/usePipeline' -import { useCreateInquiry } from '../../hooks/useInquiries' -import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' - -function buildTemplate(propertyTitle: string, location: string, areaLabel?: string): string { - const areaLine = areaLabel ? `\nDie Fläche von ${areaLabel} entspricht unserem Bedarf.` : '' - return `Guten Tag\n\nWir interessieren uns für Ihre Fläche «${propertyTitle}» in ${location}.${areaLine} Gerne würden wir einen Besichtigungstermin vereinbaren und offene Fragen klären.\n\nFür Rückfragen stehen wir jederzeit zur Verfügung.\n\nFreundliche Grüsse` -} - -export function InquiryQuickDialog() { - const showToast = useToastStore(s => s.showToast) - const dialogOpen = useInquiryStore(s => s.dialogOpen) - const pendingInquiry = useInquiryStore(s => s.pendingInquiry) - const closeInquiryDialog = useInquiryStore(s => s.closeInquiryDialog) - const currentUser = useSessionStore(s => s.currentUser) - const createInquiry = useCreateInquiry() - const { mutate: moveStage } = useMoveStage() - - const [message, setMessage] = useState('') - - useEffect(() => { - if (dialogOpen && pendingInquiry) { - setMessage(buildTemplate(pendingInquiry.propertyTitle, pendingInquiry.location, pendingInquiry.areaLabel)) - } - }, [dialogOpen, pendingInquiry]) - - function handleSend() { - if (!pendingInquiry) return - createInquiry.mutate({ - organizationId: 'org-wincasa', // Supply-Org des Objekts (Mock: Wincasa) - tenantOrgId: currentUser?.organizationId ?? 'org-mobimo', - propertyId: pendingInquiry.propertyId ?? 'unknown', - tenantName: currentUser?.name ?? 'Demand User', - tenantCompany: currentUser?.organizationName, - tenantEmail: currentUser?.email, - propertyAddress: pendingInquiry.propertyTitle, - subject: `Anfrage: ${pendingInquiry.propertyTitle}`, - message, - matchScore: pendingInquiry.matchScore, - }) - if (pendingInquiry.pipelineItemId) { - moveStage({ id: pendingInquiry.pipelineItemId, stage: 'CONTACTED' }) - } - showToast('Anfrage gesendet — Sie erhalten eine Antwort per E-Mail.', 'success') - closeInquiryDialog() - } - - if (!pendingInquiry) return null - - return ( - - - - Anfrage senden - - - - - {pendingInquiry.propertyTitle} - - {pendingInquiry.location} - {pendingInquiry.areaLabel && ` · ${pendingInquiry.areaLabel}`} - {pendingInquiry.rentLabel && ` · ${pendingInquiry.rentLabel}`} - {' · '}Match {pendingInquiry.matchScore}% - - - - setMessage(e.target.value)} - sx={{ '& .MuiInputBase-root': { fontSize: '0.875rem' } }} - /> - - - Der Text kann vor dem Absenden angepasst werden. - - - - - - - - - ) -} diff --git a/src/components/match-detail/KpiTile.tsx b/src/components/match-detail/KpiTile.tsx index f86db3d..d16a86f 100644 --- a/src/components/match-detail/KpiTile.tsx +++ b/src/components/match-detail/KpiTile.tsx @@ -1,4 +1,5 @@ import { Box, Typography } from '@mui/material' +import { DS_SLATE } from '../../lib/ds' export function KpiTile({ label, @@ -16,7 +17,7 @@ export function KpiTile({ sx={{ flex: 1, p: 1.5, - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], borderRadius: 1.5, border: '1px solid #e2e8f0', minWidth: 100, diff --git a/src/components/match-detail/LocationIntelligencePanel.tsx b/src/components/match-detail/LocationIntelligencePanel.tsx index aaab91e..cec9a23 100644 --- a/src/components/match-detail/LocationIntelligencePanel.tsx +++ b/src/components/match-detail/LocationIntelligencePanel.tsx @@ -7,7 +7,7 @@ import { useNavigate } from 'react-router' import { useProperties } from '../../hooks/useProperties' import { getCityIntelligence } from '../../lib/locationIntelligence' import type { Property } from '../../domain/property' -import { DS_TEXT, DS_SURFACE, DS_BORDER } from '../../lib/ds' +import { DS_ACCENT, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { SoftFactorBar } from './SoftFactorBar' import { KpiTile } from './KpiTile' import { NEW_PROJECTS } from './locationIntelligenceConstants' @@ -139,7 +139,7 @@ export function LocationIntelligencePanel({ property }: Props) { {intel.dominantIndustryClusters.map(c => ( - + ))} @@ -155,7 +155,7 @@ export function LocationIntelligencePanel({ property }: Props) { {intel.plannedInfrastructure.map((proj, i) => ( - + {proj.timeline} @@ -237,7 +237,7 @@ export function LocationIntelligencePanel({ property }: Props) { > - + {p.title} @@ -255,7 +255,7 @@ export function LocationIntelligencePanel({ property }: Props) { {newProjects.length > 0 && ( <> - + Neubauprojekte als Alternative {newProjects.map((proj, i) => ( diff --git a/src/components/match-detail/MatchDetailHeader.tsx b/src/components/match-detail/MatchDetailHeader.tsx index 656bfa6..76be6b6 100644 --- a/src/components/match-detail/MatchDetailHeader.tsx +++ b/src/components/match-detail/MatchDetailHeader.tsx @@ -4,11 +4,12 @@ import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay' import type { Match } from '../../domain/match' import type { Property } from '../../domain/property' import type { FutureSignal } from '../../domain/futureSignal' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' const RESULT_TYPE_META: Record = { - VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: '#152642' }, - MAISON_WORK: { label: 'Maison Work', color: '#0369a1' }, - FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, + VERIFIED_PORTFOLIO: { label: 'Verified Portfolio', color: DS_BRAND.main }, + MAISON_WORK: { label: 'Maison Work', color: DS_ACCENT.cyan.deep }, + FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: DS_ACCENT.violet.main }, } function confColor(c: number): string { @@ -29,7 +30,7 @@ interface Props { } export function MatchDetailHeader({ match, property, signal, onBack, onCompare, onShortlist }: Props) { - const rt = RESULT_TYPE_META[match.resultType ?? 'VERIFIED_PORTFOLIO'] ?? { label: '–', color: '#64748b' } + const rt = RESULT_TYPE_META[match.resultType ?? 'VERIFIED_PORTFOLIO'] ?? { label: '–', color: DS_SLATE[500] } const title = property?.title ?? signal?.companyName ?? signal?.locationHint ?? '–' const isFuture = match.resultType === 'FUTURE_AVAILABILITY' const dqScore = property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5 @@ -53,7 +54,7 @@ export function MatchDetailHeader({ match, property, signal, onBack, onCompare, startIcon={} onClick={onBack} size="small" - sx={{ mb: 1.5, color: '#64748b' }} + sx={{ mb: 1.5, color: DS_SLATE[500] }} > Zurück zu Resultaten @@ -101,7 +102,7 @@ export function MatchDetailHeader({ match, property, signal, onBack, onCompare, size="small" startIcon={} onClick={onCompare} - sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }} + sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }} > Vergleichen diff --git a/src/components/match-detail/MatchDetailHero.tsx b/src/components/match-detail/MatchDetailHero.tsx index 67698e7..7e9edfe 100644 --- a/src/components/match-detail/MatchDetailHero.tsx +++ b/src/components/match-detail/MatchDetailHero.tsx @@ -5,6 +5,7 @@ import { MatchScoreDisplay } from '../match-card/MatchScoreDisplay' import { useMatchDetail } from '../../hooks/useMatches' import type { Property } from '../../domain/property' import type { FutureSignal } from '../../domain/futureSignal' +import { DS_BG, DS_BRAND } from '../../lib/ds' type Match = NonNullable['data']> @@ -46,7 +47,7 @@ export function MatchDetailHero({ {/* Hero: image first, map fallback */} {!isFuture && ( property?.images?.[0] ? ( - + {property.title}} onClick={onCompare} - sx={{ bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, textTransform: 'none' }} + sx={{ bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt }, textTransform: 'none' }} > Vergleichen diff --git a/src/components/match-detail/MatchDetailPropertyDetails.tsx b/src/components/match-detail/MatchDetailPropertyDetails.tsx index 1f70dbf..dfc3685 100644 --- a/src/components/match-detail/MatchDetailPropertyDetails.tsx +++ b/src/components/match-detail/MatchDetailPropertyDetails.tsx @@ -1,25 +1,13 @@ import { Box, Chip, Typography } from '@mui/material' import { ShieldCheck } from 'lucide-react' import type { PropertyUnit } from '../../domain/property' +import { floorLabelFromLevel } from '../../lib/propertyLabels' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' -// ── Property detail helpers ──────────────────────────────────────────────────── - -export const FLOOR_LABEL = (level: number) => - level === 0 ? 'EG' : level < 0 ? `UG ${Math.abs(level)}` : `${level}.OG` - -export const ASSET_LABELS: Record = { - OFFICE: 'Büro', LOGISTICS: 'Lager / Logistik', RETAIL: 'Retail / Laden', - PRODUCTION: 'Produktion', MIXED: 'Gewerbe (gemischt)', -} -export const RISK_LABELS: Record = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch' } -export const SOURCE_LABELS: Record = { - ERP_IMPORT: 'ERP-Import (intern)', IMMOSCOUT_SCRAPE: 'ImmoScout24', - HOMEGATE_SCRAPE: 'Homegate', MATCHOFFICE_SCRAPE: 'MatchOffice', - NEWHOME_SCRAPE: 'newhome.ch', AI_SIGNAL: 'KI-Signal', MANUAL: 'Manuell erfasst', -} -export const PASSERBY_LABELS: Record = { - LOW: 'Gering', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch', -} +// Die Beschriftungstabellen dieser Datei sind nach `lib/constants.ts` gewandert +// (sie standen mehrfach in der Codebasis), `FLOOR_LABEL` nach +// `lib/propertyLabels.ts`. Diese Datei enthält jetzt nur noch Komponenten — +// erst dadurch behält Fast Refresh beim Bearbeiten den Zustand. export function KeyFactRow({ label, value }: { label: string; value?: string | null }) { if (!value) return null @@ -33,12 +21,12 @@ export function KeyFactRow({ label, value }: { label: string; value?: string | n export function UnitStatusChip({ unit }: { unit: PropertyUnit }) { if (unit.schattenmarktRelease?.enabled) { - return } label="PRE-MARKET" sx={{ bgcolor: '#f0fdf4', color: '#1a7a4a', border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} /> + return } label="PRE-MARKET" sx={{ bgcolor: DS_ACCENT.success.bg, color: DS_ACCENT.success.main, border: '1px solid #86efac', fontWeight: 700, fontSize: '0.68rem', height: 20 }} /> } if (unit.available) { - return + return } - return + return } export function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted: boolean }) { @@ -53,7 +41,7 @@ export function UnitRow({ unit, highlighted }: { unit: PropertyUnit; highlighted }}> - {FLOOR_LABEL(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''} + {floorLabelFromLevel(unit.floorLevel)}{unit.unitLabel ? ` · ${unit.unitLabel}` : ''} {unit.currentTenant && {unit.currentTenant}} diff --git a/src/components/match-detail/MatchDetailPropertySections.tsx b/src/components/match-detail/MatchDetailPropertySections.tsx index 8311567..5d15226 100644 --- a/src/components/match-detail/MatchDetailPropertySections.tsx +++ b/src/components/match-detail/MatchDetailPropertySections.tsx @@ -3,7 +3,9 @@ import { Building2, Clock, ExternalLink, Info, Layers, Tag, Train, TrendingUp } import { useMatchDetail } from '../../hooks/useMatches' import { ResultType } from '../../domain/enums' import type { Property } from '../../domain/property' -import { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' +import { KeyFactRow, UnitRow } from './MatchDetailPropertyDetails' +import { floorLabelFromLevel } from '../../lib/propertyLabels' +import { ASSET_TYPE_LABELS, PASSERBY_LABELS, RISK_LABELS, SOURCE_TYPE_LABELS } from '../../lib/constants' import { FloorPlanSection } from './FloorPlanSection' import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds' @@ -26,7 +28,7 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp ? `CHF ${rawMonthlyPerSqm}.–` : `CHF ${rawMonthlyPerSqm.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` const minLettable = property.areaSqmMin ?? (flexibleUnits.length > 0 ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) : undefined) - const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_LABELS[property.sourceType] ?? property.sourceType + const sourceLabel = property.sourceLabel ?? property.sourceMeta?.sourceLabel ?? SOURCE_TYPE_LABELS[property.sourceType] ?? property.sourceType return ( <> @@ -51,12 +53,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp Hauptangaben - + {minLettable != null && } {property.contractDurationMonths != null && } {(property.floorLevel != null || matchedUnit) && ( - + )} {property.currentTenant && } {property.leaseEndDate && } @@ -145,12 +147,12 @@ export function MatchDetailPropertySections({ property, match }: MatchDetailProp const plans: Array<{ url: string; label?: string }> = [] if (matchedUnit?.floorPlanUrl) { const lbl = matchedUnit.unitLabel - ? `${FLOOR_LABEL(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}` - : FLOOR_LABEL(matchedUnit.floorLevel) + ? `${floorLabelFromLevel(matchedUnit.floorLevel)} ${matchedUnit.unitLabel}` + : floorLabelFromLevel(matchedUnit.floorLevel) plans.push({ url: matchedUnit.floorPlanUrl, label: units.length > 1 ? lbl : undefined }) } else { units.filter(u => u.floorPlanUrl).forEach(u => { - const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel) + const lbl = u.unitLabel ? `${floorLabelFromLevel(u.floorLevel)} ${u.unitLabel}` : floorLabelFromLevel(u.floorLevel) plans.push({ url: u.floorPlanUrl!, label: plans.length > 0 || units.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined }) }) } diff --git a/src/components/match-detail/MissingInformationPanel.tsx b/src/components/match-detail/MissingInformationPanel.tsx index e8f2a3d..9ff252f 100644 --- a/src/components/match-detail/MissingInformationPanel.tsx +++ b/src/components/match-detail/MissingInformationPanel.tsx @@ -1,6 +1,7 @@ import { Box, Button, Chip, Paper, Typography } from '@mui/material' import { FileQuestion } from 'lucide-react' import type { Match, MissingDataItem } from '../../domain/match' +import { DS_SLATE } from '../../lib/ds' const IMPORTANCE_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] const IMPORTANCE_META: Record = { @@ -34,10 +35,10 @@ function MissingItemRow({ item }: MissingItemRowProps) { - - diff --git a/src/components/match-detail/NeedAlignmentPanel.tsx b/src/components/match-detail/NeedAlignmentPanel.tsx index 807b38a..051594b 100644 --- a/src/components/match-detail/NeedAlignmentPanel.tsx +++ b/src/components/match-detail/NeedAlignmentPanel.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, XCircle, HelpCircle, Minus } from 'lucide-react' import type { Match, MustHaveResult } from '../../domain/match' import type { Property } from '../../domain/property' import type { Need } from '../../domain/need' +import { DS_SLATE } from '../../lib/ds' type FitStatus = 'MATCH' | 'NO_MATCH' | 'PARTIAL' | 'UNKNOWN' @@ -130,7 +131,7 @@ export function NeedAlignmentPanel({ match, need, property }: Props) { {/* Comparison table */} - + Kriterium Gesucht Objekt @@ -163,7 +164,7 @@ export function NeedAlignmentPanel({ match, need, property }: Props) { {/* Evaluated must-haves */} {showEvaluated && ( - + Must-have Kriterien @@ -176,7 +177,7 @@ export function NeedAlignmentPanel({ match, need, property }: Props) { }}> {mustHaveIcon(r)} {r.criterion} - + {r.confidence === 'UNKNOWN' ? 'Nicht prüfbar' : r.passed ? 'Erfüllt' : 'Nicht erfüllt'} @@ -189,12 +190,12 @@ export function NeedAlignmentPanel({ match, need, property }: Props) { {/* Fallback: unevaluated must-haves (no criteria text to evaluate) */} {showFallback && ( - + Must-have Kriterien {rawTexts.map((t, i) => ( - + {t} diff --git a/src/components/match-detail/NextActionsPanel.tsx b/src/components/match-detail/NextActionsPanel.tsx index 03ee604..b7f6c31 100644 --- a/src/components/match-detail/NextActionsPanel.tsx +++ b/src/components/match-detail/NextActionsPanel.tsx @@ -1,6 +1,7 @@ import { Box, Button, Paper, Typography } from '@mui/material' import { MessageSquare } from 'lucide-react' import type { Match, NextBestAction } from '../../domain/match' +import { DS_BRAND } from '../../lib/ds' const PRIORITY_ORDER: Record = { HIGH: 0, MEDIUM: 1, LOW: 2 } @@ -28,7 +29,7 @@ export function NextActionsPanel({ match, onCompare, onPipeline, onInquire }: Pr variant="contained" startIcon={} onClick={onInquire} - sx={{ justifyContent: 'flex-start', bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' } }} + sx={{ justifyContent: 'flex-start', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }} > Anfrage senden diff --git a/src/components/match-detail/PropertyDetailPublicSections.tsx b/src/components/match-detail/PropertyDetailPublicSections.tsx deleted file mode 100644 index 6fc4804..0000000 --- a/src/components/match-detail/PropertyDetailPublicSections.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import { - Box, - Button, - Chip, - Paper, - Typography, -} from '@mui/material' -import { - Building2, - Clock, - ExternalLink, - Info, - Layers, - Tag, - Train, - TrendingUp, -} from 'lucide-react' -import { ResultType } from '../../domain/enums' -import type { Property } from '../../domain/property' -import { - ASSET_LABELS, - FLOOR_LABEL, - KeyFactRow, - PASSERBY_LABELS, - RISK_LABELS, - SOURCE_LABELS, - UnitRow, -} from './MatchDetailPropertyDetails' -import { FloorPlanSection } from './FloorPlanSection' -import { DS_TEXT, DS_BG, DS_SURFACE, DS_BORDER, DS_MARKET_SIGNAL } from '../../lib/ds' - -interface PropertyDetailPublicSectionsProps { - property: Property - highlightUnitId?: string | null -} - -export function PropertyDetailPublicSections({ property, highlightUnitId }: PropertyDetailPublicSectionsProps) { - const preMarketUnits = (property.units ?? []).filter(u => u.schattenmarktRelease?.enabled) - const otherUnits = (property.units ?? []).filter(u => !u.schattenmarktRelease?.enabled) - const flexibleUnits = (property.units ?? []).filter(u => u.isFlexible && u.minLettableSqm !== undefined) - - const totalMonthly = Math.round(property.areaSqm * property.rentPricePerSqm / 12) - const rawMonthlyPerSqm = property.rentPricePerSqm / 12 - const monthlyPerSqmLabel = Number.isInteger(rawMonthlyPerSqm) - ? `CHF ${rawMonthlyPerSqm}.–` - : `CHF ${rawMonthlyPerSqm.toLocaleString('de-CH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` - const minLettable = property.areaSqmMin - ?? (flexibleUnits.length > 0 - ? Math.min(...flexibleUnits.map(u => u.minLettableSqm!)) - : undefined) - - const sourceLabel = property.sourceLabel - ?? property.sourceMeta?.sourceLabel - ?? SOURCE_LABELS[property.sourceType] - ?? property.sourceType - - return ( - <> - {/* ── Preis ── */} - - - - Preis - - - - - {property.ancillaryCosts != null && ( - - )} - - - {/* ── Hauptangaben ── */} - - - - Hauptangaben - - - - - {minLettable != null && ( - - )} - {property.contractDurationMonths != null && ( - - )} - {property.floorLevel != null && ( - - )} - {property.currentTenant && ( - - )} - {property.leaseEndDate && ( - - )} - {property.breakoutOption && ( - - )} - {property.riskLevel && ( - - )} - {property.expansionPotentialSqm != null && ( - - )} - - - {/* ── Eigenschaften ── */} - {property.softFactors && ( - - - - Eigenschaften - - - {property.softFactors.publicTransportMinutes != null && ( - } - label={`ÖV: ${property.softFactors.publicTransportMinutes} Min. zu Fuss`} - sx={{ bgcolor: DS_SURFACE.blue.bg, color: DS_MARKET_SIGNAL.accent, border: `1px solid ${DS_SURFACE.blue.border}`, fontWeight: 500 }} - /> - )} - {property.softFactors.parkingSpots != null && property.softFactors.parkingSpots > 0 && ( - - )} - {property.softFactors.prestige != null && property.softFactors.prestige >= 80 && ( - - )} - {property.softFactors.visibilityScore != null && property.softFactors.visibilityScore >= 80 && ( - - )} - {property.softFactors.passerbyFrequency && ( - - )} - {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( - - )} - {property.softFactors.talentAccess != null && property.softFactors.talentAccess >= 80 && ( - - )} - - - )} - - {/* ── Wegzeit ── */} - {property.softFactors?.publicTransportMinutes != null && ( - - - - Wegzeit - - - - - - - - {property.softFactors.publicTransportMinutes} Min. zu Fuss - - - Nächster ÖV-Anschluss — {property.location.city} - - - - {property.softFactors.infrastructureNotes && ( - - {property.softFactors.infrastructureNotes} - - )} - - Die Zeiten beziehen sich auf die Strecke zu Fuss. - - - )} - - {/* ── Einheiten ── */} - {(property.units ?? []).length > 0 && ( - - - - Einheiten - - - {['Einheit', 'Fläche', 'Miete', 'Verfügbar ab', 'Status'].map(h => ( - {h} - ))} - - {preMarketUnits.map(u => ( - - ))} - {otherUnits.map(u => ( - - ))} - - )} - - {/* ── Grundriss ── */} - {(() => { - const allUnits = property.units ?? [] - const plans: Array<{ url: string; label?: string }> = [] - allUnits.filter(u => u.floorPlanUrl).forEach(u => { - const lbl = u.unitLabel ? `${FLOOR_LABEL(u.floorLevel)} ${u.unitLabel}` : FLOOR_LABEL(u.floorLevel) - plans.push({ url: u.floorPlanUrl!, label: allUnits.filter(x => x.floorPlanUrl).length > 1 ? lbl : undefined }) - }) - if (plans.length === 0 && property.floorPlanUrl) plans.push({ url: property.floorPlanUrl }) - return - })()} - - {/* ── Beschreibung ── */} - {property.description && ( - - - - Beschreibung - - - {property.description} - - - )} - - {/* ── Quelle & Referenz ── */} - - - - Quelle & Referenz - - - {property.propertyNumber && ( - - )} - {property.importedFrom && ( - - )} - {property.dataQuality.lastVerifiedAt && ( - - )} - {property.sourceUrl && property.resultType === ResultType.MAISON_WORK && ( - - - - )} - - - ) -} diff --git a/src/components/match-detail/PropertyOverviewPanel.tsx b/src/components/match-detail/PropertyOverviewPanel.tsx index 1440831..29f5369 100644 --- a/src/components/match-detail/PropertyOverviewPanel.tsx +++ b/src/components/match-detail/PropertyOverviewPanel.tsx @@ -3,6 +3,7 @@ import { Banknote, Calendar, HardHat, MapPin, Maximize2, Tag } from 'lucide-reac import type { Match } from '../../domain/match' import type { Property } from '../../domain/property' import type { FutureSignal } from '../../domain/futureSignal' +import { DS_SLATE } from '../../lib/ds' interface FactRowProps { icon: React.ReactNode @@ -13,7 +14,7 @@ interface FactRowProps { function FactRow({ icon, label, value }: FactRowProps) { return ( - {icon} + {icon} {label} {value} diff --git a/src/components/match-detail/RiskPanel.tsx b/src/components/match-detail/RiskPanel.tsx index 3f6cb73..eaa98a2 100644 --- a/src/components/match-detail/RiskPanel.tsx +++ b/src/components/match-detail/RiskPanel.tsx @@ -2,13 +2,14 @@ import { Box, Chip, Paper, Typography } from '@mui/material' import { ShieldAlert } from 'lucide-react' import type { Match } from '../../domain/match' import type { Risk } from '../../domain/match' +import { DS_ACCENT } from '../../lib/ds' const LEVEL_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] const LEVEL_META: Record = { - CRITICAL: { label: 'Kritisch', color: 'error', bgcolor: '#fef2f2', border: '#fca5a5' }, - HIGH: { label: 'Hoch', color: 'error', bgcolor: '#fff7ed', border: '#fed7aa' }, - MEDIUM: { label: 'Mittel', color: 'warning', bgcolor: '#fffbeb', border: '#fde68a' }, - LOW: { label: 'Gering', color: 'success', bgcolor: '#f0fdf4', border: '#bbf7d0' }, + CRITICAL: { label: 'Kritisch', color: 'error', bgcolor: DS_ACCENT.danger.bg, border: '#fca5a5' }, + HIGH: { label: 'Hoch', color: 'error', bgcolor: DS_ACCENT.warning.bgAlt, border: '#fed7aa' }, + MEDIUM: { label: 'Mittel', color: 'warning', bgcolor: DS_ACCENT.warning.bg, border: '#fde68a' }, + LOW: { label: 'Gering', color: 'success', bgcolor: DS_ACCENT.success.bg, border: '#bbf7d0' }, } interface Props { diff --git a/src/components/match-detail/SignalSourcesSection.tsx b/src/components/match-detail/SignalSourcesSection.tsx index 4905520..2d4b78a 100644 --- a/src/components/match-detail/SignalSourcesSection.tsx +++ b/src/components/match-detail/SignalSourcesSection.tsx @@ -2,6 +2,7 @@ import { Box, Button, Typography } from '@mui/material' import { Calendar, Clock, ExternalLink, Globe, ShieldCheck } from 'lucide-react' import type { FutureSignal } from '../../domain/futureSignal' import { domainLabel } from './futureAvailabilityContextUtils' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface SignalSourcesSectionProps { sourceMeta: { label: string; icon: React.ReactNode; badge?: string; description?: string } | null @@ -34,13 +35,13 @@ export function SignalSourcesSection({ )} {credMeta && ( - + {credMeta.label} )} {signal.source.publishedAt && ( - + {new Date(signal.source.publishedAt).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' })} @@ -51,14 +52,14 @@ export function SignalSourcesSection({ {/* Verified contract special box */} {isVerifiedContract && ( - + - Vertragsende aus internem ERP bestätigt + Vertragsende aus internem ERP bestätigt - Verwaltung hat Marktfreigabe erteilt + Verwaltung hat Marktfreigabe erteilt )} @@ -78,8 +79,8 @@ export function SignalSourcesSection({ startIcon={} sx={{ textTransform: 'none', justifyContent: 'flex-start', - color: '#0369a1', borderColor: '#bae6fd', - '&:hover': { borderColor: '#0369a1', bgcolor: '#f0f9ff' }, + color: DS_ACCENT.cyan.deep, borderColor: DS_ACCENT.cyan.border, + '&:hover': { borderColor: DS_ACCENT.cyan.deep, bgcolor: DS_ACCENT.blue.bgAlt }, fontSize: '0.78rem', fontWeight: 500, }} > @@ -88,7 +89,7 @@ export function SignalSourcesSection({ ))} ) : ( - + Kein direkter Quellenlink verfügbar — Signal basiert auf aggregierten {sourceMeta?.label ?? 'Marktdaten'}. @@ -107,8 +108,8 @@ export function SignalSourcesSection({ {/* Evidence summary */} {signal.evidence?.summary && ( - - Evidenz-Zusammenfassung + + Evidenz-Zusammenfassung {signal.evidence.summary} )} diff --git a/src/components/match-detail/SoftFactorBar.tsx b/src/components/match-detail/SoftFactorBar.tsx index 0312661..b94ca29 100644 --- a/src/components/match-detail/SoftFactorBar.tsx +++ b/src/components/match-detail/SoftFactorBar.tsx @@ -1,4 +1,5 @@ import { Box, Chip, LinearProgress, Tooltip, Typography } from '@mui/material' +import { DS_SLATE } from '../../lib/ds' function scoreColor(v: number) { if (v >= 0.72) return '#1a7a4a' @@ -30,7 +31,7 @@ export function SoftFactorBar({ - {icon} + {icon} {label} ) diff --git a/src/components/match-detail/SourceProvenancePanel.tsx b/src/components/match-detail/SourceProvenancePanel.tsx index aa0380d..d2014c6 100644 --- a/src/components/match-detail/SourceProvenancePanel.tsx +++ b/src/components/match-detail/SourceProvenancePanel.tsx @@ -1,5 +1,6 @@ import { Box, Chip, Link, Paper, Typography } from '@mui/material' import type { Property } from '../../domain/property' +import { DS_ACCENT } from '../../lib/ds' const FRESHNESS_META: Record = { FRESH: { label: 'Aktuell (< 48h)', color: 'success' }, @@ -58,7 +59,7 @@ export function SourceProvenancePanel({ property }: Props) { ))} {warnings.length > 0 && ( - + {warnings.map((w, i) => ( ⚠ {w} diff --git a/src/components/match-detail/TradeoffPanel.tsx b/src/components/match-detail/TradeoffPanel.tsx index a69d23f..479be83 100644 --- a/src/components/match-detail/TradeoffPanel.tsx +++ b/src/components/match-detail/TradeoffPanel.tsx @@ -1,6 +1,7 @@ import { Box, Chip, Paper, Typography } from '@mui/material' import { ArrowLeftRight } from 'lucide-react' import type { Match } from '../../domain/match' +import { DS_ACCENT } from '../../lib/ds' const SEVERITY_META: Record = { HIGH: { label: 'Hoch', color: 'error' }, @@ -25,7 +26,7 @@ export function TradeoffPanel({ match }: Props) { return ( @@ -43,7 +44,7 @@ export function TradeoffPanel({ match }: Props) { )} {t.impactOnScore !== undefined && ( - + Score-Einfluss: {t.impactOnScore} Punkte )} diff --git a/src/components/match-detail/futureAvailabilityContextUtils.ts b/src/components/match-detail/futureAvailabilityContextUtils.ts index bcce5bf..1b15fde 100644 --- a/src/components/match-detail/futureAvailabilityContextUtils.ts +++ b/src/components/match-detail/futureAvailabilityContextUtils.ts @@ -1,7 +1,8 @@ +import { DS_ACCENT } from '../../lib/ds' export const CREDIBILITY_META: Record = { - HIGH: { label: 'Hohe Quellenqualität', color: '#1a7a4a' }, - MEDIUM: { label: 'Mittlere Quellenqualität', color: '#d97706' }, - LOW: { label: 'Niedrige Quellenqualität', color: '#c0392b' }, + HIGH: { label: 'Hohe Quellenqualität', color: DS_ACCENT.success.main }, + MEDIUM: { label: 'Mittlere Quellenqualität', color: DS_ACCENT.warning.main }, + LOW: { label: 'Niedrige Quellenqualität', color: DS_ACCENT.danger.main }, } export const SENSITIVITY_META: Record = { @@ -11,10 +12,10 @@ export const SENSITIVITY_META: Record = { - LOW: { label: 'Niedrig', color: '#1a7a4a' }, - MEDIUM: { label: 'Mittel', color: '#d97706' }, - HIGH: { label: 'Hoch', color: '#c0392b' }, - CRITICAL: { label: 'Kritisch', color: '#7f1d1d' }, + LOW: { label: 'Niedrig', color: DS_ACCENT.success.main }, + MEDIUM: { label: 'Mittel', color: DS_ACCENT.warning.main }, + HIGH: { label: 'Hoch', color: DS_ACCENT.danger.main }, + CRITICAL: { label: 'Kritisch', color: DS_ACCENT.danger.darkest }, } export function ageLabel(isoDate: string): string { diff --git a/src/components/match-detail/index.ts b/src/components/match-detail/index.ts index 297994a..019893c 100644 --- a/src/components/match-detail/index.ts +++ b/src/components/match-detail/index.ts @@ -15,4 +15,4 @@ export { NextActionsPanel } from './NextActionsPanel' export { FitOutCostPanel } from './FitOutCostPanel' export { FitOutAdvicePanel } from './FitOutAdvicePanel' export { MarketPricePanel } from './MarketPricePanel' -export { FLOOR_LABEL, ASSET_LABELS, RISK_LABELS, SOURCE_LABELS, PASSERBY_LABELS, KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' +export { KeyFactRow, UnitStatusChip, UnitRow } from './MatchDetailPropertyDetails' diff --git a/src/components/match-detail/scoreBreakdownConstants.ts b/src/components/match-detail/scoreBreakdownConstants.ts index c6b77e7..e97016e 100644 --- a/src/components/match-detail/scoreBreakdownConstants.ts +++ b/src/components/match-detail/scoreBreakdownConstants.ts @@ -1,3 +1,4 @@ +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' export const CREDIBILITY_LABELS: Record = { HIGH: 'Hohe Quellenqualität', MEDIUM: 'Mittlere Quellenqualität', @@ -20,9 +21,9 @@ export function factorLabel(criterion: string): string { // Convert normalised weight to 1-5 importance level relative to other factors in the same set export function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } { const ratio = maxWeight > 0 ? weight / maxWeight : 0 - if (ratio >= 0.85) return { label: 'Entscheidend', color: '#152642' } - if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' } - if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' } - if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' } - return { label: 'Unwichtig', color: '#cbd5e1' } + if (ratio >= 0.85) return { label: 'Entscheidend', color: DS_BRAND.main } + if (ratio >= 0.65) return { label: 'Sehr wichtig', color: DS_ACCENT.blue.main } + if (ratio >= 0.40) return { label: 'Wichtig', color: DS_SLATE[600] } + if (ratio >= 0.20) return { label: 'Wenig wichtig',color: DS_SLATE[400] } + return { label: 'Unwichtig', color: DS_SLATE[300] } } diff --git a/src/components/new-listing/AreaDetailsSection.tsx b/src/components/new-listing/AreaDetailsSection.tsx index 1ea4058..a9e345f 100644 --- a/src/components/new-listing/AreaDetailsSection.tsx +++ b/src/components/new-listing/AreaDetailsSection.tsx @@ -1,5 +1,5 @@ import { Box, Card, MenuItem, TextField, Typography } from '@mui/material' -import { ASSET_TYPE_LABELS } from '../../pages/supply/newListingConstants' +import { ASSET_TYPE_LABELS } from '../../lib/constants' interface Props { assetType: string diff --git a/src/components/new-listing/FloorPlanUrlSection.tsx b/src/components/new-listing/FloorPlanUrlSection.tsx index 2afd572..e3ccf37 100644 --- a/src/components/new-listing/FloorPlanUrlSection.tsx +++ b/src/components/new-listing/FloorPlanUrlSection.tsx @@ -1,4 +1,5 @@ import { Box, Card, TextField, Typography } from '@mui/material' +import { DS_SLATE } from '../../lib/ds' interface Props { floorPlanUrl: string @@ -25,7 +26,7 @@ export function FloorPlanUrlSection({ floorPlanUrl, onFloorPlanUrlChange }: Prop component="img" src={floorPlanUrl} alt="Grundriss Vorschau" - sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: '#f8fafc' }} + sx={{ mt: 2, width: '100%', maxHeight: 300, objectFit: 'contain', borderRadius: 1, border: '1px solid rgba(0,0,0,0.08)', bgcolor: DS_SLATE[50] }} /> )} diff --git a/src/components/new-listing/TechnicalDetailsSection.tsx b/src/components/new-listing/TechnicalDetailsSection.tsx index b034a8e..e1dba0b 100644 --- a/src/components/new-listing/TechnicalDetailsSection.tsx +++ b/src/components/new-listing/TechnicalDetailsSection.tsx @@ -1,5 +1,6 @@ import { Box, Card, FormControlLabel, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material' import { FIT_OUT_OPTIONS } from '../../pages/supply/newListingConstants' +import { DS_ACCENT } from '../../lib/ds' // Stufen, die noch Mieterausbau benötigen — nur dann ist die Träger-Frage relevant const NEEDS_FIT_OUT = new Set(['SHELL', 'BASIC']) @@ -67,7 +68,7 @@ export function TechnicalDetailsSection({ {showFitOutResponsibility && ( - Wer baut aus? * + Wer baut aus? * {fitOutByLandlord === undefined ? ( - + Pflichtangabe bei Rohbau/Edelrohbau — bitte wählen, wer den Ausbau trägt. ) : fitOutByLandlord ? ( diff --git a/src/components/ops/ConfidenceGatePanel.tsx b/src/components/ops/ConfidenceGatePanel.tsx index 8d1b32b..6871f8b 100644 --- a/src/components/ops/ConfidenceGatePanel.tsx +++ b/src/components/ops/ConfidenceGatePanel.tsx @@ -2,6 +2,7 @@ import { Box, Chip, Divider, Paper, Typography } from '@mui/material' import { CheckCircle2, ChevronRight, Target, XCircle } from 'lucide-react' import type { GateEvaluation } from '../../domain/signalPipeline' import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' +import { DS_ACCENT, DS_TEXT } from '../../lib/ds' interface ConfidenceGatePanelProps { gate: GateEvaluation @@ -50,10 +51,10 @@ function ConfidenceBar({ value }: { value: string | undefined }) { 0% - + 35% - + 75% @@ -101,7 +102,7 @@ export function ConfidenceGatePanel({ gate }: ConfidenceGatePanelProps) { )} {check.note && ( - + ({check.note}) )} @@ -120,7 +121,7 @@ export function ConfidenceGatePanel({ gate }: ConfidenceGatePanelProps) { {gate.nextAction && gate.status !== GateStatus.PASSED && ( - + {gate.nextAction} diff --git a/src/components/ops/ConnectorRunDetailDrawer.tsx b/src/components/ops/ConnectorRunDetailDrawer.tsx index 8bb3635..ee1a910 100644 --- a/src/components/ops/ConnectorRunDetailDrawer.tsx +++ b/src/components/ops/ConnectorRunDetailDrawer.tsx @@ -2,6 +2,7 @@ import { Box, Chip, Divider, Drawer, IconButton, Typography } from '@mui/materia import { X, AlertCircle, AlertTriangle, Zap } from 'lucide-react' import type { ConnectorRun } from '../../domain/dataSource' import { CONNECTOR_RUN_STATUS_LABELS, CONNECTOR_RUN_STATUS_COLORS } from '../../domain/dataSource' +import { DS_ACCENT, DS_SLATE, DS_TEXT } from '../../lib/ds' function formatDate(iso: string): string { return new Date(iso).toLocaleString('de-CH', { @@ -70,12 +71,12 @@ export function ConnectorRunDetailDrawer({ run, onClose }: ConnectorRunDetailDra {/* Stats grid */} {[ - { label: 'Erkannt', value: run.itemsDetected, color: '#1e293b' }, - { label: 'Normalisiert', value: run.itemsNormalized, color: '#15803d' }, + { label: 'Erkannt', value: run.itemsDetected, color: DS_SLATE[800] }, + { label: 'Normalisiert', value: run.itemsNormalized, color: DS_ACCENT.success.strong }, { label: 'Abgelehnt', value: run.itemsRejected, color: run.itemsRejected > 0 ? '#dc2626' : '#64748b' }, - { label: 'Signale erstellt', value: run.signalsCreated, color: '#7c3aed' }, + { label: 'Signale erstellt', value: run.signalsCreated, color: DS_ACCENT.violet.main }, ].map(({ label, value, color }) => ( - + {value} @@ -89,7 +90,7 @@ export function ConnectorRunDetailDrawer({ run, onClose }: ConnectorRunDetailDra Zusammenfassung - + {run.runSummary} @@ -97,7 +98,7 @@ export function ConnectorRunDetailDrawer({ run, onClose }: ConnectorRunDetailDra {run.signalsCreated > 0 && ( - + {run.signalsCreated} Marktsignal{run.signalsCreated !== 1 ? 'e' : ''} aus diesem Run verfügbar in Market Intelligence. @@ -113,7 +114,7 @@ export function ConnectorRunDetailDrawer({ run, onClose }: ConnectorRunDetailDra {run.errors.map((err, i) => ( - {err} + {err} ))} @@ -130,7 +131,7 @@ export function ConnectorRunDetailDrawer({ run, onClose }: ConnectorRunDetailDra {run.warnings.map((w, i) => ( - {w} + {w} ))} diff --git a/src/components/ops/ConnectorRunTable.tsx b/src/components/ops/ConnectorRunTable.tsx index 649406a..84b8149 100644 --- a/src/components/ops/ConnectorRunTable.tsx +++ b/src/components/ops/ConnectorRunTable.tsx @@ -1,6 +1,7 @@ import { Box, Chip, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material' import type { ConnectorRun } from '../../domain/dataSource' import { CONNECTOR_RUN_STATUS_LABELS, CONNECTOR_RUN_STATUS_COLORS } from '../../domain/dataSource' +import { DS_SLATE } from '../../lib/ds' function formatDate(iso: string): string { return new Date(iso).toLocaleString('de-CH', { @@ -34,7 +35,7 @@ export function ConnectorRunTable({ runs, onSelectRun }: ConnectorRunTableProps)
- + Gestartet Status Erkannt @@ -68,7 +69,7 @@ export function ConnectorRunTable({ runs, onSelectRun }: ConnectorRunTableProps) {run.signalsCreated} - + {formatDuration(run.startedAt, run.finishedAt)} diff --git a/src/components/ops/DataCategoryBadgeList.tsx b/src/components/ops/DataCategoryBadgeList.tsx index 60d0aa2..2fa9a5e 100644 --- a/src/components/ops/DataCategoryBadgeList.tsx +++ b/src/components/ops/DataCategoryBadgeList.tsx @@ -1,4 +1,5 @@ import { Box, Chip } from '@mui/material' +import { DS_BRAND } from '../../lib/ds' interface DataCategoryBadgeListProps { categories: string[] @@ -18,7 +19,7 @@ export function DataCategoryBadgeList({ categories, max }: DataCategoryBadgeList label={cat} sx={{ bgcolor: 'rgba(30,58,95,0.07)', - color: '#152642', + color: DS_BRAND.main, border: 'none', fontSize: '0.68rem', }} diff --git a/src/components/ops/EvidenceGatePanel.tsx b/src/components/ops/EvidenceGatePanel.tsx index 637a0d1..bed03b2 100644 --- a/src/components/ops/EvidenceGatePanel.tsx +++ b/src/components/ops/EvidenceGatePanel.tsx @@ -2,6 +2,7 @@ import { Box, Chip, Divider, Paper, Typography } from '@mui/material' import { CheckCircle2, ChevronRight, FileSearch, XCircle } from 'lucide-react' import type { GateEvaluation } from '../../domain/signalPipeline' import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' +import { DS_ACCENT } from '../../lib/ds' interface EvidenceGatePanelProps { gate: GateEvaluation @@ -54,7 +55,7 @@ export function EvidenceGatePanel({ gate }: EvidenceGatePanelProps) { {gate.nextAction && gate.status !== GateStatus.PASSED && ( - + {gate.nextAction} diff --git a/src/components/ops/FeedEligibilityBadge.tsx b/src/components/ops/FeedEligibilityBadge.tsx index cab496d..fa432e8 100644 --- a/src/components/ops/FeedEligibilityBadge.tsx +++ b/src/components/ops/FeedEligibilityBadge.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, MinusCircle } from 'lucide-react' import type { MarketSignal } from '../../domain/marketSignal' import { SignalProcessingStatus } from '../../domain/marketSignal' import { SensitivityLevel } from '../../domain/enums' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface FeedEligibilityBadgeProps { signal: MarketSignal @@ -30,7 +31,7 @@ export function FeedEligibilityBadge({ signal }: FeedEligibilityBadgeProps) { icon={} sx={{ bgcolor: 'rgba(22,163,74,0.1)', - color: '#15803d', + color: DS_ACCENT.success.strong, border: 'none', fontSize: '0.7rem', fontWeight: 600, @@ -47,7 +48,7 @@ export function FeedEligibilityBadge({ signal }: FeedEligibilityBadgeProps) { icon={} sx={{ bgcolor: 'rgba(148,163,184,0.1)', - color: '#64748b', + color: DS_SLATE[500], border: 'none', fontSize: '0.7rem', fontWeight: 600, diff --git a/src/components/ops/MarketSignalCard.tsx b/src/components/ops/MarketSignalCard.tsx index 46b7b07..c213a60 100644 --- a/src/components/ops/MarketSignalCard.tsx +++ b/src/components/ops/MarketSignalCard.tsx @@ -13,6 +13,7 @@ import { import type { MarketSignal } from '../../domain/marketSignal' import { SensitivityLevel } from '../../domain/enums' import { SignalConfidenceBadge } from './SignalConfidenceBadge' +import { DS_SLATE } from '../../lib/ds' const SOURCE_ICONS: Record = { PUBLIC_LISTING_PLATFORM: Building2, @@ -66,7 +67,7 @@ export function MarketSignalCard({ signal, selected, onClick }: MarketSignalCard - + {MARKET_SIGNAL_SOURCE_LABELS[signal.sourceCategory as MarketSignalSourceCategory]} @@ -88,7 +89,7 @@ export function MarketSignalCard({ signal, selected, onClick }: MarketSignalCard sx={{ fontSize: '0.8125rem', fontWeight: 500, - color: '#1e293b', + color: DS_SLATE[800], overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', @@ -102,7 +103,7 @@ export function MarketSignalCard({ signal, selected, onClick }: MarketSignalCard {/* Row 3: location + date */} - + {signal.location} · {formatDate(signal.detectedAt)} diff --git a/src/components/ops/MarketSignalDetailPanel.tsx b/src/components/ops/MarketSignalDetailPanel.tsx index ff2a451..df30c54 100644 --- a/src/components/ops/MarketSignalDetailPanel.tsx +++ b/src/components/ops/MarketSignalDetailPanel.tsx @@ -23,6 +23,7 @@ import { SignalConversionPanel } from './SignalConversionPanel' import { MarketSignalEmptyState } from './MarketSignalEmptyState' import { FeedEligibilityBadge } from './FeedEligibilityBadge' import { useUpdateSignalStatus, useCreateReviewTask } from '../../hooks/useMarketSignals' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' const SOURCE_ICONS: Record = { PUBLIC_LISTING_PLATFORM: Building2, @@ -127,7 +128,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps {/* Summary */} - + {signal.summary} @@ -156,7 +157,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps label={entity.value} sx={{ bgcolor: 'rgba(30,58,95,0.08)', - color: '#152642', + color: DS_BRAND.main, border: 'none', fontSize: '0.75rem', cursor: 'default', @@ -198,7 +199,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps startIcon={isUpdating ? : } disabled={isUpdating || signal.processingStatus === SignalProcessingStatus.APPROVED_AS_SIGNAL} onClick={() => updateStatus({ id: signal.id, status: SignalProcessingStatus.APPROVED_AS_SIGNAL })} - sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#16a34a', color: '#16a34a', '&:hover': { borderColor: '#15803d', bgcolor: 'rgba(22,163,74,0.04)' } }} + sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_ACCENT.success.bright, color: DS_ACCENT.success.bright, '&:hover': { borderColor: DS_ACCENT.success.strong, bgcolor: 'rgba(22,163,74,0.04)' } }} > Als relevant markieren @@ -228,7 +229,7 @@ export function MarketSignalDetailPanel({ signal }: MarketSignalDetailPanelProps startIcon={} disabled={isUpdating} onClick={() => updateStatus({ id: signal.id, status: SignalProcessingStatus.REJECTED })} - sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#ef4444', color: '#dc2626', '&:hover': { borderColor: '#dc2626', bgcolor: 'rgba(239,68,68,0.04)' } }} + sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_ACCENT.danger.bright, color: DS_ACCENT.danger.strong, '&:hover': { borderColor: DS_ACCENT.danger.strong, bgcolor: 'rgba(239,68,68,0.04)' } }} > Ablehnen diff --git a/src/components/ops/MatchabilityGatePanel.tsx b/src/components/ops/MatchabilityGatePanel.tsx index 355670e..4afc55d 100644 --- a/src/components/ops/MatchabilityGatePanel.tsx +++ b/src/components/ops/MatchabilityGatePanel.tsx @@ -2,6 +2,7 @@ import { Box, Chip, Divider, Paper, Typography } from '@mui/material' import { CheckCircle2, ChevronRight, Layers, XCircle } from 'lucide-react' import type { GateEvaluation } from '../../domain/signalPipeline' import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' +import { DS_ACCENT } from '../../lib/ds' interface MatchabilityGatePanelProps { gate: GateEvaluation @@ -41,7 +42,7 @@ export function MatchabilityGatePanel({ gate }: MatchabilityGatePanelProps) { )} {check.note && ( - + – {check.note} )} @@ -59,7 +60,7 @@ export function MatchabilityGatePanel({ gate }: MatchabilityGatePanelProps) { {gate.nextAction && gate.status !== GateStatus.PASSED && ( - + {gate.nextAction} diff --git a/src/components/ops/ReviewGatePanel.tsx b/src/components/ops/ReviewGatePanel.tsx index d3b0f90..28929bb 100644 --- a/src/components/ops/ReviewGatePanel.tsx +++ b/src/components/ops/ReviewGatePanel.tsx @@ -2,6 +2,7 @@ import { Box, Chip, Divider, Paper, Typography } from '@mui/material' import { CheckCircle2, ChevronRight, ClipboardCheck, XCircle } from 'lucide-react' import type { GateEvaluation } from '../../domain/signalPipeline' import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' +import { DS_ACCENT, DS_BRAND } from '../../lib/ds' interface ReviewGatePanelProps { gate: GateEvaluation @@ -50,7 +51,7 @@ export function ReviewGatePanel({ gate }: ReviewGatePanelProps) { {reviewerNotes.length > 0 && ( - + Reviewer-Informationen {reviewerNotes.map((note, i) => ( @@ -72,7 +73,7 @@ export function ReviewGatePanel({ gate }: ReviewGatePanelProps) { {gate.nextAction && gate.status !== GateStatus.PASSED && ( - + {gate.nextAction} diff --git a/src/components/ops/SensitivityGatePanel.tsx b/src/components/ops/SensitivityGatePanel.tsx index 918f890..3261c1d 100644 --- a/src/components/ops/SensitivityGatePanel.tsx +++ b/src/components/ops/SensitivityGatePanel.tsx @@ -2,6 +2,7 @@ import { Alert, Box, Chip, Divider, Paper, Typography } from '@mui/material' import { CheckCircle2, ChevronRight, ShieldCheck, XCircle } from 'lucide-react' import type { GateEvaluation } from '../../domain/signalPipeline' import { GateStatus, GATE_STATUS_LABELS, GATE_STATUS_COLORS } from '../../domain/signalPipeline' +import { DS_ACCENT } from '../../lib/ds' interface SensitivityGatePanelProps { gate: GateEvaluation @@ -75,7 +76,7 @@ export function SensitivityGatePanel({ gate }: SensitivityGatePanelProps) { {gate.nextAction && gate.status !== GateStatus.PASSED && ( - + {gate.nextAction} diff --git a/src/components/ops/SignalConversionPanel.tsx b/src/components/ops/SignalConversionPanel.tsx index 5c96b9d..8a1425c 100644 --- a/src/components/ops/SignalConversionPanel.tsx +++ b/src/components/ops/SignalConversionPanel.tsx @@ -3,6 +3,7 @@ import { ArrowRight, Zap } from 'lucide-react' import type { MarketSignal } from '../../domain/marketSignal' import { SignalProcessingStatus } from '../../domain/marketSignal' import { useConvertToFutureSignal } from '../../hooks/useMarketSignals' +import { DS_ACCENT, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' const ELIGIBLE_STATUSES: SignalProcessingStatus[] = [ SignalProcessingStatus.ENRICHED, @@ -32,7 +33,7 @@ export function SignalConversionPanel({ signal }: SignalConversionPanelProps) { }} > - + Bereits in Future Availability überführt {signal.possibleFutureSignalId && ` (${signal.possibleFutureSignalId})`} @@ -44,7 +45,7 @@ export function SignalConversionPanel({ signal }: SignalConversionPanelProps) { convert(signal.id)} - startIcon={isPending ? : } - sx={{ bgcolor: '#7c3aed', textTransform: 'none', '&:hover': { bgcolor: '#6d28d9' } }} + startIcon={isPending ? : } + sx={{ bgcolor: DS_ACCENT.violet.main, textTransform: 'none', '&:hover': { bgcolor: DS_ACCENT.violet.strong } }} > {isPending ? 'Wird überführt...' : 'Jetzt konvertieren'} diff --git a/src/components/ops/SignalEvidenceList.tsx b/src/components/ops/SignalEvidenceList.tsx index d3bdce2..2c4ea11 100644 --- a/src/components/ops/SignalEvidenceList.tsx +++ b/src/components/ops/SignalEvidenceList.tsx @@ -3,6 +3,7 @@ import { FileText, Globe, PenLine, FileArchive } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import type { SignalEvidence } from '../../domain/marketSignal' import { EvidenceType } from '../../domain/marketSignal' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' const EVIDENCE_ICONS: Record = { [EvidenceType.TEXT_EXCERPT]: FileText, @@ -47,24 +48,24 @@ export function SignalEvidenceList({ evidence }: SignalEvidenceListProps) { key={ev.id} sx={{ p: 1.5, - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], borderRadius: 1, border: '1px solid #e2e8f0', }} > - + {EVIDENCE_LABELS[ev.evidenceType]} - + {ev.content} @@ -79,7 +80,7 @@ export function SignalEvidenceList({ evidence }: SignalEvidenceListProps) { )} - + Abgerufen: {formatDate(ev.retrievedAt)} diff --git a/src/components/ops/SignalInbox.tsx b/src/components/ops/SignalInbox.tsx index 3e6e3a3..383defe 100644 --- a/src/components/ops/SignalInbox.tsx +++ b/src/components/ops/SignalInbox.tsx @@ -5,6 +5,7 @@ import { MarketSignalCard } from './MarketSignalCard' import { MarketSignalFilterBar } from './MarketSignalFilterBar' import { MarketSignalSkeleton } from './MarketSignalSkeleton' import { MarketSignalEmptyState } from './MarketSignalEmptyState' +import { DS_BRAND, DS_NEUTRAL } from '../../lib/ds' interface SignalInboxProps { signals: MarketSignal[] @@ -43,7 +44,7 @@ export function SignalInbox({ diff --git a/src/components/ops/SignalPipelineStepper.tsx b/src/components/ops/SignalPipelineStepper.tsx index 31d9d7b..d5646c6 100644 --- a/src/components/ops/SignalPipelineStepper.tsx +++ b/src/components/ops/SignalPipelineStepper.tsx @@ -1,6 +1,7 @@ import { Stepper, Step, StepLabel } from '@mui/material' import type { PipelineState } from '../../domain/signalPipeline' import { PIPELINE_STAGE_ORDER, PIPELINE_STAGE_LABELS } from '../../domain/signalPipeline' +import { DS_ACCENT, DS_BG, DS_BRAND, DS_SLATE } from '../../lib/ds' interface SignalPipelineStepperProps { pipelineState: PipelineState @@ -15,15 +16,15 @@ export function SignalPipelineStepper({ pipelineState }: SignalPipelineStepperPr alternativeLabel sx={{ mb: 1, - '& .MuiStepIcon-root': { fontSize: '1.6rem', color: '#cbd5e1' }, - '& .MuiStepIcon-root.Mui-active': { color: '#152642' }, - '& .MuiStepIcon-root.Mui-completed': { color: '#15803d' }, - '& .MuiStepLabel-label': { fontSize: '0.68rem', mt: 0.5, color: '#64748b' }, - '& .MuiStepLabel-label.Mui-active': { color: '#152642', fontWeight: 700 }, - '& .MuiStepLabel-label.Mui-completed': { color: '#15803d' }, - '& .MuiStepConnector-line': { borderTopWidth: 2, borderColor: '#e8e7e4' }, - '& .MuiStepConnector-root.Mui-completed .MuiStepConnector-line': { borderColor: '#15803d' }, - '& .MuiStepConnector-root.Mui-active .MuiStepConnector-line': { borderColor: '#152642' }, + '& .MuiStepIcon-root': { fontSize: '1.6rem', color: DS_SLATE[300] }, + '& .MuiStepIcon-root.Mui-active': { color: DS_BRAND.main }, + '& .MuiStepIcon-root.Mui-completed': { color: DS_ACCENT.success.strong }, + '& .MuiStepLabel-label': { fontSize: '0.68rem', mt: 0.5, color: DS_SLATE[500] }, + '& .MuiStepLabel-label.Mui-active': { color: DS_BRAND.main, fontWeight: 700 }, + '& .MuiStepLabel-label.Mui-completed': { color: DS_ACCENT.success.strong }, + '& .MuiStepConnector-line': { borderTopWidth: 2, borderColor: DS_BG.muted }, + '& .MuiStepConnector-root.Mui-completed .MuiStepConnector-line': { borderColor: DS_ACCENT.success.strong }, + '& .MuiStepConnector-root.Mui-active .MuiStepConnector-line': { borderColor: DS_BRAND.main }, }} > {PIPELINE_STAGE_ORDER.map((stage) => ( diff --git a/src/components/ops/SignalPipelineView.tsx b/src/components/ops/SignalPipelineView.tsx index 63360c2..93bc140 100644 --- a/src/components/ops/SignalPipelineView.tsx +++ b/src/components/ops/SignalPipelineView.tsx @@ -15,6 +15,7 @@ import { SensitivityGatePanel } from './SensitivityGatePanel' import { ReviewGatePanel } from './ReviewGatePanel' import { MatchabilityGatePanel } from './MatchabilityGatePanel' import { SignalToMatchAuditTrail } from './SignalToMatchAuditTrail' +import { DS_BRAND, DS_NEUTRAL, DS_TEXT } from '../../lib/ds' interface SignalPipelineViewProps { signal: MarketSignal @@ -44,7 +45,7 @@ export function SignalPipelineView({ signal }: SignalPipelineViewProps) { {/* Pipeline Stepper */} - + {isLoadingPipeline && ( @@ -123,8 +124,8 @@ export function SignalPipelineView({ signal }: SignalPipelineViewProps) { sx={{ textTransform: 'none', fontSize: '0.8rem', - bgcolor: '#152642', - '&:hover': { bgcolor: '#162d4a' }, + bgcolor: DS_BRAND.main, + '&:hover': { bgcolor: DS_BRAND.hoverAlt }, }} > In Future Availability publizieren diff --git a/src/components/ops/SignalToMatchAuditTrail.tsx b/src/components/ops/SignalToMatchAuditTrail.tsx index 355244e..0007f48 100644 --- a/src/components/ops/SignalToMatchAuditTrail.tsx +++ b/src/components/ops/SignalToMatchAuditTrail.tsx @@ -1,6 +1,7 @@ import { Box, Typography } from '@mui/material' import type { AuditTrailEntry } from '../../domain/signalPipeline' import { PIPELINE_STAGE_ORDER } from '../../domain/signalPipeline' +import { DS_SLATE } from '../../lib/ds' interface SignalToMatchAuditTrailProps { entries: AuditTrailEntry[] @@ -77,7 +78,7 @@ export function SignalToMatchAuditTrail({ entries }: SignalToMatchAuditTrailProp {entry.details} diff --git a/src/components/ops/SourceCard.tsx b/src/components/ops/SourceCard.tsx index 86b69bb..7be2e38 100644 --- a/src/components/ops/SourceCard.tsx +++ b/src/components/ops/SourceCard.tsx @@ -9,6 +9,7 @@ import { DATA_SOURCE_TYPE_LABELS } from '../../domain/dataSource' import { SourceHealthBadge } from './SourceHealthBadge' import { TermsStatusBadge } from './TermsStatusBadge' import { ReliabilityScorePanel } from './ReliabilityScorePanel' +import { DS_SLATE } from '../../lib/ds' const TYPE_ICONS: Record = { API_CONNECTOR: Plug2, @@ -98,7 +99,7 @@ export function SourceCard({ source, selected, onClick }: SourceCardProps) { - + {formatDate(source.lastRunAt)} @@ -110,11 +111,11 @@ export function SourceCard({ source, selected, onClick }: SourceCardProps) { key={r} size="small" label={r} - sx={{ bgcolor: 'transparent', border: '1px solid #e2e8f0', color: '#64748b', fontSize: '0.65rem', height: 16 }} + sx={{ bgcolor: 'transparent', border: '1px solid #e2e8f0', color: DS_SLATE[500], fontSize: '0.65rem', height: 16 }} /> ))} {source.regionCoverage.length > 3 && ( - + +{source.regionCoverage.length - 3} )} diff --git a/src/components/ops/SourceDetailPanel.tsx b/src/components/ops/SourceDetailPanel.tsx index cfb2aa4..4043358 100644 --- a/src/components/ops/SourceDetailPanel.tsx +++ b/src/components/ops/SourceDetailPanel.tsx @@ -29,6 +29,7 @@ import { useUpdateSourceStatus, useMarkTermsStatus, } from '../../hooks/useDataSources' +import { DS_ACCENT, DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds' const TYPE_ICONS: Record = { API_CONNECTOR: Plug2, @@ -111,11 +112,11 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) { {source.errorState && } {/* Legal basis */} - - + + Rechtliche Grundlage - + {source.legalBasis} @@ -153,7 +154,7 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) { key={r} size="small" label={r} - sx={{ bgcolor: 'transparent', border: '1px solid #e2e8f0', color: '#475569', fontSize: '0.72rem' }} + sx={{ bgcolor: 'transparent', border: '1px solid #e2e8f0', color: DS_SLATE[600], fontSize: '0.72rem' }} /> ))} @@ -166,7 +167,7 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) { key={a} size="small" label={a} - sx={{ bgcolor: 'rgba(30,58,95,0.06)', color: '#152642', border: 'none', fontSize: '0.72rem' }} + sx={{ bgcolor: 'rgba(30,58,95,0.06)', color: DS_BRAND.main, border: 'none', fontSize: '0.72rem' }} /> ))} @@ -191,10 +192,10 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) { @@ -206,7 +207,7 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) { startIcon={} disabled={isUpdating} onClick={() => updateStatus({ id: source.id, status: SourceStatus.PAUSED })} - sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#a16207', color: '#a16207', '&:hover': { borderColor: '#854d0e', bgcolor: 'rgba(161,98,7,0.04)' } }} + sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_TEXT.warning, color: DS_TEXT.warning, '&:hover': { borderColor: DS_ACCENT.warning.darker, bgcolor: 'rgba(161,98,7,0.04)' } }} > Pausieren @@ -218,7 +219,7 @@ export function SourceDetailPanel({ source }: SourceDetailPanelProps) { startIcon={} disabled={isUpdating} onClick={() => updateStatus({ id: source.id, status: SourceStatus.ACTIVE })} - sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: '#15803d', color: '#15803d', '&:hover': { borderColor: '#166534', bgcolor: 'rgba(21,128,61,0.04)' } }} + sx={{ textTransform: 'none', fontSize: '0.8rem', borderColor: DS_ACCENT.success.strong, color: DS_ACCENT.success.strong, '&:hover': { borderColor: DS_TEXT.successDark, bgcolor: 'rgba(21,128,61,0.04)' } }} > Reaktivieren diff --git a/src/components/ops/SourceList.tsx b/src/components/ops/SourceList.tsx index 7206d4b..74e58fe 100644 --- a/src/components/ops/SourceList.tsx +++ b/src/components/ops/SourceList.tsx @@ -10,6 +10,7 @@ import { import { SourceCard } from './SourceCard' import { MarketSignalSkeleton } from './MarketSignalSkeleton' import { MarketSignalEmptyState } from './MarketSignalEmptyState' +import { DS_BRAND, DS_NEUTRAL } from '../../lib/ds' interface SourceListProps { sources: DataSource[] @@ -40,7 +41,7 @@ export function SourceList({ diff --git a/src/components/pipeline/PipelineCard.tsx b/src/components/pipeline/PipelineCard.tsx index dccb4a6..2cc38d9 100644 --- a/src/components/pipeline/PipelineCard.tsx +++ b/src/components/pipeline/PipelineCard.tsx @@ -9,6 +9,7 @@ import type { PipelineItem } from '../../domain/pipeline' import { STAGES, RESULT_TYPE_META } from './pipelineConstants' import { detailPath } from './pipelineUtils' import { useInquiryStore } from '../../stores/inquiryStore' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' // ── DraggableCard ───────────────────────────────────────────────────────────── @@ -83,7 +84,7 @@ export function DraggableCard({ {item.inquiryId && onChatClick && ( - + @@ -93,7 +94,7 @@ export function DraggableCard({ { e.stopPropagation(); navigate(path) }} - sx={{ p: 0.25, color: '#94a3b8', '&:hover': { color: '#152642', bgcolor: '#eff6ff' } }} + sx={{ p: 0.25, color: DS_SLATE[400], '&:hover': { color: DS_BRAND.main, bgcolor: DS_ACCENT.blue.bg } }} > @@ -120,20 +121,20 @@ export function DraggableCard({ {(item.areaLabel || item.rentLabel) && ( {item.areaLabel && ( - {item.areaLabel} + {item.areaLabel} )} {item.areaLabel && item.rentLabel && ( - + )} {item.rentLabel && ( - {item.rentLabel} + {item.rentLabel} )} )} {/* Row 5: Notes preview */} {item.notes && ( - + {item.notes} )} @@ -154,8 +155,8 @@ export function DraggableCard({ }} sx={{ fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600, - borderColor: '#152642', color: '#152642', - '&:hover': { bgcolor: '#eff6ff', borderColor: '#152642' }, + borderColor: DS_BRAND.main, color: DS_BRAND.main, + '&:hover': { bgcolor: DS_ACCENT.blue.bg, borderColor: DS_BRAND.main }, }} > Zur Konversation @@ -180,7 +181,7 @@ export function DraggableCard({ }} sx={{ fontSize: '0.7rem', py: 0.5, textTransform: 'none', fontWeight: 600, - bgcolor: '#152642', '&:hover': { bgcolor: '#162d4a' }, + bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt }, }} > Anfrage senden diff --git a/src/components/pipeline/PipelineDetailPanel.tsx b/src/components/pipeline/PipelineDetailPanel.tsx index b69ea4a..6a43a12 100644 --- a/src/components/pipeline/PipelineDetailPanel.tsx +++ b/src/components/pipeline/PipelineDetailPanel.tsx @@ -11,7 +11,7 @@ import { useMoveStage, useUpdateNotes, useLoseItem } from '../../hooks/usePipeli import type { PipelineItem } from '../../domain/pipeline' import { STAGES, NEXT_STAGE, MOCK_DOCS } from './pipelineConstants' import { scoreColor, detailPath, getKiInsight } from './pipelineUtils' -import { DS_TEXT, DS_SURFACE, DS_BORDER, DS_PRE_MARKET, DS_COLORS } from '../../lib/ds' +import { DS_BORDER, DS_COLORS, DS_PRE_MARKET, DS_SLATE, DS_SURFACE, DS_TEXT } from '../../lib/ds' // ── DetailPanel ─────────────────────────────────────────────────────────────── @@ -184,7 +184,7 @@ export function DetailPanel({ item, onClose }: { item: PipelineItem; onClose: () display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.75, bgcolor: DS_SURFACE.neutral.bg, borderRadius: 1.5, border: `1px solid ${DS_BORDER.default}`, - cursor: 'pointer', '&:hover': { bgcolor: '#f1f5f9' }, + cursor: 'pointer', '&:hover': { bgcolor: DS_SLATE[100] }, }}> {doc.name} diff --git a/src/components/pipeline/pipelineConstants.ts b/src/components/pipeline/pipelineConstants.ts index fc84b1b..ec0d212 100644 --- a/src/components/pipeline/pipelineConstants.ts +++ b/src/components/pipeline/pipelineConstants.ts @@ -1,15 +1,16 @@ import type { PipelineStage } from '../../domain/pipeline' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' export { RESULT_TYPE_META } from '../../lib/ds' // ── Stage config ────────────────────────────────────────────────────────────── export const STAGES = [ - { key: 'SAVED' as PipelineStage, label: 'Interessiert', color: '#475569', bgColor: '#f8fafc' }, - { key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: '#0369a1', bgColor: '#f0f9ff' }, - { key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: '#d97706', bgColor: '#fffbeb' }, - { key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: '#7c3aed', bgColor: '#faf5ff' }, - { key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: '#1a7a4a', bgColor: '#f0fdf4' }, - { key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: '#c0392b', bgColor: '#fef2f2' }, + { key: 'SAVED' as PipelineStage, label: 'Interessiert', color: DS_SLATE[600], bgColor: '#f8fafc' }, + { key: 'CONTACTED' as PipelineStage, label: 'Angefragt', color: DS_ACCENT.cyan.deep, bgColor: '#f0f9ff' }, + { key: 'VISITED' as PipelineStage, label: 'Besichtigt', color: DS_ACCENT.warning.main, bgColor: '#fffbeb' }, + { key: 'NEGOTIATION' as PipelineStage, label: 'Verhandlung', color: DS_ACCENT.violet.main, bgColor: '#faf5ff' }, + { key: 'CLOSED_WON' as PipelineStage, label: 'Gewonnen', color: DS_ACCENT.success.main, bgColor: '#f0fdf4' }, + { key: 'CLOSED_LOST' as PipelineStage, label: 'Abgelehnt', color: DS_ACCENT.danger.main, bgColor: '#fef2f2' }, ] as const export const NEXT_STAGE: Partial> = { diff --git a/src/components/results/ResultConfidenceSummary.tsx b/src/components/results/ResultConfidenceSummary.tsx index 1ab9577..3b0a804 100644 --- a/src/components/results/ResultConfidenceSummary.tsx +++ b/src/components/results/ResultConfidenceSummary.tsx @@ -1,4 +1,5 @@ import { Box, LinearProgress, Stack, Typography } from '@mui/material' +import { DS_SLATE } from '../../lib/ds' interface Props { confidenceLevel: number @@ -10,7 +11,7 @@ export function ResultConfidenceSummary({ confidenceLevel, dataQualityScore }: P return ( - Konfidenz + Konfidenz {Math.round(confidenceLevel * 100)}% {dataQualityScore !== undefined && ( diff --git a/src/components/results/ResultFilterBar.tsx b/src/components/results/ResultFilterBar.tsx index 76e3c54..f3cdae3 100644 --- a/src/components/results/ResultFilterBar.tsx +++ b/src/components/results/ResultFilterBar.tsx @@ -1,5 +1,6 @@ import { Box, Card, Chip, Divider, Stack, Typography } from '@mui/material' import { Building2, Zap } from 'lucide-react' +import { DS_BRAND } from '../../lib/ds' type FilterSource = 'ALL' | 'PLATFORM' | 'MAISON_WORK' type SortBy = 'score' | 'rent' | 'area' @@ -28,7 +29,7 @@ const SORT_OPTIONS: { value: SortBy; label: string }[] = [ { value: 'rent', label: 'Mietpreis' }, ] -const ACTIVE_CHIP = { bgcolor: '#152642', color: 'white', border: '1px solid #152642', fontWeight: 600 } +const ACTIVE_CHIP = { bgcolor: DS_BRAND.main, color: 'white', border: '1px solid #152642', fontWeight: 600 } const INACTIVE_CHIP = { bgcolor: 'transparent', color: 'text.secondary', border: '1px solid #e8e7e4', fontWeight: 400 } export function ResultFilterBar({ diff --git a/src/components/results/ResultTypeBadge.tsx b/src/components/results/ResultTypeBadge.tsx index e616410..fd5d85c 100644 --- a/src/components/results/ResultTypeBadge.tsx +++ b/src/components/results/ResultTypeBadge.tsx @@ -1,9 +1,9 @@ import type { ResultType } from '../../domain/enums' import { Chip } from '@mui/material' -import { RESULT_TYPE_META } from '../../lib/ds' +import { DS_SLATE, RESULT_TYPE_META } from '../../lib/ds' export function ResultTypeBadge({ resultType }: { resultType: ResultType }) { - const meta = RESULT_TYPE_META[resultType] ?? { label: resultType, color: '#64748b' } + const meta = RESULT_TYPE_META[resultType] ?? { label: resultType, color: DS_SLATE[500] } return ( { - const prop = result.resultType !== 'FUTURE_AVAILABILITY' ? (result as any).property : null + const prop = result.resultType !== 'FUTURE_AVAILABILITY' ? result.property : null openSavedDialog({ resultId: result.matchId, resultType: result.resultType, diff --git a/src/components/results/UnifiedResultFeed.tsx b/src/components/results/UnifiedResultFeed.tsx index dec139f..0a7b1ed 100644 --- a/src/components/results/UnifiedResultFeed.tsx +++ b/src/components/results/UnifiedResultFeed.tsx @@ -2,6 +2,7 @@ import { Box, Chip, Divider, Typography } from '@mui/material' import type { UnifiedMatchResult } from '../../domain/unifiedResult' import { UnifiedResultCard } from './UnifiedResultCard' import { SCORE_THEME } from '../shared/scoreTheme' +import { DS_ACCENT } from '../../lib/ds' interface Props { results: UnifiedMatchResult[] @@ -54,7 +55,7 @@ function TierHeader({ label, sublabel, tierKey, count, isFirst }: TierHeaderProp {/* SCORE_THEME.text is '#ffffff' for every tier, so the former ternary always resolved to this one value — collapsed, rendering is unchanged. */} - + {label} {sublabel} diff --git a/src/components/review/ReviewActionToolbar.tsx b/src/components/review/ReviewActionToolbar.tsx index 9b4dd92..5ee8f24 100644 --- a/src/components/review/ReviewActionToolbar.tsx +++ b/src/components/review/ReviewActionToolbar.tsx @@ -2,6 +2,7 @@ import { Box, Button, CircularProgress } from '@mui/material' import { CheckCircle, XCircle, AlertTriangle, ArrowUpCircle } from 'lucide-react' import type { ReviewTask, ReviewTaskStatus } from '../../domain/review' import type { UserRole } from '../../domain/enums' +import { DS_ACCENT } from '../../lib/ds' interface ReviewActionToolbarProps { task: ReviewTask @@ -30,8 +31,8 @@ export function ReviewActionToolbar({ task, userRole, onAction, isSubmitting }: startIcon={isSubmitting ? : } onClick={() => onAction('APPROVED')} sx={{ - bgcolor: '#1a7a4a', - '&:hover': { bgcolor: '#155f3a' }, + bgcolor: DS_ACCENT.success.main, + '&:hover': { bgcolor: DS_ACCENT.success.dark }, textTransform: 'none', fontSize: '0.75rem', }} @@ -45,9 +46,9 @@ export function ReviewActionToolbar({ task, userRole, onAction, isSubmitting }: startIcon={} onClick={() => onAction('REJECTED')} sx={{ - color: '#c0392b', - borderColor: '#c0392b', - '&:hover': { borderColor: '#a93226', bgcolor: '#fef2f2' }, + color: DS_ACCENT.danger.main, + borderColor: DS_ACCENT.danger.main, + '&:hover': { borderColor: DS_ACCENT.danger.dark, bgcolor: DS_ACCENT.danger.bg }, textTransform: 'none', fontSize: '0.75rem', }} @@ -64,9 +65,9 @@ export function ReviewActionToolbar({ task, userRole, onAction, isSubmitting }: startIcon={} onClick={() => onAction('NEEDS_MORE_DATA')} sx={{ - color: '#d97706', - borderColor: '#d97706', - '&:hover': { borderColor: '#b45309', bgcolor: '#fffbeb' }, + color: DS_ACCENT.warning.main, + borderColor: DS_ACCENT.warning.main, + '&:hover': { borderColor: DS_ACCENT.warning.dark, bgcolor: DS_ACCENT.warning.bg }, textTransform: 'none', fontSize: '0.75rem', }} @@ -82,9 +83,9 @@ export function ReviewActionToolbar({ task, userRole, onAction, isSubmitting }: startIcon={} onClick={() => onAction('ESCALATED')} sx={{ - color: '#ea580c', - borderColor: '#ea580c', - '&:hover': { borderColor: '#c2410c', bgcolor: '#fff7ed' }, + color: DS_ACCENT.warning.strong, + borderColor: DS_ACCENT.warning.strong, + '&:hover': { borderColor: DS_ACCENT.warning.burnt, bgcolor: DS_ACCENT.warning.bgAlt }, textTransform: 'none', fontSize: '0.75rem', }} diff --git a/src/components/review/ReviewDetailPanel.tsx b/src/components/review/ReviewDetailPanel.tsx index 590b5bc..9e5cbd6 100644 --- a/src/components/review/ReviewDetailPanel.tsx +++ b/src/components/review/ReviewDetailPanel.tsx @@ -7,6 +7,8 @@ import { ReviewNotesPanel } from './ReviewNotesPanel' import { ReviewActionToolbar } from './ReviewActionToolbar' import type { ReviewTask, ReviewTaskStatus } from '../../domain/review' import type { UserRole } from '../../domain/enums' +import { RISK_LABELS } from '../../lib/constants' +import { DS_BG, DS_SLATE } from '../../lib/ds' interface ReviewDetailPanelProps { task: ReviewTask @@ -26,9 +28,7 @@ const ENTITY_TYPE_LABELS: Record = { PROPERTY_DATA_ISSUE: 'Datenfehler', } -const RISK_LABELS: Record = { - LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', CRITICAL: 'Kritisch', -} + const RISK_COLORS: Record = { LOW: '#1a7a4a', MEDIUM: '#d97706', HIGH: '#ea580c', CRITICAL: '#c0392b', @@ -41,7 +41,7 @@ function MetaRow({ label, value }: { label: string; value: string | undefined }) {label} - + {value} @@ -121,7 +121,7 @@ export function ReviewDetailPanel({ sx={{ height: 4, borderRadius: 2, - bgcolor: '#e8e7e4', + bgcolor: DS_BG.muted, '& .MuiLinearProgress-bar': { bgcolor: task.confidenceScore >= 0.7 ? '#1a7a4a' : task.confidenceScore >= 0.5 ? '#d97706' : '#c0392b', }, diff --git a/src/components/review/ReviewEmptyState.tsx b/src/components/review/ReviewEmptyState.tsx index ff49e06..b158729 100644 --- a/src/components/review/ReviewEmptyState.tsx +++ b/src/components/review/ReviewEmptyState.tsx @@ -1,5 +1,6 @@ import { Box, Typography } from '@mui/material' import { CheckCircle, MousePointer, ShieldOff } from 'lucide-react' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' interface ReviewEmptyStateProps { variant: 'empty-queue' | 'no-selection' | 'no-permission' @@ -8,19 +9,19 @@ interface ReviewEmptyStateProps { const CONFIG = { 'empty-queue': { icon: CheckCircle, - color: '#1a7a4a', + color: DS_ACCENT.success.main, title: 'Keine ausstehenden Reviews', desc: 'Alle Aufgaben wurden bearbeitet. Gute Arbeit.', }, 'no-selection': { icon: MousePointer, - color: '#94a3b8', + color: DS_SLATE[400], title: 'Aufgabe wählen', desc: 'Klicken Sie auf eine Aufgabe in der Liste, um Details und Aktionen anzuzeigen.', }, 'no-permission': { icon: ShieldOff, - color: '#c0392b', + color: DS_ACCENT.danger.main, title: 'Kein Zugriff', desc: 'Sie haben keine Berechtigung, Review-Aufgaben zu bearbeiten.', }, @@ -31,7 +32,7 @@ export function ReviewEmptyState({ variant }: ReviewEmptyStateProps) { return ( - {title} + {title} {desc} ) diff --git a/src/components/review/ReviewEntityTypeBadge.tsx b/src/components/review/ReviewEntityTypeBadge.tsx index 13fc9ea..0a750de 100644 --- a/src/components/review/ReviewEntityTypeBadge.tsx +++ b/src/components/review/ReviewEntityTypeBadge.tsx @@ -1,13 +1,14 @@ import { GenericBadge } from '../shared/GenericBadge' import type { ReviewEntityType } from '../../domain/review' +import { DS_ACCENT, DS_BRAND, DS_SLATE } from '../../lib/ds' const CONFIG: Record = { - FUTURE_SIGNAL: { label: 'Zukunftssignal', color: '#7c3aed' }, - MATCH_EXPLANATION: { label: 'Match-Begründung', color: '#152642' }, - LOW_CONFIDENCE_MATCH: { label: 'Niedr. Konfidenz', color: '#ea580c' }, - CONTACT_RELEASE: { label: 'Kontaktfreigabe', color: '#0891b2' }, - AI_OUTPUT: { label: 'AI-Output', color: '#4f46e5' }, - PROPERTY_DATA_ISSUE: { label: 'Datenfehler', color: '#c0392b' }, + FUTURE_SIGNAL: { label: 'Zukunftssignal', color: DS_ACCENT.violet.main }, + MATCH_EXPLANATION: { label: 'Match-Begründung', color: DS_BRAND.main }, + LOW_CONFIDENCE_MATCH: { label: 'Niedr. Konfidenz', color: DS_ACCENT.warning.strong }, + CONTACT_RELEASE: { label: 'Kontaktfreigabe', color: DS_ACCENT.cyan.main }, + AI_OUTPUT: { label: 'AI-Output', color: DS_ACCENT.indigo.main }, + PROPERTY_DATA_ISSUE: { label: 'Datenfehler', color: DS_ACCENT.danger.main }, } interface Props { @@ -16,6 +17,6 @@ interface Props { } export function ReviewEntityTypeBadge({ entityType, size = 'small' }: Props) { - const { label, color } = CONFIG[entityType] ?? { label: entityType, color: '#64748b' } + const { label, color } = CONFIG[entityType] ?? { label: entityType, color: DS_SLATE[500] } return } diff --git a/src/components/review/ReviewFilterBar.tsx b/src/components/review/ReviewFilterBar.tsx index 80a430e..2f1a7dc 100644 --- a/src/components/review/ReviewFilterBar.tsx +++ b/src/components/review/ReviewFilterBar.tsx @@ -1,6 +1,7 @@ import { Box, MenuItem, Select, Typography } from '@mui/material' import { ReviewEntityType, ReviewPriority, ReviewTaskStatus } from '../../domain/review' import type { ReviewFilters } from '../../provider/IReviewProvider' +import { DS_NEUTRAL } from '../../lib/ds' interface ReviewFilterBarProps { filters: ReviewFilters @@ -13,7 +14,7 @@ export function ReviewFilterBar({ filters, onChange, totalCount, filteredCount } const activeCount = Object.values(filters).filter(Boolean).length return ( - +
@@ -104,13 +121,13 @@ export function PropertyTable({ ObjektTypStandort - - + + Aktueller MieterMietlaufzeitBreakoutoptionBreakoutoption Zeitpunkt - + Aktionen @@ -182,28 +199,28 @@ export function PropertyTable({ {/* Miete */} - {p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : k.A.} + {p.rentPricePerSqm > 0 ? `CHF ${p.rentPricePerSqm.toLocaleString('de-CH')}` : k.A.} {/* Aktueller Mieter */} - {p.currentTenant ?? } + {p.currentTenant ?? } {/* Mietlaufzeit */} - {p.leaseTerm ?? } + {p.leaseTerm ?? } {/* Breakoutoption */} {p.breakoutOption == null - ? + ? : {p.breakoutOptionDate ? new Date(p.breakoutOptionDate).toLocaleDateString('de-CH') - : + : } diff --git a/src/components/supply/ReminderCalendarPlanner.tsx b/src/components/supply/ReminderCalendarPlanner.tsx new file mode 100644 index 0000000..792bd1c --- /dev/null +++ b/src/components/supply/ReminderCalendarPlanner.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react' +import { Alert, Box, Button, Paper, TextField, Typography } from '@mui/material' +import { CalendarPlus, FileText, Paperclip } from 'lucide-react' +import type { Reminder } from '../../domain/reminder' +import type { Property } from '../../domain/property' +import { + useCalendarStatus, + useCalendarEventsByReminder, + useCreateCalendarEvent, +} from '../../hooks/useCalendar' +import { buildCalendarAttachment, defaultCalendarTitle } from '../../services/calendarService' +import { useToastStore } from '../../stores/toastStore' +import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' + +/** Vorschlag: der Fälligkeitstag, vormittags — nicht der heutige Tag. */ +const DEFAULT_TIME = '09:00' +const DEFAULT_DURATION_MINUTES = 30 + +interface Props { + reminder: Reminder + property?: Property | null +} + +/** + * «Bearbeitung im Kalender einplanen» (Runde 4, §5.6). + * + * Steht an der Stelle, an der bis Runde 3 das Notizfeld war. Der Grund für den + * Tausch: eine Notiz beantwortet nicht die Frage, mit der man einen Reminder + * öffnet — «wann mache ich das?». Ein Termin im ohnehin geführten Kalender tut es. + * + * Titel und Anhang sind vorbelegt, aber änderbar. Der Anhang ist bewusst kein + * Vollarchiv, sondern der zur Reminderart passende Ausschnitt des Objektdossiers. + * + * Das Anlegen läuft über den bestehenden Mock-/Service-Layer; ohne verbundenen + * Kalender wird die Funktion nicht angeboten, statt einen Erfolg zu behaupten. + */ +export function ReminderCalendarPlanner({ reminder, property }: Props) { + const { data: status } = useCalendarStatus() + const { data: events = [] } = useCalendarEventsByReminder(reminder.id) + const createEvent = useCreateCalendarEvent() + const showToast = useToastStore(s => s.showToast) + + const [open, setOpen] = useState(false) + const [date, setDate] = useState(reminder.dueDate.slice(0, 10)) + const [time, setTime] = useState(DEFAULT_TIME) + const [title, setTitle] = useState(() => defaultCalendarTitle(reminder)) + + const attachment = useMemo( + () => buildCalendarAttachment(reminder, property), + [reminder, property], + ) + + function handleSubmit() { + createEvent.mutate( + { + title: title.trim(), + startsAt: new Date(`${date}T${time}:00`).toISOString(), + durationMinutes: DEFAULT_DURATION_MINUTES, + attachments: [attachment], + propertyId: reminder.propertyId, + reminderId: reminder.id, + }, + { + onSuccess: () => { + setOpen(false) + showToast(`Termin im ${status?.name ?? 'Kalender'} eingeplant.`, 'success') + }, + }, + ) + } + + if (!status?.connected) { + return ( + + Für die Terminplanung muss unter «Meine Agenten → Kanäle & Systeme» ein Kalender verbunden sein. + + ) + } + + return ( + + + Bearbeitung im Kalender einplanen + + + {events.length > 0 && ( + + {events.map(e => ( + + Eingeplant: {new Date(e.startsAt).toLocaleString('de-CH', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + })} — {e.title} + + ))} + + )} + + {!open ? ( + + ) : ( + + + setDate(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ flex: 1 }} + /> + setTime(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + sx={{ width: 130 }} + /> + + + setTitle(e.target.value)} + sx={{ '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> + + {/* Anhang — automatisch, aber sichtbar, damit klar ist, was mitgeht. */} + + + + + + + {attachment.fileName} + + + + {attachment.description} + + + + + + + + + + )} + + ) +} diff --git a/src/components/supply/ReminderCard.tsx b/src/components/supply/ReminderCard.tsx index 25adbb6..f693395 100644 --- a/src/components/supply/ReminderCard.tsx +++ b/src/components/supply/ReminderCard.tsx @@ -1,31 +1,25 @@ -import { Card, CardContent, Box, Typography, Chip, IconButton, Tooltip, Divider } from '@mui/material' +import { Card, CardContent, Box, Typography, IconButton, Tooltip, Divider } from '@mui/material' import { Check, Bell, X, MapPin, Calendar } from 'lucide-react' import type { Reminder } from '../../domain/reminder' -import { ReminderPriorityBadge } from './ReminderPriorityBadge' +import { ObjectDeepLink } from '../team' import { ReminderTypeBadge } from './ReminderTypeBadge' import { ReminderDaysIndicator } from './ReminderDaysIndicator' +import { isReminderUrgent } from './reminderDueDate' +import { ReminderStatusBadge } from './ReminderStatusBadge' import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' import { useReminderStore } from '../../stores/reminderStore' import { ReminderStatus } from '../../domain/reminder' - -const STATUS_CHIP_COLOR: Record = { - ACTIVE: 'info', - SNOOZED: 'warning', - COMPLETED: 'success', - DISMISSED: 'default', -} - -const STATUS_LABEL: Record = { - ACTIVE: 'Aktiv', - SNOOZED: 'Schlummernd', - COMPLETED: 'Erledigt', - DISMISSED: 'Verworfen', -} +import { DS_TEXT } from '../../lib/ds' interface Props { reminder: Reminder } +/** + * Kartenansicht — trägt dieselben Änderungen wie die Liste (Runde 4, §5.4): + * keine Priorität, neutrale Typfarben, dieselbe Fälligkeitsregel, Objektlink, + * und kein «Aktiv»-Etikett mehr. + */ export function ReminderCard({ reminder }: Props) { const { setSelectedId, setDrawerOpen } = useReminderStore() const complete = useCompleteReminder() @@ -33,6 +27,7 @@ export function ReminderCard({ reminder }: Props) { const snooze = useSnoozeReminder() const isActionable = reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED + const urgent = isReminderUrgent(reminder.dueDate) return ( {/* Top row */} - - - - - - + + + {/* Property */} - - {reminder.propertyTitle} - + e.stopPropagation()}> + + @@ -77,8 +69,11 @@ export function ReminderCard({ reminder }: Props) { {/* Due date */} - - + + Fällig: {new Date(reminder.dueDate).toLocaleDateString('de-CH')} @@ -93,7 +88,7 @@ export function ReminderCard({ reminder }: Props) { complete.mutate({ id: reminder.id })} - sx={{ color: '#16a34a', border: '1px solid #dcfce7', borderRadius: 1 }} + sx={{ color: DS_TEXT.secondary, border: '1px solid #e8e7e4', borderRadius: 1 }} > @@ -102,7 +97,7 @@ export function ReminderCard({ reminder }: Props) { snooze.mutate({ id: reminder.id, until: '2026-05-27' })} - sx={{ color: '#ca8a04', border: '1px solid #fef9c3', borderRadius: 1 }} + sx={{ color: DS_TEXT.secondary, border: '1px solid #e8e7e4', borderRadius: 1 }} > @@ -111,7 +106,7 @@ export function ReminderCard({ reminder }: Props) { dismiss.mutate({ id: reminder.id })} - sx={{ color: '#dc2626', border: '1px solid #fee2e2', borderRadius: 1 }} + sx={{ color: DS_TEXT.secondary, border: '1px solid #e8e7e4', borderRadius: 1 }} > diff --git a/src/components/supply/ReminderDaysIndicator.tsx b/src/components/supply/ReminderDaysIndicator.tsx index b05a6c5..ad2fee4 100644 --- a/src/components/supply/ReminderDaysIndicator.tsx +++ b/src/components/supply/ReminderDaysIndicator.tsx @@ -1,38 +1,34 @@ import { Typography } from '@mui/material' - -const MOCK_TODAY = new Date('2026-05-20') - -function getDays(isoDate: string): number { - const due = new Date(isoDate) - return Math.ceil((due.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24)) -} - -function getColor(days: number): string { - if (days <= 0) return '#dc2626' - if (days <= 14) return '#dc2626' - if (days <= 30) return '#ea580c' - if (days <= 60) return '#ca8a04' - return '#16a34a' -} +import { reminderDaysUntil, REMINDER_URGENT_DAYS } from './reminderDueDate' +import { DS_TEXT } from '../../lib/ds' interface Props { dueDate: string } +/** + * Fälligkeit in Tagen. + * + * Die frühere fünfstufige Ampel (rot/orange/gelb/grün) ist entfallen: sie hat + * jeder Zeile eine Dringlichkeit angeheftet, die aus der Zahl selbst schon + * hervorgeht. Es bleibt eine Schwelle — fünf Tage oder weniger sind rot, alles + * andere steht in der Standard-Schriftfarbe (Runde 4, §5.2). + */ export function ReminderDaysIndicator({ dueDate }: Props) { - const days = getDays(dueDate) - const color = getColor(days) + const days = reminderDaysUntil(dueDate) + const urgent = days <= REMINDER_URGENT_DAYS + const color = urgent ? DS_TEXT.error : DS_TEXT.secondary if (days <= 0) { return ( - + Überfällig ) } return ( - + in {days} {days === 1 ? 'Tag' : 'Tagen'} ) diff --git a/src/components/supply/ReminderDetailDrawer.tsx b/src/components/supply/ReminderDetailDrawer.tsx index cc26178..cf87864 100644 --- a/src/components/supply/ReminderDetailDrawer.tsx +++ b/src/components/supply/ReminderDetailDrawer.tsx @@ -1,39 +1,24 @@ import { useState } from 'react' import { - Drawer, Box, Typography, IconButton, Chip, Divider, - TextField, Button, Select, MenuItem, FormControl, InputLabel, Autocomplete, + Drawer, Box, Typography, IconButton, Divider, TextField, Button, } from '@mui/material' -import { X, Calendar, Activity, FileText, ExternalLink, Eye } from 'lucide-react' +import { X, Calendar } from 'lucide-react' import { useReminderStore } from '../../stores/reminderStore' import { - useReminder, useCompleteReminder, useDismissReminder, - useSnoozeReminder, useCreateReminder, + useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder, } from '../../hooks/useReminders' -import { usePropertyById, useProperties } from '../../hooks/useProperties' -import { ReminderPriorityBadge } from './ReminderPriorityBadge' +import { usePropertyById } from '../../hooks/useProperties' +import { ObjectDeepLink } from '../team' import { ReminderTypeBadge } from './ReminderTypeBadge' import { ReminderDaysIndicator } from './ReminderDaysIndicator' -import { ReminderStatus, ReminderType, ReminderPriority } from '../../domain/reminder' -import type { Property } from '../../domain/property' -import { - SHADOW_RISK_COLOR, SHADOW_RISK_LABEL, - STATUS_CHIP_COLOR, STATUS_LABEL, - SectionTitle, DateRow, ActivityEntry, -} from './reminderDetailHelpers' +import { ReminderStatusBadge } from './ReminderStatusBadge' +import { ReminderCalendarPlanner } from './ReminderCalendarPlanner' +import { ReminderStatus, ReminderType } from '../../domain/reminder' +import { SectionTitle, DateRow } from './reminderDetailHelpers' +import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' // ── Constants ──────────────────────────────────────────────────────────────── -const TYPE_LABELS: Record = { - LEASE_EXPIRY: 'Mietablauf', - BREAK_OPTION: 'Break-Option', - RENT_REVIEW: 'Mietanpassung', - INSPECTION: 'Inspektion', - INSURANCE_RENEWAL: 'Versicherung', - MAINTENANCE: 'Unterhalt', - SCHATTENMARKT_RELEASE: 'Pre-Market', - CUSTOM: 'Individuell', -} - const TYPE_SECOND_DATE: Partial> = { [ReminderType.LEASE_EXPIRY]: 'contractEndDate', [ReminderType.BREAK_OPTION]: 'breakOptionDate', @@ -48,139 +33,18 @@ const TYPE_SECOND_LABEL: Partial> = { [ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum', } -const MOCK_TODAY = new Date('2026-05-20') - -function calcPriority(dueDateStr: string): ReminderPriority { - const days = Math.ceil((new Date(dueDateStr).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24)) - if (days <= 14) return ReminderPriority.URGENT - if (days <= 30) return ReminderPriority.HIGH - if (days <= 60) return ReminderPriority.MEDIUM - return ReminderPriority.LOW -} - -// ── Create Form ─────────────────────────────────────────────────────────────── - -function CreateForm({ onClose }: { onClose: () => void }) { - const { data: properties = [] } = useProperties() - const create = useCreateReminder() - - const [type, setType] = useState(ReminderType.LEASE_EXPIRY) - const [selectedProperty, setSelectedProperty] = useState(null) - const [tenantName, setTenantName] = useState('') - const [dueDate, setDueDate] = useState('') - const [note, setNote] = useState('') - - function handlePropertyChange(_: unknown, prop: Property | null) { - setSelectedProperty(prop) - setTenantName(prop?.currentTenant ?? '') - } - - function handleSubmit() { - if (!selectedProperty || !dueDate) return - create.mutate( - { - type, - priority: calcPriority(dueDate), - status: ReminderStatus.ACTIVE, - propertyId: selectedProperty.id, - propertyTitle: selectedProperty.title, - propertyCity: selectedProperty.location.city, - propertyDistrict: selectedProperty.location.district, - tenantName: tenantName || '—', - dueDate, - eventDate: dueDate, - areaSqm: selectedProperty.areaSqm, - currentRentPerSqm: selectedProperty.rentPricePerSqm, - currency: 'CHF', - shadowMarketRisk: 'NONE', - schattenmarktEnabled: false, - note: note || undefined, - organizationId: selectedProperty.organizationId ?? 'org-1', - }, - { onSuccess: onClose }, - ) - } - - const canSubmit = !!selectedProperty && !!dueDate && !create.isPending - - return ( - - {/* Typ */} - - Typ - - - - {/* Objekt */} - p.title} - value={selectedProperty} - onChange={handlePropertyChange} - size="small" - renderInput={params => ( - - )} - /> - - {/* Mieter — nur wenn Objekt ausgewählt */} - {selectedProperty && ( - setTenantName(e.target.value)} - placeholder="Mietername eingeben…" - /> - )} - - {/* Fälligkeitsdatum */} - setDueDate(e.target.value)} - slotProps={{ inputLabel: { shrink: true } }} - /> - - {/* Notiz */} - setNote(e.target.value)} - placeholder="Kontext oder Hinweise…" - /> - - - - ) -} - -// ── Main Drawer ─────────────────────────────────────────────────────────────── +// ── Drawer ─────────────────────────────────────────────────────────────────── +/** + * Reminder-Detailansicht (Runde 4, §5.5 und §5.6). + * + * Entfallen sind: Priorität, das «Aktiv»-Etikett, die zusätzlichen Status- und + * Typfarben, der Verlauf am Ende, der Vertrags- und Pre-Market-Hinweis unter + * dem Objekt sowie der Notizbereich. Das Anlegen neuer Reminder ist mit dem + * Knopf «Reminder erstellen» weggefallen — der Drawer zeigt nur noch Bestehendes. + * + * Neu an der Stelle des Notizbereichs: die Terminplanung im verbundenen Kalender. + */ export function ReminderDetailDrawer() { const { selectedId, drawerOpen, setDrawerOpen, setSelectedId } = useReminderStore() const { data: reminder } = useReminder(selectedId ?? '') @@ -189,7 +53,6 @@ export function ReminderDetailDrawer() { const dismiss = useDismissReminder() const snooze = useSnoozeReminder() - const [noteValue, setNoteValue] = useState('') const [snoozeDate, setSnoozeDate] = useState('') function handleClose() { @@ -197,17 +60,11 @@ export function ReminderDetailDrawer() { setSelectedId(null) } - const isCreateMode = !selectedId - const secondDateKey = reminder ? TYPE_SECOND_DATE[reminder.type] : undefined const secondDateLabel = reminder ? TYPE_SECOND_LABEL[reminder.type] : undefined const secondDateValue = secondDateKey && reminder ? reminder[secondDateKey] : undefined const showSecondDate = secondDateValue && reminder && secondDateValue !== reminder.dueDate - const showPreMarket = reminder && - reminder.shadowMarketRisk !== 'NONE' && - reminder.shadowMarketRisk !== 'LOW' - const isActionable = reminder && (reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED) @@ -222,27 +79,17 @@ export function ReminderDetailDrawer() { - {isCreateMode ? ( - - Neuer Reminder - - ) : reminder ? ( + {reminder && ( <> - + - - + - ) : null} + )} - + @@ -250,60 +97,20 @@ export function ReminderDetailDrawer() { {/* ── Body ── */} - - {/* Create mode */} - {isCreateMode && } - - {/* Detail mode */} {reminder && ( - {/* 1. Objekt */} + {/* 1. Objekt — Name führt nach «Meine Objekte» */} - - {reminder.propertyTitle} - - + + {reminder.propertyCity}{reminder.propertyDistrict ? ` · ${reminder.propertyDistrict}` : ''} · {reminder.tenantName} - - - {reminderProperty?.leaseContractUrl ? ( - - - - - ) : ( - - - Kein Vertrag - - )} - - {showPreMarket && ( - - - - Pre-Mkt: {SHADOW_RISK_LABEL[reminder.shadowMarketRisk]} - - - )} - @@ -324,45 +131,31 @@ export function ReminderDetailDrawer() { - {/* 3. Notiz */} - - - Notiz - - setNoteValue(e.target.value)} - placeholder="Notiz hinzufügen…" - sx={{ '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} - /> + {/* 3. Terminplanung — steht, wo bis Runde 3 der Notizbereich war */} + + {/* 4. Aktionen */} {isActionable && ( <> - - + + Aktionen @@ -379,7 +172,7 @@ export function ReminderDetailDrawer() { variant="outlined" size="small" disabled={!snoozeDate} - sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: '#64748b', borderColor: '#e2e8f0' }} + sx={{ textTransform: 'none', whiteSpace: 'nowrap', color: DS_TEXT.secondary, borderColor: DS_SLATE[200] }} onClick={() => snoozeDate && snooze.mutate({ id: reminder.id, until: snoozeDate })} > Schlummern bis @@ -388,18 +181,6 @@ export function ReminderDetailDrawer() { )} - - - - {/* 5. Verlauf */} - - }>Verlauf - - {[...reminder.activity].reverse().map((entry, i) => ( - - ))} - - )} diff --git a/src/components/supply/ReminderEmptyState.tsx b/src/components/supply/ReminderEmptyState.tsx index ae88f66..eb9d0b0 100644 --- a/src/components/supply/ReminderEmptyState.tsx +++ b/src/components/supply/ReminderEmptyState.tsx @@ -1,5 +1,6 @@ import { Box, Typography, Button } from '@mui/material' import { BellOff } from 'lucide-react' +import { DS_SLATE } from '../../lib/ds' interface Props { onReset?: () => void @@ -22,7 +23,7 @@ export function ReminderEmptyState({ onReset }: Props) { width: 64, height: 64, borderRadius: '50%', - bgcolor: '#f1f5f9', + bgcolor: DS_SLATE[100], display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -31,7 +32,7 @@ export function ReminderEmptyState({ onReset }: Props) { - + Keine Reminder diff --git a/src/components/supply/ReminderFeed.tsx b/src/components/supply/ReminderFeed.tsx index b3d9a1b..2f70781 100644 --- a/src/components/supply/ReminderFeed.tsx +++ b/src/components/supply/ReminderFeed.tsx @@ -3,15 +3,20 @@ import { Box, Chip, Typography } from '@mui/material' import { useReminders } from '../../hooks/useReminders' import { useShallow } from 'zustand/react/shallow' import { useReminderStore } from '../../stores/reminderStore' -import { ReminderListRow } from './ReminderListRow' +import { ReminderListRow, REMINDER_LIST_COLS } from './ReminderListRow' import { ReminderCard } from './ReminderCard' import { ReminderSkeleton } from './ReminderSkeleton' import { ReminderEmptyState } from './ReminderEmptyState' import type { Reminder } from '../../domain/reminder' import { ReminderStatus } from '../../domain/reminder' import type { FilterHorizon } from '../../stores/reminderStore' +import { DS_BG, DS_SLATE, DS_TEXT } from '../../lib/ds' +import { mockToday } from '../../lib/constants' -const MOCK_TODAY = new Date('2026-05-20') +/** Stabiler Leerwert: `?? []` erzeugte bei jedem Rendern ein neues Array. */ +const EMPTY_REMINDERS: Reminder[] = [] + +const MOCK_TODAY = mockToday() function getHorizon(dueDate: string): FilterHorizon { const days = Math.ceil((new Date(dueDate).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24)) @@ -21,15 +26,18 @@ function getHorizon(dueDate: string): FilterHorizon { return 'LATER' } -const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: string; border: string }[] = [ - { key: 'OVERDUE', label: 'Überfällig', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' }, - { key: 'THIS_WEEK', label: 'Diese Woche', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' }, - { key: 'THIS_MONTH', label: 'Dieser Monat', color: '#0369a1', bg: '#f0f9ff', border: '#bae6fd' }, - { key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e8e7e4' }, +/** + * Abschnittstrenner. Titel und Anzahl-Badge stehen alle in derselben neutralen + * Standardfarbe (Runde 4, §5.2): die Gliederung ist eine Zeitachse, keine + * Ampel — «Später» ist nicht «gut» und «Diese Woche» nicht «mittelschlimm». + */ +const HORIZON_CONFIG: { key: FilterHorizon; label: string }[] = [ + { key: 'OVERDUE', label: 'Überfällig' }, + { key: 'THIS_WEEK', label: 'Diese Woche' }, + { key: 'THIS_MONTH', label: 'Dieser Monat' }, + { key: 'LATER', label: 'Später' }, ] -const LIST_COLS = '90px 130px 1fr 140px 80px 90px' - function applyFilters( reminders: Reminder[], filterType: string, @@ -67,7 +75,8 @@ export function ReminderFeed() { setSearchQuery: s.setSearchQuery, }))) - const reminders = data ?? [] + // Stabiler Leerwert — siehe EMPTY_REMINDERS. + const reminders = data ?? EMPTY_REMINDERS const filtered = useMemo( () => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery), @@ -94,21 +103,20 @@ export function ReminderFeed() { if (viewMode === 'card') { return ( - - {HORIZON_CONFIG.map(({ key, label, color }) => { + + {HORIZON_CONFIG.map(({ key, label }) => { const group = grouped[key] if (!group || group.length === 0) return null return ( - - + {label} - {['Priorität', 'Typ', 'Objekt / Mieter', 'Fälligkeit', 'Status', 'Aktionen'].map(h => ( - + {['Typ', 'Objekt / Mieter', 'Fälligkeit', 'Status', 'Aktionen'].map(h => ( + {h} ))} - {/* Grouped rows */} - {HORIZON_CONFIG.map(({ key, label, color, bg, border }) => { + {/* Grouped rows — der Abstand vor jedem Abschnitt trennt die Zeitblöcke + deutlicher als eine Farbe es könnte (Runde 4, §5.2). */} + {HORIZON_CONFIG.map(({ key, label }, index) => { const group = grouped[key] if (!group || group.length === 0) return null return ( - {/* Section divider */} {label} diff --git a/src/components/supply/ReminderFilterBar.tsx b/src/components/supply/ReminderFilterBar.tsx index ffda803..298a222 100644 --- a/src/components/supply/ReminderFilterBar.tsx +++ b/src/components/supply/ReminderFilterBar.tsx @@ -3,19 +3,9 @@ import { Search } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' import { useReminderStore } from '../../stores/reminderStore' import { ReminderType } from '../../domain/reminder' +import { REMINDER_TYPE_LABELS } from '../../lib/constants' import type { ReminderType as ReminderTypeType } from '../../domain/reminder' -const TYPE_LABELS: Record = { - LEASE_EXPIRY: 'Mietablauf', - BREAK_OPTION: 'Break-Option', - RENT_REVIEW: 'Mietanpassung', - INSPECTION: 'Inspektion', - INSURANCE_RENEWAL: 'Versicherung', - MAINTENANCE: 'Unterhalt', - SCHATTENMARKT_RELEASE: 'Pre-Market', - CUSTOM: 'Individuell', -} - export function ReminderFilterBar() { const { filterType, setFilterType, @@ -63,7 +53,7 @@ export function ReminderFilterBar() { Alle Typen {Object.values(ReminderType).map(t => ( - {TYPE_LABELS[t]} + {REMINDER_TYPE_LABELS[t]} ))} diff --git a/src/components/supply/ReminderHeader.tsx b/src/components/supply/ReminderHeader.tsx deleted file mode 100644 index 8cc6385..0000000 --- a/src/components/supply/ReminderHeader.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Box, Typography, Button } from '@mui/material' -import { Plus } from 'lucide-react' -import { useReminderStore } from '../../stores/reminderStore' - -export function ReminderHeader() { - const { setDrawerOpen, setSelectedId } = useReminderStore() - - function handleCreate() { - setSelectedId(null) - setDrawerOpen(true) - } - - return ( - - - - Reminder Manager - - - Fristen, Vertragsereignisse und Aufgaben für Ihr Portfolio - - - - - ) -} diff --git a/src/components/supply/ReminderKpiBar.tsx b/src/components/supply/ReminderKpiBar.tsx deleted file mode 100644 index cf7b186..0000000 --- a/src/components/supply/ReminderKpiBar.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { Box, Typography, Skeleton } from '@mui/material' -import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react' -import { useShallow } from 'zustand/react/shallow' -import { useReminderInsights } from '../../hooks/useReminders' -import { useReminderStore } from '../../stores/reminderStore' -import type { FilterHorizon } from '../../stores/reminderStore' -import { ReminderType } from '../../domain/reminder' - -interface SecondaryKpiProps { - icon: React.ReactNode - label: string - value: number | string - color: string - active: boolean - onClick: () => void -} - -function SecondaryKpi({ icon, label, value, color, active, onClick }: SecondaryKpiProps) { - return ( - - - {icon} - - {value} - - - - {label} - - - ) -} - -export function ReminderKpiBar() { - const { data, isLoading } = useReminderInsights() - const { filterHorizon, setFilterHorizon, filterType, setFilterType } = useReminderStore( - useShallow(s => ({ - filterHorizon: s.filterHorizon, - setFilterHorizon: s.setFilterHorizon, - filterType: s.filterType, - setFilterType: s.setFilterType, - })) - ) - - function toggleHorizon(h: FilterHorizon) { - if (filterHorizon === h) setFilterHorizon('ALL') - else { setFilterHorizon(h); setFilterType('ALL') } - } - - function togglePreMarket() { - if (filterType === ReminderType.SCHATTENMARKT_RELEASE) setFilterType('ALL') - else { setFilterType(ReminderType.SCHATTENMARKT_RELEASE); setFilterHorizon('ALL') } - } - - if (isLoading) { - return ( - - - - {[1, 2, 3].map(i => )} - - - ) - } - - const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 } - const isOverdue = ins.overdueCount > 0 - const overdueActive = filterHorizon === 'OVERDUE' - - return ( - - {/* Focal card — Überfällig */} - toggleHorizon('OVERDUE')} - sx={{ - flex: '0 0 200px', - px: 2.5, - py: 2, - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - gap: 0.5, - borderRadius: 1.5, - cursor: 'pointer', - border: overdueActive - ? '1.5px solid #dc2626' - : isOverdue - ? '1.5px solid #fca5a5' - : '1px solid #e8e7e4', - bgcolor: overdueActive - ? '#fef2f2' - : isOverdue - ? '#fff5f5' - : 'white', - transition: 'border-color 0.15s, background 0.15s', - '&:hover': { borderColor: '#dc2626', bgcolor: '#fef2f2' }, - }} - > - - - - {ins.overdueCount} - - - - Überfällig - - - - {/* Secondary KPIs */} - - } - label="Diese Woche" - value={ins.dueThisWeek} - color="#ea580c" - active={filterHorizon === 'THIS_WEEK'} - onClick={() => toggleHorizon('THIS_WEEK')} - /> - } - label="Dieser Monat" - value={ins.dueThisMonth} - color="#0369a1" - active={filterHorizon === 'THIS_MONTH'} - onClick={() => toggleHorizon('THIS_MONTH')} - /> - } - label="Pre-Market Risiko" - value={ins.schattenmarktReadyCount} - color="#be185d" - active={filterType === ReminderType.SCHATTENMARKT_RELEASE} - onClick={togglePreMarket} - /> - - - ) -} diff --git a/src/components/supply/ReminderListRow.tsx b/src/components/supply/ReminderListRow.tsx index 57f7e5f..1d72cd8 100644 --- a/src/components/supply/ReminderListRow.tsx +++ b/src/components/supply/ReminderListRow.tsx @@ -1,34 +1,19 @@ import { memo } from 'react' -import { Box, Typography, Chip, IconButton, Tooltip } from '@mui/material' +import { Box, Typography, IconButton, Tooltip } from '@mui/material' import { Check, Bell, X } from 'lucide-react' import type { Reminder } from '../../domain/reminder' -import { ReminderPriorityBadge } from './ReminderPriorityBadge' +import { ObjectDeepLink } from '../team' import { ReminderTypeBadge } from './ReminderTypeBadge' import { ReminderDaysIndicator } from './ReminderDaysIndicator' +import { isReminderUrgent } from './reminderDueDate' +import { ReminderStatusBadge } from './ReminderStatusBadge' import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' import { useReminderStore } from '../../stores/reminderStore' -import { ReminderStatus, ReminderPriority } from '../../domain/reminder' +import { ReminderStatus } from '../../domain/reminder' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' -const PRIORITY_BORDER: Record = { - [ReminderPriority.URGENT]: '#dc2626', - [ReminderPriority.HIGH]: '#ea580c', - [ReminderPriority.MEDIUM]: '#ca8a04', - [ReminderPriority.LOW]: 'transparent', -} - -const STATUS_LABEL: Record = { - ACTIVE: 'Aktiv', - SNOOZED: 'Schlummernd', - COMPLETED: 'Erledigt', - DISMISSED: 'Verworfen', -} - -const STATUS_STYLE: Record = { - ACTIVE: { bg: '#f0fdf4', color: '#15803d', border: '#bbf7d0' }, - SNOOZED: { bg: '#fefce8', color: '#92400e', border: '#fde68a' }, - COMPLETED: { bg: '#f8fafc', color: '#475569', border: '#e2e8f0' }, - DISMISSED: { bg: '#f8fafc', color: '#94a3b8', border: '#e8e7e4' }, -} +/** Ohne Prioritätsspalte — die Priorität ist mit Runde 4 entfallen (§5.2). */ +export const REMINDER_LIST_COLS = '130px 1fr 140px 110px 90px' interface Props { reminder: Reminder @@ -41,6 +26,7 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props const snooze = useSnoozeReminder() const isActionable = reminder.status === ReminderStatus.ACTIVE || reminder.status === ReminderStatus.SNOOZED + const urgent = isReminderUrgent(reminder.dueDate) function handleRowClick() { setSelectedId(reminder.id) @@ -63,89 +49,73 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props snooze.mutate({ id: reminder.id, until: '2026-05-27' }) } - const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent' - return ( - {/* Priority dot */} - - - - - {/* Type badge */} + {/* Typ */} - {/* Property + tenant */} + {/* Objekt + Mieter — der Objektname führt nach «Meine Objekte» */} - - {reminder.propertyTitle} - + {reminder.propertyCity} · {reminder.tenantName} - {/* Due date */} + {/* Fälligkeit */} - + {new Date(reminder.dueDate).toLocaleDateString('de-CH')} - {/* Status — only shown for non-default states */} + {/* Status */} - {reminder.status !== ReminderStatus.ACTIVE && ( - - )} + - {/* Actions */} + {/* Aktionen */} e.stopPropagation()}> {isActionable && ( <> - + - + - + diff --git a/src/components/supply/ReminderPriorityBadge.tsx b/src/components/supply/ReminderPriorityBadge.tsx deleted file mode 100644 index 3e9576e..0000000 --- a/src/components/supply/ReminderPriorityBadge.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Box, Typography } from '@mui/material' -import type { ReminderPriority } from '../../domain/reminder' - -const CONFIG: Record = { - URGENT: { color: '#dc2626', label: 'Dringend' }, - HIGH: { color: '#ea580c', label: 'Hoch' }, - MEDIUM: { color: '#ca8a04', label: 'Mittel' }, - LOW: { color: '#94a3b8', label: 'Niedrig' }, -} - -interface Props { - priority: ReminderPriority -} - -export function ReminderPriorityBadge({ priority }: Props) { - const { color, label } = CONFIG[priority] - return ( - - - - {label} - - - ) -} diff --git a/src/components/supply/ReminderStatusBadge.tsx b/src/components/supply/ReminderStatusBadge.tsx new file mode 100644 index 0000000..2a44a46 --- /dev/null +++ b/src/components/supply/ReminderStatusBadge.tsx @@ -0,0 +1,45 @@ +import { Chip, Typography } from '@mui/material' +import { ReminderStatus } from '../../domain/reminder' +import type { ReminderStatus as ReminderStatusType } from '../../domain/reminder' +import { REMINDER_STATUS_LABELS } from '../../lib/constants' +import { DS_BG, DS_TEXT } from '../../lib/ds' + +interface Props { + status: ReminderStatusType +} + +/** + * Einheitliche Statusdarstellung in Liste, Karte und Detailansicht (Runde 4, §5.3). + * + * «Aktiv» wird nicht ausgezeichnet — es ist der Normalfall, und ein Etikett an + * jeder zweiten Zeile trägt keine Information. Stattdessen steht dort ein + * ausgegrauter Strich, damit die Spalte nicht leer wirkt. + * + * «Schlummernd» und «Verworfen» teilen sich bewusst dieselbe ausgegraute + * Darstellung: beide heissen «hier passiert gerade nichts». Das Etikett trägt + * kein `height`, sonst schnitte «Schlummernd» ab. + */ +export function ReminderStatusBadge({ status }: Props) { + if (status === ReminderStatus.ACTIVE) { + return ( + + – + + ) + } + + return ( + + ) +} diff --git a/src/components/supply/ReminderTypeBadge.tsx b/src/components/supply/ReminderTypeBadge.tsx index 3d38760..69b0646 100644 --- a/src/components/supply/ReminderTypeBadge.tsx +++ b/src/components/supply/ReminderTypeBadge.tsx @@ -11,16 +11,23 @@ import { } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import type { ReminderType } from '../../domain/reminder' +import { DS_TEXT } from '../../lib/ds' -const CONFIG: Record = { - LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf', color: '#152642' }, - BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option', color: '#7c3aed' }, - RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung', color: '#0369a1' }, - INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion', color: '#065f46' }, - INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung', color: '#92400e' }, - MAINTENANCE: { Icon: Wrench, label: 'Unterhalt', color: '#374151' }, - SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Pre-Market', color: '#be185d' }, - CUSTOM: { Icon: Tag, label: 'Individuell', color: '#64748b' }, +/** + * Typ und Symbol bleiben erhalten, die Typfarben sind entfallen (Runde 4, §5.2). + * Acht Farben nebeneinander behaupteten eine Rangfolge, die es nicht gibt — ein + * Unterhaltstermin ist nicht «grüner» als ein Mietablauf. Alle Typen stehen + * deshalb in derselben Standard-Schriftfarbe; unterschieden wird über das Symbol. + */ +const CONFIG: Record = { + LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf' }, + BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option' }, + RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung' }, + INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion' }, + INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung' }, + MAINTENANCE: { Icon: Wrench, label: 'Unterhalt' }, + SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Pre-Market' }, + CUSTOM: { Icon: Tag, label: 'Individuell' }, } interface Props { @@ -29,12 +36,15 @@ interface Props { } export function ReminderTypeBadge({ type, compact = false }: Props) { - const { Icon, label, color } = CONFIG[type] + const { Icon, label } = CONFIG[type] return ( - + {!compact && ( - + {label} )} diff --git a/src/components/supply/SignalCard.tsx b/src/components/supply/SignalCard.tsx index 62ad3d3..4ba3200 100644 --- a/src/components/supply/SignalCard.tsx +++ b/src/components/supply/SignalCard.tsx @@ -1,6 +1,7 @@ import { Box, Chip, LinearProgress, Typography } from '@mui/material' import { FileText } from 'lucide-react' import type { PropertyMarketSignal } from '../../domain/marketReport' +import { DS_ACCENT, DS_BG, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' export function SignalCard({ signal }: { signal: PropertyMarketSignal }) { return ( @@ -14,27 +15,27 @@ export function SignalCard({ signal }: { signal: PropertyMarketSignal }) { }} > - + {signal.title} {signal.confidence != null && ( )} - + {signal.body} {signal.confidence != null && ( - KI-Konfidenz - {Math.round(signal.confidence * 100)}% + KI-Konfidenz + {Math.round(signal.confidence * 100)}% = 0.8 ? '#1a7a4a' : signal.confidence >= 0.6 ? '#d97706' : '#64748b' }, }} /> @@ -51,7 +52,7 @@ export function SignalCard({ signal }: { signal: PropertyMarketSignal }) { {signal.date && ( - + {new Date(signal.date).toLocaleDateString('de-CH')} )} diff --git a/src/components/supply/UnitFieldsEditor.tsx b/src/components/supply/UnitFieldsEditor.tsx index c7d87af..56a6ded 100644 --- a/src/components/supply/UnitFieldsEditor.tsx +++ b/src/components/supply/UnitFieldsEditor.tsx @@ -3,7 +3,7 @@ import { Box, Button, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typo import type { Property, PropertyUnit } from '../../domain/property' import { useUpdateUnit } from '../../hooks/useProperties' import { FIT_OUT_LABELS } from '../../lib/constants' -import { DS_BORDER, DS_TEXT } from '../../lib/ds' +import { DS_BORDER, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' const NEEDS_BUILD = new Set(['SHELL', 'BASIC']) @@ -55,7 +55,7 @@ export function UnitFieldsEditor({ property, unit, onClose }: Props) { } return ( - + {/* Konditionen */} - + ) diff --git a/src/components/supply/UnitStructurePanel.tsx b/src/components/supply/UnitStructurePanel.tsx index 2016d6f..ea3c3c8 100644 --- a/src/components/supply/UnitStructurePanel.tsx +++ b/src/components/supply/UnitStructurePanel.tsx @@ -6,10 +6,10 @@ import type { Property, PropertyUnit, UnitNeedMatch } from '../../domain/propert import type { Lease } from '../../domain/lease' import { useUnitMatchesMap, useUnitBundle, useBundleMatches } from '../../hooks/useUnitMatches' import { useUpdateUnit } from '../../hooks/useProperties' -import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds' +import { DS_ACCENT, DS_BORDER, DS_BRAND, DS_NEUTRAL, DS_PRE_MARKET, DS_SLATE, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { FIT_OUT_LABELS } from '../../lib/constants' import { resolveUnitFacts } from '../../lib/unitFacts' -import { floorLabel } from './PropertyDetailHelpers' +import { floorLabelFromLevel } from '../../lib/propertyLabels' import { UnitFieldsEditor } from './UnitFieldsEditor' function MatchPill({ m }: { m: UnitNeedMatch }) { @@ -35,7 +35,7 @@ function LeaseField({ label, value }: { label: string; value?: string }) { {label} - + {value} @@ -84,7 +84,8 @@ export function UnitStructurePanel({ p }: { p: Property }) { const toggleUnit = (id: string) => { setSelectedIds(prev => { const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) + if (next.has(id)) next.delete(id) + else next.add(id) return next }) } @@ -125,18 +126,18 @@ export function UnitStructurePanel({ p }: { p: Property }) { size="small" checked={isSelected} onChange={() => toggleUnit(u.id)} - sx={{ p: 0, color: DS_TEXT.disabled, '&.Mui-checked': { color: '#2563eb' } }} + sx={{ p: 0, color: DS_TEXT.disabled, '&.Mui-checked': { color: DS_ACCENT.blue.strong } }} /> )} {/* Floor — only on first unit of group */} - - {isFirstInGroup ? floorLabel(u) : ''} + + {isFirstInGroup ? floorLabelFromLevel(u.floorLevel) : ''} {/* Unit label */} - + {u.unitLabel ?? '–'} @@ -144,7 +145,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { {u.available ? ( <> - + {/* Teilbar toggle */} @@ -162,7 +163,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { setEditingFlexUnit(null) } }} - sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: '#1d4ed8' } }} + sx={{ '& .MuiSwitch-thumb': { width: 10, height: 10 }, '& .MuiSwitch-switchBase': { p: '4px' }, '& .Mui-checked + .MuiSwitch-track': { bgcolor: DS_ACCENT.blue.main } }} /> Teilbar @@ -194,7 +195,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { size="small" variant="contained" disabled={!flexDraft[u.id]} - sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: '#1d4ed8', '&:hover': { bgcolor: '#1e40af' } }} + sx={{ fontSize: '0.58rem', py: 0.25, px: 0.75, minWidth: 0, bgcolor: DS_ACCENT.blue.main, '&:hover': { bgcolor: DS_ACCENT.blue.dark } }} onClick={() => { updateUnit.mutate({ unitId: u.id, data: { isFlexible: true, minLettableSqm: flexDraft[u.id] } }) setEditingFlexUnit(null) @@ -231,7 +232,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { rentPricePerSqm: u.rentPricePerSqm ?? p.rentPricePerSqm, unitLabel: u.unitLabel, propertyId: p.id, - floor: floorLabel(u), + floor: floorLabelFromLevel(u.floorLevel), fitOut: p.hardFacts?.fitOut, fitOutByLandlord: p.hardFacts?.fitOutByLandlord, parking: p.hardFacts?.parking, @@ -249,7 +250,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { ) : ( - + {tenantName ?? 'Vermietet'} {leaseEnd && ( @@ -283,7 +284,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { p: 0.5, color: isExpanded ? '#2563eb' : '#64748b', borderRadius: 1, - '&:hover': { bgcolor: '#f1f5f9', color: '#2563eb' }, + '&:hover': { bgcolor: DS_SLATE[100], color: DS_ACCENT.blue.strong }, transition: 'color 0.15s, background 0.15s', }} > @@ -304,7 +305,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { {/* Area + Preis + Verfügbarkeit pro Einheit */} - + {(u.offeredSqm ?? u.areaSqm).toLocaleString('de-CH')} m² {u.isFlexible && u.minLettableSqm && ( @@ -378,7 +379,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { {/* Expanded: lease detail for occupied units */} {activeLease && ( - + @@ -393,7 +394,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { {activeLease.contractDocumentUrl && ( - + {activeLease.contractDocumentName ?? 'Mietvertrag'} @@ -420,7 +421,7 @@ export function UnitStructurePanel({ p }: { p: Property }) { <> - + Einheiten & Mietverträge {freeUnits.length >= 2 && ( @@ -467,16 +468,16 @@ export function UnitStructurePanel({ p }: { p: Property }) { > - + Kombination: {bundle.label} - + Diese Einheiten können gemeinsam oder separat vermietet werden. {selectedFreeUnits.some(u => u.isFlexible) && ' Flexible Teilflächen möglich — Restfläche bleibt nach Vertragsabschluss verfügbar.'} diff --git a/src/components/supply/index.ts b/src/components/supply/index.ts index 43ab247..25b778b 100644 --- a/src/components/supply/index.ts +++ b/src/components/supply/index.ts @@ -14,5 +14,6 @@ export { PropertyTable } from './PropertyTable' export { PropertyFilterBar } from './PropertyFilterBar' export type { PropertyTableFilters } from './PropertyFilterBar' export { PropertyDetailView } from './PropertyDetailView' +export { EditableObjectOverview } from './EditableObjectOverview' export { PropertyDetailSkeleton } from './PropertyDetailSkeleton' export { PropertyIntelligenceCard } from './PropertyIntelligenceCard' diff --git a/src/components/supply/negotiationInsightsUtils.ts b/src/components/supply/negotiationInsightsUtils.ts deleted file mode 100644 index c695a08..0000000 --- a/src/components/supply/negotiationInsightsUtils.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { getCityIntelligence } from '../../lib/locationIntelligence' -import type { Property } from '../../domain/property' - -// ── Selling argument generator ──────────────────────────────────────────────── - -export interface Argument { - title: string - detail: string - strength: 'strong' | 'medium' -} - -export function generateSellingArguments(p: Property): Argument[] { - const args: Argument[] = [] - const sf = p.softFactors - const intel = getCityIntelligence(p.location.city) - - if (sf) { - const footfall = sf.footfallScore ?? (sf.passerbyFrequency ? { LOW: 0.25, MEDIUM: 0.5, HIGH: 0.78, VERY_HIGH: 0.95 }[sf.passerbyFrequency] ?? 0 : 0) - if (footfall >= 0.72) - args.push({ title: 'Hervorragende Frequenzlage', detail: 'Überdurchschnittliche Passantenfrequenz — sichert Sichtbarkeit und Kundenzugang.', strength: 'strong' }) - - const taxScore = sf.taxEnvironmentScore ?? (intel ? Math.max(0, 1 - intel.taxIndexCanton / 150) : undefined) - if (taxScore !== undefined && taxScore >= 0.65) - args.push({ title: 'Steuerattraktiver Standort', detail: `${p.location.city} bietet eine günstige Steuerlast${intel ? ` (Index ${intel.taxIndexCanton}, CH = 100)` : ''} — relevant für Unternehmensansiedlungen.`, strength: 'strong' }) - - const ov = sf.commuterAccessScore - if (ov !== undefined && ov >= 0.72) - args.push({ title: 'Sehr gute ÖV-Anbindung', detail: sf.publicTransportMinutes ? `Ca. ${sf.publicTransportMinutes} Min. zum nächsten Bahnhof.` : 'Ausgezeichnete öffentliche Erreichbarkeit.', strength: 'strong' }) - - const prestige = sf.prestigeScore ?? (typeof sf.prestige === 'number' ? sf.prestige : undefined) - if (prestige !== undefined && prestige >= 0.7) - args.push({ title: 'Repräsentativer Standort', detail: 'Hoher Prestige-Wert — ideal für Unternehmen mit Repräsentationsanspruch und Aussenauftritt.', strength: 'strong' }) - - const talent = sf.talentAccessScore ?? (typeof sf.talentAccess === 'number' ? sf.talentAccess : undefined) - if (talent !== undefined && talent >= 0.65) - args.push({ title: 'Grosser Talentpool', detail: 'Zugang zu gut ausgebildeten Fachkräften im Einzugsgebiet — entscheidend für wachsende Unternehmen.', strength: 'medium' }) - - if (sf.flexibilityScore !== undefined && sf.flexibilityScore >= 0.65) - args.push({ title: 'Flexible Flächengestaltung', detail: 'Grundriss und Ausbaustandard ermöglichen individuelle Anpassungen.', strength: 'medium' }) - - if (sf.esgScore !== undefined && sf.esgScore >= 0.7) - args.push({ title: 'Nachhaltigkeitszertifizierung', detail: 'Guter ESG-Score — relevant für Unternehmen mit Nachhaltigkeitszielen und ESG-Reporting.', strength: 'medium' }) - } - - if (p.hardFacts?.isBarrierFree) - args.push({ title: 'Barrierefrei', detail: 'Vollständig rollstuhlgängig — gesetzlich zunehmend gefordert.', strength: 'medium' }) - - if (p.hardFacts?.parking && p.hardFacts.parking > 0) - args.push({ title: `${p.hardFacts.parking} Parkplätze inkl.`, detail: 'Eigene Parkierungsmöglichkeiten — in Städten ein knappes Gut.', strength: 'medium' }) - - if (p.hardFacts?.hasServerRoom) - args.push({ title: 'Serverraum vorhanden', detail: 'Sofortig nutzbare IT-Infrastruktur — spart Einrichtungskosten.', strength: 'medium' }) - - if (intel?.demandStrength === 'VERY_HIGH' || intel?.demandStrength === 'HIGH') - args.push({ title: 'Stark nachgefragter Markt', detail: `${p.location.city} verzeichnet ${intel.demandStrength === 'VERY_HIGH' ? 'sehr hohe' : 'hohe'} Nachfrage — kurze Leerstandszeiten zu erwarten.`, strength: 'strong' }) - - return args -} - -// ── Proactive weakness acknowledgement ─────────────────────────────────────── - -export interface Weakness { - issue: string - mitigation: string -} - -export function generateWeaknesses(p: Property): Weakness[] { - const ws: Weakness[] = [] - const sf = p.softFactors - const intel = getCityIntelligence(p.location.city) - - if (intel && intel.vacancyRatePct >= 5) - ws.push({ issue: 'Hohe Leerstandsquote in der Region', mitigation: 'Mietfreie Zeit oder Ausbaukostenbeteiligung als Anreiz anbieten.' }) - - if (intel && intel.taxIndexCanton >= 115) - ws.push({ issue: 'Überdurchschnittliche Steuerlast', mitigation: 'Andere Standortvorteile (Prestige, ÖV) gezielt hervorheben.' }) - - if (sf?.commuterAccessScore !== undefined && sf.commuterAccessScore < 0.45) - ws.push({ issue: 'Eingeschränkte ÖV-Anbindung', mitigation: 'Parkplatz-Angebot und Veloinfrastruktur als Alternative betonen.' }) - - if (p.hardFacts?.parking === 0 || (p.hardFacts?.parking === undefined && !sf?.parkingSpots)) - ws.push({ issue: 'Keine eigenen Parkplätze', mitigation: 'Öffentliche Parkhäuser in der Nähe aufzeigen. Ggf. Parkabonnement als Mietbonus anbieten.' }) - - if (p.dataQuality.score < 0.65) - ws.push({ issue: 'Unvollständige Objektdaten', mitigation: 'Fehlende Angaben vor dem Gespräch vervollständigen, um Vertrauen zu stärken.' }) - - return ws -} diff --git a/src/components/supply/reminderDetailHelpers.tsx b/src/components/supply/reminderDetailHelpers.tsx index 3d61258..85b462e 100644 --- a/src/components/supply/reminderDetailHelpers.tsx +++ b/src/components/supply/reminderDetailHelpers.tsx @@ -1,47 +1,19 @@ import { Box, Typography } from '@mui/material' -import type { ReminderActivity } from '../../domain/reminder' +import { DS_SLATE } from '../../lib/ds' -export const SHADOW_RISK_COLOR: Record = { - NONE: '#64748b', - LOW: '#16a34a', - MEDIUM: '#ca8a04', - HIGH: '#dc2626', -} - -export const SHADOW_RISK_LABEL: Record = { - NONE: 'Kein Risiko', - LOW: 'Niedrig', - MEDIUM: 'Mittel', - HIGH: 'Hoch', -} - -export const STATUS_CHIP_COLOR: Record = { - ACTIVE: 'info', - SNOOZED: 'warning', - COMPLETED: 'success', - DISMISSED: 'default', -} - -export const STATUS_LABEL: Record = { - ACTIVE: 'Aktiv', - SNOOZED: 'Schlummernd', - COMPLETED: 'Erledigt', - DISMISSED: 'Verworfen', -} - -export const ACTION_LABEL: Record = { - CREATED: 'Erstellt', - SNOOZED: 'Zurückgestellt', - COMPLETED: 'Erledigt', - DISMISSED: 'Verworfen', - NOTED: 'Notiz', -} +/** + * Bausteine der Reminder-Detailansicht. + * + * Die früheren Farbtabellen für Status, Schattenmarkt-Risiko und Verlaufs- + * aktionen sind mit Runde 4 entfallen — der Status läuft jetzt über + * `ReminderStatusBadge`, Risiko und Verlauf werden nicht mehr angezeigt (§5.5). + */ export function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { return ( {icon} - + {children} @@ -57,29 +29,3 @@ export function DateRow({ label, value }: { label: string; value?: string }) { ) } - -export function ActivityEntry({ entry }: { entry: ReminderActivity }) { - return ( - - - - - - - - - {ACTION_LABEL[entry.action]} - - - {entry.by} · {new Date(entry.at).toLocaleDateString('de-CH')} - - - {entry.note && ( - - {entry.note} - - )} - - - ) -} diff --git a/src/components/supply/reminderDueDate.ts b/src/components/supply/reminderDueDate.ts new file mode 100644 index 0000000..2b897a0 --- /dev/null +++ b/src/components/supply/reminderDueDate.ts @@ -0,0 +1,25 @@ +import { mockToday } from '../../lib/constants' +/** + * Fälligkeitsregel der Reminderliste (Runde 4, §5.2). + * + * Bewusst in einer eigenen Datei: Liste, Karte und Detailansicht färben die + * Fälligkeit selbst ein und brauchen deshalb dieselbe Schwelle wie die + * Anzeigekomponente. Läge sie neben der Komponente, teilte sich eine + * `.tsx`-Datei zwischen Bauteil und Hilfsfunktion — und Fast Refresh verlöre + * den Zustand bei jeder Änderung. + */ + +const MOCK_TODAY = mockToday() + +/** Ab hier wird die Fälligkeit rot — darunter bleibt sie Standard-Schriftfarbe. */ +export const REMINDER_URGENT_DAYS = 5 + +export function reminderDaysUntil(isoDate: string): number { + const due = new Date(isoDate) + return Math.ceil((due.getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24)) +} + +/** Genau eine Regel statt fünf Farbstufen. */ +export function isReminderUrgent(isoDate: string): boolean { + return reminderDaysUntil(isoDate) <= REMINDER_URGENT_DAYS +} diff --git a/src/components/tables/TableToolbar.tsx b/src/components/tables/TableToolbar.tsx index 8b86ac2..acd73b8 100644 --- a/src/components/tables/TableToolbar.tsx +++ b/src/components/tables/TableToolbar.tsx @@ -1,6 +1,7 @@ import { Box, Chip, Typography } from '@mui/material' import type { SxProps, Theme } from '@mui/material' import type { ReactNode } from 'react' +import { DS_BRAND } from '../../lib/ds' interface TableToolbarProps { title?: string @@ -36,7 +37,7 @@ export function TableToolbar({ title, count, filterSlot, actions, sx }: TableToo )} {filterSlot} diff --git a/src/components/team/AgentAvatarGroup.tsx b/src/components/team/AgentAvatarGroup.tsx index af5940e..1b0cd58 100644 --- a/src/components/team/AgentAvatarGroup.tsx +++ b/src/components/team/AgentAvatarGroup.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from 'react' import { Box, Tooltip, Typography } from '@mui/material' -import { agentDirectory } from '../../mock-data/agentDirectory' +import { useAgentDirectoryLookup } from '../../hooks/useAgentDirectory' import { AgentAvatar } from './AgentAvatar' import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' @@ -28,16 +28,16 @@ export const AgentAvatarGroup = memo(function AgentAvatarGroup({ size = 24, label, }: Props) { + const lookupAgents = useAgentDirectoryLookup() + const { visible, overflow, allNames } = useMemo(() => { - const entries = agentIds - .map(id => agentDirectory.find(a => a.id === id)) - .filter((a): a is NonNullable => !!a) + const entries = lookupAgents(agentIds) return { visible: entries.slice(0, MAX_VISIBLE), overflow: entries.slice(MAX_VISIBLE), allNames: entries.map(a => a.name).join(', '), } - }, [agentIds]) + }, [agentIds, lookupAgents]) if (visible.length === 0) { return ( diff --git a/src/components/team/AgentPreviewPopover.tsx b/src/components/team/AgentPreviewPopover.tsx deleted file mode 100644 index 7a58436..0000000 --- a/src/components/team/AgentPreviewPopover.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Box, Popover, Typography } from '@mui/material' -import type { AgentDirectoryEntry } from '../../domain/agentDirectory' -import { AgentAvatar } from './AgentAvatar' -import { GenericBadge } from '../shared/GenericBadge' -import { AGENT_LEVEL_LABELS, AGENT_ROLLOUT_LABELS } from '../../lib/constants' -import { DS_TEXT } from '../../lib/ds' - -interface Props { - agent: AgentDirectoryEntry | null - anchorEl: HTMLElement | null - onClose: () => void -} - -/** - * Kompakte Vorschau für Mitarbeitende ausserhalb des Kernteams. - * - * Bewusst kein Dossier: nur die sieben Kernteammitglieder besitzen ein - * gepflegtes Personalblatt. Für die übrigen 29 zeigt die Vorschau ausschliesslich - * das, was im Verzeichnis steht — Name, Funktion, Bereich, Aufbaustand. Es - * werden keine Aufgaben, Kanäle oder Tätigkeiten erfunden. - */ -export function AgentPreviewPopover({ agent, anchorEl, onClose }: Props) { - return ( - - {agent && ( - - - - - {agent.name} - - - {agent.role} - - - {AGENT_LEVEL_LABELS[agent.level] ?? agent.level} - - - - - - - - Für diese Stufe ist noch kein Personaldossier hinterlegt. - - - - )} - - ) -} diff --git a/src/components/team/AgentRing.tsx b/src/components/team/AgentRing.tsx deleted file mode 100644 index 8fb74c8..0000000 --- a/src/components/team/AgentRing.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { memo, useMemo } from 'react' -import { Box } from '@mui/material' -import type { AgentDirectoryEntry } from '../../domain/agentDirectory' -import type { ArcOptions, RingSpec } from './agentRingConfig' -import { RING_REFERENCE_PX, labelWidthFor, layoutArc } from './agentRingConfig' -import { AgentRingNode } from './AgentRingNode' -import { DS_BORDER } from '../../lib/ds' - -interface Props { - agents: AgentDirectoryEntry[] - spec: RingSpec - scale: number - arc?: ArcOptions - selectedId?: string | null - onSelect: (agent: AgentDirectoryEntry) => void - /** Führungslinie hinter den Portraits — die Referenzgrafik zeigt sie. */ - showGuide?: boolean - visible?: boolean -} - -/** - * Ein Ring der Kreisdarstellung. - * - * Rechnet selbst keine Positionen aus, sondern bezieht sie aus `layoutArc` — - * so teilen sich der volle Kreis der Teamübersicht und der Halbkreis des - * Organigramms dieselbe Geometrie. - */ -export const AgentRing = memo(function AgentRing({ - agents, - spec, - scale, - arc, - selectedId, - onSelect, - showGuide = true, - visible = true, -}: Props) { - const positions = useMemo( - () => layoutArc(agents.length, spec.radius, arc), - [agents.length, spec.radius, arc], - ) - - const labelWidth = useMemo( - () => labelWidthFor(agents.length, spec, scale * RING_REFERENCE_PX, arc), - [agents.length, spec, scale, arc], - ) - - return ( - - {showGuide && ( - - )} - - {agents.map((agent, index) => { - const position = positions[index] - if (!position) return null - return ( - - ) - })} - - ) -}) diff --git a/src/components/team/AgentRingNode.tsx b/src/components/team/AgentRingNode.tsx deleted file mode 100644 index c90a6c6..0000000 --- a/src/components/team/AgentRingNode.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { memo, useState } from 'react' -import { Box, Tooltip, Typography } from '@mui/material' -import type { AgentDirectoryEntry } from '../../domain/agentDirectory' -import type { RingSpec } from './agentRingConfig' -import { RING_REFERENCE_PX, avatarPxFor } from './agentRingConfig' -import { AgentAvatar } from './AgentAvatar' -import { BADGE_COLORS, DS_BORDER, DS_TEXT } from '../../lib/ds' - -interface Props { - agent: AgentDirectoryEntry - spec: RingSpec - /** Skalierungsfaktor gegenüber der Referenzbreite. */ - scale: number - /** Vom Ring vorgegeben, begrenzt durch den Bogenabstand zum Nachbarn. */ - labelWidth: number - leftPct: number - topPct: number - selected?: boolean - onSelect: (agent: AgentDirectoryEntry) => void -} - -/** - * Schriftgrössen als Anteil des Portraits statt der Containerbreite: nur so - * wachsen Bild und Beschriftung gemeinsam, wenn die Grafik bei wenigen Ringen - * vergrössert wird. Die Werte entsprechen dem bisherigen Verhältnis (11/76 und - * 9.5/76) und lassen die volle Darstellung unverändert. - */ -const NAME_FONT_RATIO = 11 / 76 -const ROLE_FONT_RATIO = 9.5 / 76 -const MIN_NAME_FONT_PX = 9.5 -const MIN_ROLE_FONT_PX = 8.5 - -/** - * Ein Portrait auf dem Ring. - * - * Die Funktionsbezeichnung steht dauerhaft nur beim Kernteam; auf den äusseren - * Ringen erscheint sie bei Hover oder Tastaturfokus. Alles dauerhaft zu - * beschriften liesse die Beschriftungen überlappen — die Referenzgrafik löst es - * genauso. - * - * Der Tooltip trägt Name und Funktion immer, damit die Information nie - * ausschliesslich im Bild steckt. - */ -export const AgentRingNode = memo(function AgentRingNode({ - agent, - spec, - scale, - labelWidth, - leftPct, - topPct, - selected = false, - onSelect, -}: Props) { - const [active, setActive] = useState(false) - const px = avatarPxFor(spec, scale * RING_REFERENCE_PX) - const showRole = spec.showRole || active || selected - const nameFontPx = Math.max(MIN_NAME_FONT_PX, px * NAME_FONT_RATIO) - const roleFontPx = Math.max(MIN_ROLE_FONT_PX, px * ROLE_FONT_RATIO) - - return ( - onSelect(agent)} - onMouseEnter={() => setActive(true)} - onMouseLeave={() => setActive(false)} - onFocus={() => setActive(true)} - onBlur={() => setActive(false)} - sx={{ - position: 'absolute', - left: `${leftPct}%`, - top: `${topPct}%`, - transform: 'translate(-50%, -50%)', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: 0.375, - width: labelWidth, - p: 0, - border: 'none', - background: 'none', - font: 'inherit', - cursor: 'pointer', - zIndex: active || selected ? 3 : 1, - // left/top wandern beim Stufenwechsel mit, weil sich der Ringradius - // ändert — ohne Übergang springen die Portraits an ihren neuen Platz. - transition: 'transform 0.2s ease, left 0.35s ease, top 0.35s ease', - '&:hover, &:focus-visible': { transform: 'translate(-50%, -50%) scale(1.06)' }, - '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 4, borderRadius: 2 }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover, &:focus-visible': { transform: 'translate(-50%, -50%)' }, - }, - }} - > - - - - - - - - {agent.name} - - - {/* Höhe wird reserviert, damit die Ringe beim Hover nicht springen */} - - {agent.role} - - - ) -}) diff --git a/src/components/team/AgentWorkspaceHero.tsx b/src/components/team/AgentWorkspaceHero.tsx new file mode 100644 index 0000000..c247320 --- /dev/null +++ b/src/components/team/AgentWorkspaceHero.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import type { KeyboardEvent } from 'react' +import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material' +import { SendHorizonal } from 'lucide-react' +import { AgentAvatar } from './AgentAvatar' +import { AGENT_CHAT_PROMPT } from '../../lib/constants' +import { DS_BORDER, DS_TEXT } from '../../lib/ds' +import { useToastStore } from '../../stores/toastStore' + +export interface AgentWorkspaceHeroProps { + agentId: string + name: string + role: string + /** Zusätzliche Kanäle, sofern für diesen Agenten definiert — z. B. WhatsApp. */ + channels?: { label: string; hint: string }[] +} + +/** + * Gemeinsamer Chat-Einstieg aller fünf Agentenseiten (Runde 4, §4). + * + * Bewusst grosszügig und ruhig: Porträt, Name, Funktion, Frage, Eingabefeld — + * mehr nicht. Kein generischer «AI Assistent»-Button, keine Vorschlagskacheln, + * kein Verlauf. Der Chat ist agentenspezifisch und bleibt auf der jeweiligen + * Seite; die operative Liste beginnt erst unterhalb dieses Weissraums. + * + * Es gibt genau diese eine Komponente — fünf beinahe gleiche Chatbereiche + * wären fünf Stellen, an denen dieselbe Änderung nachgezogen werden müsste. + * + * Das Absenden ist Frontend-Simulation: es existiert kein Chat-Backend, und + * eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung. + */ +export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: AgentWorkspaceHeroProps) { + const [message, setMessage] = useState('') + const showToast = useToastStore(s => s.showToast) + + function handleSend() { + const text = message.trim() + if (!text) return + setMessage('') + showToast(`Nachricht an ${name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, 'info') + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + return ( + + + + + {name} + + + {role} + + + + {AGENT_CHAT_PROMPT} + + + + setMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={`Nachricht an ${name} senden`} + slotProps={{ + input: { + endAdornment: ( + + + + ), + }, + }} + sx={{ + '& .MuiOutlinedInput-root': { + alignItems: 'flex-start', + borderRadius: 3, + bgcolor: 'white', + px: 2, + py: 1.25, + fontSize: '0.9375rem', + '& fieldset': { borderColor: DS_BORDER.default }, + }, + }} + /> + + {channels.length > 0 && ( + + {channels.map(c => ( + + + {c.label} + + + ))} + + )} + + + ) +} diff --git a/src/components/team/AgentWorkspaceTabs.tsx b/src/components/team/AgentWorkspaceTabs.tsx new file mode 100644 index 0000000..cd03ceb --- /dev/null +++ b/src/components/team/AgentWorkspaceTabs.tsx @@ -0,0 +1,50 @@ +import { Box, Tab, Tabs } from '@mui/material' +import { + AGENT_SECTION_LABELS, + AGENT_SECTION_ORDER, +} from '../../lib/constants' +import type { AgentSection } from '../../lib/constants' + +interface Props { + value: AgentSection + onChange: (next: AgentSection) => void +} + +/** + * Bereichsauswahl der Hauptseite «Meine Agenten». + * + * Gestaltung und Verhalten bewusst identisch zur Auswahl «Pendente Anfragen / + * Erledigte Aufträge» im Bearbeitungsverlauf (Runde 4, §2.1) — zwei + * verschiedene Reiter-Optiken auf derselben Seite wären reine Unruhe. + * + * Die Leiste spannt über die gesamte Inhaltsbreite: der weisse Streifen mit + * seiner Trennlinie reicht vom Ende der Hauptnavigation bis zum rechten Rand, + * die Beschriftungen beginnen bündig zum Seitentitel. + */ +export function AgentWorkspaceTabs({ value, onChange }: Props) { + return ( + + onChange(next)} + variant="scrollable" + scrollButtons="auto" + sx={{ + px: 3, + '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.95rem' }, + }} + > + {AGENT_SECTION_ORDER.map(section => ( + + ))} + + + ) +} diff --git a/src/pages/supply/KanaeleSysteme.tsx b/src/components/team/ChannelsSection.tsx similarity index 57% rename from src/pages/supply/KanaeleSysteme.tsx rename to src/components/team/ChannelsSection.tsx index 7cce464..bda7077 100644 --- a/src/pages/supply/KanaeleSysteme.tsx +++ b/src/components/team/ChannelsSection.tsx @@ -2,6 +2,15 @@ import { useCallback, useState } from 'react' import { Box, Typography } from '@mui/material' import { AgentConnectionCategory } from '../../domain/agentConnection' import { DS_TEXT } from '../../lib/ds' +import { ConnectionCard } from './ConnectionCard' +import { ConnectionWizard } from './ConnectionWizard' +import { CardSkeleton, ConfirmDialog, ErrorState } from '../ui' +import { useTeamAgents } from '../../hooks/useTeamAgents' +import { + useAgentConnections, + useDisconnectAgentConnection, + useTestAgentConnection, +} from '../../hooks/useAgentConnections' /** Vier Karten pro Zeile auf grossen Schirmen, zwei auf Tablet, eine auf Mobil. */ const GRID_COLUMNS = { @@ -36,27 +45,16 @@ const SECTIONS = [ ] as string[], }, ] -import { - TeamPageHeader, - ConnectionCard, - ConnectionWizard, -} from '../../components/team' -import { CardSkeleton, ConfirmDialog, ErrorState } from '../../components/ui' -import { useTeamAgents } from '../../hooks/useTeamAgents' -import { - useAgentConnections, - useDisconnectAgentConnection, - useTestAgentConnection, -} from '../../hooks/useAgentConnections' /** - * Subreiter «Kanäle & Systeme» (§13). + * Kanäle & Systeme. * * Grundprinzip: die digitalen Mitarbeiter arbeiten in den bereits genutzten - * Werkzeugen der Kunden. Diese Seite dient der Konfiguration, Kontrolle und - * Rechtevergabe — nicht dem Ersatz dieser Werkzeuge. + * Werkzeugen der Kunden. Dieser Bereich dient der Konfiguration, Kontrolle und + * Rechtevergabe — nicht dem Ersatz dieser Werkzeuge. War bis Runde 4 eine + * eigene Seite, ist seither ein Reiter der Hauptseite «Meine Agenten». */ -export default function KanaeleSysteme() { +export function ChannelsSection() { const { data: connections = [], isLoading, isError, refetch } = useAgentConnections() const { data: agents = [] } = useTeamAgents() const disconnect = useDisconnectAgentConnection() @@ -75,56 +73,48 @@ export default function KanaeleSysteme() { const pendingDisconnect = connections.find(c => c.id === disconnectId) ?? null return ( - - - - - {isError ? ( - refetch()} /> - ) : ( - <> - {isLoading ? ( + + {isError ? ( + refetch()} /> + ) : isLoading ? ( + + {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => )} + + ) : ( + SECTIONS.map((section) => { + const items = connections.filter(c => section.categories.includes(c.category)) + if (items.length === 0) return null + return ( + + + {section.title} + - {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => )} + {items.map((connection) => ( + + ))} - ) : ( - SECTIONS.map((section) => { - const items = connections.filter(c => section.categories.includes(c.category)) - if (items.length === 0) return null - return ( - - - {section.title} - - - {items.map((connection) => ( - - ))} - - - ) - }) - )} - - )} - + + ) + }) + )} - {connections.map((connection) => ( - - ))} - - ) -} - -const ConnectionSummaryCard = memo(function ConnectionSummaryCard({ - connection, -}: { - connection: AgentConnection -}) { - const title = AGENT_CONNECTION_CATEGORY_LABELS[connection.category] ?? connection.name - - return ( - - - - - {title} - - - - - - - - {connection.lastSyncAt - ? `Zuletzt abgeglichen ${formatTeamRelative(connection.lastSyncAt)}` - : 'Noch kein Abgleich'} - - - ) -}) diff --git a/src/pages/supply/Bearbeitungsverlauf.tsx b/src/components/team/HistorySection.tsx similarity index 60% rename from src/pages/supply/Bearbeitungsverlauf.tsx rename to src/components/team/HistorySection.tsx index 141d50e..c84e594 100644 --- a/src/pages/supply/Bearbeitungsverlauf.tsx +++ b/src/components/team/HistorySection.tsx @@ -1,33 +1,38 @@ import { useEffect, useMemo } from 'react' -import { useNavigate, useParams } from 'react-router' +import { useSearchParams } from 'react-router' import { Box, Tab, Tabs } from '@mui/material' -import { - TeamPageHeader, - WorkItemFilterBar, - WorkItemList, - WorkItemDetailDrawer, - toWorkItemFilters, -} from '../../components/team' +import { WorkItemFilterBar } from './WorkItemFilterBar' +import { WorkItemList } from './WorkItemList' +import { WorkItemDetailDrawer } from './WorkItemDetailDrawer' +import { toWorkItemFilters } from './teamFilterUtils' import { useTeamAgents } from '../../hooks/useTeamAgents' import { useTeamStore, HistoryTab } from '../../stores/teamStore' -import { ROUTES } from '../../lib/constants' -/** URL-Segment ↔ Reiter. Deep-Links auf beide Reiter müssen erhalten bleiben (§3.4). */ +/** URL-Segment ↔ Reiter. Deep-Links auf beide Reiter müssen erhalten bleiben. */ const TAB_SEGMENT: Record = { [HistoryTab.PENDING]: 'pendente-anfragen', [HistoryTab.DONE]: 'erledigte-auftraege', } -function tabFromSegment(segment: string | undefined): HistoryTab { +const PARAM_TAB = 'tab' + +function tabFromSegment(segment: string | null): HistoryTab { return segment === TAB_SEGMENT[HistoryTab.DONE] ? HistoryTab.DONE : HistoryTab.PENDING } -export default function Bearbeitungsverlauf() { - const { tab: segment } = useParams<{ tab?: string }>() - const navigate = useNavigate() +/** + * Bearbeitungsverlauf — pendente Anfragen und erledigte Aufträge. + * + * War bis Runde 4 eine eigene Seite; seither ein Reiter der Hauptseite «Meine + * Agenten». Die Reiterwahl liegt jetzt im Query-String statt im Pfad, alles + * andere ist unverändert. Die Gestaltung dieser inneren Reiterleiste ist die + * Vorlage für die äussere Bereichsauswahl (Runde 4, §2.1). + */ +export function HistorySection() { + const [params, setParams] = useSearchParams() const { data: agents = [] } = useTeamAgents() - const activeTab = tabFromSegment(segment) + const activeTab = tabFromSegment(params.get(PARAM_TAB)) const setHistoryTab = useTeamStore(s => s.setHistoryTab) const setSelectedWorkItemId = useTeamStore(s => s.setSelectedWorkItemId) @@ -63,26 +68,31 @@ export default function Bearbeitungsverlauf() { [activeTab, period, agentId, area, kind, priority, channel, status, search, sort], ) - const handleTabChange = (next: HistoryTab) => { + function handleTabChange(next: HistoryTab) { setSelectedWorkItemId(null) - navigate(`${ROUTES.SUPPLY.TEAM_HISTORY}/${TAB_SEGMENT[next]}`) + setParams(prev => { + const p = new URLSearchParams(prev) + p.set(PARAM_TAB, TAB_SEGMENT[next]) + return p + }) } return ( - - handleTabChange(v)} - sx={{ '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.95rem' } }} - > - - - - } - /> + + handleTabChange(v)} + sx={{ + px: 3, + borderBottom: '1px solid #e8e7e4', + flexShrink: 0, + bgcolor: 'white', + '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.95rem' }, + }} + > + + + diff --git a/src/components/team/ObjectDeepLink.tsx b/src/components/team/ObjectDeepLink.tsx new file mode 100644 index 0000000..45de0e3 --- /dev/null +++ b/src/components/team/ObjectDeepLink.tsx @@ -0,0 +1,61 @@ +import { memo } from 'react' +import { Link } from 'react-router' +import { Typography } from '@mui/material' +import type { TypographyProps } from '@mui/material' +import { propertyDetailRoute } from '../../lib/constants' +import { DS_TEXT } from '../../lib/ds' + +interface Props { + /** Objekt-ID aus «Meine Objekte». Ohne ID wird nur Text gerendert. */ + propertyId?: string + label: string + variant?: TypographyProps['variant'] + /** Setzt Schriftschnitt und Zeilenhöhe; Farbe bleibt die Standard-Textfarbe. */ + fontWeight?: number + noWrap?: boolean +} + +/** + * Objektname als Deep-Link nach «Meine Objekte» (Runde 4, §10). + * + * Bewusst in der normalen Textfarbe statt in Linkblau: die Agentenlisten sollen + * farblich ruhig bleiben, der Link zeigt sich beim Überfahren. Fehlt die + * Objekt-ID, bleibt der Name reiner Text — ein Link, der ins Leere führt, ist + * schlimmer als kein Link. + */ +export const ObjectDeepLink = memo(function ObjectDeepLink({ + propertyId, + label, + variant = 'body2', + fontWeight = 500, + noWrap = true, +}: Props) { + const sx = { + fontWeight, + color: DS_TEXT.primary, + ...(noWrap + ? { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const } + : {}), + } + + if (!propertyId) { + return {label} + } + + return ( + e.stopPropagation()} + variant={variant} + sx={{ + ...sx, + display: 'block', + textDecoration: 'none', + '&:hover': { textDecoration: 'underline' }, + }} + > + {label} + + ) +}) diff --git a/src/components/team/PersonnelSection.tsx b/src/components/team/PersonnelSection.tsx new file mode 100644 index 0000000..beab83c --- /dev/null +++ b/src/components/team/PersonnelSection.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useSearchParams } from 'react-router' +import { Box, useMediaQuery, useTheme } from '@mui/material' +import { AgentListPanel } from './AgentListPanel' +import { AgentDossier, resolveDossierSegment } from './AgentDossier' +import type { DossierSegment } from './AgentDossier' +import { EmptyState, ErrorState, PanelLoadingState } from '../ui' +import { useTeamAgents } from '../../hooks/useTeamAgents' +import { DS_BG } from '../../lib/ds' + +/** Query-Parameter dieses Bereichs — Deep-Links auf ein Personalblatt bleiben erhalten. */ +const PARAM_AGENT = 'agent' +const PARAM_TAB = 'tab' + +/** + * Personalverwaltung — Liste links, Personaldossier rechts. + * + * War bis Runde 4 eine eigene Seite unter `/supply/team/personalverwaltung`. + * Seither ist es ein Reiter der Hauptseite «Meine Agenten»; Auswahl und + * Dossierreiter wandern deshalb vom Pfad in den Query-String. Inhalt und + * Funktionsumfang bleiben unverändert. + */ +export function PersonnelSection() { + const [params, setParams] = useSearchParams() + const theme = useTheme() + const isCompact = useMediaQuery(theme.breakpoints.down('lg')) + + const agentId = params.get(PARAM_AGENT) ?? undefined + const tab = params.get(PARAM_TAB) ?? undefined + + const { data: agents = [], isLoading, isError, refetch } = useTeamAgents() + + const activeSegment: DossierSegment = resolveDossierSegment(tab) + const selectedAgent = useMemo(() => agents.find(a => a.id === agentId) ?? null, [agents, agentId]) + + // Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres + // Dossier wäre für den Nutzer eine Sackgasse. + useEffect(() => { + if (!agentId && agents.length > 0) { + setParams( + prev => { + const next = new URLSearchParams(prev) + next.set(PARAM_AGENT, agents[0].id) + return next + }, + { replace: true }, + ) + } + }, [agentId, agents, setParams]) + + const goTo = useCallback( + (nextAgentId: string, segment: DossierSegment) => { + setParams(prev => { + const next = new URLSearchParams(prev) + next.set(PARAM_AGENT, nextAgentId) + next.set(PARAM_TAB, segment) + return next + }) + }, + [setParams], + ) + + const selectAgent = useCallback( + (id: string) => goTo(id, activeSegment), + [goTo, activeSegment], + ) + + const selectSegment = useCallback( + (segment: DossierSegment) => { + if (agentId) goTo(agentId, segment) + }, + [goTo, agentId], + ) + + if (isError) { + return refetch()} /> + } + if (isLoading) return + if (!selectedAgent) { + return ( + + ) + } + + return ( + + + + + + + ) +} diff --git a/src/components/team/TeamKpiSection.tsx b/src/components/team/TeamKpiSection.tsx deleted file mode 100644 index 2f89fd8..0000000 --- a/src/components/team/TeamKpiSection.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useMemo } from 'react' -import { Box, MenuItem, Skeleton, TextField, Typography } from '@mui/material' -import type { TeamAgent } from '../../domain/teamAgent' -import { AgentDomainArea } from '../../domain/agentWorkItem' -import { AgentPeriod } from '../../domain/agentFilters' -import { useAgentKpis } from '../../hooks/useAgentWorkItems' -import { useTeamStore } from '../../stores/teamStore' -import { toKpiFilters } from './teamFilterUtils' -import { TeamSectionHeader } from './TeamSectionHeader' -import { AGENT_PERIOD_LABELS, AGENT_DOMAIN_AREA_LABELS } from '../../lib/constants' -import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' - -interface TileProps { - label: string - definition: string - value?: number - loading: boolean -} - -/** - * Bewusst zurückhaltend: klare Zahl, Bezeichnung, eine Zeile Definition. - * Keine überdimensionierten Marketing-KPIs und keine dunkelblaue Leistungsleiste - * (§4.4) — der Bewirtschafter liest hier eine Arbeitslage, keine Werbefläche. - */ -function KpiTile({ label, definition, value, loading }: TileProps) { - return ( - - {loading ? ( - - ) : ( - - {value ?? 0} - - )} - - {label} - - - {definition} - - - ) -} - -interface Props { - agents: TeamAgent[] -} - -export function TeamKpiSection({ agents }: Props) { - const period = useTeamStore(s => s.overviewPeriod) - const area = useTeamStore(s => s.overviewArea) - const agentId = useTeamStore(s => s.overviewAgentId) - const setPeriod = useTeamStore(s => s.setOverviewPeriod) - const setArea = useTeamStore(s => s.setOverviewArea) - const setAgentId = useTeamStore(s => s.setOverviewAgentId) - - const filters = useMemo(() => toKpiFilters({ period, area, agentId }), [period, area, agentId]) - const { data: kpis, isLoading } = useAgentKpis(filters) - - return ( - - - - {/* Filter direkt über den Kennzahlen — sie wirken auf alle drei zugleich (§4.2) */} - - setPeriod(e.target.value as AgentPeriod)} - sx={{ minWidth: 160, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }} - > - {Object.values(AgentPeriod).map((p) => ( - {AGENT_PERIOD_LABELS[p]} - ))} - - - setArea(e.target.value as AgentDomainArea | 'ALL')} - sx={{ minWidth: 180, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }} - > - Alle Bereiche - {Object.values(AgentDomainArea).map((a) => ( - {AGENT_DOMAIN_AREA_LABELS[a]} - ))} - - - setAgentId(e.target.value)} - sx={{ minWidth: 200, '& .MuiInputBase-root': { bgcolor: DS_BG.surface } }} - > - Alle Mitarbeiter - {agents.map((a) => ( - {a.name} · {a.role} - ))} - - - - - - - - - - ) -} diff --git a/src/components/team/TeamOverviewRing.tsx b/src/components/team/TeamOverviewRing.tsx deleted file mode 100644 index e2f9964..0000000 --- a/src/components/team/TeamOverviewRing.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Box, Typography } from '@mui/material' -import { useNavigate } from 'react-router' -import type { AgentDirectoryEntry, AgentLevel } from '../../domain/agentDirectory' -import { AGENT_LEVEL_ORDER } from '../../domain/agentDirectory' -import { agentDirectory } from '../../mock-data/agentDirectory' -import { AgentRing } from './AgentRing' -import { AgentPreviewPopover } from './AgentPreviewPopover' -import { - RING_SPECS, - RING_REFERENCE_PX, - HUB_CAPTION_MIN_PX, - HUB_FONT_RATIO, - hubDiameterPx, - zoomedRingSpecs, -} from './agentRingConfig' -import { ROUTES } from '../../lib/constants' -import { BADGE_COLORS, DS_TEXT } from '../../lib/ds' - -interface Props { - /** Bis zu welcher Stufe die Ringe sichtbar sind — kumulativ. */ - level: AgentLevel -} - -/** Platz für Seitentitel, Stufenfilter und Aussenabstände. */ -const VIEWPORT_RESERVE_PX = 250 - -/** Misst die Containerbreite, damit Portraits und Schrift mitskalieren. */ -function useContainerWidth(): [React.RefObject, number] { - const ref = useRef(null) - const [width, setWidth] = useState(RING_REFERENCE_PX) - - useEffect(() => { - const node = ref.current - if (!node) return - - // Ohne ResizeObserver bleibt die Referenzbreite stehen: die Darstellung ist - // dann nicht mitskaliert, aber vollständig bedienbar. Ein harter Absturz - // wegen einer fehlenden Browser-Schnittstelle wäre die schlechtere Antwort. - if (typeof ResizeObserver === 'undefined') { - setWidth(node.getBoundingClientRect().width || RING_REFERENCE_PX) - return - } - - const observer = new ResizeObserver(([entry]) => { - setWidth(entry.contentRect.width) - }) - observer.observe(node) - return () => observer.disconnect() - }, []) - - return [ref, width] -} - -/** - * Kreisförmige Darstellung der digitalen Belegschaft. - * - * Aufbau nach der Referenzgrafik: Kernteam innen und gold gerahmt, danach die - * Zentralen Dienste, aussen die Spezialisten der Geschäftsbereiche. Welche Ringe - * sichtbar sind, bestimmt die gewählte Stufe — kumulativ, wie im Konzept. - * - * Nicht sichtbare Ringe werden ausgeblendet statt entfernt: so bleibt die - * Bewegung beim Stufenwechsel ruhig und der Kreis springt nicht in der Grösse. - */ -export function TeamOverviewRing({ level }: Props) { - const navigate = useNavigate() - const [ref, width] = useContainerWidth() - const [preview, setPreview] = useState(null) - const [anchorEl, setAnchorEl] = useState(null) - - const scale = width / RING_REFERENCE_PX - const visibleUpTo = AGENT_LEVEL_ORDER.indexOf(level) - - /** - * Die Fläche bleibt auf jeder Stufe gleich gross; vergrössert wird stattdessen - * die Zeichnung, bis der äusserste sichtbare Ring an den Rand kommt. Die - * Viewport-Höhe begrenzt zusätzlich, damit die Grafik ohne Scrollen - * vollständig sichtbar bleibt. - */ - const boxSize = `min(100%, ${RING_REFERENCE_PX}px, calc(100vh - ${VIEWPORT_RESERVE_PX}px))` - - const specs = useMemo(() => zoomedRingSpecs(visibleUpTo), [visibleUpTo]) - - // Die Nabe folgt dem Kernring, statt eine feste Prozentzahl der Fläche zu sein. - const hubPx = hubDiameterPx(specs, width) - const showHubCaption = hubPx >= HUB_CAPTION_MIN_PX - - const agentsByLevel = useMemo(() => { - const map = new Map() - for (const spec of RING_SPECS) { - map.set(spec.level, agentDirectory.filter(a => a.level === spec.level)) - } - return map - }, []) - - /** - * Nicht sichtbare Ringe werden nicht gezeichnet, statt nur durchsichtig zu - * sein. Durchsichtige Schaltflächen bleiben anspringbar — die Tabulatortaste - * liefe sonst durch Portraits, die niemand sieht. - */ - const visibleAgentCount = useMemo( - () => RING_SPECS - .slice(0, visibleUpTo + 1) - .reduce((sum, spec) => sum + (agentsByLevel.get(spec.level)?.length ?? 0), 0), - [agentsByLevel, visibleUpTo], - ) - - const handleSelect = useCallback( - (agent: AgentDirectoryEntry, element: HTMLElement | null) => { - if (agent.isCore) { - navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agent.id}`) - return - } - setPreview(agent) - setAnchorEl(element) - }, - [navigate], - ) - - // Der Ringknoten meldet nur den Agenten; das auslösende Element holen wir uns - // aus dem Fokus, damit die Vorschau am angeklickten Portrait hängt. - const onNodeSelect = useCallback( - (agent: AgentDirectoryEntry) => { - const active = document.activeElement - handleSelect(agent, active instanceof HTMLElement ? active : null) - }, - [handleSelect], - ) - - const closePreview = useCallback(() => { - setPreview(null) - setAnchorEl(null) - }, []) - - return ( - <> - - {specs.slice(0, visibleUpTo + 1).map(spec => ( - - ))} - - {/* Nabe — bewusst unter den Ringen, damit sie keine Beschriftung deckt. */} - - - PROPERTY{' '} - ON - - - {visibleAgentCount} - - {showHubCaption && ( - - digitale Mitarbeitende - - )} - - - - - - ) -} diff --git a/src/components/team/TeamSectionHeader.tsx b/src/components/team/TeamSectionHeader.tsx deleted file mode 100644 index ab02f50..0000000 --- a/src/components/team/TeamSectionHeader.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Box, Button, Typography } from '@mui/material' -import { ArrowRight } from 'lucide-react' -import { useNavigate } from 'react-router' -import { DS_TEXT } from '../../lib/ds' - -interface Props { - title: string - description?: string - /** Quicklink rechts im Bereichskopf (§6.3, §6.6). */ - quickLink?: { - label: string - to: string - } -} - -export function TeamSectionHeader({ title, description, quickLink }: Props) { - const navigate = useNavigate() - - return ( - - - - {title} - - {description && ( - - {description} - - )} - - - {quickLink && ( - - )} - - ) -} diff --git a/src/components/team/__tests__/TeamOverviewRing.test.tsx b/src/components/team/__tests__/TeamOverviewRing.test.tsx deleted file mode 100644 index 107af2c..0000000 --- a/src/components/team/__tests__/TeamOverviewRing.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Property On — Kreisdarstellung der Belegschaft. - * - * Abgesichert werden die Zusicherungen aus den Abnahmekriterien: die richtigen - * sieben im Kernteam, Portraits mit zugänglichem Namen und — fachlich am - * wichtigsten — dass ausschliesslich Kernteammitglieder in ein Dossier führen. - * Für die übrigen 29 gibt es keines, also darf auch kein Klick dorthin führen. - * - * Die Stufenfilter sind entfallen: die Teamübersicht zeigt nur noch das - * Kernteam. Die Komponente beherrscht die äusseren Ringe technisch weiterhin, - * die Seite ruft sie aber fest mit `CORE` auf. - */ - -import { describe, it, expect } from 'vitest' -import { useLocation } from 'react-router' -import { screen, fireEvent } from '@testing-library/react' -import { renderWithProviders } from '../../../test/teamTestUtils' -import { TeamOverviewRing } from '../TeamOverviewRing' -import { agentDirectory } from '../../../mock-data/agentDirectory' -import { AgentLevel } from '../../../domain/agentDirectory' - -function LocationProbe() { - const location = useLocation() - return
{location.pathname}
-} - -const core = agentDirectory.filter(a => a.isCore) -const nonCore = agentDirectory.filter(a => !a.isCore) - -describe('Kernteam', () => { - it('besteht aus genau sieben Mitarbeitenden', () => { - expect(core).toHaveLength(7) - }) - - it('enthält die sieben Namen der Referenzgrafik', () => { - expect(core.map(a => a.name).sort()).toEqual( - ['Bruno', 'Ferdi', 'Lea', 'Livia', 'Nora', 'Reto', 'Sina'], - ) - }) - - it('umfasst insgesamt 36 Mitarbeitende auf vier Stufen', () => { - expect(agentDirectory).toHaveLength(36) - expect(agentDirectory.filter(a => a.level === AgentLevel.CORE)).toHaveLength(7) - expect(agentDirectory.filter(a => a.level === AgentLevel.CENTRAL_SERVICES)).toHaveLength(18) - expect(agentDirectory.filter(a => a.level === AgentLevel.COMMERCIAL)).toHaveLength(7) - expect(agentDirectory.filter(a => a.level === AgentLevel.RESIDENTIAL)).toHaveLength(4) - }) -}) - -describe('TeamOverviewRing', () => { - it('gibt jedem Portrait einen zugänglichen Namen aus Name und Funktion', () => { - renderWithProviders() - - // Bewusst EINE Abfrage statt 36 einzelner `getByRole(..., { name })`: - // jede Namensabfrage berechnet die Accessible Names des gesamten Baums neu, - // was bei 36 Knoten in die Zeitüberschreitung läuft. - const labels = screen - .getAllByRole('button') - .map(node => node.getAttribute('aria-label')) - - for (const agent of agentDirectory) { - expect(labels).toContain(`${agent.name}, ${agent.role}`) - } - expect(labels).toHaveLength(agentDirectory.length) - }) - - it('nennt in der Nabe die Zahl der tatsächlich gezeigten Mitarbeitenden', () => { - // Nicht die Gesamtzahl 36: sichtbar sind nur die sieben des Kernteams, und - // die Nabe darf nicht mehr behaupten, als das Bild hergibt. - renderWithProviders() - expect(screen.getByText(String(core.length))).toBeInTheDocument() - expect(screen.getByText('digitale Mitarbeitende')).toBeInTheDocument() - }) - - it('führt beim Kernteam ins Personaldossier', () => { - renderWithProviders( - <> - - - , - ) - const ferdi = core.find(a => a.id === 'ferdi')! - fireEvent.click(screen.getByRole('button', { name: `${ferdi.name}, ${ferdi.role}` })) - expect(screen.getByTestId('location')).toHaveTextContent( - '/supply/team/personalverwaltung/ferdi', - ) - }) - - it('führt ausserhalb des Kernteams NICHT ins Dossier, sondern zeigt eine Vorschau', () => { - renderWithProviders( - <> - - - , - ) - const other = nonCore[0] - fireEvent.click(screen.getByRole('button', { name: `${other.name}, ${other.role}` })) - - expect(screen.getByTestId('location')).toHaveTextContent('/') - expect(screen.getByTestId('location')).not.toHaveTextContent('personalverwaltung') - expect( - screen.getByText('Für diese Stufe ist noch kein Personaldossier hinterlegt.'), - ).toBeInTheDocument() - }) - - it('zeichnet die äusseren Ringe gar nicht erst, wenn nur das Kernteam gilt', () => { - // Früher waren sie nur durchsichtig — und damit weiterhin mit der - // Tabulatortaste erreichbar. Unsichtbare, aber anspringbare Schaltflächen - // sind eine Falle, deshalb werden sie nicht mehr gerendert. - renderWithProviders() - expect(screen.getAllByRole('button')).toHaveLength(core.length) - }) -}) diff --git a/src/components/team/agentPhotos.ts b/src/components/team/agentPhotos.ts index daafb86..ce05b3c 100644 --- a/src/components/team/agentPhotos.ts +++ b/src/components/team/agentPhotos.ts @@ -18,10 +18,8 @@ import bruno from '../../assets/team/bruno.jpg' import ferdi from '../../assets/team/ferdi.jpg' -import lea from '../../assets/team/lea.jpg' import livia from '../../assets/team/livia.jpg' import nora from '../../assets/team/nora.jpg' -import reto from '../../assets/team/reto.jpg' import sina from '../../assets/team/sina.jpg' import ada from '../../assets/team/ada.jpg' import carla from '../../assets/team/carla.jpg' @@ -56,10 +54,8 @@ import zeno from '../../assets/team/zeno.jpg' export const AGENT_PHOTOS: Record = { bruno, ferdi, - lea, livia, nora, - reto, sina, ada, carla, diff --git a/src/components/team/agentRingConfig.ts b/src/components/team/agentRingConfig.ts deleted file mode 100644 index 1634281..0000000 --- a/src/components/team/agentRingConfig.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Property On — Geometrie der Kreisdarstellung. - * - * Einziger Ort, an dem Radien, Winkel und Portraitgrössen stehen. Die - * Komponenten rechnen selbst nichts aus und tragen keine Positionswerte — - * verteilte Positionsdaten in mehreren Komponenten wären beim ersten - * Layoutwechsel nicht mehr konsistent zu halten. - * - * Alle Radien sind relativ zur halben Containerbreite (0…1), damit die - * Darstellung ohne Umrechnung mitskaliert. - */ - -import { AgentLevel } from '../../domain/agentDirectory' - -export interface RingSpec { - level: AgentLevel - /** Anteil der halben Containerkante, auf dem die Portraits sitzen. */ - radius: number - /** Portraitdurchmesser in Pixeln bei Referenzbreite `RING_REFERENCE_PX`. */ - avatarPx: number - /** Funktion dauerhaft anzeigen — sonst erst bei Fokus oder im Tooltip. */ - showRole: boolean - /** Gestrichelter Rahmen: Stufe befindet sich noch im Aufbau. */ - dashed: boolean -} - -/** Breite, für die die Pixelwerte gedacht sind; darunter wird linear skaliert. */ -export const RING_REFERENCE_PX = 940 - -export const RING_SPECS: RingSpec[] = [ - { level: AgentLevel.CORE, radius: 0.38, avatarPx: 76, showRole: true, dashed: false }, - { level: AgentLevel.CENTRAL_SERVICES, radius: 0.60, avatarPx: 54, showRole: false, dashed: false }, - { level: AgentLevel.COMMERCIAL, radius: 0.76, avatarPx: 54, showRole: false, dashed: false }, - { level: AgentLevel.RESIDENTIAL, radius: 0.90, avatarPx: 50, showRole: false, dashed: true }, -] - -export function ringSpecFor(level: AgentLevel): RingSpec { - return RING_SPECS.find(s => s.level === level) ?? RING_SPECS[RING_SPECS.length - 1] -} - -/** Kleinstes Portrait; darunter ist ein Gesicht nicht mehr zu erkennen. */ -export const MIN_AVATAR_PX = 30 - -/** - * Anteil der halben Kante, auf den der äusserste *sichtbare* Ring rückt. - * - * Nach Anzahl sichtbarer Ringe gestaffelt: sind wenige Ringe zu sehen, wachsen - * die Portraits und damit die Beschriftungen — dann braucht der Rand mehr Platz, - * sonst schreiben die äusseren Namen über die Kante hinaus. - */ -const OUTER_RING_TARGETS = [0.72, 0.84, 0.86, 0.88] - -/** - * Dämpfung, mit der Portraits der Vergrösserung folgen. - * - * Ungedämpft läge der Faktor bei einem einzigen sichtbaren Ring bei rund 1,9 — - * sieben Portraits dieser Grösse klebten den Kreis zu. - */ -const AVATAR_ZOOM_DAMPING = 0.45 - -/** Anteil des freien Raums zwischen Mittelpunkt und Kernring, den die Nabe füllt. */ -const HUB_CLEARANCE = 0.52 - -/** Kleinste Nabe; darunter ist die Zahl in der Mitte nicht mehr lesbar. */ -export const HUB_MIN_PX = 96 - -/** Ab dieser Nabengrösse trägt sie zusätzlich die erläuternde Zeile. */ -export const HUB_CAPTION_MIN_PX = 130 - -/** Schriftgrössen der Nabe als Anteil ihres Durchmessers. */ -export const HUB_FONT_RATIO = { brand: 0.073, count: 0.224, caption: 0.056 } as const - -/** Wunschbreite der Beschriftung, gemessen am Portrait. */ -const LABEL_WIDTH_RATIO = 2.1 - -/** Anteil des Bogenabstands, den eine Beschriftung höchstens einnehmen darf. */ -const LABEL_ARC_FILL = 0.96 - -/** Darunter bricht jeder Name unbrauchbar um. */ -const MIN_LABEL_WIDTH_PX = 46 - -/** - * Vergrössert die sichtbaren Ringe, bis der äusserste von ihnen die Fläche füllt. - * - * Ohne das zeichnet die Stufe «Kernteam» ihre sieben Portraits auf den innersten - * Ring und lässt zwei Drittel des Quadrats leer — Platz, der für Ringe reserviert - * ist, die gar nicht dargestellt werden. Verborgene Ringe bleiben unberührt: sie - * sind unsichtbar, und unvergrössert bleiben sie innerhalb der Fläche. - */ -export function zoomedRingSpecs(visibleUpTo: number): RingSpec[] { - const index = Math.min(Math.max(visibleUpTo, 0), RING_SPECS.length - 1) - const zoom = OUTER_RING_TARGETS[index] / RING_SPECS[index].radius - const avatarZoom = 1 + (zoom - 1) * AVATAR_ZOOM_DAMPING - - return RING_SPECS.map((spec, i) => - i <= index - ? { ...spec, radius: spec.radius * zoom, avatarPx: spec.avatarPx * avatarZoom } - : spec, - ) -} - -/** Portraitdurchmesser eines Rings bei gegebener Containerbreite. */ -export function avatarPxFor(spec: RingSpec, containerPx: number): number { - return Math.max(MIN_AVATAR_PX, Math.round((spec.avatarPx * containerPx) / RING_REFERENCE_PX)) -} - -/** - * Nabendurchmesser aus der Geometrie statt als feste Prozentzahl. - * - * Die Nabe füllt einen festen Anteil des Raums, der zwischen Mittelpunkt und - * innerem Portraitrand tatsächlich frei ist. Damit kann sie die Beschriftungen - * des Kernrings nicht mehr überdecken — was mit den früheren starren 19 % der - * Fall war, sobald nur das Kernteam sichtbar war. - */ -export function hubDiameterPx(specs: RingSpec[], containerPx: number): number { - const inner = specs[0] - const ringPx = (inner.radius * containerPx) / 2 - const avatarRadiusPx = avatarPxFor(inner, containerPx) / 2 - return Math.max(HUB_MIN_PX, (ringPx - avatarRadiusPx) * 2 * HUB_CLEARANCE) -} - -/** - * Breite einer Beschriftung, begrenzt durch den Bogenabstand zum Nachbarn. - * - * Ohne diese Grenze überlappen sich auf engen Ringen die Namen gegenseitig und - * reichen bis in die Nabe hinein. - */ -export function labelWidthFor( - count: number, - spec: RingSpec, - containerPx: number, - { sweepDeg = 360, closed = true }: ArcOptions = {}, -): number { - const wish = avatarPxFor(spec, containerPx) * LABEL_WIDTH_RATIO - if (count <= 1) return wish - - const radiusPx = (spec.radius * containerPx) / 2 - const divisor = closed ? count : count - 1 - const spacingPx = (2 * Math.PI * radiusPx * (sweepDeg / 360)) / divisor - - return Math.max(MIN_LABEL_WIDTH_PX, Math.min(wish, spacingPx * LABEL_ARC_FILL)) -} - -export interface RingNodePosition { - /** Position in Prozent der Containerkante, bezogen auf die Mitte. */ - leftPct: number - topPct: number - angleDeg: number -} - -export interface ArcOptions { - /** Startwinkel in Grad; 0 = oben, im Uhrzeigersinn wachsend. */ - startDeg?: number - /** Überstrichener Winkel. 360 = voller Kreis, 180 = Halbkreis. */ - sweepDeg?: number - /** - * Bei geschlossenem Kreis liegen Anfang und Ende aufeinander, deshalb wird - * durch `count` geteilt. Bei einem offenen Bogen soll der letzte Eintrag am - * Endwinkel stehen, deshalb durch `count - 1`. - */ - closed?: boolean -} - -/** - * Verteilt `count` Knoten gleichmässig auf einem Bogen. - * Rückgabe in Prozent, damit die Darstellung containerrelativ bleibt. - */ -export function layoutArc( - count: number, - radius: number, - { startDeg = 0, sweepDeg = 360, closed = true }: ArcOptions = {}, -): RingNodePosition[] { - if (count <= 0) return [] - if (count === 1) { - const rad = ((startDeg - 90) * Math.PI) / 180 - return [{ - leftPct: 50 + Math.cos(rad) * radius * 50, - topPct: 50 + Math.sin(rad) * radius * 50, - angleDeg: startDeg, - }] - } - - const divisor = closed ? count : count - 1 - return Array.from({ length: count }, (_, i) => { - const angleDeg = startDeg + (sweepDeg * i) / divisor - // -90°, damit 0° oben liegt statt rechts - const rad = ((angleDeg - 90) * Math.PI) / 180 - return { - leftPct: 50 + Math.cos(rad) * radius * 50, - topPct: 50 + Math.sin(rad) * radius * 50, - angleDeg, - } - }) -} - -/** - * Winkel der aktiven Position im Organigramm-Halbkreis: unten mittig. - * Der Halbkreis wird oben abgeschnitten, unten steht der gewählte Agent. - */ -export const WHEEL_ACTIVE_ANGLE_DEG = 180 - -/** Bogen, auf dem das Agentenrad seine Portraits verteilt. */ -export const WHEEL_ARC = { - startDeg: 90, - sweepDeg: 180, -} as const diff --git a/src/components/team/index.ts b/src/components/team/index.ts index a6e1d27..32bedab 100644 --- a/src/components/team/index.ts +++ b/src/components/team/index.ts @@ -1,18 +1,9 @@ -// Property On — «Teamübersicht». Barrel-Export (CLAUDE.md §13). +// Property On — «Meine Agenten». Barrel-Export (CLAUDE.md §13). export { AgentAvatar } from './AgentAvatar' export type { AgentAvatarSize } from './AgentAvatar' export { AgentAvatarGroup } from './AgentAvatarGroup' export { AGENT_PHOTOS } from './agentPhotos' -export { - RING_SPECS, - RING_REFERENCE_PX, - ringSpecFor, - layoutArc, - WHEEL_ARC, - WHEEL_ACTIVE_ANGLE_DEG, -} from './agentRingConfig' -export type { RingSpec, RingNodePosition, ArcOptions } from './agentRingConfig' export { AgentStatusBadge, @@ -25,15 +16,18 @@ export { } from './AgentBadges' export { TeamPageHeader } from './TeamPageHeader' -export { TeamSectionHeader } from './TeamSectionHeader' -export { TeamKpiSection } from './TeamKpiSection' -// Kreisdarstellung der Belegschaft — ersetzt das frühere Kernteam-Kartenraster -export { TeamOverviewRing } from './TeamOverviewRing' -export { AgentRing } from './AgentRing' -export { AgentRingNode } from './AgentRingNode' -export { AgentPreviewPopover } from './AgentPreviewPopover' -export { ConnectionSummaryList } from './ConnectionSummaryList' +// Hauptseite «Meine Agenten» — Bereichsauswahl und die drei Bereiche selbst. +// Die frühere Kreisdarstellung der Belegschaft ist mit der Teamübersicht +// entfallen (Runde 4, §2.1). +export { AgentWorkspaceTabs } from './AgentWorkspaceTabs' +export { PersonnelSection } from './PersonnelSection' +export { HistorySection } from './HistorySection' +export { ChannelsSection } from './ChannelsSection' + +// Gemeinsame Bausteine der fünf Agentenseiten +export { AgentWorkspaceHero } from './AgentWorkspaceHero' +export { ObjectDeepLink } from './ObjectDeepLink' // Personalverwaltung export { diff --git a/src/components/ui/DecisionContextPanel.tsx b/src/components/ui/DecisionContextPanel.tsx index e215dd9..5809e12 100644 --- a/src/components/ui/DecisionContextPanel.tsx +++ b/src/components/ui/DecisionContextPanel.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Alert, Box, Button, Collapse, IconButton, Typography } from '@mui/material' import { AlertTriangle, ChevronDown, ChevronUp, Target } from 'lucide-react' import type { ReactNode } from 'react' +import { DS_BG, DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds' export interface DecisionMetric { label: string @@ -61,7 +62,7 @@ export function DecisionContextPanel({ return ( - + {decision} {context && ( - + {context} )} @@ -111,7 +112,7 @@ export function DecisionContextPanel({ variant={a.primary ? 'contained' : 'outlined'} onClick={a.onClick} sx={a.primary - ? { bgcolor: '#152642', '&:hover': { bgcolor: '#0e1c30' }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.25 } + ? { bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.dark }, textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.25 } : { textTransform: 'none', fontSize: '0.75rem', py: 0.375, px: 1.25 } } > @@ -122,7 +123,7 @@ export function DecisionContextPanel({ setExpanded(v => !v)} - sx={{ color: '#64748b', width: 24, height: 24 }} + sx={{ color: DS_SLATE[500], width: 24, height: 24 }} > {expanded ? : } diff --git a/src/components/visits/VisitPreparationDrawer.tsx b/src/components/visits/VisitPreparationDrawer.tsx new file mode 100644 index 0000000..6fa9362 --- /dev/null +++ b/src/components/visits/VisitPreparationDrawer.tsx @@ -0,0 +1,300 @@ +import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material' +import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react' +import type { VisitAssignment } from '../../domain/visitAssignment' +import { VisitRequestType } from '../../domain/visitAssignment' +import { needsReportWarning } from '../../services/visitAssignmentService' +import { useRequestVisitReport } from '../../hooks/useVisitAssignments' +import { usePropertyById } from '../../hooks/useProperties' +import { ObjectDeepLink } from '../team' +import { useToastStore } from '../../stores/toastStore' +import { VISIT_REQUEST_TYPE_LABELS } from '../../lib/constants' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' + +interface Props { + assignment: VisitAssignment | null + onClose: () => void +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +/** + * Detailansicht eines Besichtigungsauftrags — seitlicher Drawer, analog zu + * Ferdis Reminder-Detailansicht (Runde 4, §9.3). + * + * Vorbereitung und Nachbereitung teilen sich Kopf und Rahmen, zeigen darunter + * aber verschiedene Dinge: vorher Argumente und Einwände, nachher das, was + * Bruno aus dem Bericht des Maklers gemacht hat. + * + * Angaben zum Interessenten stehen nur mit Quelle da. Was sich nicht belegen + * lässt, erscheint gar nicht — eine Vermutung im Briefing wird am Termin zur + * Behauptung. + */ +export function VisitPreparationDrawer({ assignment, onClose }: Props) { + const requestReport = useRequestVisitReport() + const showToast = useToastStore(s => s.showToast) + const { data: property } = usePropertyById(assignment?.propertyId ?? '') + + const prep = assignment?.preparation + const follow = assignment?.followUp + const warn = assignment ? needsReportWarning(assignment) : false + + return ( + + {assignment && ( + <> + {/* Kopf */} + + + + {VISIT_REQUEST_TYPE_LABELS[assignment.requestType]} ·{' '} + {new Date(assignment.scheduledAt).toLocaleString('de-CH', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + })} + + + + {assignment.prospect} · {assignment.origin} + + + + + + + + + {warn && ( + } + sx={{ mb: 2.5, fontSize: '0.8rem' }} + action={ + + } + > + Besichtigung in weniger als 24 Stunden — es wurde noch kein Bericht angefordert. + + )} + + {/* ── Vorbereitung ── */} + {assignment.requestType === VisitRequestType.PREPARATION && prep && ( + <> + {/* Objektbeschrieb mit Bildern und Eckdaten */} + Objekt + {property?.images?.[0] && ( + + )} + {property && ( + + {[ + ['Adresse', `${property.address.street} ${property.address.houseNumber}, ${property.address.postalCode} ${property.address.city}`], + ['Fläche', `${property.areaSqm.toLocaleString('de-CH')} m²`], + ['Mietpreis', `CHF ${property.rentPricePerSqm.toLocaleString('de-CH')}/m²/Jahr`], + ['Verfügbar ab', property.availabilityDate ? new Date(property.availabilityDate).toLocaleDateString('de-CH') : undefined], + ['Parkplätze', property.hardFacts?.parking], + ] + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([label, value]) => ( + + {label} + {String(value)} + + ))} + + )} + {property?.description && ( + + {property.description} + + )} + + + + {prep.locationReport && ( + <> + Lagebericht (von Livia) + + {prep.locationReport} + + + )} + + Verkaufsargumente + + {prep.sellingPoints.map((s, i) => ( + + {s} + + ))} + + + Mögliche Einwände + + {prep.objections.map((o, i) => ( + + + {o.objection} + + + {o.answer} + + + ))} + + + Zum Interessenten (öffentliche Quellen) + {prep.prospectFindings.length === 0 ? ( + + Keine belegbaren öffentlichen Angaben gefunden. + + ) : ( + + {prep.prospectFindings.map((f, i) => ( + + {f.statement} + Quelle: {f.source} + + ))} + + )} + + + + + + + {prep.audioAvailable && ( + + )} + + + )} + + {/* ── Nachbereitung ── */} + {assignment.requestType === VisitRequestType.FOLLOW_UP && follow && ( + <> + Auftrag des Maklers + + Eingang: {follow.inputChannel} + + + {follow.brokerInput} + + + + + Was Bruno verarbeitet hat + + {follow.processingSteps.map((s, i) => ( + + {s} + + ))} + + + Ergebnisdatei + + + + + {follow.outputFileName} + + + {follow.outputDescription} + + + + + + Bemerkungen + + {follow.remarks.map((r, i) => ( + + {r} + + ))} + + + )} + + + )} + + ) +} diff --git a/src/components/visits/index.ts b/src/components/visits/index.ts new file mode 100644 index 0000000..ee7c976 --- /dev/null +++ b/src/components/visits/index.ts @@ -0,0 +1,3 @@ +// Property On — Bruno, Besichtigungsassistent. Barrel-Export (CLAUDE.md §13). + +export { VisitPreparationDrawer } from './VisitPreparationDrawer' diff --git a/src/domain/agentDirectory.ts b/src/domain/agentDirectory.ts index df490c4..48884a3 100644 --- a/src/domain/agentDirectory.ts +++ b/src/domain/agentDirectory.ts @@ -2,7 +2,7 @@ * Property On — Verzeichnis der digitalen Belegschaft. * * Ergänzt `teamAgent.ts`, ersetzt es nicht: `TeamAgent` ist das vollständige - * Personaldossier der sieben Kernteammitglieder, `AgentDirectoryEntry` sind die + * Personaldossier der fünf Kernteammitglieder, `AgentDirectoryEntry` sind die * Stammdaten aller 36 Mitarbeitenden für die Kreisdarstellung. * * Die Stufenbezeichnungen sind aus dem Konzeptdokument übernommen und nicht neu diff --git a/src/domain/calendarEvent.ts b/src/domain/calendarEvent.ts new file mode 100644 index 0000000..87b9845 --- /dev/null +++ b/src/domain/calendarEvent.ts @@ -0,0 +1,36 @@ +/** + * Property On — Termine im angebundenen Kalender. + * + * Bewusst schmal gehalten: die Anwendung führt keinen eigenen Kalender, sie + * legt Termine im bereits genutzten Kalender der Kundschaft ab (heute + * Microsoft Calendar über die Verbindung «Kalender»). Der Typ beschreibt + * deshalb nur, was ein solcher Termin mindestens braucht — nicht, was ein + * Kalendersystem alles kann. + */ + +/** Anhang eines Termins — ein Ausschnitt aus dem Objektdossier, kein Vollarchiv. */ +export interface CalendarAttachment { + /** Sprechender Dateiname, wie er am Termin erscheint. */ + fileName: string + /** Was der Anhang enthält — erscheint als Erläuterung unter dem Dateinamen. */ + description: string + /** Quelle im Objektdossier, sofern vorhanden. */ + sourceUrl?: string +} + +export interface CalendarEvent { + id: string + title: string + /** ISO-Zeitpunkt des Terminbeginns. */ + startsAt: string + durationMinutes: number + notes?: string + attachments: CalendarAttachment[] + /** Objektbezug aus «Meine Objekte». */ + propertyId?: string + /** Auslösender Reminder, falls der Termin aus Ferdis Liste entstanden ist. */ + reminderId?: string + createdAt: string +} + +export type CreateCalendarEventInput = Omit diff --git a/src/domain/expose.ts b/src/domain/expose.ts new file mode 100644 index 0000000..ce52e96 --- /dev/null +++ b/src/domain/expose.ts @@ -0,0 +1,86 @@ +/** + * Property On — Exposé-Dossier von Livia. + * + * Der Datensatz bildet die fachlichen Bereiche der Referenzvorlage ab + * (Runde 4, §8.5.2). Bewusst als flaches Feld-Wörterbuch statt als tief + * geschachtelte Struktur: die Bereiche sind reine Gliederung des Formulars, + * kein Domänenmodell. Eine Schachtelung würde jedes Feld doppelt benennen — + * einmal im Typ, einmal in der Feldliste — und beide könnten auseinanderlaufen. + * + * Was aus «Meine Objekte» belegbar ist, füllt Livia; alles andere bleibt leer + * und wird als fehlend markiert. Es werden keine plausiblen Werte erfunden. + */ + +import type { ExposeTonality } from '../services/ai/IAIService' + +/** Kategorien für die Medien-Kuration. */ +export const ExposeImageCategory = { + EXTERIOR: 'EXTERIOR', + INTERIOR: 'INTERIOR', + FLOORPLAN: 'FLOORPLAN', + SURROUNDINGS: 'SURROUNDINGS', + OTHER: 'OTHER', +} as const +export type ExposeImageCategory = typeof ExposeImageCategory[keyof typeof ExposeImageCategory] + +export interface ExposeImage { + id: string + url: string + fileName: string + category: ExposeImageCategory + caption: string + /** Im Exposé sichtbar. */ + visible: boolean + isCover: boolean + /** true = aus «Meine Objekte» importiert, false = zusätzlich hochgeladen. */ + imported: boolean +} + +export const ExposeDocumentType = { + FLOORPLAN: 'FLOORPLAN', + LEASE_CONTRACT: 'LEASE_CONTRACT', + ENERGY_CERTIFICATE: 'ENERGY_CERTIFICATE', + SITE_PLAN: 'SITE_PLAN', + OTHER: 'OTHER', +} as const +export type ExposeDocumentType = typeof ExposeDocumentType[keyof typeof ExposeDocumentType] + +export interface ExposeDocument { + id: string + type: ExposeDocumentType + title: string + fileName: string + /** Als Anhang im Exposé sichtbar. */ + visibleAsAttachment: boolean +} + +/** Herkunft der Firmenfarben im Exposé. */ +export const ExposeBranding = { + FROM_PROFILE: 'FROM_PROFILE', + MANUAL: 'MANUAL', +} as const +export type ExposeBranding = typeof ExposeBranding[keyof typeof ExposeBranding] + +/** + * Sämtliche Formularwerte. Schlüssel siehe `lib/exposeFields.ts` — dort stehen + * Beschriftung, Feldtyp, Bereich und Pflichtangabe an genau einer Stelle. + */ +export type ExposeValues = Record + +export interface ExposeDraft { + id: string + leadId: string + /** Objekt aus «Meine Objekte», aus dem die Angaben stammen. */ + propertyId: string + values: ExposeValues + tonality: ExposeTonality + images: ExposeImage[] + documents: ExposeDocument[] + branding: ExposeBranding + brandColor?: string + /** Ansprechperson fürs Exposé — aus dem Maklerprofil oder manuell. */ + contactPerson: string + updatedAt: string + /** Zeitpunkt der letzten Erstellung; leer, solange nur gespeichert wurde. */ + generatedAt?: string +} diff --git a/src/domain/exposeLead.ts b/src/domain/exposeLead.ts new file mode 100644 index 0000000..9d15107 --- /dev/null +++ b/src/domain/exposeLead.ts @@ -0,0 +1,38 @@ +/** + * Property On — Leads, die bei Livia zur Exposé-Erstellung liegen. + * + * Ein Lead entsteht auf zwei Wegen: Nora erkennt ein Nachfragesignal und ein + * Bewirtschafter leitet es mit den passenden Objekten an Livia weiter, oder er + * erfasst ihn selbst. Der Typ bildet deshalb nur ab, was Livia zum Arbeiten + * braucht — Interessent, Kontaktdaten, Raum und Objektempfehlung. + */ + +export const ExposeLeadStatus = { + ACTIVE: 'ACTIVE', + ARCHIVED: 'ARCHIVED', +} as const +export type ExposeLeadStatus = typeof ExposeLeadStatus[keyof typeof ExposeLeadStatus] + +export interface ExposeLead { + id: string + /** Eingangsdatum, ISO. */ + receivedAt: string + /** Möglicher Interessent — Firma oder Person, wie sie belegt ist. */ + prospect: string + /** Belegbare Kontaktangaben. Unsichere Angaben stehen hier bewusst nicht. */ + contacts: string[] + /** Gesuchter Raum, z. B. «Zürich, Innenstadt / Paradeplatz». */ + locationHint: string + /** Objektempfehlung — ausschliesslich IDs aus «Meine Objekte». */ + propertyIds: string[] + status: ExposeLeadStatus + /** Ursprungssignal bei Nora, sofern der Lead von dort kam. */ + sourceSignalId?: string + /** Wer den Lead weitergeleitet hat. */ + forwardedBy?: string +} + +export type CreateExposeLeadInput = Omit & { + receivedAt?: string + status?: ExposeLeadStatus +} diff --git a/src/domain/teamAgent.ts b/src/domain/teamAgent.ts index 3b89b80..4fb55e4 100644 --- a/src/domain/teamAgent.ts +++ b/src/domain/teamAgent.ts @@ -9,7 +9,7 @@ * bündelt und dort bereits `ReviewStatus`, `SourceType`, `RiskLevel` u. a. liegen. * * Fachliche Wahrheit ist der Agenten-Katalog: Rollen, Personalnummern und - * Zuständigkeiten der sieben Kernagenten werden nicht verändert. + * Zuständigkeiten der fünf Kernagenten werden nicht verändert. */ // ── Status & Autonomie ──────────────────────────────────────────────────────── diff --git a/src/domain/visitAssignment.ts b/src/domain/visitAssignment.ts new file mode 100644 index 0000000..c6dc21a --- /dev/null +++ b/src/domain/visitAssignment.ts @@ -0,0 +1,63 @@ +/** + * Property On — Besichtigungsaufträge von Bruno. + * + * Ein Auftrag ist entweder eine Vorbereitung (vor dem Termin) oder eine + * Nachbereitung (nach dem Termin). Die beiden Fälle teilen sich Kopfdaten, + * unterscheiden sich aber im Inhalt — deshalb zwei optionale Detailblöcke + * statt zweier Typen mit fast identischen Feldern. + */ + +export const VisitRequestType = { + PREPARATION: 'PREPARATION', + FOLLOW_UP: 'FOLLOW_UP', +} as const +export type VisitRequestType = typeof VisitRequestType[keyof typeof VisitRequestType] + +/** Belegte Angabe mit Quelle — ohne Quelle wird nichts behauptet. */ +export interface SourcedFact { + statement: string + source: string +} + +export interface VisitPreparation { + /** Verkaufsargumente — Anzahl über `VISIT_MIN_SELLING_POINTS` konfiguriert. */ + sellingPoints: string[] + /** Erwartbare Einwände mit vorbereiteter Antwort. */ + objections: { objection: string; answer: string }[] + /** Lagebericht, den Bruno bei Livia angefragt hat. */ + locationReport?: string + /** Öffentliche Erkenntnisse zum Interessenten, je mit Quelle. */ + prospectFindings: SourcedFact[] + /** Ist der Bericht bereits angefordert worden? Steuert die Warnung <24 h. */ + reportRequested: boolean + /** Optionale Audiofassung des Berichts. */ + audioAvailable: boolean +} + +export interface VisitFollowUp { + /** Auftrag beziehungsweise Input des Maklers, z. B. WhatsApp-Sprachnachricht. */ + brokerInput: string + inputChannel: string + /** Was Bruno daraus verarbeitet hat — stichwortartig. */ + processingSteps: string[] + /** Ergebnisdatei, z. B. Protokoll oder Kundennotiz fürs CRM. */ + outputFileName: string + outputDescription: string + /** Bemerkungen von Bruno, etwa zur Ablage im CRM oder DMS. */ + remarks: string[] +} + +export interface VisitAssignment { + id: string + /** Termindatum, ISO. */ + scheduledAt: string + /** Objekt aus «Meine Objekte». */ + propertyId: string + propertyTitle: string + prospect: string + requestType: VisitRequestType + /** Herkunft des Auftrags: CRM oder ein Kommunikationskanal. */ + origin: string + preparation?: VisitPreparation + followUp?: VisitFollowUp +} diff --git a/src/features/matching/__tests__/mustHaveScorer.test.ts b/src/features/matching/__tests__/mustHaveScorer.test.ts index f597166..5bbce0d 100644 --- a/src/features/matching/__tests__/mustHaveScorer.test.ts +++ b/src/features/matching/__tests__/mustHaveScorer.test.ts @@ -18,7 +18,7 @@ describe('scoreMustHaves — empty criteria', () => { describe('scoreMustHaves — Erdgeschoss (ground floor)', () => { it('PASSES when property is floor 0 and criterion contains "erdgeschoss"', () => { - const prop = makeProperty({ hardFacts: { floor: 0 } } as any) + const prop = makeProperty({ hardFacts: { floor: 0 } }) const out = scoreMustHaves(['Erdgeschoss erforderlich'], prop) expect(out.results[0].passed).toBe(true) expect(out.results[0].confidence).toBe('CERTAIN') @@ -26,7 +26,7 @@ describe('scoreMustHaves — Erdgeschoss (ground floor)', () => { }) it('FAILS when property is floor 1 and criterion contains "erdgeschoss"', () => { - const prop = makeProperty({ hardFacts: { floor: 1 } } as any) + const prop = makeProperty({ hardFacts: { floor: 1 } }) const out = scoreMustHaves(['Erdgeschoss ist Pflicht'], prop) expect(out.results[0].passed).toBe(false) expect(out.results[0].confidence).toBe('CERTAIN') @@ -45,14 +45,14 @@ describe('scoreMustHaves — Erdgeschoss (ground floor)', () => { describe('scoreMustHaves — Klimaanlage (air conditioning)', () => { it('PASSES when hasAirConditioning is true', () => { - const prop = makeProperty({ hardFacts: { hasAirConditioning: true } } as any) + const prop = makeProperty({ hardFacts: { hasAirConditioning: true } }) const out = scoreMustHaves(['Klimaanlage vorhanden'], prop) expect(out.results[0].passed).toBe(true) expect(out.results[0].confidence).toBe('CERTAIN') }) it('FAILS when hasAirConditioning is false', () => { - const prop = makeProperty({ hardFacts: { hasAirConditioning: false } } as any) + const prop = makeProperty({ hardFacts: { hasAirConditioning: false } }) const out = scoreMustHaves(['Air conditioning benötigt'], prop) expect(out.results[0].passed).toBe(false) expect(out.results[0].confidence).toBe('CERTAIN') @@ -70,13 +70,13 @@ describe('scoreMustHaves — Klimaanlage (air conditioning)', () => { describe('scoreMustHaves — Laderampe (loading dock)', () => { it('PASSES when loadingDocksCount >= 1', () => { - const prop = makeProperty({ hardFacts: { loadingDocksCount: 2 } } as any) + const prop = makeProperty({ hardFacts: { loadingDocksCount: 2 } }) const out = scoreMustHaves(['Laderampe erforderlich'], prop) expect(out.results[0].passed).toBe(true) }) it('FAILS when loadingDocksCount is 0', () => { - const prop = makeProperty({ hardFacts: { loadingDocksCount: 0 } } as any) + const prop = makeProperty({ hardFacts: { loadingDocksCount: 0 } }) const out = scoreMustHaves(['Verladerampe oder Tor'], prop) expect(out.results[0].passed).toBe(false) expect(out.scoreImpact).toBe(-MUST_HAVE_PENALTY_PER_MISS) @@ -87,7 +87,7 @@ describe('scoreMustHaves — Laderampe (loading dock)', () => { describe('scoreMustHaves — Parkplatz with minimum count', () => { it('PASSES when available parking >= required count', () => { - const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 6 } as any }) + const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 6 } }) const out = scoreMustHaves(['mind. 5 Parkplätze'], prop) expect(out.results[0].passed).toBe(true) expect(out.results[0].confidence).toBe('CERTAIN') @@ -95,7 +95,7 @@ describe('scoreMustHaves — Parkplatz with minimum count', () => { }) it('FAILS when available parking < required count', () => { - const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 3 } as any }) + const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 3 } }) const out = scoreMustHaves(['mind. 10 Parkplätze'], prop) expect(out.results[0].passed).toBe(false) expect(out.results[0].confidence).toBe('CERTAIN') @@ -103,7 +103,7 @@ describe('scoreMustHaves — Parkplatz with minimum count', () => { }) it('detects parking keyword without numeric count', () => { - const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 2 } as any }) + const prop = makeProperty({ softFactors: { ...makeProperty().softFactors, parkingSpots: 2 } }) const out = scoreMustHaves(['Parkplatz vorhanden'], prop) expect(out.results[0].passed).toBe(true) }) @@ -115,7 +115,7 @@ describe('scoreMustHaves — penalty accumulation and cap', () => { it(`applies -${MUST_HAVE_PENALTY_PER_MISS} per failed CERTAIN criterion`, () => { const prop = makeProperty({ hardFacts: { hasAirConditioning: false, loadingDocksCount: 0 }, - } as any) + }) const out = scoreMustHaves(['Klimaanlage', 'Laderampe'], prop) expect(out.scoreImpact).toBe(-2 * MUST_HAVE_PENALTY_PER_MISS) }) @@ -128,7 +128,7 @@ describe('scoreMustHaves — penalty accumulation and cap', () => { floor: 2, isBarrierFree: false, }, - } as any) + }) const out = scoreMustHaves([ 'Klimaanlage', 'Laderampe', diff --git a/src/hooks/useAIMonitoring.ts b/src/hooks/useAIMonitoring.ts deleted file mode 100644 index 0ce4432..0000000 --- a/src/hooks/useAIMonitoring.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { aiMonitoringService } from '../services/aiMonitoringService' -import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider' -import type { ReviewStatus } from '../domain/enums' -import { useToastStore } from '../stores/toastStore' - -const QK = 'aiOutputs' -const STALE = 30_000 - -export function useAIOutputs(filters?: AIMonitoringFilters) { - return useQuery({ - queryKey: [QK, filters ?? {}], - queryFn: () => aiMonitoringService.getOutputs(filters), - staleTime: STALE, - select: (res) => res.data ?? [], - }) -} - -export function useAIOutput(id: string | null) { - return useQuery({ - queryKey: [QK, 'detail', id], - queryFn: () => aiMonitoringService.getOutput(id!), - staleTime: STALE, - enabled: !!id, - select: (res) => res.data ?? null, - }) -} - -export function useUpdateAIOutputReviewStatus() { - const qc = useQueryClient() - return useMutation({ - mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) => - aiMonitoringService.updateReviewStatus(id, status), - onSuccess: () => { - qc.invalidateQueries({ queryKey: [QK] }) - }, - onError: () => { - useToastStore.getState().showToast('Status konnte nicht aktualisiert werden.', 'error') - }, - }) -} diff --git a/src/hooks/useAgentDirectory.ts b/src/hooks/useAgentDirectory.ts new file mode 100644 index 0000000..f5ecc2f --- /dev/null +++ b/src/hooks/useAgentDirectory.ts @@ -0,0 +1,32 @@ +import { useCallback, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { agentDirectoryService } from '../services/agentDirectoryService' +import type { AgentDirectoryEntry } from '../domain/agentDirectory' +import { STALE_TEAM_AGENTS } from '../lib/constants' + +/** + * Stammdaten der digitalen Belegschaft. + * + * Das Verzeichnis war die letzte Datenquelle ohne Provider — die Porträtgruppe + * las es direkt aus den Mockdaten. Jetzt läuft es über dieselbe Schichtung wie + * alles andere und lässt sich später gegen ein echtes Verzeichnis tauschen. + */ +export function useAgentDirectory() { + return useQuery({ + queryKey: ['agent-directory'], + queryFn: async () => (await agentDirectoryService.getAll()).data, + staleTime: STALE_TEAM_AGENTS, + }) +} + +/** Nachschlag anhand von IDs, in der Reihenfolge der Anfrage. */ +export function useAgentDirectoryLookup() { + const { data: entries = [] } = useAgentDirectory() + const byId = useMemo(() => new Map(entries.map(e => [e.id, e])), [entries]) + + return useCallback( + (ids: string[]): AgentDirectoryEntry[] => + ids.map(id => byId.get(id)).filter((e): e is AgentDirectoryEntry => Boolean(e)), + [byId], + ) +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 9da82b5..0ac6aa9 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,8 +1,24 @@ +import { useContext } from 'react' import { useMutation } from '@tanstack/react-query' import { useNavigate } from 'react-router' import { authService } from '../services/authService' +import { AuthContext } from '../provider/authContext' +import type { AuthContextValue } from '../provider/authContext' import type { UserRole } from '../domain/enums' +/** + * Zugriff auf die angemeldete Person. + * + * Lag bis zum Aufräumen neben der Provider-Komponente — ein Hook in einer + * Komponentendatei kostet dort den Fast-Refresh-Zustand und steht ausserdem + * nicht dort, wo man Hooks sucht. + */ +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden.') + return ctx +} + export function useLogin() { const navigate = useNavigate() return useMutation({ diff --git a/src/hooks/useCalendar.ts b/src/hooks/useCalendar.ts new file mode 100644 index 0000000..2c39631 --- /dev/null +++ b/src/hooks/useCalendar.ts @@ -0,0 +1,38 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { calendarService } from '../services/calendarService' +import type { CreateCalendarEventInput } from '../domain/calendarEvent' +import { STALE_CALENDAR } from '../lib/constants' +import { useToastStore } from '../stores/toastStore' + +export function useCalendarStatus() { + return useQuery({ + queryKey: ['calendar', 'status'], + queryFn: async () => (await calendarService.getStatus()).data, + staleTime: STALE_CALENDAR, + }) +} + +export function useCalendarEventsByReminder(reminderId: string) { + return useQuery({ + queryKey: ['calendar', 'reminder', reminderId], + queryFn: async () => (await calendarService.getByReminder(reminderId)).data, + enabled: !!reminderId, + staleTime: STALE_CALENDAR, + }) +} + +export function useCreateCalendarEvent() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (input: CreateCalendarEventInput) => (await calendarService.create(input)).data, + onSuccess: (event) => { + queryClient.invalidateQueries({ queryKey: ['calendar'] }) + if (event.reminderId) { + queryClient.invalidateQueries({ queryKey: ['calendar', 'reminder', event.reminderId] }) + } + }, + onError: (err: Error) => { + useToastStore.getState().showToast(err.message || 'Termin konnte nicht eingeplant werden.', 'error') + }, + }) +} diff --git a/src/hooks/useCompareData.ts b/src/hooks/useCompareData.ts deleted file mode 100644 index 27af8b5..0000000 --- a/src/hooks/useCompareData.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import type { UnifiedMatchResult } from '../domain/unifiedResult' -import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../domain/needBuilder' -import type { WeightingKey } from '../domain/needBuilder' -import { aiService } from '../services/aiService' -import { needService } from '../services/needService' -import { CRITERION_ALIASES, getProp } from '../components/compare/compareUtils' - -export function useCompareData(compareItems: UnifiedMatchResult[]) { - const { data: aiSummary, isLoading: aiLoading } = useQuery({ - queryKey: ['ai-compare', compareItems.map(i => i.matchId)], - queryFn: () => aiService.summarizeComparison(compareItems), - enabled: compareItems.length >= 2, - select: r => r.data, - staleTime: Infinity, - }) - - const { data: needsData } = useQuery({ - queryKey: ['needs'], - queryFn: () => needService.getAll(), - select: r => r.data, - }) - - const activeNeed = needsData - ? [...needsData].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] - : undefined - - const relevantCriteria = activeNeed - ? WEIGHTING_KEYS - .map(key => ({ key, label: WEIGHTING_LABELS[key], weight: activeNeed.weightingProfile[key] ?? 0 })) - .filter(c => c.weight > 0) - .sort((a, b) => b.weight - a.weight) - : [] - - const weightedTotals = compareItems.map(item => - relevantCriteria.reduce((sum, { key, weight }) => { - const factor = [...item.match.positiveFactors, ...item.match.negativeFactors] - .find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase())) - return sum + (factor?.score ?? 50) * weight - }, 0) - ) - const maxWeightedTotal = Math.max(...weightedTotals) - const overallWinnerIdx = compareItems.length > 1 && weightedTotals.filter(t => t === maxWeightedTotal).length === 1 - ? weightedTotals.indexOf(maxWeightedTotal) - : -1 - - const bestScoreIdx = compareItems.length > 0 - ? compareItems.reduce( - (best, item, i) => item.matchScore > compareItems[best].matchScore ? i : best, 0 - ) - : -1 - - const worstConfIdx = compareItems.length > 0 - ? compareItems.reduce( - (worst, item, i) => item.match.confidenceLevel < compareItems[worst].match.confidenceLevel ? i : worst, 0 - ) - : -1 - - const dqScores = compareItems.map(item => getProp(item)?.dataQuality.score ?? 1) - const worstDQIdx = dqScores.length > 0 ? dqScores.indexOf(Math.min(...dqScores)) : -1 - - const missingCriticalCounts = compareItems.map( - item => item.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0 - ) - const maxMissingCritical = missingCriticalCounts.length > 0 ? Math.max(...missingCriticalCounts) : 0 - - return { - aiSummary, - aiLoading, - activeNeed, - relevantCriteria, - weightedTotals, - overallWinnerIdx, - bestScoreIdx, - worstConfIdx, - dqScores, - worstDQIdx, - missingCriticalCounts, - maxMissingCritical, - } -} diff --git a/src/hooks/useExpose.ts b/src/hooks/useExpose.ts new file mode 100644 index 0000000..0ff78c9 --- /dev/null +++ b/src/hooks/useExpose.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { exposeService } from '../services/exposeService' +import { aiService } from '../services/aiService' +import type { ExposeDraft } from '../domain/expose' +import type { Property } from '../domain/property' +import type { ExposeTextInput } from '../services/ai/IAIService' +import { STALE_EXPOSE_LEADS } from '../lib/constants' +import { useToastStore } from '../stores/toastStore' + +export function useExposeDraft(leadId: string, property: Property | undefined) { + return useQuery({ + queryKey: ['expose-draft', leadId, property?.id], + queryFn: async () => (await exposeService.getOrCreateDraft(leadId, property!)).data, + enabled: !!leadId && !!property, + staleTime: STALE_EXPOSE_LEADS, + }) +} + +export function useSaveExposeDraft() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (draft: ExposeDraft) => (await exposeService.save(draft)).data, + onSuccess: (draft) => { + queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft) + }, + onError: () => { + useToastStore.getState().showToast('Exposé konnte nicht gespeichert werden.', 'error') + }, + }) +} + +export function useGenerateExpose() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (draft: ExposeDraft) => (await exposeService.generate(draft)).data, + onSuccess: (draft) => { + queryClient.setQueryData(['expose-draft', draft.leadId, draft.propertyId], draft) + useToastStore.getState().showToast('Exposé erstellt — bereit für den Export.', 'success') + }, + onError: (err: Error) => { + useToastStore.getState().showToast(err.message || 'Exposé konnte nicht erstellt werden.', 'error') + }, + }) +} + +/** + * KI-Textentwurf für einen Abschnitt. + * + * Fehlen belegte Angaben, liefert der Dienst keinen Text, sondern die Liste der + * Lücken — die Oberfläche sagt das dann so, statt einen Text zu zeigen, der + * Angaben behauptet, die niemand geprüft hat (§8.5.2, §9.6). + */ +export function useGenerateExposeText() { + return useMutation({ + mutationFn: async (input: ExposeTextInput) => (await aiService.generateExposeText(input)).data, + onError: () => { + useToastStore.getState().showToast('Textentwurf konnte nicht erzeugt werden — bitte manuell erfassen.', 'error') + }, + }) +} diff --git a/src/hooks/useExposeLeads.ts b/src/hooks/useExposeLeads.ts new file mode 100644 index 0000000..9ecfb75 --- /dev/null +++ b/src/hooks/useExposeLeads.ts @@ -0,0 +1,39 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { exposeLeadService } from '../services/exposeLeadService' +import type { CreateExposeLeadInput } from '../domain/exposeLead' +import { STALE_EXPOSE_LEADS } from '../lib/constants' +import { useToastStore } from '../stores/toastStore' + +export function useExposeLeads() { + return useQuery({ + queryKey: ['expose-leads'], + queryFn: async () => (await exposeLeadService.getAll()).data, + staleTime: STALE_EXPOSE_LEADS, + }) +} + +export function useCreateExposeLead() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (input: CreateExposeLeadInput) => (await exposeLeadService.create(input)).data, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['expose-leads'] }) + }, + onError: (err: Error) => { + useToastStore.getState().showToast(err.message || 'Lead konnte nicht weitergeleitet werden.', 'error') + }, + }) +} + +export function useArchiveExposeLead() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (id: string) => (await exposeLeadService.archive(id)).data, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['expose-leads'] }) + }, + onError: () => { + useToastStore.getState().showToast('Lead konnte nicht archiviert werden.', 'error') + }, + }) +} diff --git a/src/hooks/useMatchDetailData.ts b/src/hooks/useMatchDetailData.ts deleted file mode 100644 index 1481d9f..0000000 --- a/src/hooks/useMatchDetailData.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useMatchDetail } from './useMatches' -import { propertyService } from '../services/propertyService' -import { needService } from '../services/needService' -import { futureSignalService } from '../services/futureSignalService' -import type { ItemResponse } from '../services/types' -import type { Property } from '../domain/property' -import type { Need } from '../domain/need' -import type { FutureSignal } from '../domain/futureSignal' - -type Match = NonNullable['data']> - -export function useMatchDetailData(matchId: string) { - const { data: match, isLoading } = useMatchDetail(matchId) - const isFuture = match?.resultType === 'FUTURE_AVAILABILITY' - - const { data: property = null } = useQuery, Error, Property | null>({ - queryKey: ['property', match?.propertyId], - queryFn: () => propertyService.getById(match!.propertyId), - enabled: !!match && !isFuture, - select: r => r.data ?? null, - }) - - const { data: need = null } = useQuery, Error, Need | null>({ - queryKey: ['need', match?.needId], - queryFn: () => needService.getById(match!.needId), - enabled: !!match?.needId, - select: r => r.data ?? null, - }) - - const { data: signal = null } = useQuery({ - queryKey: ['signal', match?.resultId], - queryFn: async () => { - // resultId may be a signal ID ('signal-002') or a property ID ('prop-006') - const byId = await futureSignalService.getById(match!.resultId!) - if (byId.data) return byId.data - const byProp = await futureSignalService.getByProperty(match!.resultId!) - return byProp.data[0] ?? null - }, - enabled: !!match && isFuture && !!match.resultId, - }) - - return { - match: match as Match | null | undefined, - property, - need, - signal, - isLoading, - isFuture, - } -} diff --git a/src/hooks/usePropertyLookup.ts b/src/hooks/usePropertyLookup.ts new file mode 100644 index 0000000..6e45cf2 --- /dev/null +++ b/src/hooks/usePropertyLookup.ts @@ -0,0 +1,35 @@ +import { useCallback, useMemo } from 'react' +import { useProperties } from './useProperties' +import type { Property } from '../domain/property' + +/** + * Nachschlagen von Objekten anhand ihrer ID. + * + * Mehrere Komponenten haben dafür bis zum Aufräumen direkt `mock-data/properties` + * importiert und damit Provider, Service und Hook übersprungen — beim Wechsel + * auf ein echtes Backend hätten genau diese Stellen weiter Demodaten angezeigt. + * Der Hook liest aus derselben Abfrage wie alle anderen Objektansichten. + */ +export function usePropertyLookup() { + const { data: properties = [] } = useProperties() + + const byId = useMemo(() => new Map(properties.map(p => [p.id, p])), [properties]) + + const findProperty = useCallback( + (id: string): Property | undefined => byId.get(id), + [byId], + ) + + /** Objektname, oder die ID selbst, wenn kein Objekt dazu existiert. */ + const propertyTitle = useCallback( + (id: string): string => byId.get(id)?.title ?? id, + [byId], + ) + + const findProperties = useCallback( + (ids: string[]): Property[] => ids.map(id => byId.get(id)).filter((p): p is Property => Boolean(p)), + [byId], + ) + + return { findProperty, findProperties, propertyTitle } +} diff --git a/src/hooks/useSchattenmarktSignals.ts b/src/hooks/useSchattenmarktSignals.ts index 9abb7f0..eb2798d 100644 --- a/src/hooks/useSchattenmarktSignals.ts +++ b/src/hooks/useSchattenmarktSignals.ts @@ -2,9 +2,10 @@ import { useMemo } from 'react' import type { Property, PropertyUnit } from '../domain/property' import type { FutureSignal } from '../domain/futureSignal' import { SignalType, RiskLevel, ResultType } from '../domain/enums' +import { mockToday } from '../lib/constants' // Matches the mock date used throughout the prototype (currentDate context: 2026-05-20) -const MOCK_TODAY = new Date('2026-05-20') +const MOCK_TODAY = mockToday() export function useSchattenmarktSignals(properties: Property[]): FutureSignal[] { return useMemo(() => { diff --git a/src/hooks/useTeamDemo.ts b/src/hooks/useTeamDemo.ts deleted file mode 100644 index ca456e9..0000000 --- a/src/hooks/useTeamDemo.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { teamDemoService } from '../services/teamDemoService' -import { useToastStore } from '../stores/toastStore' -import { QK_TEAM_AGENTS } from './useTeamAgents' -import { QK_AGENT_WORK_ITEMS, QK_AGENT_KPIS } from './useAgentWorkItems' -import { QK_AGENT_PROTOCOL } from './useAgentProtocol' -import { QK_AGENT_CONNECTIONS } from './useAgentConnections' - -/** - * «Demo zurücksetzen». Entwertet alle vier Property-On-Bestände, damit die - * Oberfläche unmittelbar den Auslieferungszustand zeigt — ein Reload ist nicht - * nötig. - */ -export function useResetTeamDemo() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: () => teamDemoService.reset(), - onSuccess: () => { - for (const key of [QK_TEAM_AGENTS, QK_AGENT_WORK_ITEMS, QK_AGENT_KPIS, QK_AGENT_PROTOCOL, QK_AGENT_CONNECTIONS]) { - queryClient.invalidateQueries({ queryKey: [key] }) - } - useToastStore.getState().showToast('Demo zurückgesetzt. Alle lokalen Änderungen wurden verworfen.', 'success') - }, - onError: () => { - useToastStore.getState().showToast('Demo konnte nicht zurückgesetzt werden.', 'error') - }, - }) -} diff --git a/src/hooks/useUnifiedResults.ts b/src/hooks/useUnifiedResults.ts index 2d13738..7a4ada7 100644 --- a/src/hooks/useUnifiedResults.ts +++ b/src/hooks/useUnifiedResults.ts @@ -7,6 +7,20 @@ import type { UnifiedMatchResult, VerifiedPortfolioResult, } from '../domain/unifiedResult' +import type { Match } from '../domain/match' +import type { Property } from '../domain/property' +import type { FutureSignal } from '../domain/futureSignal' + +/** + * Stabile Leerwerte. + * + * `query.data ?? []` erzeugt bei jedem Rendern ein neues Array. Jedes `useMemo`, + * das darauf hört, rechnet dann bei jedem Rendern neu — der Zwischenspeicher + * war wirkungslos, ohne dass man es sah. + */ +const EMPTY_MATCHES: Match[] = [] +const EMPTY_PROPERTIES: Property[] = [] +const EMPTY_SIGNALS: FutureSignal[] = [] export function useUnifiedResults(needId?: string) { const needMatchesQuery = useMatchesByNeed(needId ?? '') @@ -20,9 +34,9 @@ export function useUnifiedResults(needId?: string) { matchesQuery.isLoading || propertiesQuery.isLoading || signalsQuery.isLoading const error = matchesQuery.error ?? propertiesQuery.error ?? signalsQuery.error - const matches = matchesQuery.data ?? [] - const properties = propertiesQuery.data ?? [] - const signals = signalsQuery.data ?? [] + const matches = matchesQuery.data ?? EMPTY_MATCHES + const properties = propertiesQuery.data ?? EMPTY_PROPERTIES + const signals = signalsQuery.data ?? EMPTY_SIGNALS const schattenmarktSignals = useSchattenmarktSignals(properties) const allSignals = useMemo(() => [...signals, ...schattenmarktSignals], [signals, schattenmarktSignals]) diff --git a/src/hooks/useUnits.ts b/src/hooks/useUnits.ts new file mode 100644 index 0000000..7e0f326 --- /dev/null +++ b/src/hooks/useUnits.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { unitService } from '../services/unitService' +import type { PropertyUnit } from '../domain/property' +import { useToastStore } from '../stores/toastStore' + +/** + * Änderung an einer Mieteinheit. + * + * Die Pre-Market-Freigabe griff bis zum Aufräumen direkt auf + * `MockupUnitProvider` zu und übersprang damit Service und Hook. Beim Wechsel + * auf ein echtes Backend wäre genau diese Stelle stehen geblieben — sie hätte + * weiter in den lokalen Mockup geschrieben, während alles andere schon am + * Server hing. + */ +interface UpdateUnitInput { + unitId: string + data: Partial + /** Objekt, dessen Zwischenspeicher mit erneuert wird. */ + propertyId?: string +} + +export function useUpdateUnit() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ unitId, data }: UpdateUnitInput) => + (await unitService.update(unitId, data)).data, + onSuccess: (_unit, { propertyId }) => { + queryClient.invalidateQueries({ queryKey: ['units'] }) + queryClient.invalidateQueries({ queryKey: ['properties'] }) + if (propertyId) queryClient.invalidateQueries({ queryKey: ['property', propertyId] }) + }, + onError: () => { + useToastStore.getState().showToast('Einheit konnte nicht gespeichert werden.', 'error') + }, + }) +} diff --git a/src/hooks/useVisitAssignments.ts b/src/hooks/useVisitAssignments.ts new file mode 100644 index 0000000..4a7ff6a --- /dev/null +++ b/src/hooks/useVisitAssignments.ts @@ -0,0 +1,35 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { visitAssignmentService } from '../services/visitAssignmentService' +import { STALE_VISIT_ASSIGNMENTS } from '../lib/constants' +import { useToastStore } from '../stores/toastStore' + +export function useVisitAssignments() { + return useQuery({ + queryKey: ['visit-assignments'], + queryFn: async () => (await visitAssignmentService.getAll()).data, + staleTime: STALE_VISIT_ASSIGNMENTS, + }) +} + +/** Grundlage der Benachrichtigungsglocke: Besichtigung in unter 24 h ohne Bericht. */ +export function useVisitReportWarnings() { + return useQuery({ + queryKey: ['visit-assignments', 'warnings'], + queryFn: async () => (await visitAssignmentService.getPendingReportWarnings()).data, + staleTime: STALE_VISIT_ASSIGNMENTS, + }) +} + +export function useRequestVisitReport() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (id: string) => (await visitAssignmentService.requestReport(id)).data, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['visit-assignments'] }) + useToastStore.getState().showToast('Bericht bei Bruno angefordert.', 'success') + }, + onError: () => { + useToastStore.getState().showToast('Bericht konnte nicht angefordert werden.', 'error') + }, + }) +} diff --git a/src/lib/agentWorkspaces.ts b/src/lib/agentWorkspaces.ts new file mode 100644 index 0000000..886529c --- /dev/null +++ b/src/lib/agentWorkspaces.ts @@ -0,0 +1,35 @@ +/** + * Property On — die fünf Agentenseiten unter «Meine Agenten». + * + * Eine einzige Liste für Sidebar, Seitenköpfe und Chat-Einstieg. Sie trägt + * bewusst nur Identität, Funktionsbezeichnung und Route — das Porträt hängt in + * `components/team/agentPhotos.ts`, weil ein Bild-Import diese Datei an den + * Bundler koppeln würde, und das vollständige Personaldossier liegt in + * `mock-data/agents/`. + * + * Die Reihenfolge ist verbindlich (Runde 4, §2.2): Ferdi, Bruno, Livia, Nora, + * Sina. Sie folgt dem Arbeitsablauf, nicht dem Alphabet. + */ + +import { ROUTES } from './constants' + +export interface AgentWorkspace { + /** Identisch mit der Agenten-ID in `mock-data/agents/` und `AGENT_PHOTOS`. */ + id: string + name: string + /** Funktionsbezeichnung — steht im Menü und im Chat-Einstieg unter dem Namen. */ + role: string + path: string +} + +export const AGENT_WORKSPACES: AgentWorkspace[] = [ + { id: 'ferdi', name: 'Ferdi', role: 'Fristen-Wächter', path: ROUTES.SUPPLY.AGENT_FERDI }, + { id: 'bruno', name: 'Bruno', role: 'Besichtigungsassistent', path: ROUTES.SUPPLY.AGENT_BRUNO }, + { id: 'livia', name: 'Livia', role: 'Exposé Master', path: ROUTES.SUPPLY.AGENT_LIVIA }, + { id: 'nora', name: 'Nora', role: 'Marktchancen / Leads', path: ROUTES.SUPPLY.AGENT_NORA }, + { id: 'sina', name: 'Sina', role: 'Datenpflege', path: ROUTES.SUPPLY.AGENT_SINA }, +] + +export function agentWorkspaceById(id: string): AgentWorkspace | undefined { + return AGENT_WORKSPACES.find(a => a.id === id) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1986183..e211e60 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,9 +6,31 @@ export const APP_VERSION = '0.1.0' // Organisation export const DEFAULT_ORG_ID = 'org-wincasa' +/** + * Zeitanker sämtlicher Mockdaten. + * + * Alle Fristen, Termine und Verfügbarkeiten der Demo sind relativ zu diesem + * Datum gesetzt. Er stand bis zum Aufräumen fünffach in der Codebasis; wäre + * einer der Werte verschoben worden, hätten Reminderliste und Signalprüfung + * unterschiedliche Vorstellungen von «heute» gehabt. + */ +export const MOCK_TODAY_ISO = '2026-05-20T00:00:00.000Z' + +export function mockToday(): Date { + return new Date(MOCK_TODAY_ISO) +} + // Pagination export const DEFAULT_PAGE_SIZE = 25 -export const MAX_COMPARE_ITEMS = 3 +/** + * Höchstzahl Objekte im Vergleich. + * + * Stand bis zum Aufräumen doppelt in der Codebasis — hier mit 3, im + * `compareStore` mit 4. Durchgesetzt hat der Store, weil er die Prüfung + * ausführt; die 3 war wirkungslos. Der Wert lebt jetzt nur noch hier, der + * Store liest ihn. + */ +export const MAX_COMPARE_ITEMS = 4 // Data quality thresholds export const DQ_HIGH = 0.8 @@ -36,6 +58,9 @@ export const STALE_TEAM_AGENTS = 5 * 60 * 1000 export const STALE_AGENT_WORK_ITEMS = 15 * 1000 // aggressive — Freigaben ändern die Liste sofort export const STALE_AGENT_PROTOCOL = 15 * 1000 // aggressive — jede Aktion schreibt einen Eintrag export const STALE_AGENT_CONNECTIONS = 60 * 1000 +export const STALE_CALENDAR = 60 * 1000 +export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen +export const STALE_VISIT_ASSIGNMENTS = 60 * 1000 // Route paths — single source of truth export const ROUTES = { @@ -46,15 +71,28 @@ export const ROUTES = { MATCH_CENTER: '/supply/match-center', FUTURE_AVAILABILITY: '/supply/future-availability', DATA_QUALITY: '/supply/data-quality', - // Property On — «Teamübersicht». Basispfad bewusst `/supply/team` statt - // `/supply/teamuebersicht`: kürzer, kollidiert nicht mit `PowerOn` in Suchen - // und passt zum Ordner `components/team/`. Hierarchie und Deep-Links bleiben. + // Property On — «Meine Agenten». Basispfad bleibt `/supply/team`: kürzer, + // kollidiert nicht mit «Agent» im Sinne der KI-Dienste und passt zum Ordner + // `components/team/`. Die drei Verwaltungsbereiche sind ab Runde 4 keine + // eigenen Seiten mehr, sondern Reiter derselben Seite (`?section=`). TEAM: '/supply/team', TEAM_PERSONNEL: '/supply/team/personalverwaltung', TEAM_HISTORY: '/supply/team/bearbeitungsverlauf', TEAM_HISTORY_DONE: '/supply/team/bearbeitungsverlauf/erledigte-auftraege', TEAM_HISTORY_PENDING: '/supply/team/bearbeitungsverlauf/pendente-anfragen', TEAM_CONNECTIONS: '/supply/team/kanaele-systeme', + + // Die fünf Agentenseiten. Vier davon behalten den Pfad ihres Vorgängers — + // ein Umbenennen der URL brächte keinen Nutzen, bräche aber jeden Deep-Link + // und jedes Lesezeichen. Nur Bruno ist neu. + AGENT_FERDI: '/supply/reminder-manager', + AGENT_BRUNO: '/supply/besichtigungen', + AGENT_LIVIA: '/supply/my-listings', + AGENT_NORA: '/supply/market-intelligence', + AGENT_SINA: '/supply/data-quality', + + /** Objekt-Detail in «Meine Objekte» — Ziel aller Objektlinks der Agentenseiten. */ + PROPERTY_DETAIL: '/supply/properties/:propertyId', }, DEMAND: { AI_SEARCH: '/demand/ai-search', @@ -69,7 +107,20 @@ export const ROUTES = { }, } as const -// Asset type display labels +/** Konkreter Objekt-Detailpfad zu einer ID aus «Meine Objekte». */ +export function propertyDetailRoute(propertyId: string): string { + return `${ROUTES.SUPPLY.PROPERTIES}/${propertyId}` +} + +/** + * Nutzungsart — die einzige Beschriftungstabelle für `AssetType`. + * + * Stand bis zum Aufräumen sechsfach in der Codebasis, mit abweichenden Werten: + * RETAIL hiess je nach Bildschirm «Retail», «Einzelhandel» oder «Retail / + * Laden», LOGISTICS «Logistik», «Lager / Logistik» oder «Logistik / Lager». + * Dasselbe Objekt trug damit auf zwei Seiten zwei Namen — für ein Produkt, das + * Vertrauen über Nachvollziehbarkeit herstellt, ist das kein Schönheitsfehler. + */ export const ASSET_TYPE_LABELS: Record = { OFFICE: 'Büro', RETAIL: 'Retail', @@ -78,6 +129,34 @@ export const ASSET_TYPE_LABELS: Record = { LIGHT_INDUSTRIAL: 'Gewerbe', PRODUCTION: 'Produktion', MIXED: 'Gemischt', + UNKNOWN: 'Unbekannt', +} + +/** Herkunft eines Datensatzes — eine Quelle für Detailansicht und Signalkarten. */ +export const SOURCE_TYPE_LABELS: Record = { + ERP_IMPORT: 'ERP-Import (intern)', + IMMOSCOUT_SCRAPE: 'ImmoScout24', + HOMEGATE_SCRAPE: 'Homegate', + MATCHOFFICE_SCRAPE: 'MatchOffice', + NEWHOME_SCRAPE: 'newhome.ch', + AI_SIGNAL: 'KI-Signal', + MANUAL: 'Manuell erfasst', + JOB_POSTING: 'Stelleninserate', + PRESS: 'Pressebericht', + COMPANY_REPORT: 'Geschäftsbericht', + MARKET_DATA: 'Marktdaten', + // Schweizer Begriff — «Baugenehmigung» ist die deutsche Entsprechung und + // stand vor der Zusammenführung in einer der doppelten Tabellen. + CONSTRUCTION_PERMIT: 'Baubewilligung', + LEASE_CONTRACT: 'Mietvertrag', +} + +/** Passantenfrequenz einer Retailfläche. */ +export const PASSERBY_LABELS: Record = { + LOW: 'Niedrig', + MEDIUM: 'Mittel', + HIGH: 'Hoch', + VERY_HIGH: 'Sehr hoch', } // Result type display labels @@ -119,6 +198,29 @@ export const SIGNAL_TYPE_LABELS: Record = { RESTRUCTURING: 'Umstrukturierung', PROJECT_DEVELOPMENT: 'Projektentwicklung', SPACE_CONSOLIDATION: 'Flächenkonsolidierung', + // Fehlte bis zum Aufräumen — die Filterleiste zeigte deshalb den rohen + // Enum-Wert «LEASE_EXPIRY» zwischen deutschen Beschriftungen. + LEASE_EXPIRY: 'Mietvertragsablauf', +} + +// Reminder type display labels — eine Quelle für Liste, Karte, Filter und Termintitel +export const REMINDER_TYPE_LABELS: Record = { + LEASE_EXPIRY: 'Mietablauf', + BREAK_OPTION: 'Break-Option', + RENT_REVIEW: 'Mietanpassung', + INSPECTION: 'Inspektion', + INSURANCE_RENEWAL: 'Versicherung', + MAINTENANCE: 'Unterhalt', + SCHATTENMARKT_RELEASE: 'Pre-Market', + CUSTOM: 'Individuell', +} + +// Reminder status display labels +export const REMINDER_STATUS_LABELS: Record = { + ACTIVE: 'Aktiv', + SNOOZED: 'Schlummernd', + COMPLETED: 'Erledigt', + DISMISSED: 'Verworfen', } // Data freshness display labels @@ -146,13 +248,91 @@ export const DATA_QUALITY_LABELS: Record = { } // ───────────────────────────────────────────────────────────────────────────── -// Property On — «Teamübersicht» +// Property On — «Meine Agenten» // // Schweizer Schreibweise, kein Eszett. Sprache der Zielgruppe: Immobilien- // bewirtschafter, nicht KI-Entwickler — deshalb «Aufgabe», «Freigabe», // «Systemzugang» statt Prompt, Token, Tool Call. // ───────────────────────────────────────────────────────────────────────────── +/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */ +export const MY_AGENTS_LABEL = 'Meine Agenten' + +/** + * Die drei Verwaltungsbereiche auf der Hauptseite. Sie liegen im Query-String + * (`?section=`) und nicht im Pfad: der Reiterwechsel ist eine Ansichtswahl, + * kein Ortswechsel — der Browser-Zurück-Knopf soll ihn dennoch kennen. + */ +export const AGENT_SECTIONS = { + PERSONNEL: 'personnel', + HISTORY: 'history', + CHANNELS: 'channels', +} as const +export type AgentSection = typeof AGENT_SECTIONS[keyof typeof AGENT_SECTIONS] + +export const AGENT_SECTION_PARAM = 'section' + +export const AGENT_SECTION_LABELS: Record = { + [AGENT_SECTIONS.PERSONNEL]: 'Personalverwaltung', + [AGENT_SECTIONS.HISTORY]: 'Bearbeitungsverlauf', + [AGENT_SECTIONS.CHANNELS]: 'Kanäle & Systeme', +} + +/** Reihenfolge der Reiter — verbindlich (Runde 4, §2.1). */ +export const AGENT_SECTION_ORDER: AgentSection[] = [ + AGENT_SECTIONS.PERSONNEL, + AGENT_SECTIONS.HISTORY, + AGENT_SECTIONS.CHANNELS, +] + +/** Einheitliche Frage über dem Eingabefeld jeder Agentenseite (§4). */ +export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?' + +// ── Livia — Leads und Exposé (Runde 4, §8) ─────────────────────────────────── + +export const EXPOSE_LEAD_TABS = { + ACTIVE: 'aktive-leads', + ARCHIVED: 'archivierte-leads', +} as const +export type ExposeLeadTab = typeof EXPOSE_LEAD_TABS[keyof typeof EXPOSE_LEAD_TABS] + +export const EXPOSE_LEAD_TAB_LABELS: Record = { + [EXPOSE_LEAD_TABS.ACTIVE]: 'Aktive Leads', + [EXPOSE_LEAD_TABS.ARCHIVED]: 'Archivierte Leads', +} + +/** Die drei Schritte des Exposé-Prozesses (§8.5). */ +export const EXPOSE_STEPS = ['Hochladen', 'Exposé', 'Export'] as const + +export const EXPOSE_IMAGE_CATEGORY_LABELS: Record = { + EXTERIOR: 'Aussenansicht', + INTERIOR: 'Innenansicht', + FLOORPLAN: 'Grundriss', + SURROUNDINGS: 'Umgebung', + OTHER: 'Sonstiges', +} + +export const EXPOSE_DOCUMENT_TYPE_LABELS: Record = { + FLOORPLAN: 'Grundriss', + LEASE_CONTRACT: 'Mietvertrag', + ENERGY_CERTIFICATE: 'Energieausweis (GEAK)', + SITE_PLAN: 'Situationsplan', + OTHER: 'Sonstiges', +} + +export const EXPOSE_TONALITY_LABELS: Record = { + SACHLICH: 'Sachlich', + HOCHWERTIG: 'Hochwertig', + EINLADEND: 'Einladend', +} + +// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ────────────────────────────── + +export const VISIT_REQUEST_TYPE_LABELS: Record = { + PREPARATION: 'Vorbereitung', + FOLLOW_UP: 'Nachbereitung', +} + export const AGENT_STATUS_LABELS: Record = { ACTIVE: 'Aktiv', BUILDING: 'Im Aufbau', diff --git a/src/lib/ds.ts b/src/lib/ds.ts index ba4944f..a6aed1f 100644 --- a/src/lib/ds.ts +++ b/src/lib/ds.ts @@ -119,6 +119,105 @@ export const DS_SURFACE = { slate: { bg: '#f4f3f0', border: '#e8e7e4' }, } as const +// ── Neutrale Skala ──────────────────────────────────────────────────────────── + +/** + * Die kühle Graustufe, auf der Tabellen, Trennlinien und Nebentexte liegen. + * + * Sie war über rund 200 Dateien als Hex-Literal verstreut — dieselben zehn + * Werte, immer wieder abgetippt. Die Stufen entsprechen exakt den bisher + * verwendeten Werten; die Zusammenführung ändert kein einziges Pixel, macht + * aber einen späteren Themenwechsel überhaupt erst möglich. + * + * Abgrenzung zu `DS_BG`/`DS_BORDER`: jene tragen den warmen Grundton der + * Anwendung (Papier), diese Skala den kühlen für Datenflächen. + */ +export const DS_SLATE = { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', +} as const + +/** Weitere neutrale Einzelwerte ohne Platz in der Skala. */ +export const DS_NEUTRAL = { + white: '#ffffff', + offWhite: '#fafafa', + paper: '#fbfaf8', + paperTint: '#f0f4f8', + stone: '#b0aead', + graphite: '#374151', + sidebar: '#0f1923', + gold: '#b8975a', +} as const + +// ── Markenfarben ────────────────────────────────────────────────────────────── + +/** + * Das dunkle Blau der Anwendung samt seinen Hover-Stufen. + * + * `hover` und `hoverAlt` unterscheiden sich um zwei Prozent Helligkeit — sie + * sind historisch gewachsen und stehen beide im Code. Sie bleiben getrennt, + * damit die Zusammenführung nichts sichtbar verschiebt; wer sie vereinheitlichen + * will, tut das ab jetzt an einer Stelle statt an vierzig. + */ +export const DS_BRAND = { + main: '#152642', + hover: '#16304d', + hoverAlt: '#162d4a', + dark: '#0e1c30', + darker: '#1a3050', + muted: '#5a87a3', + light: '#64b5f6', +} as const + +// ── Akzentfarben nach Bedeutung ─────────────────────────────────────────────── + +/** + * Semantische Akzente. Jede Gruppe führt Vorder-, Flächen- und Randfarbe + * derselben Bedeutung, damit ein Zustand nicht auf zwei Bildschirmen zwei + * verschiedene Grüntöne bekommt. + */ +export const DS_ACCENT = { + success: { + main: '#1a7a4a', strong: '#15803d', bright: '#16a34a', emerald: '#10b981', + dark: '#155f3a', darkAlt: '#15643c', + bg: '#f0fdf4', bgAlt: '#dcfce7', border: '#86efac', + }, + warning: { + main: '#d97706', strong: '#ea580c', amber: '#f59e0b', burnt: '#c2410c', + dark: '#b45309', darker: '#854d0e', darkest: '#7a4f00', brown: '#7c3d12', + bg: '#fffbeb', bgAlt: '#fff7ed', border: '#fcd34d', borderSoft: '#fef3c7', + }, + danger: { + main: '#c0392b', strong: '#dc2626', bright: '#ef4444', dark: '#a93226', darkest: '#7f1d1d', + bg: '#fef2f2', bgAlt: '#fff1f2', border: '#fca5a5', + }, + violet: { + main: '#7c3aed', strong: '#6d28d9', light: '#8b5cf6', + dark: '#5b21b6', darkest: '#4c1d95', + bg: '#faf5ff', bgAlt: '#f5f3ff', border: '#c4b5fd', borderSoft: '#ddd6fe', tint: '#ede9fe', + }, + indigo: { + main: '#4f46e5', strong: '#4338ca', dark: '#3730a3', light: '#6366f1', + bg: '#eef2ff', border: '#e0e7ff', + }, + blue: { + main: '#1d4ed8', strong: '#2563eb', dark: '#1e40af', darkest: '#1e3a8a', + bg: '#eff6ff', bgAlt: '#f0f9ff', border: '#bfdbfe', borderSoft: '#dbeafe', + }, + cyan: { + main: '#0891b2', deep: '#0369a1', dark: '#075985', + bg: '#e0f2fe', border: '#bae6fd', + }, +} as const + // ── Match tier surface ──────────────────────────────────────────────────────── export const DS_MATCH_TIER = { diff --git a/src/lib/exposeFields.ts b/src/lib/exposeFields.ts new file mode 100644 index 0000000..59ec24e --- /dev/null +++ b/src/lib/exposeFields.ts @@ -0,0 +1,204 @@ +/** + * Property On — die fachlichen Bereiche und Felder des Exposé-Dossiers. + * + * Eine einzige Liste für Formular, Pflichtfeldprüfung, Vorschau und Export. + * Sie ist die Umsetzung der Referenzvorlage aus Runde 4, §8.5.2 — dort stehen + * dieselben Bereiche in derselben Reihenfolge. + * + * `required: true` heisst: ohne diese Angabe ist das Exposé unvollständig und + * das Feld wird rot umrandet. Der Rest ist Kür — eine Broschüre ohne Kubatur + * ist verkaufbar, eine ohne Mietpreis nicht. + */ + +export type ExposeFieldType = 'text' | 'number' | 'select' | 'multiline' | 'chips' + +export interface ExposeField { + key: string + label: string + type: ExposeFieldType + required?: boolean + options?: string[] + /** Einheit oder Hinweis in der Feldbeschriftung. */ + suffix?: string + /** Nimmt die ganze Zeilenbreite ein. */ + wide?: boolean +} + +export interface ExposeSection { + id: string + title: string + description?: string + fields: ExposeField[] + /** Abschnitt, für den sich ein KI-Textentwurf auslösen lässt. */ + aiSection?: string +} + +export const EXPOSE_SECTIONS: ExposeSection[] = [ + { + id: 'eckdaten', + title: 'Eckdaten & Vermarktung', + description: 'Objektart und Vermarktungsart steuern die Pflichtfelder des Dossiers.', + fields: [ + { key: 'objektart', label: 'Objektart', type: 'select', required: true, options: ['Büro', 'Retail', 'Gastronomie', 'Logistik', 'Gewerbe', 'Produktion', 'Gemischt'] }, + { key: 'subtyp', label: 'Subtyp', type: 'text' }, + { key: 'vermarktungsart', label: 'Vermarktungsart', type: 'select', required: true, options: ['Miete', 'Kauf', 'Untermiete'] }, + { key: 'status', label: 'Status', type: 'select', options: ['Aktiv in Vermarktung', 'In Vorbereitung', 'Reserviert', 'Abgeschlossen'] }, + { key: 'objektreferenz', label: 'Objektreferenz', type: 'text' }, + ], + }, + { + id: 'lage', + title: 'Lage', + description: 'Strukturierte Adresse; Distanzen erscheinen im Exposé und sind portal-tauglich.', + fields: [ + { key: 'strasse', label: 'Strasse', type: 'text', required: true }, + { key: 'hausnummer', label: 'Nr.', type: 'text' }, + { key: 'plz', label: 'PLZ', type: 'text', required: true }, + { key: 'ort', label: 'Ort', type: 'text', required: true }, + { key: 'gemeinde', label: 'Gemeinde', type: 'text' }, + { key: 'kanton', label: 'Kanton', type: 'text' }, + { key: 'land', label: 'Land', type: 'text' }, + ], + }, + { + id: 'koordinaten', + title: 'Koordinaten & Distanzen', + description: 'Quellen: Swisstopo, ÖV-Fahrplan, OpenStreetMap — Werte bleiben überschreibbar.', + fields: [ + { key: 'breitengrad', label: 'Breitengrad', type: 'text' }, + { key: 'laengengrad', label: 'Längengrad', type: 'text' }, + { key: 'oevHaltestelle', label: 'ÖV-Haltestelle', type: 'number', suffix: 'm' }, + { key: 'einkauf', label: 'Einkauf', type: 'number', suffix: 'm' }, + { key: 'schule', label: 'Schule / Kindergarten', type: 'number', suffix: 'm' }, + { key: 'autobahn', label: 'Autobahnanschluss', type: 'number', suffix: 'm' }, + ], + }, + { + id: 'gemeindedaten', + title: 'Gebäude- & Gemeindedaten', + description: 'Quellen: ARE (ÖV-Güteklasse), BFE Sonnendach, BFS, ESTV — überschreibbar.', + fields: [ + { key: 'oevGueteklasse', label: 'ÖV-Güteklasse', type: 'text' }, + { key: 'solareignung', label: 'Solareignung Dach', type: 'text' }, + { key: 'einwohner', label: 'Einwohner', type: 'number' }, + { key: 'bevoelkerungswachstum', label: 'Bevölkerungswachstum', type: 'number', suffix: '%/J' }, + { key: 'steuerbelastung', label: 'Steuerbelastung', type: 'number', suffix: '%' }, + { key: 'leerwohnungsziffer', label: 'Leerwohnungsziffer', type: 'number', suffix: '%' }, + ], + }, + { + id: 'flaechen', + title: 'Flächen & Gebäude', + description: 'Flächen in m², Schweizer Konvention.', + fields: [ + { key: 'zimmer', label: 'Zimmer', type: 'number' }, + { key: 'schlafzimmer', label: 'Schlafzimmer', type: 'number' }, + { key: 'badezimmer', label: 'Badezimmer', type: 'number' }, + { key: 'sepWc', label: 'Sep. WC', type: 'number' }, + { key: 'wohnflaeche', label: 'Wohnfläche', type: 'number', suffix: 'm²' }, + { key: 'flaeche', label: 'Nutzfläche', type: 'number', suffix: 'm²', required: true }, + { key: 'grundstuecksflaeche', label: 'Grundstücksfläche', type: 'number', suffix: 'm²' }, + { key: 'balkon', label: 'Balkon / Terrasse', type: 'number', suffix: 'm²' }, + { key: 'keller', label: 'Keller', type: 'number', suffix: 'm²' }, + { key: 'raumhoehe', label: 'Raumhöhe', type: 'number', suffix: 'm' }, + { key: 'kubatur', label: 'Kubatur SIA 416', type: 'number', suffix: 'm³' }, + ], + }, + { + id: 'verfuegbarkeit', + title: 'Gebäude & Verfügbarkeit', + fields: [ + { key: 'stockwerk', label: 'Stockwerk', type: 'text' }, + { key: 'geschosse', label: 'Geschosse im Gebäude', type: 'number' }, + { key: 'baujahr', label: 'Baujahr', type: 'number' }, + { key: 'letzteRenovation', label: 'Letzte Renovation', type: 'number' }, + { key: 'zustand', label: 'Zustand', type: 'select', options: ['Neuwertig', 'Gut', 'Renovationsbedürftig', 'Rohbau'] }, + { key: 'verfuegbarkeit', label: 'Verfügbarkeit', type: 'text', required: true }, + ], + }, + { + id: 'baurecht', + title: 'Grundstück & Baurecht', + description: 'Quelle: geodienste/Swisstopo — Ziffern aus dem Bau-/Zonenreglement.', + fields: [ + { key: 'nutzungszone', label: 'Nutzungszone', type: 'text' }, + { key: 'ueberbauungsziffer', label: 'Überbauungsziffer', type: 'text' }, + { key: 'ausnuetzungsziffer', label: 'Ausnützungsziffer', type: 'text' }, + ], + }, + { + id: 'preise', + title: 'Preise & Kosten', + fields: [ + { key: 'nettomiete', label: 'Nettomiete', type: 'number', suffix: 'CHF/m²/J', required: true }, + { key: 'nebenkosten', label: 'Nebenkosten', type: 'number', suffix: 'CHF/m²/J' }, + { key: 'nkArt', label: 'NK-Art', type: 'select', options: ['Akonto', 'Pauschal', 'Nach Aufwand'] }, + { key: 'kaution', label: 'Kaution', type: 'number', suffix: 'Monatsmieten' }, + { key: 'befristung', label: 'Befristung', type: 'text' }, + { key: 'parkierung', label: 'Parkierung', type: 'select', options: ['Tiefgarage', 'Aussenparkplatz', 'Besucherparkplätze', 'Keine'] }, + { key: 'parkplaetze', label: 'Anzahl Plätze', type: 'number' }, + { key: 'mietpreisParkplatz', label: 'Mietpreis Parkplatz', type: 'number', suffix: 'CHF' }, + ], + }, + { + id: 'ausstattung', + title: 'Ausstattung & Merkmale', + description: 'Mehrfachauswahl — Merkmale erscheinen als Liste im Exposé.', + fields: [ + { key: 'aussenbereich', label: 'Aussenbereich', type: 'chips', options: ['Terrasse', 'Sitzplatz', 'Garten', 'Balkon'] }, + { key: 'energieSmart', label: 'Energie & Smart', type: 'chips', options: ['Photovoltaik-Anlage', 'Wärmepumpe', 'E-Ladestation', 'Smart Building', 'Glasfaser-Anschluss'] }, + { key: 'gebaeude', label: 'Gebäude', type: 'chips', options: ['Lift', 'Rollstuhlgängig', 'Kellerabteil', 'Waschküche', 'Minergie-Standard', 'Neubau', 'Erstbezug', 'Video-Gegensprechanlage'] }, + { key: 'innenausbau', label: 'Innenausbau', type: 'chips', options: ['Teeküche', 'Klimaanlage', 'Serverraum', 'Bodenheizung', 'Einbauschränke', 'Hohe Räume', 'Gäste-WC'] }, + { key: 'parkierungMerkmale', label: 'Parkierung', type: 'chips', options: ['Tiefgaragenplatz', 'Aussenparkplatz', 'Besucherparkplätze'] }, + { key: 'umgebung', label: 'Umgebung & Sicht', type: 'chips', options: ['Seesicht', 'Bergsicht', 'Unverbaubare Weitsicht', 'Südausrichtung', 'Ruhige Lage', 'Zentrale Lage'] }, + { key: 'merkmale', label: 'Weitere Merkmale', type: 'text', wide: true }, + ], + }, + { + id: 'energie', + title: 'Energie & Technik', + fields: [ + { key: 'heizsystem', label: 'Heizsystem', type: 'select', options: ['Wärmepumpe', 'Fernwärme', 'Gas', 'Öl', 'Pellets', 'Elektro'] }, + { key: 'waermeverteilung', label: 'Wärmeverteilung', type: 'select', options: ['Bodenheizung', 'Radiatoren', 'Deckenstrahlung', 'Luft'] }, + { key: 'geak', label: 'GEAK', type: 'text' }, + { key: 'photovoltaik', label: 'Photovoltaik-Leistung', type: 'number', suffix: 'kWp' }, + { key: 'eLadestationen', label: 'E-Ladestationen', type: 'number' }, + ], + }, + { + id: 'texte', + title: 'Texte & Beschriebe', + description: 'Diese Texte bilden das Herz des Exposés. Der KI-Entwurf nutzt ausschliesslich die erfassten Objektdaten.', + aiSection: 'texte', + fields: [ + { key: 'exposeTitel', label: 'Exposé-Titel', type: 'text', required: true, wide: true }, + { key: 'kurzbeschrieb', label: 'Kurzbeschrieb (Teaser)', type: 'multiline', required: true, wide: true }, + { key: 'objektbeschrieb', label: 'Objektbeschrieb', type: 'multiline', required: true, wide: true }, + { key: 'lagebeschrieb', label: 'Lagebeschrieb', type: 'multiline', wide: true }, + { key: 'gemeindebeschrieb', label: 'Gemeindebeschrieb', type: 'multiline', wide: true }, + { key: 'ausstattungsbeschrieb', label: 'Ausstattungsbeschrieb', type: 'multiline', wide: true }, + { key: 'highlights', label: 'Highlights (eine Zeile pro Punkt)', type: 'multiline', wide: true }, + { key: 'videoUrl', label: 'Video-URL', type: 'text' }, + { key: 'rundgangUrl', label: 'Virtueller Rundgang (URL)', type: 'text' }, + ], + }, +] + +/** Alle Feldschlüssel, die für ein vollständiges Exposé gesetzt sein müssen. */ +export const EXPOSE_REQUIRED_KEYS: string[] = EXPOSE_SECTIONS + .flatMap(s => s.fields) + .filter(f => f.required) + .map(f => f.key) + +export function exposeFieldByKey(key: string): ExposeField | undefined { + for (const section of EXPOSE_SECTIONS) { + const field = section.fields.find(f => f.key === key) + if (field) return field + } + return undefined +} + +/** Welche Pflichtfelder sind noch leer? Basis für die roten Umrandungen. */ +export function missingRequiredExposeKeys(values: Record): string[] { + return EXPOSE_REQUIRED_KEYS.filter(k => !values[k] || values[k].trim() === '') +} diff --git a/src/lib/needToLatentNeedMap.ts b/src/lib/needToLatentNeedMap.ts deleted file mode 100644 index 47ccb94..0000000 --- a/src/lib/needToLatentNeedMap.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Maps a Need id (need-XXX) to its corresponding LatentNeed id (lneed-XXX). */ -export const NEED_TO_LATENT_NEED: Record = { - 'need-001': 'lneed-007', - 'need-002': 'lneed-008', - 'need-004': 'lneed-009', - 'need-005': 'lneed-010', - 'need-012': 'lneed-011', - 'need-013': 'lneed-012', - 'need-014': 'lneed-013', - 'need-015': 'lneed-014', -} diff --git a/src/lib/propertyLabels.ts b/src/lib/propertyLabels.ts new file mode 100644 index 0000000..ffea3a2 --- /dev/null +++ b/src/lib/propertyLabels.ts @@ -0,0 +1,14 @@ +/** + * Beschriftungshelfer rund um Objekte und Einheiten. + * + * Reine Funktionen, bewusst getrennt von den Komponenten, die sie benutzen: + * eine `.tsx`-Datei, die Bauteile und Hilfsfunktionen mischt, verliert bei + * jeder Änderung ihren Zustand im Entwicklungsmodus (Fast Refresh). + */ + +/** Etagenbezeichnung nach Schweizer Konvention: EG, 1.OG, UG 1. */ +export function floorLabelFromLevel(level: number): string { + if (level === 0) return 'EG' + if (level < 0) return `UG ${Math.abs(level)}` + return `${level}.OG` +} diff --git a/src/lib/speechRecognition.ts b/src/lib/speechRecognition.ts new file mode 100644 index 0000000..6a74430 --- /dev/null +++ b/src/lib/speechRecognition.ts @@ -0,0 +1,89 @@ +/** + * Typsicherer Adapter für die Web Speech API. + * + * Die Schnittstelle ist kein Standard, sondern eine Herstellererweiterung — + * TypeScript kennt sie deshalb nicht, und Chrome führt sie bis heute unter + * `webkitSpeechRecognition`. Nach CLAUDE.md §5.4 ist genau das der zulässige + * Fall für einen Cast, sofern er in einem Adapter eingeschlossen bleibt: hier + * steht er an einer einzigen Stelle, alle Aufrufer arbeiten mit echten Typen. + */ + +export interface SpeechResultAlternative { + transcript: string + confidence: number +} + +export interface SpeechResult { + readonly length: number + isFinal: boolean + [index: number]: SpeechResultAlternative +} + +export interface SpeechResultList { + readonly length: number + [index: number]: SpeechResult +} + +export interface SpeechRecognitionEvent { + resultIndex: number + results: SpeechResultList +} + +/** Die von dieser Anwendung genutzte Teilmenge — nicht die gesamte API. */ +export interface SpeechRecognizer { + lang: string + continuous: boolean + interimResults: boolean + onresult: ((event: SpeechRecognitionEvent) => void) | null + onend: (() => void) | null + onerror: (() => void) | null + start(): void + stop(): void +} + +type SpeechRecognizerConstructor = new () => SpeechRecognizer + +interface SpeechCapableWindow { + SpeechRecognition?: SpeechRecognizerConstructor + webkitSpeechRecognition?: SpeechRecognizerConstructor +} + +function speechWindow(): SpeechCapableWindow { + return window as unknown as SpeechCapableWindow +} + +/** Unterstützt der Browser Spracherkennung? Steuert die Sichtbarkeit des Knopfs. */ +export function isSpeechRecognitionSupported(): boolean { + const w = speechWindow() + return typeof window !== 'undefined' && Boolean(w.SpeechRecognition ?? w.webkitSpeechRecognition) +} + +/** Liefert einen Erkenner oder `null`, wenn der Browser keinen anbietet. */ +export function createSpeechRecognizer(lang: string): SpeechRecognizer | null { + const w = speechWindow() + const Ctor = w.SpeechRecognition ?? w.webkitSpeechRecognition + if (!Ctor) return null + + const recognizer = new Ctor() + recognizer.lang = lang + recognizer.continuous = true + recognizer.interimResults = true + return recognizer +} + +/** + * Zerlegt ein Ergebnis in den bereits endgültigen und den noch vorläufigen Teil. + * Die Trennung ist der eigentliche Zweck der API — sie hier zu kapseln erspart + * jedem Aufrufer die Indexschleife über zwei verschachtelte Listen. + */ +export function splitTranscript(event: SpeechRecognitionEvent): { final: string; interim: string } { + let final = '' + let interim = '' + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i] + const text = result[0].transcript + if (result.isFinal) final += text + else interim += text + } + return { final, interim } +} diff --git a/src/mock-data/agentConnections.ts b/src/mock-data/agentConnections.ts index 73a7181..aa1266f 100644 --- a/src/mock-data/agentConnections.ts +++ b/src/mock-data/agentConnections.ts @@ -8,7 +8,7 @@ * Verbindungsassistent eine echte Aufgabe hat; die Telefonie ist als * `ROADMAP` ausgewiesen. * - * `usedByAgentIds` ist aus den Kanälen und Systemen der sieben Personalblätter + * `usedByAgentIds` ist aus den Kanälen und Systemen der fünf Personalblätter * abgeleitet und nicht frei gesetzt: E-Mail nutzen alle, den Kalender die vier * Mitarbeiter mit Terminbezug, die Dokumentenablage alle mit Aktenbezug. * Reihenfolge alphabetisch nach Vorname, wie in `mockTeamAgents`. @@ -32,15 +32,7 @@ export const mockAgentConnections: AgentConnection[] = [ 'Die geteilten Postfächer von Bewirtschaftung und Vermarktung: Fristenmeldungen, Anfragen von Interessenten, Rückfragen, Absagen und Terminvorschläge laufen über diese vier Adressen. Eingehende Post wird gelesen und dem richtigen Objekt zugeordnet; ausgehende Post folgt der Regel des jeweiligen Mitarbeiters — Erinnerungen gehen selbständig hinaus, Eskalationen erst nach Freigabe.', status: AgentConnectionStatus.CONNECTED, accountCount: 4, - usedByAgentIds: [ - AGENT_IDS.BRUNO, - AGENT_IDS.FERDI, - AGENT_IDS.LEA, - AGENT_IDS.LIVIA, - AGENT_IDS.NORA, - AGENT_IDS.RETO, - AGENT_IDS.SINA, - ], + usedByAgentIds: [AGENT_IDS.BRUNO, AGENT_IDS.FERDI, AGENT_IDS.LIVIA, AGENT_IDS.NORA, AGENT_IDS.SINA], lastSyncAt: '2026-05-20T08:42:00.000Z', connectionLabel: 'Postfächer Zürich, Winterthur, Basel', permissions: [ @@ -86,7 +78,7 @@ export const mockAgentConnections: AgentConnection[] = [ 'Der kurze Draht zu den Personen unterwegs: Meldung aufs Diensthandy, wenn eine Frist in weniger als 30 Tagen abläuft und am Arbeitsplatz niemand erreichbar ist, sowie Eingang der Sprachmemos direkt nach einem Aussentermin — etwa nach der Begehung an der Bahnhofstrasse 44 in Zürich (OBJ-2451).', status: AgentConnectionStatus.CONNECTED, accountCount: 2, - usedByAgentIds: [AGENT_IDS.FERDI, AGENT_IDS.RETO], + usedByAgentIds: [AGENT_IDS.FERDI, AGENT_IDS.BRUNO], lastSyncAt: '2026-05-19T17:35:00.000Z', connectionLabel: 'Diensthandys Bewirtschaftung und Vermarktung', permissions: [ @@ -164,7 +156,7 @@ export const mockAgentConnections: AgentConnection[] = [ 'Fristenkalender der Bewirtschaftung, Besichtigungskalender der Vermarktung und der Terminkalender für Aussentermine. Gelesen werden freie Zeitfenster und bestehende Termine; geschrieben werden bestätigte Fristen mit ihren Vorlaufmarken, vorläufige Reservationen für Terminvorschläge und die bestätigte Besichtigung samt Erinnerung. Fremde Termine werden nie verschoben.', status: AgentConnectionStatus.CONNECTED, accountCount: 3, - usedByAgentIds: [AGENT_IDS.BRUNO, AGENT_IDS.FERDI, AGENT_IDS.LEA, AGENT_IDS.RETO], + usedByAgentIds: [AGENT_IDS.BRUNO, AGENT_IDS.FERDI], lastSyncAt: '2026-05-20T08:15:00.000Z', connectionLabel: 'Fristen-, Besichtigungs- und Terminkalender', permissions: [ @@ -210,13 +202,7 @@ export const mockAgentConnections: AgentConnection[] = [ 'Mietverträge, Nachträge, Zusatzvereinbarungen, Übergabeprotokolle, Hausordnungen und Objektunterlagen — aus der zentralen Ablage, den Standortbibliotheken von Zürich, Winterthur und St. Gallen sowie den historischen Vertragsordnern der übernommenen Mandate. Jede Aussage aus einem Dokument wird mit Dateiname, Seite und Zitat belegt.', status: AgentConnectionStatus.CONNECTED, accountCount: 4, - usedByAgentIds: [ - AGENT_IDS.BRUNO, - AGENT_IDS.FERDI, - AGENT_IDS.LIVIA, - AGENT_IDS.RETO, - AGENT_IDS.SINA, - ], + usedByAgentIds: [AGENT_IDS.BRUNO, AGENT_IDS.FERDI, AGENT_IDS.LIVIA, AGENT_IDS.SINA], lastSyncAt: '2026-05-20T05:30:00.000Z', connectionLabel: 'Ablage Bewirtschaftung und Standortbibliotheken', permissions: [ @@ -262,7 +248,7 @@ export const mockAgentConnections: AgentConnection[] = [ 'Geplante Anbindung der Telefonanlage: Anrufe von Interessenten am Empfang in Zürich und Winterthur sowie die Diktatlinie für Sprachmemos nach einem Aussentermin. Bis zur Freigabe erfasst der Empfang die Telefonnotizen von Hand, und die Sprachmemos kommen über das Diensthandy herein.', status: AgentConnectionStatus.ROADMAP, accountCount: 0, - usedByAgentIds: [AGENT_IDS.LEA, AGENT_IDS.RETO], + usedByAgentIds: [AGENT_IDS.BRUNO], permissions: [ { id: 'conn-phone-voice-perm-01', @@ -300,13 +286,7 @@ export const mockAgentConnections: AgentConnection[] = [ 'Objekt- und Mieterstammdaten, Vertragslaufzeiten, Verfügbarkeiten, Must-Kriterien der ausgeschriebenen Flächen und die Terminexporte. Der Zugang ist noch nicht eingerichtet: bis dahin stützen sich Fristen, Vertragsauskünfte und Anfragenprüfung auf die Excel-Terminlisten und die Unterlagen der Standortteams. Der Verbindungsassistent richtet den Lesezugang ein.', status: AgentConnectionStatus.DISCONNECTED, accountCount: 0, - usedByAgentIds: [ - AGENT_IDS.FERDI, - AGENT_IDS.LEA, - AGENT_IDS.LIVIA, - AGENT_IDS.RETO, - AGENT_IDS.SINA, - ], + usedByAgentIds: [AGENT_IDS.FERDI, AGENT_IDS.LIVIA, AGENT_IDS.BRUNO, AGENT_IDS.SINA], vendor: AgentErpVendor.IMMOTOP2, permissions: [ { @@ -351,7 +331,7 @@ export const mockAgentConnections: AgentConnection[] = [ 'Führt Interessenten, Bedarfsprofile, Anfrageverlauf und Terminstatus je Fläche und erkennt, wenn dieselbe Firma bereits über Portal, Formular und Telefon angefragt hat. Hier landen auch die freigegebenen Leads der Marktbeobachtung — zuletzt die Expansion der Vollenweider Gastro AG Richtung Winterthur.', status: AgentConnectionStatus.CONNECTED, accountCount: 1, - usedByAgentIds: [AGENT_IDS.LEA, AGENT_IDS.NORA, AGENT_IDS.RETO], + usedByAgentIds: [AGENT_IDS.NORA, AGENT_IDS.BRUNO], lastSyncAt: '2026-05-19T19:05:00.000Z', connectionLabel: 'Vermarktung Zürich', permissions: [ diff --git a/src/mock-data/agentDirectory.ts b/src/mock-data/agentDirectory.ts index 231e241..1ab4ca8 100644 --- a/src/mock-data/agentDirectory.ts +++ b/src/mock-data/agentDirectory.ts @@ -8,7 +8,7 @@ * Abgrenzung zu `mockTeamAgents`: dieses Verzeichnis trägt nur die Stammdaten, * die für die Kreisdarstellung nötig sind. Ein vollständiges Personaldossier — * Aufgaben, Kanäle, Systemzugänge, Einstellungen, Protokoll — besitzen - * ausschliesslich die sieben Kernteammitglieder in `src/mock-data/agents/`. + * ausschliesslich die fünf Kernteammitglieder in `src/mock-data/agents/`. * Für die übrigen 29 werden bewusst keine Inhalte erfunden. * * ACHTUNG: generiert aus dem Konzept. Nicht von Hand pflegen — bei einer neuen @@ -19,16 +19,6 @@ import type { AgentDirectoryEntry } from '../domain/agentDirectory' import { AgentLevel, AgentDepartment, AgentRollout } from '../domain/agentDirectory' export const agentDirectory: AgentDirectoryEntry[] = [ - { - id: 'bruno', - name: 'Bruno', - role: 'Besichtigungs-Briefing', - email: 'bruno@property-on.ch', - level: AgentLevel.CORE, - department: AgentDepartment.CENTRAL_SERVICES, - rollout: AgentRollout.IN_PROGRESS, - isCore: true, - }, { id: 'ferdi', name: 'Ferdi', @@ -40,19 +30,19 @@ export const agentDirectory: AgentDirectoryEntry[] = [ isCore: true, }, { - id: 'lea', - name: 'Lea', - role: 'Anfragen-Manager', - email: 'lea@property-on.ch', + id: 'bruno', + name: 'Bruno', + role: 'Besichtigungsassistent', + email: 'bruno@property-on.ch', level: AgentLevel.CORE, department: AgentDepartment.CENTRAL_SERVICES, - rollout: AgentRollout.PROTOTYPE, + rollout: AgentRollout.IN_PROGRESS, isCore: true, }, { id: 'livia', name: 'Livia', - role: 'Lage-Analyst', + role: 'Exposé Master', email: 'livia@property-on.ch', level: AgentLevel.CORE, department: AgentDepartment.CENTRAL_SERVICES, @@ -62,27 +52,17 @@ export const agentDirectory: AgentDirectoryEntry[] = [ { id: 'nora', name: 'Nora', - role: 'Markt-Scout', + role: 'Marktchancen / Leads', email: 'nora@property-on.ch', level: AgentLevel.CORE, department: AgentDepartment.CENTRAL_SERVICES, rollout: AgentRollout.PROTOTYPE, isCore: true, }, - { - id: 'reto', - name: 'Reto', - role: 'Recap-Agent', - email: 'reto@property-on.ch', - level: AgentLevel.CORE, - department: AgentDepartment.CENTRAL_SERVICES, - rollout: AgentRollout.IN_PROGRESS, - isCore: true, - }, { id: 'sina', name: 'Sina', - role: 'Vertragsauskunft', + role: 'Datenpflege', email: 'sina@property-on.ch', level: AgentLevel.CORE, department: AgentDepartment.CENTRAL_SERVICES, @@ -381,5 +361,5 @@ export const agentDirectory: AgentDirectoryEntry[] = [ }, ] -/** Nur die sieben Kernteammitglieder — sie besitzen ein vollständiges Dossier. */ +/** Nur die fünf Kernteammitglieder — sie besitzen ein vollständiges Dossier. */ export const coreAgentIds: string[] = agentDirectory.filter(a => a.isCore).map(a => a.id) diff --git a/src/mock-data/agentProtocol.ts b/src/mock-data/agentProtocol.ts index e82d4a4..c327119 100644 --- a/src/mock-data/agentProtocol.ts +++ b/src/mock-data/agentProtocol.ts @@ -1,5 +1,5 @@ /** - * Property On — Protokoll der sieben digitalen Mitarbeiter. + * Property On — Protokoll der fünf digitalen Mitarbeiter. * * Jeder Eintrag hält fest, was ein Agent getan hat, wer es ausgelöst hat und mit * welchem Ergebnis. Die Liste ist absteigend nach Zeitstempel sortiert; die @@ -29,7 +29,7 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ // ── Heute, 20.05.2026 ─────────────────────────────────────────────────────── { id: 'proto-001', - agentId: AGENT_IDS.RETO, + agentId: AGENT_IDS.BRUNO, timestamp: '2026-05-20T16:42:00.000Z', eventType: AgentProtocolEventType.TASK_RUN, title: 'Sprachmemo zur Besichtigung Technoparkstrasse 9 verarbeitet', @@ -111,7 +111,7 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ }, { id: 'proto-002', - agentId: AGENT_IDS.RETO, + agentId: AGENT_IDS.BRUNO, timestamp: '2026-05-20T16:05:00.000Z', eventType: AgentProtocolEventType.APPROVAL, title: 'Recap-Paket zur Besichtigung Industriestrasse 14 freigegeben', @@ -124,76 +124,6 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ triggeredByName: OPERATOR, approvalDecision: AgentApprovalDecision.APPROVED, }, - { - id: 'proto-003', - agentId: AGENT_IDS.LEA, - timestamp: '2026-05-20T15:20:00.000Z', - eventType: AgentProtocolEventType.MESSAGE_PROCESSED, - title: 'Portal-Anfrage zur Hardturmstrasse 118 qualifiziert', - description: - 'Lead-Mail einer Plattform erfasst, in ein einheitliches Bedarfsprofil überführt und gegen die Must-Kriterien der Fläche geprüft. Alle fünf Kriterien sind erfüllt; das Kurzprofil liegt zur Entscheidung bereit.', - status: AgentProtocolStatus.SUCCESS, - objectId: 'OBJ-2512', - channel: AgentChannelType.PORTAL_LEAD_MAIL, - triggeredBy: AgentTriggerSource.SYSTEM, - input: [ - { label: 'Herkunftskanal', value: 'Portal-Lead-Mail, Eingang 20.05.2026, 15:12' }, - { label: 'Interessent', value: 'Limmat Robotics AG, Zürich — R. Amrein, Geschäftsleitung' }, - { - label: 'Anfragetext', - value: - '«Wir suchen per Herbst 2026 rund 500 m² Bürofläche in Zürich West, gerne mit Werkstattanteil im Erdgeschoss.»', - }, - { label: 'Objektbezug', value: 'OBJ-2512, Hardturmstrasse 118, Zürich Kreis 5' }, - ], - processingSteps: [ - { - id: 'proto-003-ps-1', - label: 'Lead-Mail erfasst und der ausgeschriebenen Fläche zugeordnet', - }, - { - id: 'proto-003-ps-2', - label: 'Freitext in ein Bedarfsprofil überführt', - detail: 'Fläche, Nutzungsart, Wunschtermin, Budgetrahmen und Vertragsdauer übernommen', - }, - { - id: 'proto-003-ps-3', - label: 'Abgleich über die letzten 90 Tage auf Mehrfachanfragen', - detail: 'Keine Übereinstimmung über Portal, Formular oder Telefon gefunden', - }, - { id: 'proto-003-ps-4', label: 'Bedarfsprofil gegen die Must-Kriterien der Fläche geprüft' }, - { id: 'proto-003-ps-5', label: 'Kurzprofil zur Entscheidung vorgelegt' }, - ], - output: [ - { label: 'Bedarfsprofil', value: '480–560 m², Büro mit Werkstattanteil, Bezug ab 01.10.2026' }, - { - label: 'Kriterienprüfung', - value: [ - 'Flächenbedarf im ausgeschriebenen Rahmen — erfüllt', - 'Nutzungsart für das Objekt zulässig — erfüllt', - 'Wunschtermin passt zur Verfügbarkeit — erfüllt', - 'Budgetrahmen erreicht die Mietpreisvorstellung — erfüllt', - 'Bonitätsnachweis kann beigebracht werden — erfüllt', - ], - }, - { label: 'Nächster Schritt', value: 'Freigabe für Terminvorschläge einholen' }, - ], - sourceReferences: [ - { - id: 'proto-003-src-1', - label: 'Must-Kriterien der Fläche', - documentName: 'Vermarktungsauftrag Hardturmstrasse 118.pdf', - locator: 'Seite 2, Abschnitt «Zielmieterschaft»', - quote: '«Mindestvertragsdauer fünf Jahre, Nutzung Büro und stiller Werkstattbetrieb zulässig»', - }, - { - id: 'proto-003-src-2', - label: 'Flächenstammdaten OBJ-2512', - documentName: 'Bewirtschaftungssystem — Objektblatt OBJ-2512', - locator: 'Verfügbarkeit ab 01.09.2026', - }, - ], - }, { id: 'proto-004', agentId: AGENT_IDS.BRUNO, @@ -517,21 +447,6 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ }, ], }, - { - id: 'proto-010', - agentId: AGENT_IDS.LEA, - timestamp: '2026-05-20T08:20:00.000Z', - eventType: AgentProtocolEventType.REJECTION, - title: 'Absageentwurf an die Säntis Retail AG zurückgewiesen', - description: - 'Der vorbereitete Absagetext wurde nicht freigegeben: Der Flächenbedarf liegt nur knapp unter dem ausgeschriebenen Rahmen, der Fall gilt damit als Grenzfall. Die Anfrage ist zurück in die Entscheidung gelegt worden.', - status: AgentProtocolStatus.SUCCESS, - objectId: 'OBJ-2568', - channel: AgentChannelType.EMAIL, - triggeredBy: AgentTriggerSource.USER, - triggeredByName: OPERATOR, - approvalDecision: AgentApprovalDecision.REJECTED, - }, { id: 'proto-011', agentId: AGENT_IDS.FERDI, @@ -601,21 +516,6 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ }, // ── Laufende Woche, 18.–19.05.2026 ────────────────────────────────────────── - { - id: 'proto-012', - agentId: AGENT_IDS.LEA, - timestamp: '2026-05-19T17:05:00.000Z', - eventType: AgentProtocolEventType.APPROVAL, - title: 'Terminvorschläge für die Limmat Robotics AG freigegeben', - description: - 'Nach der Freigabe sind drei Zeitfenster im Besichtigungskalender vorläufig reserviert und dem Interessenten zugestellt worden. Termindauer 45 Minuten, Pufferzeit 15 Minuten vor und nach dem Termin.', - status: AgentProtocolStatus.SUCCESS, - objectId: 'OBJ-2512', - channel: AgentChannelType.CALENDAR, - triggeredBy: AgentTriggerSource.USER, - triggeredByName: OPERATOR, - approvalDecision: AgentApprovalDecision.APPROVED, - }, { id: 'proto-013', agentId: AGENT_IDS.NORA, @@ -632,7 +532,7 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ }, { id: 'proto-014', - agentId: AGENT_IDS.RETO, + agentId: AGENT_IDS.BRUNO, timestamp: '2026-05-19T14:12:00.000Z', eventType: AgentProtocolEventType.TASK_RUN, title: 'Kein Systemzugang zum Kontaktjournal — Mail-Fallback gegriffen', @@ -676,7 +576,7 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ { id: 'proto-014-src-1', label: 'Fallback-Einstellung', - documentName: 'Einstellungen Reto — Fallback-Ziel', + documentName: 'Einstellungen Bruno — Fallback-Ziel', locator: 'Wert «Strukturierter Mail-Fallback an die zuständige Person»', }, { @@ -813,33 +713,8 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ channel: AgentChannelType.WORKSPACE_CHAT, triggeredBy: AgentTriggerSource.SYSTEM, }, - { - id: 'proto-021', - agentId: AGENT_IDS.LEA, - timestamp: '2026-05-18T06:40:00.000Z', - eventType: AgentProtocolEventType.MESSAGE_PROCESSED, - title: 'Telefonnotiz aus dem Empfang Winterthur nur teilweise auswertbar', - description: - 'Aus der Notiz liessen sich Name und Firma übernehmen, Flächenbedarf und Wunschtermin fehlen. Ein Rückfrageentwurf mit den fehlenden Mindestangaben liegt zur Freigabe bereit; die Anfrage bleibt bis dahin offen.', - status: AgentProtocolStatus.WARNING, - objectId: 'OBJ-2568', - channel: AgentChannelType.PHONE_NOTE, - triggeredBy: AgentTriggerSource.SYSTEM, - }, // ── Laufender Monat, 01.–15.05.2026 ───────────────────────────────────────── - { - id: 'proto-022', - agentId: AGENT_IDS.LEA, - timestamp: '2026-05-15T15:45:00.000Z', - eventType: AgentProtocolEventType.SETTINGS_SAVED, - title: 'Must-Kriterien und Termindauer gespeichert', - description: - 'Das Kriterium «Vertragsdauer mindestens fünf Jahre» wurde ergänzt und die Termindauer von 60 auf 45 Minuten gesetzt. Die Änderung gilt ab dem nächsten Anfrageeingang.', - status: AgentProtocolStatus.SUCCESS, - triggeredBy: AgentTriggerSource.USER, - triggeredByName: OPERATOR, - }, { id: 'proto-023', agentId: AGENT_IDS.SINA, @@ -947,7 +822,7 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ }, { id: 'proto-027', - agentId: AGENT_IDS.RETO, + agentId: AGENT_IDS.BRUNO, timestamp: '2026-05-08T16:30:00.000Z', eventType: AgentProtocolEventType.REMINDER_SENT, title: 'Erinnerung an fehlendes Diktat nach der Besichtigung Industriestrasse 14', @@ -971,19 +846,6 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ channel: AgentChannelType.WORKSPACE_CHAT, triggeredBy: AgentTriggerSource.SYSTEM, }, - { - id: 'proto-029', - agentId: AGENT_IDS.LEA, - timestamp: '2026-05-06T13:40:00.000Z', - eventType: AgentProtocolEventType.REMINDER_SENT, - title: 'Terminreminder 24 Stunden vor der Besichtigung versandt', - description: - 'Interessent und Ansprechperson wurden an die Besichtigung vom 07.05.2026, 09:30 Uhr erinnert — mit Treffpunkt, Dauer und Ansprechperson vor Ort. Beide haben den Termin bestätigt.', - status: AgentProtocolStatus.SUCCESS, - objectId: 'OBJ-2512', - channel: AgentChannelType.EMAIL, - triggeredBy: AgentTriggerSource.SYSTEM, - }, { id: 'proto-030', agentId: AGENT_IDS.NORA, @@ -1067,7 +929,7 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ }, { id: 'proto-033', - agentId: AGENT_IDS.RETO, + agentId: AGENT_IDS.BRUNO, timestamp: '2026-04-08T09:45:00.000Z', eventType: AgentProtocolEventType.EDIT, title: 'Besichtigungsprotokoll vor der Freigabe angepasst', @@ -1119,64 +981,6 @@ export const mockAgentProtocol: AgentProtocolEntry[] = [ triggeredBy: AgentTriggerSource.USER, triggeredByName: OPERATOR, }, - { - id: 'proto-037', - agentId: AGENT_IDS.LEA, - timestamp: '2026-02-19T16:35:00.000Z', - eventType: AgentProtocolEventType.MESSAGE_PROCESSED, - title: 'Anfragen-Postfach nicht erreichbar — 14 Anfragen unbearbeitet', - description: - 'Das Anfragen-Postfach der Vermarktung liess sich über zwei Stunden nicht öffnen. 14 eingegangene Anfragen wurden weder erfasst noch beantwortet; die durchschnittliche Erstreaktionszeit stieg an diesem Tag auf 3 Stunden 40 Minuten.', - status: AgentProtocolStatus.ERROR, - channel: AgentChannelType.EMAIL, - triggeredBy: AgentTriggerSource.SYSTEM, - input: [ - { label: 'Betroffenes Postfach', value: 'Anfragen-Postfach Vermarktung' }, - { label: 'Ausfallzeitraum', value: '19.02.2026, 14:20 bis 16:30' }, - { - label: 'Betroffene Kanäle', - value: ['Direktmails von Interessenten', 'Website-Formular «Fläche anfragen»'], - }, - ], - processingSteps: [ - { id: 'proto-037-ps-1', label: 'Abruf des Postfachs planmässig gestartet' }, - { - id: 'proto-037-ps-2', - label: 'Postfach nicht erreichbar', - detail: 'Acht Versuche im Abstand von fünfzehn Minuten, alle ohne Verbindung', - }, - { - id: 'proto-037-ps-3', - label: 'Automatische Absagen für den Zeitraum ausgesetzt', - detail: 'Ohne vollständiges Bedarfsprofil wird nie abgesagt', - }, - { id: 'proto-037-ps-4', label: 'Störung an die Vermarktung gemeldet' }, - { id: 'proto-037-ps-5', label: 'Nachlauf nach Wiederherstellung eingeplant' }, - ], - output: [ - { label: 'Nicht erfasste Anfragen', value: '14, davon 9 über das Website-Formular' }, - { label: 'Erstreaktionszeit an diesem Tag', value: '3 Std. 40 Min. statt 11 Minuten' }, - { - label: 'Behebung', - value: - 'Nach Wiederherstellung um 16:30 wurden alle 14 Anfragen nacherfasst, dedupliziert und bis 18:10 qualifiziert.', - }, - ], - sourceReferences: [ - { - id: 'proto-037-src-1', - label: 'Protokoll der Abrufversuche', - documentName: 'Systemprotokoll Lea — Postfachabrufe 19.02.2026', - locator: 'Einträge 14:20 bis 16:20', - }, - { - id: 'proto-037-src-2', - label: 'Störungsmeldung an die Vermarktung', - documentName: 'Team-Kanal Vermarktung Zürich', - locator: 'Meldung vom 19.02.2026, 14:52', - }, - ], - }, { id: 'proto-038', agentId: AGENT_IDS.BRUNO, diff --git a/src/mock-data/agentWorkItems.ts b/src/mock-data/agentWorkItems.ts index 592be5a..2da21ef 100644 --- a/src/mock-data/agentWorkItems.ts +++ b/src/mock-data/agentWorkItems.ts @@ -10,9 +10,9 @@ * heute, diese Woche, diesen Monat und dieses Jahr verteilt, damit jeder Filter * der Auswertung Daten findet: * - * heute (20.05.) ferdi-01, ferdi-02, sina-01, lea-02, bruno-02 - * diese Woche (18.–19.) lea-01, reto-02, sina-02 - * dieser Monat (01.–17.) reto-01, livia-02, nora-02 + * heute (20.05.) ferdi-01, ferdi-02, sina-01, bruno-02 + * diese Woche (18.–19.) bruno-r02, sina-02 + * dieser Monat (01.–17.) bruno-r01, livia-02, nora-02 * dieses Jahr (Jan–Apr) bruno-01, nora-01, livia-01 * * Sämtliche Objekte, Firmen und Personen sind erfunden. Es werden keine echten @@ -627,426 +627,6 @@ Sina — Vertragsauskunft`, ], }, - // ── Lea — erledigt ───────────────────────────────────────────────────────── - { - id: 'awi-lea-01', - agentId: AGENT_IDS.LEA, - kind: AgentWorkItemKind.MESSAGE_TRIAGE, - area: AgentDomainArea.INQUIRIES, - title: 'Nachrichteneingang sortiert — 14 Anfragen zugeordnet, ein Duplikat zusammengeführt', - summary: - 'Die Vormittagssichtung hat 14 eingegangene Nachrichten den ausgeschriebenen Flächen zugeordnet. Eine Portal-Lead-Mail und eine Telefonnotiz betrafen dieselbe Person und wurden zu einem Vorgang zusammengeführt.', - objectId: 'OBJ-2365', - objectLabel: 'Hardturmstrasse 118, 8005 Zürich — Bürofläche 640 m², bezugsbereit ab 01.10.2026', - sourceChannel: AgentChannelType.PORTAL_LEAD_MAIL, - targetChannel: AgentChannelType.EMAIL, - createdAt: '2026-05-19T10:41:00.000Z', - completedAt: '2026-05-19T10:52:00.000Z', - priority: AgentWorkItemPriority.MEDIUM, - status: AgentWorkItemStatus.COMPLETED, - requiresDecision: false, - inputs: [ - { - label: 'Eingegangene Nachrichten', - value: [ - '9 Portal-Lead-Mails der Immobilienplattformen', - '3 Direktmails an anfragen@property-on.ch', - '1 Website-Formular «Fläche anfragen»', - '1 Telefonnotiz des Empfangs Zürich', - ], - }, - { label: 'Abgleichzeitraum für Duplikate', value: 'Anfragen der letzten 90 Tage, Stand 19.05.2026' }, - { label: 'Betroffene Flächen', value: '6 ausgeschriebene Flächen in Zürich, Winterthur und Zug' }, - ], - result: [ - { label: 'Zugeordnete Anfragen', value: '14 von 14 — keine Nachricht blieb ohne Flächenbezug' }, - { - label: 'Zusammengeführtes Duplikat', - value: - 'Marcel Sennhauser, Limmatgrund Treuhand AG — Portal-Lead-Mail vom 19.05.2026, 09:14 Uhr und Telefonnotiz vom 19.05.2026, 10:02 Uhr betreffen dieselbe Fläche OBJ-2365.', - }, - { label: 'Unvollständige Anfragen', value: '2 — Rückfrageentwürfe für die Sammelvorlage 08:30 Uhr erstellt' }, - { label: 'Eingangsbestätigungen versandt', value: '13 (das Duplikat wurde nur einmal bestätigt)' }, - { label: 'Durchschnittliche Erstreaktionszeit', value: '9 Minuten' }, - ], - processingSteps: [ - { - id: 'awi-lea-01-step-1', - label: 'Nachrichten aus allen Kanälen eingesammelt', - detail: 'Portal-Postfach, Anfragen-Postfach, Website-Formular und Telefonnotizen abgeholt.', - }, - { - id: 'awi-lea-01-step-2', - label: 'Jede Nachricht einer ausgeschriebenen Fläche zugeordnet', - detail: 'Zuordnung über Objektnummer, Adresse oder Inseratsbezug in der Nachricht.', - }, - { - id: 'awi-lea-01-step-3', - label: 'Doppelte Anfragen erkannt', - detail: 'Abgleich von Name, Firma, Telefonnummer und E-Mail über die letzten 90 Tage.', - }, - { - id: 'awi-lea-01-step-4', - label: 'Duplikat zu einem Vorgang zusammengeführt', - detail: 'Telefonnotiz als zweiter Kontaktpunkt am bestehenden Vorgang vermerkt.', - }, - { - id: 'awi-lea-01-step-5', - label: 'Eingangsbestätigungen versandt und protokolliert', - detail: 'Je Interessent eine Bestätigung, Verlauf im Anfrage-Log abgelegt.', - }, - ], - sourceReferences: [ - { - id: 'awi-lea-01-src-1', - label: 'Anfrage-Log Fläche OBJ-2365', - documentName: 'Anfrage-Log_OBJ-2365_2026.csv', - locator: 'Zeilen 41–48', - }, - { - id: 'awi-lea-01-src-2', - label: 'Telefonnotiz Empfang Zürich', - documentName: 'Telefonnotiz_2026-05-19_Empfang-Zuerich.txt', - locator: 'Eintrag 10:02 Uhr', - }, - ], - messageThread: [ - { - id: 'awi-lea-01-msg-1', - at: '2026-05-19T09:14:00.000Z', - from: 'lead-service@plattform-immobilien.ch', - to: 'portal-leads@property-on.ch', - channel: AgentChannelType.PORTAL_LEAD_MAIL, - subject: 'Neue Anfrage zu Ihrem Inserat «Bürofläche 640 m², Zürich West»', - body: `Guten Tag - -Zu Ihrem Inserat ist eine neue Anfrage eingegangen. - - Inserat: Bürofläche 640 m², Hardturmstrasse 118, 8005 Zürich - Name: Marcel Sennhauser - Firma: Limmatgrund Treuhand AG, Zürich - Telefon: +41 44 288 51 20 - E-Mail: m.sennhauser@limmatgrund-treuhand.ch - - Nachricht: «Wir suchen per Herbst 2026 rund 600 m² Bürofläche in Zürich West - für 42 Mitarbeitende. Ist eine Besichtigung möglich? Sechs Parkplätze wären - wichtig.» - -Freundliche Grüsse -Plattform Immobilien — Lead-Service`, - }, - { - id: 'awi-lea-01-msg-2', - at: '2026-05-19T10:02:00.000Z', - from: 'Empfang Zürich', - to: 'telefonnotiz@property-on.ch', - channel: AgentChannelType.PHONE_NOTE, - subject: 'Telefonnotiz — Herr Sennhauser, Limmatgrund Treuhand', - body: `Anruf 10:02 Uhr. Herr Sennhauser von der Limmatgrund Treuhand AG hat sich nach der Bürofläche an der Hardturmstrasse erkundigt. Er habe bereits über das Portal angefragt und wolle sichergehen, dass die Anfrage angekommen sei. Rückruf erbeten auf +41 44 288 51 20.`, - }, - { - id: 'awi-lea-01-msg-3', - at: '2026-05-19T10:52:00.000Z', - from: 'lea@property-on.ch', - to: 'm.sennhauser@limmatgrund-treuhand.ch', - channel: AgentChannelType.EMAIL, - subject: 'Ihre Anfrage zur Bürofläche Hardturmstrasse 118, Zürich — Eingang bestätigt', - body: `Guten Tag Herr Sennhauser - -Besten Dank für Ihre Anfrage zur Bürofläche an der Hardturmstrasse 118 in Zürich sowie für Ihren Anruf von heute Vormittag. Beide Kontakte betreffen denselben Vorgang; wir führen sie unter einer Anfrage. - -Ihre Angaben sind vollständig erfasst: - Flächenbedarf rund 600 m² - Nutzung Büro, 42 Arbeitsplätze - Mietbeginn Herbst 2026 - Parkplätze 6 - -Die zuständige Person der Vermarktung meldet sich innerhalb von zwei Arbeitstagen bei Ihnen. - -Freundliche Grüsse -Lea — Anfragen-Managerin -Property On, Vermarktung`, - }, - ], - availableActions: [AgentWorkItemAction.OPEN_SOURCE, AgentWorkItemAction.MARK_DONE], - history: [ - { - id: 'awi-lea-01-hist-1', - at: '2026-05-19T10:41:00.000Z', - actor: 'Lea', - action: 'Sichtung gestartet', - note: '14 Nachrichten aus vier Kanälen übernommen.', - }, - { - id: 'awi-lea-01-hist-2', - at: '2026-05-19T10:52:00.000Z', - actor: 'Lea', - action: 'Sichtung abgeschlossen', - note: 'Ein Duplikat zusammengeführt, 13 Eingangsbestätigungen versandt.', - }, - ], - }, - - // ── Lea — pendent: Entscheidung durch den Bewirtschafter ─────────────────── - { - id: 'awi-lea-02', - agentId: AGENT_IDS.LEA, - kind: AgentWorkItemKind.INQUIRY_QUALIFIED, - area: AgentDomainArea.INQUIRIES, - title: 'Grenzfall zur Entscheidung — Nordstern Gastro GmbH, Zürcherstrasse 204', - summary: - 'Die Anfrage erfüllt fünf von sieben Must-Kriterien. Budget und Mietbeginn liegen knapp ausserhalb des Rahmens — ein Grenzfall wird nie automatisch abgesagt, sondern zur Entscheidung vorgelegt.', - objectId: 'OBJ-2842', - objectLabel: 'Zürcherstrasse 204, 8406 Winterthur Neuhegi — Retail- und Gastrofläche, 310 m²', - sourceChannel: AgentChannelType.PORTAL_LEAD_MAIL, - targetChannel: AgentChannelType.EMAIL, - createdAt: '2026-05-20T07:36:00.000Z', - priority: AgentWorkItemPriority.HIGH, - status: AgentWorkItemStatus.PENDING, - requiresDecision: true, - escalationReason: - 'Entscheidung durch den Bewirtschafter: Zwei Must-Kriterien sind knapp verfehlt — Budgetrahmen um 8 Prozent unter der Mietpreisvorstellung, Mietbeginn zwei Monate nach der Verfügbarkeit. Bei Grenzfällen ist die automatische Absage gesperrt.', - decisionQuestion: - 'Soll die Anfrage der Nordstern Gastro GmbH trotz Budgetabweichung von 8 Prozent und zwei Monaten Leerstand zur Besichtigung zugelassen werden, oder folgt die Absage mit der hinterlegten Vorlage?', - inputs: [ - { - label: 'Bedarfsprofil', - value: [ - 'Fläche: 280 bis 340 m², Erdgeschoss mit Schaufenster', - 'Nutzung: Gastronomie mit Take-away, keine Nachtnutzung', - 'Mietbeginn: 01.03.2027', - 'Budget: CHF 285 pro m² und Jahr, netto', - 'Vertragsdauer: 8 Jahre fest, Option auf 5 Jahre', - ], - }, - { - label: 'Must-Kriterien der Fläche', - value: [ - 'Flächenbedarf 250–350 m² — erfüllt', - 'Nutzungsart Gastronomie zulässig — erfüllt', - 'Mietbeginn ab 01.01.2027 — knapp verfehlt (01.03.2027)', - 'Mietpreisvorstellung CHF 310 pro m² und Jahr — knapp verfehlt (CHF 285)', - 'Bonitätsnachweis beibringbar — erfüllt', - 'Vertragsdauer mindestens 5 Jahre — erfüllt', - 'Parkplatzbedarf gedeckt — erfüllt', - ], - }, - { label: 'Herkunftskanal', value: 'Portal-Lead-Mail vom 20.05.2026, 07:12 Uhr, ergänzt per Direktmail 07:31 Uhr' }, - { - label: 'Bisheriger Kontakt', - value: 'Kein früherer Vorgang in den letzten 90 Tagen — kein Duplikat', - }, - ], - result: [ - { label: 'Qualifizierung', value: '5 von 7 Must-Kriterien erfüllt, 2 knapp verfehlt, 0 offen' }, - { - label: 'Interessent', - value: 'Nordstern Gastro GmbH, Winterthur — Frau Sabina Rüegg, Geschäftsführerin, drei Betriebe in Winterthur und Frauenfeld', - }, - { - label: 'Wirtschaftliche Wirkung', - value: - 'Budgetabweichung CHF 7 750 pro Jahr gegenüber der Mietpreisvorstellung; zwei Monate Leerstand entsprechen rund CHF 16 000 Mietzinsausfall.', - }, - { - label: 'Argument für die Zulassung', - value: - 'Die Fläche steht seit 14 Monaten im Angebot; es liegen in diesem Zeitraum nur zwei weitere ernsthafte Anfragen vor, beide ohne Bonitätsnachweis.', - }, - { label: 'Stand', value: 'Kein Schritt Richtung Termin ausgelöst — wartet auf die Entscheidung der Vermarktung' }, - { label: 'Vorbereitet', value: 'Drei Zeitfenster am 26., 27. und 28.05.2026, Reservation noch nicht gesetzt' }, - ], - processingSteps: [ - { - id: 'awi-lea-02-step-1', - label: 'Anfrage aus zwei Nachrichten zusammengeführt', - detail: 'Portal-Lead-Mail und nachgereichte Direktmail derselben Person zu einem Vorgang verbunden.', - }, - { - id: 'awi-lea-02-step-2', - label: 'Freitext in ein Bedarfsprofil überführt', - detail: 'Fläche, Nutzung, Mietbeginn, Budget und Vertragsdauer strukturiert erfasst.', - }, - { - id: 'awi-lea-02-step-3', - label: 'Gegen die Must-Kriterien der Fläche geprüft', - detail: 'Je Kriterium erfüllt, nicht erfüllt oder offen festgehalten.', - }, - { - id: 'awi-lea-02-step-4', - label: 'Als Grenzfall eingestuft', - detail: 'Beide Abweichungen liegen innerhalb der Grenzfall-Bandbreite — automatische Absage gesperrt.', - }, - { - id: 'awi-lea-02-step-5', - label: 'Kurzprofil zur Entscheidung vorgelegt', - detail: 'Freie Zeitfenster vorsorglich ermittelt, aber nicht reserviert.', - }, - ], - sourceReferences: [ - { - id: 'awi-lea-02-src-1', - label: 'Must-Kriterien Fläche OBJ-2842', - documentName: 'Vermarktungsauftrag_OBJ-2842_2025-03-01.pdf', - locator: 'Seite 2, Abschnitt «Grundvoraussetzungen»', - quote: - '«Mietbeginn ab 1. Januar 2027; Mietzinsvorstellung CHF 310 pro Quadratmeter und Jahr netto; Vertragsdauer mindestens fünf Jahre.»', - }, - { - id: 'awi-lea-02-src-2', - label: 'Anfrage-Log Fläche OBJ-2842', - documentName: 'Anfrage-Log_OBJ-2842_2026.csv', - locator: 'Zeile 12', - }, - ], - messageThread: [ - { - id: 'awi-lea-02-msg-1', - at: '2026-05-20T07:12:00.000Z', - from: 'lead-service@plattform-immobilien.ch', - to: 'portal-leads@property-on.ch', - channel: AgentChannelType.PORTAL_LEAD_MAIL, - subject: 'Neue Anfrage zu Ihrem Inserat «Retail-/Gastrofläche 310 m², Winterthur Neuhegi»', - body: `Guten Tag - -Zu Ihrem Inserat ist eine neue Anfrage eingegangen. - - Inserat: Retail- und Gastrofläche 310 m², Zürcherstrasse 204, 8406 Winterthur - Name: Sabina Rüegg - Firma: Nordstern Gastro GmbH, Winterthur - Telefon: +41 52 214 77 30 - E-Mail: s.rueegg@nordstern-gastro.ch - - Nachricht: «Wir betreiben drei Lokale in Winterthur und Frauenfeld und suchen - für unser viertes Konzept eine Erdgeschossfläche mit Schaufenster. Die Fläche - in Neuhegi passt sehr gut. Wir würden per März 2027 starten.» - -Freundliche Grüsse -Plattform Immobilien — Lead-Service`, - }, - { - id: 'awi-lea-02-msg-2', - at: '2026-05-20T07:31:00.000Z', - from: 's.rueegg@nordstern-gastro.ch', - to: 'anfragen@property-on.ch', - channel: AgentChannelType.EMAIL, - subject: 'Ergänzung zu unserer Anfrage — Zürcherstrasse 204, Winterthur', - body: `Guten Tag - -Ergänzend zu unserer Anfrage über die Plattform von heute Morgen die noch fehlenden Angaben: - - Nutzung Gastronomie mit Take-away, keine Nachtnutzung - Mietbeginn 1. März 2027 (Umbau des bisherigen Standorts läuft bis Februar) - Budget CHF 285 pro Quadratmeter und Jahr, netto - Vertragsdauer 8 Jahre fest, mit Option auf weitere 5 Jahre - Bonität Jahresabschlüsse 2023–2025 und Betreibungsregisterauszug liegen bereit - -Über eine Besichtigung würden wir uns sehr freuen. - -Freundliche Grüsse -Sabina Rüegg -Geschäftsführerin, Nordstern Gastro GmbH -Winterthur`, - }, - { - id: 'awi-lea-02-msg-3', - at: '2026-05-20T07:34:00.000Z', - from: 'lea@property-on.ch', - to: 's.rueegg@nordstern-gastro.ch', - channel: AgentChannelType.EMAIL, - subject: 'Ihre Anfrage zur Fläche Zürcherstrasse 204, Winterthur — Eingang bestätigt', - body: `Guten Tag Frau Rüegg - -Besten Dank für Ihre Anfrage und die rasch nachgereichten Angaben zur Retail- und Gastrofläche an der Zürcherstrasse 204 in Winterthur. - -Ihre Unterlagen sind vollständig. Die zuständige Person der Vermarktung prüft Ihr Anliegen und meldet sich innerhalb von zwei Arbeitstagen bei Ihnen. - -Freundliche Grüsse -Lea — Anfragen-Managerin -Property On, Vermarktung`, - }, - { - id: 'awi-lea-02-msg-4', - at: '2026-05-20T07:36:00.000Z', - from: 'lea@property-on.ch', - to: 'nadine.brunner@property-on.ch', - channel: AgentChannelType.EMAIL, - subject: 'ENTSCHEID NÖTIG — Grenzfall Nordstern Gastro GmbH, OBJ-2842', - body: `Guten Morgen Frau Brunner - -Zur Fläche OBJ-2842 (Zürcherstrasse 204, Winterthur Neuhegi) liegt ein Grenzfall vor: - - erfüllt Fläche, Nutzung, Bonität, Vertragsdauer, Parkplätze - knapp verfehlt Budget CHF 285 statt CHF 310 pro m² und Jahr (−8 %) - knapp verfehlt Mietbeginn 01.03.2027 statt ab 01.01.2027 (2 Monate Leerstand) - -Die Fläche steht seit 14 Monaten im Angebot. Ich habe nichts Richtung Termin ausgelöst und warte auf Ihren Entscheid. Drei Zeitfenster am 26., 27. und 28. Mai stünden bereit. - -Freundliche Grüsse -Lea — Anfragen-Managerin -Property On, Vermarktung`, - }, - ], - availableActions: [ - AgentWorkItemAction.DECIDE, - AgentWorkItemAction.APPROVE, - AgentWorkItemAction.EDIT, - AgentWorkItemAction.REJECT, - AgentWorkItemAction.OPEN_SOURCE, - ], - editableFields: [ - { - id: 'awi-lea-02-field-slots', - label: 'Vorgeschlagene Besichtigungsfenster', - value: '26.05.2026 10:00, 27.05.2026 14:00, 28.05.2026 09:30', - helperText: 'Termindauer 45 Minuten, Pufferzeit 15 Minuten vor und nach dem Termin.', - }, - { - id: 'awi-lea-02-field-contact', - label: 'Ansprechperson vor Ort', - value: 'Marco Steinegger, Vermarktung Winterthur', - }, - { - id: 'awi-lea-02-field-note', - label: 'Hinweis an den Interessenten', - value: - 'Der Mietzins ist Verhandlungssache; ein Mietbeginn per März 2027 ist grundsätzlich prüfbar. Bitte bringen Sie die Jahresabschlüsse 2023 bis 2025 an die Besichtigung mit.', - multiline: true, - helperText: 'Wird der Terminbestätigung angehängt, sobald die Freigabe erteilt ist.', - }, - { - id: 'awi-lea-02-field-reject-reason', - label: 'Begründung bei Absage', - value: 'Die Mietpreisvorstellung und der gewünschte Mietbeginn liegen ausserhalb der Grundvoraussetzungen dieses Objekts.', - multiline: true, - helperText: 'Wird in die hinterlegte Absagevorlage eingesetzt, falls Sie ablehnen.', - }, - ], - history: [ - { - id: 'awi-lea-02-hist-1', - at: '2026-05-20T07:12:00.000Z', - actor: 'Lea', - action: 'Anfrage erfasst', - note: 'Portal-Lead-Mail der Plattform, Fläche OBJ-2842 zugeordnet.', - }, - { - id: 'awi-lea-02-hist-2', - at: '2026-05-20T07:33:00.000Z', - actor: 'Lea', - action: 'Bedarfsprofil vervollständigt', - note: 'Nachgereichte Direktmail zusammengeführt, alle Mindestangaben vorhanden.', - }, - { - id: 'awi-lea-02-hist-3', - at: '2026-05-20T07:36:00.000Z', - actor: 'Lea', - action: 'Als Grenzfall zur Entscheidung vorgelegt', - note: 'Automatische Absage gesperrt, keine Kalenderreservation gesetzt.', - }, - ], - }, - // ── Bruno — erledigt ─────────────────────────────────────────────────────── { id: 'awi-bruno-01', @@ -1343,10 +923,10 @@ Property On, Vermarktung`, ], }, - // ── Reto — erledigt ──────────────────────────────────────────────────────── + // ── Bruno — erledigt ──────────────────────────────────────────────────────── { - id: 'awi-reto-01', - agentId: AGENT_IDS.RETO, + id: 'awi-bruno-r01', + agentId: AGENT_IDS.BRUNO, kind: AgentWorkItemKind.RECAP, area: AgentDomainArea.VIEWINGS, title: 'Recap zur Besichtigung Technoparkstrasse 7 mit einem Klick freigegeben', @@ -1397,7 +977,7 @@ Property On, Vermarktung`, value: [ 'Grundriss mit Abtrennungsvariante versenden — Marco Steinegger, bis 15.05.2026', 'Mietzins für Teilfläche intern prüfen — Nadine Brunner, bis 19.05.2026', - 'Zweitbesichtigung vormerken — Lea, ab 22.05.2026', + 'Zweitbesichtigung vormerken — Bruno, ab 22.05.2026', ], }, { @@ -1408,40 +988,40 @@ Property On, Vermarktung`, ], processingSteps: [ { - id: 'awi-reto-01-step-1', + id: 'awi-bruno-r01-step-1', label: 'Sprachmemo entgegengenommen und zugeordnet', detail: 'Über Zeitpunkt und Objektbezug der Besichtigung vom 12.05.2026 zugewiesen.', }, { - id: 'awi-reto-01-step-2', + id: 'awi-bruno-r01-step-2', label: 'Gesprochenes in Text übertragen und bereinigt', detail: 'Füllwörter und Wiederholungen entfernt, inhaltliche Aussagen unverändert übernommen.', }, { - id: 'awi-reto-01-step-3', + id: 'awi-bruno-r01-step-3', label: 'In Feedback, Einwände, Zusagen und offene Punkte gegliedert', detail: 'Jeder Punkt behält den Bezug zur Aussage im Memo.', }, { - id: 'awi-reto-01-step-4', + id: 'awi-bruno-r01-step-4', label: 'Protokoll, Gesprächsnotiz und Aufgaben als Vorschlag zusammengestellt', detail: 'Drei Folgeaufgaben mit Frist und zuständiger Person abgeleitet.', }, { - id: 'awi-reto-01-step-5', + id: 'awi-bruno-r01-step-5', label: 'Nach der Freigabe in die Zielsysteme übertragen', detail: 'Übertragung erst nach der Bestätigung von Marco Steinegger um 17:20 Uhr.', }, ], sourceReferences: [ { - id: 'awi-reto-01-src-1', + id: 'awi-bruno-r01-src-1', label: 'Sprachmemo Besichtigung OBJ-2288', documentName: 'Sprachmemo_OBJ-2288_2026-05-12_1648.m4a', locator: 'Gesamtaufnahme 4:07 Minuten', }, { - id: 'awi-reto-01-src-2', + id: 'awi-bruno-r01-src-2', label: 'Besichtigungsprotokoll im Objektdossier', documentName: 'Besichtigungsprotokoll_OBJ-2288_2026-05-12.pdf', locator: 'Seiten 1–2', @@ -1449,17 +1029,17 @@ Property On, Vermarktung`, ], messageThread: [ { - id: 'awi-reto-01-msg-1', + id: 'awi-bruno-r01-msg-1', at: '2026-05-12T16:48:00.000Z', from: 'Marco Steinegger', - to: 'Reto Diktat (WhatsApp)', + to: 'Bruno Diktat (WhatsApp)', channel: AgentChannelType.WHATSAPP, body: '[Sprachmemo, 4:07 Minuten] Besichtigung Technoparkstrasse mit Herrn Cassano ist gelaufen. Ich fasse kurz zusammen, was gesagt wurde.', }, { - id: 'awi-reto-01-msg-2', + id: 'awi-bruno-r01-msg-2', at: '2026-05-12T17:04:00.000Z', - from: 'Reto — Recap Vermarktung', + from: 'Bruno — Recap Vermarktung', to: 'Marco Steinegger', channel: AgentChannelType.WHATSAPP, body: `Guten Abend Marco @@ -1469,13 +1049,13 @@ Das Protokoll zur Besichtigung an der Technoparkstrasse 7 liegt als Vorschlag be Eine Rückfrage: Du hast von «rund zehn Prozent teurer» gesprochen. Beziehst du das auf den Nettomietzins oder auf die Gesamtkosten inklusive Nebenkosten? Freundliche Grüsse -Reto — Recap-Agent`, +Bruno — Recap-Agent`, }, { - id: 'awi-reto-01-msg-3', + id: 'awi-bruno-r01-msg-3', at: '2026-05-12T17:19:00.000Z', from: 'Marco Steinegger', - to: 'Reto — Recap Vermarktung', + to: 'Bruno — Recap Vermarktung', channel: AgentChannelType.WHATSAPP, body: 'Auf den Nettomietzins. Sonst passt alles — bitte so ablegen und die drei Aufgaben eröffnen. Freigabe erteilt.', }, @@ -1483,14 +1063,14 @@ Reto — Recap-Agent`, availableActions: [AgentWorkItemAction.OPEN_SOURCE, AgentWorkItemAction.MARK_DONE], history: [ { - id: 'awi-reto-01-hist-1', + id: 'awi-bruno-r01-hist-1', at: '2026-05-12T17:04:00.000Z', - actor: 'Reto', + actor: 'Bruno', action: 'Vorschlag vorgelegt', note: 'Protokoll, Gesprächsnotiz und drei Folgeaufgaben zur 1-Klick-Bestätigung.', }, { - id: 'awi-reto-01-hist-2', + id: 'awi-bruno-r01-hist-2', at: '2026-05-12T17:20:00.000Z', actor: 'Marco Steinegger', action: 'Freigabe erteilt', @@ -1499,10 +1079,10 @@ Reto — Recap-Agent`, ], }, - // ── Reto — pendent: Verarbeitungsproblem ─────────────────────────────────── + // ── Bruno — pendent: Verarbeitungsproblem ─────────────────────────────────── { - id: 'awi-reto-02', - agentId: AGENT_IDS.RETO, + id: 'awi-bruno-r02', + agentId: AGENT_IDS.BRUNO, kind: AgentWorkItemKind.RECAP, area: AgentDomainArea.VIEWINGS, title: 'Sprachmemo abgebrochen und teilweise unverständlich — Recap Baarerstrasse 82', @@ -1558,35 +1138,35 @@ Reto — Recap-Agent`, ], processingSteps: [ { - id: 'awi-reto-02-step-1', + id: 'awi-bruno-r02-step-1', label: 'Sprachmemo entgegengenommen und zugeordnet', detail: 'Über Zeitpunkt und Objektbezug der Besichtigung vom 19.05.2026 zugewiesen.', }, { - id: 'awi-reto-02-step-2', + id: 'awi-bruno-r02-step-2', label: 'Abbruch der Aufnahme festgestellt', detail: 'Die Datei endet nach 3:12 Minuten mitten im Satz — kein Abschluss des Gedankens erkennbar.', }, { - id: 'awi-reto-02-step-3', + id: 'awi-bruno-r02-step-3', label: 'Drei Passagen als unverständlich markiert', detail: 'Zusammen 47 Sekunden mit durchgehendem Baulärm — Inhalte werden nicht erraten.', }, { - id: 'awi-reto-02-step-4', + id: 'awi-bruno-r02-step-4', label: 'Ablage angehalten und Rückfrage gestellt', detail: 'Ohne Entscheid gelangt kein lückenhaftes Protokoll ins Dossier und keine Notiz ins CRM.', }, ], sourceReferences: [ { - id: 'awi-reto-02-src-1', + id: 'awi-bruno-r02-src-1', label: 'Sprachmemo Besichtigung OBJ-2094', documentName: 'Sprachmemo_OBJ-2094_2026-05-19_1724.m4a', locator: 'Aufnahme 3:12 Minuten, unklar bei 1:12, 2:03 und 2:55', }, { - id: 'awi-reto-02-src-2', + id: 'awi-bruno-r02-src-2', label: 'Kalendereintrag Besichtigung', documentName: 'Kalender_Vermarktung_2026-05-19.ics', locator: 'Termin 16:00–17:00 Uhr', @@ -1600,25 +1180,25 @@ Reto — Recap-Agent`, ], editableFields: [ { - id: 'awi-reto-02-field-rent', + id: 'awi-bruno-r02-field-rent', label: 'Passage 1 — genannter Mietzins', value: '[unklar]', helperText: 'Aufnahme 1:12 bis 1:31. Bitte den genannten Betrag ergänzen oder das Feld leer lassen.', }, { - id: 'awi-reto-02-field-contact', + id: 'awi-bruno-r02-field-contact', label: 'Passage 2 — zweite Ansprechperson', value: '[unklar]', helperText: 'Aufnahme 2:03 bis 2:18. Name und Funktion beim Interessenten.', }, { - id: 'awi-reto-02-field-deadline', + id: 'awi-bruno-r02-field-deadline', label: 'Passage 3 — zugesagte Frist', value: '[unklar]', helperText: 'Aufnahme 2:55 bis 3:08. Aus dieser Angabe entsteht die Folgeaufgabe.', }, { - id: 'awi-reto-02-field-summary', + id: 'awi-bruno-r02-field-summary', label: 'Zusammenfassung des Protokolls', value: 'Die Interessentin zeigte sich vom Grundriss und vom Tageslicht im 3. Obergeschoss überzeugt. Als Einwand wurde die Parkplatzsituation genannt: verfügbar sind 4 statt der gewünschten 8 Plätze. Ein Mietbeginn per 1. Januar 2027 wäre passend.', @@ -1627,16 +1207,16 @@ Reto — Recap-Agent`, ], history: [ { - id: 'awi-reto-02-hist-1', + id: 'awi-bruno-r02-hist-1', at: '2026-05-19T17:26:00.000Z', - actor: 'Reto', + actor: 'Bruno', action: 'Verarbeitungsproblem gemeldet', note: 'Aufnahme abgebrochen, drei Passagen unverständlich — Ablage angehalten.', }, { - id: 'awi-reto-02-hist-2', + id: 'awi-bruno-r02-hist-2', at: '2026-05-20T08:05:00.000Z', - actor: 'Reto', + actor: 'Bruno', action: 'Erinnerung an die Rückfrage', note: 'Seit über 14 Stunden ohne Rückmeldung — Termin ist noch nicht protokolliert.', }, diff --git a/src/mock-data/agents/bruno.ts b/src/mock-data/agents/bruno.ts index 56f13a9..a110329 100644 --- a/src/mock-data/agents/bruno.ts +++ b/src/mock-data/agents/bruno.ts @@ -22,7 +22,7 @@ import { export const brunoAgent: TeamAgent = { id: 'bruno', name: 'Bruno', - role: 'Besichtigungs-Briefing', + role: 'Besichtigungsassistent', personnelNumber: 'PO-ZD-13', department: 'Vermarktung', email: 'bruno@property-on.ch', @@ -30,30 +30,37 @@ export const brunoAgent: TeamAgent = { status: AgentStatus.ACTIVE, autonomyLevel: AgentAutonomyLevel.AUTONOMOUS, shortDescription: - 'Bereitet Aussentermine automatisch mit Objekt-, Gegenüber- und Argumentationsinformationen vor.', + 'Bereitet Besichtigungen und Termine vor und erstellt im Nachgang Protokolle und Kundennotizen.', autonomyNote: '«Autonom»', profile: { purpose: - 'Bruno bereitet jeden Aussentermin der Vermarktung rechtzeitig vor und liefert der verantwortlichen Person ein einseitiges Briefing zu Objekt, Gegenüber und Argumentation.', + 'Bruno begleitet jeden Aussentermin der Vermarktung von beiden Seiten: vorher mit einem Briefing zu Objekt, Gegenüber und Argumentation, nachher mit einem strukturierten Protokoll und einer Kundennotiz fürs CRM.', input: [ 'Kalendertermin mit Zeit, Ort, Objektbezug und Teilnehmenden', 'Objektinformationen aus Dossier, Ablage und Bewirtschaftungssystem', 'Öffentlich verfügbare Firmeninformationen zum Gegenüber', + 'Mündlicher Bericht des Maklers nach dem Termin — per WhatsApp-Anruf oder Sprachnachricht, etwa aus dem Auto', + 'Lagebericht, den Bruno bei Livia anfragt', ], coreFlow: [ 'Zwei Stunden vor dem Aussentermin selbständig anlaufen und den Termin einlesen', 'Alles zum Objekt zusammentragen: Fläche, Ausbau, Konditionen, Verfügbarkeit, Historie', + 'Den Lagebericht bei Livia anfragen und in den Bericht übernehmen', 'Offene Punkte klar als fehlende Information kennzeichnen, statt sie zu überspielen', 'Das Gegenüber über öffentlich zugängliche Quellen einordnen und die Herkunft der Angaben festhalten', - 'Drei Verkaufsargumente und drei erwartbare Einwände samt Antwort formulieren', + 'Verkaufsargumente und erwartbare Einwände samt Antwort formulieren', 'Alles auf einer Seite bündeln, per E-Mail zustellen und bei Terminverschiebung nachführen', + 'Nach dem Termin den mündlichen Bericht entgegennehmen und in ein strukturiertes Protokoll überführen', + 'Protokoll und Kundennotiz im CRM beziehungsweise in der angebundenen Dokumentenablage ablegen', ], output: [ 'Einseitiges Briefing zum Aussentermin', 'Audiofassung des Briefings für die Anfahrt', - 'Drei Verkaufsargumente je Objekt und Gegenüber', - 'Drei erwartbare Einwände mit vorbereiteter Antwort', + 'Verkaufsargumente je Objekt und Gegenüber', + 'Erwartbare Einwände mit vorbereiteter Antwort', + 'Besichtigungsprotokoll als abgelegtes Dokument im Dossier', + 'Kundennotiz fürs CRM mit Interessentenstatus und nächstem Schritt', ], }, @@ -65,6 +72,9 @@ export const brunoAgent: TeamAgent = { 'Aufbereitung von Verkaufsargumenten und erwartbaren Einwänden', 'Erstellung und Zustellung des einseitigen Briefings samt optionaler Audiofassung', 'Nachführung des Briefings bei Terminverschiebung oder geänderter Teilnehmerliste', + 'Entgegennahme des mündlichen Berichts nach dem Termin über WhatsApp-Anruf oder Sprachnachricht', + 'Erstellung von Protokoll und Kundennotiz aus dem mündlichen Bericht', + 'Ablage der Nachbereitungsergebnisse im CRM beziehungsweise in der Dokumentenablage', ], tasks: [ @@ -156,6 +166,54 @@ export const brunoAgent: TeamAgent = { requiresApproval: false, dependsOnChannel: AgentChannelType.CALENDAR, }, + { + id: 'bruno-task-10', + title: 'Lagebericht bei Livia anfragen', + description: + 'Fragt den Lagebericht zum Objekt selbständig bei Livia an und übernimmt die Antwort in das Besichtigungs-Briefing. Fehlt der Bericht, wird die Lücke im Briefing sichtbar ausgewiesen.', + enabled: true, + schedule: 'während der Objektaufbereitung', + requiresApproval: false, + }, + { + id: 'bruno-task-11', + title: 'Mündlichen Bericht nach dem Termin entgegennehmen', + description: + 'Ist über WhatsApp anrufbar und nimmt Sprachnachrichten entgegen, damit der Makler direkt nach dem Termin — etwa aus dem Auto — mündlich berichten kann. Ordnet den Bericht über Zeitpunkt und Objektbezug dem richtigen Termin zu.', + enabled: true, + schedule: 'bei Eingang', + requiresApproval: false, + dependsOnChannel: AgentChannelType.WHATSAPP, + }, + { + id: 'bruno-task-12', + title: 'Gesprochenes in Text übertragen und bereinigen', + description: + 'Überträgt den mündlichen Bericht in Text, inklusive Schweizer Ortsnamen und Objektbezeichnungen, entfernt Füllwörter und Wiederholungen und markiert unsichere Stellen, statt sie zu raten.', + enabled: true, + schedule: 'unmittelbar nach Eingang des Berichts', + requiresApproval: false, + dependsOnChannel: AgentChannelType.PHONE_VOICE, + }, + { + id: 'bruno-task-13', + title: 'Strukturiertes Protokoll erstellen', + description: + 'Trennt den Bericht in positives Feedback, Einwände, verbindliche Zusagen und offene Fragen und ergänzt den Termin-Kontext: Objekt, Interessent, Datum, Teilnehmende.', + enabled: true, + schedule: 'nach der Bereinigung', + requiresApproval: false, + }, + { + id: 'bruno-task-14', + title: 'Kundennotiz und Protokoll ablegen', + description: + 'Legt Protokoll und Kundennotiz bei vorhandener Integration im CRM beziehungsweise in der angebundenen Dokumentenablage ab. Fehlt der Zugang, greift der eingestellte Fallback und das Paket geht als strukturierte E-Mail an die zuständige Person.', + enabled: true, + schedule: 'nach Fertigstellung des Protokolls', + requiresApproval: true, + dependsOnSystem: AgentSystemType.CRM, + }, ], channels: [ @@ -189,6 +247,37 @@ export const brunoAgent: TeamAgent = { autoReplyEnabled: false, }, }, + { + id: 'bruno-ch-whatsapp', + type: AgentChannelType.WHATSAPP, + direction: AgentChannelDirection.BOTH, + description: + 'Bruno ist über WhatsApp anrufbar. Der Makler berichtet nach einem Termin mündlich — per Anruf oder Sprachnachricht, etwa aus dem Auto; Bruno macht daraus ein strukturiertes Protokoll.', + status: AgentConnectionStatus.CONNECTED, + enabled: true, + config: { + displayName: 'Bruno Besichtigungslinie (WhatsApp)', + inboxAddress: '+41 44 000 00 13', + defaultRecipients: [], + autoReplyEnabled: true, + }, + }, + { + id: 'bruno-ch-phone', + type: AgentChannelType.PHONE_VOICE, + direction: AgentChannelDirection.INBOUND, + description: + 'Telefonische Diktatlinie als Rückfallweg, wenn WhatsApp nicht zur Verfügung steht.', + status: AgentConnectionStatus.CONNECTED, + enabled: true, + optional: true, + config: { + displayName: 'Bruno Diktatlinie', + inboxAddress: '+41 44 000 00 14', + defaultRecipients: [], + autoReplyEnabled: false, + }, + }, { id: 'bruno-ch-audio-output', type: AgentChannelType.AUDIO_OUTPUT, diff --git a/src/mock-data/agents/lea.ts b/src/mock-data/agents/lea.ts deleted file mode 100644 index a5be37c..0000000 --- a/src/mock-data/agents/lea.ts +++ /dev/null @@ -1,490 +0,0 @@ -/** - * Property On — Personalblatt «Lea», Anfragen-Managerin (Abteilung Vermarktung). - * - * Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und die - * elf Aufgaben werden unverändert übernommen. Zeitanker der Mockdaten: 20.05.2026. - */ - -import type { TeamAgent } from '../../domain/teamAgent' -import { - AgentStatus, - AgentAutonomyLevel, - AgentChannelType, - AgentChannelDirection, - AgentConnectionStatus, - AgentSystemType, - AgentAccessLevel, - AgentSettingKind, - AgentSettingGroup, - AgentAvatarTone, -} from '../../domain/teamAgent' - -const ABSAGEVORLAGE = `Guten Tag {Anrede} {Nachname} - -Besten Dank für Ihre Anfrage zur Fläche {Objekt} in {Ort}. - -Wir haben Ihre Angaben geprüft. Leider erfüllt Ihr Bedarf die für dieses Objekt -festgelegten Grundvoraussetzungen nicht: {Grund}. - -Sobald eine passende Fläche in unserem Portfolio frei wird, melden wir uns -unaufgefordert bei Ihnen. - -Freundliche Grüsse -Property On — Vermarktung -{Verantwortliche Person}` - -export const leaAgent: TeamAgent = { - id: 'lea', - name: 'Lea', - role: 'Anfragen-Managerin', - personnelNumber: 'PO-ZD-07', - department: 'Vermarktung', - email: 'lea@property-on.ch', - avatarTone: AgentAvatarTone.SUCCESS, - status: AgentStatus.ACTIVE, - autonomyLevel: AgentAutonomyLevel.APPROVAL_REQUIRED, - shortDescription: - 'Normalisiert, qualifiziert und koordiniert Anfragen bis zum bestätigten Besichtigungstermin.', - autonomyNote: - '«Automatische Absage bei Nichterfüllung; Freigabe für Schritte Richtung Termin»', - - profile: { - purpose: - 'Lea nimmt jede eingehende Flächenanfrage auf, prüft sie gegen die Must-Kriterien der ausgeschriebenen Fläche und begleitet sie bis zum bestätigten Besichtigungstermin.', - input: [ - 'Portal-Lead-Mails der Immobilienplattformen', - 'Direktmails an das Anfragen-Postfach der Vermarktung', - 'Website-Formulare von property-on.ch', - 'Telefonnotizen aus dem Empfang Zürich und Winterthur', - 'Must-Kriterien je ausgeschriebener Fläche', - 'Besichtigungskalender der verantwortlichen Person', - ], - coreFlow: [ - 'Anfragen aus allen Kanälen einsammeln und in ein einheitliches Bedarfsprofil überführen', - 'Prüfen, ob dieselbe Person bereits über einen anderen Kanal angefragt hat', - 'Fehlende Pflichtangaben mit einem vorbereiteten Rückfragetext nachfassen', - 'Bedarfsprofil mit den Must-Kriterien der Fläche abgleichen', - 'Nicht passende Interessenten sachlich absagen, passende zur Entscheidung vorlegen', - 'Nach Freigabe freie Zeitfenster suchen, Termin vorschlagen, bestätigen und den Verlauf ablegen', - ], - output: [ - 'Automatische Absagen mit nachvollziehbarer Begründung', - 'Qualifizierte Kurzprofile für die Entscheidung der Vermarktung', - 'Bestätigte Besichtigungstermine samt vorbereitetem Reminder', - 'Lückenloses Anfrage-Log je Fläche und Interessent', - ], - }, - - responsibilities: [ - 'Erfassung sämtlicher Anfragen aus Portalen, Website, E-Mail und Telefon', - 'Überführung von Freitext in ein einheitliches Bedarfsprofil', - 'Deduplizierung über alle Kanäle hinweg', - 'Qualifizierung gegen die Must-Kriterien der ausgeschriebenen Flächen', - 'Automatische Absage bei klarer Nichterfüllung', - 'Kalendersteuerung und Terminkoordination bis zur Bestätigung', - 'Protokollierung von Status und Verlauf je Anfrage', - ], - - tasks: [ - { - id: 'lea-task-01', - title: 'Anfragen aus verschiedenen Kanälen erfassen', - description: - 'Nimmt Portal-Lead-Mails, Direktmails, Website-Formulare und Telefonnotizen entgegen und legt zu jeder Anfrage einen Eintrag auf der betroffenen Fläche an.', - enabled: true, - schedule: 'bei Eingang', - requiresApproval: false, - dependsOnSystem: AgentSystemType.PORTAL_LEAD_INBOX, - }, - { - id: 'lea-task-02', - title: 'Freitext in ein Bedarfsprofil überführen', - description: - 'Liest den Anfragetext und trägt Flächenbedarf, Nutzungsart, Wunschtermin, Budgetrahmen und Vertragsdauer in ein einheitliches Bedarfsprofil ein. Unklare Angaben werden als offen markiert statt geraten.', - enabled: true, - schedule: 'bei Eingang, direkt nach der Erfassung', - requiresApproval: false, - }, - { - id: 'lea-task-03', - title: 'Duplikate kanalübergreifend erkennen', - description: - 'Gleicht Name, Firma, Telefonnummer und E-Mail mit den Anfragen der letzten 90 Tage ab. Mehrfachanfragen derselben Person über Portal, Formular und Telefon werden zu einem Vorgang zusammengeführt.', - enabled: true, - schedule: 'bei Eingang, Abgleich über die letzten 90 Tage', - requiresApproval: false, - dependsOnSystem: AgentSystemType.CRM, - }, - { - id: 'lea-task-04', - title: 'Fehlende Angaben über Rückfrageentwürfe einholen', - description: - 'Erstellt bei unvollständigen Anfragen einen Rückfrageentwurf mit den fehlenden Mindestangaben. Der Entwurf geht erst nach Freigabe der verantwortlichen Person hinaus.', - enabled: true, - schedule: 'bei unvollständiger Anfrage, Sammelvorlage werktags 08:30', - requiresApproval: true, - dependsOnChannel: AgentChannelType.EMAIL, - }, - { - id: 'lea-task-05', - title: 'Anfrage gegen Must-Kriterien prüfen', - description: - 'Vergleicht das Bedarfsprofil mit den hinterlegten Must-Kriterien der Fläche und hält je Kriterium fest, ob es erfüllt, nicht erfüllt oder noch offen ist.', - enabled: true, - schedule: 'unmittelbar nach Vollständigkeit des Bedarfsprofils', - requiresApproval: false, - dependsOnSystem: AgentSystemType.IMMOTOP2, - }, - { - id: 'lea-task-06', - title: 'Nicht passende Interessenten automatisch absagen', - description: - 'Versendet bei klarer Nichterfüllung eines Must-Kriteriums die hinterlegte Absagevorlage mit dem konkreten Grund und schliesst den Vorgang. Grenzfälle werden nie automatisch abgesagt, sondern vorgelegt.', - enabled: true, - schedule: 'stündlich, werktags 07:00–19:00', - requiresApproval: false, - dependsOnChannel: AgentChannelType.EMAIL, - }, - { - id: 'lea-task-07', - title: 'Passende Interessenten zur Entscheidung vorlegen', - description: - 'Legt der verantwortlichen Person ein Kurzprofil vor: Bedarf, erfüllte und offene Kriterien, Herkunftskanal und Dringlichkeit. Ohne Freigabe geht kein Schritt Richtung Termin hinaus.', - enabled: true, - schedule: 'werktags 07:30 und 13:30', - requiresApproval: true, - dependsOnSystem: AgentSystemType.CRM, - }, - { - id: 'lea-task-08', - title: 'Nach Freigabe Kalender abgleichen', - description: - 'Sucht im Besichtigungskalender der verantwortlichen Person freie Zeitfenster gemäss den hinterlegten Kalenderregeln, unter Berücksichtigung von Termindauer und Pufferzeit.', - enabled: true, - schedule: 'nach erteilter Freigabe', - requiresApproval: true, - dependsOnSystem: AgentSystemType.CALENDAR, - }, - { - id: 'lea-task-09', - title: 'Terminvorschläge senden', - description: - 'Sendet dem Interessenten drei Terminvorschläge mit Treffpunkt und Ansprechperson und reserviert die Zeitfenster vorläufig im Kalender.', - enabled: true, - schedule: 'innerhalb von 30 Minuten nach Freigabe', - requiresApproval: true, - dependsOnChannel: AgentChannelType.EMAIL, - }, - { - id: 'lea-task-10', - title: 'Termin bestätigen und Reminder vorbereiten', - description: - 'Bestätigt den gewählten Termin, trägt ihn verbindlich im Kalender ein, löst die übrigen Reservationen und bereitet den Reminder für Interessent und Ansprechperson vor.', - enabled: true, - schedule: 'bei Zusage des Interessenten', - requiresApproval: true, - dependsOnChannel: AgentChannelType.CALENDAR, - }, - { - id: 'lea-task-11', - title: 'Status und Verlauf protokollieren', - description: - 'Hält je Anfrage Kanal, Eingangszeit, Prüfergebnis, Freigaben, versandte Nachrichten und Terminstatus im Protokoll fest, damit der Verlauf jederzeit nachvollziehbar bleibt.', - enabled: true, - schedule: 'laufend, Tagesabschluss 19:00', - requiresApproval: false, - dependsOnSystem: AgentSystemType.CRM, - }, - ], - - channels: [ - { - id: 'lea-ch-email', - type: AgentChannelType.EMAIL, - direction: AgentChannelDirection.BOTH, - description: - 'Anfragen-Postfach der Vermarktung: Direktmails von Interessenten sowie Rückfragen, Absagen und Terminvorschläge im Versand.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Anfragen-Postfach Vermarktung', - senderAddress: 'lea@property-on.ch', - inboxAddress: 'anfragen@property-on.ch', - defaultRecipients: ['vermarktung@property-on.ch'], - autoReplyEnabled: true, - }, - }, - { - id: 'lea-ch-portal-lead-mail', - type: AgentChannelType.PORTAL_LEAD_MAIL, - direction: AgentChannelDirection.INBOUND, - description: - 'Lead-Mails der Immobilienplattformen zu den ausgeschriebenen Flächen in Zürich, Winterthur, Basel, Zug, Bern und St. Gallen.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Portal-Leads Plattformen', - inboxAddress: 'portal-leads@property-on.ch', - defaultRecipients: [], - autoReplyEnabled: false, - }, - }, - { - id: 'lea-ch-web-form', - type: AgentChannelType.WEB_FORM, - direction: AgentChannelDirection.INBOUND, - description: - 'Formular «Fläche anfragen» auf property-on.ch — liefert bereits strukturierte Felder zu Bedarf, Nutzung und Wunschtermin.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Website-Formular «Fläche anfragen»', - inboxAddress: 'formular@property-on.ch', - defaultRecipients: ['vermarktung@property-on.ch'], - autoReplyEnabled: true, - }, - }, - { - id: 'lea-ch-phone-note', - type: AgentChannelType.PHONE_NOTE, - direction: AgentChannelDirection.INBOUND, - description: - 'Telefonnotizen des Empfangs in Zürich und Winterthur, als Freitext erfasst und für die Deduplizierung mitgeführt.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - optional: true, - config: { - displayName: 'Telefonnotizen Empfang', - inboxAddress: 'telefonnotiz@property-on.ch', - defaultRecipients: [], - autoReplyEnabled: false, - }, - }, - { - id: 'lea-ch-calendar', - type: AgentChannelType.CALENDAR, - direction: AgentChannelDirection.BOTH, - description: - 'Besichtigungskalender der Vermarktung: liest freie Zeitfenster, setzt Reservationen und trägt bestätigte Termine ein.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Besichtigungskalender Vermarktung', - senderAddress: 'lea@property-on.ch', - defaultRecipients: ['nadine.brunner@property-on.ch', 'vermarktung@property-on.ch'], - autoReplyEnabled: false, - }, - }, - ], - - systems: [ - { - id: 'lea-sys-portal-inbox', - type: AgentSystemType.PORTAL_LEAD_INBOX, - access: AgentAccessLevel.READ, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Holt die Lead-Mails der Immobilienplattformen ab und ordnet sie der ausgeschriebenen Fläche zu.', - permissionNote: 'Nur Lesezugriff — es werden keine Einträge im Portal verändert.', - }, - { - id: 'lea-sys-crm', - type: AgentSystemType.CRM, - access: AgentAccessLevel.READ_WRITE, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Führt Interessent, Bedarfsprofil, Prüfergebnis und Terminstatus, erkennt Duplikate und schreibt das Anfrage-Log.', - permissionNote: - 'Schreibt Anfragen, Notizen und Statuswechsel selbständig; Vertragsdaten bleiben unberührt.', - }, - { - id: 'lea-sys-calendar', - type: AgentSystemType.CALENDAR, - access: AgentAccessLevel.READ_WRITE, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Sucht freie Zeitfenster, reserviert Terminvorschläge und trägt bestätigte Besichtigungen samt Reminder ein.', - permissionNote: - 'Schreibt erst nach Freigabe der verantwortlichen Person; bestehende Termine werden nie verschoben oder gelöscht.', - }, - { - id: 'lea-sys-exchange', - type: AgentSystemType.M365_EXCHANGE, - access: AgentAccessLevel.READ_WRITE, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Liest das Anfragen-Postfach und versendet Absagen, Rückfragen, Terminvorschläge und Bestätigungen.', - permissionNote: - 'Automatischer Versand ausschliesslich für Absagen; alle übrigen Nachrichten gehen erst nach Freigabe hinaus.', - }, - { - id: 'lea-sys-immotop2', - type: AgentSystemType.IMMOTOP2, - access: AgentAccessLevel.READ, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Liest Flächenstammdaten, Verfügbarkeit und die hinterlegten Must-Kriterien der kommerziell genutzten Immobilien.', - permissionNote: - 'Nur Lesezugriff — Stammdaten der Bewirtschaftung werden nicht verändert.', - }, - ], - - settings: [ - { - id: 'lea-set-must-criteria', - label: 'Must-Kriterien je Fläche', - description: - 'Kriterien, die eine Anfrage zwingend erfüllen muss. Wird eines davon klar verfehlt, folgt die automatische Absage.', - kind: AgentSettingKind.MULTI_SELECT, - group: AgentSettingGroup.SPECIFIC, - value: ['area_fit', 'usage_allowed', 'start_date', 'budget_range', 'credit_check'], - options: [ - { value: 'area_fit', label: 'Flächenbedarf im ausgeschriebenen Rahmen' }, - { value: 'usage_allowed', label: 'Nutzungsart für das Objekt zulässig' }, - { value: 'start_date', label: 'Wunschtermin Mietbeginn passt zur Verfügbarkeit' }, - { value: 'budget_range', label: 'Budgetrahmen erreicht die Mietpreisvorstellung' }, - { value: 'credit_check', label: 'Bonitätsnachweis kann beigebracht werden' }, - { value: 'lease_term', label: 'Vertragsdauer mindestens fünf Jahre' }, - { value: 'parking', label: 'Parkplatzbedarf am Standort abgedeckt' }, - ], - }, - { - id: 'lea-set-auto-reject', - label: 'Automatische Absage aktiv', - description: - 'Ist die Einstellung aktiv, sagt Lea bei klarer Nichterfüllung eines Must-Kriteriums ohne Rückfrage ab. Grenzfälle werden weiterhin vorgelegt.', - kind: AgentSettingKind.BOOLEAN, - group: AgentSettingGroup.APPROVAL, - value: true, - }, - { - id: 'lea-set-reject-template', - label: 'Absagevorlage', - description: - 'Text der automatischen Absage. Platzhalter in geschweiften Klammern werden je Anfrage eingesetzt.', - kind: AgentSettingKind.TEXT, - group: AgentSettingGroup.DELIVERY, - value: ABSAGEVORLAGE, - }, - { - id: 'lea-set-min-fields', - label: 'Mindestangaben', - description: - 'Angaben, die für eine Qualifizierung vorliegen müssen. Fehlt eine davon, erstellt Lea einen Rückfrageentwurf.', - kind: AgentSettingKind.MULTI_SELECT, - group: AgentSettingGroup.SPECIFIC, - value: ['contact', 'company', 'area_sqm', 'usage', 'start_date'], - options: [ - { value: 'contact', label: 'Name und Kontaktangaben' }, - { value: 'company', label: 'Firma und Branche' }, - { value: 'area_sqm', label: 'Gewünschte Fläche in m²' }, - { value: 'usage', label: 'Geplante Nutzungsart' }, - { value: 'start_date', label: 'Wunschtermin Mietbeginn' }, - { value: 'budget', label: 'Budgetrahmen' }, - { value: 'lease_term', label: 'Gewünschte Vertragsdauer' }, - { value: 'parking', label: 'Parkplatzbedarf' }, - ], - }, - { - id: 'lea-set-owner', - label: 'Verantwortliche Person', - description: - 'Erhält die qualifizierten Kurzprofile und erteilt die Freigabe für alle Schritte Richtung Termin.', - kind: AgentSettingKind.TEXT, - group: AgentSettingGroup.APPROVAL, - value: 'Nadine Brunner, Vermarktung Zürich (Stellvertretung: Marco Steinegger, Winterthur)', - }, - { - id: 'lea-set-calendar-rules', - label: 'Kalenderregeln', - description: 'Bestimmt, welche Zeitfenster Lea für Besichtigungen vorschlagen darf.', - kind: AgentSettingKind.SELECT, - group: AgentSettingGroup.SPECIFIC, - value: 'business_hours', - options: [ - { value: 'business_hours', label: 'Geschäftszeiten Mo–Fr, 08:00–17:00' }, - { value: 'extended', label: 'Erweitert inkl. Samstagvormittag' }, - { value: 'owner_availability', label: 'Nach Verfügbarkeit der verantwortlichen Person' }, - { value: 'fixed_slots', label: 'Feste Besichtigungsfenster Dienstag und Donnerstag' }, - ], - }, - { - id: 'lea-set-duration', - label: 'Termindauer', - description: 'Dauer eines Besichtigungstermins, die Lea im Kalender reserviert.', - kind: AgentSettingKind.NUMBER, - group: AgentSettingGroup.SPECIFIC, - value: 45, - unit: 'Minuten', - min: 15, - max: 120, - }, - { - id: 'lea-set-buffer', - label: 'Pufferzeit', - description: 'Freizuhaltende Zeit vor und nach einem Termin, etwa für Anfahrt und Übergabe.', - kind: AgentSettingKind.NUMBER, - group: AgentSettingGroup.SPECIFIC, - value: 15, - unit: 'Minuten', - min: 0, - max: 60, - }, - { - id: 'lea-set-reminder', - label: 'Reminder-Zeitpunkt', - description: 'Wann Interessent und Ansprechperson an den bestätigten Termin erinnert werden.', - kind: AgentSettingKind.SELECT, - group: AgentSettingGroup.DELIVERY, - value: 'h24', - options: [ - { value: 'h24', label: '24 Stunden vor dem Termin' }, - { value: 'h4', label: 'vier Stunden vor dem Termin' }, - { value: 'h2', label: 'zwei Stunden vor dem Termin' }, - { value: 'h1', label: 'eine Stunde vor dem Termin' }, - ], - }, - ], - - metrics: [ - { - id: 'lea-metric-new', - label: 'Neue Anfragen', - value: '128', - hint: 'letzte 30 Tage, alle Kanäle, nach Deduplizierung', - }, - { - id: 'lea-metric-qualified', - label: 'Qualifizierte Anfragen', - value: '74', - hint: 'Mindestangaben vollständig und Must-Kriterien erfüllt', - }, - { - id: 'lea-metric-auto-rejected', - label: 'Automatische Absagen', - value: '39', - hint: 'klare Nichterfüllung eines Must-Kriteriums', - }, - { - id: 'lea-metric-open-decisions', - label: 'Offene Entscheidungen', - value: '6', - hint: 'wartet auf Freigabe der verantwortlichen Person', - }, - { - id: 'lea-metric-confirmed', - label: 'Bestätigte Termine', - value: '31', - hint: 'Besichtigungen mit vorbereitetem Reminder', - }, - { - id: 'lea-metric-first-response', - label: 'Durchschnittliche Erstreaktionszeit', - value: '11 Minuten', - hint: 'vom Eingang bis zur ersten Rückmeldung, Stand 20.05.2026', - }, - ], - - headlineMetricId: 'lea-metric-qualified', - lastRun: '2026-05-20T06:15:00.000Z', -} diff --git a/src/mock-data/agents/livia.ts b/src/mock-data/agents/livia.ts index 892802d..4f9b1de 100644 --- a/src/mock-data/agents/livia.ts +++ b/src/mock-data/agents/livia.ts @@ -22,7 +22,7 @@ import { export const liviaAgent: TeamAgent = { id: 'livia', name: 'Livia', - role: 'Lage-Analystin', + role: 'Exposé Master', personnelNumber: 'PO-ZD-21', department: 'Vermarktung', email: 'livia@property-on.ch', @@ -30,14 +30,16 @@ export const liviaAgent: TeamAgent = { status: AgentStatus.ACTIVE, autonomyLevel: AgentAutonomyLevel.AUTONOMOUS, shortDescription: - 'Erstellt aktuelle Mikro- und Makrolagebeschriebe in zwei Längen und mit Quellen.', + 'Erstellt Lageberichte, Inserate sowie Angebots- und Offertenbroschüren aus den Daten und Bildern aus «Meine Objekte».', autonomyNote: - 'Autonom. Livia recherchiert, schreibt und frischt Lagebeschriebe ohne vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die Vermarktung jeden Satz nachprüfen kann.', + 'Autonom. Livia recherchiert, schreibt und frischt Lageberichte, Inserate und Exposés ohne vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die Vermarktung jeden Satz nachprüfen kann.', profile: { purpose: - 'Livia beschreibt die Lage einer kommerziell genutzten Immobilie so, dass Vermarktung und Interessenten in wenigen Minuten verstehen, wo das Objekt steht, wie es erreichbar ist und was sich im Umfeld verändert.', + 'Livia macht aus Objektdaten, Lageinformationen und vorhandenen Bildern ein verkaufsfähiges Dokument: Lagebericht, Inserat oder vollständige Angebots- beziehungsweise Offertenbroschüre. Grundlage sind ausschliesslich die Objekte aus «Meine Objekte».', input: [ + 'Objektdaten und Objektbilder aus «Meine Objekte»', + 'Leads, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat', 'Objektadresse aus dem Vermarktungsauftrag', 'Öffentliche Umfeld- und Verkehrsdaten zu Quartier, Anbindung und Nahversorgung', 'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Umfeldentwicklungen', @@ -52,19 +54,23 @@ export const liviaAgent: TeamAgent = { 'Quellen anhängen, Ergebnis je Objekt ablegen und quartalsweise gegen den aktuellen Stand prüfen.', ], output: [ + 'Lagebericht mit Mikro-, Makro- und Umfeldteil', 'Kurztext für Inserat und Portalauftritt', - 'Langtext für das Exposé mit Mikro-, Makro- und Umfeldteil', + 'Angebots- beziehungsweise Offertenbroschüre als PDF oder Word', 'Quellenverzeichnis mit Abrufdatum je Aussage', 'Änderungshinweise, sobald sich das Umfeld spürbar verändert', ], }, responsibilities: [ - 'Mikro- und Makrolage kommerziell genutzter Immobilien nachvollziehbar beschreiben', + 'Lageberichte für kommerziell genutzte Immobilien erstellen und nachvollziehbar belegen', + 'Inserate aus den Objektdaten in «Meine Objekte» erstellen', + 'Angebots- und Offertenbroschüren aus Objektdaten, Lageinformationen und vorhandenen Bildern erstellen', + 'Weitergeleitete Leads von Nora zügig zu einem Exposé verarbeiten', 'Erreichbarkeit, Nahversorgung und geplante Umfeldentwicklungen sauber trennen und darstellen', - 'Kurz- und Langtexte in der Firmen-Tonalität liefern, ohne Werbefloskeln ohne Beleg', 'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen', - 'Lagebeschriebe quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden', + 'Fehlende Pflichtangaben als Lücke ausweisen, statt plausible Werte zu erfinden', + 'Lageberichte quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden', ], tasks: [ @@ -175,6 +181,46 @@ export const liviaAgent: TeamAgent = { requiresApproval: false, dependsOnChannel: AgentChannelType.WORKSPACE_CHAT, }, + { + id: 'livia-task-12', + title: 'Weitergeleiteten Lead aufnehmen', + description: + 'Nimmt Leads entgegen, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat, samt der mitgegebenen Objektempfehlung. Die Objekte stammen ausschliesslich aus «Meine Objekte».', + enabled: true, + schedule: 'bei Weiterleitung', + requiresApproval: false, + dependsOnChannel: AgentChannelType.WORKSPACE_CHAT, + }, + { + id: 'livia-task-13', + title: 'Objektbilder aus «Meine Objekte» importieren', + description: + 'Importiert alle im Objektbestand hinterlegten Bilder automatisch in das Exposé, vermeidet Doppelimporte und übernimmt Kategorie, Reihenfolge und Titelbild-Markierung, soweit vorhanden.', + enabled: true, + schedule: 'beim Start eines Exposé-Auftrags', + requiresApproval: false, + dependsOnSystem: AgentSystemType.IMMOTOP2, + }, + { + id: 'livia-task-14', + title: 'Exposé-Felder aus den Objektdaten befüllen', + description: + 'Befüllt Eckdaten, Lage, Flächen, Preise, Ausstattung sowie Energie und Technik aus den in «Meine Objekte» verfügbaren Angaben. Nicht verfügbare Pflichtfelder bleiben leer und werden als fehlend markiert — es werden keine plausiblen Werte erfunden.', + enabled: true, + schedule: 'nach dem Bildimport', + requiresApproval: false, + dependsOnSystem: AgentSystemType.IMMOTOP2, + }, + { + id: 'livia-task-15', + title: 'Angebots- beziehungsweise Offertenbroschüre erstellen', + description: + 'Setzt aus den erfassten Objektdaten, Texten, Bildern und Anhängen die Broschüre in der Firmen-CI zusammen und stellt sie als PDF und Word zum Export bereit. Speichern und Erstellen bleiben getrennte Schritte.', + enabled: true, + schedule: 'auf Auslösung im Exposé-Arbeitsbereich', + requiresApproval: false, + dependsOnSystem: AgentSystemType.DMS, + }, ], channels: [ diff --git a/src/mock-data/agents/nora.ts b/src/mock-data/agents/nora.ts index 01a1498..22e47ca 100644 --- a/src/mock-data/agents/nora.ts +++ b/src/mock-data/agents/nora.ts @@ -25,7 +25,7 @@ import { export const noraAgent: TeamAgent = { id: 'nora', name: 'Nora', - role: 'Markt-Scout', + role: 'Marktchancen / Leads', personnelNumber: 'PO-ZD-19', department: 'Marktbeobachtung', email: 'nora@property-on.ch', diff --git a/src/mock-data/agents/reto.ts b/src/mock-data/agents/reto.ts deleted file mode 100644 index 8628f45..0000000 --- a/src/mock-data/agents/reto.ts +++ /dev/null @@ -1,398 +0,0 @@ -import type { TeamAgent } from '../../domain/teamAgent' -import { - AgentStatus, - AgentAutonomyLevel, - AgentChannelType, - AgentChannelDirection, - AgentConnectionStatus, - AgentSystemType, - AgentAccessLevel, - AgentSettingKind, - AgentSettingGroup, - AgentAvatarTone, -} from '../../domain/teamAgent' - -/** - * Reto — Recap-Agent der Vermarktung. - * - * Nimmt Sprachmemos und Kurztexte nach Besichtigungen und Telefonaten entgegen und - * legt daraus ein sauberes Protokoll, einen CRM-Entwurf und Folgeaufgaben vor. - * Geschrieben wird nie ohne die 1-Klick-Freigabe der Bewirtschafterin oder des - * Bewirtschafters. - */ -export const retoAgent: TeamAgent = { - id: 'reto', - name: 'Reto', - role: 'Recap-Agent', - personnelNumber: 'PO-ZD-14', - department: 'Vermarktung', - email: 'reto@property-on.ch', - avatarTone: AgentAvatarTone.SIGNAL, - status: AgentStatus.ACTIVE, - autonomyLevel: AgentAutonomyLevel.APPROVAL_REQUIRED, - shortDescription: 'Macht aus Sprachmemos strukturierte Protokolle, CRM-Einträge und Folgeaufgaben.', - autonomyNote: '«Mit 1-Klick-Freigabe»', - - profile: { - purpose: - 'Reto verwandelt das gesprochene Wort nach einer Besichtigung oder einem Telefonat in ein sauberes Protokoll, einen CRM-Entwurf und konkrete Folgeaufgaben — freigegeben mit einem einzigen Klick.', - input: [ - 'Sprachmemo per WhatsApp direkt vom Objekt, etwa nach einer Besichtigung in Zürich-Altstetten', - 'Telefonaufnahme aus einem Interessentengespräch', - 'Kurztext oder Stichwortnotiz per E-Mail', - 'Termin-Kontext aus dem Kalender: Objekt, Interessent, Uhrzeit, Teilnehmende', - ], - coreFlow: [ - 'Memo entgegennehmen und das Gesprochene in Text übertragen', - 'Füllwörter, Wiederholungen und Nebengeräusche entfernen', - 'Aussagen sortieren nach Feedback, Einwänden, Zusagen und offenen Punkten', - 'Termin-Kontext ergänzen: Objekt, Interessent, Datum, Teilnehmende', - 'Protokoll, CRM-Entwurf und Aufgabenliste als Vorschlag zusammenstellen', - 'Zur 1-Klick-Bestätigung vorlegen und erst danach in die Zielsysteme übertragen', - ], - output: [ - 'Besichtigungsprotokoll als abgelegtes Dokument im Dossier', - 'CRM-Entwurf mit Gesprächsnotiz, Interessentenstatus und nächstem Schritt', - 'Folgeaufgaben mit Fälligkeit und zuständiger Person', - 'Objektbeobachtungen für die Bewirtschaftung, etwa Mängel oder Rückfragen zum Ausbau', - ], - }, - - responsibilities: [ - 'Jedes Gespräch der Vermarktung ist am selben Tag protokolliert und im Dossier auffindbar.', - 'Feedback, Einwände und Zusagen der Interessenten sind strukturiert festgehalten, nicht nur als Fliesstext.', - 'Kein Eintrag gelangt ohne ausdrückliche Freigabe in CRM, Dossier oder Aufgabenliste.', - 'Termine ohne Recap werden aktiv nachgefasst, damit keine Zusage verloren geht.', - ], - - tasks: [ - { - id: 'reto-task-01', - title: 'Sprachmemo oder Kurztext entgegennehmen', - description: - 'Nimmt Sprachmemos aus WhatsApp, Telefonaufnahmen und Kurztexte per E-Mail entgegen und ordnet sie über Zeitpunkt und Objektbezug dem richtigen Termin zu — etwa der Besichtigung an der Technoparkstrasse in Winterthur.', - enabled: true, - schedule: 'bei Eingang', - requiresApproval: false, - dependsOnChannel: AgentChannelType.WHATSAPP, - }, - { - id: 'reto-task-02', - title: 'Sprache transkribieren', - description: - 'Überträgt das Gesprochene in Text, inklusive Schweizer Ortsnamen, Objektbezeichnungen und Fachbegriffen aus der Bewirtschaftung. Unsichere Stellen werden markiert statt geraten.', - enabled: true, - schedule: 'unmittelbar nach Eingang des Memos', - requiresApproval: false, - dependsOnChannel: AgentChannelType.PHONE_VOICE, - }, - { - id: 'reto-task-03', - title: 'Inhalt bereinigen', - description: - 'Entfernt Füllwörter, Versprecher und Wiederholungen, ordnet die Sätze chronologisch und behält dabei jede inhaltliche Aussage unverändert bei.', - enabled: true, - schedule: 'direkt nach der Transkription', - requiresApproval: false, - }, - { - id: 'reto-task-04', - title: 'Feedback, Einwände und Zusagen strukturieren', - description: - 'Trennt das Gespräch in vier Blöcke: positives Feedback zum Objekt, Einwände wie Fläche oder Mietzins, verbindliche Zusagen beider Seiten und offene Fragen. Jeder Punkt behält den Bezug zur Aussage im Memo.', - enabled: true, - schedule: 'nach der Bereinigung', - requiresApproval: false, - }, - { - id: 'reto-task-05', - title: 'Nächste Schritte identifizieren', - description: - 'Leitet aus Zusagen und offenen Fragen die konkreten nächsten Schritte ab — Unterlagen nachreichen, Zweitbesichtigung ansetzen, Mietzins intern klären — je mit Frist und zuständiger Person.', - enabled: true, - schedule: 'nach der Strukturierung', - requiresApproval: false, - }, - { - id: 'reto-task-06', - title: 'CRM- und Protokolleinträge vorschlagen', - description: - 'Stellt den Entwurf für die Gesprächsnotiz im CRM und das Besichtigungsprotokoll für das Objektdossier zusammen. Beides bleibt Vorschlag, bis die Freigabe erteilt ist.', - enabled: true, - schedule: 'nach der Strukturierung', - requiresApproval: true, - dependsOnSystem: AgentSystemType.CRM, - }, - { - id: 'reto-task-07', - title: 'Aufgaben aus dem Gespräch ableiten', - description: - 'Formuliert aus den nächsten Schritten einzelne Aufgaben mit Titel, Fälligkeit und Zuständigkeit — etwa «Grundrisse 3. OG an Interessent senden, bis 22.05.2026». Die Aufgaben werden erst nach Bestätigung angelegt.', - enabled: true, - schedule: 'nach der Strukturierung', - requiresApproval: true, - }, - { - id: 'reto-task-08', - title: 'Ergebnis zur 1-Klick-Bestätigung vorlegen', - description: - 'Legt Protokoll, CRM-Entwurf und Aufgabenliste in einer Übersicht vor. Alles lässt sich vor dem Bestätigen anpassen; ein Klick gibt das gesamte Paket frei.', - enabled: true, - schedule: 'sobald der Entwurf vollständig ist', - requiresApproval: false, - dependsOnChannel: AgentChannelType.WORKSPACE_CHAT, - }, - { - id: 'reto-task-09', - title: 'Nach Bestätigung simuliert ins Zielsystem schreiben', - description: - 'Überträgt das freigegebene Paket simuliert in das eingestellte Zielsystem: Gesprächsnotiz ins CRM, Protokoll ins Dossier, Aufgaben in die Aufgabenliste. Steht kein CRM- oder ERP-Zugang zur Verfügung, greift der eingestellte CRM-/ERP-Fallback und das Paket geht als strukturierte E-Mail an die zuständige Person. Ohne Freigabe passiert nichts.', - enabled: true, - schedule: 'unmittelbar nach der Freigabe', - requiresApproval: true, - dependsOnSystem: AgentSystemType.CRM, - }, - { - id: 'reto-task-10', - title: 'Drei Stunden nach Termin an fehlendes Diktat erinnern', - description: - 'Prüft nach jedem Vermarktungstermin im Kalender, ob ein Memo eingegangen ist. Fehlt es, folgt eine kurze Erinnerung mit Objekt und Interessent auf demselben Weg, auf dem sonst diktiert wird.', - enabled: true, - schedule: 'drei Stunden nach Terminende', - requiresApproval: false, - dependsOnSystem: AgentSystemType.CALENDAR, - }, - ], - - channels: [ - { - id: 'reto-ch-whatsapp', - type: AgentChannelType.WHATSAPP, - direction: AgentChannelDirection.INBOUND, - description: - 'Hauptweg für Sprachmemos direkt nach der Besichtigung — diktiert im Auto oder im Treppenhaus, ganz ohne Formular.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Reto Diktat (WhatsApp)', - inboxAddress: '+41 44 500 21 14', - defaultRecipients: ['vermarktung@property-on.ch'], - autoReplyEnabled: true, - }, - }, - { - id: 'reto-ch-phone', - type: AgentChannelType.PHONE_VOICE, - direction: AgentChannelDirection.INBOUND, - description: - 'Diktatnummer für längere Gesprächsnotizen und Telefonaufnahmen aus Interessentengesprächen.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Reto Diktatlinie', - inboxAddress: '+41 44 500 21 15', - defaultRecipients: ['vermarktung@property-on.ch'], - autoReplyEnabled: false, - }, - }, - { - id: 'reto-ch-email', - type: AgentChannelType.EMAIL, - direction: AgentChannelDirection.BOTH, - description: - 'Nimmt Kurztexte und Stichwortnotizen entgegen und versendet das fertige Protokoll sowie den strukturierten Fallback.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Reto — Recap Vermarktung', - senderAddress: 'reto@property-on.ch', - inboxAddress: 'recap@property-on.ch', - defaultRecipients: ['vermarktung@property-on.ch', 'bewirtschaftung@property-on.ch'], - autoReplyEnabled: true, - }, - }, - { - id: 'reto-ch-chat', - type: AgentChannelType.WORKSPACE_CHAT, - direction: AgentChannelDirection.OUTBOUND, - description: - 'Legt die Freigabekarte mit Protokoll, CRM-Entwurf und Aufgaben im Arbeitsbereich vor und erinnert dort an fehlende Diktate.', - status: AgentConnectionStatus.CONNECTED, - enabled: true, - config: { - displayName: 'Property On — Vermarktung', - defaultRecipients: ['#vermarktung-zuerich', '#vermarktung-winterthur'], - autoReplyEnabled: false, - }, - }, - ], - - systems: [ - { - id: 'reto-sys-crm', - type: AgentSystemType.CRM, - access: AgentAccessLevel.WRITE, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Legt Gesprächsnotiz, Interessentenstatus und nächsten Schritt beim passenden Kontakt und Objekt ab.', - permissionNote: - 'Schreibt ausschliesslich nach der 1-Klick-Freigabe — und in dieser Ausbaustufe nur simuliert. Ohne Bestätigung entsteht kein Eintrag.', - }, - { - id: 'reto-sys-immotop2', - type: AgentSystemType.IMMOTOP2, - access: AgentAccessLevel.READ, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Liest Objekt- und Flächendaten, damit Protokoll und Notiz die richtige Liegenschaft, Einheit und Fläche nennen.', - permissionNote: 'Nur lesender Zugang. Reto verändert keine Objekt- oder Vertragsdaten.', - }, - { - id: 'reto-sys-dms', - type: AgentSystemType.DMS, - access: AgentAccessLevel.READ_WRITE, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Liest bestehende Protokolle desselben Objekts und legt das freigegebene Besichtigungsprotokoll im Objektdossier ab.', - permissionNote: - 'Ablage erfolgt erst nach Freigabe und nur im Dossier des betroffenen Objekts. Bestehende Dokumente werden nie überschrieben.', - }, - { - id: 'reto-sys-calendar', - type: AgentSystemType.CALENDAR, - access: AgentAccessLevel.READ, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Holt den Termin-Kontext: Objekt, Interessent, Uhrzeit und Teilnehmende — und erkennt Termine ohne Recap.', - permissionNote: 'Nur lesender Zugang. Es werden keine Termine erstellt, verschoben oder abgesagt.', - }, - { - id: 'reto-sys-exchange', - type: AgentSystemType.M365_EXCHANGE, - access: AgentAccessLevel.READ_WRITE, - status: AgentConnectionStatus.CONNECTED, - usage: - 'Empfängt Kurztexte aus dem Postfach und versendet Protokoll sowie strukturierten Fallback an die zuständige Person.', - permissionNote: - 'Versand nur an interne Adressen der eigenen Organisation und erst nach Freigabe. Interessenten erhalten nie automatisch eine Nachricht.', - }, - ], - - settings: [ - { - id: 'reto-set-structure', - label: 'Standardstruktur des Recaps', - description: 'Welche Blöcke jedes Protokoll enthält. Leere Blöcke werden im Ergebnis ausgeblendet.', - kind: AgentSettingKind.MULTI_SELECT, - group: AgentSettingGroup.SPECIFIC, - value: ['SUMMARY', 'FEEDBACK', 'OBJECTIONS', 'COMMITMENTS', 'NEXT_STEPS', 'OBJECT_NOTES'], - options: [ - { value: 'SUMMARY', label: 'Kurzfazit in zwei Sätzen' }, - { value: 'FEEDBACK', label: 'Feedback zum Objekt' }, - { value: 'OBJECTIONS', label: 'Einwände und Bedenken' }, - { value: 'COMMITMENTS', label: 'Zusagen beider Seiten' }, - { value: 'NEXT_STEPS', label: 'Nächste Schritte' }, - { value: 'OBJECT_NOTES', label: 'Objektbeobachtungen für die Bewirtschaftung' }, - { value: 'PARTICIPANTS', label: 'Teilnehmende und Rollen' }, - { value: 'BUDGET', label: 'Mietzins- und Kostenthemen' }, - ], - }, - { - id: 'reto-set-crm-target', - label: 'CRM-Zielsystem', - description: 'Wohin die freigegebene Gesprächsnotiz übertragen wird.', - kind: AgentSettingKind.SELECT, - group: AgentSettingGroup.DELIVERY, - value: 'IMMOTOP2_CRM', - options: [ - { value: 'IMMOTOP2_CRM', label: 'ImmoTop2 — Interessentenbetreuung' }, - { value: 'ABACUS_ABAIMMO', label: 'Abacus AbaImmo' }, - { value: 'RIMO_R5', label: 'Rimo R5' }, - { value: 'GARAIO_REM', label: 'Garaio REM' }, - { value: 'INTERNAL_CRM', label: 'Property On — internes Kontaktjournal' }, - ], - }, - { - id: 'reto-set-fallback', - label: 'Fallback-Ziel', - description: - 'Was geschieht, wenn das CRM- oder ERP-System keinen Zugang für den Eintrag bereitstellt oder nicht erreichbar ist.', - kind: AgentSettingKind.SELECT, - group: AgentSettingGroup.DELIVERY, - value: 'STRUCTURED_MAIL', - options: [ - { value: 'STRUCTURED_MAIL', label: 'Strukturierter Mail-Fallback an die zuständige Person' }, - { value: 'DMS_DOSSIER', label: 'Ablage als Protokoll im Objektdossier' }, - { value: 'WORKSPACE_CARD', label: 'Karte im Arbeitsbereich der Vermarktung' }, - { value: 'HOLD', label: 'Zurückhalten und erneut zur Bestätigung vorlegen' }, - ], - }, - { - id: 'reto-set-approval-locked', - label: 'Freigabe zwingend aktiv', - description: 'Jedes Ergebnis wird vor der Übertragung zur Bestätigung vorgelegt.', - kind: AgentSettingKind.BOOLEAN, - group: AgentSettingGroup.APPROVAL, - value: true, - locked: true, - lockedReason: - 'Fachlich nicht abschaltbar: Ohne erteilte Freigabe schreibt Reto nie in ein Zielsystem — weder ins CRM noch ins Dossier noch in die Aufgabenliste.', - }, - { - id: 'reto-set-reminder-hours', - label: 'Erinnerung nach Termin', - description: 'Nach wie vielen Stunden ohne Diktat eine Erinnerung ausgelöst wird.', - kind: AgentSettingKind.NUMBER, - group: AgentSettingGroup.SPECIFIC, - value: 3, - unit: 'Stunden', - min: 1, - max: 24, - }, - { - id: 'reto-set-auto-tasks', - label: 'Aufgaben automatisch vorschlagen', - description: - 'Leitet aus jedem Gespräch Folgeaufgaben mit Frist und Zuständigkeit ab. Angelegt werden sie erst nach der Freigabe.', - kind: AgentSettingKind.BOOLEAN, - group: AgentSettingGroup.GENERAL, - value: true, - }, - ], - - metrics: [ - { - id: 'reto-metric-open-approvals', - label: 'Offene Bestätigungen', - value: '4', - hint: 'Fertige Recaps, die auf die 1-Klick-Freigabe warten — älteste seit gestern 16:20.', - }, - { - id: 'reto-metric-processed', - label: 'Verarbeitete Diktate', - value: '128', - hint: 'Seit dem 01.05.2026 entgegengenommene Sprachmemos und Kurztexte.', - }, - { - id: 'reto-metric-duration', - label: 'Durchschnittliche Verarbeitungszeit', - value: '2 Min. 40 Sek.', - hint: 'Vom Eingang des Memos bis zum vorgelegten Recap.', - }, - { - id: 'reto-metric-tasks-created', - label: 'Erstellte Aufgaben', - value: '96', - hint: 'Nach Freigabe angelegte Folgeaufgaben, davon 11 aktuell überfällig.', - }, - { - id: 'reto-metric-missing-recaps', - label: 'Termine ohne Recap', - value: '3', - hint: 'Vermarktungstermine der letzten sieben Tage ohne eingegangenes Diktat.', - }, - ], - - headlineMetricId: 'reto-metric-open-approvals', - lastRun: '2026-05-20T16:42:00.000Z', -} diff --git a/src/mock-data/agents/sina.ts b/src/mock-data/agents/sina.ts index 1fcf462..d840750 100644 --- a/src/mock-data/agents/sina.ts +++ b/src/mock-data/agents/sina.ts @@ -23,7 +23,7 @@ import { export const sinaAgent: TeamAgent = { id: 'sina', name: 'Sina', - role: 'Vertragsauskunft', + role: 'Datenpflege', personnelNumber: 'PO-ZD-03', department: 'Bewirtschaftung', email: 'sina@property-on.ch', diff --git a/src/mock-data/demandInquiries.ts b/src/mock-data/demandInquiries.ts index 582bbad..0568f52 100644 --- a/src/mock-data/demandInquiries.ts +++ b/src/mock-data/demandInquiries.ts @@ -200,7 +200,7 @@ export const mockDemandInquiries: Inquiry[] = [ tenantName: 'Admin User', tenantCompany: 'Mobimo Management AG', tenantEmail: 'admin@ideal-sharing.ch', - propertyManagerName: 'Reto Mäder', + propertyManagerName: 'Rico Mäder', propertyManagerCompany: 'Allreal AG', propertyAddress: 'Logistikfläche Dreispitz Basel', subject: 'Anfrage: Logistikfläche Dreispitz Basel', @@ -228,9 +228,9 @@ export const mockDemandInquiries: Inquiry[] = [ id: 'dmsg-005-2', inquiryId: 'dinq-005', senderType: 'supply_user', - senderName: 'Reto Mäder', + senderName: 'Rico Mäder', body: - 'Guten Tag\n\nJa, die Fläche ist noch verfügbar. Wir haben ca. 1\'800 m² mit 5.5 m Hallenhöhe und zwei Rampen. Besichtigung möglich ab nächster Woche. Anbei das Datenblatt.\n\nFreundliche Grüsse\nReto Mäder\nAllreal AG', + 'Guten Tag\n\nJa, die Fläche ist noch verfügbar. Wir haben ca. 1\'800 m² mit 5.5 m Hallenhöhe und zwei Rampen. Besichtigung möglich ab nächster Woche. Anbei das Datenblatt.\n\nFreundliche Grüsse\nRico Mäder\nAllreal AG', attachments: [ { id: 'datt-005-1', fileName: 'Datenblatt_Dreispitz_Logistik.pdf', fileType: 'application/pdf', fileSize: 1100000 }, ], diff --git a/src/mock-data/exposeLeads.ts b/src/mock-data/exposeLeads.ts new file mode 100644 index 0000000..4d2faf8 --- /dev/null +++ b/src/mock-data/exposeLeads.ts @@ -0,0 +1,68 @@ +/** + * Property On — Leads im Backlog von Livia. + * + * Herkunft: Nachfragesignale, die Nora erkannt hat, plus zwei bereits + * abgeschlossene Vorgänge im Archiv. Sämtliche Objekt-IDs stammen aus + * «Meine Objekte» (`mock-data/properties.ts`) — es werden hier keine Objekte + * erfunden, sonst führte die Objektempfehlung ins Leere. + * + * Zeitanker: 20.05.2026, wie alle Property-On-Mockdaten. + */ + +import type { ExposeLead } from '../domain/exposeLead' +import { ExposeLeadStatus } from '../domain/exposeLead' + +export const mockExposeLeads: ExposeLead[] = [ + { + id: 'lead-001', + receivedAt: '2026-05-19T14:20:00.000Z', + prospect: 'Alpbach Advisors AG', + contacts: ['zuerich@alpbach-advisors.ch', 'https://alpbach-advisors.ch', 'Dr. Markus Suter (Managing Partner)'], + locationHint: 'Zürich, Innenstadt / Paradeplatz', + propertyIds: ['prop-043', 'prop-044'], + status: ExposeLeadStatus.ACTIVE, + sourceSignalId: 'signal-001', + forwardedBy: 'Nadine Brunner', + }, + { + id: 'lead-002', + receivedAt: '2026-05-18T09:05:00.000Z', + prospect: 'Novabio Pharma AG', + contacts: ['https://novabio-pharma.ch'], + locationHint: 'Basel, Allschwil', + propertyIds: ['prop-008'], + status: ExposeLeadStatus.ACTIVE, + sourceSignalId: 'signal-004', + forwardedBy: 'Nadine Brunner', + }, + { + id: 'lead-003', + receivedAt: '2026-05-15T11:40:00.000Z', + prospect: 'Textilhaus Zürich AG', + contacts: ['kontakt@textilhaus-zuerich.ch'], + locationHint: 'Zürich, Kreis 4 / Langstrasse', + propertyIds: ['prop-040', 'prop-041', 'prop-046'], + status: ExposeLeadStatus.ACTIVE, + forwardedBy: 'Marc Wyss', + }, + { + id: 'lead-004', + receivedAt: '2026-04-28T08:15:00.000Z', + prospect: 'Helvetia Produktion GmbH', + contacts: ['info@helvetia-produktion.ch'], + locationHint: 'Pratteln / Basel-Land', + propertyIds: ['prop-014'], + status: ExposeLeadStatus.ARCHIVED, + forwardedBy: 'Marc Wyss', + }, + { + id: 'lead-005', + receivedAt: '2026-04-12T16:00:00.000Z', + prospect: 'Schweizer Grosshandel AG', + contacts: ['beschaffung@schweizer-grosshandel.ch'], + locationHint: 'Winterthur', + propertyIds: ['prop-009'], + status: ExposeLeadStatus.ARCHIVED, + forwardedBy: 'Nadine Brunner', + }, +] diff --git a/src/mock-data/teamAgents.ts b/src/mock-data/teamAgents.ts index 7feb646..d99b2f6 100644 --- a/src/mock-data/teamAgents.ts +++ b/src/mock-data/teamAgents.ts @@ -1,38 +1,36 @@ /** - * Property On — Kernteam der sieben digitalen Mitarbeiter. + * Property On — Kernteam der fünf digitalen Mitarbeitenden. * - * Reihenfolge alphabetisch nach Vorname (§7.2 der Spezifikation): Bruno, Ferdi, - * Lea, Livia, Nora, Reto, Sina. Die Agentenliste in der Personalverwaltung - * rendert genau diese Reihenfolge, ohne selbst zu sortieren. + * Reihenfolge nach Arbeitsablauf, nicht alphabetisch: Ferdi erkennt die Frist, + * Bruno bereitet den Termin vor, Livia erstellt das Exposé, Nora findet die + * Nachfrage, Sina hält die Objektdaten sauber. Genau diese Reihenfolge führt + * auch das Agenten-Untermenü unter «Meine Agenten» (Runde 4, §2.2). + * + * Reto und Lea sind entfallen: Retos Aufgaben liegen vollständig bei Bruno, + * Leas Angebots- und Exposé-Aufgaben bei Livia. */ import type { TeamAgent } from '../domain/teamAgent' -import { brunoAgent } from './agents/bruno' import { ferdiAgent } from './agents/ferdi' -import { leaAgent } from './agents/lea' +import { brunoAgent } from './agents/bruno' import { liviaAgent } from './agents/livia' import { noraAgent } from './agents/nora' -import { retoAgent } from './agents/reto' import { sinaAgent } from './agents/sina' export const mockTeamAgents: TeamAgent[] = [ - brunoAgent, ferdiAgent, - leaAgent, + brunoAgent, liviaAgent, noraAgent, - retoAgent, sinaAgent, ] /** Stabile IDs — Mockdaten anderer Bestände referenzieren ausschliesslich diese. */ export const AGENT_IDS = { - BRUNO: 'bruno', FERDI: 'ferdi', - LEA: 'lea', + BRUNO: 'bruno', LIVIA: 'livia', NORA: 'nora', - RETO: 'reto', SINA: 'sina', } as const diff --git a/src/mock-data/visitAssignments.ts b/src/mock-data/visitAssignments.ts new file mode 100644 index 0000000..e26a6ab --- /dev/null +++ b/src/mock-data/visitAssignments.ts @@ -0,0 +1,144 @@ +/** + * Property On — Besichtigungsaufträge bei Bruno. + * + * Sämtliche Objekte stammen aus «Meine Objekte» (`mock-data/properties.ts`) und + * tragen deren ID und Titel unverändert. Interessenten und Firmen sind erfunden; + * die Quellenangaben zeigen, wie Bruno belegte von unbelegten Angaben trennt. + * + * Zeitanker: 20.05.2026, wie alle Property-On-Mockdaten. Der Auftrag + * `visit-001` liegt bewusst weniger als 24 Stunden voraus und hat noch keinen + * angeforderten Bericht — daran zeigt sich die Warnung über die Glocke. + */ + +import type { VisitAssignment } from '../domain/visitAssignment' +import { VisitRequestType } from '../domain/visitAssignment' + +export const mockVisitAssignments: VisitAssignment[] = [ + { + id: 'visit-001', + scheduledAt: '2026-05-20T16:00:00.000Z', + propertyId: 'prop-043', + propertyTitle: 'Bürofläche Bahnhofstrasse 52', + prospect: 'Alpbach Advisors AG', + requestType: VisitRequestType.PREPARATION, + origin: 'CRM — Termin der Vermarktung Zürich', + preparation: { + sellingPoints: [ + 'Repräsentative Lage an der Bahnhofstrasse, fünf Gehminuten zum Paradeplatz.', + 'Bezugsbereite Fläche ohne Ausbauzeit — der angekündigte Standortstart bleibt haltbar.', + 'Direkte ÖV-Anbindung Hauptbahnhof für ein Team, das über mehrere Standorte pendelt.', + ], + objections: [ + { + objection: 'Der Mietzins liegt über dem Budget.', + answer: 'Nettomiete und Nebenkosten getrennt ausweisen; der Ausbau ist im Zins enthalten und entfällt als Einmalinvestition.', + }, + { + objection: 'Die Fläche ist für 38 Mitarbeitende knapp bemessen.', + answer: 'Belegungsplan mit Zellen- und Kombibüro zeigen; die Erweiterungsoption im gleichen Gebäude ansprechen.', + }, + { + objection: 'Der Bezugstermin ist zu spät.', + answer: 'Verfügbarkeitsdatum aus dem Objektdossier nennen und Zwischenlösung im selben Portfolio anbieten.', + }, + ], + locationReport: + 'Lagebericht von Livia: Zürich Kreis 1, Bahnhofstrasse — Finanzplatz mit hoher Repräsentationswirkung. ÖV-Güteklasse A, Hauptbahnhof rund 400 m. Nahversorgung und Gastronomie im unmittelbaren Umfeld. Keine bewilligten Grossbaustellen im Umkreis von 200 m.', + prospectFindings: [ + { statement: 'Zürich-Büro am 15.04. per Medienmitteilung angekündigt.', source: 'Medienmitteilung des Unternehmens' }, + { statement: 'Sitz in Luzern, 38 Mitarbeitende, Vermögensverwaltung.', source: 'Handelsregistereintrag' }, + ], + reportRequested: false, + audioAvailable: true, + }, + }, + { + id: 'visit-002', + scheduledAt: '2026-05-22T10:30:00.000Z', + propertyId: 'prop-008', + propertyTitle: 'Bürofläche Dreispitz Areal 9', + prospect: 'Novabio Pharma AG', + requestType: VisitRequestType.PREPARATION, + origin: 'Chatauftrag des Bewirtschafters', + preparation: { + sellingPoints: [ + 'Areal mit bestehender Life-Sciences-Nachbarschaft — kurze Wege zu Partnern und Zulieferern.', + 'Technisch belastbare Fläche: Deckenhöhe und Anlieferung sind für Laborausbau geeignet.', + 'Etappierbarer Bezug, falls die Laborerweiterung in zwei Schritten erfolgt.', + ], + objections: [ + { + objection: 'Die Laborinfrastruktur fehlt.', + answer: 'Ausbaustandard und Trägerschaft aus dem Objektdossier zeigen; Mieterausbaubeitrag als Verhandlungspunkt benennen.', + }, + { + objection: 'Die Anbindung an Allschwil ist unklar.', + answer: 'ÖV-Distanz und Autobahnanschluss aus dem Lagebericht nennen.', + }, + { + objection: 'Der Zeitplan der Erweiterung ist noch offen.', + answer: 'Verfügbarkeit und Reservationsfrist nennen, ohne einen Termin zu behaupten, der nicht bestätigt ist.', + }, + ], + locationReport: + 'Lagebericht von Livia: Basel Dreispitz — Transformationsareal mit gemischter Gewerbe- und Life-Sciences-Nutzung. Tramanbindung vorhanden, Autobahnanschluss rund 1,5 km.', + prospectFindings: [ + { statement: 'Kapazitätsausbau im Geschäftsbericht ausgewiesen.', source: 'Geschäftsbericht des Unternehmens' }, + ], + reportRequested: true, + audioAvailable: false, + }, + }, + { + id: 'visit-003', + scheduledAt: '2026-05-19T14:00:00.000Z', + propertyId: 'prop-040', + propertyTitle: 'Ladenlokal Langstrasse 84', + prospect: 'Textilhaus Zürich AG', + requestType: VisitRequestType.FOLLOW_UP, + origin: 'WhatsApp-Sprachnachricht des Maklers', + followUp: { + brokerInput: + 'Sprachnachricht direkt nach dem Termin: Fläche hat gefallen, Schaufensterfront war das Hauptthema. Mietzins wurde als zu hoch bezeichnet, aber nicht abgelehnt. Interessent will die Zahlen intern prüfen und meldet sich bis Ende Woche. Grundriss und Nebenkostenabrechnung sollen nachgereicht werden.', + inputChannel: 'WhatsApp (Sprachnachricht, 1:47 min)', + processingSteps: [ + 'Sprachnachricht in Text übertragen, Füllwörter entfernt', + 'Aussagen getrennt nach Feedback, Einwänden, Zusagen und offenen Punkten', + 'Termin-Kontext aus dem Kalender ergänzt: Objekt, Interessent, Datum, Teilnehmende', + 'Zwei Folgeaufgaben abgeleitet: Grundriss senden, Nebenkostenabrechnung senden', + ], + outputFileName: 'protokoll-langstrasse-84-19-05-2026.pdf', + outputDescription: 'Besichtigungsprotokoll mit Kundennotiz fürs CRM', + remarks: [ + 'Protokoll im CRM am Vorgang des Interessenten abgelegt.', + 'Kundennotiz zusätzlich in der Dokumentenablage beim Objektdossier gespeichert.', + 'Mietzins-Einwand nicht als Absage gewertet — der Interessent hat eine Prüfung zugesagt.', + ], + }, + }, + { + id: 'visit-004', + scheduledAt: '2026-05-18T09:00:00.000Z', + propertyId: 'prop-009', + propertyTitle: 'Logistikzentrum Tössfeldstrasse 18', + prospect: 'Schweizer Grosshandel AG', + requestType: VisitRequestType.FOLLOW_UP, + origin: 'Telefonnotiz aus dem CRM', + followUp: { + brokerInput: + 'Telefonisch nachbesprochen: Rampenanzahl und Andienung waren ausschlaggebend. Fläche grundsätzlich passend, aber der gewünschte Bezugstermin liegt drei Monate früher als angeboten.', + inputChannel: 'Telefonnotiz (CRM)', + processingSteps: [ + 'Notiz aus dem CRM übernommen und strukturiert', + 'Offenen Punkt «Bezugstermin» als einzigen Blocker markiert', + 'Rückfrage an die Bewirtschaftung zur Verfügbarkeit formuliert', + ], + outputFileName: 'kundennotiz-toessfeldstrasse-18-18-05-2026.pdf', + outputDescription: 'Kundennotiz mit offenem Punkt zum Bezugstermin', + remarks: [ + 'Datei im CRM am Vorgang abgelegt.', + 'Keine Zusage zum vorgezogenen Bezugstermin gemacht — die Verfügbarkeit ist nicht bestätigt.', + ], + }, + }, +] diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx deleted file mode 100644 index 27f0036..0000000 --- a/src/pages/Home.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Container, Typography, Button, Card, CardContent, Chip } from '@mui/material' -import HomeIcon from '@mui/icons-material/Home' -import SearchIcon from '@mui/icons-material/Search' - -export default function Home() { - return ( - -
- - Property Match - - - Find your ideal property - -
- -
- - -
- -
- {(['Buy', 'Rent', 'Invest'] as const).map((category) => ( - - - - - {category} a Property - - - Browse listings available for {category.toLowerCase()} in your area. - - - - ))} -
-
- ) -} diff --git a/src/pages/auth/LoginScreen.tsx b/src/pages/auth/LoginScreen.tsx index d50b2ec..0c23a9d 100644 --- a/src/pages/auth/LoginScreen.tsx +++ b/src/pages/auth/LoginScreen.tsx @@ -15,6 +15,7 @@ import { Building2 } from 'lucide-react' import { useLogin, useSwitchDemoRole } from '../../hooks/useAuth' import { useSessionStore } from '../../stores/sessionStore' import { UserRole } from '../../domain/enums' +import { DS_BRAND, DS_NEUTRAL, DS_SLATE } from '../../lib/ds' const DEMO_ROLES: { role: UserRole; label: string; description: string }[] = [ { role: UserRole.PROPERTY_MANAGER, label: 'Verwaltung', description: 'Portfolio verwalten + Markt durchsuchen' }, @@ -63,7 +64,7 @@ export default function LoginScreen() { display: 'flex', alignItems: 'center', justifyContent: 'center', - bgcolor: '#f1f5f9', + bgcolor: DS_SLATE[100], p: 2, }} > @@ -75,7 +76,7 @@ export default function LoginScreen() { width: 40, height: 40, borderRadius: 1.5, - bgcolor: '#0f1923', + bgcolor: DS_NEUTRAL.sidebar, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -84,10 +85,10 @@ export default function LoginScreen() {
- + Property Match - + Decision Intelligence @@ -134,9 +135,9 @@ export default function LoginScreen() { variant="contained" fullWidth disabled={loading} - sx={{ bgcolor: '#152642', textTransform: 'none', fontWeight: 600, '&:hover': { bgcolor: '#162d4a' } }} + sx={{ bgcolor: DS_BRAND.main, textTransform: 'none', fontWeight: 600, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }} > - {loading ? : 'Anmelden'} + {loading ? : 'Anmelden'}
@@ -146,7 +147,7 @@ export default function LoginScreen() { - + Demo-Zugänge @@ -169,7 +170,7 @@ export default function LoginScreen() { border: '1px solid #e2e8f0', cursor: 'pointer', transition: 'border-color 0.15s', - '&:hover': { borderColor: '#152642', bgcolor: 'rgba(30,58,95,0.03)' }, + '&:hover': { borderColor: DS_BRAND.main, bgcolor: 'rgba(30,58,95,0.03)' }, }} > @@ -180,7 +181,7 @@ export default function LoginScreen() { {description} - + ))} diff --git a/src/pages/supply/Besichtigungen.tsx b/src/pages/supply/Besichtigungen.tsx new file mode 100644 index 0000000..8baa745 --- /dev/null +++ b/src/pages/supply/Besichtigungen.tsx @@ -0,0 +1,146 @@ +import { memo, useMemo, useState } from 'react' +import { Box, Typography } from '@mui/material' +import { AlertTriangle } from 'lucide-react' +import { AgentWorkspaceHero, ObjectDeepLink } from '../../components/team' +import { VisitPreparationDrawer } from '../../components/visits' +import { LoadingPage, ErrorState } from '../../components/ui' +import { useVisitAssignments } from '../../hooks/useVisitAssignments' +import { needsReportWarning } from '../../services/visitAssignmentService' +import type { VisitAssignment } from '../../domain/visitAssignment' +import { VISIT_REQUEST_TYPE_LABELS } from '../../lib/constants' +import { agentWorkspaceById } from '../../lib/agentWorkspaces' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' + +const BRUNO = agentWorkspaceById('bruno')! + +/** Spaltenraster im Design der Livia-Leadliste (Runde 4, §9.2). */ +const COLS = '110px 1.4fr 1.2fr 150px' + +const COLUMN_LABELS = ['Datum', 'Objekt', 'Interessent', 'Anfragetyp'] + +const AssignmentRow = memo(function AssignmentRow({ + assignment, selected, onSelect, +}: { + assignment: VisitAssignment + selected: boolean + onSelect: (a: VisitAssignment) => void +}) { + const warn = needsReportWarning(assignment) + + return ( + onSelect(assignment)} + sx={{ + display: 'grid', + gridTemplateColumns: COLS, + alignItems: 'center', + 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', + }} + > + + {new Date(assignment.scheduledAt).toLocaleDateString('de-CH')} + + + {/* Objekte sind anklickbar und führen zu «Meine Objekte». */} + e.stopPropagation()}> + + + + + {assignment.prospect} + + + + + {VISIT_REQUEST_TYPE_LABELS[assignment.requestType]} + + {warn && ( + + )} + + + ) +}) + +/** + * Bruno — Besichtigungsassistent (Runde 4, §9). + * + * Neue Agentenseite: Chat-Einstieg, darunter die Auftragsliste im Design der + * Livia-Leadliste. Beim Klick auf einen Auftrag öffnet sich die seitliche + * Detailansicht analog zu Ferdis Reminder-Detailansicht. + * + * Die Aufträge stammen aus dem CRM oder aus dem, was der Makler über Chat und + * andere angebundene Kommunikationswege erteilt hat. + */ +export default function Besichtigungen() { + const [selected, setSelected] = useState(null) + const { data: assignments = [], isLoading, isError, refetch } = useVisitAssignments() + + const sorted = useMemo( + () => [...assignments].sort((a, b) => b.scheduledAt.localeCompare(a.scheduledAt)), + [assignments], + ) + + if (isLoading) return + if (isError) { + return refetch()} /> + } + + return ( + + + + + + + + {COLUMN_LABELS.map(h => ( + + {h} + + ))} + + + {sorted.length === 0 ? ( + + + Keine Besichtigungsaufträge. Sobald ein Termin im CRM steht oder ein Auftrag über den Chat kommt, erscheint er hier. + + + ) : ( + sorted.map(a => ( + + )) + )} + + + + + setSelected(null)} /> + + ) +} diff --git a/src/pages/supply/DataQuality.tsx b/src/pages/supply/DataQuality.tsx index a6e9adb..fced657 100644 --- a/src/pages/supply/DataQuality.tsx +++ b/src/pages/supply/DataQuality.tsx @@ -1,403 +1,289 @@ import { memo, useMemo, useState } from 'react' import { - Alert, - Box, - Card, - Chip, - Drawer, - IconButton, - LinearProgress, - Tooltip, - Typography, + Alert, Box, Card, Drawer, InputAdornment, MenuItem, TextField, Typography, } from '@mui/material' -import { AlertTriangle, CheckCircle2, Clock, Edit2, RefreshCw } from 'lucide-react' +import { ArrowDown, ArrowUp, Search } from 'lucide-react' import { LoadingPage, ErrorState } from '../../components/ui' -import { DataQualityBadge, FreshnessIndicator } from '../../components/data-quality' -import { PropertyDetailView } from '../../components/supply' +import { AgentWorkspaceHero, ObjectDeepLink } from '../../components/team' +import { EditableObjectOverview } from '../../components/supply' import { useProperties } from '../../hooks/useProperties' -import { getRecommendedActions } from '../../services/dataQualityService' -import { DataFreshness } from '../../domain/enums' +import { AssetType, AvailabilityStatus } from '../../domain/enums' +import { ASSET_TYPE_LABELS } from '../../lib/constants' +import { agentWorkspaceById } from '../../lib/agentWorkspaces' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' import type { Property } from '../../domain/property' -// ── Types ──────────────────────────────────────────────────────────────────── +const SINA = agentWorkspaceById('sina')! -type QualityFilter = 'ALL' | 'INCOMPLETE' | 'STALE' | 'LOW' | 'MEDIUM' | 'HIGH' +/** Unter dieser Schwelle wird der Datenqualitätsscore rot — sonst nirgends (§6.2). */ +const LOW_QUALITY = 0.6 -// ── Helpers ────────────────────────────────────────────────────────────────── +const GRID_COLS = '2fr 160px 140px' -const FIELD_LABEL: Record = { - 'Mietpreis/m²': 'Mietpreis', - 'Fläche m²': 'Fläche', - 'Verfügbarkeit': 'Verfügbarkeit', - 'Adresse': 'Adresse', - 'Beschreibung': 'Beschreibung', - 'Bilder': 'Bilder', - 'Ausbaustandard': 'Ausbaustandard', - 'Soft Factors': 'Soft Factors', - 'Jahresmiete (CHF)': 'Jahresmiete', - 'Expansionspotenzial': 'Erweiterung', +/** + * Statusfilter (Runde 4, §6.2). «Sonstiges» fängt alles ab, was weder vermietet + * noch leerstehend ist — etwa «bald verfügbar»; ohne diesen Topf verschwänden + * Objekte lautlos aus der Liste. + */ +const STATUS_FILTERS = { + ALL: 'ALL', + RENTED: 'RENTED', + VACANT: 'VACANT', + OTHER: 'OTHER', +} as const +type StatusFilter = typeof STATUS_FILTERS[keyof typeof STATUS_FILTERS] + +const STATUS_FILTER_LABELS: Record = { + ALL: 'Alle Status', + RENTED: 'Vermietet', + VACANT: 'Leerstehend', + OTHER: 'Sonstiges', } -function priorityOf(p: Property): number { - const hasCritical = p.dataQuality.missingCriticalFields.length > 0 - const isStale = p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED - if (hasCritical && isStale) return 0 - if (hasCritical) return 1 - if (isStale) return 2 - if (p.dataQuality.score < 0.6) return 3 - if (p.dataQuality.score < 0.8) return 4 - return 5 +function statusOf(p: Property): StatusFilter { + if (p.availabilityStatus === AvailabilityStatus.OCCUPIED) return STATUS_FILTERS.RENTED + if (p.availabilityStatus === AvailabilityStatus.AVAILABLE_NOW) return STATUS_FILTERS.VACANT + return STATUS_FILTERS.OTHER } +type SortKey = 'title' | 'quality' +type SortDir = 'asc' | 'desc' + // ── Row ────────────────────────────────────────────────────────────────────── const PropertyRow = memo(function PropertyRow({ property, - onEdit, + onOpen, }: { property: Property - onEdit: (id: string) => void + onOpen: (id: string) => void }) { - const q = property.dataQuality - const hasCritical = q.missingCriticalFields.length > 0 - const isStale = q.freshness === DataFreshness.STALE || q.freshness === DataFreshness.OUTDATED - const topAction = getRecommendedActions(q, q.freshness)[0] ?? null - - const visibleFields = q.missingCriticalFields.slice(0, 3) - const overflow = q.missingCriticalFields.length - visibleFields.length + const score = property.dataQuality.score + const isLow = score < LOW_QUALITY return ( onEdit(property.id)} + onClick={() => onOpen(property.id)} sx={{ display: 'grid', - gridTemplateColumns: '2fr 110px 1fr 110px 110px 44px', + gridTemplateColumns: GRID_COLS, alignItems: 'center', px: 2, py: 1.25, borderBottom: '1px solid #f1f5f9', cursor: 'pointer', - bgcolor: hasCritical ? '#fff8f8' : 'white', - '&:hover': { bgcolor: hasCritical ? '#fff0f0' : '#f8fafc' }, + bgcolor: 'white', + '&:hover': { bgcolor: DS_SLATE[50] }, transition: 'background 0.1s', gap: 1, }} > - {/* Objekt */} - - - {property.title} - - - {property.location.city} + {/* Objektname — führt nach «Meine Objekte» */} + e.stopPropagation()}> + + + {property.address.postalCode} {property.location.city} - {/* Score */} - - - + {/* Datenqualität */} + + {Math.round(score * 100)} % + - {/* Fehlende Pflichtfelder */} - - {hasCritical ? ( - <> - {visibleFields.map(f => ( - - ))} - {overflow > 0 && ( - - )} - - ) : ( - } - label="Vollständig" - size="small" - sx={{ height: 18, fontSize: '0.6rem', bgcolor: '#dcfce7', color: '#16a34a' }} - /> - )} - - - {/* Aktualität */} - - - - - {/* Empfehlung */} - - {topAction ? ( - - - {topAction.priority === 'HIGH' - ? - : isStale - ? - : - } - - {topAction.label} - - - - ) : ( - - )} - - - {/* Edit button */} - e.stopPropagation()}> - - onEdit(property.id)} - sx={{ color: '#94a3b8', '&:hover': { color: '#152642' } }} - > - - - - + {/* Gewerbetyp */} + + {ASSET_TYPE_LABELS[property.assetType] ?? property.assetType} + ) }) -// ── KPI Card ───────────────────────────────────────────────────────────────── +// ── Sortable header cell ───────────────────────────────────────────────────── -function KpiCard({ - label, value, sub, color, active, onClick, +function SortHeader({ + label, active, dir, onClick, }: { label: string - value: string | number - sub?: string - color: string active: boolean + dir: SortDir onClick: () => void }) { return ( - - + {label} - - {value} - - {sub && {sub}} - + {active && (dir === 'asc' ? : )} + ) } // ── Page ───────────────────────────────────────────────────────────────────── +/** + * Sina — Datenpflege (Runde 4, §6). + * + * Auswertungskarten und Qualitätsverteilung sind entfallen: sie beantworteten + * die Frage «wie steht es insgesamt?», während auf dieser Seite «welches Objekt + * muss ich anfassen?» zählt. Übrig bleiben Chat-Einstieg, Suche, drei Filter + * und eine sortierbare Objektübersicht. + */ export default function DataQuality() { - const [filter, setFilter] = useState('ALL') + const [search, setSearch] = useState('') + const [place, setPlace] = useState('') + const [assetType, setAssetType] = useState('ALL') + const [status, setStatus] = useState(STATUS_FILTERS.ALL) + const [sortKey, setSortKey] = useState('quality') + const [sortDir, setSortDir] = useState('asc') const [detailId, setDetailId] = useState(null) const { data: properties = [], isLoading, error } = useProperties() + const visible = useMemo(() => { + const q = search.trim().toLowerCase() + const loc = place.trim().toLowerCase() + const result = properties.filter(p => { + if (q) { + const hay = `${p.title} ${p.location.city} ${p.address.street}`.toLowerCase() + if (!hay.includes(q)) return false + } + if (loc) { + const hay = `${p.location.city} ${p.address.postalCode}`.toLowerCase() + if (!hay.includes(loc)) return false + } + if (assetType !== 'ALL' && p.assetType !== assetType) return false + if (status !== STATUS_FILTERS.ALL && statusOf(p) !== status) return false + return true + }) + + const dir = sortDir === 'asc' ? 1 : -1 + return result.sort((a, b) => + sortKey === 'title' + ? dir * a.title.localeCompare(b.title, 'de-CH') + : dir * (a.dataQuality.score - b.dataQuality.score), + ) + }, [properties, search, place, assetType, status, sortKey, sortDir]) + if (isLoading) return if (error) return - const total = properties.length - - const avgScore = total - ? properties.reduce((s, p) => s + p.dataQuality.score, 0) / total - : 0 - - const incomplete = properties.filter(p => p.dataQuality.missingCriticalFields.length > 0) - const stale = properties.filter( - p => p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED, - ) - const high = properties.filter(p => p.dataQuality.score >= 0.8) - const medium = properties.filter(p => p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8) - const low = properties.filter(p => p.dataQuality.score < 0.6) - - const filtered = useMemo(() => { - const base = [...properties] - const result = base.filter(p => { - if (filter === 'ALL') return true - if (filter === 'INCOMPLETE') return p.dataQuality.missingCriticalFields.length > 0 - if (filter === 'STALE') return p.dataQuality.freshness === DataFreshness.STALE || p.dataQuality.freshness === DataFreshness.OUTDATED - if (filter === 'HIGH') return p.dataQuality.score >= 0.8 - if (filter === 'MEDIUM') return p.dataQuality.score >= 0.6 && p.dataQuality.score < 0.8 - if (filter === 'LOW') return p.dataQuality.score < 0.6 - return true - }) - return result.sort((a, b) => priorityOf(a) - priorityOf(b)) - }, [properties, filter]) - - const scoreColor = avgScore >= 0.8 ? '#1a7a4a' : avgScore >= 0.6 ? '#d97706' : '#dc2626' - - function toggleFilter(f: QualityFilter) { - setFilter(prev => prev === f ? 'ALL' : f) + function toggleSort(key: SortKey) { + if (sortKey === key) setSortDir(d => (d === 'asc' ? 'desc' : 'asc')) + else { setSortKey(key); setSortDir('asc') } } return ( - {/* Header */} - - Datenpflege - - Vollständigkeit, Aktualität und Vertrauen der Objektdaten · Klick auf KPI-Karte filtert die Liste - - + + - + + + Objektübersicht + - {/* KPI cards */} - - setFilter('ALL')} - sub={`${total} Objekte insgesamt`} - /> - toggleFilter('INCOMPLETE')} - /> - toggleFilter('STALE')} - /> - - - {/* Quality distribution */} - - Qualitätsverteilung - - {[ - { label: 'Hoch (≥80%)', count: high.length, color: '#16a34a', f: 'HIGH' as QualityFilter }, - { label: 'Mittel (60–79%)', count: medium.length, color: '#d97706', f: 'MEDIUM' as QualityFilter }, - { label: 'Niedrig (<60%)', count: low.length, color: '#dc2626', f: 'LOW' as QualityFilter }, - ].map(row => ( - toggleFilter(row.f)} - sx={{ - display: 'flex', alignItems: 'center', gap: 2, cursor: 'pointer', - p: 0.75, borderRadius: 1, - bgcolor: filter === row.f ? `${row.color}10` : 'transparent', - '&:hover': { bgcolor: `${row.color}08` }, - transition: 'background 0.1s', - }} - > - - {row.label} - - - - 0 ? (row.count / total) * 100 : 0} - sx={{ - height: 10, borderRadius: 5, - bgcolor: `${row.color}18`, - '& .MuiLinearProgress-bar': { bgcolor: row.color, borderRadius: 5 }, - }} - /> - - - {total > 0 ? Math.round((row.count / total) * 100) : 0}% - - - ))} - - - - {/* Object list */} - - - Objektübersicht - - {filtered.length} von {total} Objekten · sortiert nach Handlungsbedarf - - {filter !== 'ALL' && ( - setFilter('ALL')} - sx={{ height: 22, fontSize: '0.68rem' }} - /> - )} + {/* Suche und Filter */} + + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ width: 260, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> + setPlace(e.target.value)} + sx={{ width: 170, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> + setAssetType(e.target.value)} + sx={{ width: 180, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + > + Alle Gewerbetypen + {Object.values(AssetType).map(t => ( + {ASSET_TYPE_LABELS[t] ?? t} + ))} + + setStatus(e.target.value as StatusFilter)} + sx={{ width: 170, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + > + {(Object.values(STATUS_FILTERS) as StatusFilter[]).map(s => ( + {STATUS_FILTER_LABELS[s]} + ))} + - {filtered.length > 0 ? ( + {visible.length > 0 ? ( - {/* Table header */} - {['Objekt', 'Qualität', 'Fehlende Pflichtfelder', 'Aktualität', 'Empfehlung', ''].map(h => ( - - {h} - - ))} + toggleSort('title')} + /> + toggleSort('quality')} + /> + + Gewerbetyp + - {filtered.map(p => ( - + {visible.map(p => ( + ))} ) : ( - - Keine Objekte in dieser Kategorie — alles in Ordnung! + + Kein Objekt passt zu Suche und Filtern. )} - - {/* Edit drawer */} + {/* Detailansicht — direkt editierbar */} {detailId && ( - setDetailId(null)} /> + setDetailId(null)} /> )} diff --git a/src/pages/supply/FutureAvailability.tsx b/src/pages/supply/FutureAvailability.tsx index dbe79ff..a105eba 100644 --- a/src/pages/supply/FutureAvailability.tsx +++ b/src/pages/supply/FutureAvailability.tsx @@ -12,6 +12,7 @@ import { AddToShortlistDialog } from '../../components/shortlist' import { ErrorState } from '../../components/ui' import type { SignalFilterState } from '../../components/future-signals' import type { FutureSignal } from '../../domain/futureSignal' +import { DS_ACCENT, DS_SLATE } from '../../lib/ds' function applyFilters(signals: FutureSignal[], f: SignalFilterState): FutureSignal[] { return signals.filter(s => { @@ -52,20 +53,20 @@ export default function FutureAvailability() { - Zukunftssignale + Zukunftssignale Probabilistische Markt- und Verfügbarkeitssignale {confidentialCount > 0 && ( )} @@ -96,7 +97,7 @@ export default function FutureAvailability() { /> {/* Signal list — always full width */} - + {isLoading ? ( {[0, 1, 2, 3].map(i => ( diff --git a/src/pages/supply/MarketIntelligence.tsx b/src/pages/supply/MarketIntelligence.tsx index 58c7981..19e5ae9 100644 --- a/src/pages/supply/MarketIntelligence.tsx +++ b/src/pages/supply/MarketIntelligence.tsx @@ -1,1056 +1,49 @@ -import { memo, useState, useEffect, useCallback, useMemo } from 'react' -import { useNavigate } from 'react-router' -import { - Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, - Divider, FormControl, FormControlLabel, - InputLabel, MenuItem, Select as MuiSelect, - Skeleton, Switch, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, - Tooltip, Typography, -} from '@mui/material' -import { - AlertCircle, Bell, Building2, CheckCircle2, Copy, - ExternalLink, Globe, Linkedin, Lock, Mail, MapPin, Phone, - Plus, Ruler, Search, Sparkles, TrendingUp, User, Users, -} from 'lucide-react' +import { useState, useMemo } from 'react' +import { Box, Chip, Skeleton, Tab, Tabs, Typography, Button } from '@mui/material' +import { Plus, Sparkles, Users } from 'lucide-react' import { useMarketLeads } from '../../hooks/useMarketLeads' -import type { MarketLead } from '../../hooks/useMarketLeads' -import { useMarktHinweise, useCreateMarktHinweis } from '../../hooks/useMarktHinweise' -import type { MarktHinweis, HinweisVisibility, HinweisDirection, HinweisQuelle, CreateMarktHinweisInput } from '../../domain/marktHinweis' -import { useProperties } from '../../hooks/useProperties' -import type { Property } from '../../domain/property' -import type { ExtractedContact } from '../../domain/futureSignal' - -// ── Constants ──────────────────────────────────────────────────────────────── - -const OWN_VERWALTUNG_ID = 'v-001' - -const SOURCE_LABELS: Record = { - JOB_POSTING: 'Stelleninserate', - PRESS: 'Pressebericht', - COMPANY_REPORT: 'Geschäftsbericht', - MARKET_DATA: 'Marktdaten', - MANUAL: 'Manuell', - CONSTRUCTION_PERMIT: 'Baugenehmigung', - LEASE_CONTRACT: 'Mietvertrag', -} - -const QUELLE_LABELS: Record = { - NETZWERKEVENT: 'Netzwerkevent', - TELEFONAT: 'Telefonat', - BESICHTIGUNG: 'Besichtigung', - MESSE: 'Messe', - EMAIL: 'E-Mail', - SONSTIGES: 'Sonstiges', -} - -const ASSET_TYPE_LABELS: Record = { - OFFICE: 'Büro', - RETAIL: 'Einzelhandel', - LIGHT_INDUSTRIAL: 'Gewerbe', - LOGISTICS: 'Logistik', - PRODUCTION: 'Produktion', - MIXED: 'Gemischt', -} - -const CONTACT_ICONS: Record = { - EMAIL: , - PHONE: , - WEBSITE: , - LINKEDIN: , - CONTACT_PERSON: , -} - -const CONF_COLOR: Record = { - HIGH: '#16a34a', - MEDIUM: '#d97706', - LOW: '#94a3b8', -} - -const CONF_LABEL: Record = { - HIGH: 'Bestätigt', - MEDIUM: 'Wahrscheinlich', - LOW: 'Spekulativ', -} - -function probColor(p: number): string { - if (p >= 0.70) return '#1a7a4a' - if (p >= 0.50) return '#d97706' - return '#dc2626' -} - -function areaFitPct(propArea: number, signalArea: number): number { - return Math.max(0, Math.round(100 - (Math.abs(propArea - signalArea) / signalArea) * 100)) -} - -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString('de-CH') -} - -function buildAnschreiben(company: string, location: string, p: Property, areaSqmEstimate?: number): string { - const areaLine = areaSqmEstimate - ? `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m² (Sie suchen ca. ${areaSqmEstimate.toLocaleString('de-CH')} m²)` - : `Fläche: ${p.areaSqm.toLocaleString('de-CH')} m²` - return `Sehr geehrte Damen und Herren, - -wir haben erkannt, dass ${company} nach Gewerbeflächen im Raum ${location} sucht. Gerne möchten wir Ihnen eine passende Option aus unserem Portfolio vorstellen: - -Objekt: ${p.title} -Lage: ${p.location.city} -${areaLine} -Mietpreis: CHF ${p.rentPricePerSqm.toLocaleString('de-CH')} / m² / Jahr - -Wir würden uns freuen, Ihnen das Objekt in einer unverbindlichen Besichtigung vorzustellen und Ihre konkreten Anforderungen zu besprechen. - -Mit freundlichen Grüssen -Wincasa AG -Immobilienverwaltung` -} - -// ── Left pane list item ────────────────────────────────────────────────────── - -const LeadListItem = memo(function LeadListItem({ - lead, selected, onClick, -}: { - lead: MarketLead - selected: boolean - onClick: () => void -}) { - const { signal, matchingProperties } = lead - const prob = Math.round(signal.probability * 100) - const color = probColor(signal.probability) - - return ( - - - - {signal.companyName ?? signal.locationHint} - - - - {signal.title && ( - - {signal.title} - - )} - - - {signal.locationHint} · {signal.timeHorizonMonths} Mo. - - {matchingProperties.length > 0 && ( - - )} - - - ) -}) - -// ── Contact row ────────────────────────────────────────────────────────────── - -function ContactRow({ contact }: { contact: ExtractedContact }) { - const icon = CONTACT_ICONS[contact.type] - const confColor = CONF_COLOR[contact.confidence] - const isClickable = contact.type === 'WEBSITE' || contact.type === 'LINKEDIN' || contact.type === 'EMAIL' - const href = contact.type === 'EMAIL' ? `mailto:${contact.value}` : contact.type === 'WEBSITE' || contact.type === 'LINKEDIN' ? `https://${contact.value.replace(/^https?:\/\//, '')}` : undefined - - return ( - - {icon} - - {isClickable && href ? ( - - {contact.value} - - - ) : ( - - {contact.value} - - )} - {contact.label && ( - - {contact.label} - - )} - - - - - - ) -} - -// ── Property match card with Anschreiben composer ──────────────────────────── - -function PropertyMatchCard({ - p, signal, companyName, -}: { - p: Property - signal: MarketLead['signal'] - companyName: string -}) { - const navigate = useNavigate() - const [open, setOpen] = useState(false) - const [copied, setCopied] = useState(false) - const [draft, setDraft] = useState(() => - buildAnschreiben(companyName, signal.locationHint, p, signal.areaSqmEstimate) - ) - - const fit = signal.areaSqmEstimate ? areaFitPct(p.areaSqm, signal.areaSqmEstimate) : null - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(draft) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }, [draft]) - - return ( - - {/* Property header */} - navigate('/supply/properties')} - > - - - - {p.title} - - - {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J - - - {fit !== null && ( - = 75 ? '#dcfce7' : fit >= 50 ? '#fef9c3' : '#fee2e2', - color: fit >= 75 ? '#16a34a' : fit >= 50 ? '#92400e' : '#dc2626', - }} - /> - )} - - - {/* Anschreiben toggle */} - - - - - - {/* Anschreiben composer */} - {open && ( - - - - Anschreiben-Entwurf - - - - setDraft(e.target.value)} - sx={{ - '& .MuiOutlinedInput-root': { fontSize: '0.8rem', bgcolor: 'white' }, - '& textarea': { lineHeight: 1.6 }, - }} - /> - - Text bearbeitbar — dann kopieren und per E-Mail versenden - - - )} - - ) -} - -// ── Right pane detail ──────────────────────────────────────────────────────── - -function LeadDetail({ lead }: { lead: MarketLead }) { - const { signal, matchingProperties } = lead - const prob = Math.round(signal.probability * 100) - const color = probColor(signal.probability) - const sourceLabel = SOURCE_LABELS[signal.source.type] ?? signal.source.type - const companyName = signal.companyName ?? signal.locationHint - - return ( - - {/* Header */} - - - - {companyName} - - - - {signal.title && ( - - {signal.title} - - )} - {/* Stats row */} - - } label={signal.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} /> - } label={signal.areaSqmEstimate ? `~${signal.areaSqmEstimate.toLocaleString('de-CH')} m²` : 'Fläche unbekannt'} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} /> - - - {/* Source link */} - - - Quelle: {sourceLabel} - {signal.source.publishedAt && ` · ${new Date(signal.source.publishedAt).toLocaleDateString('de-CH')}`} - - {signal.source.url && ( - - Quelle öffnen - - )} - - - - - {/* KI summary */} - {(signal.aiSummary ?? signal.strategicInterpretation) && ( - - - - - KI-Analyse - - - - {signal.aiSummary ?? signal.strategicInterpretation} - - - )} - - {/* Market indicators */} - {(signal.marketIndicators?.length ?? 0) > 0 && ( - - - Erkannte Nachfragesignale - - - {signal.marketIndicators!.map((ind, i) => ( - - - {ind} - - ))} - - - )} - - {/* Facts */} - {((signal.confirmedFacts?.length ?? 0) + (signal.unconfirmedFacts?.length ?? 0)) > 0 && ( - - - Faktencheck - - - {(signal.confirmedFacts ?? []).map((f, i) => ( - - - {f} - - ))} - {(signal.unconfirmedFacts ?? []).map((f, i) => ( - - - {f} - - ))} - - - )} - - - - {/* KI contact extraction */} - - - - - KI-Kontaktdaten - - - {(['HIGH','MEDIUM','LOW'] as const).map(c => ( - - - {CONF_LABEL[c]} - - ))} - - - {(signal.extractedContacts?.length ?? 0) === 0 ? ( - - Keine Kontaktdaten aus Crawler-Quellen extrahiert — manuelle Recherche empfohlen - - ) : ( - - {signal.extractedContacts!.map((c, i) => )} - - )} - - - - - {/* Portfolio matches */} - - - Passende Objekte im Portfolio ({matchingProperties.length}) - - {matchingProperties.length === 0 ? ( - - Kein passendes Portfolioobjekt gefunden — manuelle Prüfung empfohlen - - ) : ( - matchingProperties.map(p => ( - - )) - )} - - - {signal.disclaimer && ( - - {signal.disclaimer} - - )} - - - ) -} - -// ── Netzwerk: HinweisPropertyCard ──────────────────────────────────────────── - -const HinweisPropertyCard = memo(function HinweisPropertyCard({ - p, - hinweis, -}: { - p: Property - hinweis: MarktHinweis -}) { - const [open, setOpen] = useState(false) - const [copied, setCopied] = useState(false) - const companyName = hinweis.isAnonymized ? 'Interessent' : (hinweis.companyName ?? 'Interessent') - const areaEstimate = hinweis.areaSqmMax ?? hinweis.areaSqmMin - const [draft, setDraft] = useState(() => - buildAnschreiben(companyName, hinweis.locationHint, p, areaEstimate) - ) - const fit = areaEstimate != null ? areaFitPct(p.areaSqm, areaEstimate) : null - - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(draft) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }, [draft]) - - return ( - - {/* Property header */} - - - - - {p.title} - - - {p.location.city} · {p.areaSqm.toLocaleString('de-CH')} m² · CHF {p.rentPricePerSqm}/m²/J - - - {fit !== null && ( - = 75 ? '#dcfce7' : fit >= 50 ? '#fef9c3' : '#fee2e2', - color: fit >= 75 ? '#16a34a' : fit >= 50 ? '#92400e' : '#dc2626', - }} - /> - )} - - - {/* Anschreiben toggle */} - - - - - {/* Anschreiben composer */} - {open && ( - - - - Anschreiben-Entwurf - - - - setDraft(e.target.value)} - sx={{ - '& .MuiOutlinedInput-root': { fontSize: '0.8rem', bgcolor: 'white' }, - '& textarea': { lineHeight: 1.6 }, - }} - /> - - Text bearbeitbar — dann kopieren und per E-Mail versenden - - - )} - - ) -}) - -// ── Netzwerk: HinweisListItem ──────────────────────────────────────────────── - -const HinweisListItem = memo(function HinweisListItem({ - hinweis, selected, onClick, -}: { - hinweis: MarktHinweis - selected: boolean - onClick: () => void -}) { - const displayName = !hinweis.isAnonymized && hinweis.companyName - ? hinweis.companyName - : `Anonym · ${ASSET_TYPE_LABELS[hinweis.assetType] ?? hinweis.assetType}` - - const areaStr = useMemo(() => { - if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) { - return ` · ${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²` - } - if (hinweis.areaSqmMax != null) return ` · ${hinweis.areaSqmMax} m²` - if (hinweis.areaSqmMin != null) return ` · ${hinweis.areaSqmMin} m²` - return '' - }, [hinweis.areaSqmMin, hinweis.areaSqmMax]) - - const visibilityBadge = useMemo(() => { - if (hinweis.visibility === 'INTERN') { - return - } - if (hinweis.verwaltungId === OWN_VERWALTUNG_ID) { - return - } - return - }, [hinweis.visibility, hinweis.verwaltungId, hinweis.verwaltungName]) - - return ( - - - - {hinweis.direction === 'SUCHE' - ? - : - } - - - {displayName} - - {visibilityBadge} - - - {hinweis.locationHint}{areaStr} - - - {hinweis.direction === 'SUCHE' - ? - : - } - - - ) -}) - -// ── Netzwerk: HinweisDetail ────────────────────────────────────────────────── - -function HinweisDetail({ hinweis }: { hinweis: MarktHinweis }) { - const { data: properties = [] } = useProperties() - - const matchingProperties = useMemo( - () => properties.filter(p => p.assetType === hinweis.assetType).slice(0, 3), - [properties, hinweis.assetType], - ) - - const displayName = !hinweis.isAnonymized && hinweis.companyName ? hinweis.companyName : 'Anonym' - - const areaStr = useMemo(() => { - if (hinweis.areaSqmMin != null && hinweis.areaSqmMax != null && hinweis.areaSqmMin !== hinweis.areaSqmMax) { - return `${hinweis.areaSqmMin}–${hinweis.areaSqmMax} m²` - } - if (hinweis.areaSqmMax != null) return `${hinweis.areaSqmMax} m²` - if (hinweis.areaSqmMin != null) return `${hinweis.areaSqmMin} m²` - return null - }, [hinweis.areaSqmMin, hinweis.areaSqmMax]) - - return ( - - {/* Header */} - - - {displayName} - - - {/* Badge row */} - - {hinweis.direction === 'SUCHE' - ? - : - } - {hinweis.visibility === 'INTERN' - ? - : hinweis.verwaltungId === OWN_VERWALTUNG_ID - ? - : - } - - - {/* Stat chips */} - - } label={hinweis.locationHint} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} /> - {areaStr && } label={areaStr} size="small" sx={{ bgcolor: '#f1f5f9', color: '#475569', fontSize: '0.75rem' }} />} - - - - {/* Quelle row */} - - - Quelle: {QUELLE_LABELS[hinweis.quelle] ?? hinweis.quelle} - {' · '}{hinweis.createdBy} - {' · '}{formatDate(hinweis.createdAt)} - - - - - {/* Body */} - - {hinweis.note && ( - - - Notiz - - - {hinweis.note} - - - )} - - - - {hinweis.direction === 'SUCHE' ? ( - - - Passende Objekte im Portfolio ({matchingProperties.length}) - - {matchingProperties.length === 0 ? ( - - Kein passendes Portfolioobjekt gefunden - - ) : ( - matchingProperties.map(p => ( - - )) - )} - - ) : ( - - Verfügbarkeitssignal — wird auf der Plattform für andere Verwaltungen sichtbar gemacht - - )} - - - ) -} - -// ── Netzwerk: HinweisErfassenDialog ───────────────────────────────────────── - -function HinweisErfassenDialog({ - open, - onClose, - onCreated, -}: { - open: boolean - onClose: () => void - onCreated: (id: string) => void -}) { - const mutation = useCreateMarktHinweis() - - const [direction, setDirection] = useState('SUCHE') - const [assetType, setAssetType] = useState('') - const [locationHint, setLocationHint] = useState('') - const [areaSqmMin, setAreaSqmMin] = useState('') - const [areaSqmMax, setAreaSqmMax] = useState('') - const [companyName, setCompanyName] = useState('') - const [isAnonymized, setIsAnonymized] = useState(false) - const [quelle, setQuelle] = useState('NETZWERKEVENT') - const [note, setNote] = useState('') - const [visibility, setVisibility] = useState('INTERN') - - const isValid = assetType.trim() !== '' && locationHint.trim() !== '' - - function resetForm() { - setDirection('SUCHE') - setAssetType('') - setLocationHint('') - setAreaSqmMin('') - setAreaSqmMax('') - setCompanyName('') - setIsAnonymized(false) - setQuelle('NETZWERKEVENT') - setNote('') - setVisibility('INTERN') - } - - function handleSave() { - if (!isValid) return - const input: CreateMarktHinweisInput = { - direction, - assetType: assetType as MarktHinweis['assetType'], - locationHint, - areaSqmMin: areaSqmMin ? Number(areaSqmMin) : undefined, - areaSqmMax: areaSqmMax ? Number(areaSqmMax) : undefined, - companyName: companyName.trim() || undefined, - isAnonymized, - quelle, - note: note.trim() || undefined, - visibility, - status: 'OFFEN', - } - mutation.mutate(input, { - onSuccess: (result) => { - onCreated(result.id) - resetForm() - }, - }) - } - - function handleClose() { - resetForm() - onClose() - } - - return ( - - Hinweis erfassen - - {/* Richtung */} - - - Richtung - - { if (v) setDirection(v as HinweisDirection) }} - size="small" - sx={{ '& .MuiToggleButton-root': { textTransform: 'none', fontSize: '0.8rem' } }} - > - - Jemand sucht - - - Wird verfügbar - - - - - {/* Asset-Typ */} - - Asset-Typ * - setAssetType(e.target.value)} - > - Büro - Einzelhandel - Gewerbe - Logistik - Produktion - - - - {/* Stadt / Region */} - setLocationHint(e.target.value)} - /> - - {/* Fläche */} - - setAreaSqmMin(e.target.value)} - sx={{ flex: 1 }} - /> - setAreaSqmMax(e.target.value)} - sx={{ flex: 1 }} - /> - - - {/* Firma / Name */} - setCompanyName(e.target.value)} - /> - - {/* Quelle */} - - Quelle - setQuelle(e.target.value as HinweisQuelle)} - > - {Object.entries(QUELLE_LABELS).map(([k, v]) => ( - {v} - ))} - - - - {/* Notiz */} - setNote(e.target.value)} - /> - - {/* Sichtbarkeit */} - - - Sichtbarkeit - - - setVisibility('INTERN')} - sx={{ - flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer', - display: 'flex', flexDirection: 'column', gap: 0.5, - border: visibility === 'INTERN' ? '2px solid #4338ca' : '1px solid #e2e8f0', - bgcolor: visibility === 'INTERN' ? '#eef2ff' : 'white', - }} - > - - - Intern - - Nur für Ihr Team sichtbar - - setVisibility('PLATTFORM')} - sx={{ - flex: 1, borderRadius: 1.5, p: 1.5, cursor: 'pointer', - display: 'flex', flexDirection: 'column', gap: 0.5, - border: visibility === 'PLATTFORM' ? '2px solid #7c3aed' : '1px solid #e2e8f0', - bgcolor: visibility === 'PLATTFORM' ? '#faf5ff' : 'white', - }} - > - - - Plattform - - Für alle Verwaltungen auf Property Match sichtbar - - - - - {/* Anonymisieren (only for PLATTFORM) */} - {visibility === 'PLATTFORM' && ( - setIsAnonymized(e.target.checked)} - size="small" - /> - } - label={Firmenname anonymisieren} - /> - )} - - - - - - - - ) -} +import { useMarktHinweise } from '../../hooks/useMarktHinweise' +import { AgentWorkspaceHero } from '../../components/team' +import { LeadDetail, LeadListItem } from '../../components/market-leads' +import { HinweisDetail, HinweisErfassenDialog, HinweisListItem } from '../../components/markt-hinweise' +import { agentWorkspaceById } from '../../lib/agentWorkspaces' +import { DS_ACCENT, DS_BRAND, DS_NEUTRAL } from '../../lib/ds' + +const NORA = agentWorkspaceById('nora')! // ── Page ───────────────────────────────────────────────────────────────────── export default function MarketIntelligence() { const [activeTab, setActiveTab] = useState(0) - const [selectedSignalId, setSelectedSignalId] = useState(null) - const [selectedHinweisId, setSelectedHinweisId] = useState(null) + const [chosenSignalId, setChosenSignalId] = useState(null) + const [chosenHinweisId, setChosenHinweisId] = useState(null) const [erfassenOpen, setErfassenOpen] = useState(false) const [hinweisFilter, setHinweisFilter] = useState<'ALL' | 'INTERN' | 'PLATTFORM'>('ALL') const { data: leads, isLoading: leadsLoading } = useMarketLeads() const { data: hinweise = [], isLoading: hinweiseLoading } = useMarktHinweise() - // auto-select first signal - useEffect(() => { - if (!selectedSignalId && leads.length > 0) setSelectedSignalId(leads[0].signal.id) - }, [leads, selectedSignalId]) - - // auto-select first hinweis - useEffect(() => { - if (!selectedHinweisId && hinweise.length > 0) setSelectedHinweisId(hinweise[0].id) - }, [hinweise, selectedHinweisId]) - - const selectedLead = leads.find(l => l.signal.id === selectedSignalId) ?? null - const filteredHinweise = useMemo(() => { if (hinweisFilter === 'ALL') return hinweise return hinweise.filter(h => h.visibility === hinweisFilter) }, [hinweise, hinweisFilter]) + // Ohne eigene Wahl steht der erste Eintrag offen. Abgeleitet statt in einem + // Effekt nachgetragen: der Effekt rief `setState` beim ersten Rendern auf und + // erzwang damit einen zweiten Durchgang, in dem die Detailspalte noch leer war. + const selectedSignalId = chosenSignalId ?? leads[0]?.signal.id ?? null + const selectedHinweisId = chosenHinweisId ?? filteredHinweise[0]?.id ?? null + + const selectedLead = leads.find(l => l.signal.id === selectedSignalId) ?? null const selectedHinweis = hinweise.find(h => h.id === selectedHinweisId) ?? null return ( - {/* Page header */} - - Marktchancen - - KI-Signale aus Web-Quellen und menschliche Netzwerk-Hinweise — mit Portfolio-Match - + {/* Chat-Einstieg statt Seitentitel und Beschreibung (Runde 4, §7.1). + Beispielnutzung: eigene Netzwerkleads mitteilen, aktuelle Leads für + einen Ort erfragen. */} + + {/* Tabs */} @@ -1075,7 +68,7 @@ export default function MarketIntelligence() { Erkannte Signale - + {leadsLoading @@ -1091,7 +84,7 @@ export default function MarketIntelligence() { key={lead.signal.id} lead={lead} selected={lead.signal.id === selectedSignalId} - onClick={() => setSelectedSignalId(lead.signal.id)} + onClick={() => setChosenSignalId(lead.signal.id)} /> ))} @@ -1121,7 +114,7 @@ export default function MarketIntelligence() { - ) : ( - - - - - - )} - - - ) -} - -interface SuchaboDialogProps { - propertyId: string - matches: Match[] - onClose: () => void -} - -function SuchaboDialogContent({ matches }: SuchaboDialogProps) { - const [selectedNeedId, setSelectedNeedId] = useState( - matches.length === 1 ? matches[0].needId : null, - ) - const latentNeedId = selectedNeedId ? (NEED_TO_LATENT_NEED[selectedNeedId] ?? null) : null - const { data: latentNeed, isLoading } = useLatentNeedById(latentNeedId) - - const showList = !selectedNeedId - - if (showList) { - return ( - - {matches.map(m => ( - - ))} - - ) - } - - if (isLoading) { - return ( - - - - ) - } - - if (!latentNeed) { - return ( - - Suchabo nicht gefunden. - - ) - } - - return ( - - {matches.length > 1 && ( - - - - )} - - - ) -} - -// ─── Row ─────────────────────────────────────────────────────────────────── - -interface RowProps { - p: Property - isLast: boolean - onDetail: (id: string) => void - onToggleStatus: (p: Property) => void - onDelete: (p: Property) => void - onChipClick: (propertyId: string, matches: Match[]) => void - updatePending: boolean - removePending: boolean -} - -const ListingRow = memo(function ListingRow({ - p, isLast, onDetail, onToggleStatus, onDelete, onChipClick, updatePending, removePending, -}: RowProps) { - const { data: matches = [] } = useMatchesByProperty(p.id) - const topScore = matches.length > 0 ? Math.max(...matches.map(m => m.matchScore)) : 0 - - return ( - onDetail(p.id)} - sx={{ - display: 'grid', - gridTemplateColumns: GRID_COLS, - px: 2, py: 1.25, - alignItems: 'center', - borderBottom: isLast ? 'none' : '1px solid #f1f5f9', - cursor: 'pointer', - '&:hover': { bgcolor: '#f8fafc' }, - transition: 'background 0.1s', - }} - > - {/* Title + type */} - - - {p.title} - - - - - {/* City */} - - {p.location.city} - - - {/* Area */} - - {p.areaSqm.toLocaleString('de-CH')} m² - - - {/* Rent */} - - CHF {p.rentPricePerSqm.toLocaleString('de-CH')} - - - {/* Match count */} - e.stopPropagation()}> - {matches.length > 0 ? ( - - onChipClick(p.id, matches)} - sx={{ - height: 20, - fontSize: '0.65rem', - fontWeight: 600, - bgcolor: `${matchColor(topScore)}18`, - color: matchColor(topScore), - cursor: 'pointer', - '&:hover': { opacity: 0.85 }, - }} - /> - - ) : ( - - )} - - - {/* Created */} - - {formatDate(p.createdAt)} - - - {/* Status toggle */} - e.stopPropagation()}> - - onToggleStatus(p)} - disabled={updatePending} - sx={{ '& .MuiSwitch-thumb': { width: 14, height: 14 } }} - /> - - - - {/* Delete */} - e.stopPropagation()}> - - onDelete(p)} - disabled={removePending} - sx={{ color: '#94a3b8', '&:hover': { color: '#ef4444' } }} - > - - - - - - ) -}) - -// ─── Page ───────────────────────────────────────────────────────────────── - +/** + * Livia — Exposé Master (Runde 4, §8). + * + * Die frühere Inserate-Seite ist vollständig ersetzt: Titel, Beschreibung, der + * Knopf «Neues Inserat» und die Inserate-Tabelle sind entfallen. Ziel der Seite + * ist die schnelle Verarbeitung von Leads, die Nora erkannt und ein + * Bewirtschafter zur Exposé-Erstellung weitergeleitet hat. + */ export default function MyListings() { - const navigate = useNavigate() + const [params, setParams] = useSearchParams() + const tab = resolveTab(params.get(PARAM_TAB)) + const [selectedId, setSelectedId] = useState(null) + const scrollRef = useRef(null) - const { data: listings = [], isLoading } = useProperties({ sourceType: 'DIRECT' }) - const updateProperty = useUpdateProperty() - const removeProperty = useRemoveProperty() + const { data: leads = [], isLoading, isError, refetch } = useExposeLeads() + const { data: properties = [] } = useProperties() - const [detailId, setDetailId] = useState(null) - const [confirmDelete, setConfirmDelete] = useState(null) - const [actionError, setActionError] = useState(null) - const [suchaboDialog, setSuchaboDialog] = useState<{ - propertyId: string - matches: Match[] - } | null>(null) + const visible = useMemo(() => { + const wanted = tab === EXPOSE_LEAD_TABS.ARCHIVED ? ExposeLeadStatus.ARCHIVED : ExposeLeadStatus.ACTIVE + return leads + .filter(l => l.status === wanted) + .sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)) + }, [leads, tab]) - function handleToggleStatus(p: Property) { - const next = p.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' - updateProperty.mutate( - { id: p.id, input: { status: next } }, - { onError: () => setActionError('Status konnte nicht geändert werden.') }, - ) - } + /** + * Beim Öffnen gleitet die gewählte Zeile an den oberen Rand des sichtbaren + * Inhaltsbereichs; die nachfolgenden Leads rücken nach unten und dürfen aus + * dem Blickfeld verschwinden (Runde 4, §8.4). Umgesetzt mit `scrollTo` auf dem + * bestehenden Scrollcontainer — dafür braucht es keine Animationsbibliothek. + * + * Bei `prefers-reduced-motion` wird ohne Bewegung direkt gesprungen. + */ + const handleSelect = useCallback((id: string, row: HTMLElement | null) => { + const next = id === selectedId ? null : id + setSelectedId(next) + if (!next || !row || !scrollRef.current) return - function handleDelete(p: Property) { - removeProperty.mutate(p.id, { - onSuccess: () => setConfirmDelete(null), - onError: () => { - setActionError('Inserat konnte nicht gelöscht werden.') - setConfirmDelete(null) - }, + const container = scrollRef.current + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const top = container.scrollTop + row.getBoundingClientRect().top - container.getBoundingClientRect().top + + // Nach dem Zustandswechsel scrollen, sonst misst der Browser die alte Höhe. + requestAnimationFrame(() => { + container.scrollTo({ top, behavior: reduced ? 'auto' : 'smooth' }) }) - } + }, [selectedId]) + + const handleTabChange = useCallback((next: ExposeLeadTab) => { + setSelectedId(null) + setParams({ [PARAM_TAB]: next }) + }, [setParams]) + + if (isLoading) return + if (isError) return refetch()} /> return ( - {/* Header */} - - - Inserate - - Direkt erstellte Inserate — unabhängig vom Portfolio - - - - + + - - - {actionError && ( - setActionError(null)}> - {actionError} - - )} - - {isLoading ? ( - - - - ) : listings.length === 0 ? ( - - Noch keine Inserate - - Erstellen Sie Ihr erstes direktes Inserat — ohne vollständiges Objekt im Portfolio. - - - - ) : ( - - {/* Header */} - - {['Inserat', 'Ort', 'Fläche', 'Preis/m²/J', 'Suchabos', 'Erstellt', 'Aktiv', ''].map(h => ( - - {h} - + {Object.values(EXPOSE_LEAD_TABS).map(value => ( + ))} - + - {listings.map((p, i) => ( - setSuchaboDialog({ propertyId: pid, matches: m })} - updatePending={updateProperty.isPending} - removePending={removeProperty.isPending} - /> - ))} - - )} - - {/* end scroll container */} - - {/* Detail / edit drawer */} - setDetailId(null)} - slotProps={{ paper: { sx: { width: { xs: '100%', sm: '90vw', md: 480, lg: 540, xl: 560 } } } }} - > - {detailId && ( - setDetailId(null)} hideTabs={['Matchability', 'Marktsignale']} /> - )} - - - {/* Suchabo dialog */} - setSuchaboDialog(null)} - maxWidth="sm" - fullWidth - slotProps={{ paper: { sx: { maxHeight: '85vh' } } }} - > - - Suchabos - {suchaboDialog && ( - - {suchaboDialog.matches.length} Suchabo{suchaboDialog.matches.length !== 1 ? 's' : ''} matchen dieses Inserat - - )} - - {suchaboDialog && ( - setSuchaboDialog(null)} + - )} - - {/* Delete confirmation */} - setConfirmDelete(null)} maxWidth="xs" fullWidth> - Inserat löschen? - - - {confirmDelete?.title} wird unwiderruflich gelöscht. - - - - - - - - - {/* OfferWizard — triggered by PublicNeedDetail's "Angebot erstellen" button */} - + {/* Weissraum unter der Liste, damit auch die letzte Zeile an den + oberen Rand gleiten kann. */} + + + ) } diff --git a/src/pages/supply/NewListing.tsx b/src/pages/supply/NewListing.tsx index e2c2f9c..10bb8b1 100644 --- a/src/pages/supply/NewListing.tsx +++ b/src/pages/supply/NewListing.tsx @@ -13,7 +13,7 @@ import { ContactSection, CreatedScreen, } from '../../components/new-listing' -import { DS_TEXT } from '../../lib/ds' +import { DS_SLATE, DS_TEXT } from '../../lib/ds' import type { LocationState } from './newListingConstants' export default function NewListing() { @@ -46,7 +46,7 @@ export default function NewListing() { - Neues Inserat erstellen + Neues Inserat erstellen {form.isPrefilled ? `Einheit ${pre.unitLabel ?? ''} aus Portfolio vorausgefüllt — Angaben prüfen und veröffentlichen.` diff --git a/src/pages/supply/Personalverwaltung.tsx b/src/pages/supply/Personalverwaltung.tsx deleted file mode 100644 index 907f0ab..0000000 --- a/src/pages/supply/Personalverwaltung.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useCallback, useEffect, useMemo } from 'react' -import { useNavigate, useParams } from 'react-router' -import { Box, useMediaQuery, useTheme } from '@mui/material' -import { - TeamPageHeader, - AgentListPanel, - AgentDossier, - resolveDossierSegment, -} from '../../components/team' -import type { DossierSegment } from '../../components/team' -import { EmptyState, ErrorState, PanelLoadingState } from '../../components/ui' -import { useTeamAgents } from '../../hooks/useTeamAgents' -import { ROUTES } from '../../lib/constants' -import { DS_BG } from '../../lib/ds' - -/** - * Personalverwaltung — Liste links, Personaldossier rechts. - * - * Die frühere Organigramm-Ansicht mit drehbarem Halbkreis ist entfallen, mit - * ihr der Umschalter im Kopfbereich. Es gibt nur noch eine Ansicht, also braucht - * es auch keine Wahl mehr; ein Umschalter mit einem einzigen Ziel wäre reine - * Zierde. - */ -export default function Personalverwaltung() { - const { agentId, tab } = useParams<{ agentId?: string; tab?: string }>() - const navigate = useNavigate() - const theme = useTheme() - const isCompact = useMediaQuery(theme.breakpoints.down('lg')) - - const { data: agents = [], isLoading, isError, refetch } = useTeamAgents() - - const activeSegment: DossierSegment = resolveDossierSegment(tab) - const selectedAgent = useMemo(() => agents.find(a => a.id === agentId) ?? null, [agents, agentId]) - - // Ohne Auswahl in der URL das erste Kernteammitglied öffnen — ein leeres - // Dossier wäre für den Nutzer eine Sackgasse. - useEffect(() => { - if (!agentId && agents.length > 0) { - navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${agents[0].id}`, { replace: true }) - } - }, [agentId, agents, navigate]) - - const goTo = useCallback( - (nextAgentId: string, segment: DossierSegment) => { - navigate(`${ROUTES.SUPPLY.TEAM_PERSONNEL}/${nextAgentId}/${segment}`) - }, - [navigate], - ) - - const selectAgent = useCallback( - (id: string) => goTo(id, activeSegment), - [goTo, activeSegment], - ) - - const selectSegment = useCallback( - (segment: DossierSegment) => { - if (agentId) goTo(agentId, segment) - }, - [goTo, agentId], - ) - - return ( - - - - {isError ? ( - refetch()} /> - ) : isLoading ? ( - - ) : !selectedAgent ? ( - - ) : ( - - - - - - - )} - - ) -} diff --git a/src/pages/supply/Properties.tsx b/src/pages/supply/Properties.tsx index 65276f7..7c9653b 100644 --- a/src/pages/supply/Properties.tsx +++ b/src/pages/supply/Properties.tsx @@ -1,4 +1,5 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useCallback } from 'react' +import { useNavigate, useParams } from 'react-router' import { Box, Collapse, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material' import { ChevronDown, ChevronUp } from 'lucide-react' import { PageHeader } from '../../components/layout' @@ -8,6 +9,8 @@ import { useActiveInquiries } from '../../hooks/useInquiries' import { PropertyFilterBar, PropertyTable, PropertyDetailView, PropertyIntelligenceCard } from '../../components/supply' import type { PropertyTableFilters } from '../../components/supply' import type { Property } from '../../domain/property' +import { ROUTES, propertyDetailRoute } from '../../lib/constants' +import { DS_SLATE } from '../../lib/ds' function applyFilters(properties: Property[], filters: PropertyTableFilters): Property[] { let result = [...properties] @@ -60,7 +63,15 @@ const CARD_GRID_COLUMNS = { export default function Properties() { const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('md')) - const [selectedId, setSelectedId] = useState(null) + // Die Auswahl liegt im Pfad, nicht im lokalen Zustand: nur so lässt sich ein + // Objekt aus einer Agentenliste heraus direkt verlinken (Runde 4, §10). + const { propertyId } = useParams<{ propertyId?: string }>() + const navigate = useNavigate() + const selectedId = propertyId ?? null + const setSelectedId = useCallback( + (id: string | null) => navigate(id ? propertyDetailRoute(id) : ROUTES.SUPPLY.PROPERTIES), + [navigate], + ) const [filters, setFilters] = useState({}) const [view, setView] = useState<'list' | 'grid'>(() => (localStorage.getItem('view-properties') as 'list' | 'grid') ?? 'list' @@ -108,15 +119,15 @@ export default function Properties() { justifyContent: 'space-between', px: 3, py: 0.5, - bgcolor: '#f8fafc', + bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0', cursor: 'pointer', flexShrink: 0, userSelect: 'none', - '&:hover': { bgcolor: '#f1f5f9' }, + '&:hover': { bgcolor: DS_SLATE[100] }, }} > - + Filter & Übersicht {headerOpen ? : } diff --git a/src/pages/supply/ReminderManager.tsx b/src/pages/supply/ReminderManager.tsx index d995163..d60b8ef 100644 --- a/src/pages/supply/ReminderManager.tsx +++ b/src/pages/supply/ReminderManager.tsx @@ -1,19 +1,30 @@ import { Box } from '@mui/material' -import { ReminderHeader } from '../../components/supply/ReminderHeader' -import { ReminderKpiBar } from '../../components/supply/ReminderKpiBar' +import { AgentWorkspaceHero } from '../../components/team' import { ReminderFilterBar } from '../../components/supply/ReminderFilterBar' import { ReminderFeed } from '../../components/supply/ReminderFeed' import { ReminderDetailDrawer } from '../../components/supply/ReminderDetailDrawer' +import { agentWorkspaceById } from '../../lib/agentWorkspaces' +const FERDI = agentWorkspaceById('ferdi')! + +/** + * Ferdi — Fristen-Wächter (Runde 4, §5). + * + * Seitentitel, Beschreibungstext, der Knopf «Reminder erstellen» und die vier + * Auswertungskarten «Überfällig», «Diese Woche», «Dieser Monat» und + * «Pre-Market-Risiko» sind entfallen. Die Seite beginnt mit dem gemeinsamen + * Chat-Einstieg; erst darunter folgt die Reminderliste. + */ export default function ReminderManager() { return ( - - + + + - - - - + + + + diff --git a/src/pages/supply/Teamuebersicht.tsx b/src/pages/supply/Teamuebersicht.tsx deleted file mode 100644 index c410fe8..0000000 --- a/src/pages/supply/Teamuebersicht.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Box } from '@mui/material' -import { - TeamPageHeader, - TeamSectionHeader, - TeamKpiSection, - TeamOverviewRing, - ConnectionSummaryList, -} from '../../components/team' -import { ErrorState } from '../../components/ui' -import { AgentLevel as Level } from '../../domain/agentDirectory' -import { useTeamAgents } from '../../hooks/useTeamAgents' -import { useAgentConnections } from '../../hooks/useAgentConnections' -import { ROUTES } from '../../lib/constants' - -/** - * Startseite des Property-On-Bereichs. - * - * Aufbau: die Belegschaft als Kreis direkt unter dem Seitentitel, darunter die - * Auswertung und die Verbindungen. Der Kreis steht bewusst zuoberst — er ist die - * Antwort auf die Frage, mit der ein Bewirtschafter die Seite öffnet: «wer - * arbeitet hier eigentlich für mich?» - * - * Kopfbereich bewusst ohne Beschreibungstext und ohne «Demo zurücksetzen»: der - * Platz gehört der Visualisierung. - * - * Gezeigt wird ausschliesslich das Kernteam. Die Stufenfilter über der Grafik - * sind entfallen — die übrigen Stufen sind in diesem Branch nicht mehr - * einsehbar. - */ -export default function Teamuebersicht() { - const { data: agents = [], isError, refetch } = useTeamAgents() - const { data: connections = [] } = useAgentConnections() - - return ( - - - - - - {isError ? ( - refetch()} /> - ) : ( - <> - - - - - - - - - - - - - - )} - - - ) -} diff --git a/src/pages/supply/newListingConstants.ts b/src/pages/supply/newListingConstants.ts index 0f2adc5..be7de48 100644 --- a/src/pages/supply/newListingConstants.ts +++ b/src/pages/supply/newListingConstants.ts @@ -1,11 +1,5 @@ -export const ASSET_TYPE_LABELS: Record = { - OFFICE: 'Büro', - RETAIL: 'Einzelhandel', - LIGHT_INDUSTRIAL: 'Gewerbe', - LOGISTICS: 'Logistik', - PRODUCTION: 'Produktion', - MIXED: 'Gemischt', -} +// Die Nutzungsart-Beschriftungen liegen zentral in `lib/constants.ts` +// (`ASSET_TYPE_LABELS`) — hier standen sie in einer abweichenden Fassung. export const SOFT_FACTORS = [ { key: 'prestige', label: 'Prestige / Adressqualität' }, diff --git a/src/pages/supply/newListingMapper.ts b/src/pages/supply/newListingMapper.ts index 25c7a1a..42a386d 100644 --- a/src/pages/supply/newListingMapper.ts +++ b/src/pages/supply/newListingMapper.ts @@ -1,6 +1,7 @@ import { AssetType, AvailabilityStatus, ResultType } from '../../domain/enums' import type { CreatePropertyInput } from '../../domain/property' -import { ASSET_TYPE_LABELS, LEVEL_TO_SCORE } from './newListingConstants' +import { LEVEL_TO_SCORE } from './newListingConstants' +import { ASSET_TYPE_LABELS } from '../../lib/constants' export function buildCreatePropertyInput(fields: { assetType: string diff --git a/src/provider/AuthProvider.tsx b/src/provider/AuthProvider.tsx index 7920b25..a74927d 100644 --- a/src/provider/AuthProvider.tsx +++ b/src/provider/AuthProvider.tsx @@ -1,19 +1,7 @@ -import { createContext, useContext, type ReactNode } from 'react' +import type { ReactNode } from 'react' import { useSessionStore } from '../stores/sessionStore' -import type { MockUser } from '../stores/sessionStore' - -// Placeholder AuthContext — swap for real auth (Supabase, Auth0, etc.) later. -// All call sites use this context; no component imports sessionStore directly. - -interface AuthContextValue { - user: MockUser | null - isAuthenticated: boolean - isLoading: boolean - login: (user: MockUser) => void - logout: () => void -} - -const AuthContext = createContext(null) +import { AuthContext } from './authContext' +import type { AuthContextValue } from './authContext' export function AuthProvider({ children }: { children: ReactNode }) { const currentUser = useSessionStore(s => s.currentUser) @@ -31,9 +19,3 @@ export function AuthProvider({ children }: { children: ReactNode }) { return {children} } - -export function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext) - if (!ctx) throw new Error('useAuth must be used within AuthProvider') - return ctx -} diff --git a/src/provider/IAgentDirectoryProvider.ts b/src/provider/IAgentDirectoryProvider.ts new file mode 100644 index 0000000..405c03a --- /dev/null +++ b/src/provider/IAgentDirectoryProvider.ts @@ -0,0 +1,6 @@ +import type { AgentDirectoryEntry } from '../domain/agentDirectory' + +export interface IAgentDirectoryProvider { + getAll(): Promise + getByIds(ids: string[]): Promise +} diff --git a/src/provider/ICalendarProvider.ts b/src/provider/ICalendarProvider.ts new file mode 100644 index 0000000..74a36b9 --- /dev/null +++ b/src/provider/ICalendarProvider.ts @@ -0,0 +1,10 @@ +import type { CalendarEvent, CreateCalendarEventInput } from '../domain/calendarEvent' + +export interface ICalendarProvider { + /** Ist ein Kalender verbunden? Steuert, ob die Terminplanung angeboten wird. */ + isConnected(): Promise + /** Anzeigename des verbundenen Kalenders, z. B. «Microsoft 365 Kalender». */ + getConnectionName(): Promise + getEventsByReminder(reminderId: string): Promise + create(input: CreateCalendarEventInput): Promise +} diff --git a/src/provider/IExposeLeadProvider.ts b/src/provider/IExposeLeadProvider.ts new file mode 100644 index 0000000..32573eb --- /dev/null +++ b/src/provider/IExposeLeadProvider.ts @@ -0,0 +1,8 @@ +import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead' + +export interface IExposeLeadProvider { + getAll(): Promise + getById(id: string): Promise + create(input: CreateExposeLeadInput): Promise + archive(id: string): Promise +} diff --git a/src/provider/IExposeProvider.ts b/src/provider/IExposeProvider.ts new file mode 100644 index 0000000..a1e7c61 --- /dev/null +++ b/src/provider/IExposeProvider.ts @@ -0,0 +1,9 @@ +import type { ExposeDraft } from '../domain/expose' + +export interface IExposeProvider { + /** Entwurf zu einem Lead und Objekt — null, solange keiner besteht. */ + getDraft(leadId: string, propertyId: string): Promise + save(draft: ExposeDraft): Promise + /** Setzt den Erstellungszeitpunkt — trennt «gespeichert» von «erstellt». */ + markGenerated(draftId: string): Promise +} diff --git a/src/provider/IVisitAssignmentProvider.ts b/src/provider/IVisitAssignmentProvider.ts new file mode 100644 index 0000000..59fe7d8 --- /dev/null +++ b/src/provider/IVisitAssignmentProvider.ts @@ -0,0 +1,8 @@ +import type { VisitAssignment } from '../domain/visitAssignment' + +export interface IVisitAssignmentProvider { + getAll(): Promise + getById(id: string): Promise + /** Bericht anfordern — beendet die Warnung «Besichtigung in weniger als 24 h». */ + requestReport(id: string): Promise +} diff --git a/src/provider/MockupAgentDirectoryProvider.ts b/src/provider/MockupAgentDirectoryProvider.ts new file mode 100644 index 0000000..f91ebda --- /dev/null +++ b/src/provider/MockupAgentDirectoryProvider.ts @@ -0,0 +1,17 @@ +import { agentDirectory } from '../mock-data/agentDirectory' +import type { AgentDirectoryEntry } from '../domain/agentDirectory' +import type { IAgentDirectoryProvider } from './IAgentDirectoryProvider' + +export const MockupAgentDirectoryProvider: IAgentDirectoryProvider = { + async getAll() { + return [...agentDirectory] + }, + + async getByIds(ids) { + // Reihenfolge der angefragten IDs beibehalten — die Oberfläche zeigt sie so, + // wie der aufrufende Datensatz sie führt, nicht alphabetisch. + return ids + .map(id => agentDirectory.find(a => a.id === id)) + .filter((a): a is AgentDirectoryEntry => Boolean(a)) + }, +} diff --git a/src/provider/MockupCalendarProvider.ts b/src/provider/MockupCalendarProvider.ts new file mode 100644 index 0000000..ac7a792 --- /dev/null +++ b/src/provider/MockupCalendarProvider.ts @@ -0,0 +1,45 @@ +import type { CalendarEvent, CreateCalendarEventInput } from '../domain/calendarEvent' +import type { ICalendarProvider } from './ICalendarProvider' +import { mockAgentConnections } from '../mock-data/agentConnections' +import { AgentConnectionCategory } from '../domain/agentConnection' +import { AgentConnectionStatus } from '../domain/teamAgent' + +/** + * Kalender-Ablage der Demo. + * + * Es wird kein echter Kalender kontaktiert. Verbindungsname und Verbindungs- + * zustand kommen aus dem bestehenden Bestand «Kanäle & Systeme» — so zeigt die + * Terminplanung denselben Kalender an, den der Nutzer dort konfiguriert hat, + * statt einen zweiten, erfundenen zu behaupten. + */ +let store: CalendarEvent[] = [] +let sequence = 0 + +function calendarConnection() { + return mockAgentConnections.find(c => c.category === AgentConnectionCategory.CALENDAR) +} + +export const MockupCalendarProvider: ICalendarProvider = { + async isConnected() { + return calendarConnection()?.status === AgentConnectionStatus.CONNECTED + }, + + async getConnectionName() { + return calendarConnection()?.name ?? 'Kalender' + }, + + async getEventsByReminder(reminderId) { + return store.filter(e => e.reminderId === reminderId) + }, + + async create(input: CreateCalendarEventInput) { + sequence += 1 + const event: CalendarEvent = { + ...input, + id: `cal-${String(sequence).padStart(3, '0')}`, + createdAt: new Date().toISOString(), + } + store = [...store, event] + return event + }, +} diff --git a/src/provider/MockupDashboardProvider.ts b/src/provider/MockupDashboardProvider.ts deleted file mode 100644 index 95c90b9..0000000 --- a/src/provider/MockupDashboardProvider.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { IDashboardProvider, DashboardStats } from './IDashboardProvider' -import { mockProperties } from '../mock-data/properties' -import { mockMatches } from '../mock-data/matches' -import { mockNeeds } from '../mock-data/needs' -import { mockFutureSignals } from '../mock-data/futureSignals' -import { mockReviewQueue } from '../mock-data/reviewQueue' -import { mockDelay } from '../lib/mockUtils' - -export const MockupDashboardProvider: IDashboardProvider = { - async getStats(organizationId?) { - await mockDelay() - - let props = mockProperties - let matches = mockMatches - let needs = mockNeeds - let signals = mockFutureSignals - let queue = mockReviewQueue - - if (organizationId) { - props = props.filter(p => p.organizationId === organizationId) - matches = matches.filter(m => m.organizationId === organizationId) - needs = needs.filter(n => n.organizationId === organizationId) - signals = signals.filter(s => s.organizationId === organizationId) - queue = queue.filter(r => r.relatedOrganizationId === organizationId) - } - - const avgScore = - matches.length > 0 - ? matches.reduce((sum, m) => sum + m.matchScore, 0) / matches.length - : 0 - - const stats: DashboardStats = { - totalProperties: props.length, - verifiedProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length, - verifiedPortfolioProperties: props.filter(p => p.resultType === 'VERIFIED_PORTFOLIO').length, - futureSignalProperties: props.filter(p => p.resultType === 'FUTURE_AVAILABILITY').length, - totalMatches: matches.length, - pendingReviews: queue.filter(r => r.status === 'PENDING').length, - approvedMatches: matches.filter(m => m.isApproved === true).length, - activeNeeds: needs.length, - totalSignals: signals.length, - verifiedSignals: signals.filter(s => s.isVerified).length, - averageMatchScore: Math.round(avgScore), - highConfidenceMatches: matches.filter(m => m.confidenceLevel >= 0.75).length, - } - return stats - }, -} diff --git a/src/provider/MockupExposeLeadProvider.ts b/src/provider/MockupExposeLeadProvider.ts new file mode 100644 index 0000000..038adda --- /dev/null +++ b/src/provider/MockupExposeLeadProvider.ts @@ -0,0 +1,36 @@ +import { mockExposeLeads } from '../mock-data/exposeLeads' +import type { ExposeLead } from '../domain/exposeLead' +import { ExposeLeadStatus } from '../domain/exposeLead' +import type { IExposeLeadProvider } from './IExposeLeadProvider' + +let store: ExposeLead[] = [...mockExposeLeads] +let sequence = store.length + +export const MockupExposeLeadProvider: IExposeLeadProvider = { + async getAll() { + return [...store] + }, + + async getById(id) { + return store.find(l => l.id === id) ?? null + }, + + async create(input) { + sequence += 1 + const lead: ExposeLead = { + ...input, + id: `lead-${String(sequence).padStart(3, '0')}`, + receivedAt: input.receivedAt ?? new Date().toISOString(), + status: input.status ?? ExposeLeadStatus.ACTIVE, + } + store = [lead, ...store] + return lead + }, + + async archive(id) { + const idx = store.findIndex(l => l.id === id) + if (idx === -1) throw new Error(`Lead ${id} nicht gefunden`) + store[idx] = { ...store[idx], status: ExposeLeadStatus.ARCHIVED } + return store[idx] + }, +} diff --git a/src/provider/MockupExposeProvider.ts b/src/provider/MockupExposeProvider.ts new file mode 100644 index 0000000..dd209ef --- /dev/null +++ b/src/provider/MockupExposeProvider.ts @@ -0,0 +1,37 @@ +import type { ExposeDraft } from '../domain/expose' +import type { IExposeProvider } from './IExposeProvider' + +/** + * Entwurfsablage der Demo — in-memory, wie alle Mockup-Provider. + * + * Der Schlüssel ist das Paar aus Lead und Objekt: derselbe Lead kann mehrere + * Objekte empfehlen, und jedes bekommt sein eigenes Exposé. + */ +const store = new Map() + +function key(leadId: string, propertyId: string): string { + return `${leadId}::${propertyId}` +} + +export const MockupExposeProvider: IExposeProvider = { + async getDraft(leadId, propertyId) { + return store.get(key(leadId, propertyId)) ?? null + }, + + async save(draft) { + const saved: ExposeDraft = { ...draft, updatedAt: new Date().toISOString() } + store.set(key(draft.leadId, draft.propertyId), saved) + return saved + }, + + async markGenerated(draftId) { + for (const [k, d] of store) { + if (d.id === draftId) { + const updated: ExposeDraft = { ...d, generatedAt: new Date().toISOString() } + store.set(k, updated) + return updated + } + } + throw new Error(`Exposé-Entwurf ${draftId} nicht gefunden`) + }, +} diff --git a/src/provider/MockupMarketIntelligenceProvider.ts b/src/provider/MockupMarketIntelligenceProvider.ts index 52cd866..de63b09 100644 --- a/src/provider/MockupMarketIntelligenceProvider.ts +++ b/src/provider/MockupMarketIntelligenceProvider.ts @@ -3,7 +3,7 @@ import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from ' import { MOCK_MARKET_SIGNALS } from '../mock-data/marketSignals' // Mutable in-memory copy for status updates -let signals: MarketSignal[] = [...MOCK_MARKET_SIGNALS] +const signals: MarketSignal[] = [...MOCK_MARKET_SIGNALS] function applyFilters(data: MarketSignal[], filters?: MarketSignalFilters): MarketSignal[] { if (!filters) return data diff --git a/src/provider/MockupReminderProvider.ts b/src/provider/MockupReminderProvider.ts index f73f603..371c0ae 100644 --- a/src/provider/MockupReminderProvider.ts +++ b/src/provider/MockupReminderProvider.ts @@ -3,7 +3,7 @@ import type { Reminder, ReminderActivity } from '../domain/reminder' import { ReminderStatus } from '../domain/reminder' import type { IReminderProvider } from './IReminderProvider' -let store: Reminder[] = [...mockReminders] +const store: Reminder[] = [...mockReminders] function now(): string { return new Date().toISOString() diff --git a/src/provider/MockupSignalPipelineProvider.ts b/src/provider/MockupSignalPipelineProvider.ts index ec45a71..5430f48 100644 --- a/src/provider/MockupSignalPipelineProvider.ts +++ b/src/provider/MockupSignalPipelineProvider.ts @@ -4,7 +4,7 @@ import { GateStatus, PipelineStage } from '../domain/signalPipeline' import type { GateType } from '../domain/signalPipeline' import { MOCK_PIPELINE_STATES, MOCK_AUDIT_TRAILS } from '../mock-data/signalPipelines' -let states: PipelineState[] = [...MOCK_PIPELINE_STATES] +const states: PipelineState[] = [...MOCK_PIPELINE_STATES] export const MockupSignalPipelineProvider: ISignalPipelineProvider = { async getPipelineState(signalId) { diff --git a/src/provider/MockupVisitAssignmentProvider.ts b/src/provider/MockupVisitAssignmentProvider.ts new file mode 100644 index 0000000..c6142ef --- /dev/null +++ b/src/provider/MockupVisitAssignmentProvider.ts @@ -0,0 +1,24 @@ +import { mockVisitAssignments } from '../mock-data/visitAssignments' +import type { VisitAssignment } from '../domain/visitAssignment' +import type { IVisitAssignmentProvider } from './IVisitAssignmentProvider' + +const store: VisitAssignment[] = [...mockVisitAssignments] + +export const MockupVisitAssignmentProvider: IVisitAssignmentProvider = { + async getAll() { + return [...store] + }, + + async getById(id) { + return store.find(a => a.id === id) ?? null + }, + + async requestReport(id) { + const idx = store.findIndex(a => a.id === id) + if (idx === -1) throw new Error(`Auftrag ${id} nicht gefunden`) + const current = store[idx] + if (!current.preparation) throw new Error('Nur Vorbereitungsaufträge kennen einen Bericht.') + store[idx] = { ...current, preparation: { ...current.preparation, reportRequested: true } } + return store[idx] + }, +} diff --git a/src/provider/authContext.ts b/src/provider/authContext.ts new file mode 100644 index 0000000..2ebfd5c --- /dev/null +++ b/src/provider/authContext.ts @@ -0,0 +1,21 @@ +import { createContext } from 'react' +import type { MockUser } from '../stores/sessionStore' + +/** + * Platzhalter-Auth-Kontext — wird später gegen eine echte Anmeldung getauscht + * (Supabase, Auth0 o. ä.). Alle Aufrufer gehen über diesen Kontext; keine + * Komponente greift direkt auf den `sessionStore` zu. + * + * Kontext und Provider liegen in getrennten Dateien, weil eine `.tsx`-Datei, + * die eine Komponente und einen Nicht-Komponenten-Wert exportiert, im + * Entwicklungsmodus bei jeder Änderung ihren Zustand verliert. + */ +export interface AuthContextValue { + user: MockUser | null + isAuthenticated: boolean + isLoading: boolean + login: (user: MockUser) => void + logout: () => void +} + +export const AuthContext = createContext(null) diff --git a/src/services/agentDirectoryService.ts b/src/services/agentDirectoryService.ts new file mode 100644 index 0000000..1b63bf2 --- /dev/null +++ b/src/services/agentDirectoryService.ts @@ -0,0 +1,17 @@ +import { MockupAgentDirectoryProvider } from '../provider/MockupAgentDirectoryProvider' +import type { AgentDirectoryEntry } from '../domain/agentDirectory' +import type { ListResponse } from './types' +import { throwServiceError } from './errors' + +const provider = MockupAgentDirectoryProvider + +export const agentDirectoryService = { + async getAll(): Promise> { + try { + const data = await provider.getAll() + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + } catch (err) { + throwServiceError(err) + } + }, +} diff --git a/src/services/ai/IAIService.ts b/src/services/ai/IAIService.ts index 2fef478..97a06fd 100644 --- a/src/services/ai/IAIService.ts +++ b/src/services/ai/IAIService.ts @@ -197,6 +197,39 @@ export interface PreMarketRentRecommendation { confidence: 'LOW' | 'MEDIUM' | 'HIGH' } +// ── Exposé texts (Livia) ────────────────────────────────────────────────────── + +/** + * Die Textabschnitte eines Exposés. Jeder lässt sich einzeln erzeugen — + * gesamthaft heisst schlicht: alle nacheinander (Runde 4, §8.5.2). + */ +export const ExposeTextSection = { + TITLE: 'TITLE', + TEASER: 'TEASER', + OBJECT: 'OBJECT', + LOCATION: 'LOCATION', + MUNICIPALITY: 'MUNICIPALITY', + FEATURES: 'FEATURES', + HIGHLIGHTS: 'HIGHLIGHTS', +} as const +export type ExposeTextSection = typeof ExposeTextSection[keyof typeof ExposeTextSection] + +export type ExposeTonality = 'SACHLICH' | 'HOCHWERTIG' | 'EINLADEND' + +export interface ExposeTextInput { + section: ExposeTextSection + tonality: ExposeTonality + /** Ausschliesslich belegte Angaben aus «Meine Objekte» — nichts Erfundenes. */ + facts: Record +} + +export interface ExposeTextResult { + section: ExposeTextSection + text: string + /** Angaben, die für einen vollständigen Text fehlen — statt sie zu erfinden. */ + missingFacts: string[] +} + // ── Legacy types (kept for backward compatibility) ──────────────────────────── export interface CriteriaExtractionResult { @@ -244,6 +277,9 @@ export interface IAIService { // Pre-market rent recommendation (supply side) — based on regional comparables, supply & demand recommendPreMarketRent(input: PreMarketRentInput): Promise> + // Exposé-Texte (Livia) — pro Abschnitt auslösbar + generateExposeText(input: ExposeTextInput): Promise> + // Legacy methods extractCriteria(input: string): Promise> generateFollowUp(partialNeed: Partial): Promise> diff --git a/src/services/ai/backend/BackendAIService.ts b/src/services/ai/backend/BackendAIService.ts index ef907fb..f3569bd 100644 --- a/src/services/ai/backend/BackendAIService.ts +++ b/src/services/ai/backend/BackendAIService.ts @@ -59,6 +59,8 @@ import type { FitOutAdvice, PreMarketRentInput, PreMarketRentRecommendation, + ExposeTextInput, + ExposeTextResult, } from '../IAIService' import { ServiceErrorCode } from '../../types' import { AppError } from '../../errors' @@ -154,7 +156,7 @@ async function chat(system: string, user: string): Promise { function extractJSON(raw: string): T | null { const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/) - const candidate = fenced ? fenced[1] : raw.match(/([\[{][\s\S]*[\]}])/)?.[1] ?? raw + const candidate = fenced ? fenced[1] : raw.match(/([[{][\s\S]*[\]}])/)?.[1] ?? raw try { return JSON.parse(candidate) as T } catch { @@ -675,6 +677,17 @@ Miettrend 12M: ${intel?.rentTrend12m ?? '?'}% }, () => MockAIService.extractCriteria(input)) }, + // ── generateExposeText ────────────────────────────────────────────────────── + // + // Bewusst ohne eigenen LLM-Aufruf: der Text darf ausschliesslich auf erfassten + // Objektdaten beruhen (Runde 4, §8.5.2). Ein freies Sprachmodell würde genau + // die fehlenden Angaben plausibel ergänzen, die hier rot markiert gehören. + // Deshalb erzeugt auch im Backend-Betrieb der deterministische Builder den + // Entwurf; er nennt Lücken, statt sie zu füllen. + generateExposeText(input: ExposeTextInput): Promise> { + return MockAIService.generateExposeText(input) + }, + // ── Legacy: generateFollowUp ──────────────────────────────────────────────── generateFollowUp(partialNeed: Partial): Promise> { return withFallback('generateFollowUp', async () => { diff --git a/src/services/ai/mock/MockAIService.ts b/src/services/ai/mock/MockAIService.ts index 3a5ab5a..0b69607 100644 --- a/src/services/ai/mock/MockAIService.ts +++ b/src/services/ai/mock/MockAIService.ts @@ -14,12 +14,15 @@ import type { FitOutAdvice, PreMarketRentInput, PreMarketRentRecommendation, + ExposeTextInput, + ExposeTextResult, } from '../IAIService' import { mockProvenance } from '../IAIService' import { aiTraceStore } from '../tracing' import { mockParseNeed } from './needParser' import { buildComparisonSummary } from './compareBuilder' import { buildMockDecisionBrief } from './decisionBrief' +import { buildExposeText } from './exposeTextBuilder' import { getCityIntelligence, getMarketRent } from '../../../lib/locationIntelligence' const SIMULATED_DELAY = { fast: 300, medium: 600, slow: 1800 } @@ -299,6 +302,12 @@ export const MockAIService: IAIService = { } }), + generateExposeText: (input: ExposeTextInput) => + traceMock('generateExposeText', async () => { + await delay(SIMULATED_DELAY.medium) + return { data: buildExposeText(input), provenance: mockProvenance() } + }), + generateFitOutAdvice: (input: FitOutAdviceInput) => traceMock('generateFitOutAdvice', async () => { await delay(SIMULATED_DELAY.medium) diff --git a/src/services/ai/mock/compareBuilder.ts b/src/services/ai/mock/compareBuilder.ts index 02fa40d..2ff1fb5 100644 --- a/src/services/ai/mock/compareBuilder.ts +++ b/src/services/ai/mock/compareBuilder.ts @@ -1,11 +1,21 @@ import type { UnifiedMatchResult } from '../../../domain/unifiedResult' import type { ComparisonSummary } from '../IAIService' +/** + * `UnifiedMatchResult` ist eine Union über `resultType`. Die Verzweigung + * darüber genügt TypeScript, um das jeweilige Feld freizugeben — die früheren + * `as any` haben genau diese Prüfung ausgeschaltet und dabei auch echte + * Tippfehler durchgelassen. + */ +function rentPerSqm(item: UnifiedMatchResult): number | undefined { + return item.property?.rentPricePerSqm +} + export function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary { const getTitle = (item: UnifiedMatchResult) => item.resultType !== 'FUTURE_AVAILABILITY' - ? (item as any).property?.title ?? `Match ${item.matchScore}` - : (item as any).signal?.companyName ?? 'Zukunftssignal' + ? item.property.title + : item.signal.companyName ?? 'Zukunftssignal' if (items.length === 0) { return { @@ -26,7 +36,7 @@ export function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonS const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY') const bestValue = propertyItems.length > 0 ? propertyItems.reduce((a, b) => - ((a as any).property?.rentPricePerSqm ?? Infinity) <= ((b as any).property?.rentPricePerSqm ?? Infinity) ? a : b + (rentPerSqm(a) ?? Infinity) <= (rentPerSqm(b) ?? Infinity) ? a : b ) : null @@ -59,7 +69,7 @@ export function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonS // Per-property assessment const deriveBestFor = (item: UnifiedMatchResult): string => { - const assetType = (item as any).property?.assetType ?? '' + const assetType = item.property?.assetType ?? '' const score = item.matchScore const hasPosPrestige = item.match.positiveFactors.some(f => f.criterion.toLowerCase().includes('prestige') || f.criterion.toLowerCase().includes('location') @@ -106,7 +116,7 @@ export function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonS ? { matchId: bestValue.matchId, label: getTitle(bestValue), - reason: `Niedrigster Mietpreis (CHF ${(bestValue as any).property?.rentPricePerSqm ?? '–'}/m²)`, + reason: `Niedrigster Mietpreis (CHF ${rentPerSqm(bestValue) ?? '–'}/m²)`, } : null, highestConfidence: { diff --git a/src/services/ai/mock/exposeTextBuilder.ts b/src/services/ai/mock/exposeTextBuilder.ts new file mode 100644 index 0000000..d3fc1e5 --- /dev/null +++ b/src/services/ai/mock/exposeTextBuilder.ts @@ -0,0 +1,105 @@ +import type { ExposeTextInput, ExposeTextResult } from '../IAIService' +import { ExposeTextSection } from '../IAIService' + +/** + * Deterministische Exposé-Texte für Entwicklung und Demo. + * + * Kernregel der Vorgabe: keine unbelegten Fakten erfinden (Runde 4, §8.5.2). + * Der Builder verwendet deshalb ausschliesslich die übergebenen Angaben. Was + * fehlt, wird nicht plausibel ergänzt, sondern in `missingFacts` genannt — + * die Oberfläche markiert es rot, statt einen glatten Text zu zeigen, der + * nachher nicht stimmt. + */ + +const TONE_OPENER: Record = { + SACHLICH: '', + HOCHWERTIG: 'Repräsentativ und sorgfältig ausgebaut: ', + EINLADEND: 'Willkommen in Ihrer neuen Fläche — ', +} + +/** Welche Angaben braucht ein Abschnitt, um überhaupt geschrieben werden zu können? */ +const REQUIRED: Record = { + [ExposeTextSection.TITLE]: ['objektart', 'ort'], + [ExposeTextSection.TEASER]: ['objektart', 'flaeche', 'ort'], + [ExposeTextSection.OBJECT]: ['objektart', 'flaeche', 'ausbaustandard'], + [ExposeTextSection.LOCATION]: ['strasse', 'ort'], + [ExposeTextSection.MUNICIPALITY]: ['ort'], + [ExposeTextSection.FEATURES]: ['ausbaustandard'], + [ExposeTextSection.HIGHLIGHTS]: ['objektart', 'ort'], +} + +function value(facts: ExposeTextInput['facts'], key: string): string | undefined { + const raw = facts[key] + if (raw === undefined || raw === null || raw === '') return undefined + return String(raw) +} + +function compose(input: ExposeTextInput): string { + const f = input.facts + const v = (k: string) => value(f, k) + const opener = TONE_OPENER[input.tonality] ?? '' + + switch (input.section) { + case ExposeTextSection.TITLE: + return [v('objektart'), v('flaeche') && `${v('flaeche')} m²`, 'in', v('ort')] + .filter(Boolean).join(' ') + + case ExposeTextSection.TEASER: + return `${opener}${v('objektart')} mit ${v('flaeche')} m² in ${v('ort')}` + + (v('verfuegbarkeit') ? `, verfügbar ab ${v('verfuegbarkeit')}` : '') + '.' + + case ExposeTextSection.OBJECT: + return [ + `${opener}Die Fläche umfasst ${v('flaeche')} m² und ist im Ausbaustandard «${v('ausbaustandard')}» ausgeführt.`, + v('stockwerk') ? `Sie liegt im ${v('stockwerk')}. Stockwerk.` : '', + v('parkplaetze') ? `Zur Fläche gehören ${v('parkplaetze')} Parkplätze.` : '', + v('raumhoehe') ? `Die Raumhöhe beträgt ${v('raumhoehe')} m.` : '', + ].filter(Boolean).join(' ') + + case ExposeTextSection.LOCATION: + return [ + `Das Objekt befindet sich an der ${v('strasse')} in ${v('plz') ?? ''} ${v('ort')}.`.replace(/\s+/g, ' '), + v('oevHaltestelle') ? `Die nächste ÖV-Haltestelle liegt rund ${v('oevHaltestelle')} m entfernt.` : '', + v('einkauf') ? `Einkaufsmöglichkeiten sind in etwa ${v('einkauf')} m erreichbar.` : '', + v('autobahn') ? `Der Autobahnanschluss ist rund ${v('autobahn')} m entfernt.` : '', + ].filter(Boolean).join(' ') + + case ExposeTextSection.MUNICIPALITY: + return [ + `${v('ort')}${v('kanton') ? ` (${v('kanton')})` : ''} als Standort:`, + v('einwohner') ? `${v('einwohner')} Einwohnerinnen und Einwohner.` : '', + v('bevoelkerungswachstum') ? `Bevölkerungswachstum ${v('bevoelkerungswachstum')} % pro Jahr.` : '', + v('steuerbelastung') ? `Steuerbelastung ${v('steuerbelastung')} %.` : '', + v('leerwohnungsziffer') ? `Leerwohnungsziffer ${v('leerwohnungsziffer')} %.` : '', + ].filter(Boolean).join(' ') + + case ExposeTextSection.FEATURES: + return [ + `Ausbaustandard «${v('ausbaustandard')}».`, + v('heizsystem') ? `Beheizt über ${v('heizsystem')}.` : '', + v('merkmale') ? `Weitere Merkmale: ${v('merkmale')}.` : '', + ].filter(Boolean).join(' ') + + case ExposeTextSection.HIGHLIGHTS: + return [ + v('flaeche') ? `${v('flaeche')} m² ${v('objektart')}` : '', + v('ort') ? `Standort ${v('ort')}` : '', + v('parkplaetze') ? `${v('parkplaetze')} Parkplätze` : '', + v('oevHaltestelle') ? `ÖV in ${v('oevHaltestelle')} m` : '', + ].filter(Boolean).join('\n') + + default: + return '' + } +} + +export function buildExposeText(input: ExposeTextInput): ExposeTextResult { + const missingFacts = (REQUIRED[input.section] ?? []).filter(k => value(input.facts, k) === undefined) + + // Fehlt eine Grundangabe, wird kein Text behauptet — die Lücke ist die Antwort. + if (missingFacts.length > 0) { + return { section: input.section, text: '', missingFacts } + } + + return { section: input.section, text: compose(input).trim(), missingFacts: [] } +} diff --git a/src/services/ai/mock/needParser.ts b/src/services/ai/mock/needParser.ts index 34da415..0d71b16 100644 --- a/src/services/ai/mock/needParser.ts +++ b/src/services/ai/mock/needParser.ts @@ -18,7 +18,7 @@ export function mockParseNeed(input: string): ParseNeedResult { : undefined // Area - const areaRangeMatch = input.match(/(\d+)\s*[–\-–]\s*(\d+)\s*m[²2]/i) + const areaRangeMatch = input.match(/(\d+)\s*[–-–]\s*(\d+)\s*m[²2]/i) const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i) let areaRange: { min: number; max: number } | undefined let areaConfidence = 0.25 @@ -72,7 +72,7 @@ export function mockParseNeed(input: string): ParseNeedResult { } // Extract district list from patterns like "Kreis 3, 4, 5, und 8" or "Kreis 3/4/5" - const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,\/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/) + const kreisListMatch = lower.match(/\bkreis\s+([\d]+(?:\s*[,/]\s*[\d]+)*(?:\s+und\s+[\d]+)?)/) if (kreisListMatch) { const nums = kreisListMatch[1].match(/\d+/g) ?? [] nums.forEach(n => { @@ -101,9 +101,9 @@ export function mockParseNeed(input: string): ParseNeedResult { const budgetPerSqmMatch = cleanedBudget.match(/(?:mz|mietzins|max\.?|budget)?\s*(?:CHF\s*)?(\d{2,4})\s*\/\s*m[²2]/i) // Range "250 – 280 CHF" or "250-280/m²" — require explicit currency or /m² so area ranges like "120–150m2" are not matched const budgetRangeMatch = - cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ?? - cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[–\-]\s*(\d{2,4})/i) ?? - cleanedBudget.match(/(\d{2,4})\s*[–\-]\s*(\d{2,4})\s*\/\s*m[²2]/i) + cleanedBudget.match(/(\d{2,4})\s*[–-]\s*(\d{2,4})\s*(?:CHF|Fr\.?)\b/i) ?? + cleanedBudget.match(/(?:CHF|Fr\.?)\s*(\d{2,4})\s*[–-]\s*(\d{2,4})/i) ?? + cleanedBudget.match(/(\d{2,4})\s*[–-]\s*(\d{2,4})\s*\/\s*m[²2]/i) const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i) let budgetRange: { maxPerSqm: number; currency: string } | undefined let budgetConfidence = 0.20 @@ -128,7 +128,7 @@ export function mockParseNeed(input: string): ParseNeedResult { // Timing const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(20\d{2})/) // "Q4/26" or "Q1/27" — two-digit year (collect all occurrences, use first as earliest) - const quarterMatches = [...input.matchAll(/Q([1-4])\s*[\/\-]?\s*(\d{2})\b/gi)] + const quarterMatches = [...input.matchAll(/Q([1-4])\s*[/-]?\s*(\d{2})\b/gi)] const soonMatch = lower.includes('sofort') || lower.includes('asap') let timing: ParsedNeedCriteria['timing'] | undefined let timingConfidence = 0.20 @@ -318,7 +318,7 @@ export function mockParseNeed(input: string): ParseNeedResult { if (apartmentMatch) { const sizeHint = apartmentMatch[1] ? `${apartmentMatch[1]}-Zi-Wohnung` : 'Wohnung' const rentMatch = input.match(/(\d['.\s]?\d{3})[.,\-\s]*(?:inkl|inkl\.)/i) - ?? input.match(/(\d{4})[.,\-]\s*(?:chf|fr)?/i) + ?? input.match(/(\d{4})[.,-]\s*(?:chf|fr)?/i) const rentHint = rentMatch ? ` max. CHF ${rentMatch[1].replace(/['\s]/g, "'")}` : '' notesParts.push(`Wunsch: ${sizeHint} im Haus oder in der Nähe${rentHint} inkl. NK`) } diff --git a/src/services/aiSearch/needSearchMapper.ts b/src/services/aiSearch/needSearchMapper.ts deleted file mode 100644 index 584f37c..0000000 --- a/src/services/aiSearch/needSearchMapper.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { AssetType } from '../../domain/enums' -import type { ParsedNeedCriteria, WeightingKey } from '../../domain/needBuilder' -import type { CreateNeedInput, Need } from '../../domain/need' - -const ASSET_LABELS_TEXT: Record = { - OFFICE: 'Bürofläche', RETAIL: 'Retail-Fläche', LOGISTICS: 'Logistikfläche', - PRODUCTION: 'Produktionsfläche', LIGHT_INDUSTRIAL: 'Gewerbefläche', MIXED: 'gemischte Fläche', -} - -export function needToParsedCriteria(need: Need): ParsedNeedCriteria { - return { - assetType: need.assetType, - areaRange: need.requiredArea, - preferredLocations: need.preferredLocations, - budgetRange: need.budgetRange, - timing: need.timing, - mustHaveCriteria: need.mustCriteriaText, - softFactors: need.softFactors, - requireGroundFloor: need.requireGroundFloor, - requiredFitOut: need.requiredFitOut, - requiredParkingMin: need.requiredParkingMin, - requireAirConditioning: need.requireAirConditioning, - requireLoadingDock: need.requireLoadingDock, - requireBarrierFree: need.requireBarrierFree, - minCeilingHeightM: need.minCeilingHeightM, - minContractDurationMonths: need.minContractDurationMonths, - searchRadius: need.searchRadius, - isAnonymous: need.isAnonymous, - requiresDivisibility: need.requiresDivisibility, - minDivisibleUnit: need.minDivisibleUnit, - fitOutBudgetMaxPerSqm: need.fitOutBudgetMaxPerSqm, - notes: need.notes, - companyName: need.companyName, - } -} - -export function generateSummary(c: ParsedNeedCriteria): string { - const parts: string[] = [] - if (c.assetType) parts.push(`Suche ${ASSET_LABELS_TEXT[c.assetType] ?? c.assetType}`) - if (c.areaRange && (c.areaRange.min > 0 || c.areaRange.max > 0)) - parts.push(`${c.areaRange.min}–${c.areaRange.max} m²`) - if (c.preferredLocations?.length) parts.push(`in ${c.preferredLocations.join(', ')}`) - if (c.budgetRange?.maxPerSqm) parts.push(`Budget max. CHF ${c.budgetRange.maxPerSqm}/m²`) - if (c.timing?.earliestMoveIn) parts.push(`ab ${c.timing.earliestMoveIn}`) - if (c.mustHaveCriteria?.length) parts.push(`Must-haves: ${c.mustHaveCriteria.join(', ')}`) - if (c.searchRadius) parts.push(`Radius ${c.searchRadius} km`) - if (c.isAnonymous) parts.push('Anonyme Suche') - if (c.requiresDivisibility && c.minDivisibleUnit) parts.push(`Teilbar ab ${c.minDivisibleUnit} m²`) - if (c.fitOutBudgetMaxPerSqm) parts.push(`Ausbaubudget max. CHF ${c.fitOutBudgetMaxPerSqm}/m²`) - return parts.join(', ') -} - -export function buildNeedInput( - criteria: ParsedNeedCriteria, - weights: Record, - needTitle: string, - overallConfidence: number, - status: 'DRAFT' | 'ACTIVE', -): CreateNeedInput { - return { - companyName: needTitle || criteria.companyName || 'Neue Suche', - assetType: criteria.assetType ?? AssetType.UNKNOWN, - requiredArea: criteria.areaRange ?? { min: 0, max: 0 }, - preferredLocations: criteria.preferredLocations ?? [], - budgetRange: criteria.budgetRange ?? { maxPerSqm: 0, currency: 'CHF' }, - timing: { - earliestMoveIn: criteria.timing?.earliestMoveIn ?? '', - latestMoveIn: criteria.timing?.latestMoveIn ?? criteria.timing?.earliestMoveIn ?? '', - contractDurationMonths: criteria.timing?.contractDurationMonths, - flexibleTiming: criteria.timing?.flexibleTiming ?? true, - }, - weightingProfile: weights, - confidenceInCriteria: overallConfidence, - status, - mustCriteriaText: criteria.mustHaveCriteria ?? [], - requireGroundFloor: criteria.requireGroundFloor, - requiredFitOut: criteria.requiredFitOut, - requiredParkingMin: criteria.requiredParkingMin, - requireAirConditioning: criteria.requireAirConditioning, - requireLoadingDock: criteria.requireLoadingDock, - requireBarrierFree: criteria.requireBarrierFree, - minCeilingHeightM: criteria.minCeilingHeightM, - minContractDurationMonths: criteria.minContractDurationMonths, - searchRadius: criteria.searchRadius, - isAnonymous: criteria.isAnonymous, - requiresDivisibility: criteria.requiresDivisibility, - minDivisibleUnit: criteria.minDivisibleUnit, - fitOutBudgetMaxPerSqm: criteria.fitOutBudgetMaxPerSqm, - notes: criteria.notes, - extractedFromText: undefined, - } -} diff --git a/src/services/calendarService.ts b/src/services/calendarService.ts new file mode 100644 index 0000000..9f5b2f6 --- /dev/null +++ b/src/services/calendarService.ts @@ -0,0 +1,99 @@ +import { MockupCalendarProvider } from '../provider/MockupCalendarProvider' +import type { CalendarAttachment, CreateCalendarEventInput } from '../domain/calendarEvent' +import type { ItemResponse, ListResponse } from './types' +import type { CalendarEvent } from '../domain/calendarEvent' +import { throwServiceError } from './errors' +import { ReminderType } from '../domain/reminder' +import type { Reminder } from '../domain/reminder' +import type { Property } from '../domain/property' +import { REMINDER_TYPE_LABELS } from '../lib/constants' + +const provider = MockupCalendarProvider + +/** + * Welcher Ausschnitt des Objektdossiers gehört zu welcher Reminderart? + * + * Die Vorgabe ist ausdrücklich: keine generische Vollakte anhängen, sondern den + * zur Reminderart passenden relevanten Ausschnitt (Runde 4, §5.6). Bei einem + * Mietauslauf ist das die Objektübersicht plus der vorhandene Mietvertrag; bei + * einer Inspektion nützt der Mietvertrag niemandem. + */ +const TYPE_ATTACHMENT_PARTS: Record = { + [ReminderType.LEASE_EXPIRY]: ['Objektübersicht', 'Mietvertrag'], + [ReminderType.BREAK_OPTION]: ['Objektübersicht', 'Mietvertrag'], + [ReminderType.RENT_REVIEW]: ['Objektübersicht', 'Mietzins- und Nebenkostenübersicht'], + [ReminderType.INSPECTION]: ['Objektübersicht', 'Zustands- und Unterhaltsangaben'], + [ReminderType.INSURANCE_RENEWAL]: ['Objektübersicht', 'Gebäude- und Versicherungsangaben'], + [ReminderType.MAINTENANCE]: ['Objektübersicht', 'Zustands- und Unterhaltsangaben'], + [ReminderType.SCHATTENMARKT_RELEASE]: ['Objektübersicht', 'Verfügbarkeit und Vermarktungsstand'], + [ReminderType.CUSTOM]: ['Objektübersicht'], +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +/** Standard-Termintitel: «Ferdi: [Reminder-Typ] - [Objektname]» (§5.6). */ +export function defaultCalendarTitle(reminder: Reminder): string { + const type = REMINDER_TYPE_LABELS[reminder.type] ?? reminder.type + return `Ferdi: ${type} - ${reminder.propertyTitle}` +} + +/** + * Der Anhang zum Termin — ein PDF mit genau den Abschnitten, die für diese + * Reminderart gebraucht werden. Ist zum Objekt ein Mietvertrag hinterlegt, + * wird er verlinkt; fehlt er, wird er im Anhang nicht behauptet. + */ +export function buildCalendarAttachment(reminder: Reminder, property?: Property | null): CalendarAttachment { + const parts = [...(TYPE_ATTACHMENT_PARTS[reminder.type] ?? ['Objektübersicht'])] + const hasContract = Boolean(property?.leaseContractUrl) + const effective = parts.filter(p => p !== 'Mietvertrag' || hasContract) + + return { + fileName: `${slug(reminder.propertyTitle)}-${slug(REMINDER_TYPE_LABELS[reminder.type] ?? 'reminder')}.pdf`, + description: effective.join(' · '), + sourceUrl: property?.leaseContractUrl, + } +} + +export const calendarService = { + async getStatus(): Promise> { + try { + const [connected, name] = await Promise.all([ + provider.isConnected(), + provider.getConnectionName(), + ]) + return { data: { connected, name } } + } catch (err) { + throwServiceError(err) + } + }, + + async getByReminder(reminderId: string): Promise> { + try { + const data = await provider.getEventsByReminder(reminderId) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + } catch (err) { + throwServiceError(err) + } + }, + + async create(input: CreateCalendarEventInput): Promise> { + try { + if (!input.title.trim()) { + throw new Error('Ein Termin braucht einen Titel.') + } + if (Number.isNaN(new Date(input.startsAt).getTime())) { + throw new Error('Datum und Uhrzeit sind unvollständig.') + } + const data = await provider.create(input) + return { data } + } catch (err) { + throwServiceError(err) + } + }, +} diff --git a/src/services/exposeExport.ts b/src/services/exposeExport.ts new file mode 100644 index 0000000..fdbcd15 --- /dev/null +++ b/src/services/exposeExport.ts @@ -0,0 +1,165 @@ +/** + * Property On — Vorschau und Export der Angebotsbroschüre (Runde 4, §8.5.3). + * + * Bewusst ohne PDF-Bibliothek: der Browser kann drucken und in PDF speichern, + * und Word öffnet HTML mit `.doc`-Endung als bearbeitbares Dokument. Eine + * zusätzliche Abhängigkeit von mehreren hundert Kilobyte brächte für eine + * Broschüre aus Text und Bildern keinen Gegenwert. + * + * CI, Bilder, Texte und Anhänge folgen der Exposé-Konfiguration. + */ + +import type { ExposeDraft } from '../domain/expose' +import { EXPOSE_SECTIONS } from '../lib/exposeFields' +import { EXPOSE_DOCUMENT_TYPE_LABELS } from '../lib/constants' + +/** Firmenfarbe der «Beispiel Immobilien AG», sofern nicht manuell gesetzt. */ +const DEFAULT_BRAND_COLOR = '#152642' + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function paragraphs(text: string): string { + return text + .split('\n') + .filter(line => line.trim() !== '') + .map(line => `

${escapeHtml(line)}

`) + .join('') +} + +export function buildExposeHtml(draft: ExposeDraft): string { + const v = draft.values + const brand = draft.brandColor?.trim() || DEFAULT_BRAND_COLOR + const visibleImages = draft.images.filter(i => i.visible) + const cover = visibleImages.find(i => i.isCover) ?? visibleImages[0] + const gallery = visibleImages.filter(i => i !== cover) + const attachments = draft.documents.filter(d => d.visibleAsAttachment) + + // Alle Bereiche ausser den Texten erscheinen als Faktentabelle; leere Felder + // werden weggelassen statt mit einem Strich gefüllt. + const factRows = EXPOSE_SECTIONS + .filter(s => s.id !== 'texte') + .map(section => { + const rows = section.fields + .filter(f => (v[f.key] ?? '').trim() !== '') + .map(f => `
`) + .join('') + return rows ? `

${escapeHtml(section.title)}

${escapeHtml(f.label)}${escapeHtml(v[f.key])}${f.suffix ? ` ${escapeHtml(f.suffix)}` : ''}
${rows}
` : '' + }) + .join('') + + const textBlocks = [ + ['Kurzbeschrieb', v.kurzbeschrieb], + ['Objektbeschrieb', v.objektbeschrieb], + ['Lagebeschrieb', v.lagebeschrieb], + ['Die Gemeinde', v.gemeindebeschrieb], + ['Ausstattung', v.ausstattungsbeschrieb], + ] + .filter(([, text]) => (text ?? '').trim() !== '') + .map(([title, text]) => `

${escapeHtml(title!)}

${paragraphs(text!)}`) + .join('') + + const highlights = (v.highlights ?? '').trim() + ? `

Highlights

    ${v.highlights.split('\n').filter(Boolean).map(h => `
  • ${escapeHtml(h)}
  • `).join('')}
` + : '' + + const attachmentList = attachments.length + ? `

Anhänge

    ${attachments.map(d => `
  • ${escapeHtml(EXPOSE_DOCUMENT_TYPE_LABELS[d.type] ?? d.type)}: ${escapeHtml(d.title || d.fileName)}
  • `).join('')}
` + : '' + + const contact = draft.contactPerson.trim() + ? `

Kontakt

${escapeHtml(draft.contactPerson)}

` + : '' + + return ` + + + +${escapeHtml(v.exposeTitel || 'Exposé')} + + + +
+

${escapeHtml(v.exposeTitel || 'Exposé')}

+
${escapeHtml([v.strasse, v.hausnummer].filter(Boolean).join(' '))}${v.ort ? `, ${escapeHtml(v.plz ?? '')} ${escapeHtml(v.ort)}` : ''}
+
+${cover ? `
${escapeHtml(cover.caption || v.exposeTitel || '')}
` : ''} +${textBlocks} +${highlights} +${factRows} +${gallery.length ? `

Bilder

` : ''} +${attachmentList} +${contact} + +` +} + +function fileBaseName(draft: ExposeDraft): string { + const title = draft.values.exposeTitel || draft.values.ort || 'expose' + return title + .toLowerCase() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'expose' +} + +/** Vorschau in einem neuen Browser-Tab; von dort lässt sich als PDF drucken. */ +export function openExposePreview(draft: ExposeDraft): boolean { + const win = window.open('', '_blank') + if (!win) return false + win.document.write(buildExposeHtml(draft)) + win.document.close() + return true +} + +function download(content: string, fileName: string, mime: string) { + const blob = new Blob([content], { type: mime }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + a.click() + URL.revokeObjectURL(url) +} + +/** + * PDF-Download über den Druckdialog des Browsers: dort steht «Als PDF + * speichern». Ohne Serverkomponente ist das der ehrlichste Weg — ein Knopf, + * der eine Datei nur so nennt, wäre keiner. + */ +export function downloadExposePdf(draft: ExposeDraft): boolean { + const win = window.open('', '_blank') + if (!win) return false + win.document.write(buildExposeHtml(draft)) + win.document.close() + win.addEventListener('load', () => win.print()) + return true +} + +/** Word öffnet HTML mit `.doc`-Endung als bearbeitbares Dokument. */ +export function downloadExposeWord(draft: ExposeDraft): void { + download(buildExposeHtml(draft), `${fileBaseName(draft)}.doc`, 'application/msword') +} diff --git a/src/services/exposeLeadService.ts b/src/services/exposeLeadService.ts new file mode 100644 index 0000000..815092c --- /dev/null +++ b/src/services/exposeLeadService.ts @@ -0,0 +1,56 @@ +import { MockupExposeLeadProvider } from '../provider/MockupExposeLeadProvider' +import type { CreateExposeLeadInput, ExposeLead } from '../domain/exposeLead' +import type { ItemResponse, ListResponse } from './types' +import { throwServiceError } from './errors' +import { useSessionStore } from '../stores/sessionStore' + +const provider = MockupExposeLeadProvider + +export const exposeLeadService = { + async getAll(): Promise> { + try { + const data = await provider.getAll() + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + } catch (err) { + throwServiceError(err) + } + }, + + async getById(id: string): Promise> { + try { + return { data: await provider.getById(id) } + } catch (err) { + throwServiceError(err) + } + }, + + /** + * Weiterleitung von Nora an Livia. + * + * Ohne mindestens ein Objekt hätte Livia nichts, woraus sie ein Exposé bauen + * könnte — deshalb ist die Objektempfehlung Pflicht und keine Kür. + */ + async create(input: CreateExposeLeadInput): Promise> { + try { + if (input.propertyIds.length === 0) { + throw new Error('Bitte mindestens ein Objekt auswählen.') + } + const user = useSessionStore.getState().currentUser + const data = await provider.create({ + ...input, + forwardedBy: input.forwardedBy ?? user?.name, + }) + return { data } + } catch (err) { + throwServiceError(err) + } + }, + + async archive(id: string): Promise> { + try { + return { data: await provider.archive(id) } + } catch (err) { + throwServiceError(err) + } + }, +} diff --git a/src/services/exposeService.ts b/src/services/exposeService.ts new file mode 100644 index 0000000..633d9b2 --- /dev/null +++ b/src/services/exposeService.ts @@ -0,0 +1,158 @@ +import { MockupExposeProvider } from '../provider/MockupExposeProvider' +import type { ExposeDraft, ExposeImage, ExposeValues } from '../domain/expose' +import { ExposeBranding, ExposeImageCategory } from '../domain/expose' +import type { Property } from '../domain/property' +import type { ItemResponse } from './types' +import { throwServiceError } from './errors' +import { ASSET_TYPE_LABELS, FIT_OUT_LABELS } from '../lib/constants' +import { missingRequiredExposeKeys } from '../lib/exposeFields' +import type { ExposeTonality } from './ai/IAIService' + +const provider = MockupExposeProvider + +function str(value: unknown): string { + if (value === undefined || value === null) return '' + return String(value) +} + +/** + * Vorbelegung aus «Meine Objekte» (Runde 4, §8.5.2). + * + * Es wird nur übernommen, was im Objektdatensatz tatsächlich steht. Felder ohne + * Entsprechung bleiben leer — sie sind der Grund, weshalb das Exposé rote + * Umrandungen zeigt, und genau das soll es. Ein vorbelegtes «ca. 3.0 m + * Raumhöhe» wäre eine Behauptung, keine Angabe. + */ +export function prefillFromProperty(p: Property): ExposeValues { + const hf = p.hardFacts ?? {} + const sf = p.softFactors ?? {} + + return { + objektart: ASSET_TYPE_LABELS[p.assetType] ?? '', + vermarktungsart: 'Miete', + status: 'Aktiv in Vermarktung', + objektreferenz: str(p.propertyNumber), + + strasse: str(p.address.street), + hausnummer: str(p.address.houseNumber), + plz: str(p.address.postalCode), + ort: str(p.address.city), + gemeinde: str(p.location.city), + kanton: str(p.location.canton), + land: str(p.address.country), + + breitengrad: str(p.location.coordinates?.lat), + laengengrad: str(p.location.coordinates?.lng), + + flaeche: str(p.areaSqm), + raumhoehe: str(hf.ceilingHeightM), + + stockwerk: str(hf.floor ?? p.floorLevel), + verfuegbarkeit: p.availabilityDate ? new Date(p.availabilityDate).toLocaleDateString('de-CH') : '', + + nettomiete: str(p.rentPricePerSqm), + nebenkosten: str(p.ancillaryCosts), + parkplaetze: str(hf.parking ?? sf.parkingSpots), + + objektbeschrieb: str(p.description), + merkmale: hf.fitOut ? `Ausbaustandard ${FIT_OUT_LABELS[hf.fitOut] ?? hf.fitOut}` : '', + } +} + +/** + * Objektbilder aus «Meine Objekte» automatisch importieren (§8.5.1). + * Doppelte Bilder werden über die URL ausgeschlossen — dieselbe Datei zweimal + * im Exposé wäre kein Fehler des Nutzers, sondern einer des Imports. + */ +export function importPropertyImages(p: Property): ExposeImage[] { + const seen = new Set() + const images: ExposeImage[] = [] + + for (const url of p.images ?? []) { + if (seen.has(url)) continue + seen.add(url) + const fileName = url.split('/').pop() ?? `bild-${images.length + 1}.jpg` + images.push({ + id: `img-${p.id}-${images.length + 1}`, + url, + fileName, + category: images.length === 0 ? ExposeImageCategory.EXTERIOR : ExposeImageCategory.INTERIOR, + caption: '', + visible: true, + isCover: images.length === 0, + imported: true, + }) + } + + return images +} + +/** Die Angaben, aus denen der Textentwurf schöpfen darf — nichts darüber hinaus. */ +export function textFactsFrom(values: ExposeValues): Record { + const facts: Record = {} + for (const [k, v] of Object.entries(values)) { + if (v && v.trim() !== '') facts[k] = v + } + return facts +} + +function newDraft(leadId: string, property: Property, tonality: ExposeTonality): ExposeDraft { + return { + id: `expose-${leadId}-${property.id}`, + leadId, + propertyId: property.id, + values: prefillFromProperty(property), + tonality, + images: importPropertyImages(property), + documents: [], + branding: ExposeBranding.FROM_PROFILE, + contactPerson: '', + updatedAt: new Date().toISOString(), + } +} + +export const exposeService = { + /** + * Bestehenden Entwurf laden oder einen neuen aus den Objektdaten aufbauen. + * Ein gespeicherter Entwurf gewinnt immer — sonst überschriebe die + * Vorbelegung bei jedem Öffnen die Arbeit des Nutzers. + */ + async getOrCreateDraft( + leadId: string, + property: Property, + tonality: ExposeTonality = 'SACHLICH', + ): Promise> { + try { + const existing = await provider.getDraft(leadId, property.id) + if (existing) return { data: existing } + return { data: await provider.save(newDraft(leadId, property, tonality)) } + } catch (err) { + throwServiceError(err) + } + }, + + async save(draft: ExposeDraft): Promise> { + try { + return { data: await provider.save(draft) } + } catch (err) { + throwServiceError(err) + } + }, + + /** + * Erstellen ist mehr als Speichern: erst hier wird geprüft, ob das Dossier + * vollständig ist. Ein Exposé mit leerem Mietpreis darf nicht in den Export. + */ + async generate(draft: ExposeDraft): Promise> { + try { + const missing = missingRequiredExposeKeys(draft.values) + if (missing.length > 0) { + throw new Error(`Es fehlen noch ${missing.length} Pflichtangabe(n) — sie sind rot umrandet.`) + } + const saved = await provider.save(draft) + return { data: await provider.markGenerated(saved.id) } + } catch (err) { + throwServiceError(err) + } + }, +} diff --git a/src/services/visitAssignmentService.ts b/src/services/visitAssignmentService.ts new file mode 100644 index 0000000..3c3c595 --- /dev/null +++ b/src/services/visitAssignmentService.ts @@ -0,0 +1,54 @@ +import { MockupVisitAssignmentProvider } from '../provider/MockupVisitAssignmentProvider' +import type { VisitAssignment } from '../domain/visitAssignment' +import { VisitRequestType } from '../domain/visitAssignment' +import type { ItemResponse, ListResponse } from './types' +import { throwServiceError } from './errors' + +const provider = MockupVisitAssignmentProvider + +/** Zeitanker der Mockdaten — dieselbe Grundlage wie bei Ferdis Reminderliste. */ +const MOCK_NOW = new Date('2026-05-20T09:00:00.000Z') + +const HOURS_24 = 24 * 60 * 60 * 1000 + +/** + * Wird gewarnt? Nur wenn die Besichtigung in weniger als 24 Stunden stattfindet + * und noch kein Bericht angefordert wurde (Runde 4, §9.3.1). Ein bereits + * angeforderter Bericht braucht keine Erinnerung mehr, ein vergangener Termin + * erst recht nicht. + */ +export function needsReportWarning(assignment: VisitAssignment, now: Date = MOCK_NOW): boolean { + if (assignment.requestType !== VisitRequestType.PREPARATION) return false + if (assignment.preparation?.reportRequested) return false + const delta = new Date(assignment.scheduledAt).getTime() - now.getTime() + return delta > 0 && delta < HOURS_24 +} + +export const visitAssignmentService = { + async getAll(): Promise> { + try { + const data = await provider.getAll() + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + } catch (err) { + throwServiceError(err) + } + }, + + async getPendingReportWarnings(): Promise> { + try { + const all = await provider.getAll() + const data = all.filter(a => needsReportWarning(a)) + return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } } + } catch (err) { + throwServiceError(err) + } + }, + + async requestReport(id: string): Promise> { + try { + return { data: await provider.requestReport(id) } + } catch (err) { + throwServiceError(err) + } + }, +} diff --git a/src/stores/compareStore.ts b/src/stores/compareStore.ts index bcb46bf..a021c8b 100644 --- a/src/stores/compareStore.ts +++ b/src/stores/compareStore.ts @@ -1,7 +1,6 @@ import { create } from 'zustand' import type { UnifiedMatchResult } from '../domain/unifiedResult' - -const MAX_COMPARE_ITEMS = 4 +import { MAX_COMPARE_ITEMS } from '../lib/constants' interface CompareState { compareItems: UnifiedMatchResult[]