655 lines
28 KiB
TypeScript
655 lines
28 KiB
TypeScript
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<CalFilters>;
|
|
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<string | null>(null);
|
|
const [filters, setFilters] = useState<CalFilters>(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 = (
|
|
<div className="flex flex-col gap-2">
|
|
{/* month nav */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
data-testid="space-cal-prev"
|
|
onClick={() => setMonth(m => shiftMonth(m, -1))}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
|
aria-label={t('calendar.prevMonth')}
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M10 4l-4 4 4 4" /></svg>
|
|
</button>
|
|
<h2 data-testid="space-cal-title" className="text-sm font-bold text-slate-800">{monthLabel}</h2>
|
|
<button
|
|
type="button"
|
|
data-testid="space-cal-next"
|
|
onClick={() => setMonth(m => shiftMonth(m, 1))}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
|
aria-label={t('calendar.nextMonth')}
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4l4 4-4 4" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 表示フィルター(タスク / 変更ファイル / 予定) */}
|
|
<div data-testid="space-cal-filters" className="flex flex-wrap items-center gap-1.5">
|
|
{FILTER_DEFS.map(f => {
|
|
const on = filters[f.key];
|
|
return (
|
|
<button
|
|
key={f.key}
|
|
type="button"
|
|
data-testid={`space-cal-filter-${f.key}`}
|
|
aria-pressed={on}
|
|
onClick={() => toggleFilter(f.key)}
|
|
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-2xs font-medium transition-colors ${
|
|
on
|
|
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)]/10 text-slate-800 dark:text-slate-100'
|
|
: 'border-hairline bg-canvas text-slate-400 line-through'
|
|
}`}
|
|
>
|
|
<span aria-hidden>{f.icon}</span>{t(f.labelKey)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* weekday header */}
|
|
<div className="grid grid-cols-7 gap-1 text-center text-2xs font-semibold text-slate-400">
|
|
{WEEKDAYS.map((w, i) => (
|
|
<div key={w} className={i === 0 ? 'text-red-400' : i === 6 ? 'text-sky-400' : ''}>{w}</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* day cells + 複数日の横棒(週ごと) */}
|
|
<div data-testid="space-cal-grid" className="flex flex-col gap-1">
|
|
{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 (
|
|
<div key={wi} className="grid grid-cols-7 gap-1">
|
|
{week.map(d => {
|
|
const inMonth = d.slice(0, 7) === month;
|
|
const c = counts[d];
|
|
const isToday = d === today;
|
|
const isSelected = d === selectedDate;
|
|
return (
|
|
<button
|
|
key={d}
|
|
type="button"
|
|
data-testid={`space-cal-day-${d}`}
|
|
onClick={() => setSelectedDate(d)}
|
|
className={`flex min-h-[2.5rem] flex-col items-stretch gap-0.5 rounded-md border p-1 text-left transition-colors ${
|
|
isSelected
|
|
? 'border-[var(--brand-primary)] bg-surface'
|
|
: 'border-hairline hover:bg-surface'
|
|
} ${inMonth ? '' : 'opacity-40'}`}
|
|
>
|
|
<span
|
|
className={`text-[11px] font-semibold ${
|
|
isToday
|
|
? 'inline-flex h-5 w-5 items-center justify-center self-start rounded-full bg-[var(--brand-primary)] text-white'
|
|
: 'text-slate-600'
|
|
}`}
|
|
>
|
|
{Number(d.slice(8, 10))}
|
|
</span>
|
|
{filters.tasks && c?.taskCount ? (
|
|
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={t('calendar.taskCount', { count: c.taskCount })}>
|
|
💬{c.taskCount}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
{bars.length > 0 && (
|
|
<div
|
|
className="col-span-7 grid grid-cols-7 gap-x-1 gap-y-0.5 pb-0.5"
|
|
style={{ gridTemplateRows: `repeat(${laneCount}, 1.05rem)` }}
|
|
>
|
|
{bars.map(b => (
|
|
<button
|
|
key={b.ev.id}
|
|
type="button"
|
|
data-testid={`space-cal-bar-${b.ev.id}`}
|
|
title={t('calendar.barTitle', { title: b.ev.title, range: fmtRange(b.ev) })}
|
|
onClick={() => setSelectedDate(week[b.colStart - 1]!)}
|
|
style={{ gridColumn: `${b.colStart} / span ${b.colSpan}`, gridRow: b.lane + 1 }}
|
|
className={`flex items-center overflow-hidden whitespace-nowrap bg-amber-100 px-1 text-[9px] font-semibold leading-none text-amber-800 transition-colors hover:bg-amber-200 dark:bg-amber-500/25 dark:text-amber-200 ${
|
|
b.continuesLeft ? 'rounded-l-none' : 'rounded-l'
|
|
} ${b.continuesRight ? 'rounded-r-none' : 'rounded-r'}`}
|
|
>
|
|
<span className="truncate">
|
|
{b.continuesLeft ? '◀ ' : b.ev.time ? `${b.ev.time} ` : ''}{b.ev.title}{b.continuesRight ? ' ▶' : ''}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
|
|
</div>
|
|
);
|
|
|
|
const panel = selectedDate ? (
|
|
<DayPanel
|
|
spaceId={spaceId}
|
|
date={selectedDate}
|
|
tzOffset={tzOffset}
|
|
month={month}
|
|
canEdit={canEdit}
|
|
filters={filters}
|
|
onOpenChat={onOpenChat}
|
|
onClose={() => setSelectedDate(null)}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
|
{t('calendar.emptyHint')}
|
|
</div>
|
|
);
|
|
|
|
// モバイル: 上に月グリッド(左右スワイプで月送り)、下に日詳細の上下 2 分割。
|
|
if (isMobile) {
|
|
return (
|
|
<div data-testid="space-calendar" className="flex h-full flex-col overflow-hidden">
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
<SwipeableTabs
|
|
tabs={[shiftMonth(month, -1), month, shiftMonth(month, 1)]}
|
|
activeTab={month}
|
|
onTabChange={(m) => setMonth(m)}
|
|
renderTab={(m) => (m === month ? <div className="p-3 overflow-y-auto h-full">{grid}</div> : <div className="h-full" />)}
|
|
/>
|
|
</div>
|
|
<div data-testid="space-cal-mobile-detail" className="h-[45%] min-h-0 shrink-0 overflow-hidden border-t border-hairline">
|
|
{panel}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// デスクトップ: 左に月グリッド、右に日詳細(split)。
|
|
return (
|
|
<div data-testid="space-calendar" className="flex h-full min-h-0 gap-3 overflow-hidden">
|
|
<div className="w-full md:w-[440px] md:shrink-0 overflow-y-auto p-3">{grid}</div>
|
|
<div className="hidden md:block flex-1 min-w-0 overflow-y-auto border-l border-hairline">{panel}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<DayPanelPreview | null>(null);
|
|
const [editing, setEditing] = useState<CalendarEvent | null>(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 (
|
|
<div data-testid="space-cal-day-panel" className="flex h-full flex-col">
|
|
<div className="flex items-center justify-between gap-2 border-b border-hairline px-3 py-2">
|
|
<h3 className="text-sm font-bold text-slate-800">{date}</h3>
|
|
<button
|
|
type="button"
|
|
data-testid="space-cal-day-close"
|
|
onClick={onClose}
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-surface hover:text-slate-700"
|
|
aria-label={t('calendar.close')}
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
|
|
{/* タスク */}
|
|
{filters.tasks && (
|
|
<section>
|
|
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.tasks')}</h4>
|
|
{day && day.tasks.length > 0 ? (
|
|
<ul className="space-y-1">
|
|
{day.tasks.map(task => (
|
|
<li key={task.id}>
|
|
<button
|
|
type="button"
|
|
data-testid={`space-cal-task-${task.id}`}
|
|
onClick={() => onOpenChat(task.id)}
|
|
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{task.title || t('calendar.taskFallback', { id: task.id })}</span>
|
|
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{task.status ?? task.state}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-xs text-slate-400">{t('calendar.day.noTasks')}</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* 変更ファイル */}
|
|
{filters.files && (
|
|
<section>
|
|
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.changedFiles')}</h4>
|
|
{day && day.files.length > 0 ? (
|
|
<ul className="space-y-1">
|
|
{day.files.map(f => (
|
|
<li key={f.path}>
|
|
<button
|
|
type="button"
|
|
data-testid="space-cal-file"
|
|
data-name={f.name}
|
|
onClick={() => void handlePreview(f.path, f.name)}
|
|
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
|
|
title={f.path}
|
|
>
|
|
<FileTypeIcon name={f.name} className="h-4 w-4 shrink-0 text-slate-400" />
|
|
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{f.name}</span>
|
|
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(f.size)}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="text-xs text-slate-400">{t('calendar.day.noChangedFiles')}</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* 予定 */}
|
|
{filters.events && (
|
|
<section>
|
|
<div className="mb-1.5 flex items-center justify-between">
|
|
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.events')}</h4>
|
|
{canEdit && !showAdd && !editing && (
|
|
<button
|
|
type="button"
|
|
data-testid="space-cal-add-event-btn"
|
|
onClick={() => setShowAdd(true)}
|
|
className="rounded-md border border-hairline bg-canvas px-2 py-0.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900"
|
|
>
|
|
{t('calendar.day.addEvent')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{(showAdd || editing) && (
|
|
<EventForm
|
|
spaceId={spaceId}
|
|
defaultDate={date}
|
|
event={editing}
|
|
onCancel={() => { setShowAdd(false); setEditing(null); }}
|
|
onSaved={() => { setShowAdd(false); setEditing(null); invalidate(); }}
|
|
/>
|
|
)}
|
|
|
|
{day && day.events.length > 0 ? (
|
|
<ul className="mt-1 space-y-1">
|
|
{day.events.map(ev => (
|
|
<li
|
|
key={ev.id}
|
|
data-testid={`space-cal-event-${ev.id}`}
|
|
className="flex items-start gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5"
|
|
>
|
|
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
|
{fmtTimeBadge(ev)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
|
|
{ev.endDate && ev.endDate > ev.date && (
|
|
<div className="text-[10px] font-medium text-amber-700 dark:text-amber-300">🗓 {fmtRange(ev)}</div>
|
|
)}
|
|
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
|
|
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">{t('calendar.day.agent')}</div>}
|
|
</div>
|
|
{canEdit && (
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
data-testid={`space-cal-event-edit-${ev.id}`}
|
|
onClick={() => { setShowAdd(false); setEditing(ev); }}
|
|
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-surface hover:text-slate-700"
|
|
aria-label={t('calendar.day.editEvent')}
|
|
>
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M11 2l3 3-8 8H3v-3z" /></svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid={`space-cal-event-delete-${ev.id}`}
|
|
onClick={async () => {
|
|
if (!window.confirm(t('calendar.day.deleteEventConfirm'))) return;
|
|
await deleteCalendarEvent(spaceId, ev.id);
|
|
invalidate();
|
|
}}
|
|
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
|
aria-label={t('common:delete')}
|
|
>
|
|
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" /></svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
!showAdd && <p className="text-xs text-slate-400">{t('calendar.day.noEvents')}</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
|
|
{preview && (
|
|
<FilePreview
|
|
name={preview.name}
|
|
content={preview.content}
|
|
imageSrc={preview.imageSrc}
|
|
markdownImageBaseUrl={preview.markdownImageBaseUrl}
|
|
trustedHtmlUrl={preview.trustedHtmlUrl}
|
|
office={preview.office}
|
|
onClose={() => setPreview(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div data-testid="space-cal-add-event" className="mb-2 space-y-2 rounded-md border border-hairline bg-surface p-2.5">
|
|
<input
|
|
type="text"
|
|
data-testid="space-cal-event-title"
|
|
value={title}
|
|
onChange={e => 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"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.start')}</label>
|
|
<input
|
|
type="date"
|
|
data-testid="space-cal-event-date"
|
|
value={date}
|
|
onChange={e => 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"
|
|
/>
|
|
<input
|
|
type="time"
|
|
data-testid="space-cal-event-time"
|
|
value={time}
|
|
onChange={e => 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"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.end')}</label>
|
|
<input
|
|
type="date"
|
|
data-testid="space-cal-event-end-date"
|
|
value={endDate}
|
|
min={date}
|
|
onChange={e => 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"
|
|
/>
|
|
<input
|
|
type="time"
|
|
data-testid="space-cal-event-end-time"
|
|
value={endTime}
|
|
disabled={!time}
|
|
title={!time ? t('calendar.form.endTimeHint') : undefined}
|
|
onChange={e => 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"
|
|
/>
|
|
</div>
|
|
{endDate && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEndDate('')}
|
|
className="self-start rounded-md border border-hairline bg-canvas px-2 py-1 text-2xs text-slate-500 hover:bg-surface"
|
|
>
|
|
{t('calendar.form.backToSingleDay')}
|
|
</button>
|
|
)}
|
|
<textarea
|
|
data-testid="space-cal-event-description"
|
|
value={description}
|
|
onChange={e => setDescription(e.target.value)}
|
|
placeholder={t('calendar.form.notePlaceholder')}
|
|
rows={2}
|
|
className="w-full resize-none 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"
|
|
/>
|
|
{error && <p className="text-2xs text-red-600">{error}</p>}
|
|
<div className="flex justify-end gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={saving}
|
|
className="rounded-md border border-hairline bg-canvas px-2.5 py-1 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
|
|
>
|
|
{t('common:cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="space-cal-event-save"
|
|
onClick={() => void submit()}
|
|
disabled={saving}
|
|
className="rounded-md bg-accent px-2.5 py-1 text-2xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{event ? t('calendar.form.update') : t('calendar.form.add')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|