All checks were successful
build-and-push / build (push) Successful in 31s
- useDirectory() 훅 + /members/directory 연동(전 유저 동작) - MemberSelect 기본 소스를 디렉터리로 전환(이름 표시) - 프로젝트 카드/관리 테이블 PM, 작업자 탭, 칸반 담당자, 작업 상세 담당자·댓글 작성자, 승인관리 신청자/근무기록 구성원 모두 이름으로 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|