diff --git a/src/components/MemberSelect.tsx b/src/components/MemberSelect.tsx new file mode 100644 index 0000000..9a7df04 --- /dev/null +++ b/src/components/MemberSelect.tsx @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDown, Search, Check, X } from "lucide-react"; +import { getMembers } from "@/lib/api"; +import { classNames, rankLabel } from "@/lib/format"; + +export type MemberOption = { value: string; label: string; sub?: string }; + +// 구성원 검색·선택 콤보박스. 이메일을 직접 타이핑하는 대신 등록된 구성원을 +// 이름/이메일로 검색해 드롭다운에서 고른다. +// - options 미지정 시 전체 구성원 디렉터리(getMembers)를 불러온다(관리자 컨텍스트). +// - options 지정 시 그 목록만 사용(예: 작업 담당자 = 프로젝트 작업자). +export function MemberSelect({ + value, onChange, options, placeholder = "구성원 검색·선택", disabled, allowClear = true, dropUp = false, +}: { + value: string; + onChange: (v: string) => void; + options?: MemberOption[]; + placeholder?: string; + disabled?: boolean; + allowClear?: boolean; + dropUp?: boolean; +}) { + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const ref = useRef(null); + const inputRef = useRef(null); + + const memQ = useQuery({ queryKey: ["members"], queryFn: getMembers, enabled: !options }); + const opts: MemberOption[] = options ?? (memQ.data ?? []).map((m) => ({ + value: m.email, + label: m.displayName || m.email, + sub: [m.email, rankLabel(m.rank)].filter(Boolean).join(" · "), + })); + const selected = opts.find((o) => o.value === value); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 0); }, [open]); + + const filtered = useMemo(() => { + const t = q.trim().toLowerCase(); + if (!t) return opts; + return opts.filter((o) => `${o.label} ${o.sub ?? ""} ${o.value}`.toLowerCase().includes(t)); + }, [opts, q]); + + return ( +
+ + + {open && !disabled && ( +
+
+ + setQ(e.target.value)} /> +
+
+ {filtered.length === 0 ? ( +
+ {memQ.isLoading ? "불러오는 중…" : "구성원이 없습니다"} +
+ ) : filtered.map((o) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index d16bece..db5631b 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -18,6 +18,7 @@ import { } from "@/components/ui"; import { Gantt } from "@/components/Gantt"; import { Kanban } from "@/components/Kanban"; +import { MemberSelect } from "@/components/MemberSelect"; import { formatDate, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, classNames, } from "@/lib/format"; @@ -136,7 +137,7 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean } {isAdmin && (
- setEmail(e.target.value)} className="w-56" disabled={!!editId} /> +
setRole(e.target.value)} className="w-32" /> setPortion(e.target.value)} className="w-24" /> @@ -244,6 +245,12 @@ function CalendarView({ tasks }: { tasks: ProjectTask[] }) { } function TaskModal({ projectId, task, onClose, onDone }: { projectId: string; task?: ProjectTask | null; onClose: () => void; onDone: () => void }) { + const pmQ = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) }); + const assigneeOpts = (pmQ.data ?? []).map((m) => ({ + value: m.memberEmail, + label: m.memberEmail.split("@")[0], + sub: [m.memberEmail, m.role].filter(Boolean).join(" · "), + })); const [form, setForm] = useState({ title: task?.title ?? "", lane: task?.lane ?? "todo", start: task?.start ?? "", end: task?.end ?? "", assignee: task?.assignee ?? "", progress: String(task?.progress ?? 0), @@ -271,7 +278,10 @@ function TaskModal({ projectId, task, onClose, onDone }: { projectId: string; ta setForm({ ...form, start: e.target.value })} /> setForm({ ...form, end: e.target.value })} />
- setForm({ ...form, assignee: e.target.value })} /> + + setForm({ ...form, assignee: v })} + options={assigneeOpts} placeholder="작업자 중 선택" dropUp /> + ); @@ -466,7 +476,7 @@ function EditProjectModal({ project, onClose }: { project: Project; onClose: ()