Files
leocrm/frontend/src/components/calendar/CalendarDetail.tsx
T

381 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 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;