7 Commits

Author SHA1 Message Date
Benjamin Sutter e55be64aeb fix(startseite): kein Innenscroll mehr, fünf Karten in einer Reihe
Auf dem Bildschirm des Nutzers waren die oberen Karten oben abgeschnitten —
Porträt und Name fehlten — und rechts lief ein zweiter Scrollbalken innerhalb
des Belegschaftsbereichs. Drei Ursachen, alle aus derselben Wurzel: der
Bereich hatte eine gedeckelte Höhe und durfte in sich scrollen.

1. Feste Höhe entfernt. Der Bereich richtet sich nach seinem Inhalt und hat
   nur noch eine Mindesthöhe. Passte der Inhalt vorher nicht in den Deckel,
   scrollte er in sich selbst und schnitt oben ab — ein Bereich über einer
   ohnehin scrollenden Seite darf das nie.

2. Fünf Karten stehen ab 1200 px in einer Reihe statt in zwei. Zwei Reihen
   brauchten rund 250 px mehr Höhe; genau dieser Betrag drückte Titel und
   Filterbalken aus dem Bild. Eine Reihe ist zugleich das ehrlichere Bild:
   fünf Kolleginnen und Kollegen nebeneinander, keine Hierarchie.

3. `minWidth: 0` auf der Karte. Ohne das kann eine Flexkarte nicht unter ihre
   Inhaltsbreite schrumpfen — die fünf Karten passten rechnerisch in eine
   Reihe, brachen aber trotzdem um.

Dazu: lange deutsche Komposita («Besichtigungsassistent», «Marktbeobachtung»)
brechen ohne Zutun nicht um und liefen aus schmalen Karten heraus. Funktion,
Abteilung, Personalnummer und beide Textblöcke brechen jetzt im Wort statt
abgeschnitten zu werden — eine halbe Funktionsbezeichnung ist keine, und ohne
Personalnummer ist jemand kein Mitarbeitender.

Geprüft über zehn Auflösungen von 1024×700 bis 3440×1440, ausdrücklich auch
die Windows-Skalierungen 125 % und 150 % (effektiv 1536×864 und 1280×720):
nichts abgeschnitten, nirgends Innenscroll, Titel «Meine Objekte» überall im
Bild. Dazu 433 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).

Verbleibend: Bei einer Inhaltshöhe unter 620 px (1280×600, 1024×700) steht der
Balken «Filter & Übersicht» rund 30 px unter der Kante. Der Titel bleibt
sichtbar; der Balken kommt mit der kleinsten Scrollbewegung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:33:12 +02:00
Benjamin Sutter 42a9bece36 fix(startseite): Belegschaft auf grossen Bildschirmen fassen
Auf 2560×1440 sah die Startseite schlecht aus, und der Fehler war meiner: die
Prüfung reichte nur bis 1920 px. Darüber lief nichts mehr auf Anschlag.

Zwei Ursachen:

Die Karten hatten keine Breitengrenze. Bei drei Spalten ohne Deckel wurden sie
auf 2560 px rund 733 px breit bei 207 px Höhe, auf einem Ultrawide sogar
1027 px — flache Streifen, in denen zwei Textzeilen verloren stehen. Der
Inhalt ist jetzt auf 1280 px gefasst und mittig gestellt, die Karten bleiben
damit auf jeder Breite rund 395 px. Der Hintergrund bleibt randlos.

Die Höhe wuchs unbegrenzt mit dem Viewport, während die Karten ihre
Inhaltshöhe behielten. Auf einem 1440er Monitor stand dadurch über und unter
den Karten ein halber Bildschirm leere dunkle Fläche. Die Höhe ist jetzt auf
760 px gedeckelt. Der Titel «Meine Objekte» bleibt sichtbar — er rückt nach
oben, und mehr von der Objektliste wird sichtbar.

