227 lines
7.6 KiB
TypeScript
227 lines
7.6 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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;
|