feat(ui): 이메일 직접입력 → 구성원 검색·선택 콤보박스 (MemberSelect)
All checks were successful
build-and-push / build (push) Successful in 32s

- 신규 MemberSelect: 이름/이메일 검색 + 드롭다운 선택, 아바타 이니셜·직급 표기
- 프로젝트 작업자/PM(생성·수정), 작업 담당자(프로젝트 작업자 한정), 승인관리 필터,
  인센티브 시뮬레이터 작업자에 적용 → 텍스트 타이핑 제거
- 담당자는 프로젝트 작업자 목록에서 선택(getProjectMembers), 그 외는 전체 구성원 디렉터리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 08:45:19 +09:00
parent a0911804ee
commit 2013152fa7
5 changed files with 126 additions and 7 deletions

View File

@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<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>
);
}

View File

@ -18,6 +18,7 @@ import {
} from "@/components/ui"; } from "@/components/ui";
import { Gantt } from "@/components/Gantt"; import { Gantt } from "@/components/Gantt";
import { Kanban } from "@/components/Kanban"; import { Kanban } from "@/components/Kanban";
import { MemberSelect } from "@/components/MemberSelect";
import { import {
formatDate, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, classNames, formatDate, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, classNames,
} from "@/lib/format"; } from "@/lib/format";
@ -136,7 +137,7 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }
</table> </table>
{isAdmin && ( {isAdmin && (
<div className="flex flex-wrap items-end gap-2 mt-4 p-3 bg-canvas rounded-control"> <div className="flex flex-wrap items-end gap-2 mt-4 p-3 bg-canvas rounded-control">
<Field label="작업자 이메일"><Input value={email} onChange={(e) => setEmail(e.target.value)} className="w-56" disabled={!!editId} /></Field> <Field label="작업자"><div className="w-60"><MemberSelect value={email} onChange={setEmail} disabled={!!editId} placeholder="구성원 검색·선택" /></div></Field>
<Field label="역할"><Input value={role} onChange={(e) => setRole(e.target.value)} className="w-32" /></Field> <Field label="역할"><Input value={role} onChange={(e) => setRole(e.target.value)} className="w-32" /></Field>
<Field label="기여도 %"><Input type="number" value={portion} onChange={(e) => setPortion(e.target.value)} className="w-24" /></Field> <Field label="기여도 %"><Input type="number" value={portion} onChange={(e) => setPortion(e.target.value)} className="w-24" /></Field>
<Button icon={editId ? undefined : <Plus size={15} />} disabled={!email || save.isPending} onClick={() => save.mutate()}>{editId ? "수정" : "추가"}</Button> <Button icon={editId ? undefined : <Plus size={15} />} disabled={!email || save.isPending} onClick={() => save.mutate()}>{editId ? "수정" : "추가"}</Button>
@ -244,6 +245,12 @@ function CalendarView({ tasks }: { tasks: ProjectTask[] }) {
} }
function TaskModal({ projectId, task, onClose, onDone }: { projectId: string; task?: ProjectTask | null; onClose: () => void; onDone: () => void }) { 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({ const [form, setForm] = useState({
title: task?.title ?? "", lane: task?.lane ?? "todo", start: task?.start ?? "", title: task?.title ?? "", lane: task?.lane ?? "todo", start: task?.start ?? "",
end: task?.end ?? "", assignee: task?.assignee ?? "", progress: String(task?.progress ?? 0), end: task?.end ?? "", assignee: task?.assignee ?? "", progress: String(task?.progress ?? 0),
@ -271,7 +278,10 @@ function TaskModal({ projectId, task, onClose, onDone }: { projectId: string; ta
<Field label="시작일"><Input type="date" value={form.start} onChange={(e) => setForm({ ...form, start: e.target.value })} /></Field> <Field label="시작일"><Input type="date" value={form.start} onChange={(e) => setForm({ ...form, start: e.target.value })} /></Field>
<Field label="종료일"><Input type="date" value={form.end} onChange={(e) => setForm({ ...form, end: e.target.value })} /></Field> <Field label="종료일"><Input type="date" value={form.end} onChange={(e) => setForm({ ...form, end: e.target.value })} /></Field>
</div> </div>
<Field label="담당자 이메일"><Input value={form.assignee} onChange={(e) => setForm({ ...form, assignee: e.target.value })} /></Field> <Field label="담당자" hint={assigneeOpts.length === 0 ? "먼저 '작업자' 탭에서 작업자를 추가하세요." : undefined}>
<MemberSelect value={form.assignee} onChange={(v) => setForm({ ...form, assignee: v })}
options={assigneeOpts} placeholder="작업자 중 선택" dropUp />
</Field>
</div> </div>
</Modal> </Modal>
); );
@ -466,7 +476,7 @@ function EditProjectModal({ project, onClose }: { project: Project; onClose: ()
<Field label="계약 범위 — 그림"><Textarea value={f.scopeGraphic} onChange={(e) => setF({ ...f, scopeGraphic: e.target.value })} /></Field> <Field label="계약 범위 — 그림"><Textarea value={f.scopeGraphic} onChange={(e) => setF({ ...f, scopeGraphic: e.target.value })} /></Field>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="PM 이메일"><Input value={f.pmEmail} onChange={(e) => setF({ ...f, pmEmail: e.target.value })} /></Field> <Field label="PM"><MemberSelect value={f.pmEmail} onChange={(v) => setF({ ...f, pmEmail: v })} placeholder="PM 검색·선택" /></Field>
<Field label="시작일"><Input type="date" value={f.startDate} onChange={(e) => setF({ ...f, startDate: e.target.value })} /></Field> <Field label="시작일"><Input type="date" value={f.startDate} onChange={(e) => setF({ ...f, startDate: e.target.value })} /></Field>
<Field label="마감일"><Input type="date" value={f.dueDate} onChange={(e) => setF({ ...f, dueDate: e.target.value })} /></Field> <Field label="마감일"><Input type="date" value={f.dueDate} onChange={(e) => setF({ ...f, dueDate: e.target.value })} /></Field>
</div> </div>

View File

@ -11,6 +11,7 @@ import {
EmptyState, LoadingState, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { useProjectFilters } from "@/components/ProjectFilters"; import { useProjectFilters } from "@/components/ProjectFilters";
import { MemberSelect } from "@/components/MemberSelect";
import { formatDate, PROJECT_STATUS_META } from "@/lib/format"; import { formatDate, PROJECT_STATUS_META } from "@/lib/format";
// 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일). // 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일).
@ -140,7 +141,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
<Field label="계약 범위 — 그림" hint="그림 작업 범위를 자유롭게 기술"><Textarea value={form.scopeGraphic} onChange={(e) => setForm({ ...form, scopeGraphic: e.target.value })} placeholder="예: 도면·UI 목업 제작" /></Field> <Field label="계약 범위 — 그림" hint="그림 작업 범위를 자유롭게 기술"><Textarea value={form.scopeGraphic} onChange={(e) => setForm({ ...form, scopeGraphic: e.target.value })} placeholder="예: 도면·UI 목업 제작" /></Field>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="PM 이메일"><Input value={form.pmEmail} onChange={(e) => setForm({ ...form, pmEmail: e.target.value })} /></Field> <Field label="PM"><MemberSelect value={form.pmEmail} onChange={(v) => setForm({ ...form, pmEmail: v })} placeholder="PM 검색·선택" /></Field>
<Field label="시작일"><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></Field> <Field label="시작일"><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></Field>
<Field label="마감일"><Input type="date" value={form.dueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value })} /></Field> <Field label="마감일"><Input type="date" value={form.dueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value })} /></Field>
</div> </div>

View File

@ -3,8 +3,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, X } from "lucide-react"; import { Check, X } from "lucide-react";
import { getApprovals, decideLeave, getAttendance } from "@/lib/api"; import { getApprovals, decideLeave, getAttendance } from "@/lib/api";
import { import {
Card, Button, Tabs, PageHeader, EmptyState, LoadingState, Input, Card, Button, Tabs, PageHeader, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { MemberSelect } from "@/components/MemberSelect";
import { formatDate, minutesToHM, LEAVE_LABELS } from "@/lib/format"; import { formatDate, minutesToHM, LEAVE_LABELS } from "@/lib/format";
export function ApprovalsPage() { export function ApprovalsPage() {
@ -57,7 +58,7 @@ export function ApprovalsPage() {
{tab === "records" && ( {tab === "records" && (
<div> <div>
<div className="mb-3 max-w-xs"><Input placeholder="이메일로 필터 (비우면 전체)" value={email} onChange={(e) => setEmail(e.target.value)} /></div> <div className="mb-3 max-w-xs"><MemberSelect value={email} onChange={setEmail} placeholder="구성원으로 필터 (비우면 전체)" /></div>
{attQ.isLoading ? <LoadingState /> : (attQ.data?.length ?? 0) === 0 ? <EmptyState title="근무 기록 없음" /> : ( {attQ.isLoading ? <LoadingState /> : (attQ.data?.length ?? 0) === 0 ? <EmptyState title="근무 기록 없음" /> : (
<table className="dense-table"> <table className="dense-table">
<thead><tr><th></th><th></th><th></th></tr></thead> <thead><tr><th></th><th></th><th></th></tr></thead>

View File

@ -12,6 +12,7 @@ import {
import { import {
formatWon, formatPoints, FIX_STATUS_META, FIX_ORDER, STAGE_KIND_LABELS, SCOPE_LABELS, formatWon, formatPoints, FIX_STATUS_META, FIX_ORDER, STAGE_KIND_LABELS, SCOPE_LABELS,
} from "@/lib/format"; } from "@/lib/format";
import { MemberSelect } from "@/components/MemberSelect";
import type { FixStatus, PaymentStage, UserIncentive } from "@/types"; import type { FixStatus, PaymentStage, UserIncentive } from "@/types";
const QUARTERS = [1, 2, 3, 4]; const QUARTERS = [1, 2, 3, 4];
@ -218,7 +219,7 @@ function SimulatorTab() {
<div className="space-y-2"> <div className="space-y-2">
{members.map((m, i) => ( {members.map((m, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Input value={m.email} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, email: e.target.value } : x))} className="flex-1" /> <div className="flex-1"><MemberSelect value={m.email} onChange={(v) => setMembers(members.map((x, j) => j === i ? { ...x, email: v } : x))} placeholder="작업자 검색·선택" /></div>
<Input type="number" value={m.portion} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, portion: +e.target.value } : x))} className="w-20" /> <Input type="number" value={m.portion} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, portion: +e.target.value } : x))} className="w-20" />
<label className="text-xs flex items-center gap-1 whitespace-nowrap"><input type="checkbox" checked={m.isPartner} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, isPartner: e.target.checked } : x))} /></label> <label className="text-xs flex items-center gap-1 whitespace-nowrap"><input type="checkbox" checked={m.isPartner} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, isPartner: e.target.checked } : x))} /></label>
<button className="text-ink-muted" onClick={() => setMembers(members.filter((_, j) => j !== i))}></button> <button className="text-ink-muted" onClick={() => setMembers(members.filter((_, j) => j !== i))}></button>