You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
227 lines
7.8 KiB
TypeScript
227 lines
7.8 KiB
TypeScript
// frontend/components/calendar/DayView.tsx — 일 뷰 (실제 날짜 기반: 그리드 + 집중 카드 + 어젠다)
|
|
"use client";
|
|
import type { MouseEvent } from "react";
|
|
import { Icon } from "@/components/Icon";
|
|
import { HOURS, dayNum, monthNum, parseISO, timeOfPx, toMin, topOf } from "@/lib/calendar/time";
|
|
import type { CalEvent, Calendar, FocusBlock as FocusBlockT } from "@/lib/calendar/types";
|
|
import { EvBlock, type EvCommit } from "./EvBlock";
|
|
import { FocusBlock } from "./FocusBlock";
|
|
|
|
const PEOPLE_COLORS = ["var(--coral)", "var(--blue)", "var(--violet)"];
|
|
const WEEKDAYS_KO = ["일", "월", "화", "수", "목", "금", "토"];
|
|
|
|
function evSort(a: CalEvent, b: CalEvent): number {
|
|
// 종일 먼저, 그다음 시작시각 순
|
|
if (!a.start && b.start) return -1;
|
|
if (a.start && !b.start) return 1;
|
|
if (!a.start && !b.start) return 0;
|
|
return toMin(a.start) - toMin(b.start);
|
|
}
|
|
|
|
export function DayView({
|
|
events,
|
|
focusBlocks,
|
|
calMap,
|
|
dayDate,
|
|
todayDate,
|
|
onOpen,
|
|
onMeet,
|
|
onSlotClick,
|
|
onCommit,
|
|
}: {
|
|
events: CalEvent[];
|
|
focusBlocks: FocusBlockT[];
|
|
calMap: Record<string, Calendar>;
|
|
dayDate: string;
|
|
todayDate: string;
|
|
onOpen: (ev: CalEvent, e: MouseEvent) => void;
|
|
onMeet: (ev: CalEvent) => void;
|
|
onSlotClick: (dateIso: string, time: string) => void;
|
|
onCommit?: EvCommit;
|
|
}) {
|
|
const dayEvents = events.filter((e) => e.date === dayDate).sort(evSort);
|
|
const dayFocus = focusBlocks
|
|
.filter((b) => b.date === dayDate)
|
|
.sort((a, b) => toMin(a.start) - toMin(b.start));
|
|
const wd = WEEKDAYS_KO[parseISO(dayDate).getDay()];
|
|
const isToday = dayDate === todayDate;
|
|
const now = new Date();
|
|
const nowTop = topOf(
|
|
`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`,
|
|
);
|
|
|
|
return (
|
|
<div className="day-wrap">
|
|
<div className="cpanel">
|
|
<div className="day-grid" role="region" aria-label="일간 시간표" tabIndex={0}>
|
|
<div className="wk-times">
|
|
{HOURS.map((h) => (
|
|
<div key={h} className="wk-thour">
|
|
<span>{h}:00</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div
|
|
className="day-col"
|
|
data-date={dayDate}
|
|
onClick={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
onSlotClick(dayDate, timeOfPx(e.clientY - rect.top));
|
|
}}
|
|
>
|
|
{HOURS.map((h) => (
|
|
<div key={h} className="wk-hr" />
|
|
))}
|
|
{isToday && <div className="wk-now" style={{ top: nowTop + "px" }} />}
|
|
{dayFocus.map((b) => (
|
|
<FocusBlock key={b.id} b={b} />
|
|
))}
|
|
{dayEvents
|
|
.filter((e) => !e.start)
|
|
.map((ev, ai) => (
|
|
<EvBlock key={ev.id} ev={ev} cal={calMap[ev.cal]} onClick={onOpen} idx={ai} />
|
|
))}
|
|
{dayEvents
|
|
.filter((e) => e.start)
|
|
.map((ev) => (
|
|
<EvBlock
|
|
key={ev.id}
|
|
ev={ev}
|
|
cal={calMap[ev.cal]}
|
|
onClick={onOpen}
|
|
onCommit={onCommit}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="day-side">
|
|
{dayFocus.length > 0 && (
|
|
<div className="cpanel focus-card">
|
|
<div className="fc-head">
|
|
<div className="fc-ic">
|
|
<Icon name="spark" />
|
|
</div>
|
|
<div className="fc-h">
|
|
<b>오늘의 집중 모드</b>
|
|
<span>빈 시간에 맞춰 자동 배치했어요</span>
|
|
</div>
|
|
</div>
|
|
<p className="fc-note">
|
|
오전 미팅 사이엔 가벼운 일을, 오후 빈 블록엔 딥 워크를 넣었어요.
|
|
</p>
|
|
{dayFocus.map((b) => (
|
|
<div className={"fc-item " + b.type} key={b.id}>
|
|
<div className="fci-time">{b.start}</div>
|
|
<div className="fci-body">
|
|
<div className="fci-top">
|
|
<span className={"fci-tag " + b.type}>{b.tag}</span>
|
|
</div>
|
|
<div className="fci-title">{b.title}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="cpanel agenda">
|
|
<div className="ag-head">
|
|
<div className="ag-title">
|
|
{monthNum(dayDate)}월 {dayNum(dayDate)}일 {wd}요일
|
|
</div>
|
|
<div className="ag-sub">
|
|
일정 {dayEvents.length}개{isToday ? " · 오늘" : ""}
|
|
</div>
|
|
</div>
|
|
{dayEvents.length === 0 && <div className="ag-empty">이 날은 일정이 없어요.</div>}
|
|
{dayEvents.map((ev) => (
|
|
<div
|
|
key={ev.id}
|
|
className={"ag-item tint-" + (calMap[ev.cal]?.tone ?? "ink")}
|
|
onClick={(e) => onOpen(ev, e)}
|
|
>
|
|
<div className="ag-time">
|
|
{ev.start ? (
|
|
<>
|
|
{ev.start}
|
|
<small>{ev.end}</small>
|
|
</>
|
|
) : (
|
|
"종일"
|
|
)}
|
|
</div>
|
|
<div className="ag-body">
|
|
<div className="ag-t">
|
|
{ev.title} {ev.soon && <span className="ag-soon">곧</span>}
|
|
</div>
|
|
<div className="ag-m">
|
|
<span className="epop-cat">
|
|
<span
|
|
className="pdot"
|
|
style={{
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: "50%",
|
|
background: "var(--evc)",
|
|
display: "inline-block",
|
|
}}
|
|
/>{" "}
|
|
{calMap[ev.cal]?.name ?? "기타"}
|
|
</span>
|
|
{ev.loc && (
|
|
<>
|
|
<span>·</span>
|
|
<span>
|
|
<Icon name="pin" /> {ev.loc}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{ev.people.length > 0 && (
|
|
<div className="ag-people">
|
|
{ev.people.slice(0, 3).map((p, i) => (
|
|
<div className="pp" key={i} style={{ background: PEOPLE_COLORS[i] }}>
|
|
{p[0]}
|
|
</div>
|
|
))}
|
|
<span className="pl">{ev.people.join(", ")}</span>
|
|
</div>
|
|
)}
|
|
{ev.note && <div className="ag-note">{ev.note}</div>}
|
|
{ev.actions.length > 0 && (
|
|
<div className="ag-actions">
|
|
<div className="aga-head">
|
|
<Icon name="spark" />
|
|
내가 맡기로 한 일
|
|
</div>
|
|
{ev.actions.map((a, i) => (
|
|
<div className="aga-row" key={i}>
|
|
<span className="aga-check">
|
|
<Icon name="tick" w={3} />
|
|
</span>
|
|
<span className="aga-text">{a.text}</span>
|
|
<span className="aga-who">{a.when}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{ev.has_meeting && (
|
|
<button
|
|
className="meet-open"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onMeet(ev);
|
|
}}
|
|
>
|
|
<Icon name="spark" />
|
|
{ev.meet_label}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|