diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx index b1d1c31..ee33fd6 100644 --- a/src/components/AccountMenu.tsx +++ b/src/components/AccountMenu.tsx @@ -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() { + )} + + + ); + + return { filtered, bar }; +} + +function FilterSelect({ + value, onChange, all, options, +}: { + value: string; + onChange: (v: string) => void; + all: string; + options: { v: string; label: string }[]; +}) { + return ( + + ); +} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index ab393c6..d27ad59 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -42,7 +42,7 @@ export function Topbar({ 관리자 )} - + {unread > 0 && ( diff --git a/src/index.css b/src/index.css index 8a74e27..88d97e6 100644 --- a/src/index.css +++ b/src/index.css @@ -29,30 +29,35 @@ body { font-feature-settings: "tnum"; } -/* ---------- form inputs ---------- */ -.form-input, -.form-select { - height: 2.25rem; - padding: 0 0.75rem; - font-size: 13px; - background: #ffffff; - border: 1px solid #d0d5dd; - border-radius: 8px; - width: 100%; - color: #101828; -} -.form-input:focus, -.form-select:focus { - outline: none; - border-color: #03143f; - box-shadow: 0 0 0 3px rgba(3, 20, 63, 0.12); -} -.form-label { - display: block; - font-size: 12px; - font-weight: 600; - color: #475467; - margin-bottom: 4px; +/* ---------- form inputs ---------- + @layer components 안에 두어 Tailwind utilities(@layer utilities)가 + 이를 덮어쓸 수 있게 한다. 그래야 검색창의 pl-9(아이콘 자리 확보) 같은 + 유틸리티가 form-input의 padding 단축속성에 묻히지 않는다. */ +@layer components { + .form-input, + .form-select { + height: 2.25rem; + padding: 0 0.75rem; + font-size: 13px; + background: #ffffff; + border: 1px solid #d0d5dd; + border-radius: 8px; + width: 100%; + color: #101828; + } + .form-input:focus, + .form-select:focus { + outline: none; + border-color: #03143f; + box-shadow: 0 0 0 3px rgba(3, 20, 63, 0.12); + } + .form-label { + display: block; + font-size: 12px; + font-weight: 600; + color: #475467; + margin-bottom: 4px; + } } /* ---------- dense accounting tables ---------- */ diff --git a/src/lib/format.ts b/src/lib/format.ts index e83d036..73ae01b 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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); diff --git a/src/pages/AccountSettings.tsx b/src/pages/AccountSettings.tsx index ee869cd..e2298ff 100644 --- a/src/pages/AccountSettings.tsx +++ b/src/pages/AccountSettings.tsx @@ -35,43 +35,47 @@ export function AccountSettingsPage() { if (!member) return 구성원 정보가 없습니다. 관리자에게 문의하세요.; return ( -
+
- - save.mutate()} disabled={save.isPending}>저장} /> -
- setDisplayName(e.target.value)} /> - -
-
- - - -
- setPref("notifyProject", "spin.notify.project", v)} /> - setPref("notifyWork", "spin.notify.work", v)} /> - setPref("notifyIncentive", "spin.notify.incentive", v)} /> -
-
- - - -
- setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} /> -
-
- - - -
-
- -

비밀번호 변경·2단계 인증 등 계정 보안과 계정 생성/삭제는 Keycloak 계정에서 관리됩니다.

+
+ + save.mutate()} disabled={save.isPending}>저장} /> +
+ setDisplayName(e.target.value)} /> +
- +
+ + + +
+ setPref("notifyProject", "spin.notify.project", v)} /> + setPref("notifyWork", "spin.notify.work", v)} /> + setPref("notifyIncentive", "spin.notify.incentive", v)} /> +
+
+ +
+ + +
+ setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} /> +
+
+ + + +
+
+ +

비밀번호 변경·2단계 인증 등 계정 보안과 계정 생성/삭제는 Keycloak 계정에서 관리됩니다.

+
+ +
+
- +
); } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7454ad3..a9f6637 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -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() {
- +
diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 71c15d6..8ec6e9f 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -13,7 +13,7 @@ const TINT: Record = { 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 (
0 && } /> diff --git a/src/pages/Incentive.tsx b/src/pages/Incentive.tsx index 80c0950..46c64f2 100644 --- a/src/pages/Incentive.tsx +++ b/src/pages/Incentive.tsx @@ -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() {
setYear(Number(e.target.value))} className="w-28"> {YEARS.map((y) => )} @@ -36,7 +36,7 @@ export function IncentivePage() {
- +
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 58fcffa..d2ea7a3 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -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 ( -
+
- - -
-
-
- {avatar ? ( - {member.displayName} - ) : ( -
- {member.displayName.slice(0, 1) || } -
- )} - - { const f = e.target.files?.[0]; if (f) avatarM.mutate(f); }} /> + {/* 넓은 화면에서는 좌측 신원 카드 + 우측 상세/연락처로 공간을 가득 채운다. */} +
+ {/* 신원 카드 */} + +
+ {avatar ? ( + {member.displayName} + ) : ( +
+ {member.displayName.slice(0, 1) || } +
+ )} + + { const f = e.target.files?.[0]; if (f) avatarM.mutate(f); }} /> +
+
{member.displayName}
+
{member.email}
+
+ {member.rank && } + +
+
{avatarM.isPending ? "사진 업로드 중…" : ""}
+
+ + {/* 상세 + 연락처 */} +
+ + +
+ } label="이름" value={member.displayName} /> + } label="이메일" value={member.email} /> + } label="직급" value={rankLabel(member.rank) || "미지정"} /> + } label="부서" value={dept || "미지정"} /> + } label="권한" value={member.role === "admin" ? "관리자" : "구성원"} /> + } label="입사일" value={formatDate(member.joinDate)} />
- {avatarM.isPending ? "업로드 중…" : "사진 변경"} -
+ -
- - - : "미지정"} /> - - - -
+ + save.mutate()} disabled={save.isPending}>저장} /> +
+ setPhone(e.target.value)} placeholder="010-0000-0000" /> + +
+
- - - - save.mutate()} disabled={save.isPending}>저장} /> -
- setPhone(e.target.value)} placeholder="010-0000-0000" /> -
-
+
); } -function Info({ label, value }: { label: string; value: React.ReactNode }) { +function Info({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) { return ( -
-
{label}
-
{value || "—"}
+
+ {icon} + {label} + {value || "—"}
); } diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index b497179..d3230c8 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -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 (
@@ -30,16 +26,7 @@ export function ProjectsPage() { description="내가 참여 중인 프로젝트입니다. (생성·관리는 관리자 전용)" /> - -
- - setQ(e.target.value)} /> -
- -
+ {bar} {projQ.isLoading ? : filtered.length === 0 ? ( } description="프로젝트에 배정되면 여기에 표시됩니다." /> diff --git a/src/pages/admin/ProjectsAdmin.tsx b/src/pages/admin/ProjectsAdmin.tsx index 664cf64..47b1a51 100644 --- a/src/pages/admin/ProjectsAdmin.tsx +++ b/src/pages/admin/ProjectsAdmin.tsx @@ -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 (
} onClick={() => setOpen(true)}>프로젝트 생성} /> - -
- - setQ(e.target.value)} /> -
- -
+ {bar} {projQ.isLoading ? : rows.length === 0 ? (