Geprüft über neun Auflösungen von 1366×641 bis 3440×1440, jeweils fünf Karten
vollständig sichtbar, keine Überlappung, kein Innenscroll, Titel und
«Filter & Übersicht» im Viewport. Dazu 433 Tests, Typecheck, Build, ESLint,
Token-Schwelle (889/925).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:13:17 +02:00
Benjamin Sutter 093a958dcf feat(startseite): Giorgio entfernt, Belegschaft auf die fünf Agenten
Auf ausdrücklichen Wunsch: Giorgio entfällt vollständig. Das weicht bewusst
von §6 des Auftrags ab, der ihn als Personalverwalter und Vorgesetzten
vorsah — festgehalten, damit die Abweichung später nachvollziehbar bleibt.

Entfernt: Rollendefinition `AGENT_SUPERVISOR`, sein gezeichnetes Porträt, sein
Eintrag in der Auslastung und in der Belegschaftsansicht sowie die Erwähnung
in der Produkttour. Die Personalverwaltung bleibt unverändert über den
Menüpunkt «Meine Agenten» erreichbar — sie brauchte nie eine eigene Person am
Bildschirm.

Mit Giorgio entfällt auch die einzige Kennzahl, die aus den Freigabevorgängen
kam; `computeAgentWorkload` braucht `agentWorkItems` deshalb nicht mehr.

Ordner und Komponenten heissen jetzt nach ihrem Inhalt: `components/staff/`,
`AgentStaffOverview`, `StaffBackdrop`. «Meetingraum» war seit dem Umbau auf
Personenkarten irreführend, und ein falscher Name kostet den Nächsten mehr
Zeit als die Umbenennung.

Fünf statt sechs Karten heisst Flexlayout statt Dreierraster: drei oben, zwei
mittig darunter, gleich breit und ohne Lücke. Die Karten geben ihre Höhe nicht
mehr selbst vor — mit `height: 100%` beanspruchte jede die volle Fläche und
die zweite Zeile fiel aus dem sichtbaren Bereich.

Geprüft: 433 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Browser 1280–1920 px und Tablet: fünf Karten vollständig sichtbar, keine
Überlappung, Titel und «Filter & Übersicht» beim Einstieg sichtbar, alle fünf
Klicks auf der richtigen Subpage, «Meine Agenten» weiterhin erreichbar, keine
Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:03:18 +02:00
Benjamin Sutter 435f2aa2f1 feat(startseite): digitale Belegschaft mit Präsenz, eigener Stimme und Nachweis
Der nachgebaute Meetingraum entfällt. Eine gezeichnete Kulisse bleibt neben
fotografierten Porträts erkennbar gezeichnet — und ein Raum allein macht
niemanden menschlich. Menschlich wirkt im echten Büro dasselbe wie hier: dass
jemand da ist, etwas zu sagen hat und nachweisbar gearbeitet hat.

Die Startseite zeigt deshalb sechs Personenkarten vor einer abgedunkelten
Büroaufnahme. Jede Karte trägt, was ein Personalblatt trägt:

- Porträt mit Dienstpunkt — ist da oder pausiert
- Name, Funktion, Abteilung, Personalnummer
- Autonomiestufe: «Autonom», «Freigabe erforderlich», «Nur Vorschlag».
  Sie sagt, wie viel Verantwortung jemand trägt, und trennt Personal von
  Automatisierung.
- Ein Satz in der ersten Person zur aktuellen Lage, statt einer Kennzahl:
  «Bei 12 Fristen ist der Termin durch. Fürs Eskalieren brauche ich deine
  Freigabe.»
- Die zuletzt protokollierte Handlung mit Zeitangabe. Diese Zeile ist der
  Unterschied zwischen Behauptung und Beleg — eine Kennzahl kann jede
  Software anzeigen.

Darüber fasst ein Anriss zusammen, wie viele Mitarbeitende heute eine
Entscheidung brauchen, samt Dienststand der Belegschaft.

Schichten nach CLAUDE.md §4.2: `agentPresenceService` entscheidet, was als
letzte Handlung gilt und wie eine Zeitangabe in Alltagssprache lautet;
`agentWorkloadService` hält Zeitfenster, Schwellen und Formulierungen. Beide
nehmen den Stichzeitpunkt als Parameter — dieselbe Datenlage ergibt immer
dasselbe Ergebnis und ist ohne Renderer prüfbar (22 Tests).

Giorgio hat kein Personalblatt; Abteilung, Personalnummer und Stufe kommen
aus der zentralen Rollendefinition. Wer kein Dossier hat, gilt als im Dienst —
sonst stünde die halbe Belegschaft still, weil Stammdaten fehlen.

Bild: Unsplash, freie kommerzielle Lizenz. Herkunft und Austauschhinweis in
`src/assets/meeting-room/HERKUNFT.md`.

Geprüft: 435 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Browser 1280–1920 px und Tablet: sechs Karten ohne Überlappung, Titel und
«Filter & Übersicht» beim Einstieg sichtbar, alle sechs Klicks auf der
richtigen Subpage, keine Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:50:16 +02:00
Benjamin Sutter d8f8f728cc refactor(startseite): Meetingraum als Fotoszene mit Live-Auslastung
Die gezeichnete Raumkulisse ist ersetzt. Aus Farbflächen konstruierte Wände
bleiben neben fotografierten Agentenporträts erkennbar gezeichnet — die
Bildwirkung muss aus dem Foto kommen, nicht aus Geometrie. Entfallen sind
damit die beiden SVG-Stadtansichten, die CSS-3D-Flächen und die Farbwelt
DS_MEETING_ROOM.

Neu trägt die Szene die Basler Aussicht aus dem Auftrag als Foto (§5), mit
abgestuftem Verlauf für Lesbarkeit und Übergang zur Objektliste. Ein Austausch
des Motivs ist ein Einzeiler in `roomBackdrop.ts`.

Mehrwert statt Kulisse: Jedes Namensschild zeigt die aktuelle Auslastung
seines Agenten — überfällige Fristen, Termine der Woche, offene Exposés, neue
Marktchancen, Objekte mit Datenlücken, offene Freigaben. Darüber fasst ein
Anriss zusammen, wie viele Bereiche heute Aufmerksamkeit brauchen. Damit
beantwortet die Startseite die Frage, mit der sie geöffnet wird, statt sie
auf sechs Zahlen zu verteilen.

- `agentWorkloadService` hält die fachlichen Festlegungen: Zeitfenster,
  Dringlichkeitsschwellen und Formulierungen. Der Stichzeitpunkt ist ein
  Parameter, nicht die Systemuhr — dieselbe Datenlage ergibt dasselbe
  Ergebnis, und der Dienst ist ohne Renderer prüfbar (10 neue Tests).
- `useAgentWorkload` sammelt nur die Bestände ein.
- Noras Zahl wird nie «dringend»: sie schlägt vor, sie entscheidet nicht.
- Die Schilder bemessen ihre Höchstbreite an der Szenenbreite (`cqw`), nicht
  am Fenster. Am Fenster gemessen überlappten sie auf schmalen Laptops, weil
  die Szene um das Seitenmenü schmaler ist.

Geprüft: 423 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Im Browser 1280–1920 px: keine Überlappungen, Titel und «Filter & Übersicht»
beim Einstieg sichtbar, alle sechs Agentenklicks auf der richtigen Subpage,
Tablet-Ansicht mit allen sechs Agenten, keine Konsolenfehler, keine 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:07:21 +02:00
Benjamin Sutter 9ed21e33a7 feat(runde-5): Startseite mit Agenten-Meetingraum, sticky Chat und Noras Kanäle
Hauptnavigation und Startseite (§2)
- Hauptseite «Übersicht» samt Route, Menüeintrag, Hook, Service und den nur
  dort verwendeten Widgets entfernt; /supply/dashboard leitet auf die
  Startseite um, damit Lesezeichen nicht ins Leere laufen.
- «Meine Objekte» heisst im Menü «Startseite»; die frühere Überschrift
  «Objektverwaltung» heisst jetzt «Meine Objekte».
