feat(calendar): 구성원별 체크박스 필터 + 다른 구성원 일정 보기(읽기 전용)
All checks were successful
build-and-push / build (push) Successful in 31s

- 일정 소유자별 체크박스로 표시/숨김(기본 전체), 칩에 타인 일정은 이름 이니셜 표기
- 남의 일정 클릭 시 읽기 전용 모달(수정/삭제 불가), 내 일정만 편집

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 16:15:17 +09:00
parent 09713f5e23
commit b2f4a397e3

View File

@ -1,7 +1,9 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react"; import { Plus, Trash2, ChevronLeft, ChevronRight, Users } from "lucide-react";
import { getEvents, createEvent, updateEvent, deleteEvent, getProjects } from "@/lib/api"; import { getEvents, createEvent, updateEvent, deleteEvent, getProjects } from "@/lib/api";
import { useAuth } from "@/context/Auth";
import { useDirectory } from "@/lib/directory";
import { import {
Card, Button, PageHeader, Modal, Field, Input, Select, Textarea, LoadingState, Card, Button, PageHeader, Modal, Field, Input, Select, Textarea, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
@ -20,14 +22,32 @@ function eventColor(e: { category: string; projectId: string }): string {
} }
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
// 전체 캘린더 — 개인 일정을 분류(프로젝트/기타/개인)별 색으로 관리. // 전체 캘린더 — 직접 넣는 일정만. 다른 구성원 일정도 보이고 구성원별 체크박스로 필터.
export function CalendarPage() { export function CalendarPage() {
const now = new Date(); const now = new Date();
const { me } = useAuth();
const myEmail = me?.user.email ?? "";
const { nameOf } = useDirectory();
const [ym, setYm] = useState({ y: now.getFullYear(), m: now.getMonth() }); const [ym, setYm] = useState({ y: now.getFullYear(), m: now.getMonth() });
const [editing, setEditing] = useState<Partial<CalendarEvent> | null>(null); const [editing, setEditing] = useState<Partial<CalendarEvent> | null>(null);
const [selected, setSelected] = useState<Set<string> | null>(null); // null = 전체 표시
const evQ = useQuery({ queryKey: ["events"], queryFn: getEvents }); const evQ = useQuery({ queryKey: ["events"], queryFn: getEvents });
const events = evQ.data ?? []; const events = evQ.data ?? [];
// 일정 소유자 목록(나 우선)
const owners = useMemo(() => {
const s = new Set(events.map((e) => e.ownerEmail));
if (myEmail) s.add(myEmail);
return Array.from(s).sort((a, b) => (a === myEmail ? -1 : b === myEmail ? 1 : nameOf(a).localeCompare(nameOf(b))));
}, [events, myEmail, nameOf]);
const isOn = (email: string) => selected === null || selected.has(email);
const toggleOwner = (email: string) =>
setSelected((prev) => {
const next = new Set(prev ?? owners);
if (next.has(email)) next.delete(email); else next.add(email);
return next;
});
const first = new Date(ym.y, ym.m, 1); const first = new Date(ym.y, ym.m, 1);
const startDow = first.getDay(); const startDow = first.getDay();
const daysInMonth = new Date(ym.y, ym.m + 1, 0).getDate(); const daysInMonth = new Date(ym.y, ym.m + 1, 0).getDate();
@ -36,8 +56,8 @@ export function CalendarPage() {
const eventsOn = useMemo(() => { const eventsOn = useMemo(() => {
const map = new Map<string, CalendarEvent[]>(); const map = new Map<string, CalendarEvent[]>();
for (const e of events) { for (const e of events) {
if (!(selected === null || selected.has(e.ownerEmail))) continue;
const s = e.start, end = e.end || e.start; const s = e.start, end = e.end || e.start;
// start..end 사이 모든 날짜에 배치
let d = new Date(s); let d = new Date(s);
const last = new Date(end); const last = new Date(end);
if (Number.isNaN(d.getTime())) continue; if (Number.isNaN(d.getTime())) continue;
@ -48,16 +68,33 @@ export function CalendarPage() {
} }
} }
return map; return map;
}, [events]); }, [events, selected]);
const prevMonth = () => setYm((s) => ({ y: s.m === 0 ? s.y - 1 : s.y, m: s.m === 0 ? 11 : s.m - 1 })); const prevMonth = () => setYm((s) => ({ y: s.m === 0 ? s.y - 1 : s.y, m: s.m === 0 ? 11 : s.m - 1 }));
const nextMonth = () => setYm((s) => ({ y: s.m === 11 ? s.y + 1 : s.y, m: s.m === 11 ? 0 : s.m + 1 })); const nextMonth = () => setYm((s) => ({ y: s.m === 11 ? s.y + 1 : s.y, m: s.m === 11 ? 0 : s.m + 1 }));
return ( return (
<div> <div>
<PageHeader title="캘린더" description="내 일정을 분류(프로젝트·기타·개인)별 색으로 관리합니다." <PageHeader title="캘린더" description="내 일정을 분류(프로젝트·기타·개인)별 색으로 관리하고, 구성원 일정도 함께 확인합니다."
action={<Button icon={<Plus size={16} />} onClick={() => setEditing({ category: "etc", start: ymd(now) })}> </Button>} /> action={<Button icon={<Plus size={16} />} onClick={() => setEditing({ category: "etc", start: ymd(now) })}> </Button>} />
{/* 구성원 필터 (체크박스) */}
{owners.length > 1 && (
<Card className="mb-3 p-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-ink-secondary mr-1"><Users size={14} /> </span>
{owners.map((email) => (
<label key={email} className={classNames("inline-flex items-center gap-1.5 text-sm cursor-pointer px-2 py-1 rounded-control border transition-colors",
isOn(email) ? "border-navy bg-navy-subtle/40 text-ink" : "border-border text-ink-muted")}>
<input type="checkbox" checked={isOn(email)} onChange={() => toggleOwner(email)} className="accent-navy" />
<span className="w-2.5 h-2.5 rounded-full" style={{ background: projectColor(email) }} />
{nameOf(email)}{email === myEmail ? " (나)" : ""}
</label>
))}
</div>
</Card>
)}
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<button className="p-1.5 rounded-control hover:bg-canvas text-ink-secondary" onClick={prevMonth}><ChevronLeft size={18} /></button> <button className="p-1.5 rounded-control hover:bg-canvas text-ink-secondary" onClick={prevMonth}><ChevronLeft size={18} /></button>
@ -83,8 +120,8 @@ export function CalendarPage() {
{dayEvents.slice(0, 4).map((e) => ( {dayEvents.slice(0, 4).map((e) => (
<button key={e.id + dateStr} onClick={() => setEditing(e)} <button key={e.id + dateStr} onClick={() => setEditing(e)}
className="block w-full text-left text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white" className="block w-full text-left text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white"
style={{ background: eventColor(e) }} title={e.title}> style={{ background: eventColor(e) }} title={`${nameOf(e.ownerEmail)} · ${e.title}`}>
{e.title} {e.ownerEmail !== myEmail && <span className="opacity-80">{(nameOf(e.ownerEmail)[0] || "·")} </span>}{e.title}
</button> </button>
))} ))}
{dayEvents.length > 4 && <div className="text-[10px] text-ink-muted px-1">+{dayEvents.length - 4}</div>} {dayEvents.length > 4 && <div className="text-[10px] text-ink-muted px-1">+{dayEvents.length - 4}</div>}
@ -96,7 +133,6 @@ export function CalendarPage() {
})} })}
</div> </div>
)} )}
{/* 범례 */}
<div className="flex flex-wrap items-center gap-3 mt-3 text-xs text-ink-muted"> <div className="flex flex-wrap items-center gap-3 mt-3 text-xs text-ink-muted">
<span className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR.etc }} /> </span> <span className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR.etc }} /> </span>
<span className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR.personal }} /> </span> <span className="inline-flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR.personal }} /> </span>
@ -104,14 +140,15 @@ export function CalendarPage() {
</div> </div>
</Card> </Card>
{editing && <EventModal init={editing} onClose={() => setEditing(null)} />} {editing && <EventModal init={editing} myEmail={myEmail} ownerName={nameOf(editing.ownerEmail)} onClose={() => setEditing(null)} />}
</div> </div>
); );
} }
function EventModal({ init, onClose }: { init: Partial<CalendarEvent>; onClose: () => void }) { function EventModal({ init, myEmail, ownerName, onClose }: { init: Partial<CalendarEvent>; myEmail: string; ownerName: string; onClose: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }) }); const readOnly = !!init.id && init.ownerEmail !== myEmail; // 남의 일정은 보기 전용
const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }), enabled: !readOnly });
const [f, setF] = useState({ const [f, setF] = useState({
title: init.title ?? "", category: init.category ?? "etc", projectId: init.projectId ?? "", title: init.title ?? "", category: init.category ?? "etc", projectId: init.projectId ?? "",
start: init.start ?? "", end: init.end ?? "", memo: init.memo ?? "", start: init.start ?? "", end: init.end ?? "", memo: init.memo ?? "",
@ -127,6 +164,19 @@ function EventModal({ init, onClose }: { init: Partial<CalendarEvent>; onClose:
}); });
const del = useMutation({ mutationFn: () => deleteEvent(init.id!), onSuccess: () => { invalidate(); onClose(); } }); const del = useMutation({ mutationFn: () => deleteEvent(init.id!), onSuccess: () => { invalidate(); onClose(); } });
if (readOnly) {
return (
<Modal open onClose={onClose} title="일정" footer={<Button variant="secondary" onClick={onClose}></Button>}>
<div className="space-y-2 text-sm">
<div className="text-base font-bold text-ink">{init.title}</div>
<div className="text-ink-secondary">{ownerName} </div>
<div className="text-ink-secondary tabular">{init.start}{init.end && init.end !== init.start ? ` ~ ${init.end}` : ""}</div>
{init.memo && <p className="text-ink-secondary whitespace-pre-wrap">{init.memo}</p>}
</div>
</Modal>
);
}
return ( return (
<Modal open onClose={onClose} title={isEdit ? "일정 수정" : "일정 추가"} <Modal open onClose={onClose} title={isEdit ? "일정 수정" : "일정 추가"}
footer={<> footer={<>