spin-frontend/src/components/MemberSelect.tsx
theorose49 2c5078aa2f
All checks were successful
build-and-push / build (push) Successful in 31s
feat(ui): PM·작업자·담당자·신청자를 이메일 대신 이름으로 표시
- useDirectory() 훅 + /members/directory 연동(전 유저 동작)
- MemberSelect 기본 소스를 디렉터리로 전환(이름 표시)
- 프로젝트 카드/관리 테이블 PM, 작업자 탭, 칸반 담당자, 작업 상세 담당자·댓글 작성자,
  승인관리 신청자/근무기록 구성원 모두 이름으로

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:47:43 +09:00

107 lines
4.7 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Search, Check, X } from "lucide-react";
import { getDirectory } from "@/lib/api";
import { classNames } 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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const memQ = useQuery({ queryKey: ["directory"], queryFn: getDirectory, enabled: !options });
const opts: MemberOption[] = options ?? (memQ.data ?? []).map((m) => ({
value: m.email,
label: m.displayName || m.email.split("@")[0],
sub: m.email,
}));
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 (
<div ref={ref} className="relative">
<button
type="button" disabled={disabled}
onClick={() => setOpen((o) => !o)}
className={classNames(
"form-input flex items-center justify-between gap-2 text-left",
disabled && "opacity-60 cursor-not-allowed",
!selected && "text-ink-muted"
)}
>
<span className="truncate">{selected ? selected.label : (value || placeholder)}</span>
<span className="flex items-center gap-1 shrink-0">
{allowClear && value && !disabled && (
<X size={14} className="text-ink-muted hover:text-ink"
onClick={(e) => { e.stopPropagation(); onChange(""); }} />
)}
<ChevronDown size={15} className="text-ink-muted" />
</span>
</button>
{open && !disabled && (
<div className={classNames(
"absolute z-50 w-full min-w-[240px] bg-surface rounded-card shadow-pop border border-border",
dropUp ? "bottom-full mb-1" : "mt-1"
)}>
<div className="p-2 border-b border-divider relative">
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-ink-muted pointer-events-none" />
<input ref={inputRef} className="form-input pl-8 h-9" placeholder="이름·이메일 검색"
value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="max-h-60 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-ink-muted">
{memQ.isLoading ? "불러오는 중…" : "구성원이 없습니다"}
</div>
) : filtered.map((o) => (
<button key={o.value} type="button"
onClick={() => { onChange(o.value); setOpen(false); setQ(""); }}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-canvas">
<span className="w-6 h-6 rounded-full bg-navy text-white text-[11px] font-bold flex items-center justify-center shrink-0">
{o.label.slice(0, 1) || "?"}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-ink truncate">{o.label}</span>
{o.sub && <span className="block text-[11px] text-ink-muted truncate">{o.sub}</span>}
</span>
{o.value === value && <Check size={15} className="text-navy shrink-0" />}
</button>
))}
</div>
</div>
)}
</div>
);
}