- Die Startseite scrollt als Ganzes. Die Höhe des Meetingraums rechnet gegen
  die Viewport-Höhe, damit Titel und Balken «Filter & Übersicht» beim Einstieg
  auf jeder Monitorgrösse sichtbar bleiben (geprüft 1280–1920 px).

Agenten-Meetingraum (§3)
- Zentralperspektive aus clip-path-Flächen, ohne 3D-Engine und ohne
  Animationsbibliothek: rechts geschlossene Wand, links und gegenüber
  boden- bis deckenhohe Glasfronten mit Blick auf Rhein, Basler Münster,
  Altstadt und Wettsteinbrücke.
- Sitzordnung, Zielrouten, Grössen und Plakettenversatz liegen in einer
  zentralen Konfiguration (meetingRoomSeats.ts).
- Neuer Agent Giorgio, Personalverwalter, sitzt vis-à-vis und führt in
  «Meine Agenten». Er steht bewusst nicht in AGENT_WORKSPACES: er hat keine
  Arbeitsseite, sondern ist die Adminfläche selbst.
- Auf schmalen Schirmen tritt eine vereinfachte Kachelanordnung an die Stelle
  der Perspektive; ausgeblendet wird kein Agent.

Sticky Chat bei allen Agenten (§5, §9–§11)
- Scrollt der Chatkopf unter den globalen Balken, übernimmt dort eine kompakte
  Fassung mit Porträt, Name, Frage und Eingabefeld.
- Ein gemeinsamer Zustand (agentChatStore) statt zweier Chat-Instanzen:
  Eingabetext und Fokus überstehen den Wechsel in beide Richtungen.
- Noras Seite scrollte bisher gar nicht — Chatkopf ausserhalb des
  Scrollbereichs, zwei getrennt scrollende Spalten darunter. Behoben über den
  Seitenaufbau, nicht über eine Nora-Sonderlösung.

Nora: CRM-Kanal und gemeinsame Lead-Liste (§12, §13)
- Dritter Kanal «CRM» mit eigenem Domänentyp, Mockdaten, Provider, Service und
  Hook nach dem bestehenden Schichtenmuster.
- Die Reiter «KI-Signale»/«Netzwerk» sind einer gemeinsamen Liste gewichen.
  Jede Zeile trägt ihre Kanalherkunft als Badge; die Herkunft steht im
  Datenmodell (LeadChannel) und wird nicht aus der Darstellung geraten.
- Der frühere Reiterbalken ist der Filter «Kanaltyp» mit Alle/KI Signal/
  Netzwerk/CRM, Standard «Alle», mit Empty State und Rücksetzung.

Produkttour und Profilmenü (§8)
- Tour auf die neue Struktur umgeschrieben: Startseite, Meetingraum, Agenten,
  sticky Chat, Leadkanäle. «Übersicht» kommt nicht mehr vor, «Meine Objekte»
  bezeichnet nur noch den Objektbereich.
- Bereich «Demo-Modus» mit «Verwaltung» und «Bürosuche» entfernt.

