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

232 lines
7.2 KiB
TypeScript

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