// frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록(드래그 이동·리사이즈) "use client"; import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; import { GRID_END_MIN, HSTART, PXH, fromMin, toMin, topOf } from "@/lib/calendar/time"; import type { CalEvent, Calendar } from "@/lib/calendar/types"; export type EvCommit = ( ev: CalEvent, patch: { date: string; start: string; end: string }, ) => void; export function EvBlock({ ev, cal, onClick, idx = 0, onCommit, }: { ev: CalEvent; cal: Calendar; onClick: (ev: CalEvent, e: ReactMouseEvent) => void; idx?: number; // 종일 일정 세로 스택 인덱스 onCommit?: EvCommit; // 드래그 이동/리사이즈 확정(없으면 드래그 비활성) }) { // 종일(시작 시각 없음) 일정은 그리드 상단에 22px 간격으로 쌓는다(NaN 위치 방지). const allDay = !ev.start; const tone = cal?.tone ?? "ink"; // 캘린더 누락 시에도 안전 const baseTop = allDay ? idx * 22 : topOf(ev.start); const baseH = allDay ? 20 : ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH; const short = allDay || baseH < 38; const canDrag = !allDay && !!onCommit; const [active, setActive] = useState(false); const [vis, setVis] = useState<{ mode: "move" | "resize"; dy: number }>({ mode: "move", dy: 0 }); const dragRef = useRef<{ mode: "move" | "resize"; startX: number; startY: number; moved: boolean } | null>(null); const suppressClick = useRef(false); useEffect(() => { if (!active) return; const onMove = (e: MouseEvent) => { const d = dragRef.current; if (!d) return; const dy = e.clientY - d.startY; if (Math.abs(dy) > 3 || Math.abs(e.clientX - d.startX) > 3) d.moved = true; setVis({ mode: d.mode, dy }); }; const onUp = (e: MouseEvent) => { const d = dragRef.current; dragRef.current = null; setActive(false); if (!d || !d.moved || !onCommit) return; suppressClick.current = true; // 드래그 직후의 click 으로 팝오버가 열리지 않도록 const dy = e.clientY - d.startY; const deltaMin = Math.round((dy / PXH) * 60 / 15) * 15; // 15분 스냅 const startMin = toMin(ev.start); const endMin = toMin(ev.end); const dur = endMin - startMin; if (d.mode === "move") { let ns = startMin + deltaMin; ns = Math.max(HSTART * 60, Math.min(ns, GRID_END_MIN - dur)); // 날짜 변경(주 뷰): 포인터 아래 컬럼의 data-date 로 이동 let date = ev.date; const under = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; const col = under?.closest("[data-date]") as HTMLElement | null; if (col?.dataset.date) date = col.dataset.date; if (ns === startMin && date === ev.date) return; onCommit(ev, { date, start: fromMin(ns), end: fromMin(ns + dur) }); } else { let ne = endMin + deltaMin; ne = Math.max(startMin + 15, Math.min(ne, GRID_END_MIN)); if (ne === endMin) return; onCommit(ev, { date: ev.date, start: ev.start, end: fromMin(ne) }); } }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; }, [active, ev, onCommit]); const beginDrag = (mode: "move" | "resize", e: ReactMouseEvent) => { if (!canDrag) return; e.stopPropagation(); e.preventDefault(); dragRef.current = { mode, startX: e.clientX, startY: e.clientY, moved: false }; setVis({ mode, dy: 0 }); setActive(true); }; let top = baseTop; let h = baseH; if (active && vis.mode === "move") top = baseTop + vis.dy; if (active && vis.mode === "resize") h = Math.max(baseH + vis.dy, 20); const handleClick = (e: ReactMouseEvent) => { e.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return; } onClick(ev, e); }; return (