Geprüft: 413 Tests, Typecheck, Build, ESLint, Token-Schwelle (889/925).
Im Browser: Navigation, Agentenklicks aller sechs Plätze, Kanalfilter und
Detailschubladen, Sticky Chat bei vier Agenten, 1280–1920 px sowie
Tablet- und Mobilbreite, keine 404 für Bilder oder Routen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:44:22 +02:00
Benjamin Sutter d200d4e930 feat(agenten): Runde 4 — «Meine Agenten» mit fünf digitalen Mitarbeitenden
Setzt das Umsetzungsbriefing Runde 4 im bestehenden Frontend um.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:43:16 +02:00
399 changed files with 10685 additions and 9640 deletions
+13
View File
@@ -20,6 +20,19 @@ export default defineConfig([
languageOptions: {
globals: globals.browser,
},
rules: {
// Der Unterstrich ist im Projekt die etablierte Kennzeichnung für
// bewusst ungenutzte Parameter — etwa wenn eine Schnittstelle ein
// Argument vorschreibt, das eine bestimmte Implementierung nicht
// braucht. Ohne diese Ausnahme meldete die Regel genau die Fälle,
// die der Autor bereits als «gewollt» markiert hat.
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
}],
},
},
// ── Design Token Enforcement ────────────────────────────────────────────────
// New hardcoded hex colors in components/pages are blocked (error).
+5 -1
View File
@@ -18,7 +18,11 @@ import { join, extname } from 'node:path'
// Hex literals allowed before CI blocks the build.
// This is a ratchet — lower it as migration progresses. Never raise it.
// Baseline after initial token migration (2026-05-24): 1958
const THRESHOLD = 1958
// Nach der Token-Migration aller Farb-Properties (bgcolor/color/borderColor/
// background/fill/stroke): 925. Die verbleibenden Treffer stehen in
// Farbverläufen, `rgba()`-Werten, Icon-Attributen und Datentabellen, die diese
// Zählung mitnimmt, die ESLint-Regel aber nicht erfasst.
const THRESHOLD = 925
const HEX_PATTERN = /#[0-9A-Fa-f]{3,8}\b/g
+44 -24
View File
@@ -5,21 +5,22 @@ 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.
// Seit Runde 5 ist das die Startseite; die frühere «Übersicht» existiert nicht mehr.
const WORKSPACE_HOME: Record<string, string> = {
[WorkspaceType.SUPPLY]: '/supply/dashboard',
[WorkspaceType.SUPPLY]: ROUTES.SUPPLY.PROPERTIES,
}
function RoleRedirect() {
const currentUser = useSessionStore(s => s.currentUser)
const first = currentUser?.allowedWorkspaces[0] ?? WorkspaceType.SUPPLY
return <Navigate to={WORKSPACE_HOME[first] ?? '/supply/dashboard'} replace />
return <Navigate to={WORKSPACE_HOME[first] ?? ROUTES.SUPPLY.PROPERTIES} replace />
}
const LoginScreen = lazy(() => import('./pages/auth/LoginScreen'))
const SupplyDashboard = lazy(() => import('./pages/supply/SupplyDashboard'))
const Properties = lazy(() => import('./pages/supply/Properties'))
const MatchCenter = lazy(() => import('./pages/supply/MatchCenter'))
const FutureAvailability = lazy(() => import('./pages/supply/FutureAvailability'))
@@ -28,12 +29,15 @@ const ReminderManager = lazy(() => import('./pages/supply/ReminderManager'))
const MarketIntelligence = lazy(() => import('./pages/supply/MarketIntelligence'))
const NewListing = lazy(() => import('./pages/supply/NewListing'))
const MyListings = lazy(() => import('./pages/supply/MyListings'))
const Besichtigungen = lazy(() => import('./pages/supply/Besichtigungen'))
// Property On — «Teamübersicht»
const Teamuebersicht = lazy(() => import('./pages/supply/Teamuebersicht'))
const Personalverwaltung = lazy(() => import('./pages/supply/Personalverwaltung'))
const Bearbeitungsverlauf = lazy(() => import('./pages/supply/Bearbeitungsverlauf'))
const KanaeleSysteme = lazy(() => import('./pages/supply/KanaeleSysteme'))
// Property On — «Meine Agenten»
const MeineAgenten = lazy(() => import('./pages/supply/MeineAgenten'))
/** Alter Unterseitenpfad → Reiter der Hauptseite. Hält Lesezeichen am Leben. */
function TeamSectionRedirect({ section }: { section: string }) {
return <Navigate to={`${ROUTES.SUPPLY.TEAM}?${AGENT_SECTION_PARAM}=${section}`} replace />
}
function App() {
return (
@@ -50,27 +54,43 @@ function App() {
{/* Supply Workspace */}
<Route element={<ProtectedRoute workspace={WorkspaceType.SUPPLY} />}>
<Route path="/supply/dashboard" element={<SupplyDashboard />} />
{/* Runde 5, §2.1: Die Hauptseite «Übersicht» ist entfernt. Der
alte Pfad bleibt als Umleitung bestehen, damit Lesezeichen
und Deep Links auf der Startseite landen statt im Nichts. */}
<Route
path={ROUTES.SUPPLY.LEGACY_DASHBOARD}
element={<Navigate to={ROUTES.SUPPLY.PROPERTIES} replace />}
/>
<Route path="/supply/properties" element={<Properties />} />
{/* Objekt-Detailroute — Ziel aller Objektlinks der Agentenseiten. */}
<Route path="/supply/properties/:propertyId" element={<Properties />} />
<Route path="/supply/match-center" element={<MatchCenter />} />
<Route path="/supply/future-availability" element={<FutureAvailability />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/new-listing" element={<NewListing />} />
<Route path="/supply/my-listings" element={<MyListings />} />
{/* Property On — Hierarchie und Deep-Links bleiben erhalten (§3.4).
Bewusst flache Geschwisterrouten statt <Outlet/>: die App kennt
keine einzige Feature-Route mit eigenem Outlet, ein Novum hier
würde Sidebar, Seitentitel und Guards gleichzeitig betreffen. */}
<Route path="/supply/team" element={<Teamuebersicht />} />
<Route path="/supply/team/personalverwaltung" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId" element={<Personalverwaltung />} />
<Route path="/supply/team/personalverwaltung/:agentId/:tab" element={<Personalverwaltung />} />
<Route path="/supply/team/bearbeitungsverlauf" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/bearbeitungsverlauf/:tab" element={<Bearbeitungsverlauf />} />
<Route path="/supply/team/kanaele-systeme" element={<KanaeleSysteme />} />
{/* Die fünf Agentenseiten. Vier behalten den Pfad ihres Vorgängers. */}
<Route path="/supply/reminder-manager" element={<ReminderManager />} />
<Route path="/supply/besichtigungen" element={<Besichtigungen />} />
<Route path="/supply/my-listings" element={<MyListings />} />
<Route path="/supply/market-intelligence" element={<MarketIntelligence />} />
<Route path="/supply/data-quality" element={<DataQuality />} />
{/* Property On — «Meine Agenten». Die drei Verwaltungsbereiche sind
seit Runde 4 Reiter derselben Seite; die alten Unterseitenpfade
leiten dorthin um, damit bestehende Lesezeichen nicht brechen. */}
<Route path="/supply/team" element={<MeineAgenten />} />
<Route
path="/supply/team/personalverwaltung/*"
element={<TeamSectionRedirect section={AGENT_SECTIONS.PERSONNEL} />}
/>
<Route
path="/supply/team/bearbeitungsverlauf/*"
element={<TeamSectionRedirect section={AGENT_SECTIONS.HISTORY} />}
/>
<Route
path="/supply/team/kanaele-systeme"
element={<TeamSectionRedirect section={AGENT_SECTIONS.CHANNELS} />}
/>
</Route>
</Route>
</Route>
+14
View File
@@ -0,0 +1,14 @@
# Bildherkunft
| Datei | Quelle | Lizenz |
|---|---|---|
| `buero.jpg` | Unsplash — Craig Lovelidge, «An empty conference room with desks and chairs» (The Mark, Rotterdam) | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig |
Direktbezug: `https://images.unsplash.com/photo-1637665599155-abfcf9fc3f78`
Das Bild dient als abgedunkelte Umgebung hinter den Personenkarten der
Startseite. Es wird nie unbearbeitet gezeigt — der Verlauf in `roomBackdrop.ts`
nimmt ihm so viel Kontrast, dass die Porträts und die Schrift davor tragen.
**Austausch:** Datei ersetzen, Dateinamen beibehalten. Eine eigene Büroaufnahme
ist jederzeit vorzuziehen — sie zeigt den echten Arbeitsplatz der Belegschaft.
Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

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

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