feat(ui): 직급 라벨(X 컨설턴트)·쪽지함 명칭·검색아이콘 겹침 수정·프로젝트 다중필터·공간활용 강화
All checks were successful
build-and-push / build (push) Successful in 32s

- 우측 상단/프로필/대시보드/인센티브 직급을 "주임 컨설턴트"식 라벨로 표시(rankLabel)
- form-input을 @layer components로 이동 → 검색창 pl-9가 padding 단축속성에 묻히던 아이콘 겹침 전역 해결
- 프로젝트 목록(유저/관리자 공용) 다중 필터: 검색+상태+업체+컨설팅+국가+범위+PM, 초기화 (ProjectFilters)
- 메일함 → 쪽지함 명칭 통일(Topbar/Inbox/AccountSettings)
- 내 프로필: 전폭 반응형 레이아웃(신원 카드 + 상세 3열) 공간 활용
- 계정 설정: 2열 반응형 그리드로 넓은 화면 공간 활용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-29 07:33:29 +09:00
parent c29e3af9c2
commit a0911804ee
12 changed files with 279 additions and 150 deletions

View File

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { ChevronDown, User as UserIcon, UserCircle, Settings, LogOut } from "lucide-react"; import { ChevronDown, User as UserIcon, UserCircle, Settings, LogOut } from "lucide-react";
import { useAuth } from "@/context/Auth"; import { useAuth } from "@/context/Auth";
import { avatarUrl, logout } from "@/lib/api"; import { avatarUrl, logout } from "@/lib/api";
import { rankLabel } from "@/lib/format";
// 우측 상단 계정 메뉴: 클릭하면 프로필·계정 설정·로그아웃이 펼쳐집니다. // 우측 상단 계정 메뉴: 클릭하면 프로필·계정 설정·로그아웃이 펼쳐집니다.
export function AccountMenu() { export function AccountMenu() {
@ -36,7 +37,7 @@ export function AccountMenu() {
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-2.5 pl-1 pr-1 sm:pr-2 py-1 rounded-control hover:bg-canvas transition-colors"> <button onClick={() => setOpen((o) => !o)} className="flex items-center gap-2.5 pl-1 pr-1 sm:pr-2 py-1 rounded-control hover:bg-canvas transition-colors">
<Avatar size={32} /> <Avatar size={32} />
<div className="hidden sm:block leading-tight text-left"> <div className="hidden sm:block leading-tight text-left">
<div className="text-sm font-semibold text-ink">{name}{rank ? ` · ${rank}` : ""}</div> <div className="text-sm font-semibold text-ink">{name}{rank ? ` · ${rankLabel(rank)}` : ""}</div>
<div className="text-[11px] text-ink-muted">{email}</div> <div className="text-[11px] text-ink-muted">{email}</div>
</div> </div>
<ChevronDown size={15} className="hidden sm:block text-ink-muted" /> <ChevronDown size={15} className="hidden sm:block text-ink-muted" />
@ -50,7 +51,7 @@ export function AccountMenu() {
<div className="text-sm font-bold text-ink truncate">{name}</div> <div className="text-sm font-bold text-ink truncate">{name}</div>
<div className="text-[11px] text-ink-muted truncate">{email}</div> <div className="text-[11px] text-ink-muted truncate">{email}</div>
<div className="mt-1 inline-flex items-center text-[10px] font-medium text-navy bg-navy-subtle rounded-pill px-1.5 py-0.5"> <div className="mt-1 inline-flex items-center text-[10px] font-medium text-navy bg-navy-subtle rounded-pill px-1.5 py-0.5">
{isAdmin ? "관리자" : "구성원"}{rank ? ` · ${rank}` : ""} {isAdmin ? "관리자" : "구성원"}{rank ? ` · ${rankLabel(rank)}` : ""}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,126 @@
import { useMemo, useState } from "react";
import { Search, X } from "lucide-react";
import { Card, Select } from "@/components/ui";
import { PROJECT_STATUS_META } from "@/lib/format";
import type { Project } from "@/types";
// 프로젝트 목록 다중 필터 — 유저(나의 프로젝트)·관리자(전체) 양쪽 공용.
// 데이터는 전부 로드한 뒤 클라이언트에서 필터링하므로 옵션 목록도 로드된 데이터에서 파생된다.
const SCOPE_OPTS = [
{ v: "text", label: "글" },
{ v: "graphic", label: "그림" },
{ v: "both", label: "글+그림" },
];
const uniq = (xs: (string | undefined | null)[]) =>
Array.from(new Set(xs.filter((x): x is string => !!x))).sort((a, b) => a.localeCompare(b, "ko"));
export function useProjectFilters(projects: Project[]) {
const [q, setQ] = useState("");
const [status, setStatus] = useState("");
const [company, setCompany] = useState("");
const [consultingType, setConsultingType] = useState("");
const [country, setCountry] = useState("");
const [scope, setScope] = useState("");
const [pm, setPm] = useState("");
const companies = useMemo(() => uniq(projects.map((p) => p.companyName)), [projects]);
const types = useMemo(() => uniq(projects.map((p) => p.consultingType)), [projects]);
const countries = useMemo(() => uniq(projects.map((p) => p.country)), [projects]);
const pms = useMemo(() => uniq(projects.map((p) => p.pmEmail)), [projects]);
const filtered = useMemo(
() =>
projects.filter((p) => {
if (q) {
const t = q.toLowerCase();
const hay = [p.name, p.companyName, p.productName, p.versionName].join(" ").toLowerCase();
if (!hay.includes(t)) return false;
}
if (status && p.status !== status) return false;
if (company && p.companyName !== company) return false;
if (consultingType && p.consultingType !== consultingType) return false;
if (country && p.country !== country) return false;
if (pm && p.pmEmail !== pm) return false;
if (scope) {
const hasText = !!p.scopeText;
const hasGraphic = !!p.scopeGraphic;
if (scope === "text" && !hasText) return false;
if (scope === "graphic" && !hasGraphic) return false;
if (scope === "both" && !(hasText && hasGraphic)) return false;
}
return true;
}),
[projects, q, status, company, consultingType, country, pm, scope]
);
const activeCount = [status, company, consultingType, country, scope, pm].filter(Boolean).length;
const reset = () => {
setQ("");
setStatus("");
setCompany("");
setConsultingType("");
setCountry("");
setScope("");
setPm("");
};
const bar = (
<Card className="mb-4 p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px]">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-muted pointer-events-none" />
<input
className="form-input pl-9"
placeholder="프로젝트·업체·제품 검색"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<FilterSelect value={status} onChange={setStatus} all="전체 상태"
options={Object.entries(PROJECT_STATUS_META).map(([k, v]) => ({ v: k, label: v.label }))} />
<FilterSelect value={company} onChange={setCompany} all="전체 업체"
options={companies.map((c) => ({ v: c, label: c }))} />
<FilterSelect value={consultingType} onChange={setConsultingType} all="전체 컨설팅"
options={types.map((c) => ({ v: c, label: c }))} />
<FilterSelect value={country} onChange={setCountry} all="전체 국가"
options={countries.map((c) => ({ v: c, label: c }))} />
<FilterSelect value={scope} onChange={setScope} all="전체 범위" options={SCOPE_OPTS} />
<FilterSelect value={pm} onChange={setPm} all="전체 PM"
options={pms.map((c) => ({ v: c, label: c.split("@")[0] }))} />
{activeCount > 0 && (
<button
onClick={reset}
className="inline-flex items-center gap-1 text-xs font-medium text-ink-secondary hover:text-ink px-2.5 h-9 rounded-control hover:bg-canvas"
>
<X size={13} /> ({activeCount})
</button>
)}
</div>
</Card>
);
return { filtered, bar };
}
function FilterSelect({
value, onChange, all, options,
}: {
value: string;
onChange: (v: string) => void;
all: string;
options: { v: string; label: string }[];
}) {
return (
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-auto min-w-[120px] flex-1 sm:flex-none sm:w-36"
>
<option value="">{all}</option>
{options.map((o) => (
<option key={o.v} value={o.v}>{o.label}</option>
))}
</Select>
);
}

View File

@ -42,7 +42,7 @@ export function Topbar({
<ShieldCheck size={13} /> <ShieldCheck size={13} />
</span> </span>
)} )}
<Link to="/inbox" title="메일함" className="relative p-2 rounded-control text-ink-secondary hover:bg-canvas hover:text-ink transition-colors"> <Link to="/inbox" title="쪽지함" className="relative p-2 rounded-control text-ink-secondary hover:bg-canvas hover:text-ink transition-colors">
<Bell size={18} /> <Bell size={18} />
{unread > 0 && ( {unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-pill bg-[#F04438] text-white text-[10px] font-bold flex items-center justify-center font-num"> <span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-pill bg-[#F04438] text-white text-[10px] font-bold flex items-center justify-center font-num">

View File

@ -29,7 +29,11 @@ body {
font-feature-settings: "tnum"; font-feature-settings: "tnum";
} }
/* ---------- form inputs ---------- */ /* ---------- form inputs ----------
@layer components 안에 두어 Tailwind utilities(@layer utilities)
이를 덮어쓸 있게 한다. 그래야 검색창의 pl-9(아이콘 자리 확보) 같은
유틸리티가 form-input의 padding 단축속성에 묻히지 않는다. */
@layer components {
.form-input, .form-input,
.form-select { .form-select {
height: 2.25rem; height: 2.25rem;
@ -54,6 +58,7 @@ body {
color: #475467; color: #475467;
margin-bottom: 4px; margin-bottom: 4px;
} }
}
/* ---------- dense accounting tables ---------- */ /* ---------- dense accounting tables ---------- */
.dense-table { .dense-table {

View File

@ -4,6 +4,12 @@ export function classNames(...xs: (string | false | null | undefined)[]) {
return xs.filter(Boolean).join(" "); return xs.filter(Boolean).join(" ");
} }
// 직급 표시용 라벨: "주임" → "주임 컨설턴트" (우측 상단·프로필 등 노출 UI).
// 편집 드롭다운에서는 원본 rank 값을 그대로 사용한다.
export function rankLabel(rank?: string | null): string {
return rank ? `${rank} 컨설턴트` : "";
}
export function formatDate(value?: string | null): string { export function formatDate(value?: string | null): string {
if (!value) return "—"; if (!value) return "—";
const d = new Date(value); const d = new Date(value);

View File

@ -35,10 +35,11 @@ export function AccountSettingsPage() {
if (!member) return <Card className="p-8 text-center text-ink-secondary"> . .</Card>; if (!member) return <Card className="p-8 text-center text-ink-secondary"> . .</Card>;
return ( return (
<div className="max-w-2xl"> <div>
<PageHeader title="계정 설정" description="표시 이름과 알림·화면 환경을 설정합니다. 비밀번호 등 보안은 Keycloak 계정에서 관리됩니다." /> <PageHeader title="계정 설정" description="표시 이름과 알림·화면 환경을 설정합니다. 비밀번호 등 보안은 Keycloak 계정에서 관리됩니다." />
<Card> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
<Card className="lg:col-span-2">
<CardHeader title="기본" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}></Button>} /> <CardHeader title="기본" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}></Button>} />
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="표시 이름"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} /></Field> <Field label="표시 이름"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} /></Field>
@ -46,8 +47,8 @@ export function AccountSettingsPage() {
</div> </div>
</Card> </Card>
<Card className="mt-4"> <Card>
<CardHeader title="알림" subtitle="메일함에서 받을 알림 종류" /> <CardHeader title="알림" subtitle="쪽지함에서 받을 알림 종류" />
<div className="p-5 divide-y divide-divider"> <div className="p-5 divide-y divide-divider">
<ToggleRow label="프로젝트 알림" desc="프로젝트 추가·변경 알림" on={prefs.notifyProject} onChange={(v) => setPref("notifyProject", "spin.notify.project", v)} /> <ToggleRow label="프로젝트 알림" desc="프로젝트 추가·변경 알림" on={prefs.notifyProject} onChange={(v) => setPref("notifyProject", "spin.notify.project", v)} />
<ToggleRow label="근무 알림" desc="휴가·초과근무 승인/반려 알림" on={prefs.notifyWork} onChange={(v) => setPref("notifyWork", "spin.notify.work", v)} /> <ToggleRow label="근무 알림" desc="휴가·초과근무 승인/반려 알림" on={prefs.notifyWork} onChange={(v) => setPref("notifyWork", "spin.notify.work", v)} />
@ -55,14 +56,15 @@ export function AccountSettingsPage() {
</div> </div>
</Card> </Card>
<Card className="mt-4"> <div className="space-y-4">
<Card>
<CardHeader title="화면" /> <CardHeader title="화면" />
<div className="p-5 divide-y divide-divider"> <div className="p-5 divide-y divide-divider">
<ToggleRow label="사이드바 기본 접힘" desc="다음 접속부터 메뉴를 접은 상태로 시작" on={prefs.sidebarCollapsed} onChange={(v) => setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} /> <ToggleRow label="사이드바 기본 접힘" desc="다음 접속부터 메뉴를 접은 상태로 시작" on={prefs.sidebarCollapsed} onChange={(v) => setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} />
</div> </div>
</Card> </Card>
<Card className="mt-4"> <Card>
<CardHeader title="보안 · 세션" /> <CardHeader title="보안 · 세션" />
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
<div className="flex items-start gap-3 text-sm text-ink-secondary bg-canvas rounded-control p-4"> <div className="flex items-start gap-3 text-sm text-ink-secondary bg-canvas rounded-control p-4">
@ -73,6 +75,8 @@ export function AccountSettingsPage() {
</div> </div>
</Card> </Card>
</div> </div>
</div>
</div>
); );
} }

View File

@ -5,7 +5,7 @@ import { getDashboard, getMyIncentive } from "@/lib/api";
import { useAuth } from "@/context/Auth"; import { useAuth } from "@/context/Auth";
import { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui"; import { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui";
import { IncentiveGaugeCard } from "@/components/IncentiveGauge"; import { IncentiveGaugeCard } from "@/components/IncentiveGauge";
import { formatPoints } from "@/lib/format"; import { formatPoints, rankLabel } from "@/lib/format";
// 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고 // 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고
// 회계·전사 위젯은 넣지 않습니다. (전사 현황은 각 관리자 메뉴에서 확인) // 회계·전사 위젯은 넣지 않습니다. (전사 현황은 각 관리자 메뉴에서 확인)
@ -45,7 +45,7 @@ export function DashboardPage() {
<Card> <Card>
<CardHeader title="내 정보" /> <CardHeader title="내 정보" />
<div className="p-5 space-y-2.5"> <div className="p-5 space-y-2.5">
<InfoRow label="직급" value={me?.member?.rank || "—"} /> <InfoRow label="직급" value={rankLabel(me?.member?.rank) || "—"} />
<InfoRow label="권한" value={me?.isAdmin ? "관리자" : "구성원"} /> <InfoRow label="권한" value={me?.isAdmin ? "관리자" : "구성원"} />
<InfoRow label="이메일" value={me?.user.email || "—"} /> <InfoRow label="이메일" value={me?.user.email || "—"} />
</div> </div>

View File

@ -13,7 +13,7 @@ const TINT: Record<string, string> = {
project: "#175CD3", leave: "#067647", overtime: "#B54708", incentive: "#5925DC", settlement: "#03143F", project: "#175CD3", leave: "#067647", overtime: "#B54708", incentive: "#5925DC", settlement: "#03143F",
}; };
// 메일함: 프로젝트 추가·휴가/초과근무 승인·인센티브 반영/정산 등 본인 관련 이벤트. // 쪽지함: 프로젝트 추가·휴가/초과근무 승인·인센티브 반영/정산 등 본인 관련 이벤트.
export function InboxPage() { export function InboxPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const nav = useNavigate(); const nav = useNavigate();
@ -37,7 +37,7 @@ export function InboxPage() {
return ( return (
<div> <div>
<PageHeader <PageHeader
title="메일함" title="쪽지함"
description="나의 근무·프로젝트·인센티브 관련 알림" description="나의 근무·프로젝트·인센티브 관련 알림"
action={unread > 0 && <Button variant="secondary" size="sm" icon={<CheckCheck size={15} />} onClick={() => allM.mutate()}> </Button>} action={unread > 0 && <Button variant="secondary" size="sm" icon={<CheckCheck size={15} />} onClick={() => allM.mutate()}> </Button>}
/> />

View File

@ -6,7 +6,7 @@ import {
Card, CardHeader, Stat, PageHeader, Select, LoadingState, EmptyState, Badge, Card, CardHeader, Stat, PageHeader, Select, LoadingState, EmptyState, Badge,
} from "@/components/ui"; } from "@/components/ui";
import { IncentiveGaugeCard } from "@/components/IncentiveGauge"; import { IncentiveGaugeCard } from "@/components/IncentiveGauge";
import { formatPoints, formatWon, FIX_STATUS_META, STAGE_KIND_LABELS } from "@/lib/format"; import { formatPoints, formatWon, rankLabel, FIX_STATUS_META, STAGE_KIND_LABELS } from "@/lib/format";
const YEARS = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i); const YEARS = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
@ -23,7 +23,7 @@ export function IncentivePage() {
<div> <div>
<PageHeader <PageHeader
title="내 인센티브" title="내 인센티브"
description={`직급 ${d.rank || "—"}`} description={`직급 ${rankLabel(d.rank) || "—"}`}
action={ action={
<Select value={year} onChange={(e) => setYear(Number(e.target.value))} className="w-28"> <Select value={year} onChange={(e) => setYear(Number(e.target.value))} className="w-28">
{YEARS.map((y) => <option key={y} value={y}>{y}</option>)} {YEARS.map((y) => <option key={y} value={y}>{y}</option>)}
@ -36,7 +36,7 @@ export function IncentivePage() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Stat label="누적 포인트" value={formatPoints(d.pointsTotal)} sub="예정 포함" /> <Stat label="누적 포인트" value={formatPoints(d.pointsTotal)} sub="예정 포함" />
<Stat label="반영완료 포인트" value={formatPoints(d.pointsApplied)} accent="#12B76A" sub="정산 기준" /> <Stat label="반영완료 포인트" value={formatPoints(d.pointsApplied)} accent="#12B76A" sub="정산 기준" />
<Stat label="직급 할당량" value={formatPoints(d.quota)} sub={`${d.rank || "—"} 기준`} /> <Stat label="직급 할당량" value={formatPoints(d.quota)} sub={`${rankLabel(d.rank) || "—"} 기준`} />
<Stat label="예상 인센티브" value={formatWon(d.estPayout)} accent="#067647" sub="할당량 초과분" /> <Stat label="예상 인센티브" value={formatWon(d.estPayout)} accent="#067647" sub="할당량 초과분" />
</div> </div>

View File

@ -1,10 +1,10 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, User as UserIcon } from "lucide-react"; import { Camera, User as UserIcon, Mail, Building2, BadgeCheck, CalendarDays, ShieldCheck } from "lucide-react";
import { updateMember, uploadAvatar, avatarUrl, getDepartments } from "@/lib/api"; import { updateMember, uploadAvatar, avatarUrl, getDepartments } from "@/lib/api";
import { useAuth } from "@/context/Auth"; import { useAuth } from "@/context/Auth";
import { Card, CardHeader, Button, Field, Input, PageHeader, Badge, LoadingState } from "@/components/ui"; import { Card, CardHeader, Button, Field, Input, PageHeader, Badge, LoadingState } from "@/components/ui";
import { formatDate } from "@/lib/format"; import { formatDate, rankLabel } from "@/lib/format";
export function ProfilePage() { export function ProfilePage() {
const { me, loading } = useAuth(); const { me, loading } = useAuth();
@ -25,60 +25,73 @@ export function ProfilePage() {
const avatar = avatarUrl(member.id, member.avatarKey); const avatar = avatarUrl(member.id, member.avatarKey);
return ( return (
<div className="max-w-2xl"> <div>
<PageHeader title="내 프로필" description="기본 정보는 관리자/Keycloak가 관리하며, 사진·연락처 등 일부만 직접 수정할 수 있습니다." /> <PageHeader title="내 프로필" description="기본 정보는 관리자/Keycloak가 관리하며, 사진·연락처 등 일부만 직접 수정할 수 있습니다." />
<Card> {/* 넓은 화면에서는 좌측 신원 카드 + 우측 상세/연락처로 공간을 가득 채운다. */}
<CardHeader title="기본 정보" /> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
<div className="p-5 flex gap-6"> {/* 신원 카드 */}
<div className="shrink-0 flex flex-col items-center gap-2"> <Card className="p-6 flex flex-col items-center text-center lg:sticky lg:top-20">
<div className="relative"> <div className="relative">
{avatar ? ( {avatar ? (
<img src={avatar} alt={member.displayName} className="w-24 h-24 rounded-full object-cover border border-border" /> <img src={avatar} alt={member.displayName} className="w-28 h-28 rounded-full object-cover border border-border" />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-navy text-white flex items-center justify-center text-3xl font-bold"> <div className="w-28 h-28 rounded-full bg-navy text-white flex items-center justify-center text-4xl font-bold">
{member.displayName.slice(0, 1) || <UserIcon size={28} />} {member.displayName.slice(0, 1) || <UserIcon size={32} />}
</div> </div>
)} )}
<button <button
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
className="absolute bottom-0 right-0 w-8 h-8 rounded-full bg-navy text-white flex items-center justify-center border-2 border-surface hover:bg-navy-hover" className="absolute bottom-0 right-0 w-9 h-9 rounded-full bg-navy text-white flex items-center justify-center border-2 border-surface hover:bg-navy-hover"
title="프로필 사진 변경" title="프로필 사진 변경"
> >
<Camera size={15} /> <Camera size={16} />
</button> </button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" <input ref={fileRef} type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) avatarM.mutate(f); }} /> onChange={(e) => { const f = e.target.files?.[0]; if (f) avatarM.mutate(f); }} />
</div> </div>
<span className="text-xs text-ink-muted">{avatarM.isPending ? "업로드 중…" : "사진 변경"}</span> <div className="mt-4 text-lg font-bold text-ink">{member.displayName}</div>
<div className="text-sm text-ink-muted">{member.email}</div>
<div className="mt-3 flex flex-wrap items-center justify-center gap-1.5">
{member.rank && <Badge label={rankLabel(member.rank)} fg="#03143F" bg="#E9ECF3" />}
<Badge label={member.role === "admin" ? "관리자" : "구성원"} fg="#175CD3" bg="#D1E9FF" />
</div> </div>
<div className="mt-1 text-xs text-ink-muted">{avatarM.isPending ? "사진 업로드 중…" : ""}</div>
</Card>
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1"> {/* 상세 + 연락처 */}
<Info label="이름" value={member.displayName} /> <div className="lg:col-span-2 space-y-4">
<Info label="이메일" value={member.email} /> <Card>
<Info label="직급" value={member.rank ? <Badge label={member.rank} fg="#03143F" bg="#E9ECF3" /> : "미지정"} /> <CardHeader title="기본 정보" subtitle="관리자/Keycloak에서 관리" />
<Info label="부서" value={dept || "미지정"} /> <div className="p-5 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-1">
<Info label="권한" value={member.role === "admin" ? "관리자" : "구성원"} /> <Info icon={<UserIcon size={15} />} label="이름" value={member.displayName} />
<Info label="입사일" value={formatDate(member.joinDate)} /> <Info icon={<Mail size={15} />} label="이메일" value={member.email} />
</div> <Info icon={<BadgeCheck size={15} />} label="직급" value={rankLabel(member.rank) || "미지정"} />
<Info icon={<Building2 size={15} />} label="부서" value={dept || "미지정"} />
<Info icon={<ShieldCheck size={15} />} label="권한" value={member.role === "admin" ? "관리자" : "구성원"} />
<Info icon={<CalendarDays size={15} />} label="입사일" value={formatDate(member.joinDate)} />
</div> </div>
</Card> </Card>
<Card className="mt-4"> <Card>
<CardHeader title="연락처 정보" subtitle="직접 수정 가능" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}></Button>} /> <CardHeader title="연락처 정보" subtitle="직접 수정 가능" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}></Button>} />
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="전화번호"><Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="010-0000-0000" /></Field> <Field label="전화번호"><Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="010-0000-0000" /></Field>
<Field label="이메일"><Input value={member.email} disabled /></Field>
</div> </div>
</Card> </Card>
</div> </div>
</div>
</div>
); );
} }
function Info({ label, value }: { label: string; value: React.ReactNode }) { function Info({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return ( return (
<div className="flex py-2.5 border-b border-divider"> <div className="flex items-center gap-2.5 py-2.5 border-b border-divider min-w-0">
<div className="w-20 shrink-0 text-sm text-ink-muted">{label}</div> <span className="text-ink-muted shrink-0">{icon}</span>
<div className="text-sm text-ink">{value || "—"}</div> <span className="w-14 shrink-0 text-xs text-ink-muted">{label}</span>
<span className="text-sm text-ink truncate">{value || "—"}</span>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Search, FolderKanban } from "lucide-react"; import { FolderKanban } from "lucide-react";
import { import {
getProjects, createProject, getCompanies, getProducts, getVersions, getProjects, createProject, getCompanies, getProducts, getVersions,
createCompany, createProduct, createVersion, createCompany, createProduct, createVersion,
@ -10,18 +10,14 @@ import {
Card, Button, Badge, PageHeader, Modal, Field, Input, Select, Textarea, Card, Button, Badge, PageHeader, Modal, Field, Input, Select, Textarea,
EmptyState, LoadingState, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { useProjectFilters } from "@/components/ProjectFilters";
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 [q, setQ] = useState(""); const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }) });
const [status, setStatus] = useState(""); const { filtered, bar } = useProjectFilters(projQ.data ?? []);
const projQ = useQuery({ queryKey: ["projects", "mine", status], queryFn: () => getProjects({ scope: "mine", status: status || undefined }) });
const filtered = (projQ.data ?? []).filter((p) =>
!q || p.name.toLowerCase().includes(q.toLowerCase()) || p.companyName.toLowerCase().includes(q.toLowerCase())
);
return ( return (
<div> <div>
@ -30,16 +26,7 @@ export function ProjectsPage() {
description="내가 참여 중인 프로젝트입니다. (생성·관리는 관리자 전용)" description="내가 참여 중인 프로젝트입니다. (생성·관리는 관리자 전용)"
/> />
<Card className="mb-4 p-3 flex flex-wrap items-center gap-3"> {bar}
<div className="relative flex-1 min-w-[220px]">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-muted" />
<input className="form-input pl-9" placeholder="프로젝트·업체명 검색" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<Select value={status} onChange={(e) => setStatus(e.target.value)} className="w-40">
<option value=""> </option>
{Object.entries(PROJECT_STATUS_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</Select>
</Card>
{projQ.isLoading ? <LoadingState /> : filtered.length === 0 ? ( {projQ.isLoading ? <LoadingState /> : filtered.length === 0 ? (
<EmptyState title="참여 중인 프로젝트가 없습니다" icon={<FolderKanban size={28} />} description="프로젝트에 배정되면 여기에 표시됩니다." /> <EmptyState title="참여 중인 프로젝트가 없습니다" icon={<FolderKanban size={28} />} description="프로젝트에 배정되면 여기에 표시됩니다." />

View File

@ -1,31 +1,27 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, Trash2, ExternalLink, FolderCog } from "lucide-react"; import { Plus, Trash2, ExternalLink, FolderCog } from "lucide-react";
import { getProjects, deleteProject } from "@/lib/api"; import { getProjects, deleteProject } from "@/lib/api";
import { CreateProjectModal } from "@/pages/Projects"; import { CreateProjectModal } from "@/pages/Projects";
import { import {
Card, Button, Badge, PageHeader, EmptyState, LoadingState, Select, Card, Button, Badge, PageHeader, EmptyState, LoadingState,
} from "@/components/ui"; } from "@/components/ui";
import { useProjectFilters } from "@/components/ProjectFilters";
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 [q, setQ] = useState("");
const [status, setStatus] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const projQ = useQuery({ queryKey: ["projects", "all", status], queryFn: () => getProjects({ status: status || undefined }) }); const projQ = useQuery({ queryKey: ["projects", "all"], queryFn: () => getProjects() });
const { filtered: rows, bar } = useProjectFilters(projQ.data ?? []);
const del = useMutation({ const del = useMutation({
mutationFn: (id: string) => deleteProject(id), mutationFn: (id: string) => deleteProject(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }),
}); });
const rows = (projQ.data ?? []).filter((p) =>
!q || p.name.toLowerCase().includes(q.toLowerCase()) || p.companyName.toLowerCase().includes(q.toLowerCase())
);
return ( return (
<div> <div>
<PageHeader <PageHeader
@ -34,16 +30,7 @@ export function ProjectsAdminPage() {
action={<Button icon={<Plus size={16} />} onClick={() => setOpen(true)}> </Button>} action={<Button icon={<Plus size={16} />} onClick={() => setOpen(true)}> </Button>}
/> />
<Card className="mb-4 p-3 flex flex-wrap items-center gap-3"> {bar}
<div className="relative flex-1 min-w-[220px]">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-muted" />
<input className="form-input pl-9" placeholder="프로젝트·업체명 검색" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<Select value={status} onChange={(e) => setStatus(e.target.value)} className="w-40">
<option value=""> </option>
{Object.entries(PROJECT_STATUS_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</Select>
</Card>
<Card> <Card>
{projQ.isLoading ? <LoadingState /> : rows.length === 0 ? ( {projQ.isLoading ? <LoadingState /> : rows.length === 0 ? (