feat(ui): PM·작업자·담당자·신청자를 이메일 대신 이름으로 표시
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
7f62f41134
commit
2c5078aa2f
@ -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<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 }) {
|
||||
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 && (
|
||||
<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">
|
||||
{task.assignee.slice(0, 1).toUpperCase()}
|
||||
{nameOf(task.assignee).slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate max-w-[80px]">{task.assignee.split("@")[0]}</span>
|
||||
<span className="truncate max-w-[80px]">{nameOf(task.assignee)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<HTMLDivElement>(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) => ({
|
||||
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);
|
||||
|
||||
|
||||
@ -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<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 createMember = (b: Partial<Member>) => api.post<Member>("/members", b).then((r) => r.data);
|
||||
export const updateMember = (id: string, b: Partial<Member>) =>
|
||||
|
||||
20
src/lib/directory.ts
Normal file
20
src/lib/directory.ts
Normal 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 };
|
||||
}
|
||||
@ -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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10">
|
||||
<div>
|
||||
@ -87,7 +89,7 @@ function Overview({ project: p }: { project: Project }) {
|
||||
<Row label="제출 국가" value={p.country} />
|
||||
<Row label="계약 범위(글)" value={p.scopeText} />
|
||||
<Row label="계약 범위(그림)" value={p.scopeGraphic} />
|
||||
<Row label="PM" value={p.pmEmail} />
|
||||
<Row label="PM" value={p.pmEmail ? nameOf(p.pmEmail) : ""} />
|
||||
</div>
|
||||
<div>
|
||||
<Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} />
|
||||
@ -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<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
@ -126,7 +129,7 @@ function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }
|
||||
<tbody>
|
||||
{(q.data ?? []).map((pm) => (
|
||||
<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 className="text-right tabular font-medium">{pm.portion}%</td>
|
||||
{isAdmin && <td className="text-right whitespace-nowrap">
|
||||
@ -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) => (
|
||||
<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">
|
||||
{(c.authorEmail.slice(0, 1) || "?").toUpperCase()}
|
||||
{(nameOf(c.authorEmail).slice(0, 1) || "?").toUpperCase()}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
{(isAdmin || c.authorEmail === myEmail) && (
|
||||
<button className="ml-auto text-ink-muted hover:text-money-out" onClick={() => delC.mutate(c.id)}><Trash2 size={13} /></button>
|
||||
|
||||
@ -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 && <span className="text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5">그림</span>}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@ -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() {
|
||||
<tbody>
|
||||
{q.data!.leave.map((l) => (
|
||||
<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">{l.days}일</td><td className="text-ink-secondary max-w-[220px] truncate">{l.reason}</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
@ -64,7 +66,7 @@ export function ApprovalsPage() {
|
||||
<thead><tr><th>구성원</th><th>날짜</th><th>근무시간</th></tr></thead>
|
||||
<tbody>
|
||||
{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>
|
||||
</table>
|
||||
|
||||
@ -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() {
|
||||
<td>{p.consultingType}</td>
|
||||
<td>{p.country}</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>{m && <Badge label={m.label} fg={m.fg} bg={m.bg} dot size="sm" />}</td>
|
||||
<td className="text-right">
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user