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>
This commit is contained in:
@@ -0,0 +1,770 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user