Files
leocrm/frontend/src/components/calendar/MonthView.tsx
T
A0-Orchestrator 7350739554 T08b: Frontend Calendar UI (month view, kanban, ICS, resources, sharing)
- 8 calendar components (MonthView, KanbanBoard, AppointmentModal, TaskDetailPanel, IcsControls, ResourceBooking, SharingSettings + API client)

- 2 pages (/calendar, /calendar/kanban) + zustand store

- 17 vitest tests (MonthView 5, KanbanBoard 6, AppointmentModal 6) all passing

- i18n: calendar namespace in en/de (104 lines each, +exportSuccess key)

- TS strict mode pass, npm run build pass
2026-06-30 11:35:08 +02:00

227 lines
7.6 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.
/**
* MonthView — monthly calendar grid with click-to-create and drag-to-move.
*
* Click an empty cell → fires `onCreateAt(date)`.
* Click an entry → fires `onEditEntry(entry)`.
* Drag an entry onto another day → calls `onMoveEntry(entry, newDate)`.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import type { CalendarEntry } from '@/api/calendar';
export interface MonthViewProps {
/** First day of the visible month (UTC). */
visibleMonth: Date;
/** Entries to render. Entries with start_at fall on that day. */
entries: CalendarEntry[];
/** Loading state — disables interactions. */
loading?: boolean;
/** Clicked an empty cell — create a new entry. */
onCreateAt: (date: Date) => void;
/** Clicked an existing entry — open editor. */
onEditEntry: (entry: CalendarEntry) => void;
/** Dragged an entry to a new day. */
onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise<void>;
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
return out;
}
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function fmtTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function MonthView({
visibleMonth,
entries,
loading,
onCreateAt,
onEditEntry,
onMoveEntry,
}: MonthViewProps) {
const { t } = useTranslation();
const today = startOfDay(new Date());
// Build a 6-row × 7-day grid (always 42 cells to keep height stable)
const gridDays = useMemo(() => {
const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1);
// Week starts on Monday (DE locale)
const dayOfWeek = (first.getDay() + 6) % 7; // 0 = Monday
const start = addDays(first, -dayOfWeek);
return Array.from({ length: 42 }, (_, i) => addDays(start, i));
}, [visibleMonth]);
// Group entries by yyyy-mm-dd
const entriesByDay = useMemo(() => {
const map = new Map<string, CalendarEntry[]>();
for (const entry of entries) {
if (!entry.start_at) continue;
const d = new Date(entry.start_at);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const list = map.get(key) ?? [];
list.push(entry);
map.set(key, list);
}
return map;
}, [entries]);
const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[];
const months = t('calendar.months', { returnObjects: true }) as string[];
const headerLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;
const handleDragStart = (
e: React.DragEvent<HTMLDivElement>,
entry: CalendarEntry
) => {
e.dataTransfer.setData('application/x-calendar-entry', entry.id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes('application/x-calendar-entry')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (
e: React.DragEvent<HTMLDivElement>,
day: Date
) => {
e.preventDefault();
const id = e.dataTransfer.getData('application/x-calendar-entry');
if (!id) return;
const entry = entries.find((x) => x.id === id);
if (!entry || !entry.start_at) return;
// Preserve original time-of-day, change only date
const oldStart = new Date(entry.start_at);
const newStart = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
oldStart.getHours(),
oldStart.getMinutes(),
oldStart.getSeconds()
);
void onMoveEntry(entry, newStart);
};
return (
<div className="bg-white rounded-lg shadow border border-secondary-200" data-testid="month-view">
<div
className="px-4 py-3 border-b border-secondary-200 text-base font-semibold text-secondary-900"
data-testid="month-view-header"
>
{headerLabel}
</div>
<div className="grid grid-cols-7 border-b border-secondary-200 bg-secondary-50">
{weekdays.map((wd) => (
<div
key={wd}
className="px-2 py-2 text-xs font-medium text-secondary-600 text-center uppercase tracking-wide"
>
{wd}
</div>
))}
</div>
<div className="grid grid-cols-7 grid-rows-6" data-testid="month-view-grid">
{gridDays.map((day) => {
const inMonth = day.getMonth() === visibleMonth.getMonth();
const isToday = sameDay(day, today);
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
const dayEntries = entriesByDay.get(key) ?? [];
return (
<div
key={key}
role="button"
tabIndex={0}
data-testid={`month-cell-${key}`}
onClick={() => !loading && onCreateAt(day)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !loading) {
e.preventDefault();
onCreateAt(day);
}
}}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, day)}
className={clsx(
'border border-secondary-100 min-h-[6rem] p-1 align-top cursor-pointer',
'hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
!inMonth && 'bg-secondary-50 text-secondary-400',
loading && 'opacity-60 pointer-events-none'
)}
>
<div
className={clsx(
'text-xs font-medium mb-1 inline-flex items-center justify-center',
'h-6 w-6 rounded-full',
isToday && 'bg-primary-600 text-white'
)}
data-testid={`month-day-number-${key}`}
>
{day.getDate()}
</div>
<div className="space-y-1">
{dayEntries.slice(0, 3).map((entry) => {
const time = entry.start_at ? fmtTime(new Date(entry.start_at)) : '';
return (
<div
key={entry.id}
draggable
onDragStart={(e) => handleDragStart(e, entry)}
onClick={(e) => {
e.stopPropagation();
onEditEntry(entry);
}}
data-testid={`month-entry-${entry.id}`}
className={clsx(
'text-xs truncate rounded px-1 py-0.5 cursor-grab active:cursor-grabbing',
'bg-primary-100 text-primary-900 hover:bg-primary-200'
)}
title={entry.title}
>
{time && <span className="font-mono mr-1">{time}</span>}
{entry.title}
</div>
);
})}
{dayEntries.length > 3 && (
<div className="text-xs text-secondary-500 px-1">
+{dayEntries.length - 3} {t('calendar.noEntries').includes('Keine')
? 'weitere'
: 'more'}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
export default MonthView;