import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchSpaceCalendarMonth, fetchSpaceCalendarDay, createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, fetchSpaceFileContent, getSpaceFileRawUrl, getSpaceFileOfficePreviewUrl, getSpaceTrustedHtmlUrl, type CalendarEvent, } from '../../api'; import { localToday, localTzOffset } from '../../lib/localDate'; import { WEEKDAYS, localMonth, shiftMonth, monthGridDays, splitWeeks, fmtRange, fmtTimeBadge, layoutWeekBars, } from '../../lib/calendar'; import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils'; import { useIsMobile } from '../../hooks/useIsMobile'; import { FilePreview } from '../files/FilePreview'; import type { OfficePreviewDescriptor } from '../files/FilePreview'; import { FileTypeIcon } from '../files/FileTypeIcon'; import { SwipeableTabs } from '../mobile/SwipeableTabs'; function fmtSize(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}MB`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}KB`; return `${n}B`; } // ── カレンダーの表示フィルター(タスク / 変更ファイル / 予定)───────────── type CalFilters = { tasks: boolean; files: boolean; events: boolean }; const FILTER_DEFS: ReadonlyArray<{ key: keyof CalFilters; labelKey: string; icon: string }> = [ { key: 'tasks', labelKey: 'calendar.filter.tasks', icon: '💬' }, { key: 'files', labelKey: 'calendar.filter.files', icon: '📄' }, { key: 'events', labelKey: 'calendar.filter.events', icon: '📌' }, ]; const FILTERS_STORAGE_KEY = 'spaceCalendarFilters'; function loadFilters(): CalFilters { try { const raw = localStorage.getItem(FILTERS_STORAGE_KEY); if (raw) { const p = JSON.parse(raw) as Partial; return { tasks: p.tasks !== false, files: p.files !== false, events: p.events !== false }; } } catch { /* ignore */ } return { tasks: true, files: true, events: true }; } interface SpaceCalendarProps { spaceId: string; /** open the chat for a task created in this space (switches to the chat tab). */ onOpenChat: (taskId: number) => void; /** owner/admin can add/edit/delete events; viewers see read-only. */ canEdit: boolean; } export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarProps) { const { t } = useTranslation('spaces'); const tzOffset = localTzOffset(); const isMobile = useIsMobile(); const [month, setMonth] = useState(localMonth()); const [selectedDate, setSelectedDate] = useState(null); const [filters, setFilters] = useState(loadFilters); const toggleFilter = useCallback((key: keyof CalFilters) => { setFilters((prev) => { const next = { ...prev, [key]: !prev[key] }; try { localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(next)); } catch { /* ignore */ } return next; }); }, []); const monthQuery = useQuery({ queryKey: ['spaceCalendarMonth', spaceId, month, tzOffset], queryFn: () => fetchSpaceCalendarMonth(spaceId, month, tzOffset), }); const today = localToday(); const days = useMemo(() => monthGridDays(month), [month]); const weeks = useMemo(() => splitWeeks(days), [days]); const counts = monthQuery.data?.days ?? {}; const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]); const monthLabel = (() => { const [y, m] = month.split('-'); return t('calendar.monthLabel', { year: y, month: Number(m) }); })(); const grid = (
{/* month nav */}

{monthLabel}

{/* 表示フィルター(タスク / 変更ファイル / 予定) */}
{FILTER_DEFS.map(f => { const on = filters[f.key]; return ( ); })}
{/* weekday header */}
{WEEKDAYS.map((w, i) => (
{w}
))}
{/* day cells + 複数日の横棒(週ごと) */}
{weeks.map((week, wi) => { const bars = filters.events ? layoutWeekBars(week, events) : []; const laneCount = bars.reduce((m, b) => Math.max(m, b.lane + 1), 0); return (
{week.map(d => { const inMonth = d.slice(0, 7) === month; const c = counts[d]; const isToday = d === today; const isSelected = d === selectedDate; return ( ); })} {bars.length > 0 && (
{bars.map(b => ( ))}
)}
); })}
{monthQuery.isError &&

{t('calendar.fetchError')}

}
); const panel = selectedDate ? ( setSelectedDate(null)} /> ) : (
{t('calendar.emptyHint')}
); // モバイル: 上に月グリッド(左右スワイプで月送り)、下に日詳細の上下 2 分割。 if (isMobile) { return (
setMonth(m)} renderTab={(m) => (m === month ?
{grid}
:
)} />
{panel}
); } // デスクトップ: 左に月グリッド、右に日詳細(split)。 return (
{grid}
{panel}
); } interface DayPanelPreview { name: string; content: string; imageSrc: string; markdownImageBaseUrl?: string; trustedHtmlUrl?: string; office?: OfficePreviewDescriptor; } function DayPanel({ spaceId, date, tzOffset, month, canEdit, filters, onOpenChat, onClose, }: { spaceId: string; date: string; tzOffset: number; month: string; canEdit: boolean; filters: CalFilters; onOpenChat: (taskId: number) => void; onClose: () => void; }) { const { t } = useTranslation('spaces'); const qc = useQueryClient(); const dayQuery = useQuery({ queryKey: ['spaceCalendarDay', spaceId, date, tzOffset], queryFn: () => fetchSpaceCalendarDay(spaceId, date, tzOffset), }); const [preview, setPreview] = useState(null); const [editing, setEditing] = useState(null); const [showAdd, setShowAdd] = useState(false); const invalidate = useCallback(() => { qc.invalidateQueries({ queryKey: ['spaceCalendarDay', spaceId, date, tzOffset] }); qc.invalidateQueries({ queryKey: ['spaceCalendarMonth', spaceId, month, tzOffset] }); }, [qc, spaceId, date, tzOffset, month]); const handlePreview = useCallback(async (filePath: string, name: string) => { try { if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) { const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation'; setPreview({ name, content: '', imageSrc: '', office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) }, }); return; } if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) { const imageSrc = getSpaceFileRawUrl(spaceId, filePath); const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined; setPreview({ name, content: '', imageSrc, trustedHtmlUrl }); return; } const content = await fetchSpaceFileContent(spaceId, filePath); let markdownImageBaseUrl: string | undefined; if (/\.(md|markdown)$/i.test(name)) { const dir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/') + 1) : ''; markdownImageBaseUrl = `/api/local/spaces/${spaceId}/files/raw?path=${dir}`; } setPreview({ name, content, imageSrc: '', markdownImageBaseUrl }); } catch { /* preview failure is non-fatal; leave the list intact. */ } }, [spaceId]); const day = dayQuery.data; return (

{date}

{/* タスク */} {filters.tasks && (

{t('calendar.day.tasks')}

{day && day.tasks.length > 0 ? (
    {day.tasks.map(task => (
  • ))}
) : (

{t('calendar.day.noTasks')}

)}
)} {/* 変更ファイル */} {filters.files && (

{t('calendar.day.changedFiles')}

{day && day.files.length > 0 ? (
    {day.files.map(f => (
  • ))}
) : (

{t('calendar.day.noChangedFiles')}

)}
)} {/* 予定 */} {filters.events && (

{t('calendar.day.events')}

{canEdit && !showAdd && !editing && ( )}
{(showAdd || editing) && ( { setShowAdd(false); setEditing(null); }} onSaved={() => { setShowAdd(false); setEditing(null); invalidate(); }} /> )} {day && day.events.length > 0 ? (
    {day.events.map(ev => (
  • {fmtTimeBadge(ev)}
    {ev.title}
    {ev.endDate && ev.endDate > ev.date && (
    🗓 {fmtRange(ev)}
    )} {ev.description &&
    {ev.description}
    } {ev.createdBy === 'agent' &&
    {t('calendar.day.agent')}
    }
    {canEdit && (
    )}
  • ))}
) : ( !showAdd &&

{t('calendar.day.noEvents')}

)}
)}
{preview && ( setPreview(null)} /> )}
); } function EventForm({ spaceId, defaultDate, event, onCancel, onSaved, }: { spaceId: string; defaultDate: string; event: CalendarEvent | null; onCancel: () => void; onSaved: () => void; }) { const { t } = useTranslation('spaces'); const [title, setTitle] = useState(event?.title ?? ''); const [date, setDate] = useState(event?.date ?? defaultDate); const [endDate, setEndDate] = useState(event?.endDate ?? ''); const [time, setTime] = useState(event?.time ?? ''); const [endTime, setEndTime] = useState(event?.endTime ?? ''); const [description, setDescription] = useState(event?.description ?? ''); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const submit = useCallback(async () => { if (!title.trim()) { setError(t('calendar.form.titleRequired')); return; } if (endDate && endDate < date) { setError(t('calendar.form.endAfterStart')); return; } // 終了時刻は開始時刻があるときだけ。単日は開始以降のみ(複数日は終了日側の時刻なので順序不問)。 const effEndTime = time && endTime ? endTime : null; const isMultiDay = !!(endDate && endDate > date); if (effEndTime && !isMultiDay && effEndTime < time) { setError(t('calendar.form.endTimeAfterStart')); return; } setSaving(true); setError(''); try { const payload = { date, // 終了日が開始日より後のときだけ複数日。それ以外は単日(null)に正規化。 endDate: endDate && endDate > date ? endDate : null, time: time ? time : null, endTime: effEndTime, title: title.trim(), description: description ? description : null, }; if (event) await updateCalendarEvent(spaceId, event.id, payload); else await createCalendarEvent(spaceId, payload); onSaved(); } catch (e) { setError((e as Error)?.message ?? t('calendar.form.saveFailed')); } finally { setSaving(false); } }, [title, date, endDate, time, endTime, description, event, spaceId, onSaved]); return (
setTitle(e.target.value)} placeholder={t('calendar.form.titlePlaceholder')} className="w-full rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring" />
setDate(e.target.value)} className="flex-1 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring" /> setTime(e.target.value)} className="w-28 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring" />
setEndDate(e.target.value)} className="flex-1 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring" /> setEndTime(e.target.value)} className="w-28 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:cursor-not-allowed disabled:opacity-50" />
{endDate && ( )}