feat(ui): 직급 라벨(X 컨설턴트)·쪽지함 명칭·검색아이콘 겹침 수정·프로젝트 다중필터·공간활용 강화
All checks were successful
build-and-push / build (push) Successful in 32s
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:
parent
c29e3af9c2
commit
a0911804ee
@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { ChevronDown, User as UserIcon, UserCircle, Settings, LogOut } from "lucide-react";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { avatarUrl, logout } from "@/lib/api";
|
||||
import { rankLabel } from "@/lib/format";
|
||||
|
||||
// 우측 상단 계정 메뉴: 클릭하면 프로필·계정 설정·로그아웃이 펼쳐집니다.
|
||||
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">
|
||||
<Avatar size={32} />
|
||||
<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>
|
||||
<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-[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">
|
||||
{isAdmin ? "관리자" : "구성원"}{rank ? ` · ${rank}` : ""}
|
||||
{isAdmin ? "관리자" : "구성원"}{rank ? ` · ${rankLabel(rank)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
126
src/components/ProjectFilters.tsx
Normal file
126
src/components/ProjectFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -42,7 +42,7 @@ export function Topbar({
|
||||
<ShieldCheck size={13} /> 관리자
|
||||
</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} />
|
||||
{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">
|
||||
|
||||
@ -29,7 +29,11 @@ body {
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
/* ---------- form inputs ---------- */
|
||||
/* ---------- form inputs ----------
|
||||
@layer components 안에 두어 Tailwind utilities(@layer utilities)가
|
||||
이를 덮어쓸 수 있게 한다. 그래야 검색창의 pl-9(아이콘 자리 확보) 같은
|
||||
유틸리티가 form-input의 padding 단축속성에 묻히지 않는다. */
|
||||
@layer components {
|
||||
.form-input,
|
||||
.form-select {
|
||||
height: 2.25rem;
|
||||
@ -54,6 +58,7 @@ body {
|
||||
color: #475467;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- dense accounting tables ---------- */
|
||||
.dense-table {
|
||||
|
||||
@ -4,6 +4,12 @@ export function classNames(...xs: (string | false | null | undefined)[]) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// 직급 표시용 라벨: "주임" → "주임 컨설턴트" (우측 상단·프로필 등 노출 UI).
|
||||
// 편집 드롭다운에서는 원본 rank 값을 그대로 사용한다.
|
||||
export function rankLabel(rank?: string | null): string {
|
||||
return rank ? `${rank} 컨설턴트` : "";
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
|
||||
@ -35,10 +35,11 @@ export function AccountSettingsPage() {
|
||||
if (!member) return <Card className="p-8 text-center text-ink-secondary">구성원 정보가 없습니다. 관리자에게 문의하세요.</Card>;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<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>} />
|
||||
<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>
|
||||
@ -46,8 +47,8 @@ export function AccountSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader title="알림" subtitle="메일함에서 받을 알림 종류" />
|
||||
<Card>
|
||||
<CardHeader title="알림" subtitle="쪽지함에서 받을 알림 종류" />
|
||||
<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.notifyWork} onChange={(v) => setPref("notifyWork", "spin.notify.work", v)} />
|
||||
@ -55,14 +56,15 @@ export function AccountSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader title="화면" />
|
||||
<div className="p-5 divide-y divide-divider">
|
||||
<ToggleRow label="사이드바 기본 접힘" desc="다음 접속부터 메뉴를 접은 상태로 시작" on={prefs.sidebarCollapsed} onChange={(v) => setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<Card>
|
||||
<CardHeader title="보안 · 세션" />
|
||||
<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">
|
||||
@ -73,6 +75,8 @@ export function AccountSettingsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { getDashboard, getMyIncentive } from "@/lib/api";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui";
|
||||
import { IncentiveGaugeCard } from "@/components/IncentiveGauge";
|
||||
import { formatPoints } from "@/lib/format";
|
||||
import { formatPoints, rankLabel } from "@/lib/format";
|
||||
|
||||
// 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고
|
||||
// 회계·전사 위젯은 넣지 않습니다. (전사 현황은 각 관리자 메뉴에서 확인)
|
||||
@ -45,7 +45,7 @@ export function DashboardPage() {
|
||||
<Card>
|
||||
<CardHeader title="내 정보" />
|
||||
<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?.user.email || "—"} />
|
||||
</div>
|
||||
|
||||
@ -13,7 +13,7 @@ const TINT: Record<string, string> = {
|
||||
project: "#175CD3", leave: "#067647", overtime: "#B54708", incentive: "#5925DC", settlement: "#03143F",
|
||||
};
|
||||
|
||||
// 메일함: 프로젝트 추가·휴가/초과근무 승인·인센티브 반영/정산 등 본인 관련 이벤트.
|
||||
// 쪽지함: 프로젝트 추가·휴가/초과근무 승인·인센티브 반영/정산 등 본인 관련 이벤트.
|
||||
export function InboxPage() {
|
||||
const qc = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
@ -37,7 +37,7 @@ export function InboxPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="메일함"
|
||||
title="쪽지함"
|
||||
description="나의 근무·프로젝트·인센티브 관련 알림"
|
||||
action={unread > 0 && <Button variant="secondary" size="sm" icon={<CheckCheck size={15} />} onClick={() => allM.mutate()}>모두 읽음</Button>}
|
||||
/>
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
Card, CardHeader, Stat, PageHeader, Select, LoadingState, EmptyState, Badge,
|
||||
} from "@/components/ui";
|
||||
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);
|
||||
|
||||
@ -23,7 +23,7 @@ export function IncentivePage() {
|
||||
<div>
|
||||
<PageHeader
|
||||
title="내 인센티브"
|
||||
description={`직급 ${d.rank || "—"}`}
|
||||
description={`직급 ${rankLabel(d.rank) || "—"}`}
|
||||
action={
|
||||
<Select value={year} onChange={(e) => setYear(Number(e.target.value))} className="w-28">
|
||||
{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">
|
||||
<Stat label="누적 포인트" value={formatPoints(d.pointsTotal)} 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="할당량 초과분" />
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useRef, useState } from "react";
|
||||
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 { useAuth } from "@/context/Auth";
|
||||
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() {
|
||||
const { me, loading } = useAuth();
|
||||
@ -25,60 +25,73 @@ export function ProfilePage() {
|
||||
const avatar = avatarUrl(member.id, member.avatarKey);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div>
|
||||
<PageHeader title="내 프로필" description="기본 정보는 관리자/Keycloak가 관리하며, 사진·연락처 등 일부만 직접 수정할 수 있습니다." />
|
||||
|
||||
<Card>
|
||||
<CardHeader title="기본 정보" />
|
||||
<div className="p-5 flex gap-6">
|
||||
<div className="shrink-0 flex flex-col items-center gap-2">
|
||||
{/* 넓은 화면에서는 좌측 신원 카드 + 우측 상세/연락처로 공간을 가득 채운다. */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
|
||||
{/* 신원 카드 */}
|
||||
<Card className="p-6 flex flex-col items-center text-center lg:sticky lg:top-20">
|
||||
<div className="relative">
|
||||
{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">
|
||||
{member.displayName.slice(0, 1) || <UserIcon size={28} />}
|
||||
<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={32} />}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
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="프로필 사진 변경"
|
||||
>
|
||||
<Camera size={15} />
|
||||
<Camera size={16} />
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) avatarM.mutate(f); }} />
|
||||
</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 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} />
|
||||
<Info label="이메일" value={member.email} />
|
||||
<Info label="직급" value={member.rank ? <Badge label={member.rank} fg="#03143F" bg="#E9ECF3" /> : "미지정"} />
|
||||
<Info label="부서" value={dept || "미지정"} />
|
||||
<Info label="권한" value={member.role === "admin" ? "관리자" : "구성원"} />
|
||||
<Info label="입사일" value={formatDate(member.joinDate)} />
|
||||
</div>
|
||||
{/* 상세 + 연락처 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card>
|
||||
<CardHeader title="기본 정보" subtitle="관리자/Keycloak에서 관리" />
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-1">
|
||||
<Info icon={<UserIcon size={15} />} label="이름" value={member.displayName} />
|
||||
<Info icon={<Mail size={15} />} label="이메일" value={member.email} />
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<Card>
|
||||
<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">
|
||||
<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>
|
||||
</Card>
|
||||
</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 (
|
||||
<div className="flex py-2.5 border-b border-divider">
|
||||
<div className="w-20 shrink-0 text-sm text-ink-muted">{label}</div>
|
||||
<div className="text-sm text-ink">{value || "—"}</div>
|
||||
<div className="flex items-center gap-2.5 py-2.5 border-b border-divider min-w-0">
|
||||
<span className="text-ink-muted shrink-0">{icon}</span>
|
||||
<span className="w-14 shrink-0 text-xs text-ink-muted">{label}</span>
|
||||
<span className="text-sm text-ink truncate">{value || "—"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, FolderKanban } from "lucide-react";
|
||||
import { FolderKanban } from "lucide-react";
|
||||
import {
|
||||
getProjects, createProject, getCompanies, getProducts, getVersions,
|
||||
createCompany, createProduct, createVersion,
|
||||
@ -10,18 +10,14 @@ import {
|
||||
Card, Button, Badge, PageHeader, Modal, Field, Input, Select, Textarea,
|
||||
EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import { useProjectFilters } from "@/components/ProjectFilters";
|
||||
import { formatDate, PROJECT_STATUS_META } from "@/lib/format";
|
||||
|
||||
// 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일).
|
||||
// 생성/관리는 관리자 전용 페이지(/admin/projects)에서만 가능.
|
||||
export function ProjectsPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
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())
|
||||
);
|
||||
const projQ = useQuery({ queryKey: ["projects", "mine"], queryFn: () => getProjects({ scope: "mine" }) });
|
||||
const { filtered, bar } = useProjectFilters(projQ.data ?? []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -30,16 +26,7 @@ export function ProjectsPage() {
|
||||
description="내가 참여 중인 프로젝트입니다. (생성·관리는 관리자 전용)"
|
||||
/>
|
||||
|
||||
<Card className="mb-4 p-3 flex flex-wrap items-center gap-3">
|
||||
<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>
|
||||
{bar}
|
||||
|
||||
{projQ.isLoading ? <LoadingState /> : filtered.length === 0 ? (
|
||||
<EmptyState title="참여 중인 프로젝트가 없습니다" icon={<FolderKanban size={28} />} description="프로젝트에 배정되면 여기에 표시됩니다." />
|
||||
|
||||
@ -1,31 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 { CreateProjectModal } from "@/pages/Projects";
|
||||
import {
|
||||
Card, Button, Badge, PageHeader, EmptyState, LoadingState, Select,
|
||||
Card, Button, Badge, PageHeader, EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import { useProjectFilters } from "@/components/ProjectFilters";
|
||||
import { formatDate, PROJECT_STATUS_META } from "@/lib/format";
|
||||
|
||||
// 관리자 전용 프로젝트 관리창: 전체 프로젝트 생성·조회·삭제. 세부 관리(작업자·계약·
|
||||
// 분할입금·태스크 등)는 각 프로젝트 상세 페이지의 탭에서 수행.
|
||||
export function ProjectsAdminPage() {
|
||||
const qc = useQueryClient();
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
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({
|
||||
mutationFn: (id: string) => deleteProject(id),
|
||||
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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
@ -34,16 +30,7 @@ export function ProjectsAdminPage() {
|
||||
action={<Button icon={<Plus size={16} />} onClick={() => setOpen(true)}>프로젝트 생성</Button>}
|
||||
/>
|
||||
|
||||
<Card className="mb-4 p-3 flex flex-wrap items-center gap-3">
|
||||
<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>
|
||||
{bar}
|
||||
|
||||
<Card>
|
||||
{projQ.isLoading ? <LoadingState /> : rows.length === 0 ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user