Quellcode des ICON CRM (4)
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { searchApi, type AiAnswer, type SearchGroup } from '../lib/api';
|
||||
import { useNavigation, type OpenTarget } from '../lib/navigation';
|
||||
import { formatStamp } from '../types';
|
||||
import { Avatar } from './ui/Avatar';
|
||||
|
||||
/*
|
||||
* Globale Suche.
|
||||
*
|
||||
* Ein Feld fuer das ganze CRM: Kontakte, Unternehmen, Ausschreibungen,
|
||||
* Projekte, Leads und die Historie. Ein Treffer oeffnet direkt die Karte.
|
||||
*
|
||||
* Die Tastenkombination Strg+K (bzw. Cmd+K) springt von ueberall hierher -
|
||||
* das ist die Geste, die Benutzer aus anderen Werkzeugen mitbringen.
|
||||
*/
|
||||
|
||||
const OPENABLE: Record<string, OpenTarget | null> = {
|
||||
contact: 'contact',
|
||||
company: 'company',
|
||||
tender: 'tender',
|
||||
project: 'project',
|
||||
lead: 'lead',
|
||||
history: null,
|
||||
// Kontakte aus dem perinco CRM haben hier keine Karte - sie werden drueben
|
||||
// gepflegt. Der Treffer zeigt sie, oeffnet aber nichts.
|
||||
perinco: null,
|
||||
};
|
||||
|
||||
export const GlobalSearch: React.FC = () => {
|
||||
const { open } = useNavigation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [term, setTerm] = useState('');
|
||||
const [groups, setGroups] = useState<SearchGroup[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
/*
|
||||
* Zwei Modi im selben Feld:
|
||||
*
|
||||
* Lupe die gewohnte Suche über alle Bereiche - sie läuft beim Tippen mit
|
||||
* KI eine Frage in Worten ("Wer arbeitet alles bei der KfW?"), erst auf
|
||||
* Knopfdruck bzw. mit Enter, weil jede Anfrage das Modell kostet
|
||||
*
|
||||
* Getrennt gehalten, weil sie sich unterschiedlich verhalten: die eine
|
||||
* antwortet sofort und ständig, die andere einmal und mit Bedacht.
|
||||
*/
|
||||
const [mode, setMode] = useState<'text' | 'ai'>('text');
|
||||
const [answer, setAnswer] = useState<AiAnswer | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Klick ausserhalb schliesst die Trefferliste - dieselbe Erwartung wie beim
|
||||
// Hintergrundklick auf ein Dialogfenster.
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!boxRef.current?.contains(event.target as Node)) setFocused(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const q = term.trim();
|
||||
if (mode !== 'text' || q.length < 2) {
|
||||
setGroups([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setBusy(true);
|
||||
// Kurze Verzoegerung: sonst laeuft je Tastenanschlag eine Abfrage ueber
|
||||
// sechs Tabellen.
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await searchApi.search(q);
|
||||
if (!cancelled) setGroups(result.groups);
|
||||
} catch {
|
||||
if (!cancelled) setGroups([]);
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
}, 220);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [term, mode]);
|
||||
|
||||
const ask = async () => {
|
||||
const question = term.trim();
|
||||
if (question.length < 3) return;
|
||||
setBusy(true);
|
||||
setAiError(null);
|
||||
setAnswer(null);
|
||||
try {
|
||||
setAnswer(await searchApi.ask(question));
|
||||
} catch (err) {
|
||||
setAiError(err instanceof Error ? err.message : 'Die KI hat nicht geantwortet.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const choose = (type: string, item: Record<string, unknown>) => {
|
||||
const target = OPENABLE[type];
|
||||
if (target) {
|
||||
open(target, Number(item.id));
|
||||
} else if (type === 'history' && item.entity_type && item.entity_id) {
|
||||
const entity = OPENABLE[String(item.entity_type)];
|
||||
if (entity) open(entity, Number(item.entity_id));
|
||||
}
|
||||
setFocused(false);
|
||||
setTerm('');
|
||||
};
|
||||
|
||||
const showResults = mode === 'text' && focused && term.trim().length >= 2;
|
||||
const showAnswer = mode === 'ai' && focused && (busy || answer !== null || aiError !== null);
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative w-full max-w-xl">
|
||||
<div className="relative flex items-center gap-1 rounded-lg border border-ink-200 bg-canvas pr-1">
|
||||
<span className="pl-2 text-ink-400">
|
||||
{mode === 'text' ? (
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9 3a6 6 0 1 0 3.7 10.7l3.3 3.3a1 1 0 0 0 1.4-1.4l-3.3-3.3A6 6 0 0 0 9 3Zm0 2a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4 text-brand-600" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 1.6l1.5 3.9 3.9 1.5-3.9 1.5L10 12.4 8.5 8.5 4.6 7l3.9-1.5L10 1.6ZM15.5 12l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8.8-2Z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setTerm('');
|
||||
setAnswer(null);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
if (event.key === 'Enter' && mode === 'ai') void ask();
|
||||
}}
|
||||
placeholder={
|
||||
mode === 'text'
|
||||
? 'Alles durchsuchen – Kontakte, Unternehmen, Verträge, Historie … (Strg+K)'
|
||||
: 'Frage stellen, z. B. „Wer arbeitet alles bei der KfW?" – Enter'
|
||||
}
|
||||
className="min-w-0 flex-1 bg-transparent py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400"
|
||||
/>
|
||||
|
||||
{/* Umschalter: Lupe = Volltext, Funke = Frage an die KI. */}
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('text');
|
||||
setAnswer(null);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
aria-pressed={mode === 'text'}
|
||||
title="Normale Suche"
|
||||
className={`rounded-md p-1.5 ${
|
||||
mode === 'text' ? 'bg-surface text-ink-800 shadow-card' : 'text-ink-400 hover:text-ink-700'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9 3a6 6 0 1 0 3.7 10.7l3.3 3.3a1 1 0 0 0 1.4-1.4l-3.3-3.3A6 6 0 0 0 9 3Zm0 2a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode('ai');
|
||||
setGroups([]);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
aria-pressed={mode === 'ai'}
|
||||
title="Erweiterte Suche – Frage in Worten, beantwortet von der KI"
|
||||
className={`rounded-md p-1.5 ${
|
||||
mode === 'ai' ? 'bg-surface text-brand-700 shadow-card' : 'text-ink-400 hover:text-brand-700'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 1.6l1.5 3.9 3.9 1.5-3.9 1.5L10 12.4 8.5 8.5 4.6 7l3.9-1.5L10 1.6ZM15.5 12l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8.8-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showAnswer && (
|
||||
<div className="absolute z-40 mt-2 max-h-[70vh] w-full overflow-y-auto rounded-xl border border-ink-200 bg-surface p-4 shadow-lift scrollbar-slim">
|
||||
{busy ? (
|
||||
<p className="text-sm text-ink-400">Die KI liest den Bestand …</p>
|
||||
) : aiError ? (
|
||||
<p className="text-sm text-red-600">{aiError}</p>
|
||||
) : (
|
||||
answer && (
|
||||
<>
|
||||
<p className="whitespace-pre-wrap text-sm text-ink-800">{answer.answer}</p>
|
||||
|
||||
{answer.refs.length > 0 && (
|
||||
<ul className="mt-3 flex flex-wrap gap-1.5">
|
||||
{answer.refs.map((ref) => (
|
||||
<li key={`${ref.type}-${ref.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = OPENABLE[ref.type];
|
||||
if (target) open(target, ref.id);
|
||||
setFocused(false);
|
||||
}}
|
||||
className="rounded-full border border-brand-200 bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700 hover:bg-brand-100"
|
||||
>
|
||||
{ref.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-ink-400">
|
||||
Antwort von {answer.model} auf Grundlage des CRM-Bestands.
|
||||
{answer.truncated && ' Der Bestand wurde für die Anfrage gekürzt.'}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showResults && (
|
||||
<div className="absolute z-40 mt-2 max-h-[70vh] w-full overflow-y-auto rounded-xl border border-ink-200 bg-surface shadow-lift scrollbar-slim">
|
||||
{groups.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-ink-400">
|
||||
{busy ? 'Wird gesucht …' : 'Nichts gefunden.'}
|
||||
</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<section key={group.type}>
|
||||
<h3 className="field-label sticky top-0 bg-ink-50 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li key={`${group.type}-${item.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose(group.type, item)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-brand-50"
|
||||
>
|
||||
{group.type === 'history' ? (
|
||||
<span className="w-28 shrink-0 text-xs text-ink-400">
|
||||
{formatStamp(String(item.created_at ?? ''))}
|
||||
</span>
|
||||
) : (
|
||||
<Avatar
|
||||
name={String(item.title ?? '?')}
|
||||
src={(item.avatar ?? item.logo) as string | null}
|
||||
size={28}
|
||||
square={group.type === 'company'}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-ink-900">
|
||||
{String(item.title ?? item.entity_label ?? item.text ?? '–')}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-ink-400">
|
||||
{describe(group.type, item)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function describe(type: string, item: Record<string, unknown>): string {
|
||||
const text = (key: string) => (item[key] ? String(item[key]) : '');
|
||||
switch (type) {
|
||||
case 'contact':
|
||||
return [text('position'), text('company'), text('status')].filter(Boolean).join(' · ');
|
||||
case 'company':
|
||||
return [text('industry'), text('group_name'), item.is_customer ? 'Kunde' : ''].filter(Boolean).join(' · ');
|
||||
case 'tender':
|
||||
return [text('company'), text('stage'), text('status')].filter(Boolean).join(' · ');
|
||||
case 'project':
|
||||
return [text('company'), text('contact'), text('status')].filter(Boolean).join(' · ');
|
||||
case 'lead':
|
||||
return [text('company'), text('contact'), text('stage')].filter(Boolean).join(' · ');
|
||||
case 'perinco':
|
||||
return [text('position'), text('company'), 'aus dem perinco CRM'].filter(Boolean).join(' · ');
|
||||
case 'history':
|
||||
return [text('text'), text('user_name')].filter(Boolean).join(' · ');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { THEME_PALETTES, THEME_PALETTE_LABELS, type View } from '../types';
|
||||
import { GlobalSearch } from './GlobalSearch';
|
||||
import { useTheme } from '../lib/theme';
|
||||
|
||||
interface HeaderProps {
|
||||
view: View;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const SUN =
|
||||
'M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Zm0-5v3m0 14v3M2 12h3m14 0h3M4.2 4.2l2.1 2.1m11.4 11.4 2.1 2.1M19.8 4.2l-2.1 2.1M6.3 17.7l-2.1 2.1';
|
||||
const MOON = 'M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10Z';
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ view, onToggleSidebar }) => {
|
||||
const { theme, setTheme, mode, toggleMode, resetMode, followsSystem } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
/*
|
||||
* Sicherheitsnetz: kennt die Liste den Namen nicht, darf der Kopf trotzdem
|
||||
* nicht mit einer TypeError-Ausnahme aussteigen - er trägt die ganze
|
||||
* Anwendung. Ein entfernter Wert hat genau das schon einmal getan.
|
||||
*/
|
||||
const label = THEME_PALETTE_LABELS[theme] ?? THEME_PALETTE_LABELS.icon;
|
||||
|
||||
return (
|
||||
<header className="app-header flex h-16 shrink-0 items-center gap-3 border-b border-ink-200 bg-surface px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Menü ein- und ausklappen"
|
||||
className="rounded-lg p-2 text-ink-500 hover:bg-ink-100"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 6h18v2H3V6Zm0 5h18v2H3v-2Zm0 5h18v2H3v-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h1 className="hidden shrink-0 text-lg font-bold text-ink-900 md:block">{view}</h1>
|
||||
|
||||
<div className="flex flex-1 justify-center">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Farbwelt. Ein Auswahlfeld und keine Knopfreihe: es kommen weitere
|
||||
Designs dazu, und drei bis fünf Knöpfe im Kopf würden die Suche
|
||||
verdrängen. Die Wahl gilt für den angemeldeten Benutzer - sie steht an
|
||||
seinem Konto, nicht nur im Browser.
|
||||
*/}
|
||||
<label className="sr-only" htmlFor="theme-palette">
|
||||
Design
|
||||
</label>
|
||||
<select
|
||||
id="theme-palette"
|
||||
value={THEME_PALETTE_LABELS[theme] ? theme : 'icon'}
|
||||
onChange={(event) => setTheme(event.target.value as typeof theme)}
|
||||
title={`Design: ${label.hint}`}
|
||||
className="rounded-lg border border-ink-200 bg-surface px-2.5 py-2 text-xs font-semibold text-ink-600 outline-none hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
{THEME_PALETTES.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{THEME_PALETTE_LABELS[option].name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Doppelklick löst die manuelle Wahl und folgt wieder dem System. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
onDoubleClick={resetMode}
|
||||
aria-pressed={isDark}
|
||||
aria-label={`${isDark ? 'Helles' : 'Dunkles'} Design einschalten`}
|
||||
title={`${isDark ? 'Helles' : 'Dunkles'} Design einschalten${
|
||||
followsSystem ? ' (folgt derzeit dem System)' : ' · Doppelklick: wieder dem System folgen'
|
||||
}`}
|
||||
className="flex items-center gap-2 rounded-lg border border-ink-200 px-2.5 py-2 text-xs font-semibold text-ink-600 hover:border-brand-300 hover:text-brand-700"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={isDark ? SUN : MOON} />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">{isDark ? 'Hell' : 'Dunkel'}</span>
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export const LoginScreen: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen.');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-canvas px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<img src="/logo.png" alt="ICON" className="logo-adaptive mx-auto h-12 object-contain" />
|
||||
<p className="mt-3 text-sm text-ink-400">Kontakte, Unternehmen und Projekte an einer Stelle.</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="rounded-2xl border border-ink-200 bg-surface p-6 shadow-card"
|
||||
>
|
||||
<label className="field-label block text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
Benutzername oder E-Mail
|
||||
</label>
|
||||
<input
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
className="mt-1 w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm text-ink-900"
|
||||
/>
|
||||
|
||||
<label className="field-label mt-4 block text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm text-ink-900"
|
||||
/>
|
||||
|
||||
{error && <p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !identifier || !password}
|
||||
className="mt-6 w-full rounded-lg bg-brand-600 py-2.5 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Anmelden …' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import type { View } from '../types';
|
||||
import { userColor } from '../types';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { Avatar } from './ui/Avatar';
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelect: (view: View) => void;
|
||||
counts: Record<string, number>;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
view: View;
|
||||
label: string;
|
||||
badge?: string;
|
||||
icon: React.ReactNode;
|
||||
adminOnly?: boolean;
|
||||
/** Unterpunkte klappen auf, sobald einer der Punkte des Zweigs aktiv ist. */
|
||||
children?: Array<{ view: View; label: string; badge?: string }>;
|
||||
}
|
||||
|
||||
const icon = (path: string) => (
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5 shrink-0" fill="currentColor" aria-hidden="true">
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ view, onSelect, counts, collapsed }) => {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
view: 'Dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: icon('M3 3h8v8H3V3Zm10 0h8v5h-8V3ZM3 13h8v8H3v-8Zm10-3h8v11h-8V10Z'),
|
||||
},
|
||||
{
|
||||
view: 'Leads',
|
||||
label: 'Leads',
|
||||
badge: 'leads_open',
|
||||
icon: icon('M3 5h18v4H3V5Zm0 6h8v8H3v-8Zm10 0h8v3h-8v-3Zm0 5h8v3h-8v-3Z'),
|
||||
children: [{ view: 'Leads · Archiviert', label: 'Archiviert', badge: 'leads_archived' }],
|
||||
},
|
||||
{
|
||||
view: 'Kontakte',
|
||||
label: 'Kontakte',
|
||||
badge: 'contacts',
|
||||
icon: icon('M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z'),
|
||||
},
|
||||
{
|
||||
view: 'Unternehmen',
|
||||
label: 'Unternehmen',
|
||||
badge: 'companies',
|
||||
icon: icon('M4 21V6l7-3 7 3v15h-5v-5h-4v5H4Zm7-11h2v2h-2v-2Zm0 4h2v2h-2v-2Z'),
|
||||
},
|
||||
{
|
||||
view: 'Ausschreibungen',
|
||||
label: 'Ausschreibungen',
|
||||
badge: 'tenders_active',
|
||||
icon: icon('M6 2h9l4 4v16H6V2Zm8 1.5V7h3.5L14 3.5ZM8 11h8v2H8v-2Zm0 4h8v2H8v-2Z'),
|
||||
children: [
|
||||
{ view: 'Live!', label: 'Live!' },
|
||||
{ view: 'Ausschreibungen · Archiv', label: 'Archiv', badge: 'tenders_archived' },
|
||||
],
|
||||
},
|
||||
{
|
||||
view: 'Laufende Projekte',
|
||||
label: 'Laufende Projekte',
|
||||
badge: 'projects_active',
|
||||
icon: icon('M3 6h7l2 2h9v11H3V6Zm0-2h8l2 2H3V4Z'),
|
||||
},
|
||||
{
|
||||
view: 'Historie',
|
||||
label: 'Historie',
|
||||
icon: icon('M12 3a9 9 0 1 0 9 9h-2a7 7 0 1 1-7-7v3l4-4-4-4v3Zm-1 4v6l5 3 1-1.7-4-2.3V7h-2Z'),
|
||||
},
|
||||
{
|
||||
view: 'Einstellungen',
|
||||
label: 'Einstellungen',
|
||||
adminOnly: true,
|
||||
icon: icon(
|
||||
'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 4-1.9-.6a7.3 7.3 0 0 0-.7-1.7l.9-1.8-1.6-1.6-1.8.9a7.3 7.3 0 0 0-1.7-.7L13.6 4h-3.2l-.6 1.9c-.6.2-1.2.4-1.7.7l-1.8-.9L4.7 7.3l.9 1.8c-.3.5-.5 1.1-.7 1.7L3 11.4v3.2l1.9.6c.2.6.4 1.2.7 1.7l-.9 1.8 1.6 1.6 1.8-.9c.5.3 1.1.5 1.7.7l.6 1.9h3.2l.6-1.9c.6-.2 1.2-.4 1.7-.7l1.8.9 1.6-1.6-.9-1.8c.3-.5.5-1.1.7-1.7l1.9-.6v-3.2Z'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const badge = (key: string | undefined) => (key ? counts[key] : undefined);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`app-sidebar flex h-full shrink-0 flex-col border-r border-ink-200 bg-surface transition-all ${
|
||||
collapsed ? 'w-16' : 'w-60'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-16 items-center border-b border-ink-200 ${collapsed ? 'justify-center px-2' : 'px-5'}`}>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ICON"
|
||||
className={`logo-adaptive object-contain ${collapsed ? 'h-7 w-7' : 'h-8'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3 scrollbar-slim">
|
||||
<ul className="space-y-0.5">
|
||||
{items
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) => {
|
||||
const active = view === item.view;
|
||||
const count = badge(item.badge);
|
||||
// Der Zweig bleibt offen, solange irgendein Punkt daraus aktiv ist.
|
||||
const branchActive = active || (item.children ?? []).some((child) => child.view === view);
|
||||
|
||||
return (
|
||||
<li key={item.view}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item.view)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-ink-600 hover:bg-ink-100 hover:text-ink-900'
|
||||
} ${collapsed ? 'justify-center px-0' : ''}`}
|
||||
>
|
||||
<span className={branchActive ? 'text-brand-600' : 'text-ink-400'}>{item.icon}</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate text-left">{item.label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs font-semibold text-ink-500">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Unterpunkte erscheinen nur, wenn der Zweig gerade benutzt
|
||||
wird. Dauerhaft ausgeklappt wäre die Leiste doppelt so lang,
|
||||
und Archive schaut man selten an.
|
||||
*/}
|
||||
{!collapsed && branchActive && item.children && (
|
||||
<ul className="mb-1 ml-4 mt-0.5 space-y-0.5 border-l border-ink-200 pl-3">
|
||||
{item.children.map((child) => {
|
||||
const childCount = badge(child.badge);
|
||||
return (
|
||||
<li key={child.view}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(child.view)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-colors ${
|
||||
view === child.view
|
||||
? 'bg-brand-50 font-medium text-brand-700'
|
||||
: 'text-ink-500 hover:bg-ink-100 hover:text-ink-900'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate text-left">{child.label}</span>
|
||||
{childCount !== undefined && childCount > 0 && (
|
||||
<span className="rounded-full bg-ink-100 px-1.5 py-0.5 text-xs text-ink-500">
|
||||
{childCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-ink-200 p-2">
|
||||
<div className={`flex items-center gap-3 rounded-lg px-2 py-2 ${collapsed ? 'justify-center' : ''}`}>
|
||||
<Avatar name={user?.name ?? '?'} src={user?.avatar} size={32} color={userColor(user)} />
|
||||
{!collapsed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-900">{user?.name}</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{user?.role === 'admin' ? 'Administrator' : 'Benutzer'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
title="Abmelden"
|
||||
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-ink-700"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5v-2H5V5h5V3Zm6.3 4.3-1.4 1.4L17.2 11H9v2h8.2l-2.3 2.3 1.4 1.4L21 12l-4.7-4.7Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
|
||||
type ToastKind = 'info' | 'success' | 'error';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, kind?: ToastKind) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const STYLES: Record<ToastKind, string> = {
|
||||
info: 'border-ink-200 bg-surface text-ink-800',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
error: 'border-red-200 bg-red-50 text-red-700',
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
|
||||
const toast = useCallback((message: string, kind: ToastKind = 'info') => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((list) => [...list, { id, kind, message }]);
|
||||
setTimeout(() => setItems((list) => list.filter((item) => item.id !== id)), 4000);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ toast }), [toast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="status"
|
||||
className={`pointer-events-auto rounded-lg border px-4 py-3 text-sm shadow-lift ${STYLES[item.kind]}`}
|
||||
>
|
||||
{item.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast muss innerhalb von <ToastProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { Company, DirectoryUser } from '../../types';
|
||||
import { companiesApi } from '../../lib/api';
|
||||
import { useOptions } from '../../lib/options';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { StarRating } from '../ui/StarRating';
|
||||
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
|
||||
import { CompanyModal } from './CompanyModal';
|
||||
import { Network } from '../network/Network';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
interface CompaniesProps {
|
||||
users: DirectoryUser[];
|
||||
onChanged: () => void;
|
||||
openId: number | null;
|
||||
onOpenHandled: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
* Unternehmen - die fruehere Ansicht "Kunden".
|
||||
*
|
||||
* Der Umschalter oben entscheidet, welcher Ausschnitt gezeigt wird: unsere
|
||||
* Kunden (aktiv oder Alumni) oder alle bekannten Unternehmen. Es sind dieselben
|
||||
* Daten - ein Kunde ist ein Unternehmen mit gesetztem Haken.
|
||||
*/
|
||||
type Scope = 'all' | 'customers' | 'alumni';
|
||||
type Layout = 'grid' | 'list' | 'groups' | 'network';
|
||||
|
||||
const SCOPE_LABELS: Record<Scope, string> = {
|
||||
all: 'Alle Unternehmen',
|
||||
customers: 'Kunden (aktiv)',
|
||||
alumni: 'Kunden (Alumni)',
|
||||
};
|
||||
|
||||
export const Companies: React.FC<CompaniesProps> = ({ users, onChanged, openId, onOpenHandled }) => {
|
||||
const { toast } = useToast();
|
||||
const { industries: industryOptions } = useOptions();
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [industries, setIndustries] = useState<string[]>([]);
|
||||
const [groups, setGroups] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [term, setTerm] = useState('');
|
||||
const [scope, setScope] = useState<Scope>('all');
|
||||
const [industry, setIndustry] = useState('');
|
||||
const [minRating, setMinRating] = useState(0);
|
||||
const [layout, setLayout] = useState<Layout>('grid');
|
||||
const [editing, setEditing] = useState<number | null | undefined>(undefined);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [{ companies: rows }, options] = await Promise.all([
|
||||
companiesApi.list(),
|
||||
companiesApi.options(),
|
||||
]);
|
||||
setCompanies(rows);
|
||||
setIndustries(options.industries);
|
||||
setGroups(options.groups);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (openId === null) return;
|
||||
setEditing(openId);
|
||||
onOpenHandled();
|
||||
}, [openId, onOpenHandled]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = term.trim().toLowerCase();
|
||||
return companies.filter((company) => {
|
||||
if (scope === 'customers' && !(company.is_customer && company.customer_state === 'aktiv')) return false;
|
||||
if (scope === 'alumni' && !(company.is_customer && company.customer_state === 'alumni')) return false;
|
||||
if (industry && company.industry !== industry) return false;
|
||||
if (company.rating < minRating) return false;
|
||||
if (!q) return true;
|
||||
return [company.name, company.group_name, company.industry, company.address, company.website]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
});
|
||||
}, [companies, term, scope, industry, minRating]);
|
||||
|
||||
/** Fuer die Gruppenansicht: nach Dachgesellschaft buendeln. */
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Company[]>();
|
||||
for (const company of visible) {
|
||||
const key = company.group_name || 'Ohne Gruppe';
|
||||
map.set(key, [...(map.get(key) ?? []), company]);
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => {
|
||||
if (a[0] === 'Ohne Gruppe') return 1;
|
||||
if (b[0] === 'Ohne Gruppe') return -1;
|
||||
return a[0].localeCompare(b[0], 'de');
|
||||
});
|
||||
}, [visible]);
|
||||
|
||||
const afterSave = () => {
|
||||
void load();
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const card = (company: Company) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(company.id)}
|
||||
className="flex h-full w-full flex-col gap-3 rounded-xl border border-ink-200 bg-surface p-4 text-left shadow-card transition-shadow hover:shadow-lift"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar name={company.name} src={company.logo} size={44} square />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold text-ink-900">{company.name}</p>
|
||||
<p className="truncate text-sm text-ink-500">{company.industry || 'Branche offen'}</p>
|
||||
{company.group_name && <p className="truncate text-xs text-ink-400">Gruppe {company.group_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StarRating value={company.rating} />
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{company.is_customer && (
|
||||
<Pill tone={company.customer_state === 'aktiv' ? 'emerald' : 'ink'}>
|
||||
{company.customer_state === 'aktiv' ? 'Kunde' : 'Alumni'}
|
||||
</Pill>
|
||||
)}
|
||||
{company.employees ? <Pill>{company.employees.toLocaleString('de-DE')} MA</Pill> : null}
|
||||
{company.freelancers !== 'unbekannt' && (
|
||||
<Pill tone={company.freelancers === 'ja' ? 'brand' : 'ink'}>
|
||||
Freelancer: {company.freelancers}
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-3 pt-1 text-xs text-ink-400">
|
||||
<span>{company.contact_count ?? 0} Kontakte</span>
|
||||
{(company.former_count ?? 0) > 0 && <span>{company.former_count} verlassen</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Segmented
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
options={(Object.keys(SCOPE_LABELS) as Scope[]).map((value) => ({
|
||||
value,
|
||||
label: SCOPE_LABELS[value],
|
||||
}))}
|
||||
/>
|
||||
|
||||
<input
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder="Unternehmen filtern …"
|
||||
className={`${inputClass} max-w-xs`}
|
||||
/>
|
||||
<select
|
||||
value={industry}
|
||||
onChange={(event) => setIndustry(event.target.value)}
|
||||
className={`${inputClass} w-auto`}
|
||||
>
|
||||
<option value="">Alle Branchen</option>
|
||||
{/* Gepflegte Liste zuerst, dazu was im Bestand sonst noch vorkommt. */}
|
||||
{[...new Set([...industryOptions, ...industries])].map((value) => (
|
||||
<option key={value}>{value}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-500">
|
||||
ab
|
||||
<StarRating value={minRating} onChange={setMinRating} />
|
||||
</label>
|
||||
|
||||
<span className="ml-auto text-sm text-ink-400">
|
||||
{visible.length} von {companies.length}
|
||||
</span>
|
||||
|
||||
<Segmented
|
||||
value={layout}
|
||||
onChange={setLayout}
|
||||
options={[
|
||||
{ value: 'grid', label: 'Kacheln' },
|
||||
{ value: 'list', label: 'Liste' },
|
||||
{ value: 'groups', label: 'Group', title: 'Firmengruppe mit den darunter liegenden Firmen' },
|
||||
{ value: 'network', label: 'Netzwerk', title: 'Unternehmen und ihre Kontakte als Karte' },
|
||||
]}
|
||||
/>
|
||||
<PrimaryButton type="button" onClick={() => setEditing(null)}>
|
||||
Neues Unternehmen
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{layout === 'network' ? (
|
||||
/* Die Netzwerkansicht holt ihre Daten selbst - sie bekommt aber
|
||||
dieselben Filter, damit beide Ansichten denselben Ausschnitt zeigen. */
|
||||
<Network
|
||||
mode="companies"
|
||||
term={term}
|
||||
onlyCustomers={scope !== 'all'}
|
||||
minRating={minRating}
|
||||
industry={industry}
|
||||
/>
|
||||
) : loading ? (
|
||||
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-ink-400">
|
||||
{companies.length === 0 ? 'Noch kein Unternehmen erfasst.' : 'Kein Unternehmen passt zum Filter.'}
|
||||
</p>
|
||||
) : layout === 'grid' ? (
|
||||
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{visible.map((company) => (
|
||||
<li key={company.id}>{card(company)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : layout === 'groups' ? (
|
||||
<div className="space-y-6">
|
||||
{grouped.map(([group, list]) => (
|
||||
<section key={group}>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink-900">
|
||||
{group}
|
||||
<span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs font-medium text-ink-500">
|
||||
{list.length}
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{list.map((company) => (
|
||||
<li key={company.id}>{card(company)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-xs uppercase tracking-wide text-ink-500">
|
||||
<th className="px-4 py-2.5 font-semibold">Unternehmen</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Branche</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Gruppe</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Mitarbeiter</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Bewertung</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Kontakte</th>
|
||||
<th className="px-4 py-2.5 font-semibold">Kunde</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{visible.map((company) => (
|
||||
<tr
|
||||
key={company.id}
|
||||
onClick={() => setEditing(company.id)}
|
||||
className="cursor-pointer hover:bg-brand-50"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar name={company.name} src={company.logo} size={26} square />
|
||||
<span className="font-medium text-ink-900">{company.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">{company.industry || '–'}</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">{company.group_name || '–'}</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">
|
||||
{company.employees ? company.employees.toLocaleString('de-DE') : '–'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<StarRating value={company.rating} size={14} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-600">{company.contact_count ?? 0}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{company.is_customer ? (
|
||||
<Pill tone={company.customer_state === 'aktiv' ? 'emerald' : 'ink'}>
|
||||
{company.customer_state}
|
||||
</Pill>
|
||||
) : (
|
||||
<span className="text-ink-400">–</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing !== undefined && (
|
||||
<CompanyModal
|
||||
open
|
||||
companyId={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={afterSave}
|
||||
users={users}
|
||||
industries={industries}
|
||||
groups={groups}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { formatDate, type Company, type DirectoryUser, type Tristate } from '../../types';
|
||||
import { companiesApi, historyApi, type CompanyDetail } from '../../lib/api';
|
||||
import { Modal, ConfirmDialog } from '../ui/Modal';
|
||||
import { AvatarPicker } from '../ui/AvatarPicker';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { StarRating } from '../ui/StarRating';
|
||||
import { Field, Pill, PrimaryButton, SecondaryButton, inputClass, labelClass } from '../ui/form';
|
||||
import { MentionField } from '../../lib/mentions';
|
||||
import { DictateButton } from '../ui/Dictate';
|
||||
import { HistoryFeed } from '../history/HistoryFeed';
|
||||
import { TRISTATE_LABELS } from '../contacts/contactUi';
|
||||
import { useToast } from '../Toast';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { useOptions } from '../../lib/options';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
|
||||
interface CompanyModalProps {
|
||||
companyId: number | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
users: DirectoryUser[];
|
||||
industries: string[];
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
type Tab = 'stamm' | 'stammbaum' | 'historie';
|
||||
|
||||
const EMPTY = {
|
||||
name: '',
|
||||
group_name: '',
|
||||
industry: '',
|
||||
employees: '',
|
||||
website: '',
|
||||
address: '',
|
||||
rating: 0,
|
||||
is_customer: false,
|
||||
customer_state: 'aktiv' as 'aktiv' | 'alumni',
|
||||
freelancers: 'unbekannt' as Tristate,
|
||||
notes: '',
|
||||
owner_id: '' as number | '',
|
||||
primary_contact_id: '' as number | '',
|
||||
logo: null as string | null,
|
||||
};
|
||||
|
||||
export const CompanyModal: React.FC<CompanyModalProps> = ({
|
||||
companyId,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
users,
|
||||
industries,
|
||||
groups,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const { open: openEntity } = useNavigation();
|
||||
const { industries: industryOptions } = useOptions();
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>('stamm');
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
const [detail, setDetail] = useState<CompanyDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const isNew = companyId === null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTab('stamm');
|
||||
|
||||
if (isNew) {
|
||||
setDetail(null);
|
||||
/*
|
||||
* Beim Anlegen ist der angemeldete Benutzer voreingestellt - in aller Regel
|
||||
* kümmert sich der, der den Datensatz erfasst. Ändern lässt es sich weiterhin.
|
||||
*/
|
||||
setForm({ ...EMPTY, owner_id: user?.id ?? '' });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
companiesApi
|
||||
.detail(companyId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setDetail(data);
|
||||
setForm({ ...EMPTY, ...toForm(data.company) });
|
||||
})
|
||||
.catch((err) => toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, companyId]);
|
||||
|
||||
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const save = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast('Der Name des Unternehmens ist erforderlich.', 'error');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
...form,
|
||||
employees: form.employees === '' ? null : Number(form.employees),
|
||||
owner_id: form.owner_id === '' ? null : Number(form.owner_id),
|
||||
primary_contact_id: form.primary_contact_id === '' ? null : Number(form.primary_contact_id),
|
||||
};
|
||||
if (isNew) {
|
||||
await companiesApi.create(body);
|
||||
toast(`${form.name} angelegt.`, 'success');
|
||||
} else {
|
||||
await companiesApi.update(companyId, body);
|
||||
toast('Gespeichert.', 'success');
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (isNew) return;
|
||||
try {
|
||||
await companiesApi.remove(companyId);
|
||||
toast('Unternehmen gelöscht.', 'success');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addNote = async (text: string) => {
|
||||
if (!companyId) return;
|
||||
await historyApi.add('company', companyId, text);
|
||||
setDetail(await companiesApi.detail(companyId));
|
||||
};
|
||||
|
||||
const tabs: Array<{ id: Tab; label: string; count?: number }> = [
|
||||
{ id: 'stamm', label: 'Stammdaten' },
|
||||
{
|
||||
id: 'stammbaum',
|
||||
label: 'Stammbaum',
|
||||
count: (detail?.contacts.length ?? 0) + (detail?.former.length ?? 0),
|
||||
},
|
||||
{ id: 'historie', label: 'Historie', count: detail?.history.length },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={isNew ? 'Neues Unternehmen' : form.name || 'Unternehmen'}
|
||||
subtitle={[form.industry, form.group_name && `Gruppe ${form.group_name}`]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
footer={
|
||||
<>
|
||||
{!isNew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="mr-auto rounded-lg px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
Abbrechen
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" onClick={save} disabled={saving}>
|
||||
{saving ? 'Speichern …' : 'Speichern'}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-ink-400">Wird geladen …</p>
|
||||
) : (
|
||||
<>
|
||||
{!isNew && (
|
||||
<nav className="mb-5 flex gap-1 border-b border-ink-200">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
|
||||
tab === item.id
|
||||
? 'border-brand-500 text-brand-700'
|
||||
: 'border-transparent text-ink-500 hover:text-ink-800'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{item.count !== undefined && item.count > 0 && (
|
||||
<span className="ml-1.5 text-xs text-ink-400">{item.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{(isNew || tab === 'stamm') && (
|
||||
<div className="space-y-5">
|
||||
<AvatarPicker
|
||||
name={form.name || 'Unternehmen'}
|
||||
value={form.logo}
|
||||
onChange={(value) => set('logo', value)}
|
||||
square
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Name *">
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => set('name', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Gruppe"
|
||||
hint="Dachgesellschaft, z. B. Finanz Informatik über FI Hamburg und FI Frankfurt."
|
||||
>
|
||||
<input
|
||||
value={form.group_name}
|
||||
onChange={(event) => set('group_name', event.target.value)}
|
||||
className={inputClass}
|
||||
list="company-groups"
|
||||
/>
|
||||
<datalist id="company-groups">
|
||||
{groups.map((group) => (
|
||||
<option key={group} value={group} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field
|
||||
label="Branche"
|
||||
hint="Gepflegt unter Einstellungen → Stammdaten."
|
||||
>
|
||||
<select
|
||||
value={form.industry}
|
||||
onChange={(event) => set('industry', event.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">– keine –</option>
|
||||
{/* Ein Altwert, der nicht mehr in der Liste steht, bleibt
|
||||
wählbar - sonst würde er beim nächsten Speichern still
|
||||
verschwinden. */}
|
||||
{(industryOptions.includes(form.industry) || !form.industry
|
||||
? industryOptions
|
||||
: [form.industry, ...industryOptions]
|
||||
).map((industry) => (
|
||||
<option key={industry} value={industry}>
|
||||
{industry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Mitarbeiterzahl">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.employees}
|
||||
onChange={(event) => set('employees', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Website">
|
||||
<input
|
||||
value={form.website}
|
||||
onChange={(event) => set('website', event.target.value)}
|
||||
placeholder="https://…"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zuständig">
|
||||
<select
|
||||
value={form.owner_id}
|
||||
onChange={(event) =>
|
||||
set('owner_id', event.target.value === '' ? '' : Number(event.target.value))
|
||||
}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">– niemand –</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{!isNew && (detail?.contacts.length ?? 0) > 0 && (
|
||||
<Field
|
||||
label="Hauptansprechpartner"
|
||||
hint="Auswahl aus den Kontakten, die diesem Unternehmen zugeordnet sind."
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<select
|
||||
value={form.primary_contact_id}
|
||||
onChange={(event) =>
|
||||
set(
|
||||
'primary_contact_id',
|
||||
event.target.value === '' ? '' : Number(event.target.value)
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">– keiner –</option>
|
||||
{detail?.contacts.map((contact) => (
|
||||
<option key={contact.id} value={contact.id}>
|
||||
{[contact.first_name, contact.last_name].filter(Boolean).join(' ')}
|
||||
{contact.position ? ` · ${contact.position}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Adresse" className="sm:col-span-2">
|
||||
<textarea
|
||||
value={form.address}
|
||||
onChange={(event) => set('address', event.target.value)}
|
||||
rows={2}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className={labelClass}>Bewertung</label>
|
||||
<div className="flex h-[38px] items-center">
|
||||
<StarRating value={form.rating} onChange={(value) => set('rating', value)} size={22} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-400">Wie interessant ist das Unternehmen für uns?</p>
|
||||
</div>
|
||||
|
||||
<Field label="Arbeitet mit Freelancern">
|
||||
<select
|
||||
value={form.freelancers}
|
||||
onChange={(event) => set('freelancers', event.target.value as Tristate)}
|
||||
className={inputClass}
|
||||
>
|
||||
{(Object.keys(TRISTATE_LABELS) as Tristate[]).map((state) => (
|
||||
<option key={state} value={state}>
|
||||
{TRISTATE_LABELS[state]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Kunde</label>
|
||||
<label className="flex h-[38px] items-center gap-2 text-sm text-ink-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_customer}
|
||||
onChange={(event) => set('is_customer', event.target.checked)}
|
||||
className="h-4 w-4 accent-[var(--color-brand-600)]"
|
||||
/>
|
||||
Aktiver Kunde
|
||||
</label>
|
||||
{form.is_customer && (
|
||||
<select
|
||||
value={form.customer_state}
|
||||
onChange={(event) =>
|
||||
set('customer_state', event.target.value as 'aktiv' | 'alumni')
|
||||
}
|
||||
className={`${inputClass} mt-1`}
|
||||
>
|
||||
<option value="aktiv">aktiv</option>
|
||||
<option value="alumni">Alumni</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<label className={`${labelClass} mb-0`}>Notizen</label>
|
||||
<DictateButton
|
||||
label="Notizen"
|
||||
compact
|
||||
onBeforeNavigate={onClose}
|
||||
onText={(text) =>
|
||||
set('notes', form.notes.trim() ? `${form.notes.trimEnd()} ${text}` : text)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<MentionField value={form.notes} onChange={(value) => set('notes', value)} rows={4} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && tab === 'stammbaum' && detail && (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink-900">
|
||||
Aktuell tätig
|
||||
<Pill tone="emerald">{detail.contacts.length}</Pill>
|
||||
</h3>
|
||||
{detail.contacts.length === 0 ? (
|
||||
<p className="text-sm text-ink-400">Kein Kontakt ist diesem Unternehmen zugeordnet.</p>
|
||||
) : (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{detail.contacts.map((contact) => (
|
||||
<li
|
||||
key={contact.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-ink-200 px-3 py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEntity('contact', contact.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<Avatar
|
||||
name={`${contact.first_name ?? ''} ${contact.last_name}`}
|
||||
src={contact.avatar}
|
||||
size={32}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-ink-900">
|
||||
{[contact.first_name, contact.last_name].filter(Boolean).join(' ')}
|
||||
{contact.id === Number(form.primary_contact_id) && (
|
||||
<Pill tone="brand" className="ml-2">
|
||||
Hauptkontakt
|
||||
</Pill>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-ink-400">
|
||||
{contact.position || contact.kind}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Mail und Telefon direkt anklickbar - ein Klick öffnet
|
||||
das Mailprogramm bzw. wählt die Nummer. */}
|
||||
{contact.email && (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
title={contact.email}
|
||||
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
|
||||
aria-label={`E-Mail an ${contact.last_name}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 5h18v14H3V5Zm2.2 2L12 12l6.8-5H5.2Z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<a
|
||||
href={`tel:${contact.phone.replace(/[^+\d]/g, '')}`}
|
||||
title={contact.phone}
|
||||
className="rounded-lg p-1.5 text-ink-400 hover:bg-ink-100 hover:text-brand-700"
|
||||
aria-label={`${contact.last_name} anrufen`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.6 3h3l1.5 4-2 1.5a12 12 0 0 0 5.4 5.4L16 12l4 1.5v3A2.5 2.5 0 0 1 17.5 19 14.5 14.5 0 0 1 5 6.5 2.5 2.5 0 0 1 6.6 3Z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink-900">
|
||||
Verlassen
|
||||
<Pill tone="amber">{detail.former.length}</Pill>
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-ink-400">
|
||||
Wer hier einmal gearbeitet hat. Der Eintrag entsteht automatisch, sobald bei einem
|
||||
Kontakt der Arbeitgeber wechselt.
|
||||
</p>
|
||||
{detail.former.length === 0 ? (
|
||||
<p className="text-sm text-ink-400">Niemand hat das Unternehmen bisher verlassen.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
|
||||
{detail.former.map((entry) => (
|
||||
<li key={entry.employment_id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEntity('contact', entry.id)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-brand-50"
|
||||
>
|
||||
<Avatar
|
||||
name={`${entry.first_name ?? ''} ${entry.last_name}`}
|
||||
src={entry.avatar}
|
||||
size={32}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-ink-900">
|
||||
{[entry.first_name, entry.last_name].filter(Boolean).join(' ')}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-ink-400">
|
||||
{entry.position || '–'} · bis {formatDate(entry.ended_on)}
|
||||
</span>
|
||||
</span>
|
||||
{entry.now_company && (
|
||||
<span className="shrink-0 text-xs text-ink-500">
|
||||
jetzt: <span className="font-medium">{entry.now_company}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-semibold text-ink-900">Interviews</h3>
|
||||
{detail.interviews.length === 0 ? (
|
||||
<p className="text-sm text-ink-400">Noch kein Interview bei diesem Unternehmen.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-100 rounded-lg border border-ink-200">
|
||||
{detail.interviews.map((entry) => (
|
||||
<li key={entry.id} className="px-3 py-2.5 text-sm">
|
||||
<span className="font-medium text-ink-900">{formatDate(entry.held_on)}</span>
|
||||
{entry.contact_name && <span className="ml-2 text-ink-600">{entry.contact_name}</span>}
|
||||
<span className="ml-2 text-xs text-ink-400">{entry.user_name ?? ''}</span>
|
||||
{entry.notes && <p className="mt-1 text-ink-600">{entry.notes}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && tab === 'historie' && detail && (
|
||||
<div className="space-y-6">
|
||||
<HistoryFeed entries={detail.history} onAdd={addNote} />
|
||||
|
||||
{detail.mentioned.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-semibold text-ink-900">
|
||||
Anderswo genannt ({detail.mentioned.length})
|
||||
</h3>
|
||||
<HistoryFeed entries={detail.mentioned} searchable={false} showEntity />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Unternehmen löschen"
|
||||
message={`${form.name || 'Dieses Unternehmen'} wird endgültig gelöscht. Zugeordnete Kontakte bleiben bestehen, verlieren aber ihre Zuordnung.`}
|
||||
onConfirm={remove}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function toForm(company: Company) {
|
||||
return {
|
||||
name: company.name ?? '',
|
||||
group_name: company.group_name ?? '',
|
||||
industry: company.industry ?? '',
|
||||
employees: company.employees === null || company.employees === undefined ? '' : String(company.employees),
|
||||
website: company.website ?? '',
|
||||
address: company.address ?? '',
|
||||
rating: company.rating ?? 0,
|
||||
is_customer: Boolean(company.is_customer),
|
||||
customer_state: company.customer_state ?? 'aktiv',
|
||||
freelancers: company.freelancers ?? 'unbekannt',
|
||||
notes: company.notes ?? '',
|
||||
owner_id: (company.owner_id ?? '') as number | '',
|
||||
primary_contact_id: (company.primary_contact_id ?? '') as number | '',
|
||||
logo: company.logo ?? null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user