mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat(frontend): Kalender-Event-Modal + Zeitzonen-Fix
- Neues CalendarEventModal: Klick auf Termin in Agenda-/Wochenansicht öffnet Popup mit Betreff, Datum/Uhrzeit, Ort, Organisator, Teilnehmern und Online-Meeting-Link - CRM-Aktivität: Kontaktsuche direkt im Modal; Termin als MEETING- Aktivität beim gewählten CRM-Kontakt speichern - "Im Outlook öffnen"-Link im Modal-Footer - Zeitzonen-Fix: MS Graph liefert UTC-Zeiten ohne 'Z'-Suffix → toDate() hängt 'Z' an → alle Termine jetzt in korrekter Ortszeit - DayAgenda + AgendaView + WeekView: <a>-Tags durch klickbare <div> (role=button) ersetzt; onEventClick-Prop weitergereicht Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3d75a7f9de
commit
2d56ab6b3d
2 changed files with 591 additions and 42 deletions
|
|
@ -619,3 +619,306 @@
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Event-Detail-Modal ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg, var(--radius-md));
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
position: relative;
|
||||||
|
padding: 1.75rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalClose {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalClose:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalTitle {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 2.25rem 0.5rem 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalDateTime {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalDateSep {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalLocation {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detailbox ── */
|
||||||
|
|
||||||
|
.modalDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.875rem 0 1rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
min-width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLink {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailEmpty {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendeeList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendeeItem {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kontakt-Zuordnung ── */
|
||||||
|
|
||||||
|
.contactSection {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactSectionTitle {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactSearchWrap {
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactSearchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactSearchInput:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactResultList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactResultItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactResultItem:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactResultItemSelected {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
outline: 1px solid var(--color-primary);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactResultName {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactResultMeta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactResultEmpty {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveActivityBtn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5625rem 1rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveActivityBtn:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveActivityBtn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveActivityBtnError {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactSectionSuccess {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #16a34a;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal-Footer ── */
|
||||||
|
|
||||||
|
.modalFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 0.875rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outlookBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.4375rem 1rem;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outlookBtn:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks';
|
import { useIntegrations, useOffice365CalendarRange, useContacts, useCreateActivity } from '../crm/hooks';
|
||||||
import type { M365CalendarEvent } from '../crm/types';
|
import type { M365CalendarEvent, Contact } from '../crm/types';
|
||||||
import styles from './DashboardCalendarTab.module.css';
|
import styles from './DashboardCalendarTab.module.css';
|
||||||
|
|
||||||
export type CalendarViewMode = 'month' | 'week' | 'agenda';
|
export type CalendarViewMode = 'month' | 'week' | 'agenda';
|
||||||
|
|
@ -46,8 +46,19 @@ function toISODate(date: Date): string {
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
/**
|
||||||
return new Date(iso).toLocaleTimeString('de-DE', {
|
* Timezone-aware Date: MS Graph gibt UTC-Zeiten ohne 'Z'-Suffix zurück.
|
||||||
|
* Ohne diesen Fix interpretiert JavaScript die Zeiten als Ortszeit → 1h Versatz.
|
||||||
|
*/
|
||||||
|
function toDate(dateTime: string, timeZone: string): Date {
|
||||||
|
if (timeZone === 'UTC' && !dateTime.endsWith('Z') && !dateTime.includes('+')) {
|
||||||
|
return new Date(dateTime + 'Z');
|
||||||
|
}
|
||||||
|
return new Date(dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateTime: string, timeZone: string): string {
|
||||||
|
return toDate(dateTime, timeZone).toLocaleTimeString('de-DE', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|
@ -55,10 +66,11 @@ function formatTime(iso: string): string {
|
||||||
|
|
||||||
function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] {
|
function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] {
|
||||||
return events
|
return events
|
||||||
.filter((e) => isSameDay(new Date(e.start.dateTime), day))
|
.filter((e) => isSameDay(toDate(e.start.dateTime, e.start.timeZone), day))
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime(),
|
toDate(a.start.dateTime, a.start.timeZone).getTime() -
|
||||||
|
toDate(b.start.dateTime, b.start.timeZone).getTime(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,16 +82,222 @@ function eventColor(id: string): string {
|
||||||
return EVENT_COLORS[h];
|
return EVENT_COLORS[h];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Calendar Event Detail Modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CalendarEventModal({
|
||||||
|
event,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
event: M365CalendarEvent;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const createActivity = useCreateActivity();
|
||||||
|
const [contactSearch, setContactSearch] = useState('');
|
||||||
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||||
|
const [actStatus, setActStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
const contactsQuery = useContacts({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 8,
|
||||||
|
search: contactSearch || undefined,
|
||||||
|
});
|
||||||
|
const contacts: Contact[] = contactsQuery.data?.data ?? [];
|
||||||
|
|
||||||
|
const startDate = toDate(event.start.dateTime, event.start.timeZone);
|
||||||
|
const endDate = toDate(event.end.dateTime, event.end.timeZone);
|
||||||
|
|
||||||
|
const dateLabel = startDate.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
const timeLabel = `${formatTime(event.start.dateTime, event.start.timeZone)} – ${formatTime(event.end.dateTime, event.end.timeZone)} Uhr`;
|
||||||
|
|
||||||
|
const contactDisplayName = (c: Contact) =>
|
||||||
|
[c.firstName, c.lastName].filter(Boolean).join(' ') || c.email || c.id;
|
||||||
|
|
||||||
|
const handleSaveActivity = async () => {
|
||||||
|
if (!selectedContact || actStatus === 'loading') return;
|
||||||
|
if (actStatus === 'error') {
|
||||||
|
setActStatus('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActStatus('loading');
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const isPast = endDate < now;
|
||||||
|
await createActivity.mutateAsync({
|
||||||
|
contactId: selectedContact.id,
|
||||||
|
type: 'MEETING',
|
||||||
|
subject: event.subject,
|
||||||
|
description: event.attendees?.length
|
||||||
|
? `Teilnehmer: ${event.attendees.map((a) => a.emailAddress.name || a.emailAddress.address).join(', ')}`
|
||||||
|
: undefined,
|
||||||
|
scheduledAt: startDate.toISOString(),
|
||||||
|
completedAt: isPast ? endDate.toISOString() : undefined,
|
||||||
|
});
|
||||||
|
setActStatus('success');
|
||||||
|
} catch {
|
||||||
|
setActStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
{/* Schließen */}
|
||||||
|
<button className={styles.modalClose} onClick={onClose} title="Schließen">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Titel */}
|
||||||
|
<h2 className={styles.modalTitle}>{event.subject}</h2>
|
||||||
|
<div className={styles.modalDateTime}>
|
||||||
|
<span>📅 {dateLabel}</span>
|
||||||
|
<span className={styles.modalDateSep}>·</span>
|
||||||
|
<span>🕐 {timeLabel}</span>
|
||||||
|
{event.isOnlineMeeting && <span className={styles.onlineBadge}>Online</span>}
|
||||||
|
</div>
|
||||||
|
{event.location?.displayName && (
|
||||||
|
<div className={styles.modalLocation}>📍 {event.location.displayName}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detailbox */}
|
||||||
|
<div className={styles.modalDetails}>
|
||||||
|
{event.organizer && (
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Organisator</span>
|
||||||
|
<span className={styles.detailValue}>
|
||||||
|
{event.organizer.emailAddress.name || event.organizer.emailAddress.address}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.attendees && event.attendees.length > 0 && (
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Teilnehmer</span>
|
||||||
|
<div className={styles.attendeeList}>
|
||||||
|
{event.attendees.map((a, i) => (
|
||||||
|
<div key={i} className={styles.attendeeItem}>
|
||||||
|
{a.emailAddress.name
|
||||||
|
? `${a.emailAddress.name} <${a.emailAddress.address}>`
|
||||||
|
: a.emailAddress.address}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.onlineMeetingUrl && (
|
||||||
|
<div className={styles.detailRow}>
|
||||||
|
<span className={styles.detailLabel}>Meeting</span>
|
||||||
|
<a
|
||||||
|
href={event.onlineMeetingUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.detailLink}
|
||||||
|
>
|
||||||
|
Beitreten ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!event.organizer && (!event.attendees || event.attendees.length === 0) && !event.onlineMeetingUrl && (
|
||||||
|
<p className={styles.detailEmpty}>Keine weiteren Details verfügbar.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zu Kontakt hinzufügen */}
|
||||||
|
{actStatus !== 'success' ? (
|
||||||
|
<div className={styles.contactSection}>
|
||||||
|
<div className={styles.contactSectionTitle}>Als Aktivität bei Kontakt speichern</div>
|
||||||
|
<div className={styles.contactSearchWrap}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Kontakt suchen…"
|
||||||
|
value={contactSearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setContactSearch(e.target.value);
|
||||||
|
setSelectedContact(null);
|
||||||
|
setActStatus('idle');
|
||||||
|
}}
|
||||||
|
className={styles.contactSearchInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(contacts.length > 0 || contactsQuery.isLoading) && (
|
||||||
|
<div className={styles.contactResultList}>
|
||||||
|
{contactsQuery.isLoading ? (
|
||||||
|
<div className={styles.contactResultEmpty}>Suche…</div>
|
||||||
|
) : (
|
||||||
|
contacts.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`${styles.contactResultItem} ${selectedContact?.id === c.id ? styles.contactResultItemSelected : ''}`}
|
||||||
|
onClick={() => setSelectedContact(c)}
|
||||||
|
>
|
||||||
|
<span className={styles.contactResultName}>{contactDisplayName(c)}</span>
|
||||||
|
{c.company?.name && (
|
||||||
|
<span className={styles.contactResultMeta}>{c.company.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contacts.length === 0 && !contactsQuery.isLoading && contactSearch.length > 0 && (
|
||||||
|
<div className={styles.contactResultEmpty}>Keine Kontakte gefunden</div>
|
||||||
|
)}
|
||||||
|
{selectedContact && (
|
||||||
|
<button
|
||||||
|
className={`${styles.saveActivityBtn} ${actStatus === 'error' ? styles.saveActivityBtnError : ''}`}
|
||||||
|
onClick={handleSaveActivity}
|
||||||
|
disabled={actStatus === 'loading'}
|
||||||
|
>
|
||||||
|
{actStatus === 'loading'
|
||||||
|
? 'Speichere…'
|
||||||
|
: actStatus === 'error'
|
||||||
|
? '✕ Fehler – Erneut versuchen'
|
||||||
|
: `Als Meeting bei „${contactDisplayName(selectedContact)}" speichern`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.contactSectionSuccess}>
|
||||||
|
✓ Meeting-Aktivität wurde erfolgreich gespeichert
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={styles.modalFooter}>
|
||||||
|
<a
|
||||||
|
href={event.webLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.outlookBtn}
|
||||||
|
>
|
||||||
|
Im Outlook öffnen ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Day Agenda (Tages-Detailansicht, auch für Home) ───────────────────────────
|
// ── Day Agenda (Tages-Detailansicht, auch für Home) ───────────────────────────
|
||||||
|
|
||||||
export function DayAgenda({
|
export function DayAgenda({
|
||||||
day,
|
day,
|
||||||
events,
|
events,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
|
onEventClick,
|
||||||
}: {
|
}: {
|
||||||
day: Date;
|
day: Date;
|
||||||
events: M365CalendarEvent[];
|
events: M365CalendarEvent[];
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
onEventClick?: (event: M365CalendarEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
const dayEvents = getEventsForDay(events, day);
|
const dayEvents = getEventsForDay(events, day);
|
||||||
|
|
||||||
|
|
@ -99,18 +317,21 @@ export function DayAgenda({
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.agendaList}>
|
<div className={styles.agendaList}>
|
||||||
{dayEvents.map((event) => (
|
{dayEvents.map((event) => (
|
||||||
<a
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
href={event.webLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={styles.agendaItem}
|
className={styles.agendaItem}
|
||||||
style={{ borderLeftColor: eventColor(event.id) }}
|
style={{ borderLeftColor: eventColor(event.id), cursor: 'pointer' }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onEventClick?.(event)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onEventClick?.(event);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.agendaTime}>
|
<div className={styles.agendaTime}>
|
||||||
{formatTime(event.start.dateTime)}
|
{formatTime(event.start.dateTime, event.start.timeZone)}
|
||||||
{' – '}
|
{' – '}
|
||||||
{formatTime(event.end.dateTime)}
|
{formatTime(event.end.dateTime, event.end.timeZone)}
|
||||||
{event.isOnlineMeeting && (
|
{event.isOnlineMeeting && (
|
||||||
<span className={styles.onlineBadge}>Online</span>
|
<span className={styles.onlineBadge}>Online</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -131,7 +352,7 @@ export function DayAgenda({
|
||||||
{event.attendees.length > 2 && ` +${event.attendees.length - 2}`}
|
{event.attendees.length > 2 && ` +${event.attendees.length - 2}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</a>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -146,11 +367,13 @@ function AgendaView({
|
||||||
events,
|
events,
|
||||||
selectedDay,
|
selectedDay,
|
||||||
onDayClick,
|
onDayClick,
|
||||||
|
onEventClick,
|
||||||
}: {
|
}: {
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
events: M365CalendarEvent[];
|
events: M365CalendarEvent[];
|
||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
onDayClick: (d: Date) => void;
|
onDayClick: (d: Date) => void;
|
||||||
|
onEventClick: (event: M365CalendarEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
// 14 Tage ab currentDate
|
// 14 Tage ab currentDate
|
||||||
|
|
@ -160,7 +383,7 @@ function AgendaView({
|
||||||
<div className={styles.agendaFullView}>
|
<div className={styles.agendaFullView}>
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const dayEvents = getEventsForDay(events, day);
|
const dayEvents = getEventsForDay(events, day);
|
||||||
const isToday = isSameDay(day, today);
|
const isToday = isSameDay(day, today);
|
||||||
const isSelected = isSameDay(day, selectedDay);
|
const isSelected = isSameDay(day, selectedDay);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -184,23 +407,29 @@ function AgendaView({
|
||||||
<span className={styles.agendaFullEmpty}>Kein Termin</span>
|
<span className={styles.agendaFullEmpty}>Kein Termin</span>
|
||||||
) : (
|
) : (
|
||||||
dayEvents.map((e) => (
|
dayEvents.map((e) => (
|
||||||
<a
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
href={e.webLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={styles.agendaFullEvent}
|
className={styles.agendaFullEvent}
|
||||||
style={{ borderLeftColor: eventColor(e.id) }}
|
style={{ borderLeftColor: eventColor(e.id), cursor: 'pointer' }}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
onEventClick(e);
|
||||||
|
}}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') onEventClick(e);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.agendaFullEventTime}>
|
<span className={styles.agendaFullEventTime}>
|
||||||
{formatTime(e.start.dateTime)}
|
{formatTime(e.start.dateTime, e.start.timeZone)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.agendaFullEventSubj}>{e.subject}</span>
|
<span className={styles.agendaFullEventSubj}>{e.subject}</span>
|
||||||
{e.isOnlineMeeting && (
|
{e.isOnlineMeeting && (
|
||||||
<span className={styles.onlineBadge}>Online</span>
|
<span className={styles.onlineBadge}>Online</span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -226,9 +455,9 @@ function MonthView({
|
||||||
events: M365CalendarEvent[];
|
events: M365CalendarEvent[];
|
||||||
onDayClick: (d: Date) => void;
|
onDayClick: (d: Date) => void;
|
||||||
}) {
|
}) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const cells = getMonthCells(currentDate);
|
const cells = getMonthCells(currentDate);
|
||||||
const month = currentDate.getMonth();
|
const month = currentDate.getMonth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.monthGrid}>
|
<div className={styles.monthGrid}>
|
||||||
|
|
@ -241,17 +470,17 @@ function MonthView({
|
||||||
|
|
||||||
{/* Tageszellen */}
|
{/* Tageszellen */}
|
||||||
{cells.map((day, i) => {
|
{cells.map((day, i) => {
|
||||||
const inMonth = day.getMonth() === month;
|
const inMonth = day.getMonth() === month;
|
||||||
const isToday = isSameDay(day, today);
|
const isToday = isSameDay(day, today);
|
||||||
const isSelected = isSameDay(day, selectedDay);
|
const isSelected = isSameDay(day, selectedDay);
|
||||||
const dayEvents = getEventsForDay(events, day);
|
const dayEvents = getEventsForDay(events, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={[
|
className={[
|
||||||
styles.dayCell,
|
styles.dayCell,
|
||||||
!inMonth ? styles.dayCellOther : '',
|
!inMonth ? styles.dayCellOther : '',
|
||||||
isSelected ? styles.dayCellSelected : '',
|
isSelected ? styles.dayCellSelected : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
@ -269,9 +498,9 @@ function MonthView({
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className={styles.eventChip}
|
className={styles.eventChip}
|
||||||
style={{ background: eventColor(e.id) }}
|
style={{ background: eventColor(e.id) }}
|
||||||
title={`${formatTime(e.start.dateTime)} ${e.subject}`}
|
title={`${formatTime(e.start.dateTime, e.start.timeZone)} ${e.subject}`}
|
||||||
>
|
>
|
||||||
{formatTime(e.start.dateTime)} {e.subject}
|
{formatTime(e.start.dateTime, e.start.timeZone)} {e.subject}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{dayEvents.length > 2 && (
|
{dayEvents.length > 2 && (
|
||||||
|
|
@ -292,21 +521,23 @@ function WeekView({
|
||||||
selectedDay,
|
selectedDay,
|
||||||
events,
|
events,
|
||||||
onDayClick,
|
onDayClick,
|
||||||
|
onEventClick,
|
||||||
}: {
|
}: {
|
||||||
currentDate: Date;
|
currentDate: Date;
|
||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
events: M365CalendarEvent[];
|
events: M365CalendarEvent[];
|
||||||
onDayClick: (d: Date) => void;
|
onDayClick: (d: Date) => void;
|
||||||
|
onEventClick: (event: M365CalendarEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const weekDays = getWorkWeekDays(currentDate); // nur Mo–Fr
|
const weekDays = getWorkWeekDays(currentDate); // nur Mo–Fr
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.weekGrid}>
|
<div className={styles.weekGrid}>
|
||||||
{weekDays.map((day) => {
|
{weekDays.map((day) => {
|
||||||
const isToday = isSameDay(day, today);
|
const isToday = isSameDay(day, today);
|
||||||
const isSelected = isSameDay(day, selectedDay);
|
const isSelected = isSameDay(day, selectedDay);
|
||||||
const dayEvents = getEventsForDay(events, day);
|
const dayEvents = getEventsForDay(events, day);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -335,11 +566,11 @@ function WeekView({
|
||||||
title={e.subject}
|
title={e.subject}
|
||||||
onClick={(ev) => {
|
onClick={(ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
window.open(e.webLink, '_blank', 'noopener,noreferrer');
|
onEventClick(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.weekEventTime}>
|
<span className={styles.weekEventTime}>
|
||||||
{formatTime(e.start.dateTime)}
|
{formatTime(e.start.dateTime, e.start.timeZone)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.weekEventSubj}>{e.subject}</span>
|
<span className={styles.weekEventSubj}>{e.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -361,9 +592,10 @@ export function DashboardCalendarTab() {
|
||||||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||||
) ?? false;
|
) ?? false;
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
|
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
|
||||||
const [currentDate, setCurrentDate] = useState(() => new Date());
|
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||||
const [selectedDay, setSelectedDay] = useState(() => new Date());
|
const [selectedDay, setSelectedDay] = useState(() => new Date());
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<M365CalendarEvent | null>(null);
|
||||||
|
|
||||||
// Datumsbereich berechnen
|
// Datumsbereich berechnen
|
||||||
const rangeStart =
|
const rangeStart =
|
||||||
|
|
@ -422,7 +654,7 @@ export function DashboardCalendarTab() {
|
||||||
}
|
}
|
||||||
// agenda
|
// agenda
|
||||||
const end = addDays(currentDate, 13);
|
const end = addDays(currentDate, 13);
|
||||||
const sm = currentDate.getMonth() === end.getMonth();
|
const sm = currentDate.getMonth() === end.getMonth();
|
||||||
return sm
|
return sm
|
||||||
? `${currentDate.getDate()}. – ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`
|
? `${currentDate.getDate()}. – ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`
|
||||||
: `${currentDate.getDate()}. ${currentDate.toLocaleDateString('de-DE', { month: 'short' })} – ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`;
|
: `${currentDate.getDate()}. ${currentDate.toLocaleDateString('de-DE', { month: 'short' })} – ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`;
|
||||||
|
|
@ -529,6 +761,7 @@ export function DashboardCalendarTab() {
|
||||||
selectedDay={selectedDay}
|
selectedDay={selectedDay}
|
||||||
events={events}
|
events={events}
|
||||||
onDayClick={setSelectedDay}
|
onDayClick={setSelectedDay}
|
||||||
|
onEventClick={setSelectedEvent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{viewMode === 'agenda' && (
|
{viewMode === 'agenda' && (
|
||||||
|
|
@ -537,16 +770,29 @@ export function DashboardCalendarTab() {
|
||||||
events={events}
|
events={events}
|
||||||
selectedDay={selectedDay}
|
selectedDay={selectedDay}
|
||||||
onDayClick={setSelectedDay}
|
onDayClick={setSelectedDay}
|
||||||
|
onEventClick={setSelectedEvent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tages-Agenda nur bei Monat- und Wochenansicht */}
|
{/* Tages-Agenda nur bei Monat- und Wochenansicht */}
|
||||||
{(viewMode === 'month' || viewMode === 'week') && (
|
{(viewMode === 'month' || viewMode === 'week') && (
|
||||||
<DayAgenda day={selectedDay} events={events} />
|
<DayAgenda
|
||||||
|
day={selectedDay}
|
||||||
|
events={events}
|
||||||
|
onEventClick={setSelectedEvent}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Event-Detail-Modal */}
|
||||||
|
{selectedEvent && (
|
||||||
|
<CalendarEventModal
|
||||||
|
event={selectedEvent}
|
||||||
|
onClose={() => setSelectedEvent(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue