d200d4e930
Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um. Informationsarchitektur - «Teamübersicht» heisst «Meine Agenten»; ihr bisheriger Inhalt (Kreisgrafik, Auswertung, Verbindungszusammenfassung) ist entfallen. - Personalverwaltung, Bearbeitungsverlauf und Kanäle & Systeme sind jetzt Reiter derselben Seite (?section=), gestaltet wie die Auswahl im Bearbeitungsverlauf. Es wird nur der gewählte Bereich gerendert. - Die alten Unterseitenpfade leiten um, damit Lesezeichen nicht brechen. Agentenbestand - Reto und Lea vollständig entfernt — aus Navigation, Dossiers, Protokoll, Verbindungen, Vorgängen und Porträtbestand. - Retos Aufgaben liegen bei Bruno: Nachbereitung, WhatsApp-Anruf, Protokoll und Kundennotiz, Ablage im CRM. - Livia ist «Exposé Master»: Lageberichte, Inserate, Angebotsbroschüren. - Sidebar führt Ferdi, Bruno, Livia, Nora, Sina mit Porträt und Funktion. Agentenseiten - Ein gemeinsamer AgentWorkspaceHero auf allen fünf Seiten. - Ferdi: Auswertungskarten, Priorität und Typfarben entfallen; Fälligkeit nur bei fünf Tagen oder weniger rot; Objektlinks nach «Meine Objekte»; neu die Terminplanung im verbundenen Kalender mit typgerechtem PDF-Ausschnitt. - Sina: reduzierte, filter- und sortierbare Objektübersicht; Detailansicht direkt editierbar, leere Pflichtfelder rot umrandet. - Nora: Signale ohne Prozentsätze und Konfidenzstufen, nur belegbare Angaben; Mehrfachauswahl leitet Objekte an Livia weiter. - Livia: aktive und archivierte Leads, Arbeitsbereich gleitet an den oberen Rand; dreistufiger Exposé-Prozess Hochladen → Exposé → Export. - Bruno: neue Seite mit Auftragsliste und Vor-/Nachbereitungs-Drawer; Glocke warnt bei Besichtigung unter 24 Stunden ohne Bericht. Datenschicht - Neu: Kalender, Exposé-Leads, Exposé-Entwürfe, Besichtigungsaufträge — je Domain, Provider, Service und Hook. - IAIService um generateExposeText erweitert; der Entwurf nutzt ausschliesslich erfasste Objektdaten und meldet Lücken, statt sie zu füllen. - Alle Objektverweise zeigen auf reale Einträge aus «Meine Objekte»; neue Detailroute /supply/properties/:propertyId. Offen: Chat, Kalender, CRM und DMS sind Frontend-Simulation ohne Anbindung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
import { Box, Button, Chip, CircularProgress, Divider, IconButton, LinearProgress, Paper, Typography } from '@mui/material'
|
|
import { X } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { SignalTypeBadge } from './SignalTypeBadge'
|
|
import { SensitivityBadge } from './SensitivityBadge'
|
|
import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
|
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
|
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
|
|
import { useCreateReviewTask } from '../../hooks/useReviewQueue'
|
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
|
import { useToastStore } from '../../stores/toastStore'
|
|
import { ReviewStatus } from '../../domain/enums'
|
|
import type { FutureSignal } from '../../domain/futureSignal'
|
|
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
|
import { DS_ACCENT, DS_SLATE } from '../../lib/ds'
|
|
|
|
|
|
|
|
const CREDIBILITY_META: Record<string, { label: string; color: string }> = {
|
|
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 }) {
|
|
const color = value >= 0.75 ? '#1a7a4a' : value >= 0.55 ? '#d97706' : '#c0392b'
|
|
return (
|
|
<Box sx={{ mb: 1.5 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
|
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color }}>{Math.round(value * 100)}%</Typography>
|
|
</Box>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={value * 100}
|
|
sx={{ height: 5, borderRadius: 2, bgcolor: DS_SLATE[100], '& .MuiLinearProgress-bar': { bgcolor: color } }}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
interface Props {
|
|
signal: FutureSignal
|
|
onClose: () => void
|
|
}
|
|
|
|
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
|
const updateStatus = useUpdateSignalReviewStatus()
|
|
const createReviewTask = useCreateReviewTask()
|
|
const { openAddDialog } = useShortlistStore()
|
|
const showToast = useToastStore((s) => s.showToast)
|
|
const [reviewTaskSent, setReviewTaskSent] = useState(false)
|
|
|
|
const reviewStatus = signal.reviewStatus ?? ReviewStatus.UNREVIEWED
|
|
const isRejected = reviewStatus === ReviewStatus.REJECTED
|
|
const isApproved = reviewStatus === ReviewStatus.APPROVED
|
|
|
|
const STATUS_TOAST: Record<string, string> = {
|
|
IN_REVIEW: 'Signal zur Prüfung markiert.',
|
|
APPROVED: 'Signal genehmigt.',
|
|
REJECTED: 'Signal abgelehnt.',
|
|
FLAGGED: 'Signal markiert.',
|
|
}
|
|
|
|
async function handleStatus(status: typeof ReviewStatus[keyof typeof ReviewStatus]) {
|
|
try {
|
|
await updateStatus.mutateAsync({ id: signal.id, status })
|
|
showToast(STATUS_TOAST[status] ?? 'Status aktualisiert.')
|
|
} catch {
|
|
showToast('Statusänderung fehlgeschlagen.', 'error')
|
|
}
|
|
}
|
|
|
|
function handleSendReview() {
|
|
createReviewTask.mutate(signal.id, {
|
|
onSuccess: () => {
|
|
setReviewTaskSent(true)
|
|
showToast('Prüfungsaufgabe erstellt.')
|
|
},
|
|
})
|
|
}
|
|
|
|
function handleShortlist() {
|
|
openAddDialog({
|
|
resultId: signal.id,
|
|
resultType: 'FUTURE_AVAILABILITY',
|
|
title: signal.title ?? signal.companyName ?? signal.locationHint,
|
|
matchScore: Math.round(signal.confidenceScore * 100),
|
|
confidenceScore: signal.confidenceScore,
|
|
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: DS_SLATE[500] }
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
|
{/* Header */}
|
|
<Box sx={{ px: 2.5, py: 2, borderBottom: '1px solid #e2e8f0', bgcolor: 'white', flexShrink: 0 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', mb: 1 }}>
|
|
<Box sx={{ flex: 1, mr: 1 }}>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
|
|
{signal.title ?? signal.companyName ?? signal.locationHint}
|
|
</Typography>
|
|
{(signal.title || signal.companyName) && (
|
|
<Typography variant="caption" color="text.secondary">{signal.locationHint}</Typography>
|
|
)}
|
|
</Box>
|
|
<IconButton size="small" onClick={onClose} sx={{ color: DS_SLATE[400], mt: -0.5 }}>
|
|
<X size={16} />
|
|
</IconButton>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
|
<SignalTypeBadge type={signal.signalType} />
|
|
<SensitivityBadge level={signal.sensitivityLevel} />
|
|
<SignalReviewStatusBadge status={signal.reviewStatus} />
|
|
{signal.isVerified && (
|
|
<Chip label="Verifiziert" size="small" sx={{ bgcolor: DS_ACCENT.success.main, color: 'white', fontSize: 10 }} />
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Body */}
|
|
<Box sx={{ flex: 1, overflowY: 'auto', p: 2.5 }}>
|
|
<FutureSignalDisclaimer />
|
|
|
|
{/* Probability + confidence */}
|
|
<BarRow label="Wahrscheinlichkeit" value={signal.probability} />
|
|
<BarRow label="Konfidenz" value={signal.confidenceScore} />
|
|
|
|
<Divider sx={{ my: 1.5 }} />
|
|
|
|
{/* Meta */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Zeithorizont</Typography>
|
|
<Typography variant="caption">~{signal.timeHorizonMonths} Monate</Typography>
|
|
</Box>
|
|
{signal.areaSqmEstimate && (
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Geschätzte Fläche</Typography>
|
|
<Typography variant="caption">~{signal.areaSqmEstimate.toLocaleString('de-CH')} m²</Typography>
|
|
</Box>
|
|
)}
|
|
{signal.companyName && (
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Unternehmen</Typography>
|
|
<Typography variant="caption">{signal.companyName}</Typography>
|
|
</Box>
|
|
)}
|
|
{signal.riskLevel && (
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Risikoniveau</Typography>
|
|
<Typography variant="caption">{signal.riskLevel}</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Divider sx={{ my: 1.5 }} />
|
|
|
|
{/* Source */}
|
|
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Quelle</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Typ</Typography>
|
|
<Typography variant="caption">{SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type}</Typography>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Glaubwürdigkeit</Typography>
|
|
<Chip label={credMeta.label} size="small" sx={{ bgcolor: credMeta.color, color: 'white', fontSize: 10, height: 18 }} />
|
|
</Box>
|
|
{signal.source.publishedAt && (
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 140 }}>Veröffentlicht</Typography>
|
|
<Typography variant="caption">{signal.source.publishedAt}</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Evidence */}
|
|
{signal.evidence?.summary && (
|
|
<>
|
|
<Divider sx={{ my: 1.5 }} />
|
|
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.75 }}>Evidenz</Typography>
|
|
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: DS_SLATE[50] }}>
|
|
<Typography variant="body2" color="text.secondary">{signal.evidence.summary}</Typography>
|
|
</Paper>
|
|
</>
|
|
)}
|
|
|
|
{/* Market indicator */}
|
|
{signal.marketIndicator && (
|
|
<>
|
|
<Divider sx={{ my: 1.5 }} />
|
|
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 0.5 }}>Marktindikator</Typography>
|
|
<Typography variant="body2" color="text.secondary">{signal.marketIndicator}</Typography>
|
|
</>
|
|
)}
|
|
|
|
<Divider sx={{ my: 1.5 }} />
|
|
|
|
{/* Actions */}
|
|
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }}>Aktionen</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
{reviewStatus === ReviewStatus.UNREVIEWED && (
|
|
<Button
|
|
fullWidth size="small" variant="outlined"
|
|
disabled={updateStatus.isPending}
|
|
onClick={() => handleStatus(ReviewStatus.IN_REVIEW)}
|
|
sx={{ justifyContent: 'flex-start' }}
|
|
>
|
|
Verfolgen (In Prüfung setzen)
|
|
</Button>
|
|
)}
|
|
{!isRejected && !reviewTaskSent && (
|
|
<Button
|
|
fullWidth size="small" variant="outlined"
|
|
onClick={handleSendReview}
|
|
sx={{ justifyContent: 'flex-start', color: DS_ACCENT.warning.main, borderColor: DS_ACCENT.warning.main }}
|
|
>
|
|
Zur Prüfung senden
|
|
</Button>
|
|
)}
|
|
{(reviewStatus === ReviewStatus.IN_REVIEW || reviewStatus === ReviewStatus.FLAGGED) && !isApproved && (
|
|
<Button
|
|
fullWidth size="small" variant="contained"
|
|
disabled={updateStatus.isPending}
|
|
onClick={() => handleStatus(ReviewStatus.APPROVED)}
|
|
sx={{ bgcolor: DS_ACCENT.success.main, '&:hover': { bgcolor: DS_ACCENT.success.darkAlt }, justifyContent: 'flex-start' }}
|
|
endIcon={updateStatus.isPending ? <CircularProgress size={14} color="inherit" /> : undefined}
|
|
>
|
|
Genehmigen
|
|
</Button>
|
|
)}
|
|
{!isRejected && (
|
|
<Button
|
|
fullWidth size="small" variant="outlined"
|
|
disabled={updateStatus.isPending}
|
|
onClick={() => handleStatus(ReviewStatus.REJECTED)}
|
|
sx={{ justifyContent: 'flex-start', color: DS_ACCENT.danger.main, borderColor: DS_ACCENT.danger.main }}
|
|
>
|
|
Ablehnen
|
|
</Button>
|
|
)}
|
|
<Button
|
|
fullWidth size="small" variant="outlined"
|
|
onClick={handleShortlist}
|
|
sx={{ justifyContent: 'flex-start' }}
|
|
>
|
|
Zu Shortlist hinzufügen
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
|
|
</Box>
|
|
)
|
|
}
|