feat(calendar): 일정 추가 시 참여자 선택(프로젝트 작업자 일괄 추가) + 참여자 기준 필터
All checks were successful
build-and-push / build (push) Successful in 32s

- 일정 모달에 참여자 추가(MemberSelect, '이 프로젝트 작업자 모두 추가'), 칩 표시/삭제
- 구성원 필터·표시를 소유자뿐 아니라 참여자 포함으로 확장
- 읽기 전용 모달에 참여자 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 16:32:03 +09:00
parent b2f4a397e3
commit bce5016387
2 changed files with 43 additions and 6 deletions

View File

@ -1,9 +1,10 @@
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, Users } 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, getProjectMembers } from "@/lib/api";
import { useAuth } from "@/context/Auth"; import { useAuth } from "@/context/Auth";
import { useDirectory } from "@/lib/directory"; import { useDirectory } from "@/lib/directory";
import { MemberSelect } from "@/components/MemberSelect";
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";
@ -34,12 +35,19 @@ export function CalendarPage() {
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 owners = useMemo(() => {
const s = new Set(events.map((e) => e.ownerEmail)); const s = new Set<string>();
for (const e of events) {
s.add(e.ownerEmail);
(e.participants ?? []).forEach((p) => s.add(p));
}
if (myEmail) s.add(myEmail); if (myEmail) s.add(myEmail);
return Array.from(s).sort((a, b) => (a === myEmail ? -1 : b === myEmail ? 1 : nameOf(a).localeCompare(nameOf(b)))); return Array.from(s).filter(Boolean).sort((a, b) => (a === myEmail ? -1 : b === myEmail ? 1 : nameOf(a).localeCompare(nameOf(b))));
}, [events, myEmail, nameOf]); }, [events, myEmail, nameOf]);
// 이벤트가 선택된 구성원에 해당하는지(소유자 또는 참여자)
const eventShown = (e: CalendarEvent) =>
selected === null || selected.has(e.ownerEmail) || (e.participants ?? []).some((p) => selected.has(p));
const isOn = (email: string) => selected === null || selected.has(email); const isOn = (email: string) => selected === null || selected.has(email);
const toggleOwner = (email: string) => const toggleOwner = (email: string) =>
setSelected((prev) => { setSelected((prev) => {
@ -56,7 +64,7 @@ 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; if (!eventShown(e)) continue;
const s = e.start, end = e.end || e.start; const s = e.start, end = e.end || e.start;
let d = new Date(s); let d = new Date(s);
const last = new Date(end); const last = new Date(end);
@ -147,17 +155,23 @@ export function CalendarPage() {
function EventModal({ init, myEmail, ownerName, onClose }: { init: Partial<CalendarEvent>; myEmail: string; ownerName: string; onClose: () => void }) { function EventModal({ init, myEmail, ownerName, onClose }: { init: Partial<CalendarEvent>; myEmail: string; ownerName: string; onClose: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { nameOf } = useDirectory();
const readOnly = !!init.id && init.ownerEmail !== myEmail; // 남의 일정은 보기 전용 const readOnly = !!init.id && init.ownerEmail !== myEmail; // 남의 일정은 보기 전용
const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }), enabled: !readOnly }); 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 ?? "",
}); });
const [participants, setParticipants] = useState<string[]>(init.participants ?? []);
const pmQ = useQuery({ queryKey: ["pm", f.projectId], queryFn: () => getProjectMembers(f.projectId), enabled: !readOnly && f.category === "project" && !!f.projectId });
const addP = (email: string) => { if (email && !participants.includes(email)) setParticipants((p) => [...p, email]); };
const removeP = (email: string) => setParticipants((p) => p.filter((x) => x !== email));
const addProjectMembers = () => setParticipants((p) => Array.from(new Set([...p, ...(pmQ.data ?? []).map((m) => m.memberEmail)])));
const isEdit = !!init.id; const isEdit = !!init.id;
const invalidate = () => qc.invalidateQueries({ queryKey: ["events"] }); const invalidate = () => qc.invalidateQueries({ queryKey: ["events"] });
const save = useMutation({ const save = useMutation({
mutationFn: () => { mutationFn: () => {
const body = { ...f, color: f.category === "project" ? projectColor(f.projectId) : (CAT_COLOR[f.category] ?? "") }; const body = { ...f, participants, color: f.category === "project" ? projectColor(f.projectId) : (CAT_COLOR[f.category] ?? "") };
return isEdit ? updateEvent(init.id!, body) : createEvent(body); return isEdit ? updateEvent(init.id!, body) : createEvent(body);
}, },
onSuccess: () => { invalidate(); onClose(); }, onSuccess: () => { invalidate(); onClose(); },
@ -171,6 +185,9 @@ function EventModal({ init, myEmail, ownerName, onClose }: { init: Partial<Calen
<div className="text-base font-bold text-ink">{init.title}</div> <div className="text-base font-bold text-ink">{init.title}</div>
<div className="text-ink-secondary">{ownerName} </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> <div className="text-ink-secondary tabular">{init.start}{init.end && init.end !== init.start ? ` ~ ${init.end}` : ""}</div>
{(init.participants?.length ?? 0) > 0 && (
<div className="text-ink-secondary">: {init.participants!.map((p) => nameOf(p)).join(", ")}</div>
)}
{init.memo && <p className="text-ink-secondary whitespace-pre-wrap">{init.memo}</p>} {init.memo && <p className="text-ink-secondary whitespace-pre-wrap">{init.memo}</p>}
</div> </div>
</Modal> </Modal>
@ -206,6 +223,25 @@ function EventModal({ init, myEmail, ownerName, onClose }: { init: Partial<Calen
<Field label="종료일 (하루면 비움)"><Input type="date" value={f.end} onChange={(e) => setF({ ...f, end: e.target.value })} /></Field> <Field label="종료일 (하루면 비움)"><Input type="date" value={f.end} onChange={(e) => setF({ ...f, end: e.target.value })} /></Field>
</div> </div>
<Field label="메모"><Textarea value={f.memo} onChange={(e) => setF({ ...f, memo: e.target.value })} /></Field> <Field label="메모"><Textarea value={f.memo} onChange={(e) => setF({ ...f, memo: e.target.value })} /></Field>
{/* 참여자 — 이 사람들 캘린더에도 표시 */}
<div>
<span className="form-label"> <span className="text-ink-muted font-normal">· </span></span>
<MemberSelect value="" onChange={(v) => v && addP(v)} placeholder="참여자 추가" />
{f.category === "project" && f.projectId && (pmQ.data?.length ?? 0) > 0 && (
<button type="button" onClick={addProjectMembers} className="mt-1.5 text-xs text-navy hover:underline">+ </button>
)}
{participants.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{participants.map((p) => (
<span key={p} className="inline-flex items-center gap-1 text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5">
{nameOf(p)}<button type="button" onClick={() => removeP(p)} className="hover:text-money-out">×</button>
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-ink-muted"> <div className="flex items-center gap-2 text-xs text-ink-muted">
: <span className="w-4 h-4 rounded-full inline-block" style={{ background: f.category === "project" ? projectColor(f.projectId) : (CAT_COLOR[f.category] ?? "#475467") }} /> : <span className="w-4 h-4 rounded-full inline-block" style={{ background: f.category === "project" ? projectColor(f.projectId) : (CAT_COLOR[f.category] ?? "#475467") }} />
{f.category === "project" ? "프로젝트별 색 자동" : "분류 색 자동"} {f.category === "project" ? "프로젝트별 색 자동" : "분류 색 자동"}

View File

@ -308,6 +308,7 @@ export interface CalendarEvent {
end: string; end: string;
allDay: boolean; allDay: boolean;
memo: string; memo: string;
participants: string[] | null; // 참여자 이메일 — 그들 캘린더에도 표시
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }