diff --git a/frontend/src/components/calendar/CalendarDetail.tsx b/frontend/src/components/calendar/CalendarDetail.tsx new file mode 100644 index 0000000..bdf31fa --- /dev/null +++ b/frontend/src/components/calendar/CalendarDetail.tsx @@ -0,0 +1,387 @@ +/** + * CalendarDetail - right column detail panel for the Calendar plugin. + * + * Shows entry details (title, type, status, priority, dates, location, + * description, calendar info), subtask checklist for tasks, linked entities, + * and action buttons (Edit, Delete). + * Pattern follows DMS FileDetails. + */ + +import React, { useEffect, useState } from 'react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import { + createSubtask, + getEntry, + updateSubtask, + type Calendar, + type CalendarEntry, + type Subtask, +} from '@/api/calendar'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; + +function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return '-'; + try { + const d = new Date(dateStr); + return d.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } +} + +function priorityClass(p: string): string { + switch (p) { + case 'high': + return 'bg-danger-100 text-danger-800'; + case 'low': + return 'bg-secondary-100 text-secondary-700'; + default: + return 'bg-warning-100 text-warning-800'; + } +} + +export interface CalendarDetailProps { + entry: CalendarEntry | null; + calendar: Calendar | null; + onEdit: () => void; + onDelete: () => void; + onClose: () => void; + onEntryChanged: (entry: CalendarEntry) => void; +} + +export function CalendarDetail({ + entry, + calendar, + onEdit, + onDelete, + onClose, + onEntryChanged, +}: CalendarDetailProps) { + const { t } = useTranslation(); + const [subtasks, setSubtasks] = useState(entry?.subtasks ?? []); + const [loadingSubtasks, setLoadingSubtasks] = useState(false); + const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); + const [subtaskError, setSubtaskError] = useState(null); + + // Load subtasks if entry is a task and subtasks are missing + useEffect(() => { + if (!entry) { + setSubtasks([]); + return; + } + if (entry.entry_type !== 'task') { + setSubtasks([]); + return; + } + if ((entry.subtasks?.length ?? 0) > 0) { + setSubtasks(entry.subtasks ?? []); + return; + } + let cancelled = false; + setLoadingSubtasks(true); + getEntry(entry.id) + .then((full) => { + if (cancelled) return; + setSubtasks(full.subtasks ?? []); + onEntryChanged(full); + }) + .catch((e: Error) => { + if (!cancelled) setSubtaskError(e.message); + }) + .finally(() => { + if (!cancelled) setLoadingSubtasks(false); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entry?.id, entry?.entry_type, entry?.subtasks]); + + const handleAddSubtask = async () => { + if (!entry) return; + const title = newSubtaskTitle.trim(); + if (!title) return; + setSubtaskError(null); + try { + const created = await createSubtask(entry.id, { title }); + setSubtasks((cur) => [...cur, created]); + setNewSubtaskTitle(''); + } catch (e) { + setSubtaskError((e as Error).message ?? t('calendar.errorSave')); + } + }; + + const handleToggleSubtask = async (sub: Subtask) => { + if (!entry) return; + setSubtaskError(null); + const prev = sub.done; + setSubtasks((cur) => cur.map((s) => (s.id === sub.id ? { ...s, done: !prev } : s))); + try { + const updated = await updateSubtask(entry.id, sub.id, { done: !prev }); + setSubtasks((cur) => cur.map((s) => (s.id === sub.id ? updated : s))); + } catch (e) { + setSubtasks((cur) => cur.map((s) => (s.id === sub.id ? { ...s, done: prev } : s))); + setSubtaskError((e as Error).message ?? t('calendar.errorSave')); + } + }; + + if (!entry) { + return ( +
+ +

{t('calendar.detail.noSelection')}

+

{t('calendar.detail.noSelectionHint')}

