feat(ui): PM·작업자·담당자·신청자를 이메일 대신 이름으로 표시
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>
This commit is contained in:
theorose49 2026-06-30 09:47:43 +09:00
parent 7f62f41134
commit 2c5078aa2f
9 changed files with 59 additions and 17 deletions

View File

@ -4,6 +4,7 @@ import {
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import type { Lane, ProjectTask } from "@/types"; import type { Lane, ProjectTask } from "@/types";
import { LANE_LABELS, PRIORITY_META, formatDate, classNames } from "@/lib/format"; import { LANE_LABELS, PRIORITY_META, formatDate, classNames } from "@/lib/format";
import { useDirectory } from "@/lib/directory";
const LANES: Lane[] = ["todo", "doing", "review", "done"]; const LANES: Lane[] = ["todo", "doing", "review", "done"];
const LANE_DOT: Record<Lane, string> = { const LANE_DOT: Record<Lane, string> = {
@ -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 }) { 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 { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, disabled: readOnly });
const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined; const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined;
return ( return (
@ -85,9 +87,9 @@ function KanbanCard({ task, onCardClick, readOnly }: { task: ProjectTask; onCard
{task.assignee && ( {task.assignee && (
<span className="flex items-center gap-1 text-[11px] text-ink-muted min-w-0"> <span className="flex items-center gap-1 text-[11px] text-ink-muted min-w-0">
<span className="w-5 h-5 rounded-full bg-navy text-white text-[10px] font-bold flex items-center justify-center shrink-0"> <span className="w-5 h-5 rounded-full bg-navy text-white text-[10px] font-bold flex items-center justify-center shrink-0">
{task.assignee.slice(0, 1).toUpperCase()} {nameOf(task.assignee).slice(0, 1).toUpperCase()}
</span> </span>
<span className="truncate max-w-[80px]">{task.assignee.split("@")[0]}</span> <span className="truncate max-w-[80px]">{nameOf(task.assignee)}</span>
</span> </span>
)} )}
</div> </div>

View File

@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Search, Check, X } from "lucide-react"; import { ChevronDown, Search, Check, X } from "lucide-react";
import { getMembers } from "@/lib/api"; import { getDirectory } from "@/lib/api";
import { classNames, rankLabel } from "@/lib/format"; import { classNames } from "@/lib/format";
export type MemberOption = { value: string; label: string; sub?: string }; export type MemberOption = { value: string; label: string; sub?: string };
@ -26,11 +26,11 @@ export function MemberSelect({
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(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) => ({ const opts: MemberOption[] = options ?? (memQ.data ?? []).map((m) => ({
value: m.email, value: m.email,
label: m.displayName || m.email, label: m.displayName || m.email.split("@")[0],
sub: [m.email, rankLabel(m.rank)].filter(Boolean).join(" · "), sub: m.email,
})); }));
const selected = opts.find((o) => o.value === value); const selected = opts.find((o) => o.value === value);

View File

@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import type { import type {
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact, 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, LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification,
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember, OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember,
ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction, ProjectTask, Settlement, SimResult, TaskComment, TaxRecord, Timesheet, Transaction,
@ -53,6 +53,7 @@ export const avatarUrl = (memberId?: string, avatarKey?: string) =>
/* ---- members / org ---- */ /* ---- members / org ---- */
export const getMembers = () => api.get<Member[]>("/members").then((r) => r.data); export const getMembers = () => api.get<Member[]>("/members").then((r) => r.data);
export const getDirectory = () => api.get<DirectoryEntry[]>("/members/directory").then((r) => r.data);
export const getMember = (id: string) => api.get<Member>(`/members/${id}`).then((r) => r.data); export const getMember = (id: string) => api.get<Member>(`/members/${id}`).then((r) => r.data);
export const createMember = (b: Partial<Member>) => api.post<Member>("/members", b).then((r) => r.data); export const createMember = (b: Partial<Member>) => api.post<Member>("/members", b).then((r) => r.data);
export const updateMember = (id: string, b: Partial<Member>) => export const updateMember = (id: string, b: Partial<Member>) =>

20
src/lib/directory.ts Normal file
View File

@ -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 };
}

View File

@ -20,6 +20,7 @@ import {
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 { MemberSelect } from "@/components/MemberSelect";
import { useDirectory } from "@/lib/directory";
import { useFieldSuggestions, ROLE_SUGGESTIONS } from "@/lib/suggest"; import { useFieldSuggestions, ROLE_SUGGESTIONS } from "@/lib/suggest";
import { import {
formatDate, formatDateTime, formatWon, formatSize, PROJECT_STATUS_META, LANE_LABELS, 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 }) { function Overview({ project: p }: { project: Project }) {
const { nameOf } = useDirectory();
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10">
<div> <div>
@ -87,7 +89,7 @@ function Overview({ project: p }: { project: Project }) {
<Row label="제출 국가" value={p.country} /> <Row label="제출 국가" value={p.country} />
<Row label="계약 범위(글)" value={p.scopeText} /> <Row label="계약 범위(글)" value={p.scopeText} />
<Row label="계약 범위(그림)" value={p.scopeGraphic} /> <Row label="계약 범위(그림)" value={p.scopeGraphic} />
<Row label="PM" value={p.pmEmail} /> <Row label="PM" value={p.pmEmail ? nameOf(p.pmEmail) : ""} />
</div> </div>
<div> <div>
<Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} /> <Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} />
@ -102,6 +104,7 @@ function Overview({ project: p }: { project: Project }) {
/* ---- members & portion ---- */ /* ---- members & portion ---- */
function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) { function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { nameOf } = useDirectory();
const q = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) }); const q = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) });
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -126,7 +129,7 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }
<tbody> <tbody>
{(q.data ?? []).map((pm) => ( {(q.data ?? []).map((pm) => (
<tr key={pm.id}> <tr key={pm.id}>
<td>{pm.memberEmail}</td> <td><span className="font-medium text-ink">{nameOf(pm.memberEmail)}</span> <span className="text-ink-muted text-xs">{pm.memberEmail}</span></td>
<td>{pm.role}</td> <td>{pm.role}</td>
<td className="text-right tabular font-medium">{pm.portion}%</td> <td className="text-right tabular font-medium">{pm.portion}%</td>
{isAdmin && <td className="text-right whitespace-nowrap"> {isAdmin && <td className="text-right whitespace-nowrap">
@ -247,10 +250,11 @@ function CalendarView({ tasks, onTaskClick }: { tasks: ProjectTask[]; onTaskClic
// 프로젝트 작업자(ProjectMember) → 담당자 선택 옵션 // 프로젝트 작업자(ProjectMember) → 담당자 선택 옵션
function useAssigneeOptions(projectId: string) { function useAssigneeOptions(projectId: string) {
const { nameOf } = useDirectory();
const pmQ = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) }); const pmQ = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) });
return (pmQ.data ?? []).map((m) => ({ return (pmQ.data ?? []).map((m) => ({
value: m.memberEmail, value: m.memberEmail,
label: m.memberEmail.split("@")[0], label: nameOf(m.memberEmail),
sub: [m.memberEmail, m.role].filter(Boolean).join(" · "), 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 }) { function TaskDetailModal({ projectId, task, isAdmin, onClose }: { projectId: string; task: ProjectTask; isAdmin: boolean; onClose: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { me } = useAuth(); const { me } = useAuth();
const { nameOf } = useDirectory();
const myEmail = me?.user.email ?? ""; const myEmail = me?.user.email ?? "";
const assigneeOpts = useAssigneeOptions(projectId); const assigneeOpts = useAssigneeOptions(projectId);
const [cur, setCur] = useState(task); const [cur, setCur] = useState(task);
@ -356,11 +361,11 @@ function TaskDetailModal({ projectId, task, isAdmin, onClose }: { projectId: str
{(cQ.data ?? []).map((c) => ( {(cQ.data ?? []).map((c) => (
<div key={c.id} className="flex gap-2.5"> <div key={c.id} className="flex gap-2.5">
<span className="w-7 h-7 rounded-full bg-navy text-white text-[11px] font-bold flex items-center justify-center shrink-0"> <span className="w-7 h-7 rounded-full bg-navy text-white text-[11px] font-bold flex items-center justify-center shrink-0">
{(c.authorEmail.slice(0, 1) || "?").toUpperCase()} {(nameOf(c.authorEmail).slice(0, 1) || "?").toUpperCase()}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink">{c.authorEmail.split("@")[0]}</span> <span className="text-sm font-medium text-ink">{nameOf(c.authorEmail)}</span>
<span className="text-[11px] text-ink-muted tabular">{formatDateTime(c.createdAt)}</span> <span className="text-[11px] text-ink-muted tabular">{formatDateTime(c.createdAt)}</span>
{(isAdmin || c.authorEmail === myEmail) && ( {(isAdmin || c.authorEmail === myEmail) && (
<button className="ml-auto text-ink-muted hover:text-money-out" onClick={() => delC.mutate(c.id)}><Trash2 size={13} /></button> <button className="ml-auto text-ink-muted hover:text-money-out" onClick={() => delC.mutate(c.id)}><Trash2 size={13} /></button>

View File

@ -12,12 +12,14 @@ import {
} from "@/components/ui"; } from "@/components/ui";
import { useProjectFilters } from "@/components/ProjectFilters"; import { useProjectFilters } from "@/components/ProjectFilters";
import { MemberSelect } from "@/components/MemberSelect"; import { MemberSelect } from "@/components/MemberSelect";
import { useDirectory } from "@/lib/directory";
import { useFieldSuggestions } from "@/lib/suggest"; import { useFieldSuggestions } from "@/lib/suggest";
import { formatDate, PROJECT_STATUS_META } from "@/lib/format"; import { formatDate, PROJECT_STATUS_META } from "@/lib/format";
// 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일). // 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일).
// 생성/관리는 관리자 전용 페이지(/admin/projects)에서만 가능. // 생성/관리는 관리자 전용 페이지(/admin/projects)에서만 가능.
export function ProjectsPage() { export function ProjectsPage() {
const { nameOf } = useDirectory();
const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }) }); const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }) });
const { filtered, bar } = useProjectFilters(projQ.data ?? []); const { filtered, bar } = useProjectFilters(projQ.data ?? []);
@ -51,7 +53,7 @@ export function ProjectsPage() {
{p.scopeGraphic && <span className="text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5"></span>} {p.scopeGraphic && <span className="text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5"></span>}
</div> </div>
<div className="text-xs text-ink-muted mt-3 flex justify-between"> <div className="text-xs text-ink-muted mt-3 flex justify-between">
<span>PM {p.pmEmail?.split("@")[0] || "—"}</span> <span>PM {p.pmEmail ? nameOf(p.pmEmail) : "—"}</span>
<span className="tabular">{formatDate(p.startDate)} ~ {formatDate(p.dueDate)}</span> <span className="tabular">{formatDate(p.startDate)} ~ {formatDate(p.dueDate)}</span>
</div> </div>
</Card> </Card>

View File

@ -6,10 +6,12 @@ import {
Card, Button, Tabs, PageHeader, EmptyState, LoadingState, Card, Button, Tabs, PageHeader, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { MemberSelect } from "@/components/MemberSelect"; import { MemberSelect } from "@/components/MemberSelect";
import { useDirectory } from "@/lib/directory";
import { formatDate, minutesToHM, LEAVE_LABELS } from "@/lib/format"; import { formatDate, minutesToHM, LEAVE_LABELS } from "@/lib/format";
export function ApprovalsPage() { export function ApprovalsPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { nameOf } = useDirectory();
const [tab, setTab] = useState("queue"); const [tab, setTab] = useState("queue");
const q = useQuery({ queryKey: ["approvals"], queryFn: getApprovals }); const q = useQuery({ queryKey: ["approvals"], queryFn: getApprovals });
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -39,7 +41,7 @@ export function ApprovalsPage() {
<tbody> <tbody>
{q.data!.leave.map((l) => ( {q.data!.leave.map((l) => (
<tr key={l.id}> <tr key={l.id}>
<td>{l.memberEmail}</td><td>{LEAVE_LABELS[l.type]}</td> <td>{nameOf(l.memberEmail)}</td><td>{LEAVE_LABELS[l.type]}</td>
<td className="tabular">{formatDate(l.startDate)}{l.endDate !== l.startDate ? `~${formatDate(l.endDate)}` : ""}</td> <td className="tabular">{formatDate(l.startDate)}{l.endDate !== l.startDate ? `~${formatDate(l.endDate)}` : ""}</td>
<td className="tabular">{l.days}</td><td className="text-ink-secondary max-w-[220px] truncate">{l.reason}</td> <td className="tabular">{l.days}</td><td className="text-ink-secondary max-w-[220px] truncate">{l.reason}</td>
<td className="text-right whitespace-nowrap"> <td className="text-right whitespace-nowrap">
@ -64,7 +66,7 @@ export function ApprovalsPage() {
<thead><tr><th></th><th></th><th></th></tr></thead> <thead><tr><th></th><th></th><th></th></tr></thead>
<tbody> <tbody>
{attQ.data!.map((a) => ( {attQ.data!.map((a) => (
<tr key={a.id}><td>{a.memberEmail}</td><td className="tabular">{formatDate(a.date)}</td><td className="tabular">{minutesToHM(a.workMinutes)}</td></tr> <tr key={a.id}><td>{nameOf(a.memberEmail)}</td><td className="tabular">{formatDate(a.date)}</td><td className="tabular">{minutesToHM(a.workMinutes)}</td></tr>
))} ))}
</tbody> </tbody>
</table> </table>

View File

@ -8,12 +8,14 @@ import {
Card, Button, Badge, PageHeader, EmptyState, LoadingState, Card, Button, Badge, PageHeader, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { useProjectFilters } from "@/components/ProjectFilters"; import { useProjectFilters } from "@/components/ProjectFilters";
import { useDirectory } from "@/lib/directory";
import { formatDate, PROJECT_STATUS_META } from "@/lib/format"; import { formatDate, PROJECT_STATUS_META } from "@/lib/format";
// 관리자 전용 프로젝트 관리창: 전체 프로젝트 생성·조회·삭제. 세부 관리(작업자·계약· // 관리자 전용 프로젝트 관리창: 전체 프로젝트 생성·조회·삭제. 세부 관리(작업자·계약·
// 분할입금·태스크 등)는 각 프로젝트 상세 페이지의 탭에서 수행. // 분할입금·태스크 등)는 각 프로젝트 상세 페이지의 탭에서 수행.
export function ProjectsAdminPage() { export function ProjectsAdminPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { nameOf } = useDirectory();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const projQ = useQuery({ queryKey: ["projects", "all"], queryFn: () => getProjects() }); const projQ = useQuery({ queryKey: ["projects", "all"], queryFn: () => getProjects() });
const { filtered: rows, bar } = useProjectFilters(projQ.data ?? []); const { filtered: rows, bar } = useProjectFilters(projQ.data ?? []);
@ -54,7 +56,7 @@ export function ProjectsAdminPage() {
<td>{p.consultingType}</td> <td>{p.consultingType}</td>
<td>{p.country}</td> <td>{p.country}</td>
<td>{[p.scopeText && "글", p.scopeGraphic && "그림"].filter(Boolean).join("+") || "—"}</td> <td>{[p.scopeText && "글", p.scopeGraphic && "그림"].filter(Boolean).join("+") || "—"}</td>
<td className="text-ink-secondary">{p.pmEmail?.split("@")[0] || "—"}</td> <td className="text-ink-secondary">{p.pmEmail ? nameOf(p.pmEmail) : "—"}</td>
<td className="tabular text-ink-muted">{formatDate(p.startDate)} ~ {formatDate(p.dueDate)}</td> <td className="tabular text-ink-muted">{formatDate(p.startDate)} ~ {formatDate(p.dueDate)}</td>
<td>{m && <Badge label={m.label} fg={m.fg} bg={m.bg} dot size="sm" />}</td> <td>{m && <Badge label={m.label} fg={m.fg} bg={m.bg} dot size="sm" />}</td>
<td className="text-right"> <td className="text-right">

View File

@ -211,6 +211,14 @@ export interface ClientContact {
} }
export type Lane = "todo" | "doing" | "review" | "done"; 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 type TaskPriority = "low" | "medium" | "high" | "urgent";
export interface ProjectTask { export interface ProjectTask {