feat(calendar): 구성원별 체크박스 필터 + 다른 구성원 일정 보기(읽기 전용)
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
09713f5e23
commit
b2f4a397e3
@ -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={<>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user