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;
|
||||
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 { useIntegrations, useOffice365CalendarRange } from '../crm/hooks';
|
||||
import type { M365CalendarEvent } from '../crm/types';
|
||||
import { useIntegrations, useOffice365CalendarRange, useContacts, useCreateActivity } from '../crm/hooks';
|
||||
import type { M365CalendarEvent, Contact } from '../crm/types';
|
||||
import styles from './DashboardCalendarTab.module.css';
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'agenda';
|
||||
|
|
@ -46,8 +46,19 @@ function toISODate(date: Date): string {
|
|||
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',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
|
@ -55,10 +66,11 @@ function formatTime(iso: string): string {
|
|||
|
||||
function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] {
|
||||
return events
|
||||
.filter((e) => isSameDay(new Date(e.start.dateTime), day))
|
||||
.filter((e) => isSameDay(toDate(e.start.dateTime, e.start.timeZone), day))
|
||||
.sort(
|
||||
(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];
|
||||
}
|
||||
|
||||
// ── 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) ───────────────────────────
|
||||
|
||||
export function DayAgenda({
|
||||
day,
|
||||
events,
|
||||
fullWidth = false,
|
||||
onEventClick,
|
||||
}: {
|
||||
day: Date;
|
||||
events: M365CalendarEvent[];
|
||||
fullWidth?: boolean;
|
||||
onEventClick?: (event: M365CalendarEvent) => void;
|
||||
}) {
|
||||
const dayEvents = getEventsForDay(events, day);
|
||||
|
||||
|
|
@ -99,18 +317,21 @@ export function DayAgenda({
|
|||
) : (
|
||||
<div className={styles.agendaList}>
|
||||
{dayEvents.map((event) => (
|
||||
<a
|
||||
<div
|
||||
key={event.id}
|
||||
href={event.webLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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}>
|
||||
{formatTime(event.start.dateTime)}
|
||||
{formatTime(event.start.dateTime, event.start.timeZone)}
|
||||
{' – '}
|
||||
{formatTime(event.end.dateTime)}
|
||||
{formatTime(event.end.dateTime, event.end.timeZone)}
|
||||
{event.isOnlineMeeting && (
|
||||
<span className={styles.onlineBadge}>Online</span>
|
||||
)}
|
||||
|
|
@ -131,7 +352,7 @@ export function DayAgenda({
|
|||
{event.attendees.length > 2 && ` +${event.attendees.length - 2}`}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -146,11 +367,13 @@ function AgendaView({
|
|||
events,
|
||||
selectedDay,
|
||||
onDayClick,
|
||||
onEventClick,
|
||||
}: {
|
||||
currentDate: Date;
|
||||
events: M365CalendarEvent[];
|
||||
selectedDay: Date;
|
||||
onDayClick: (d: Date) => void;
|
||||
onEventClick: (event: M365CalendarEvent) => void;
|
||||
}) {
|
||||
const today = new Date();
|
||||
// 14 Tage ab currentDate
|
||||
|
|
@ -160,7 +383,7 @@ function AgendaView({
|
|||
<div className={styles.agendaFullView}>
|
||||
{days.map((day) => {
|
||||
const dayEvents = getEventsForDay(events, day);
|
||||
const isToday = isSameDay(day, today);
|
||||
const isToday = isSameDay(day, today);
|
||||
const isSelected = isSameDay(day, selectedDay);
|
||||
return (
|
||||
<div
|
||||
|
|
@ -184,23 +407,29 @@ function AgendaView({
|
|||
<span className={styles.agendaFullEmpty}>Kein Termin</span>
|
||||
) : (
|
||||
dayEvents.map((e) => (
|
||||
<a
|
||||
<div
|
||||
key={e.id}
|
||||
href={e.webLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.agendaFullEvent}
|
||||
style={{ borderLeftColor: eventColor(e.id) }}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
style={{ borderLeftColor: eventColor(e.id), cursor: 'pointer' }}
|
||||
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}>
|
||||
{formatTime(e.start.dateTime)}
|
||||
{formatTime(e.start.dateTime, e.start.timeZone)}
|
||||
</span>
|
||||
<span className={styles.agendaFullEventSubj}>{e.subject}</span>
|
||||
{e.isOnlineMeeting && (
|
||||
<span className={styles.onlineBadge}>Online</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -226,9 +455,9 @@ function MonthView({
|
|||
events: M365CalendarEvent[];
|
||||
onDayClick: (d: Date) => void;
|
||||
}) {
|
||||
const today = new Date();
|
||||
const cells = getMonthCells(currentDate);
|
||||
const month = currentDate.getMonth();
|
||||
const today = new Date();
|
||||
const cells = getMonthCells(currentDate);
|
||||
const month = currentDate.getMonth();
|
||||
|
||||
return (
|
||||
<div className={styles.monthGrid}>
|
||||
|
|
@ -241,17 +470,17 @@ function MonthView({
|
|||
|
||||
{/* Tageszellen */}
|
||||
{cells.map((day, i) => {
|
||||
const inMonth = day.getMonth() === month;
|
||||
const isToday = isSameDay(day, today);
|
||||
const inMonth = day.getMonth() === month;
|
||||
const isToday = isSameDay(day, today);
|
||||
const isSelected = isSameDay(day, selectedDay);
|
||||
const dayEvents = getEventsForDay(events, day);
|
||||
const dayEvents = getEventsForDay(events, day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
styles.dayCell,
|
||||
!inMonth ? styles.dayCellOther : '',
|
||||
!inMonth ? styles.dayCellOther : '',
|
||||
isSelected ? styles.dayCellSelected : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
|
@ -269,9 +498,9 @@ function MonthView({
|
|||
key={e.id}
|
||||
className={styles.eventChip}
|
||||
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>
|
||||
))}
|
||||
{dayEvents.length > 2 && (
|
||||
|
|
@ -292,21 +521,23 @@ function WeekView({
|
|||
selectedDay,
|
||||
events,
|
||||
onDayClick,
|
||||
onEventClick,
|
||||
}: {
|
||||
currentDate: Date;
|
||||
selectedDay: Date;
|
||||
events: M365CalendarEvent[];
|
||||
onDayClick: (d: Date) => void;
|
||||
onEventClick: (event: M365CalendarEvent) => void;
|
||||
}) {
|
||||
const today = new Date();
|
||||
const today = new Date();
|
||||
const weekDays = getWorkWeekDays(currentDate); // nur Mo–Fr
|
||||
|
||||
return (
|
||||
<div className={styles.weekGrid}>
|
||||
{weekDays.map((day) => {
|
||||
const isToday = isSameDay(day, today);
|
||||
const isToday = isSameDay(day, today);
|
||||
const isSelected = isSameDay(day, selectedDay);
|
||||
const dayEvents = getEventsForDay(events, day);
|
||||
const dayEvents = getEventsForDay(events, day);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -335,11 +566,11 @@ function WeekView({
|
|||
title={e.subject}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
window.open(e.webLink, '_blank', 'noopener,noreferrer');
|
||||
onEventClick(e);
|
||||
}}
|
||||
>
|
||||
<span className={styles.weekEventTime}>
|
||||
{formatTime(e.start.dateTime)}
|
||||
{formatTime(e.start.dateTime, e.start.timeZone)}
|
||||
</span>
|
||||
<span className={styles.weekEventSubj}>{e.subject}</span>
|
||||
</div>
|
||||
|
|
@ -361,9 +592,10 @@ export function DashboardCalendarTab() {
|
|||
(i) => i.provider === 'MICROSOFT_365' && i.connected,
|
||||
) ?? false;
|
||||
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
|
||||
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||
const [selectedDay, setSelectedDay] = useState(() => new Date());
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
|
||||
const [currentDate, setCurrentDate] = useState(() => new Date());
|
||||
const [selectedDay, setSelectedDay] = useState(() => new Date());
|
||||
const [selectedEvent, setSelectedEvent] = useState<M365CalendarEvent | null>(null);
|
||||
|
||||
// Datumsbereich berechnen
|
||||
const rangeStart =
|
||||
|
|
@ -422,7 +654,7 @@ export function DashboardCalendarTab() {
|
|||
}
|
||||
// agenda
|
||||
const end = addDays(currentDate, 13);
|
||||
const sm = currentDate.getMonth() === end.getMonth();
|
||||
const sm = currentDate.getMonth() === end.getMonth();
|
||||
return sm
|
||||
? `${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' })}`;
|
||||
|
|
@ -529,6 +761,7 @@ export function DashboardCalendarTab() {
|
|||
selectedDay={selectedDay}
|
||||
events={events}
|
||||
onDayClick={setSelectedDay}
|
||||
onEventClick={setSelectedEvent}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'agenda' && (
|
||||
|
|
@ -537,16 +770,29 @@ export function DashboardCalendarTab() {
|
|||
events={events}
|
||||
selectedDay={selectedDay}
|
||||
onDayClick={setSelectedDay}
|
||||
onEventClick={setSelectedEvent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tages-Agenda nur bei Monat- und Wochenansicht */}
|
||||
{(viewMode === 'month' || viewMode === 'week') && (
|
||||
<DayAgenda day={selectedDay} events={events} />
|
||||
<DayAgenda
|
||||
day={selectedDay}
|
||||
events={events}
|
||||
onEventClick={setSelectedEvent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event-Detail-Modal */}
|
||||
{selectedEvent && (
|
||||
<CalendarEventModal
|
||||
event={selectedEvent}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue