From 2c5078aa2f6cc1bde183229804ea0ead5bf30470 Mon Sep 17 00:00:00 2001 From: theorose49 Date: Tue, 30 Jun 2026 09:47:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20PM=C2=B7=EC=9E=91=EC=97=85=EC=9E=90?= =?UTF-8?q?=C2=B7=EB=8B=B4=EB=8B=B9=EC=9E=90=C2=B7=EC=8B=A0=EC=B2=AD?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDirectory() 훅 + /members/directory 연동(전 유저 동작) - MemberSelect 기본 소스를 디렉터리로 전환(이름 표시) - 프로젝트 카드/관리 테이블 PM, 작업자 탭, 칸반 담당자, 작업 상세 담당자·댓글 작성자, 승인관리 신청자/근무기록 구성원 모두 이름으로 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Kanban.tsx | 6 ++++-- src/components/MemberSelect.tsx | 10 +++++----- src/lib/api.ts | 3 ++- src/lib/directory.ts | 20 ++++++++++++++++++++ src/pages/ProjectDetail.tsx | 15 ++++++++++----- src/pages/Projects.tsx | 4 +++- src/pages/admin/Approvals.tsx | 6 ++++-- src/pages/admin/ProjectsAdmin.tsx | 4 +++- src/types.ts | 8 ++++++++ 9 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 src/lib/directory.ts diff --git a/src/components/Kanban.tsx b/src/components/Kanban.tsx index 2e5b2f9..b4d6e92 100644 --- a/src/components/Kanban.tsx +++ b/src/components/Kanban.tsx @@ -4,6 +4,7 @@ import { } from "@dnd-kit/core"; import type { Lane, ProjectTask } from "@/types"; import { LANE_LABELS, PRIORITY_META, formatDate, classNames } from "@/lib/format"; +import { useDirectory } from "@/lib/directory"; const LANES: Lane[] = ["todo", "doing", "review", "done"]; const LANE_DOT: Record = { @@ -53,6 +54,7 @@ function Column({ lane, tasks, onCardClick, readOnly }: { lane: Lane; tasks: Pro } function KanbanCard({ task, onCardClick, readOnly }: { task: ProjectTask; onCardClick?: (t: ProjectTask) => void; readOnly?: boolean }) { + const { nameOf } = useDirectory(); const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, disabled: readOnly }); const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined; return ( @@ -85,9 +87,9 @@ function KanbanCard({ task, onCardClick, readOnly }: { task: ProjectTask; onCard {task.assignee && ( - {task.assignee.slice(0, 1).toUpperCase()} + {nameOf(task.assignee).slice(0, 1).toUpperCase()} - {task.assignee.split("@")[0]} + {nameOf(task.assignee)} )} diff --git a/src/components/MemberSelect.tsx b/src/components/MemberSelect.tsx index 9a7df04..f02d720 100644 --- a/src/components/MemberSelect.tsx +++ b/src/components/MemberSelect.tsx @@ -1,8 +1,8 @@ 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"; +import { getDirectory } from "@/lib/api"; +import { classNames } from "@/lib/format"; export type MemberOption = { value: string; label: string; sub?: string }; @@ -26,11 +26,11 @@ export function MemberSelect({ const ref = useRef(null); const inputRef = useRef(null); - const memQ = useQuery({ queryKey: ["members"], queryFn: getMembers, enabled: !options }); + 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, - sub: [m.email, rankLabel(m.rank)].filter(Boolean).join(" · "), + label: m.displayName || m.email.split("@")[0], + sub: m.email, })); const selected = opts.find((o) => o.value === value); diff --git a/src/lib/api.ts b/src/lib/api.ts index d7e4f51..87a6cf4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,7 +1,7 @@ import axios from "axios"; import type { Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact, - Company, Contract, ContractFile, Dashboard, Department, IncentiveConfig, + Company, Contract, ContractFile, Dashboard, Department, DirectoryEntry, IncentiveConfig, LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification, OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction, @@ -53,6 +53,7 @@ export const avatarUrl = (memberId?: string, avatarKey?: string) => /* ---- members / org ---- */ export const getMembers = () => api.get("/members").then((r) => r.data); +export const getDirectory = () => api.get("/members/directory").then((r) => r.data); export const getMember = (id: string) => api.get(`/members/${id}`).then((r) => r.data); export const createMember = (b: Partial) => api.post("/members", b).then((r) => r.data); export const updateMember = (id: string, b: Partial) => diff --git a/src/lib/directory.ts b/src/lib/directory.ts new file mode 100644 index 0000000..ff64fa4 --- /dev/null +++ b/src/lib/directory.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getDirectory } from "@/lib/api"; + +// 구성원 이메일 → 표시 이름 변환. PM·작업자·담당자 등은 이메일이 아니라 이름으로 보여준다. +// 전 구성원 공개 디렉터리(/members/directory)를 사용하므로 일반 유저도 동작한다. +export function useDirectory() { + const q = useQuery({ queryKey: ["directory"], queryFn: getDirectory, staleTime: 5 * 60_000 }); + const entries = q.data ?? []; + const byEmail = useMemo( + () => new Map(entries.map((e) => [e.email.toLowerCase(), e])), + [entries] + ); + // 이름을 모르면 이메일 로컬파트로 폴백(@앞부분). + const nameOf = (email?: string | null) => { + if (!email) return "—"; + return byEmail.get(email.toLowerCase())?.displayName || email.split("@")[0]; + }; + return { entries, byEmail, nameOf }; +} diff --git a/src/pages/ProjectDetail.tsx b/src/pages/ProjectDetail.tsx index f51a0e8..9587fe1 100644 --- a/src/pages/ProjectDetail.tsx +++ b/src/pages/ProjectDetail.tsx @@ -20,6 +20,7 @@ import { import { Gantt } from "@/components/Gantt"; import { Kanban } from "@/components/Kanban"; import { MemberSelect } from "@/components/MemberSelect"; +import { useDirectory } from "@/lib/directory"; import { useFieldSuggestions, ROLE_SUGGESTIONS } from "@/lib/suggest"; import { formatDate, formatDateTime, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, @@ -80,6 +81,7 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) { } function Overview({ project: p }: { project: Project }) { + const { nameOf } = useDirectory(); return (
@@ -87,7 +89,7 @@ function Overview({ project: p }: { project: Project }) { - +
@@ -102,6 +104,7 @@ function Overview({ project: p }: { project: Project }) { /* ---- members & portion ---- */ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { const qc = useQueryClient(); + const { nameOf } = useDirectory(); const q = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) }); const [editId, setEditId] = useState(null); const [email, setEmail] = useState(""); @@ -126,7 +129,7 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean } {(q.data ?? []).map((pm) => ( - {pm.memberEmail} + {nameOf(pm.memberEmail)} {pm.memberEmail} {pm.role} {pm.portion}% {isAdmin && @@ -247,10 +250,11 @@ function CalendarView({ tasks, onTaskClick }: { tasks: ProjectTask[]; onTaskClic // 프로젝트 작업자(ProjectMember) → 담당자 선택 옵션 function useAssigneeOptions(projectId: string) { + const { nameOf } = useDirectory(); const pmQ = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) }); return (pmQ.data ?? []).map((m) => ({ value: m.memberEmail, - label: m.memberEmail.split("@")[0], + label: nameOf(m.memberEmail), sub: [m.memberEmail, m.role].filter(Boolean).join(" · "), })); } @@ -295,6 +299,7 @@ function CreateTaskModal({ projectId, onClose, onCreated }: { projectId: string; function TaskDetailModal({ projectId, task, isAdmin, onClose }: { projectId: string; task: ProjectTask; isAdmin: boolean; onClose: () => void }) { const qc = useQueryClient(); const { me } = useAuth(); + const { nameOf } = useDirectory(); const myEmail = me?.user.email ?? ""; const assigneeOpts = useAssigneeOptions(projectId); const [cur, setCur] = useState(task); @@ -356,11 +361,11 @@ function TaskDetailModal({ projectId, task, isAdmin, onClose }: { projectId: str {(cQ.data ?? []).map((c) => (
- {(c.authorEmail.slice(0, 1) || "?").toUpperCase()} + {(nameOf(c.authorEmail).slice(0, 1) || "?").toUpperCase()}
- {c.authorEmail.split("@")[0]} + {nameOf(c.authorEmail)} {formatDateTime(c.createdAt)} {(isAdmin || c.authorEmail === myEmail) && ( diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index 2375bf0..9501335 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -12,12 +12,14 @@ import { } from "@/components/ui"; import { useProjectFilters } from "@/components/ProjectFilters"; import { MemberSelect } from "@/components/MemberSelect"; +import { useDirectory } from "@/lib/directory"; import { useFieldSuggestions } from "@/lib/suggest"; import { formatDate, PROJECT_STATUS_META } from "@/lib/format"; // 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일). // 생성/관리는 관리자 전용 페이지(/admin/projects)에서만 가능. export function ProjectsPage() { + const { nameOf } = useDirectory(); const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }) }); const { filtered, bar } = useProjectFilters(projQ.data ?? []); @@ -51,7 +53,7 @@ export function ProjectsPage() { {p.scopeGraphic && 그림}
- PM {p.pmEmail?.split("@")[0] || "—"} + PM {p.pmEmail ? nameOf(p.pmEmail) : "—"} {formatDate(p.startDate)} ~ {formatDate(p.dueDate)}
diff --git a/src/pages/admin/Approvals.tsx b/src/pages/admin/Approvals.tsx index 446d6c1..9d287cf 100644 --- a/src/pages/admin/Approvals.tsx +++ b/src/pages/admin/Approvals.tsx @@ -6,10 +6,12 @@ import { Card, Button, Tabs, PageHeader, EmptyState, LoadingState, } from "@/components/ui"; import { MemberSelect } from "@/components/MemberSelect"; +import { useDirectory } from "@/lib/directory"; import { formatDate, minutesToHM, LEAVE_LABELS } from "@/lib/format"; export function ApprovalsPage() { const qc = useQueryClient(); + const { nameOf } = useDirectory(); const [tab, setTab] = useState("queue"); const q = useQuery({ queryKey: ["approvals"], queryFn: getApprovals }); const [email, setEmail] = useState(""); @@ -39,7 +41,7 @@ export function ApprovalsPage() { {q.data!.leave.map((l) => ( - {l.memberEmail}{LEAVE_LABELS[l.type]} + {nameOf(l.memberEmail)}{LEAVE_LABELS[l.type]} {formatDate(l.startDate)}{l.endDate !== l.startDate ? `~${formatDate(l.endDate)}` : ""} {l.days}일{l.reason} @@ -64,7 +66,7 @@ export function ApprovalsPage() { 구성원날짜근무시간 {attQ.data!.map((a) => ( - {a.memberEmail}{formatDate(a.date)}{minutesToHM(a.workMinutes)} + {nameOf(a.memberEmail)}{formatDate(a.date)}{minutesToHM(a.workMinutes)} ))} diff --git a/src/pages/admin/ProjectsAdmin.tsx b/src/pages/admin/ProjectsAdmin.tsx index 47b1a51..fdcb714 100644 --- a/src/pages/admin/ProjectsAdmin.tsx +++ b/src/pages/admin/ProjectsAdmin.tsx @@ -8,12 +8,14 @@ import { Card, Button, Badge, PageHeader, EmptyState, LoadingState, } from "@/components/ui"; import { useProjectFilters } from "@/components/ProjectFilters"; +import { useDirectory } from "@/lib/directory"; import { formatDate, PROJECT_STATUS_META } from "@/lib/format"; // 관리자 전용 프로젝트 관리창: 전체 프로젝트 생성·조회·삭제. 세부 관리(작업자·계약· // 분할입금·태스크 등)는 각 프로젝트 상세 페이지의 탭에서 수행. export function ProjectsAdminPage() { const qc = useQueryClient(); + const { nameOf } = useDirectory(); const [open, setOpen] = useState(false); const projQ = useQuery({ queryKey: ["projects", "all"], queryFn: () => getProjects() }); const { filtered: rows, bar } = useProjectFilters(projQ.data ?? []); @@ -54,7 +56,7 @@ export function ProjectsAdminPage() { {p.consultingType} {p.country} {[p.scopeText && "글", p.scopeGraphic && "그림"].filter(Boolean).join("+") || "—"} - {p.pmEmail?.split("@")[0] || "—"} + {p.pmEmail ? nameOf(p.pmEmail) : "—"} {formatDate(p.startDate)} ~ {formatDate(p.dueDate)} {m && } diff --git a/src/types.ts b/src/types.ts index d67d955..fdbfac1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -211,6 +211,14 @@ export interface ClientContact { } export type Lane = "todo" | "doing" | "review" | "done"; +// 전 구성원 공개 최소 디렉터리 (이메일 → 이름 표시용) +export interface DirectoryEntry { + id: string; + email: string; + displayName: string; + avatarKey: string; +} + export type TaskPriority = "low" | "medium" | "high" | "urgent"; export interface ProjectTask {