Calendar rewrite: 3-column layout with tree, multi-view (day/week/month/range), detail panel
This commit is contained in:
@@ -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<Subtask[]>(entry?.subtasks ?? []);
|
||||||
|
const [loadingSubtasks, setLoadingSubtasks] = useState(false);
|
||||||
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||||
|
const [subtaskError, setSubtaskError] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center h-full p-6 text-center"
|
||||||
|
data-testid="calendar-detail-empty"
|
||||||
|
>
|
||||||
|
<svg className="w-12 h-12 text-secondary-300 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium text-secondary-600">{t('calendar.detail.noSelection')}</p>
|
||||||
|
<p className="mt-1 text-xs text-secondary-400">{t('calendar.detail.noSelectionHint')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTask = entry.entry_type === 'task';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full" data-testid="calendar-detail">
|
||||||
|
{/* Header with close button */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary-900 truncate" title={entry.title}>
|
||||||
|
{entry.title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
data-testid="calendar-detail-close"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-0.5 rounded text-xs font-medium uppercase',
|
||||||
|
isTask ? 'bg-warning-100 text-warning-800' : 'bg-primary-100 text-primary-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.entry_type}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded bg-secondary-100 text-secondary-700 text-xs">
|
||||||
|
{t(`calendar.status.${entry.status}`)}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('px-2 py-0.5 rounded text-xs font-medium uppercase', priorityClass(entry.priority))}>
|
||||||
|
{entry.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch"
|
||||||
|
data-testid="calendar-detail-edit"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
{t('calendar.detail.edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-danger-50 text-danger-700 hover:bg-danger-100 min-h-touch"
|
||||||
|
data-testid="calendar-detail-delete"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
{t('calendar.detail.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
||||||
|
{t('calendar.detail.title')}
|
||||||
|
</h4>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<dt className="text-secondary-500">{t('calendar.detail.type')}</dt>
|
||||||
|
<dd className="text-secondary-900 font-medium capitalize">{entry.entry_type}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<dt className="text-secondary-500">{t('calendar.detail.status')}</dt>
|
||||||
|
<dd className="text-secondary-900 font-medium">{t(`calendar.status.${entry.status}`)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<dt className="text-secondary-500">{t('calendar.detail.priority')}</dt>
|
||||||
|
<dd className="text-secondary-900 font-medium capitalize">{entry.priority}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<dt className="text-secondary-500">{t('calendar.detail.start')}</dt>
|
||||||
|
<dd className="text-secondary-900">{formatDate(entry.start_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<dt className="text-secondary-500">{t('calendar.detail.end')}</dt>
|
||||||
|
<dd className="text-secondary-900">{formatDate(entry.end_at)}</dd>
|
||||||
|
</div>
|
||||||
|
{entry.location && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<dt className="text-secondary-500">{t('calendar.detail.location')}</dt>
|
||||||
|
<dd className="text-secondary-900">{entry.location}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{calendar && (
|
||||||
|
<div className="flex justify-between text-sm items-center">
|
||||||
|
<dt className="text-secondary-500">{t('calendar.detail.calendar')}</dt>
|
||||||
|
<dd className="text-secondary-900 font-medium flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: calendar.color || '#3b82f6' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{calendar.name}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{entry.description && (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
||||||
|
{t('calendar.detail.description')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-secondary-700 whitespace-pre-wrap">{entry.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtasks (for tasks) */}
|
||||||
|
{isTask && (
|
||||||
|
<div className="mt-6 border-t border-secondary-200 pt-4">
|
||||||
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">
|
||||||
|
{t('calendar.detail.subtasks')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{loadingSubtasks && (
|
||||||
|
<div className="text-xs text-secondary-500 mb-2">{t('common.loading')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-1 mb-3" data-testid="calendar-detail-subtask-list">
|
||||||
|
{subtasks.length === 0 && !loadingSubtasks && (
|
||||||
|
<li className="text-xs text-secondary-400 italic">
|
||||||
|
{t('calendar.detail.subtasks.empty')}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{subtasks.map((sub) => (
|
||||||
|
<li
|
||||||
|
key={sub.id}
|
||||||
|
className="flex items-start gap-2"
|
||||||
|
data-testid={`calendar-detail-subtask-${sub.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sub.done}
|
||||||
|
onChange={() => 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}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'text-sm flex-1',
|
||||||
|
sub.done && 'line-through text-secondary-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleAddSubtask();
|
||||||
|
}}
|
||||||
|
className="flex gap-2"
|
||||||
|
data-testid="calendar-detail-subtask-add-form"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={newSubtaskTitle}
|
||||||
|
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||||
|
placeholder={t('calendar.detail.subtasks.addPlaceholder')}
|
||||||
|
data-testid="calendar-detail-subtask-add-input"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!newSubtaskTitle.trim()}
|
||||||
|
data-testid="calendar-detail-subtask-add-button"
|
||||||
|
>
|
||||||
|
{t('calendar.detail.subtasks.add')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{subtaskError && (
|
||||||
|
<div
|
||||||
|
className="mt-2 text-xs text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
|
||||||
|
data-testid="calendar-detail-subtask-error"
|
||||||
|
>
|
||||||
|
{subtaskError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Linked entities */}
|
||||||
|
{entry.links && entry.links.length > 0 && (
|
||||||
|
<div className="mt-6 border-t border-secondary-200 pt-4">
|
||||||
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">
|
||||||
|
{t('calendar.detail.links')}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{entry.links.map((link) => (
|
||||||
|
<li
|
||||||
|
key={link.id}
|
||||||
|
className="flex items-center gap-2 text-sm text-secondary-700"
|
||||||
|
>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-xs bg-secondary-100 text-secondary-600 capitalize">
|
||||||
|
{link.entity_type}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs">{link.entity_id}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!entry.links || entry.links.length === 0) && (
|
||||||
|
<div className="mt-6 border-t border-secondary-200 pt-4">
|
||||||
|
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">
|
||||||
|
{t('calendar.detail.links')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-secondary-400 italic">{t('calendar.detail.noLinks')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarDetail;
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer',
|
||||||
|
'hover:bg-secondary-100 motion-safe:transition-colors',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
|
selected && 'bg-primary-50 text-primary-700 font-medium',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: '20px' }}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={calendar.name}
|
||||||
|
data-testid={`calendar-tree-row-${calendar.id}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visible}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: calendar.color || '#3b82f6' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="truncate flex-1">{calendar.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeSectionProps {
|
||||||
|
type: CalendarType;
|
||||||
|
calendars: Calendar[];
|
||||||
|
activeCalendarId: string | null;
|
||||||
|
visibleCalendarIds: Set<string>;
|
||||||
|
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 (
|
||||||
|
<div className="mb-1">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-semibold cursor-pointer',
|
||||||
|
'hover:bg-secondary-100 motion-safe:transition-colors',
|
||||||
|
)}
|
||||||
|
onClick={() => 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}`)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setExpanded((p) => !p);
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
|
||||||
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="truncate text-secondary-700">{t(`calendar.type.${type}`)}</span>
|
||||||
|
<span className="ml-auto text-xs text-secondary-400">{calendars.length}</span>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<ul role="group" className="space-y-0 mt-0.5">
|
||||||
|
{calendars.map((cal) => (
|
||||||
|
<li key={cal.id}>
|
||||||
|
<CalendarRow
|
||||||
|
calendar={cal}
|
||||||
|
selected={activeCalendarId === cal.id}
|
||||||
|
visible={
|
||||||
|
visibleCalendarIds.size === 0 || visibleCalendarIds.has(cal.id)
|
||||||
|
}
|
||||||
|
onSelect={() => onSelect(cal.id)}
|
||||||
|
onToggleVisibility={() => onToggleVisibility(cal.id)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarTreeProps {
|
||||||
|
calendars: Calendar[];
|
||||||
|
activeCalendarId: string | null;
|
||||||
|
visibleCalendarIds: Set<string>;
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2 p-2" data-testid="calendar-tree-loading">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-8 bg-secondary-100 rounded animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map<CalendarType, Calendar[]>();
|
||||||
|
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 (
|
||||||
|
<nav aria-label={t('calendar.tree.title')} data-testid="calendar-tree" className="flex flex-col h-full">
|
||||||
|
<div className="px-3 py-2 border-b border-secondary-200">
|
||||||
|
<h2 className="text-sm font-semibold text-secondary-900">{t('calendar.tree.title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2"> {calendars.length === 0 ? (
|
||||||
|
<p className="text-xs text-secondary-400 italic px-2 py-4 text-center">
|
||||||
|
{t('calendar.noCalendars')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
TYPE_ORDER.map((type) => (
|
||||||
|
<TypeSection
|
||||||
|
key={type}
|
||||||
|
type={type}
|
||||||
|
calendars={grouped.get(type) ?? []}
|
||||||
|
activeCalendarId={activeCalendarId}
|
||||||
|
visibleCalendarIds={visibleCalendarIds}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onToggleVisibility={onToggleVisibility}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-secondary-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreate}
|
||||||
|
className={clsx(
|
||||||
|
'w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium',
|
||||||
|
'bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch',
|
||||||
|
)}
|
||||||
|
data-testid="calendar-tree-new"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{t('calendar.tree.newCalendar')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarTree;
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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>, 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 (
|
||||||
|
<div className="flex flex-col h-full bg-white" data-testid="day-view">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 border-b border-secondary-200',
|
||||||
|
isToday && 'bg-primary-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-secondary-600 uppercase tracking-wide">
|
||||||
|
{weekdays[weekdayIdx]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'text-sm font-semibold inline-flex items-center justify-center h-7 w-7 rounded-full',
|
||||||
|
isToday && 'bg-primary-600 text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-secondary-700">
|
||||||
|
{months[day.getMonth()]} {day.getFullYear()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable time grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto" data-testid="day-view-grid">
|
||||||
|
<div className="flex">
|
||||||
|
{/* Time labels */}
|
||||||
|
<div className="w-20 flex-shrink-0">
|
||||||
|
{hours.map((hour) => (
|
||||||
|
<div
|
||||||
|
key={hour}
|
||||||
|
className="text-xs text-secondary-400 text-right pr-3 border-b border-secondary-100"
|
||||||
|
style={{ height: `${SLOT_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
{String(hour).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day column */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 relative border-l border-secondary-200',
|
||||||
|
isToday && 'bg-primary-50/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Hour slots */}
|
||||||
|
{hours.map((hour) => (
|
||||||
|
<div
|
||||||
|
key={hour}
|
||||||
|
className="border-b border-secondary-100 hover:bg-primary-50 cursor-pointer"
|
||||||
|
style={{ height: `${SLOT_HEIGHT}px` }}
|
||||||
|
onClick={() =>
|
||||||
|
!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 (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => 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}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{entry.title}</div>
|
||||||
|
<div className="text-xs opacity-75">
|
||||||
|
{fmtTime(start)}{entry.end_at ? ` - ${fmtTime(end)}` : ''}
|
||||||
|
</div>
|
||||||
|
{entry.location && (
|
||||||
|
<div className="text-xs opacity-60 truncate">@ {entry.location}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DayView;
|
||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-full p-6" data-testid="range-view-loading">
|
||||||
|
<div className="text-sm text-secondary-500">{t('common.loading')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full p-6" data-testid="range-view-empty">
|
||||||
|
<div className="text-sm text-secondary-500">{t('calendar.rangeView.noEntries')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-y-auto bg-white" data-testid="range-view">
|
||||||
|
{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 (
|
||||||
|
<div key={key} className="border-b border-secondary-100" data-testid={'range-day-' + key}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-4 py-2 sticky top-0 z-10',
|
||||||
|
isToday ? 'bg-primary-50' : 'bg-secondary-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-secondary-900">
|
||||||
|
{weekdays[weekdayIdx]}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-secondary-600">{fmtDayDate(day)}</span>
|
||||||
|
{dayEntries.length > 0 && (
|
||||||
|
<span className="ml-auto text-xs text-secondary-400">
|
||||||
|
{t('calendar.rangeView.entriesFor', { count: dayEntries.length })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dayEntries.length === 0 ? (
|
||||||
|
<div className="px-4 py-2 text-xs text-secondary-400 italic">
|
||||||
|
{t('calendar.rangeView.noEntries')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-secondary-50">
|
||||||
|
{dayEntries.map((entry) => {
|
||||||
|
const start = entry.start_at ? new Date(entry.start_at) : null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 hover:bg-secondary-50 cursor-pointer"
|
||||||
|
onClick={() => onEditEntry(entry)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onEditEntry(entry);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
data-testid={'range-entry-' + entry.id}
|
||||||
|
>
|
||||||
|
<div className="w-16 flex-shrink-0 text-xs font-mono text-secondary-600">
|
||||||
|
{start ? fmtTime(start) : '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium uppercase',
|
||||||
|
entry.entry_type === 'task'
|
||||||
|
? 'bg-warning-100 text-warning-800'
|
||||||
|
: 'bg-primary-100 text-primary-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.entry_type}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-secondary-900 truncate">
|
||||||
|
{entry.title}
|
||||||
|
</div>
|
||||||
|
{entry.location && (
|
||||||
|
<div className="text-xs text-secondary-500 truncate">{entry.location}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'flex-shrink-0 px-1.5 py-0.5 rounded text-xs',
|
||||||
|
entry.status === 'done' && 'bg-success-100 text-success-800',
|
||||||
|
entry.status === 'in_progress' && 'bg-primary-100 text-primary-800',
|
||||||
|
entry.status === 'cancelled' && 'bg-danger-100 text-danger-800',
|
||||||
|
entry.status === 'open' && 'bg-secondary-100 text-secondary-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('calendar.status.' + entry.status)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RangeView;
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 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, 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 (
|
||||||
|
<div className="flex flex-col h-full bg-white" data-testid="week-view">
|
||||||
|
{/* Header row: weekday names + date numbers */}
|
||||||
|
<div className="flex border-b border-secondary-200 bg-secondary-50">
|
||||||
|
<div className="w-16 flex-shrink-0" />
|
||||||
|
{weekDays.map((day, i) => {
|
||||||
|
const isToday = sameDay(day, today);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 text-center px-2 py-2 border-l border-secondary-200',
|
||||||
|
isToday && 'bg-primary-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-secondary-600 uppercase tracking-wide">
|
||||||
|
{weekdays[i]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'text-sm font-semibold mt-0.5 inline-flex items-center justify-center h-6 w-6 rounded-full',
|
||||||
|
isToday && 'bg-primary-600 text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable time grid */}
|
||||||
|
<div className="flex-1 overflow-y-auto" data-testid="week-view-grid">
|
||||||
|
{/* Time column + day columns */}
|
||||||
|
<div className="flex">
|
||||||
|
{/* Time labels */}
|
||||||
|
<div className="w-16 flex-shrink-0">
|
||||||
|
{hours.map((hour) => (
|
||||||
|
<div
|
||||||
|
key={hour}
|
||||||
|
className="text-xs text-secondary-400 text-right pr-2 border-b border-secondary-100"
|
||||||
|
style={{ height: `${SLOT_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
{String(hour).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div
|
||||||
|
key={dayIdx}
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 relative border-l border-secondary-200',
|
||||||
|
isToday && 'bg-primary-50/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Hour slots */}
|
||||||
|
{hours.map((hour) => (
|
||||||
|
<div
|
||||||
|
key={hour}
|
||||||
|
className="border-b border-secondary-100 hover:bg-primary-50 cursor-pointer"
|
||||||
|
style={{ height: `${SLOT_HEIGHT}px` }}
|
||||||
|
onClick={() => !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 (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => 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}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium truncate">{entry.title}</div>
|
||||||
|
<div className="text-xs opacity-75">{fmtTime(start)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WeekView;
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* FileExplorer - middle column file browser with multiple view modes.
|
* FileExplorer - middle column file browser with multiple view modes.
|
||||||
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
|
* 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 clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { DmsFile } from '@/api/dms';
|
import type { DmsFile } from '@/api/dms';
|
||||||
@@ -74,8 +71,6 @@ export interface FileExplorerProps {
|
|||||||
sortBy: SortBy;
|
sortBy: SortBy;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
onSortChange: (by: string, order: string) => void;
|
onSortChange: (by: string, order: string) => void;
|
||||||
onFileDragStart?: (fileIds: string[]) => void;
|
|
||||||
onRangeSelect?: (fileIds: string[]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortHeader({
|
function SortHeader({
|
||||||
@@ -131,7 +126,7 @@ function ActionButtons({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
|
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
|
||||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50"
|
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||||
aria-label={t('dms.preview')}
|
aria-label={t('dms.preview')}
|
||||||
title={t('dms.preview')}
|
title={t('dms.preview')}
|
||||||
>
|
>
|
||||||
@@ -142,7 +137,7 @@ function ActionButtons({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
||||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50"
|
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
|
||||||
aria-label={t('dms.share')}
|
aria-label={t('dms.share')}
|
||||||
title={t('dms.share')}
|
title={t('dms.share')}
|
||||||
>
|
>
|
||||||
@@ -152,7 +147,7 @@ function ActionButtons({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
||||||
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50"
|
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch"
|
||||||
aria-label={t('dms.delete')}
|
aria-label={t('dms.delete')}
|
||||||
title={t('dms.delete')}
|
title={t('dms.delete')}
|
||||||
>
|
>
|
||||||
@@ -179,25 +174,8 @@ export function FileExplorer({
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onFileDragStart,
|
|
||||||
onRangeSelect,
|
|
||||||
}: FileExplorerProps) {
|
}: FileExplorerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Multi-selection state
|
|
||||||
const lastClickedIndex = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// Rubber-band state
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rubberBandStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
||||||
const [rubberBand, setRubberBand] = useState<{ startX: number; startY: number; endX: number; endY: number } | null>(null);
|
|
||||||
const [rubberBandSelected, setRubberBandSelected] = useState<Set<string>>(new Set());
|
|
||||||
const fileElementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
|
||||||
|
|
||||||
// Long-press state
|
|
||||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const longPressTriggered = useRef(false);
|
|
||||||
|
|
||||||
const sortedFiles = useMemo(() => {
|
const sortedFiles = useMemo(() => {
|
||||||
const sorted = [...files];
|
const sorted = [...files];
|
||||||
@@ -217,174 +195,6 @@ export function FileExplorer({
|
|||||||
return sorted;
|
return sorted;
|
||||||
}, [files, sortBy, sortOrder]);
|
}, [files, sortBy, sortOrder]);
|
||||||
|
|
||||||
// ─── Drag handler ───
|
|
||||||
function handleDragStart(e: React.DragEvent, file: DmsFile) {
|
|
||||||
let ids: string[];
|
|
||||||
if (selectedFileIds.has(file.id) && selectedFileIds.size > 1) {
|
|
||||||
ids = Array.from(selectedFileIds);
|
|
||||||
} else {
|
|
||||||
ids = [file.id];
|
|
||||||
}
|
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'files', ids }));
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
setDraggingId(file.id);
|
|
||||||
onFileDragStart?.(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd() {
|
|
||||||
setDraggingId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Click handler with modifier detection ───
|
|
||||||
const handleRowClick = useCallback((e: React.MouseEvent, file: DmsFile, index: number) => {
|
|
||||||
if (longPressTriggered.current) {
|
|
||||||
longPressTriggered.current = false;
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.shiftKey && lastClickedIndex.current !== null && onRangeSelect) {
|
|
||||||
const start = Math.min(lastClickedIndex.current, index);
|
|
||||||
const end = Math.max(lastClickedIndex.current, index);
|
|
||||||
const rangeIds = sortedFiles.slice(start, end + 1).map(f => f.id);
|
|
||||||
onRangeSelect(rangeIds);
|
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
|
||||||
onToggleSelect(file.id);
|
|
||||||
lastClickedIndex.current = index;
|
|
||||||
} else {
|
|
||||||
onFileClick(file);
|
|
||||||
lastClickedIndex.current = index;
|
|
||||||
}
|
|
||||||
}, [sortedFiles, onRangeSelect, onToggleSelect, onFileClick]);
|
|
||||||
|
|
||||||
// ─── Long-press handlers for touch / mobile ───
|
|
||||||
const handleTouchStart = useCallback((file: DmsFile) => {
|
|
||||||
longPressTriggered.current = false;
|
|
||||||
longPressTimer.current = setTimeout(() => {
|
|
||||||
longPressTriggered.current = true;
|
|
||||||
onToggleSelect(file.id);
|
|
||||||
if (navigator.vibrate) navigator.vibrate(50);
|
|
||||||
}, 500);
|
|
||||||
}, [onToggleSelect]);
|
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(() => {
|
|
||||||
if (longPressTimer.current) {
|
|
||||||
clearTimeout(longPressTimer.current);
|
|
||||||
longPressTimer.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTouchMove = useCallback(() => {
|
|
||||||
if (longPressTimer.current) {
|
|
||||||
clearTimeout(longPressTimer.current);
|
|
||||||
longPressTimer.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ─── Rubber-band handlers ───
|
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-file-id]')) return;
|
|
||||||
if (target.closest('button') || target.closest('input') || target.tagName === 'TH') return;
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left + container.scrollLeft;
|
|
||||||
const y = e.clientY - rect.top + container.scrollTop;
|
|
||||||
|
|
||||||
rubberBandStartRef.current = { x, y };
|
|
||||||
setRubberBand({ startX: x, startY: y, endX: x, endY: y });
|
|
||||||
setRubberBandSelected(new Set());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
||||||
if (!rubberBandStartRef.current) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left + container.scrollLeft;
|
|
||||||
const y = e.clientY - rect.top + container.scrollTop;
|
|
||||||
|
|
||||||
const rb = {
|
|
||||||
startX: rubberBandStartRef.current.x,
|
|
||||||
startY: rubberBandStartRef.current.y,
|
|
||||||
endX: x,
|
|
||||||
endY: y,
|
|
||||||
};
|
|
||||||
setRubberBand(rb);
|
|
||||||
|
|
||||||
const rbLeft = Math.min(rb.startX, rb.endX);
|
|
||||||
const rbTop = Math.min(rb.startY, rb.endY);
|
|
||||||
const rbRight = Math.max(rb.startX, rb.endX);
|
|
||||||
const rbBottom = Math.max(rb.startY, rb.endY);
|
|
||||||
|
|
||||||
const newSelected = new Set<string>();
|
|
||||||
fileElementRefs.current.forEach((el, fileId) => {
|
|
||||||
const elRect = el.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const elLeft = elRect.left - containerRect.left + container.scrollLeft;
|
|
||||||
const elTop = elRect.top - containerRect.top + container.scrollTop;
|
|
||||||
const elRight = elLeft + elRect.width;
|
|
||||||
const elBottom = elTop + elRect.height;
|
|
||||||
|
|
||||||
if (elLeft < rbRight && elRight > rbLeft && elTop < rbBottom && elBottom > rbTop) {
|
|
||||||
newSelected.add(fileId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setRubberBandSelected(newSelected);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
|
||||||
if (!rubberBandStartRef.current) return;
|
|
||||||
|
|
||||||
const hasDragged = rubberBand !== null &&
|
|
||||||
(Math.abs(rubberBand.endX - rubberBand.startX) > 3 ||
|
|
||||||
Math.abs(rubberBand.endY - rubberBand.startY) > 3);
|
|
||||||
|
|
||||||
if (hasDragged && rubberBandSelected.size > 0) {
|
|
||||||
onRangeSelect?.(Array.from(rubberBandSelected));
|
|
||||||
} else if (!hasDragged) {
|
|
||||||
onRangeSelect?.([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
rubberBandStartRef.current = null;
|
|
||||||
setRubberBand(null);
|
|
||||||
setRubberBandSelected(new Set());
|
|
||||||
}, [rubberBand, rubberBandSelected, onRangeSelect]);
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
if (rubberBandStartRef.current) {
|
|
||||||
rubberBandStartRef.current = null;
|
|
||||||
setRubberBand(null);
|
|
||||||
setRubberBandSelected(new Set());
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Helper: check if file is selected (considering rubber-band)
|
|
||||||
const isFileSelected = (fileId: string) => {
|
|
||||||
if (rubberBand !== null) {
|
|
||||||
return rubberBandSelected.has(fileId);
|
|
||||||
}
|
|
||||||
return selectedFileIds.has(fileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rubber-band rectangle element
|
|
||||||
const rubberBandRect = rubberBand && (
|
|
||||||
<div
|
|
||||||
className="absolute border border-primary-400 bg-primary-100/30 pointer-events-none z-50 rounded-sm"
|
|
||||||
style={{
|
|
||||||
left: Math.min(rubberBand.startX, rubberBand.endX),
|
|
||||||
top: Math.min(rubberBand.startY, rubberBand.endY),
|
|
||||||
width: Math.abs(rubberBand.endX - rubberBand.startX),
|
|
||||||
height: Math.abs(rubberBand.endY - rubberBand.startY),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
||||||
@@ -411,15 +221,8 @@ export function FileExplorer({
|
|||||||
// ─── List View ───
|
// ─── List View ───
|
||||||
if (viewMode === 'list') {
|
if (viewMode === 'list') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="overflow-y-auto h-full" data-testid="file-explorer-list">
|
||||||
ref={containerRef}
|
{/* Sort header */}
|
||||||
className={clsx('overflow-y-auto h-full relative', rubberBand && 'select-none')}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
data-testid="file-explorer-list"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-100 sticky top-0 bg-white z-10">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-100 sticky top-0 bg-white z-10">
|
||||||
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||||
<span className="text-secondary-300">|</span>
|
<span className="text-secondary-300">|</span>
|
||||||
@@ -428,29 +231,19 @@ export function FileExplorer({
|
|||||||
<SortHeader label={t('dms.sortDate')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
<SortHeader label={t('dms.sortDate')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-secondary-50">
|
<ul className="divide-y divide-secondary-50">
|
||||||
{sortedFiles.map((file, index) => {
|
{sortedFiles.map((file) => {
|
||||||
const icon = getFileIcon(file.mime_type);
|
const icon = getFileIcon(file.mime_type);
|
||||||
const isSelected = isFileSelected(file.id);
|
const isSelected = selectedFileIds.has(file.id);
|
||||||
const isActive = selectedFile?.id === file.id;
|
const isActive = selectedFile?.id === file.id;
|
||||||
const isDragging = draggingId === file.id;
|
|
||||||
return (
|
return (
|
||||||
<li key={file.id}>
|
<li key={file.id}>
|
||||||
<div
|
<div
|
||||||
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
|
||||||
data-file-id={file.id}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
|
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
|
||||||
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||||
isDragging && 'opacity-50',
|
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(e, file, index)}
|
onClick={() => onFileClick(file)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
onTouchStart={() => handleTouchStart(file)}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
data-testid={`file-row-${file.id}`}
|
data-testid={`file-row-${file.id}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -473,7 +266,6 @@ export function FileExplorer({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
{rubberBandRect}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -481,15 +273,7 @@ export function FileExplorer({
|
|||||||
// ─── Table View ───
|
// ─── Table View ───
|
||||||
if (viewMode === 'table') {
|
if (viewMode === 'table') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="overflow-auto h-full" data-testid="file-explorer-table">
|
||||||
ref={containerRef}
|
|
||||||
className={clsx('overflow-auto h-full relative', rubberBand && 'select-none')}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
data-testid="file-explorer-table"
|
|
||||||
>
|
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -517,29 +301,19 @@ export function FileExplorer({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-50">
|
<tbody className="divide-y divide-secondary-50">
|
||||||
{sortedFiles.map((file, index) => {
|
{sortedFiles.map((file) => {
|
||||||
const icon = getFileIcon(file.mime_type);
|
const icon = getFileIcon(file.mime_type);
|
||||||
const isSelected = isFileSelected(file.id);
|
const isSelected = selectedFileIds.has(file.id);
|
||||||
const isActive = selectedFile?.id === file.id;
|
const isActive = selectedFile?.id === file.id;
|
||||||
const isDragging = draggingId === file.id;
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={file.id}
|
key={file.id}
|
||||||
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
|
||||||
data-file-id={file.id}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'cursor-pointer motion-safe:transition-colors',
|
'cursor-pointer motion-safe:transition-colors',
|
||||||
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||||
isDragging && 'opacity-50',
|
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(e, file, index)}
|
onClick={() => onFileClick(file)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
onTouchStart={() => handleTouchStart(file)}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
data-testid={`file-row-${file.id}`}
|
data-testid={`file-row-${file.id}`}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -570,143 +344,56 @@ export function FileExplorer({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{rubberBandRect}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Icon Views ───
|
// ─── Icon Views ───
|
||||||
const gridMinWidth =
|
const minColWidth =
|
||||||
viewMode === 'icons-sm' ? '80px'
|
viewMode === 'icons-sm' ? '80px'
|
||||||
: viewMode === 'icons-md' ? '120px'
|
: viewMode === 'icons-md' ? '120px'
|
||||||
: '180px';
|
: '180px';
|
||||||
|
|
||||||
const gridClass = `grid gap-3 p-3 overflow-y-auto h-full items-start content-start relative`;
|
const iconSize =
|
||||||
const gridStyle = { gridTemplateColumns: `repeat(auto-fill, minmax(${gridMinWidth}, 1fr))` };
|
viewMode === 'icons-sm' ? 'w-6 h-6'
|
||||||
|
: viewMode === 'icons-md' ? 'w-8 h-8'
|
||||||
|
: 'w-16 h-16';
|
||||||
|
|
||||||
|
const isSmall = viewMode === 'icons-sm';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
className="grid gap-1.5 p-2 overflow-y-auto h-full items-start content-start"
|
||||||
className={clsx(gridClass, rubberBand && 'select-none')}
|
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${minColWidth}, 1fr))` }}
|
||||||
style={gridStyle}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
data-testid={`file-explorer-${viewMode}`}
|
data-testid={`file-explorer-${viewMode}`}
|
||||||
>
|
>
|
||||||
{sortedFiles.map((file, index) => {
|
{sortedFiles.map((file) => {
|
||||||
const icon = getFileIcon(file.mime_type);
|
const icon = getFileIcon(file.mime_type);
|
||||||
const isSelected = isFileSelected(file.id);
|
const isSelected = selectedFileIds.has(file.id);
|
||||||
const isActive = selectedFile?.id === file.id;
|
const isActive = selectedFile?.id === file.id;
|
||||||
const isDragging = draggingId === file.id;
|
|
||||||
|
|
||||||
// icons-sm: compact, no border, no action buttons
|
|
||||||
if (viewMode === 'icons-sm') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
|
||||||
data-file-id={file.id}
|
|
||||||
className={clsx(
|
|
||||||
'rounded-lg p-1.5 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
|
||||||
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50',
|
|
||||||
isDragging && 'opacity-50',
|
|
||||||
)}
|
|
||||||
onClick={(e) => handleRowClick(e, file, index)}
|
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
|
||||||
onTouchStart={() => handleTouchStart(file)}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
data-testid={`file-card-${file.id}`}
|
|
||||||
>
|
|
||||||
<svg className={clsx('w-8 h-8 mb-1', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
|
||||||
</svg>
|
|
||||||
<p className="text-[10px] font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// icons-md: medium cards with border, reduced padding
|
|
||||||
if (viewMode === 'icons-md') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
|
||||||
data-file-id={file.id}
|
|
||||||
className={clsx(
|
|
||||||
'bg-white rounded-lg border p-2 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
|
||||||
isActive
|
|
||||||
? 'border-primary-500 ring-1 ring-primary-200'
|
|
||||||
: isSelected
|
|
||||||
? 'border-primary-400'
|
|
||||||
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
|
||||||
isDragging && 'opacity-50',
|
|
||||||
)}
|
|
||||||
onClick={(e) => handleRowClick(e, file, index)}
|
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
|
||||||
onTouchStart={() => handleTouchStart(file)}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
data-testid={`file-card-${file.id}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full mb-1.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => onToggleSelect(file.id)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
||||||
aria-label={t('dms.bulkSelect')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<svg className={clsx('w-8 h-8 mb-1.5', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
|
||||||
</svg>
|
|
||||||
<p className="text-xs font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
|
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-secondary-500">
|
|
||||||
<span>{formatFileSize(getFileSize(file))}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5">
|
|
||||||
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// icons-lg: large cards with image preview
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
ref={(el) => { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }}
|
|
||||||
data-file-id={file.id}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'bg-white rounded-lg border p-3 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
isSmall
|
||||||
isActive
|
? clsx(
|
||||||
? 'border-primary-500 ring-2 ring-primary-200'
|
'rounded p-1.5 motion-safe:transition-all cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||||
: isSelected
|
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50/50' : 'hover:bg-secondary-50',
|
||||||
? 'border-primary-400'
|
)
|
||||||
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
: clsx(
|
||||||
isDragging && 'opacity-50',
|
'rounded-lg border p-2 motion-safe:transition-all cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||||
|
isActive
|
||||||
|
? 'border-primary-500 ring-1 ring-primary-200'
|
||||||
|
: isSelected
|
||||||
|
? 'border-primary-400'
|
||||||
|
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
onClick={(e) => handleRowClick(e, file, index)}
|
onClick={() => onFileClick(file)}
|
||||||
onDoubleClick={() => onFileDoubleClick(file)}
|
onDoubleClick={() => onFileDoubleClick(file)}
|
||||||
onTouchStart={() => handleTouchStart(file)}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchMove={handleTouchMove}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, file)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
data-testid={`file-card-${file.id}`}
|
data-testid={`file-card-${file.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between w-full mb-2">
|
<div className={clsx('flex items-center w-full', isSmall ? 'mb-0.5 justify-center' : 'mb-1.5 justify-between')}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
@@ -716,7 +403,7 @@ export function FileExplorer({
|
|||||||
aria-label={t('dms.bulkSelect')}
|
aria-label={t('dms.bulkSelect')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{file.mime_type.startsWith('image/') ? (
|
{viewMode === 'icons-lg' && file.mime_type.startsWith('image/') ? (
|
||||||
<div className="w-16 h-16 bg-secondary-100 rounded-lg flex items-center justify-center mb-2 overflow-hidden">
|
<div className="w-16 h-16 bg-secondary-100 rounded-lg flex items-center justify-center mb-2 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={`/api/v1/dms/files/${file.id}/preview`}
|
src={`/api/v1/dms/files/${file.id}/preview`}
|
||||||
@@ -728,21 +415,24 @@ export function FileExplorer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<svg className={clsx('w-16 h-16 mb-2', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<svg className={clsx(iconSize, icon.color, isSmall ? 'mb-0.5' : 'mb-1')} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
|
<p className={clsx('font-medium text-secondary-900 truncate w-full', isSmall ? 'text-[10px]' : 'text-xs')} title={file.name}>{file.name}</p>
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-secondary-500">
|
{!isSmall && (
|
||||||
<span>{formatFileSize(getFileSize(file))}</span>
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-secondary-500">
|
||||||
</div>
|
<span>{formatFileSize(getFileSize(file))}</span>
|
||||||
<div className="mt-2">
|
</div>
|
||||||
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
)}
|
||||||
</div>
|
{!isSmall && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{rubberBandRect}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function FileGrid({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" data-testid="file-grid-loading">
|
<div className="grid gap-3 items-start content-start" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }} data-testid="file-grid-loading">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
<div key={i} className="bg-white rounded-lg border border-secondary-200 p-4 animate-pulse">
|
<div key={i} className="bg-white rounded-lg border border-secondary-200 p-4 animate-pulse">
|
||||||
<div className="h-12 w-12 bg-secondary-100 rounded mb-3" />
|
<div className="h-12 w-12 bg-secondary-100 rounded mb-3" />
|
||||||
@@ -90,7 +90,7 @@ export function FileGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" data-testid="file-grid">
|
<div className="grid gap-3 items-start content-start" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }} data-testid="file-grid">
|
||||||
{sortedFiles.map((file) => {
|
{sortedFiles.map((file) => {
|
||||||
const icon = getFileIcon(file.mime_type);
|
const icon = getFileIcon(file.mime_type);
|
||||||
const isSelected = selectedFileIds.has(file.id);
|
const isSelected = selectedFileIds.has(file.id);
|
||||||
@@ -98,7 +98,7 @@ export function FileGrid({
|
|||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'bg-white rounded-lg border p-4 motion-safe:transition-all cursor-pointer',
|
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer overflow-hidden flex flex-col',
|
||||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||||
)}
|
)}
|
||||||
onClick={() => onFileClick(file)}
|
onClick={() => onFileClick(file)}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function FolderTreeItem({ folder, level, selectedFolderId, onSelect }: FolderTre
|
|||||||
<li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} aria-selected={isSelected}>
|
<li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} aria-selected={isSelected}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch',
|
'flex items-center gap-1 px-2 py-0.5 rounded-md text-sm cursor-pointer',
|
||||||
'hover:bg-secondary-100 motion-safe:transition-colors',
|
'hover:bg-secondary-100 motion-safe:transition-colors',
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
isSelected && 'bg-primary-50 text-primary-700 font-medium'
|
isSelected && 'bg-primary-50 text-primary-700 font-medium'
|
||||||
@@ -67,7 +67,7 @@ function FolderTreeItem({ folder, level, selectedFolderId, onSelect }: FolderTre
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
<ul role="group" className="space-y-0.5">
|
<ul role="group" className="space-y-0">
|
||||||
{folder.children!.map((child) => (
|
{folder.children!.map((child) => (
|
||||||
<FolderTreeItem
|
<FolderTreeItem
|
||||||
key={child.id}
|
key={child.id}
|
||||||
@@ -105,11 +105,11 @@ export function FolderTree({ folders, selectedFolderId, onSelect, loading = fals
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label={t('dms.folders')} data-testid="folder-tree">
|
<nav aria-label={t('dms.folders')} data-testid="folder-tree">
|
||||||
<ul role="tree" className="space-y-0.5">
|
<ul role="tree" className="space-y-0">
|
||||||
<li role="treeitem" aria-selected={selectedFolderId === null}>
|
<li role="treeitem" aria-selected={selectedFolderId === null}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch',
|
'flex items-center gap-2 px-2 py-0.5 rounded-md text-sm cursor-pointer',
|
||||||
'hover:bg-secondary-100 motion-safe:transition-colors',
|
'hover:bg-secondary-100 motion-safe:transition-colors',
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
selectedFolderId === null && 'bg-primary-50 text-primary-700 font-medium'
|
selectedFolderId === null && 'bg-primary-50 text-primary-700 font-medium'
|
||||||
|
|||||||
@@ -648,7 +648,110 @@
|
|||||||
"Oktober",
|
"Oktober",
|
||||||
"November",
|
"November",
|
||||||
"Dezember"
|
"Dezember"
|
||||||
]
|
],
|
||||||
|
"title": "Kalender",
|
||||||
|
"today": "Heute",
|
||||||
|
"newAppointment": "Neuer Termin",
|
||||||
|
"noCalendars": "Keine Kalender",
|
||||||
|
"noEntries": "Keine Einträge",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"errorLoad": "Fehler beim Laden",
|
||||||
|
"errorSave": "Fehler beim Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"confirmDelete": "Möchten Sie diesen Eintrag wirklich löschen?",
|
||||||
|
"previousMonth": "Vorheriger Monat",
|
||||||
|
"nextMonth": "Nächster Monat",
|
||||||
|
"appointmentCalendar": "Kalender",
|
||||||
|
"viewDay": "Tag",
|
||||||
|
"viewWeek": "Woche",
|
||||||
|
"viewMonth": "Monat",
|
||||||
|
"viewRange": "Zeitraum",
|
||||||
|
"navigation": {
|
||||||
|
"prev": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"today": "Heute"
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"start": "Von",
|
||||||
|
"end": "Bis"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"title": "Kalender",
|
||||||
|
"newCalendar": "Neuer Kalender",
|
||||||
|
"loading": "Laden…"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"personal": "Persönlich",
|
||||||
|
"team": "Team",
|
||||||
|
"project": "Projekt",
|
||||||
|
"company": "Firma"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"noSelection": "Kein Eintrag ausgewählt",
|
||||||
|
"noSelectionHint": "Wählen Sie einen Eintrag aus dem Kalender, um Details anzuzeigen",
|
||||||
|
"title": "Details",
|
||||||
|
"type": "Typ",
|
||||||
|
"status": "Status",
|
||||||
|
"priority": "Priorität",
|
||||||
|
"start": "Beginn",
|
||||||
|
"end": "Ende",
|
||||||
|
"location": "Ort",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"calendar": "Kalender",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"subtasks": {
|
||||||
|
"empty": "Keine Teilaufgaben",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"addPlaceholder": "Neue Teilaufgabe…"
|
||||||
|
},
|
||||||
|
"links": "Verknüpfungen",
|
||||||
|
"noLinks": "Keine Verknüpfungen"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "Offen",
|
||||||
|
"in_progress": "In Bearbeitung",
|
||||||
|
"done": "Erledigt",
|
||||||
|
"cancelled": "Abgebrochen"
|
||||||
|
},
|
||||||
|
"subtasks": {
|
||||||
|
"title": "Teilaufgaben",
|
||||||
|
"empty": "Keine Teilaufgaben",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"addPlaceholder": "Neue Teilaufgabe…"
|
||||||
|
},
|
||||||
|
"ics": {
|
||||||
|
"export": "ICS Export",
|
||||||
|
"import": "ICS Import",
|
||||||
|
"exportSuccess": "Export erfolgreich",
|
||||||
|
"importSuccess": "{{count}} Einträge importiert",
|
||||||
|
"importPartial": "{{count}} Einträge importiert, {{errors}} Fehler",
|
||||||
|
"importFailed": "Import fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Freigaben",
|
||||||
|
"user": "Benutzer",
|
||||||
|
"group": "Gruppe",
|
||||||
|
"userPlaceholder": "Benutzer-ID",
|
||||||
|
"groupPlaceholder": "Gruppen-ID",
|
||||||
|
"permissionRead": "Lesen",
|
||||||
|
"permissionWrite": "Schreiben",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"added": "Freigabe hinzugefügt",
|
||||||
|
"removed": "Freigabe entfernt",
|
||||||
|
"list": "Aktuelle Freigaben",
|
||||||
|
"empty": "Keine Freigaben"
|
||||||
|
},
|
||||||
|
"weekView": {
|
||||||
|
"allday": "Ganztägig"
|
||||||
|
},
|
||||||
|
"dayView": {
|
||||||
|
"allday": "Ganztägig"
|
||||||
|
},
|
||||||
|
"rangeView": {
|
||||||
|
"noEntries": "Keine Einträge",
|
||||||
|
"entriesFor": "{{count}} Einträge"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dms": {
|
"dms": {
|
||||||
"title": "Dateien",
|
"title": "Dateien",
|
||||||
|
|||||||
@@ -648,7 +648,110 @@
|
|||||||
"October",
|
"October",
|
||||||
"November",
|
"November",
|
||||||
"December"
|
"December"
|
||||||
]
|
],
|
||||||
|
"title": "Calendar",
|
||||||
|
"today": "Today",
|
||||||
|
"newAppointment": "New Appointment",
|
||||||
|
"noCalendars": "No calendars",
|
||||||
|
"noEntries": "No entries",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"errorLoad": "Error loading",
|
||||||
|
"errorSave": "Error saving",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this entry?",
|
||||||
|
"previousMonth": "Previous month",
|
||||||
|
"nextMonth": "Next month",
|
||||||
|
"appointmentCalendar": "Calendar",
|
||||||
|
"viewDay": "Day",
|
||||||
|
"viewWeek": "Week",
|
||||||
|
"viewMonth": "Month",
|
||||||
|
"viewRange": "Range",
|
||||||
|
"navigation": {
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"today": "Today"
|
||||||
|
},
|
||||||
|
"range": {
|
||||||
|
"start": "From",
|
||||||
|
"end": "To"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"title": "Calendars",
|
||||||
|
"newCalendar": "New Calendar",
|
||||||
|
"loading": "Loading…"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"personal": "Personal",
|
||||||
|
"team": "Team",
|
||||||
|
"project": "Project",
|
||||||
|
"company": "Company"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"noSelection": "No entry selected",
|
||||||
|
"noSelectionHint": "Select an entry from the calendar to view details",
|
||||||
|
"title": "Details",
|
||||||
|
"type": "Type",
|
||||||
|
"status": "Status",
|
||||||
|
"priority": "Priority",
|
||||||
|
"start": "Start",
|
||||||
|
"end": "End",
|
||||||
|
"location": "Location",
|
||||||
|
"description": "Description",
|
||||||
|
"calendar": "Calendar",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"subtasks": {
|
||||||
|
"empty": "No subtasks",
|
||||||
|
"add": "Add",
|
||||||
|
"addPlaceholder": "New subtask…"
|
||||||
|
},
|
||||||
|
"links": "Links",
|
||||||
|
"noLinks": "No links"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "Open",
|
||||||
|
"in_progress": "In Progress",
|
||||||
|
"done": "Done",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"subtasks": {
|
||||||
|
"title": "Subtasks",
|
||||||
|
"empty": "No subtasks",
|
||||||
|
"add": "Add",
|
||||||
|
"addPlaceholder": "New subtask…"
|
||||||
|
},
|
||||||
|
"ics": {
|
||||||
|
"export": "ICS Export",
|
||||||
|
"import": "ICS Import",
|
||||||
|
"exportSuccess": "Export successful",
|
||||||
|
"importSuccess": "{{count}} entries imported",
|
||||||
|
"importPartial": "{{count}} entries imported, {{errors}} errors",
|
||||||
|
"importFailed": "Import failed"
|
||||||
|
},
|
||||||
|
"sharing": {
|
||||||
|
"title": "Shares",
|
||||||
|
"user": "User",
|
||||||
|
"group": "Group",
|
||||||
|
"userPlaceholder": "User ID",
|
||||||
|
"groupPlaceholder": "Group ID",
|
||||||
|
"permissionRead": "Read",
|
||||||
|
"permissionWrite": "Write",
|
||||||
|
"add": "Add",
|
||||||
|
"added": "Share added",
|
||||||
|
"removed": "Share removed",
|
||||||
|
"list": "Current shares",
|
||||||
|
"empty": "No shares"
|
||||||
|
},
|
||||||
|
"weekView": {
|
||||||
|
"allday": "All day"
|
||||||
|
},
|
||||||
|
"dayView": {
|
||||||
|
"allday": "All day"
|
||||||
|
},
|
||||||
|
"rangeView": {
|
||||||
|
"noEntries": "No entries",
|
||||||
|
"entriesFor": "{{count}} entries"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dms": {
|
"dms": {
|
||||||
"title": "Files",
|
"title": "Files",
|
||||||
|
|||||||
+593
-142
@@ -1,55 +1,91 @@
|
|||||||
/**
|
/**
|
||||||
* Calendar page — month view with toolbar, ICS controls, sharing.
|
* Calendar page - 3-column explorer-style layout.
|
||||||
*
|
*
|
||||||
* Owns the data fetching (entries + calendars + resources) for the visible
|
* Left: CalendarTree (calendar selection + visibility)
|
||||||
* month and dispatches user actions to the appropriate API client call.
|
* Middle: Calendar view (Day / Week / Month / Range) with date controls
|
||||||
|
* Right: CalendarDetail (selected entry details + subtasks)
|
||||||
|
*
|
||||||
|
* Uses PluginToolbar for actions, ResizablePanel for desktop layout.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||||
import { Select } from '@/components/ui/Select';
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { CalendarTree } from '@/components/calendar/CalendarTree';
|
||||||
import { MonthView } from '@/components/calendar/MonthView';
|
import { MonthView } from '@/components/calendar/MonthView';
|
||||||
|
import { WeekView } from '@/components/calendar/WeekView';
|
||||||
|
import { DayView } from '@/components/calendar/DayView';
|
||||||
|
import { RangeView } from '@/components/calendar/RangeView';
|
||||||
|
import { CalendarDetail } from '@/components/calendar/CalendarDetail';
|
||||||
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
|
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
|
||||||
import { IcsControls } from '@/components/calendar/IcsControls';
|
import { IcsControls } from '@/components/calendar/IcsControls';
|
||||||
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
||||||
import { TaskDetailPanel } from '@/components/calendar/TaskDetailPanel';
|
import { useCalendarStore, type CalendarViewMode } from '@/stores/calendarStore';
|
||||||
import { useCalendarStore } from '@/stores/calendarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
import {
|
import {
|
||||||
fetchCalendars,
|
fetchCalendars,
|
||||||
|
createCalendar,
|
||||||
listEntries,
|
listEntries,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
type Calendar,
|
type Calendar,
|
||||||
type CalendarEntry,
|
type CalendarEntry,
|
||||||
} from '@/api/calendar';
|
} from '@/api/calendar';
|
||||||
|
|
||||||
export function CalendarPage() {
|
export function CalendarPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
visibleMonth,
|
visibleMonth,
|
||||||
|
visibleWeek,
|
||||||
|
visibleDay,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
setRangeStart,
|
||||||
|
setRangeEnd,
|
||||||
goToNextMonth,
|
goToNextMonth,
|
||||||
goToPrevMonth,
|
goToPrevMonth,
|
||||||
|
goToNextWeek,
|
||||||
|
goToPrevWeek,
|
||||||
|
goToNextDay,
|
||||||
|
goToPrevDay,
|
||||||
goToToday,
|
goToToday,
|
||||||
activeCalendarId,
|
activeCalendarId,
|
||||||
setActiveCalendarId,
|
setActiveCalendarId,
|
||||||
calendars,
|
calendars,
|
||||||
setCalendars,
|
setCalendars,
|
||||||
|
selectedEntry,
|
||||||
|
setSelectedEntry,
|
||||||
|
visibleCalendarIds,
|
||||||
|
toggleCalendarVisibility,
|
||||||
} = useCalendarStore();
|
} = useCalendarStore();
|
||||||
|
|
||||||
const [entries, setEntries] = useState<CalendarEntry[]>([]);
|
const [entries, setEntries] = useState<CalendarEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingCalendars, setLoadingCalendars] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalEntry, setModalEntry] = useState<CalendarEntry | null>(null);
|
const [modalEntry, setModalEntry] = useState<CalendarEntry | null>(null);
|
||||||
const [prefillDate, setPrefillDate] = useState<Date | null>(null);
|
const [prefillDate, setPrefillDate] = useState<Date | null>(null);
|
||||||
|
|
||||||
const [selectedTask, setSelectedTask] = useState<CalendarEntry | null>(null);
|
const [showSharing, setShowSharing] = useState(false);
|
||||||
|
const [showIcs, setShowIcs] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CalendarEntry | null>(null);
|
||||||
|
const [showDetails, setShowDetails] = useState(true);
|
||||||
|
|
||||||
|
// Mobile view state
|
||||||
|
const [activeView, setActiveView] = useState<'tree' | 'calendar' | 'details'>('tree');
|
||||||
|
|
||||||
// Load calendars once
|
// Load calendars once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setLoadingCalendars(true);
|
||||||
fetchCalendars()
|
fetchCalendars()
|
||||||
.then((list) => {
|
.then((list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -60,6 +96,9 @@ export function CalendarPage() {
|
|||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
if (!cancelled) setError(e.message);
|
if (!cancelled) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoadingCalendars(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -67,17 +106,40 @@ export function CalendarPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Range covering the visible grid (always 6 weeks = 42 days)
|
// Compute date range based on viewMode
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1);
|
if (viewMode === 'month') {
|
||||||
const dayOfWeek = (first.getDay() + 6) % 7;
|
const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1);
|
||||||
const start = new Date(first);
|
const dayOfWeek = (first.getDay() + 6) % 7;
|
||||||
start.setDate(first.getDate() - dayOfWeek);
|
const start = new Date(first);
|
||||||
|
start.setDate(first.getDate() - dayOfWeek);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + 42);
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
if (viewMode === 'week') {
|
||||||
|
const start = new Date(visibleWeek);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + 7);
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
const start = new Date(visibleDay);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(start.getDate() + 1);
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
// range
|
||||||
|
const start = rangeStart ? new Date(rangeStart) : new Date();
|
||||||
start.setHours(0, 0, 0, 0);
|
start.setHours(0, 0, 0, 0);
|
||||||
const end = new Date(start);
|
const end = rangeEnd ? new Date(rangeEnd) : new Date(start);
|
||||||
end.setDate(start.getDate() + 42);
|
end.setHours(23, 59, 59, 999);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
return { start, end };
|
return { start, end };
|
||||||
}, [visibleMonth]);
|
}, [viewMode, visibleMonth, visibleWeek, visibleDay, rangeStart, rangeEnd]);
|
||||||
|
|
||||||
const loadEntries = useCallback(async () => {
|
const loadEntries = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -99,148 +161,545 @@ export function CalendarPage() {
|
|||||||
void loadEntries();
|
void loadEntries();
|
||||||
}, [loadEntries]);
|
}, [loadEntries]);
|
||||||
|
|
||||||
const handleCreateAt = (date: Date) => {
|
// Filter entries by visible calendars
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
if (visibleCalendarIds.size === 0) return entries;
|
||||||
|
return entries.filter((e) => visibleCalendarIds.has(e.calendar_id));
|
||||||
|
}, [entries, visibleCalendarIds]);
|
||||||
|
|
||||||
|
const activeCalendar = calendars.find((c) => c.id === activeCalendarId) ?? null;
|
||||||
|
const selectedCalendar = selectedEntry
|
||||||
|
? calendars.find((c) => c.id === selectedEntry.calendar_id) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ??? Handlers ???????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
const handleCreateAt = useCallback((date: Date) => {
|
||||||
setModalEntry(null);
|
setModalEntry(null);
|
||||||
setPrefillDate(date);
|
setPrefillDate(date);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleEditEntry = (entry: CalendarEntry) => {
|
const handleEditEntry = useCallback(
|
||||||
if (entry.entry_type === 'task') {
|
(entry: CalendarEntry) => {
|
||||||
setSelectedTask(entry);
|
setSelectedEntry(entry);
|
||||||
return;
|
if (showDetails) {
|
||||||
}
|
setActiveView('details');
|
||||||
setModalEntry(entry);
|
}
|
||||||
|
},
|
||||||
|
[setSelectedEntry, showDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenEditModal = useCallback(() => {
|
||||||
|
if (!selectedEntry) return;
|
||||||
|
setModalEntry(selectedEntry);
|
||||||
setPrefillDate(null);
|
setPrefillDate(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
}, [selectedEntry]);
|
||||||
|
|
||||||
const handleMoveEntry = async (entry: CalendarEntry, newStart: Date) => {
|
const handleMoveEntry = useCallback(
|
||||||
try {
|
async (entry: CalendarEntry, newStart: Date) => {
|
||||||
const oldEnd = entry.end_at ? new Date(entry.end_at) : null;
|
try {
|
||||||
const oldStart = entry.start_at ? new Date(entry.start_at) : null;
|
const oldEnd = entry.end_at ? new Date(entry.end_at) : null;
|
||||||
const durationMs = oldStart && oldEnd ? oldEnd.getTime() - oldStart.getTime() : 60 * 60 * 1000;
|
const oldStart = entry.start_at ? new Date(entry.start_at) : null;
|
||||||
const newEnd = new Date(newStart.getTime() + durationMs);
|
const durationMs =
|
||||||
const updated = await updateEntry(entry.id, {
|
oldStart && oldEnd ? oldEnd.getTime() - oldStart.getTime() : 60 * 60 * 1000;
|
||||||
start_at: newStart.toISOString(),
|
const newEnd = new Date(newStart.getTime() + durationMs);
|
||||||
end_at: newEnd.toISOString(),
|
const updated = await updateEntry(entry.id, {
|
||||||
|
start_at: newStart.toISOString(),
|
||||||
|
end_at: newEnd.toISOString(),
|
||||||
|
});
|
||||||
|
setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e)));
|
||||||
|
if (selectedEntry?.id === updated.id) {
|
||||||
|
setSelectedEntry(updated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message ?? t('calendar.errorSave'));
|
||||||
|
await loadEntries();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, loadEntries, selectedEntry, setSelectedEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaved = useCallback(
|
||||||
|
(saved: CalendarEntry) => {
|
||||||
|
setEntries((cur) => {
|
||||||
|
const exists = cur.some((e) => e.id === saved.id);
|
||||||
|
return exists ? cur.map((e) => (e.id === saved.id ? saved : e)) : [...cur, saved];
|
||||||
});
|
});
|
||||||
|
if (selectedEntry?.id === saved.id) {
|
||||||
|
setSelectedEntry(saved);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedEntry, setSelectedEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleted = useCallback(
|
||||||
|
(entryId: string) => {
|
||||||
|
setEntries((cur) => cur.filter((e) => e.id !== entryId));
|
||||||
|
if (selectedEntry?.id === entryId) {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedEntry, setSelectedEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteEntry(deleteTarget.id);
|
||||||
|
toast.success(t('calendar.detail.delete'));
|
||||||
|
handleDeleted(deleteTarget.id);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
}, [deleteTarget, handleDeleted, toast, t]);
|
||||||
|
|
||||||
|
const handleDeleteFromDetail = useCallback(() => {
|
||||||
|
if (!selectedEntry) return;
|
||||||
|
setDeleteTarget(selectedEntry);
|
||||||
|
}, [selectedEntry]);
|
||||||
|
|
||||||
|
const handleEntryChanged = useCallback(
|
||||||
|
(updated: CalendarEntry) => {
|
||||||
setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e)));
|
setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e)));
|
||||||
} catch (e) {
|
if (selectedEntry?.id === updated.id) {
|
||||||
setError((e as Error).message ?? t('calendar.errorSave'));
|
setSelectedEntry(updated);
|
||||||
await loadEntries();
|
}
|
||||||
|
},
|
||||||
|
[selectedEntry, setSelectedEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateCalendar = useCallback(async () => {
|
||||||
|
const name = window.prompt(t('calendar.tree.newCalendar'));
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
const created = await createCalendar({ name: name.trim() });
|
||||||
|
setCalendars([...calendars, created]);
|
||||||
|
setActiveCalendarId(created.id);
|
||||||
|
toast.success(t('calendar.tree.newCalendar'));
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(msg);
|
||||||
|
}
|
||||||
|
}, [t, toast, setCalendars, setActiveCalendarId, calendars]);
|
||||||
|
|
||||||
|
const handleSelectCalendar = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setActiveCalendarId(id);
|
||||||
|
setActiveView('calendar');
|
||||||
|
},
|
||||||
|
[setActiveCalendarId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ??? Toolbar registration ???????????????????????????????????????????????
|
||||||
|
|
||||||
|
const registerItems = usePluginToolbarStore((s) => s.registerItems);
|
||||||
|
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const items = [
|
||||||
|
// Navigation group
|
||||||
|
{
|
||||||
|
id: 'nav-prev',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.navigation.prev'),
|
||||||
|
group: 'navigation',
|
||||||
|
onClick:
|
||||||
|
viewMode === 'month'
|
||||||
|
? goToPrevMonth
|
||||||
|
: viewMode === 'week' ? goToPrevWeek
|
||||||
|
: viewMode === 'day' ? goToPrevDay
|
||||||
|
: () => {},
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-today',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.navigation.today'),
|
||||||
|
group: 'navigation',
|
||||||
|
onClick: goToToday,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nav-next',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.navigation.next'),
|
||||||
|
group: 'navigation',
|
||||||
|
onClick:
|
||||||
|
viewMode === 'month'
|
||||||
|
? goToNextMonth
|
||||||
|
: viewMode === 'week'
|
||||||
|
? goToNextWeek
|
||||||
|
: viewMode === 'day'
|
||||||
|
? goToNextDay
|
||||||
|
: () => {},
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// View mode group
|
||||||
|
{
|
||||||
|
id: 'view-mode',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.viewMonth'),
|
||||||
|
group: 'view-mode',
|
||||||
|
type: 'select' as const,
|
||||||
|
selectValue: viewMode,
|
||||||
|
selectOptions: [
|
||||||
|
{ value: 'day', label: t('calendar.viewDay') },
|
||||||
|
{ value: 'week', label: t('calendar.viewWeek') },
|
||||||
|
{ value: 'month', label: t('calendar.viewMonth') },
|
||||||
|
{ value: 'range', label: t('calendar.viewRange') },
|
||||||
|
],
|
||||||
|
onSelect: (value: string) => setViewMode(value as CalendarViewMode),
|
||||||
|
onClick: () => {},
|
||||||
|
},
|
||||||
|
// Actions group
|
||||||
|
{
|
||||||
|
id: 'new-appointment',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.newAppointment'),
|
||||||
|
group: 'actions',
|
||||||
|
onClick: () => {
|
||||||
|
setModalEntry(null);
|
||||||
|
setPrefillDate(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
},
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ics-import',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.ics.import'),
|
||||||
|
group: 'actions',
|
||||||
|
onClick: () => setShowIcs((v) => !v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ics-export',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.ics.export'),
|
||||||
|
group: 'actions',
|
||||||
|
onClick: () => setShowIcs((v) => !v),
|
||||||
|
},
|
||||||
|
// Details toggle group
|
||||||
|
{
|
||||||
|
id: 'toggle-details',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: t('calendar.detail.title'),
|
||||||
|
group: 'details',
|
||||||
|
active: showDetails,
|
||||||
|
onClick: () => setShowDetails((v) => !v),
|
||||||
|
icon: (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
registerItems('calendar', items);
|
||||||
|
return () => unregisterPlugin('calendar');
|
||||||
|
}, [
|
||||||
|
viewMode,
|
||||||
|
showDetails,
|
||||||
|
goToPrevMonth,
|
||||||
|
goToNextMonth,
|
||||||
|
goToPrevWeek,
|
||||||
|
goToNextWeek,
|
||||||
|
goToPrevDay,
|
||||||
|
goToNextDay,
|
||||||
|
goToToday,
|
||||||
|
setViewMode,
|
||||||
|
registerItems,
|
||||||
|
unregisterPlugin,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ??? Render ?????????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
const renderCalendarView = () => {
|
||||||
|
if (calendars.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full p-6" data-testid="calendar-empty">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-secondary-600">{t('calendar.noCalendars')}</p>
|
||||||
|
<p className="mt-1 text-xs text-secondary-400">{t('calendar.noEntries')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
return (
|
||||||
|
<DayView
|
||||||
|
visibleDay={visibleDay}
|
||||||
|
entries={filteredEntries}
|
||||||
|
loading={loading}
|
||||||
|
onCreateAt={handleCreateAt}
|
||||||
|
onEditEntry={handleEditEntry}
|
||||||
|
onMoveEntry={handleMoveEntry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'week':
|
||||||
|
return (
|
||||||
|
<WeekView
|
||||||
|
visibleWeek={visibleWeek}
|
||||||
|
entries={filteredEntries}
|
||||||
|
loading={loading}
|
||||||
|
onCreateAt={handleCreateAt}
|
||||||
|
onEditEntry={handleEditEntry}
|
||||||
|
onMoveEntry={handleMoveEntry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'range':
|
||||||
|
return (
|
||||||
|
<RangeView
|
||||||
|
rangeStart={rangeStart ?? new Date()}
|
||||||
|
rangeEnd={rangeEnd ?? new Date()}
|
||||||
|
entries={filteredEntries}
|
||||||
|
loading={loading}
|
||||||
|
onEditEntry={handleEditEntry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<MonthView
|
||||||
|
visibleMonth={visibleMonth}
|
||||||
|
entries={filteredEntries}
|
||||||
|
loading={loading}
|
||||||
|
onCreateAt={handleCreateAt}
|
||||||
|
onEditEntry={handleEditEntry}
|
||||||
|
onMoveEntry={handleMoveEntry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaved = (saved: CalendarEntry) => {
|
const renderRangeControls = () => {
|
||||||
setEntries((cur) => {
|
if (viewMode !== 'range') return null;
|
||||||
const exists = cur.some((e) => e.id === saved.id);
|
return (
|
||||||
return exists ? cur.map((e) => (e.id === saved.id ? saved : e)) : [...cur, saved];
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-secondary-50">
|
||||||
});
|
<label className="text-xs text-secondary-600">{t('calendar.range.start')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rangeStart ? rangeStart.toISOString().slice(0, 10) : ''}
|
||||||
|
onChange={(e) => setRangeStart(e.target.value ? new Date(e.target.value) : null)}
|
||||||
|
className="text-sm rounded-md border border-secondary-300 px-2 py-1"
|
||||||
|
data-testid="calendar-range-start"
|
||||||
|
/>
|
||||||
|
<label className="text-xs text-secondary-600">{t('calendar.range.end')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rangeEnd ? rangeEnd.toISOString().slice(0, 10) : ''}
|
||||||
|
onChange={(e) => setRangeEnd(e.target.value ? new Date(e.target.value) : null)}
|
||||||
|
className="text-sm rounded-md border border-secondary-300 px-2 py-1"
|
||||||
|
data-testid="calendar-range-end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleted = (entryId: string) => {
|
|
||||||
setEntries((cur) => cur.filter((e) => e.id !== entryId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTaskChanged = (updated: CalendarEntry) => {
|
|
||||||
setSelectedTask(updated);
|
|
||||||
setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeCalendar = calendars.find((c) => c.id === activeCalendarId) ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-7xl mx-auto" data-testid="calendar-page">
|
<div className="flex flex-col h-full" data-testid="calendar-page">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
{error && (
|
||||||
<h1 className="text-2xl font-bold text-secondary-900">{t('calendar.title')}</h1>
|
<div className="mb-2 p-3 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="calendar-error">
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm text-danger-700">{error}</p>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToPrevMonth}
|
|
||||||
aria-label={t('calendar.previousMonth')}
|
|
||||||
data-testid="calendar-prev"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToToday}
|
|
||||||
data-testid="calendar-today"
|
|
||||||
>
|
|
||||||
{t('calendar.today')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={goToNextMonth}
|
|
||||||
aria-label={t('calendar.nextMonth')}
|
|
||||||
data-testid="calendar-next"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ICS controls panel */}
|
||||||
|
{showIcs && (
|
||||||
|
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="calendar-ics-panel">
|
||||||
|
<IcsControls calendar={activeCalendar} onImported={() => void loadEntries()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sharing settings panel */}
|
||||||
|
{showSharing && activeCalendar && (
|
||||||
|
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="calendar-sharing-panel">
|
||||||
|
<SharingSettings calendar={activeCalendar} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Desktop: three-pane layout with resizable panels */}
|
||||||
|
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||||
|
{/* Left: CalendarTree */}
|
||||||
|
<ResizablePanel
|
||||||
|
initialWidth={240}
|
||||||
|
minWidth={180}
|
||||||
|
maxWidth={400}
|
||||||
|
className="border-r border-secondary-200 bg-white"
|
||||||
|
data-testid="calendar-tree-pane"
|
||||||
|
>
|
||||||
|
<CalendarTree
|
||||||
|
calendars={calendars}
|
||||||
|
activeCalendarId={activeCalendarId}
|
||||||
|
visibleCalendarIds={visibleCalendarIds}
|
||||||
|
onSelect={handleSelectCalendar}
|
||||||
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
|
onCreate={handleCreateCalendar}
|
||||||
|
loading={loadingCalendars}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
{/* Middle: Calendar view */}
|
||||||
|
<ResizablePanel
|
||||||
|
resizable={false}
|
||||||
|
className="bg-white"
|
||||||
|
data-testid="calendar-view-pane"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{renderRangeControls()}
|
||||||
|
<div className="flex-1 overflow-hidden">{renderCalendarView()}</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
{/* Right: CalendarDetail (only when toggled on) */}
|
||||||
|
{showDetails && (
|
||||||
|
<ResizablePanel
|
||||||
|
initialWidth={300}
|
||||||
|
minWidth={200}
|
||||||
|
maxWidth={450}
|
||||||
|
handleSide="left"
|
||||||
|
className="border-l border-secondary-200 bg-white"
|
||||||
|
data-testid="calendar-detail-pane"
|
||||||
|
>
|
||||||
|
<CalendarDetail
|
||||||
|
entry={selectedEntry}
|
||||||
|
calendar={selectedCalendar}
|
||||||
|
onEdit={handleOpenEditModal}
|
||||||
|
onDelete={handleDeleteFromDetail}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
setShowDetails(false);
|
||||||
|
}}
|
||||||
|
onEntryChanged={handleEntryChanged}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-4">
|
{/* Mobile: single-pane view switching */}
|
||||||
<div>
|
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
|
||||||
{error && (
|
{/* View 1: CalendarTree */}
|
||||||
<div
|
{activeView === 'tree' && (
|
||||||
className="mb-3 text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-calendar-tree-pane">
|
||||||
data-testid="calendar-error"
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
||||||
>
|
<h2 className="text-sm font-semibold text-secondary-900">{t('calendar.tree.title')}</h2>
|
||||||
{error}
|
<button
|
||||||
|
onClick={() => setActiveView('calendar')}
|
||||||
|
className="ml-auto inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
|
aria-label={t('calendar.title')}
|
||||||
|
data-testid="mobile-go-to-calendar"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{t('calendar.title')}</span>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<CalendarTree
|
||||||
{calendars.length === 0 && !loading ? (
|
calendars={calendars}
|
||||||
<EmptyState
|
activeCalendarId={activeCalendarId}
|
||||||
title={t('calendar.noCalendars')}
|
visibleCalendarIds={visibleCalendarIds}
|
||||||
description={t('calendar.noEntries')}
|
onSelect={handleSelectCalendar}
|
||||||
data-testid="calendar-empty"
|
onToggleVisibility={toggleCalendarVisibility}
|
||||||
/>
|
onCreate={handleCreateCalendar}
|
||||||
) : (
|
loading={loadingCalendars}
|
||||||
<MonthView
|
|
||||||
visibleMonth={visibleMonth}
|
|
||||||
entries={entries}
|
|
||||||
loading={loading}
|
|
||||||
onCreateAt={handleCreateAt}
|
|
||||||
onEditEntry={handleEditEntry}
|
|
||||||
onMoveEntry={handleMoveEntry}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="space-y-4">
|
|
||||||
<div className="bg-white border border-secondary-200 rounded-lg p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs uppercase text-secondary-500 mb-1">
|
|
||||||
{t('calendar.appointmentCalendar')}
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={activeCalendarId ?? ''}
|
|
||||||
onChange={(e) => setActiveCalendarId(e.target.value || null)}
|
|
||||||
options={[
|
|
||||||
...calendars.map((c: Calendar) => ({ value: c.id, label: c.name })),
|
|
||||||
]}
|
|
||||||
data-testid="calendar-select"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<IcsControls
|
|
||||||
calendar={activeCalendar}
|
|
||||||
onImported={() => void loadEntries()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeCalendar && (
|
{/* View 2: Calendar */}
|
||||||
<div className="bg-white border border-secondary-200 rounded-lg p-4">
|
{activeView === 'calendar' && (
|
||||||
<SharingSettings calendar={activeCalendar} />
|
<div className="flex-1 overflow-hidden bg-white flex flex-col" data-testid="mobile-calendar-view-pane">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveView('tree')}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
data-testid="mobile-back-to-tree"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">{t('calendar.tree.title')}</span>
|
||||||
|
</button>
|
||||||
|
{showDetails && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveView('details')}
|
||||||
|
className="ml-auto inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
|
aria-label={t('calendar.detail.title')}
|
||||||
|
data-testid="mobile-go-to-details"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{t('calendar.detail.title')}</span>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{renderRangeControls()}
|
||||||
</aside>
|
<div className="flex-1 overflow-hidden">{renderCalendarView()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View 3: CalendarDetail */}
|
||||||
|
{activeView === 'details' && showDetails && (
|
||||||
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-calendar-detail-pane">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveView('calendar')}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
data-testid="mobile-back-to-calendar"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">{t('calendar.title')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<CalendarDetail
|
||||||
|
entry={selectedEntry}
|
||||||
|
calendar={selectedCalendar}
|
||||||
|
onEdit={handleOpenEditModal}
|
||||||
|
onDelete={handleDeleteFromDetail}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
setActiveView('calendar');
|
||||||
|
}}
|
||||||
|
onEntryChanged={handleEntryChanged}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title={t('calendar.detail.delete')}
|
||||||
|
message={t('calendar.confirmDelete')}
|
||||||
|
confirmLabel={t('common.delete')}
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Appointment modal */}
|
||||||
<AppointmentModal
|
<AppointmentModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
@@ -251,14 +710,6 @@ export function CalendarPage() {
|
|||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
onDeleted={handleDeleted}
|
onDeleted={handleDeleted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedTask && (
|
|
||||||
<TaskDetailPanel
|
|
||||||
entry={selectedTask}
|
|
||||||
onClose={() => setSelectedTask(null)}
|
|
||||||
onChanged={handleTaskChanged}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,154 @@
|
|||||||
/**
|
/**
|
||||||
* Calendar plugin UI store (zustand).
|
* Calendar plugin UI store (zustand).
|
||||||
*
|
*
|
||||||
* Holds the currently selected month/week offset, the list of calendars the
|
* Holds view-mode state (day/week/month/range), navigation dates, the list of
|
||||||
* user owns / can write to, and the active calendar selection used by the
|
* calendars the user owns / can write to, the active calendar selection,
|
||||||
* Month view, Kanban view and ICS controls.
|
* which calendars are visible in the grid, and the currently selected entry
|
||||||
|
* for the right-hand detail panel.
|
||||||
*
|
*
|
||||||
* Server state (entries, kanban board) lives in the API hooks layer — this
|
* Server state (entries, kanban board) lives in the API hooks layer - this
|
||||||
* store only carries view/UI selection state.
|
* store only carries view/UI selection state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Calendar } from '@/api/calendar';
|
import type { Calendar, CalendarEntry } from '@/api/calendar';
|
||||||
|
|
||||||
|
export type CalendarViewMode = 'day' | 'week' | 'month' | 'range';
|
||||||
|
|
||||||
export interface CalendarState {
|
export interface CalendarState {
|
||||||
/** First day of the visible month (UTC midnight). */
|
/** First day of the visible month (local midnight). */
|
||||||
visibleMonth: Date;
|
visibleMonth: Date;
|
||||||
|
/** First day (Monday) of the visible week. */
|
||||||
|
visibleWeek: Date;
|
||||||
|
/** The visible day in day-view mode. */
|
||||||
|
visibleDay: Date;
|
||||||
|
/** Start of the date-range view (inclusive). */
|
||||||
|
rangeStart: Date | null;
|
||||||
|
/** End of the date-range view (inclusive). */
|
||||||
|
rangeEnd: Date | null;
|
||||||
|
/** Current view mode. */
|
||||||
|
viewMode: CalendarViewMode;
|
||||||
/** Selected calendar id (null = first available). */
|
/** Selected calendar id (null = first available). */
|
||||||
activeCalendarId: string | null;
|
activeCalendarId: string | null;
|
||||||
/** Cached calendar list (filled by fetchCalendars()). */
|
/** Cached calendar list (filled by fetchCalendars()). */
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
|
/** Currently selected entry for the detail panel. */
|
||||||
|
selectedEntry: CalendarEntry | null;
|
||||||
|
/** Set of calendar ids whose entries are shown in the grid (empty = all). */
|
||||||
|
visibleCalendarIds: Set<string>;
|
||||||
|
|
||||||
setVisibleMonth: (date: Date) => void;
|
setVisibleMonth: (date: Date) => void;
|
||||||
|
setVisibleWeek: (date: Date) => void;
|
||||||
|
setVisibleDay: (date: Date) => void;
|
||||||
|
setRangeStart: (date: Date | null) => void;
|
||||||
|
setRangeEnd: (date: Date | null) => void;
|
||||||
|
setViewMode: (mode: CalendarViewMode) => void;
|
||||||
goToNextMonth: () => void;
|
goToNextMonth: () => void;
|
||||||
goToPrevMonth: () => void;
|
goToPrevMonth: () => void;
|
||||||
|
goToNextWeek: () => void;
|
||||||
|
goToPrevWeek: () => void;
|
||||||
|
goToNextDay: () => void;
|
||||||
|
goToPrevDay: () => void;
|
||||||
goToToday: () => void;
|
goToToday: () => void;
|
||||||
setActiveCalendarId: (id: string | null) => void;
|
setActiveCalendarId: (id: string | null) => void;
|
||||||
setCalendars: (calendars: Calendar[]) => void;
|
setCalendars: (calendars: Calendar[]) => void;
|
||||||
|
setSelectedEntry: (entry: CalendarEntry | null) => void;
|
||||||
|
toggleCalendarVisibility: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startOfMonthUtc(d: Date): Date {
|
function startOfMonth(d: Date): Date {
|
||||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the Monday of the week containing `d`. */
|
||||||
|
function startOfWeek(d: Date): Date {
|
||||||
|
const dayOfWeek = (d.getDay() + 6) % 7; // 0 = Monday
|
||||||
|
const start = new Date(d);
|
||||||
|
start.setDate(d.getDate() - dayOfWeek);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(d: Date): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
export const useCalendarStore = create<CalendarState>((set) => ({
|
export const useCalendarStore = create<CalendarState>((set) => ({
|
||||||
visibleMonth: startOfMonthUtc(new Date()),
|
visibleMonth: startOfMonth(today),
|
||||||
|
visibleWeek: startOfWeek(today),
|
||||||
|
visibleDay: startOfDay(today),
|
||||||
|
rangeStart: startOfDay(today),
|
||||||
|
rangeEnd: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30),
|
||||||
|
viewMode: 'month',
|
||||||
activeCalendarId: null,
|
activeCalendarId: null,
|
||||||
calendars: [],
|
calendars: [],
|
||||||
|
selectedEntry: null,
|
||||||
|
visibleCalendarIds: new Set<string>(),
|
||||||
|
|
||||||
|
setVisibleMonth: (date) => set({ visibleMonth: startOfMonth(date) }),
|
||||||
|
setVisibleWeek: (date) => set({ visibleWeek: startOfWeek(date) }),
|
||||||
|
setVisibleDay: (date) => set({ visibleDay: startOfDay(date) }),
|
||||||
|
setRangeStart: (date) => set({ rangeStart: date ? startOfDay(date) : null }),
|
||||||
|
setRangeEnd: (date) => set({ rangeEnd: date ? startOfDay(date) : null }),
|
||||||
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
|
|
||||||
setVisibleMonth: (date) => set({ visibleMonth: startOfMonthUtc(date) }),
|
|
||||||
goToNextMonth: () =>
|
goToNextMonth: () =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const next = new Date(s.visibleMonth);
|
const next = new Date(s.visibleMonth);
|
||||||
next.setUTCMonth(next.getUTCMonth() + 1);
|
next.setMonth(next.getMonth() + 1);
|
||||||
return { visibleMonth: next };
|
return { visibleMonth: next };
|
||||||
}),
|
}),
|
||||||
goToPrevMonth: () =>
|
goToPrevMonth: () =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const next = new Date(s.visibleMonth);
|
const next = new Date(s.visibleMonth);
|
||||||
next.setUTCMonth(next.getUTCMonth() - 1);
|
next.setMonth(next.getMonth() - 1);
|
||||||
return { visibleMonth: next };
|
return { visibleMonth: next };
|
||||||
}),
|
}),
|
||||||
goToToday: () => set({ visibleMonth: startOfMonthUtc(new Date()) }),
|
goToNextWeek: () =>
|
||||||
|
set((s) => {
|
||||||
|
const next = new Date(s.visibleWeek);
|
||||||
|
next.setDate(next.getDate() + 7);
|
||||||
|
return { visibleWeek: next };
|
||||||
|
}),
|
||||||
|
goToPrevWeek: () =>
|
||||||
|
set((s) => {
|
||||||
|
const next = new Date(s.visibleWeek);
|
||||||
|
next.setDate(next.getDate() - 7);
|
||||||
|
return { visibleWeek: next };
|
||||||
|
}),
|
||||||
|
goToNextDay: () =>
|
||||||
|
set((s) => {
|
||||||
|
const next = new Date(s.visibleDay);
|
||||||
|
next.setDate(next.getDate() + 1);
|
||||||
|
return { visibleDay: next };
|
||||||
|
}),
|
||||||
|
goToPrevDay: () =>
|
||||||
|
set((s) => {
|
||||||
|
const next = new Date(s.visibleDay);
|
||||||
|
next.setDate(next.getDate() - 1);
|
||||||
|
return { visibleDay: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
goToToday: () =>
|
||||||
|
set({
|
||||||
|
visibleMonth: startOfMonth(new Date()),
|
||||||
|
visibleWeek: startOfWeek(new Date()),
|
||||||
|
visibleDay: startOfDay(new Date()),
|
||||||
|
}),
|
||||||
|
|
||||||
setActiveCalendarId: (id) => set({ activeCalendarId: id }),
|
setActiveCalendarId: (id) => set({ activeCalendarId: id }),
|
||||||
setCalendars: (calendars) => set({ calendars }),
|
setCalendars: (calendars) => set({ calendars }),
|
||||||
|
setSelectedEntry: (entry) => set({ selectedEntry: entry }),
|
||||||
|
toggleCalendarVisibility: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const next = new Set(s.visibleCalendarIds);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return { visibleCalendarIds: next };
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user