381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
/**
|
|
* 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';
|
|
import { Calendar as CalendarIcon, Pencil, Trash2, X } from 'lucide-react';
|
|
|
|
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"
|
|
>
|
|
<CalendarIcon className="w-12 h-12 text-secondary-300 mb-3" aria-hidden="true" strokeWidth={1.5} />
|
|
<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"
|
|
>
|
|
<X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
|
</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"
|
|
>
|
|
<Pencil className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
|
{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"
|
|
>
|
|
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
|
{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;
|