From 2013152fa7572fc436db4b9d7487b26601af6d27 Mon Sep 17 00:00:00 2001 From: theorose49 Date: Tue, 30 Jun 2026 08:45:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=EC=9E=85=EB=A0=A5=20=E2=86=92=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=EC=9B=90=20=EA=B2=80=EC=83=89=C2=B7=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EC=BD=A4=EB=B3=B4=EB=B0=95=EC=8A=A4=20(MemberSelect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 MemberSelect: 이름/이메일 검색 + 드롭다운 선택, 아바타 이니셜·직급 표기 - 프로젝트 작업자/PM(생성·수정), 작업 담당자(프로젝트 작업자 한정), 승인관리 필터, 인센티브 시뮬레이터 작업자에 적용 → 텍스트 타이핑 제거 - 담당자는 프로젝트 작업자 목록에서 선택(getProjectMembers), 그 외는 전체 구성원 디렉터리 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/MemberSelect.tsx | 106 +++++++++++++++++++++++++++++ src/pages/ProjectDetail.tsx | 16 ++++- src/pages/Projects.tsx | 3 +- src/pages/admin/Approvals.tsx | 5 +- src/pages/admin/IncentiveAdmin.tsx | 3 +- 5 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 src/components/MemberSelect.tsx 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: ()