This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceCalendarMonth,
|
||||
@@ -8,60 +9,40 @@ import {
|
||||
deleteCalendarEvent,
|
||||
fetchSpaceFileContent,
|
||||
getSpaceFileRawUrl,
|
||||
getSpaceFileOfficePreviewUrl,
|
||||
getSpaceTrustedHtmlUrl,
|
||||
type CalendarEvent,
|
||||
} from '../../api';
|
||||
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
|
||||
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';
|
||||
|
||||
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
|
||||
|
||||
/** 'YYYY-MM' of the viewer's local today. */
|
||||
function localMonth(): string {
|
||||
return localToday().slice(0, 7);
|
||||
}
|
||||
/** month ± delta, as 'YYYY-MM' (calendar arithmetic on the 1st via UTC). */
|
||||
function shiftMonth(month: string, delta: number): string {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
/** All 'YYYY-MM-DD' cells for the month grid, padded to full weeks (Sun-start). */
|
||||
function monthGridDays(month: string): string[] {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const first = new Date(Date.UTC(y, m - 1, 1));
|
||||
const start = shiftDay(first.toISOString().slice(0, 10), -first.getUTCDay());
|
||||
const days: string[] = [];
|
||||
// 6 weeks always covers any month layout (max 31 days + 6 lead).
|
||||
for (let i = 0; i < 42; i++) days.push(shiftDay(start, i));
|
||||
return days;
|
||||
}
|
||||
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`;
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 'M/D'(月グリッドのバー・期間表示用)。 */
|
||||
function fmtMonthDay(d: string): string {
|
||||
return `${Number(d.slice(5, 7))}/${Number(d.slice(8, 10))}`;
|
||||
}
|
||||
/** 予定の期間を短く: 単日は 'M/D'、複数日は 'M/D–M/D'。 */
|
||||
function fmtRange(ev: CalendarEvent): string {
|
||||
const end = ev.endDate && ev.endDate > ev.date ? ev.endDate : null;
|
||||
return end ? `${fmtMonthDay(ev.date)}–${fmtMonthDay(end)}` : fmtMonthDay(ev.date);
|
||||
}
|
||||
|
||||
// ── カレンダーの表示フィルター(タスク / 変更ファイル / 予定)─────────────
|
||||
type CalFilters = { tasks: boolean; files: boolean; events: boolean };
|
||||
const FILTER_DEFS: ReadonlyArray<{ key: keyof CalFilters; label: string; icon: string }> = [
|
||||
{ key: 'tasks', label: 'タスク', icon: '💬' },
|
||||
{ key: 'files', label: '変更ファイル', icon: '📄' },
|
||||
{ key: 'events', label: '予定', icon: '📌' },
|
||||
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 {
|
||||
@@ -75,49 +56,6 @@ function loadFilters(): CalFilters {
|
||||
return { tasks: true, files: true, events: true };
|
||||
}
|
||||
|
||||
/** 1 週(7 日)にかかるイベントを、横棒の lane(重ならない行)に割り付ける。 */
|
||||
interface WeekBar {
|
||||
ev: CalendarEvent;
|
||||
colStart: number; // 1–7
|
||||
colSpan: number;
|
||||
lane: number; // 0-based の積み上げ行
|
||||
continuesLeft: boolean;
|
||||
continuesRight: boolean;
|
||||
}
|
||||
function layoutWeekBars(weekDays: string[], events: CalendarEvent[]): WeekBar[] {
|
||||
const weekStart = weekDays[0]!;
|
||||
const weekEnd = weekDays[6]!;
|
||||
const segs = events
|
||||
.map((ev) => {
|
||||
const evEnd = ev.endDate && ev.endDate > ev.date ? ev.endDate : ev.date;
|
||||
if (evEnd < weekStart || ev.date > weekEnd) return null;
|
||||
const segStart = ev.date < weekStart ? weekStart : ev.date;
|
||||
const segEnd = evEnd > weekEnd ? weekEnd : evEnd;
|
||||
const colStart = weekDays.indexOf(segStart) + 1;
|
||||
const colEnd = weekDays.indexOf(segEnd) + 1;
|
||||
return {
|
||||
ev,
|
||||
colStart,
|
||||
colSpan: colEnd - colStart + 1,
|
||||
continuesLeft: ev.date < weekStart,
|
||||
continuesRight: evEnd > weekEnd,
|
||||
};
|
||||
})
|
||||
.filter((s): s is Omit<WeekBar, 'lane'> => s !== null)
|
||||
// 長い棒・早い開始を優先して上の lane に積む
|
||||
.sort((a, b) => b.colSpan - a.colSpan || a.colStart - b.colStart || a.ev.id - b.ev.id);
|
||||
|
||||
const laneEnds: number[] = []; // lane ごとの「最後に埋まった列」
|
||||
const bars: WeekBar[] = [];
|
||||
for (const s of segs) {
|
||||
let lane = laneEnds.findIndex((end) => end < s.colStart);
|
||||
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0); }
|
||||
laneEnds[lane] = s.colStart + s.colSpan - 1;
|
||||
bars.push({ ...s, lane });
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
interface SpaceCalendarProps {
|
||||
spaceId: string;
|
||||
/** open the chat for a task created in this space (switches to the chat tab). */
|
||||
@@ -127,6 +65,7 @@ interface SpaceCalendarProps {
|
||||
}
|
||||
|
||||
export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const tzOffset = localTzOffset();
|
||||
const isMobile = useIsMobile();
|
||||
const [month, setMonth] = useState(localMonth());
|
||||
@@ -148,16 +87,12 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
|
||||
const today = localToday();
|
||||
const days = useMemo(() => monthGridDays(month), [month]);
|
||||
const weeks = useMemo(() => {
|
||||
const out: string[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
|
||||
return out;
|
||||
}, [days]);
|
||||
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 `${y}年${Number(m)}月`;
|
||||
return t('calendar.monthLabel', { year: y, month: Number(m) });
|
||||
})();
|
||||
|
||||
const grid = (
|
||||
@@ -169,7 +104,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
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="前の月"
|
||||
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>
|
||||
@@ -179,7 +114,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
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="次の月"
|
||||
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>
|
||||
@@ -202,7 +137,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
: 'border-hairline bg-canvas text-slate-400 line-through'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{f.icon}</span>{f.label}
|
||||
<span aria-hidden>{f.icon}</span>{t(f.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -249,7 +184,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
{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={`タスク ${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}
|
||||
@@ -266,7 +201,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
key={b.ev.id}
|
||||
type="button"
|
||||
data-testid={`space-cal-bar-${b.ev.id}`}
|
||||
title={`${b.ev.title}(${fmtRange(b.ev)})`}
|
||||
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 ${
|
||||
@@ -284,7 +219,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">カレンダーの取得に失敗しました。</p>}
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -301,7 +236,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
||||
日付を選ぶと、その日のタスク・変更ファイル・予定が表示されます。
|
||||
{t('calendar.emptyHint')}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -339,6 +274,7 @@ interface DayPanelPreview {
|
||||
imageSrc: string;
|
||||
markdownImageBaseUrl?: string;
|
||||
trustedHtmlUrl?: string;
|
||||
office?: OfficePreviewDescriptor;
|
||||
}
|
||||
|
||||
function DayPanel({
|
||||
@@ -360,6 +296,7 @@ function DayPanel({
|
||||
onOpenChat: (taskId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const dayQuery = useQuery({
|
||||
queryKey: ['spaceCalendarDay', spaceId, date, tzOffset],
|
||||
@@ -376,6 +313,16 @@ function DayPanel({
|
||||
|
||||
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;
|
||||
@@ -405,7 +352,7 @@ function DayPanel({
|
||||
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="閉じる"
|
||||
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>
|
||||
@@ -415,25 +362,25 @@ function DayPanel({
|
||||
{/* タスク */}
|
||||
{filters.tasks && (
|
||||
<section>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">タスク</h4>
|
||||
<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(t => (
|
||||
<li key={t.id}>
|
||||
{day.tasks.map(task => (
|
||||
<li key={task.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-cal-task-${t.id}`}
|
||||
onClick={() => onOpenChat(t.id)}
|
||||
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">{t.title || `タスク #${t.id}`}</span>
|
||||
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{t.status ?? t.state}</span>
|
||||
<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">この日のタスクはありません。</p>
|
||||
<p className="text-xs text-slate-400">{t('calendar.day.noTasks')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -441,7 +388,7 @@ function DayPanel({
|
||||
{/* 変更ファイル */}
|
||||
{filters.files && (
|
||||
<section>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">変更ファイル</h4>
|
||||
<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 => (
|
||||
@@ -462,7 +409,7 @@ function DayPanel({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">この日に変更されたファイルはありません。</p>
|
||||
<p className="text-xs text-slate-400">{t('calendar.day.noChangedFiles')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -471,7 +418,7 @@ function DayPanel({
|
||||
{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">予定</h4>
|
||||
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.events')}</h4>
|
||||
{canEdit && !showAdd && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -479,7 +426,7 @@ function DayPanel({
|
||||
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>
|
||||
@@ -503,7 +450,7 @@ function DayPanel({
|
||||
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">
|
||||
{ev.time ?? '終日'}
|
||||
{fmtTimeBadge(ev)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
|
||||
@@ -511,7 +458,7 @@ function DayPanel({
|
||||
<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">🤖 エージェント</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">
|
||||
@@ -520,7 +467,7 @@ function DayPanel({
|
||||
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="編集"
|
||||
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>
|
||||
@@ -528,12 +475,12 @@ function DayPanel({
|
||||
type="button"
|
||||
data-testid={`space-cal-event-delete-${ev.id}`}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('この予定を削除しますか?')) return;
|
||||
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="削除"
|
||||
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>
|
||||
@@ -543,7 +490,7 @@ function DayPanel({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
!showAdd && <p className="text-xs text-slate-400">予定はありません。</p>
|
||||
!showAdd && <p className="text-xs text-slate-400">{t('calendar.day.noEvents')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -556,6 +503,7 @@ function DayPanel({
|
||||
imageSrc={preview.imageSrc}
|
||||
markdownImageBaseUrl={preview.markdownImageBaseUrl}
|
||||
trustedHtmlUrl={preview.trustedHtmlUrl}
|
||||
office={preview.office}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -576,17 +524,25 @@ function EventForm({
|
||||
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('タイトルを入力してください。'); return; }
|
||||
if (endDate && endDate < date) { setError('終了日は開始日以降にしてください。'); return; }
|
||||
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 {
|
||||
@@ -595,6 +551,7 @@ function EventForm({
|
||||
// 終了日が開始日より後のときだけ複数日。それ以外は単日(null)に正規化。
|
||||
endDate: endDate && endDate > date ? endDate : null,
|
||||
time: time ? time : null,
|
||||
endTime: effEndTime,
|
||||
title: title.trim(),
|
||||
description: description ? description : null,
|
||||
};
|
||||
@@ -602,11 +559,11 @@ function EventForm({
|
||||
else await createCalendarEvent(spaceId, payload);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? '保存に失敗しました。');
|
||||
setError((e as Error)?.message ?? t('calendar.form.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [title, date, endDate, time, description, event, spaceId, onSaved]);
|
||||
}, [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">
|
||||
@@ -615,11 +572,11 @@ function EventForm({
|
||||
data-testid="space-cal-event-title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="予定のタイトル"
|
||||
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">開始</label>
|
||||
<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"
|
||||
@@ -636,7 +593,7 @@ function EventForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">終了</label>
|
||||
<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"
|
||||
@@ -645,21 +602,30 @@ function EventForm({
|
||||
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"
|
||||
/>
|
||||
{endDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEndDate('')}
|
||||
className="shrink-0 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-2xs text-slate-500 hover:bg-surface"
|
||||
>
|
||||
単日に戻す
|
||||
</button>
|
||||
)}
|
||||
<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="メモ(任意)"
|
||||
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"
|
||||
/>
|
||||
@@ -671,7 +637,7 @@ function EventForm({
|
||||
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"
|
||||
@@ -680,7 +646,7 @@ function EventForm({
|
||||
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 ? '更新' : '追加'}
|
||||
{event ? t('calendar.form.update') : t('calendar.form.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user