feat(ui): 이메일 직접입력 → 구성원 검색·선택 콤보박스 (MemberSelect)
All checks were successful
build-and-push / build (push) Successful in 32s
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:
parent
a0911804ee
commit
2013152fa7
106
src/components/MemberSelect.tsx
Normal file
106
src/components/MemberSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user