Files
perinco-CRM/src/components/calendar/Calendar.tsx
T
Sascha WolfandClaude Opus 5 82d1b7eab4 feat: Projekt Scanner integrieren, Whisper in die Einstellungen
Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft
parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt
statt geteilt, damit die alte Installation unberuehrt bleibt:

- eigene Datenbank perinco_scanner, legt sich beim Start selbst an
- eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis
- eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig
  mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM)
- Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie,
  ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten

Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des
Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem
statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der
alten Datenbank, ohne sie zu veraendern.

Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit
aufklappbaren Unterpunkten, Einstellungen als eigener Reiter.

Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den
Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die
Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet
die Rolle.

Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf
1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische
Dokumentation mit Funktionszeichnungen unter docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:08:59 +02:00

771 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { EventModal } from './EventModal';
import { EventDetailModal } from './EventDetailModal';
import { UserDetailModal } from './UserDetailModal';
import { LeadModal } from '../leads/LeadModal';
import { ConfirmDialog } from '../ui/Modal';
import { UserAvatar } from '../ui/UserAvatar';
import { MentionText, mentionPlainText } from './mentions';
import {
DAY_MS,
WEEKDAY_LABELS,
addDays,
addMonths,
compareEvents,
formatDayLong,
formatEventRange,
formatMonthYear,
formatTime,
isSameDay,
monthMatrix,
parseSqlDateTime,
positionEvents,
startOfDay,
startOfWeek,
weekDays,
} from './calendarUtils';
import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth';
import { eventsApi } from '../../lib/api';
import {
eventColor,
userColor,
type AppUser,
type CalendarEvent,
type CalendarViewMode,
type Lead,
type LeadConnection,
} from '../../types';
interface CalendarProps {
events: CalendarEvent[];
leads: Lead[];
users: AppUser[];
connections: LeadConnection[];
isLoading: boolean;
reload: () => void;
}
const VIEW_BUTTONS: { mode: CalendarViewMode; label: string }[] = [
{ mode: 'month', label: 'Monat' },
{ mode: 'week', label: 'Woche' },
{ mode: 'day', label: 'Tag' },
];
const HOUR_HEIGHT = 48;
/** Ab wie vielen Terminen pro Monatstag auf "+N weitere" gekuerzt wird. */
const MONTH_CHIP_LIMIT = 3;
// ---------- Bausteine ----------
// Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React
// sie bei jedem Render als neuen Typ behandeln und das Zeitraster neu montieren
// - die Scrollposition waere danach jedes Mal verloren.
const EventChip: React.FC<{
event: CalendarEvent;
onOpen: (id: number) => void;
showTime?: boolean;
}> = ({ event, onOpen, showTime = false }) => (
<button
type="button"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onOpen(event.id);
}}
title={`${event.title} · ${formatEventRange(event)}${
event.created_by_name ? ` · ${event.created_by_name}` : ''
}`}
className="flex w-full items-center gap-1.5 overflow-hidden rounded-md px-1.5 py-0.5 text-left text-[11px] font-bold text-white transition hover:brightness-110"
style={{ backgroundColor: eventColor(event) }}
>
{showTime && !event.all_day && (
<span className="shrink-0 opacity-80">{formatTime(parseSqlDateTime(event.starts_at))}</span>
)}
<span className="truncate">{event.title}</span>
</button>
);
interface GridProps {
today: Date;
dayEvents: (day: Date) => CalendarEvent[];
onCreate: (start: Date) => void;
onOpenEvent: (id: number) => void;
onShowDay: (day: Date) => void;
}
const MonthView: React.FC<GridProps & { cursor: Date }> = ({
cursor,
today,
dayEvents,
onCreate,
onOpenEvent,
onShowDay,
}) => {
const days = monthMatrix(cursor);
return (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface">
<div className="grid grid-cols-7 border-b border-ink-100 bg-ink-50/60">
{WEEKDAY_LABELS.map((label) => (
<div
key={label}
className="px-2 py-2 text-center text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400"
>
{label}
</div>
))}
</div>
<div className="grid grid-cols-7">
{days.map((day, index) => {
const isCurrentMonth = day.getMonth() === cursor.getMonth();
const isToday = isSameDay(day, today);
const list = dayEvents(day);
const visible = list.slice(0, MONTH_CHIP_LIMIT);
const hidden = list.length - visible.length;
const createAt = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9);
return (
<div
key={day.toDateString()}
role="button"
tabIndex={0}
onClick={() => onCreate(createAt)}
onKeyDown={(keyEvent) => {
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
keyEvent.preventDefault();
onCreate(createAt);
}
}}
title="Klicken, um an diesem Tag einen Termin anzulegen"
className={`min-h-[104px] cursor-pointer border-b border-ink-100 p-1.5 transition hover:bg-brand-50/40 ${
index % 7 === 6 ? '' : 'border-r'
} ${isCurrentMonth ? '' : 'bg-ink-50/50'}`}
>
<div className="mb-1 flex items-center justify-between px-0.5">
<span
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-extrabold ${
isToday
? 'bg-brand-500 text-white'
: isCurrentMonth
? 'text-ink-700'
: 'text-ink-300'
}`}
>
{day.getDate()}
</span>
{list.length > 0 && (
<span className="text-[10px] font-bold text-ink-300">{list.length}</span>
)}
</div>
<div className="space-y-1">
{visible.map((event) => (
<EventChip key={event.id} event={event} onOpen={onOpenEvent} showTime />
))}
{hidden > 0 && (
<button
type="button"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onShowDay(day);
}}
className="w-full rounded-md px-1.5 py-0.5 text-left text-[11px] font-bold text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
>
+{hidden} weitere
</button>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
const TimeGrid: React.FC<GridProps & { days: Date[]; scrollRef: React.Ref<HTMLDivElement> }> = ({
days,
today,
dayEvents,
onCreate,
onOpenEvent,
onShowDay,
scrollRef,
}) => {
const allDayRows = days.map((day) => dayEvents(day).filter((event) => event.all_day));
const hasAllDay = allDayRows.some((list) => list.length > 0);
const gridTemplate = `56px repeat(${days.length}, minmax(0, 1fr))`;
const now = new Date();
const nowPercent = ((now.getTime() - startOfDay(now).getTime()) / DAY_MS) * 100;
return (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface">
{/* Kopfzeile mit den Tagen */}
<div
className="grid border-b border-ink-100 bg-ink-50/60"
style={{ gridTemplateColumns: gridTemplate }}
>
<div />
{days.map((day) => (
<button
key={day.toDateString()}
type="button"
onClick={() => onShowDay(day)}
className="border-l border-ink-100 px-2 py-2 text-center transition hover:bg-brand-50/50"
>
<span className="block text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
{WEEKDAY_LABELS[(day.getDay() + 6) % 7]}
</span>
<span
className={`mx-auto mt-1 flex h-7 w-7 items-center justify-center rounded-full text-sm font-extrabold ${
isSameDay(day, today) ? 'bg-brand-500 text-white' : 'text-ink-700'
}`}
>
{day.getDate()}
</span>
</button>
))}
</div>
{/* Ganztaegige Termine */}
{hasAllDay && (
<div className="grid border-b border-ink-100" style={{ gridTemplateColumns: gridTemplate }}>
<div className="px-2 py-2 text-right text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
Ganztag
</div>
{days.map((day, index) => (
<div key={day.toDateString()} className="space-y-1 border-l border-ink-100 p-1">
{allDayRows[index].map((event) => (
<EventChip key={event.id} event={event} onOpen={onOpenEvent} />
))}
</div>
))}
</div>
)}
{/* Zeitraster */}
<div ref={scrollRef} className="scrollbar-slim max-h-[62vh] overflow-y-auto">
<div className="grid" style={{ gridTemplateColumns: gridTemplate }}>
<div>
{Array.from({ length: 24 }, (_, hour) => (
<div
key={hour}
className="relative border-b border-ink-100"
style={{ height: HOUR_HEIGHT }}
>
<span className="absolute -top-2 right-2 text-[10px] font-bold text-ink-400">
{hour === 0 ? '' : `${String(hour).padStart(2, '0')}:00`}
</span>
</div>
))}
</div>
{days.map((day) => {
const positioned = positionEvents(
dayEvents(day).filter((event) => !event.all_day),
day
);
return (
<div key={day.toDateString()} className="relative border-l border-ink-100">
{/* Stundenzellen sind gleichzeitig Klickflaeche fuer neue Termine */}
{Array.from({ length: 24 }, (_, hour) => (
<div
key={hour}
onClick={() =>
onCreate(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour))
}
title={`Termin um ${String(hour).padStart(2, '0')}:00 anlegen`}
className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/40"
style={{ height: HOUR_HEIGHT }}
/>
))}
{isSameDay(day, today) && (
<div
className="pointer-events-none absolute left-0 right-0 z-10 border-t-2 border-red-500"
style={{ top: `${nowPercent}%` }}
>
<span className="absolute -left-1 -top-[5px] h-2 w-2 rounded-full bg-red-500" />
</div>
)}
{positioned.map(({ event, topPercent, heightPercent, leftPercent, widthPercent }) => (
<button
key={event.id}
type="button"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
onOpenEvent(event.id);
}}
title={`${event.title} · ${formatEventRange(event)}`}
className="absolute overflow-hidden rounded-lg border-l-4 px-1.5 py-1 text-left text-[11px] leading-tight text-white shadow-card transition hover:brightness-110"
style={{
top: `${topPercent}%`,
height: `calc(${heightPercent}% - 2px)`,
left: `calc(${leftPercent}% + 2px)`,
width: `calc(${widthPercent}% - 4px)`,
backgroundColor: eventColor(event),
borderLeftColor: 'rgba(255, 255, 255, 0.55)',
}}
>
<span className="block truncate font-extrabold">{event.title}</span>
<span className="block truncate opacity-85">{formatEventRange(event)}</span>
{event.location && (
<span className="block truncate opacity-75">{event.location}</span>
)}
</button>
))}
</div>
);
})}
</div>
</div>
</div>
);
};
// ---------- Hauptansicht ----------
export const Calendar: React.FC<CalendarProps> = ({
events,
leads,
users,
connections,
isLoading,
reload,
}) => {
const { addToast } = useToast();
const { user, isAdmin } = useAuth();
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
const [cursor, setCursor] = useState<Date>(() => startOfDay(new Date()));
const [createStart, setCreateStart] = useState<Date | null>(null);
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
const [detailEventId, setDetailEventId] = useState<number | null>(null);
const [eventToDelete, setEventToDelete] = useState<CalendarEvent | null>(null);
const [detailUserId, setDetailUserId] = useState<number | null>(null);
const [detailLeadId, setDetailLeadId] = useState<number | null>(null);
const timeGridRef = useRef<HTMLDivElement>(null);
// Das Zeitraster startet am Vormittag - die Nachtstunden interessieren selten.
useEffect(() => {
if (viewMode === 'month') return;
const element = timeGridRef.current;
if (element) element.scrollTop = 7 * HOUR_HEIGHT;
}, [viewMode]);
const today = startOfDay(new Date());
// Deaktivierte Konten sollen nicht mehr auswaehlbar sein - zum Aufloesen
// bestehender Mentions bleibt die vollstaendige Liste in Verwendung.
const activeUsers = useMemo(() => users.filter((item) => item.is_active), [users]);
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of events) {
const start = startOfDay(parseSqlDateTime(event.starts_at));
const end = startOfDay(parseSqlDateTime(event.ends_at));
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue;
// Mehrtaegige Termine an jedem betroffenen Tag eintragen. Der Deckel bei
// 400 Tagen verhindert, dass ein Tippfehler im Jahr die Ansicht sprengt.
for (let day = start, guard = 0; day <= end && guard < 400; day = addDays(day, 1), guard++) {
const key = day.toDateString();
const list = map.get(key) ?? [];
list.push(event);
map.set(key, list);
}
}
for (const list of map.values()) list.sort(compareEvents);
return map;
}, [events]);
const dayEvents = useMemo(
() => (day: Date) => eventsByDay.get(day.toDateString()) ?? [],
[eventsByDay]
);
const detailEvent = detailEventId
? events.find((event) => event.id === detailEventId) ?? null
: null;
const detailUser = detailUserId ? users.find((item) => item.id === detailUserId) ?? null : null;
const detailLead = detailLeadId ? leads.find((lead) => lead.id === detailLeadId) ?? null : null;
const canModify = (event: CalendarEvent) => isAdmin || event.created_by === user?.id;
// Anstehende Termine fuer die Liste rechts: alles, was noch nicht vorbei ist,
// der naechste Termin zuerst.
const upcoming = useMemo(() => {
const now = Date.now();
return events
.filter((event) => parseSqlDateTime(event.ends_at).getTime() >= now)
.sort(
(a, b) =>
parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime() ||
a.id - b.id
);
}, [events]);
const upcomingGroups = useMemo(() => {
const groups: { day: Date; items: CalendarEvent[] }[] = [];
for (const event of upcoming) {
const day = startOfDay(parseSqlDateTime(event.starts_at));
const last = groups[groups.length - 1];
if (last && isSameDay(last.day, day)) last.items.push(event);
else groups.push({ day, items: [event] });
}
return groups;
}, [upcoming]);
// Nur Benutzer mit eigenen Terminen kommen in die Legende.
const legendUsers = useMemo(() => {
const ids = new Set(
events.map((event) => event.created_by).filter((id): id is number => id !== null)
);
return users.filter((item) => ids.has(item.id));
}, [events, users]);
const step = (direction: 1 | -1) =>
setCursor((current) => {
if (viewMode === 'month') return addMonths(current, direction);
if (viewMode === 'week') return addDays(current, direction * 7);
return addDays(current, direction);
});
const rangeLabel =
viewMode === 'month'
? formatMonthYear(cursor)
: viewMode === 'week'
? (() => {
const first = startOfWeek(cursor);
const last = addDays(first, 6);
return `${first.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
})} – ${last.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}`;
})()
: formatDayLong(cursor);
const openCreate = (start: Date) => {
setEditEvent(null);
setCreateStart(start);
};
const showDay = (day: Date) => {
setCursor(startOfDay(day));
setViewMode('day');
};
const confirmDelete = async () => {
if (!eventToDelete) return;
try {
await eventsApi.remove(eventToDelete.id);
addToast('Termin geloescht.', 'success');
setDetailEventId(null);
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error');
} finally {
setEventToDelete(null);
}
};
const gridProps: GridProps = {
today,
dayEvents,
onCreate: openCreate,
onOpenEvent: setDetailEventId,
onShowDay: showDay,
};
return (
<div className="space-y-4">
{/* Steuerleiste */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex items-center gap-2">
<button
onClick={() => step(-1)}
aria-label="Zurueck"
className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setCursor(startOfDay(new Date()))}
className="rounded-xl border border-ink-200 bg-surface px-4 py-2 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
>
Heute
</button>
<button
onClick={() => step(1)}
aria-label="Weiter"
className="rounded-xl border border-ink-200 bg-surface p-2 text-ink-500 transition hover:border-brand-300 hover:text-brand-700"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<p className="ml-2 truncate text-sm font-extrabold text-ink-800">{rangeLabel}</p>
</div>
<div className="flex items-center gap-3 lg:ml-auto">
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
{VIEW_BUTTONS.map((button) => (
<button
key={button.mode}
onClick={() => setViewMode(button.mode)}
aria-pressed={viewMode === button.mode}
className={`rounded-lg px-3 py-1.5 text-sm font-bold transition ${
viewMode === button.mode
? 'bg-brand-500 text-white'
: 'text-ink-500 hover:bg-brand-50 hover:text-brand-700'
}`}
>
{button.label}
</button>
))}
</div>
<button
onClick={() => openCreate(new Date(new Date().setMinutes(0, 0, 0) + 60 * 60 * 1000))}
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Termin hinzufuegen
</button>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
{/* Kalender */}
<div className="space-y-3">
{isLoading && events.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : viewMode === 'month' ? (
<MonthView cursor={cursor} {...gridProps} />
) : (
<TimeGrid
days={viewMode === 'week' ? weekDays(cursor) : [startOfDay(cursor)]}
scrollRef={timeGridRef}
{...gridProps}
/>
)}
{legendUsers.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-ink-200 bg-surface px-4 py-3">
<span className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
Farben
</span>
{legendUsers.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setDetailUserId(item.id)}
className="flex items-center gap-2 text-xs font-bold text-ink-600 transition hover:text-brand-700"
>
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: userColor(item) }}
/>
{item.name}
{item.id === user?.id && <span className="text-ink-300">(ich)</span>}
</button>
))}
</div>
)}
</div>
{/* Anstehende Termine */}
<aside className="flex flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface">
<div className="shrink-0 border-b border-ink-100 px-4 py-3">
<h2 className="text-sm text-ink-900">Naechste Termine</h2>
<p className="text-xs text-ink-400">
{upcoming.length === 0
? 'Keine anstehenden Termine'
: `${upcoming.length} ${
upcoming.length === 1 ? 'Termin' : 'Termine'
} · naechster zuerst`}
</p>
</div>
<div className="scrollbar-slim max-h-[70vh] flex-1 overflow-y-auto p-3">
{upcomingGroups.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-200 px-4 py-10 text-center text-xs font-semibold text-ink-400">
Nichts geplant. Lege links im Kalender einen Termin an.
</p>
) : (
<ul className="space-y-4">
{upcomingGroups.map((group) => (
<li key={group.day.toDateString()}>
<p className="mb-2 flex items-center gap-2 px-1 text-[10px] font-extrabold uppercase tracking-[0.18em] text-ink-400">
{isSameDay(group.day, today)
? 'Heute'
: isSameDay(group.day, addDays(today, 1))
? 'Morgen'
: group.day.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
})}
<span className="h-px flex-1 bg-ink-100" />
</p>
<ul className="space-y-2">
{group.items.map((event) => (
<li key={event.id}>
<div
role="button"
tabIndex={0}
onClick={() => setDetailEventId(event.id)}
onKeyDown={(keyEvent) => {
if (keyEvent.key === 'Enter' || keyEvent.key === ' ') {
keyEvent.preventDefault();
setDetailEventId(event.id);
}
}}
className="flex cursor-pointer items-start gap-3 rounded-xl border border-ink-200 bg-surface p-3 text-left transition hover:border-brand-300 hover:shadow-card"
>
<span
className="mt-0.5 w-1.5 shrink-0 self-stretch rounded-full"
style={{ backgroundColor: eventColor(event) }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-ink-900">{event.title}</p>
<p className="text-xs font-semibold text-ink-500">
{formatEventRange(event)}
</p>
{event.location && (
<p className="truncate text-xs text-ink-400">{event.location}</p>
)}
{event.description && (
<div className="line-clamp-3 mt-1.5 text-xs text-ink-500">
<MentionText
text={event.description}
leads={leads}
users={users}
onOpenLead={setDetailLeadId}
onOpenUser={setDetailUserId}
/>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{event.participants.map((participant) => (
<button
key={participant.id}
type="button"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
setDetailUserId(participant.id);
}}
className="rounded-full transition hover:brightness-110"
>
<UserAvatar user={participant} size={24} />
</button>
))}
{event.created_by_name && (
<span className="ml-auto truncate text-[10px] font-bold uppercase tracking-wider text-ink-300">
{event.created_by_name}
</span>
)}
</div>
</div>
</div>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
</aside>
</div>
{/* Dialoge */}
{(createStart !== null || editEvent !== null) && (
<EventModal
event={editEvent}
defaultStart={createStart ?? undefined}
leads={leads}
users={activeUsers}
currentUserId={user?.id ?? 0}
onClose={() => {
setCreateStart(null);
setEditEvent(null);
}}
onSaved={reload}
/>
)}
{detailEvent && editEvent === null && (
<EventDetailModal
event={detailEvent}
leads={leads}
users={users}
canModify={canModify(detailEvent)}
onEdit={() => {
setEditEvent(detailEvent);
setDetailEventId(null);
}}
onDelete={() => setEventToDelete(detailEvent)}
onClose={() => setDetailEventId(null)}
onOpenLead={setDetailLeadId}
onOpenUser={setDetailUserId}
/>
)}
{detailUser && (
<UserDetailModal
user={detailUser}
leads={leads}
events={events}
onClose={() => setDetailUserId(null)}
onOpenLead={(leadId) => {
setDetailUserId(null);
setDetailLeadId(leadId);
}}
/>
)}
{detailLead && (
<LeadModal
lead={detailLead}
leads={leads}
users={users}
connections={connections}
onClose={() => setDetailLeadId(null)}
onSaved={reload}
/>
)}
{eventToDelete && (
<ConfirmDialog
title="Termin loeschen?"
message={`"${eventToDelete.title}"${
eventToDelete.description
? ` – ${mentionPlainText(eventToDelete.description).slice(0, 80)}`
: ''
} wird fuer alle Benutzer entfernt.`}
onConfirm={confirmDelete}
onCancel={() => setEventToDelete(null)}
/>
)}
</div>
);
};