+
+ ); + } + + const isTask = entry.entry_type === 'task'; + + return ( +
+ {/* Header with close button */} +
+

+ {entry.title} +

+ +
+ + {/* Scrollable content */} +
+ {/* Badges */} +
+ + {entry.entry_type} + + + {t(`calendar.status.${entry.status}`)} + + + {entry.priority} + +
+ + {/* Action buttons */} +
+ + +
+ + {/* Metadata section */} +
+

+ {t('calendar.detail.title')} +

+
+
+
{t('calendar.detail.type')}
+
{entry.entry_type}
+
+
+
{t('calendar.detail.status')}
+
{t(`calendar.status.${entry.status}`)}
+
+
+
{t('calendar.detail.priority')}
+
{entry.priority}
+
+
+
{t('calendar.detail.start')}
+
{formatDate(entry.start_at)}
+
+
+
{t('calendar.detail.end')}
+
{formatDate(entry.end_at)}
+
+ {entry.location && ( +
+
{t('calendar.detail.location')}
+
{entry.location}
+
+ )} + {calendar && ( +
+
{t('calendar.detail.calendar')}
+
+
+
+ )} +
+
+ + {/* Description */} + {entry.description && ( +
+

+ {t('calendar.detail.description')} +

+

{entry.description}

+
+ )} + + {/* Subtasks (for tasks) */} + {isTask && ( +
+

+ {t('calendar.detail.subtasks')} +

+ + {loadingSubtasks && ( +
{t('common.loading')}
+ )} + +
    + {subtasks.length === 0 && !loadingSubtasks && ( +
  • + {t('calendar.detail.subtasks.empty')} +
  • + )} + {subtasks.map((sub) => ( +
  • + handleToggleSubtask(sub)} + className="mt-1 h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + data-testid={`calendar-detail-subtask-toggle-${sub.id}`} + /> + + {sub.title} + +
  • + ))} +
+ +
{ + e.preventDefault(); + void handleAddSubtask(); + }} + className="flex gap-2" + data-testid="calendar-detail-subtask-add-form" + > + setNewSubtaskTitle(e.target.value)} + placeholder={t('calendar.detail.subtasks.addPlaceholder')} + data-testid="calendar-detail-subtask-add-input" + /> + +
+ + {subtaskError && ( +
+ {subtaskError} +
+ )} +
+ )} + + {/* Linked entities */} + {entry.links && entry.links.length > 0 && ( +
+

+ {t('calendar.detail.links')} +

+
    + {entry.links.map((link) => ( +
  • + + {link.entity_type} + + {link.entity_id} +
  • + ))} +
+
+ )} + + {(!entry.links || entry.links.length === 0) && ( +
+

+ {t('calendar.detail.links')} +

+

{t('calendar.detail.noLinks')}

+
+ )} +
+
+ ); +} + +export default CalendarDetail; diff --git a/frontend/src/components/calendar/CalendarTree.tsx b/frontend/src/components/calendar/CalendarTree.tsx new file mode 100644 index 0000000..8c01884 --- /dev/null +++ b/frontend/src/components/calendar/CalendarTree.tsx @@ -0,0 +1,231 @@ +/** + * CalendarTree - left sidebar calendar selector for the Calendar plugin. + * + * Groups calendars by type (personal, team, project, company) with color dots, + * visibility checkboxes, and a "New Calendar" button at the bottom. + * Pattern follows DMS SourceTree. + */ + +import React, { useState } from 'react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import type { Calendar, CalendarType } from '@/api/calendar'; + +const TYPE_ORDER: CalendarType[] = ['personal', 'team', 'project', 'company']; + +interface CalendarRowProps { + calendar: Calendar; + selected: boolean; + visible: boolean; + onSelect: () => void; + onToggleVisibility: () => void; +} + +function CalendarRow({ calendar, selected, visible, onSelect, onToggleVisibility }: CalendarRowProps) { + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(); + } + }} + tabIndex={0} + role="button" + aria-label={calendar.name} + data-testid={`calendar-tree-row-${calendar.id}`} + > + { + e.stopPropagation(); + onToggleVisibility(); + }} + onClick={(e) => e.stopPropagation()} + className="flex-shrink-0 h-3.5 w-3.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + aria-label={`toggle visibility ${calendar.name}`} + data-testid={`calendar-tree-visibility-${calendar.id}`} + /> +
+ ); +} + +interface TypeSectionProps { + type: CalendarType; + calendars: Calendar[]; + activeCalendarId: string | null; + visibleCalendarIds: Set; + onSelect: (id: string) => void; + onToggleVisibility: (id: string) => void; +} + +function TypeSection({ + type, + calendars, + activeCalendarId, + visibleCalendarIds, + onSelect, + onToggleVisibility, +}: TypeSectionProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(true); + + if (calendars.length === 0) return null; + + return ( +
+
setExpanded((p) => !p)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded((p) => !p); + } + }} + tabIndex={0} + role="button" + aria-expanded={expanded} + aria-label={t(`calendar.type.${type}`)} + > + + {t(`calendar.type.${type}`)} + {calendars.length} +
+ {expanded && ( +
    + {calendars.map((cal) => ( +
  • + onSelect(cal.id)} + onToggleVisibility={() => onToggleVisibility(cal.id)} + /> +
  • + ))} +
+ )} +
+ ); +} + +export interface CalendarTreeProps { + calendars: Calendar[]; + activeCalendarId: string | null; + visibleCalendarIds: Set; + onSelect: (id: string) => void; + onToggleVisibility: (id: string) => void; + onCreate: () => void; + loading: boolean; +} + +export function CalendarTree({ + calendars, + activeCalendarId, + visibleCalendarIds, + onSelect, + onToggleVisibility, + onCreate, + loading, +}: CalendarTreeProps) { + const { t } = useTranslation(); + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + const grouped = new Map(); + for (const type of TYPE_ORDER) { + grouped.set(type, []); + } + for (const cal of calendars) { + const list = grouped.get(cal.type) ?? grouped.get('personal')!; + list.push(cal); + } + + return ( + + ); +} + +export default CalendarTree; diff --git a/frontend/src/components/calendar/DayView.tsx b/frontend/src/components/calendar/DayView.tsx new file mode 100644 index 0000000..1af170b --- /dev/null +++ b/frontend/src/components/calendar/DayView.tsx @@ -0,0 +1,207 @@ +/** + * DayView - single-day view with hourly time slots. + * + * Click an empty slot -> fires `onCreateAt(date)`. + * Click an entry -> fires `onEditEntry(entry)`. + * Drag an entry to another time slot -> calls `onMoveEntry(entry, newStart)`. + */ + +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import type { CalendarEntry } from '@/api/calendar'; + +export interface DayViewProps { + visibleDay: Date; + entries: CalendarEntry[]; + loading?: boolean; + onCreateAt: (date: Date) => void; + onEditEntry: (entry: CalendarEntry) => void; + onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise; +} + +const HOUR_START = 0; +const HOUR_END = 24; +const SLOT_HEIGHT = 56; // px per hour - slightly taller for single-day view + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +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 DayView({ + visibleDay, + entries, + loading, + onCreateAt, + onEditEntry, + onMoveEntry, +}: DayViewProps) { + const { t } = useTranslation(); + + const today = startOfDay(new Date()); + const day = startOfDay(visibleDay); + const isToday = sameDay(day, today); + const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[]; + const months = t('calendar.months', { returnObjects: true }) as string[]; + const weekdayIdx = (day.getDay() + 6) % 7; // 0 = Monday + + const hours = useMemo(() => { + return Array.from({ length: HOUR_END - HOUR_START }, (_, i) => HOUR_START + i); + }, []); + + const dayEntries = useMemo(() => { + return entries.filter((e) => { + if (!e.start_at) return false; + return sameDay(new Date(e.start_at), day); + }); + }, [entries, day]); + + const handleDragStart = (e: React.DragEvent, entry: CalendarEntry) => { + e.dataTransfer.setData('application/x-calendar-entry', entry.id); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes('application/x-calendar-entry')) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }; + + const handleDrop = (e: React.DragEvent, hour: number) => { + 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; + const oldStart = new Date(entry.start_at); + const newStart = new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + hour, + oldStart.getMinutes(), + oldStart.getSeconds(), + ); + void onMoveEntry(entry, newStart); + }; + + return ( +
+ {/* Header */} +
+
+ {weekdays[weekdayIdx]} +
+
+ {day.getDate()} +
+
+ {months[day.getMonth()]} {day.getFullYear()} +
+
+ + {/* Scrollable time grid */} +
+
+ {/* Time labels */} +
+ {hours.map((hour) => ( +
+ {String(hour).padStart(2, '0')}:00 +
+ ))} +
+ + {/* Day column */} +
+ {/* Hour slots */} + {hours.map((hour) => ( +
+ !loading && + onCreateAt(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, 0)) + } + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, hour)} + data-testid={`day-slot-${hour}`} + /> + ))} + + {/* Entries positioned absolutely */} + {dayEntries.map((entry) => { + if (!entry.start_at) return null; + const start = new Date(entry.start_at); + const end = entry.end_at ? new Date(entry.end_at) : new Date(start.getTime() + 60 * 60 * 1000); + const startHour = start.getHours() + start.getMinutes() / 60; + const endHour = end.getHours() + end.getMinutes() / 60; + const durationHours = Math.max(endHour - startHour, 0.5); + const top = (startHour - HOUR_START) * SLOT_HEIGHT; + const height = durationHours * SLOT_HEIGHT; + return ( +
handleDragStart(e, entry)} + onClick={(e) => { + e.stopPropagation(); + onEditEntry(entry); + }} + className={clsx( + 'absolute left-2 right-2 rounded px-2 py-1 text-sm cursor-grab active:cursor-grabbing', + 'bg-primary-100 text-primary-900 hover:bg-primary-200 overflow-hidden', + entry.entry_type === 'task' && 'bg-warning-100 text-warning-900 hover:bg-warning-200', + )} + style={{ top: `${top}px`, height: `${height}px`, minHeight: '24px' }} + title={entry.title} + data-testid={`day-entry-${entry.id}`} + > +
{entry.title}
+
+ {fmtTime(start)}{entry.end_at ? ` - ${fmtTime(end)}` : ''} +
+ {entry.location && ( +
@ {entry.location}
+ )} +
+ ); + })} +
+
+
+
+ ); +} + +export default DayView; diff --git a/frontend/src/components/calendar/RangeView.tsx b/frontend/src/components/calendar/RangeView.tsx new file mode 100644 index 0000000..b2cf3fa --- /dev/null +++ b/frontend/src/components/calendar/RangeView.tsx @@ -0,0 +1,204 @@ +/** + * RangeView - agenda-style list of entries between a start and end date. + * + * Groups entries by day, showing day headers with date + weekday. + * Click an entry -> fires onEditEntry(entry). + */ + +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import type { CalendarEntry } from '@/api/calendar'; + +export interface RangeViewProps { + rangeStart: Date; + rangeEnd: Date; + entries: CalendarEntry[]; + loading?: boolean; + onEditEntry: (entry: CalendarEntry) => 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()); +} + +function fmtDayDate(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear(); +} + +export function RangeView({ + rangeStart, + rangeEnd, + entries, + loading, + onEditEntry, +}: RangeViewProps) { + const { t } = useTranslation(); + + const today = startOfDay(new Date()); + const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[]; + + const days = useMemo(() => { + if (!rangeStart || !rangeEnd) return []; + const start = startOfDay(rangeStart); + const end = startOfDay(rangeEnd); + if (end < start) return [start]; + const result: Date[] = []; + let cur = start; + while (cur <= end) { + result.push(cur); + cur = addDays(cur, 1); + } + return result; + }, [rangeStart, rangeEnd]); + + const entriesByDay = useMemo(() => { + const map = new Map(); + 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); + } + for (const list of map.values()) { + list.sort((a, b) => { + const at = a.start_at ? new Date(a.start_at).getTime() : 0; + const bt = b.start_at ? new Date(b.start_at).getTime() : 0; + return at - bt; + }); + } + return map; + }, [entries]); + + if (loading) { + return ( +
+
{t('common.loading')}
+
+ ); + } + + if (days.length === 0) { + return ( +
+
{t('calendar.rangeView.noEntries')}
+
+ ); + } + + return ( +
+ {days.map((day) => { + const key = day.getFullYear() + '-' + day.getMonth() + '-' + day.getDate(); + const dayEntries = entriesByDay.get(key) ?? []; + const isToday = sameDay(day, today); + const weekdayIdx = (day.getDay() + 6) % 7; + + return ( +
+
+ + {weekdays[weekdayIdx]} + + {fmtDayDate(day)} + {dayEntries.length > 0 && ( + + {t('calendar.rangeView.entriesFor', { count: dayEntries.length })} + + )} +
+ + {dayEntries.length === 0 ? ( +
+ {t('calendar.rangeView.noEntries')} +
+ ) : ( +
    + {dayEntries.map((entry) => { + const start = entry.start_at ? new Date(entry.start_at) : null; + return ( +
  • onEditEntry(entry)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onEditEntry(entry); + } + }} + tabIndex={0} + role="button" + data-testid={'range-entry-' + entry.id} + > +
    + {start ? fmtTime(start) : '-'} +
    + + + {entry.entry_type} + + +
    +
    + {entry.title} +
    + {entry.location && ( +
    {entry.location}
    + )} +
    + + + {t('calendar.status.' + entry.status)} + +
  • + ); + })} +
+ )} +
+ ); + })} +
+ ); +} + +export default RangeView; diff --git a/frontend/src/components/calendar/WeekView.tsx b/frontend/src/components/calendar/WeekView.tsx new file mode 100644 index 0000000..4910240 --- /dev/null +++ b/frontend/src/components/calendar/WeekView.tsx @@ -0,0 +1,227 @@ +/** + * WeekView - 7-day week grid (Mon-Sun) with hourly time slots. + * + * Click an empty slot -> fires `onCreateAt(date)`. + * Click an entry -> fires `onEditEntry(entry)`. + * Drag an entry onto another day/slot -> calls `onMoveEntry(entry, newStart)`. + */ + +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import type { CalendarEntry } from '@/api/calendar'; + +export interface WeekViewProps { + /** Monday of the visible week. */ + visibleWeek: Date; + entries: CalendarEntry[]; + loading?: boolean; + onCreateAt: (date: Date) => void; + onEditEntry: (entry: CalendarEntry) => void; + onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise; +} + +const HOUR_START = 0; +const HOUR_END = 24; +const SLOT_HEIGHT = 48; // px per hour + +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 WeekView({ + visibleWeek, + entries, + loading, + onCreateAt, + onEditEntry, + onMoveEntry, +}: WeekViewProps) { + const { t } = useTranslation(); + + const today = startOfDay(new Date()); + const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[]; + + const weekDays = useMemo(() => { + return Array.from({ length: 7 }, (_, i) => addDays(startOfDay(visibleWeek), i)); + }, [visibleWeek]); + + const hours = useMemo(() => { + return Array.from({ length: HOUR_END - HOUR_START }, (_, i) => HOUR_START + i); + }, []); + + const entriesByDay = useMemo(() => { + const map = new Map(); + 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 handleDragStart = (e: React.DragEvent, entry: CalendarEntry) => { + e.dataTransfer.setData('application/x-calendar-entry', entry.id); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes('application/x-calendar-entry')) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }; + + const handleDrop = (e: React.DragEvent, day: Date, hour: number) => { + 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; + const oldStart = new Date(entry.start_at); + const newStart = new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + hour, + oldStart.getMinutes(), + oldStart.getSeconds(), + ); + void onMoveEntry(entry, newStart); + }; + + return ( +
+ {/* Header row: weekday names + date numbers */} +
+
+ {weekDays.map((day, i) => { + const isToday = sameDay(day, today); + return ( +
+
+ {weekdays[i]} +
+
+ {day.getDate()} +
+
+ ); + })} +
+ + {/* Scrollable time grid */} +
+ {/* Time column + day columns */} +
+ {/* Time labels */} +
+ {hours.map((hour) => ( +
+ {String(hour).padStart(2, '0')}:00 +
+ ))} +
+ + {/* Day columns */} + {weekDays.map((day, dayIdx) => { + const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`; + const dayEntries = entriesByDay.get(key) ?? []; + const isToday = sameDay(day, today); + return ( +
+ {/* Hour slots */} + {hours.map((hour) => ( +
!loading && onCreateAt(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, 0))} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, day, hour)} + data-testid={`week-slot-${dayIdx}-${hour}`} + /> + ))} + + {/* Entries positioned absolutely */} + {dayEntries.map((entry) => { + if (!entry.start_at) return null; + const start = new Date(entry.start_at); + const end = entry.end_at ? new Date(entry.end_at) : new Date(start.getTime() + 60 * 60 * 1000); + const startHour = start.getHours() + start.getMinutes() / 60; + const endHour = end.getHours() + end.getMinutes() / 60; + const durationHours = Math.max(endHour - startHour, 0.5); + const top = (startHour - HOUR_START) * SLOT_HEIGHT; + const height = durationHours * SLOT_HEIGHT; + return ( +
handleDragStart(e, entry)} + onClick={(e) => { + e.stopPropagation(); + onEditEntry(entry); + }} + className={clsx( + 'absolute left-1 right-1 rounded px-1.5 py-1 text-xs cursor-grab active:cursor-grabbing', + 'bg-primary-100 text-primary-900 hover:bg-primary-200 overflow-hidden', + entry.entry_type === 'task' && 'bg-warning-100 text-warning-900 hover:bg-warning-200', + )} + style={{ top: `${top}px`, height: `${height}px`, minHeight: '20px' }} + title={entry.title} + data-testid={`week-entry-${entry.id}`} + > +
{entry.title}
+
{fmtTime(start)}
+
+ ); + })} +
+ ); + })} +
+
+
+ ); +} + +export default WeekView; diff --git a/frontend/src/components/dms/FileExplorer.tsx b/frontend/src/components/dms/FileExplorer.tsx index b663539..336cebc 100644 --- a/frontend/src/components/dms/FileExplorer.tsx +++ b/frontend/src/components/dms/FileExplorer.tsx @@ -1,12 +1,9 @@ /** * FileExplorer - middle column file browser with multiple view modes. * Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg. - * Files are draggable for drag-and-drop into folders (SourceTree). - * Multi-selection: Shift+Click (range), Ctrl/Cmd+Click (toggle), - * rubber-band drag, mobile long-press. */ -import React, { useMemo, useState, useRef, useCallback } from 'react'; +import React, { useMemo } from 'react'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import type { DmsFile } from '@/api/dms'; @@ -74,8 +71,6 @@ export interface FileExplorerProps { sortBy: SortBy; sortOrder: SortOrder; onSortChange: (by: string, order: string) => void; - onFileDragStart?: (fileIds: string[]) => void; - onRangeSelect?: (fileIds: string[]) => void; } function SortHeader({ @@ -131,7 +126,7 @@ function ActionButtons({
- - +
+ {error && ( +
+

{error}

+ )} + + {/* ICS controls panel */} + {showIcs && ( +
+ void loadEntries()} /> +
+ )} + + {/* Sharing settings panel */} + {showSharing && activeCalendar && ( +
+ +
+ )} + + {/* Desktop: three-pane layout with resizable panels */} +
+ {/* Left: CalendarTree */} + + + + + {/* Middle: Calendar view */} + +
+ {renderRangeControls()} +
{renderCalendarView()}
+
+
+ + {/* Right: CalendarDetail (only when toggled on) */} + {showDetails && ( + + { + setSelectedEntry(null); + setShowDetails(false); + }} + onEntryChanged={handleEntryChanged} + /> + + )}
-
-
- {error && ( -
- {error} + {/* Mobile: single-pane view switching */} +
+ {/* View 1: CalendarTree */} + {activeView === 'tree' && ( +
+
+

{t('calendar.tree.title')}

+
- )} - {calendars.length === 0 && !loading ? ( - - ) : ( - - )} -
- -