feat: 메일함·프로필 사진·근무상태 드롭다운·접이식 사이드바 + #03143F 팔레트 + 인센티브 게이지
All checks were successful
build-and-push / build (push) Successful in 31s
All checks were successful
build-and-push / build (push) Successful in 31s
- 브랜드 포인트컬러 #03143F로 팔레트 전면 재설정, 회사 로고 흰 wrap 제거+크롭, 로고 블렌딩 - 사이드바 접기/펼치기(localStorage), 로고 아래 근무상태 드롭다운(출근/퇴근/휴식/미팅/이동) - 대시보드 역할 무관 동일(회계/전사 위젯 제거) - 유저 근무화면 단순화(남은연차 소수점·기록·휴가/공가만), 관리자 근무관리(/admin/attendance) - 프로젝트: 관리자 전용 관리창(/admin/projects), 나의 업무는 본인 참여분 read-only - 메일함(/inbox)+탑바 벨(미확인), 프로필(부서·연락처·사진 업로드) - 인센티브 유저: BE/non-BE·환율 숨김, 할당량 세그먼트 게이지(지급완료→반영완료→반영중→예정, 할당량 화살표) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7cab590fe2
commit
65bcb69374
@ -8,7 +8,10 @@ import { ProjectsPage } from "@/pages/Projects";
|
||||
import { ProjectDetailPage } from "@/pages/ProjectDetail";
|
||||
import { IncentivePage } from "@/pages/Incentive";
|
||||
import { ProfilePage } from "@/pages/Profile";
|
||||
import { InboxPage } from "@/pages/Inbox";
|
||||
import { ApprovalsPage } from "@/pages/admin/Approvals";
|
||||
import { AttendanceAdminPage } from "@/pages/admin/AttendanceAdmin";
|
||||
import { ProjectsAdminPage } from "@/pages/admin/ProjectsAdmin";
|
||||
import { IncentiveAdminPage } from "@/pages/admin/IncentiveAdmin";
|
||||
import { AccountingPage } from "@/pages/admin/Accounting";
|
||||
import { MembersPage } from "@/pages/admin/Members";
|
||||
@ -38,7 +41,10 @@ function Shell() {
|
||||
<Route path="/projects/:id" element={<ProjectDetailPage />} />
|
||||
<Route path="/incentive" element={<IncentivePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/admin/approvals" element={<RequireAdmin><ApprovalsPage /></RequireAdmin>} />
|
||||
<Route path="/admin/attendance" element={<RequireAdmin><AttendanceAdminPage /></RequireAdmin>} />
|
||||
<Route path="/admin/projects" element={<RequireAdmin><ProjectsAdminPage /></RequireAdmin>} />
|
||||
<Route path="/admin/incentive" element={<RequireAdmin><IncentiveAdminPage /></RequireAdmin>} />
|
||||
<Route path="/admin/accounting" element={<RequireAdmin><AccountingPage /></RequireAdmin>} />
|
||||
<Route path="/admin/members" element={<RequireAdmin><MembersPage /></RequireAdmin>} />
|
||||
|
||||
@ -1,13 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function AppShell() {
|
||||
// Sidebar collapse state, persisted so it survives reloads.
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("spin.sidebarCollapsed") === "1"
|
||||
);
|
||||
const toggle = () => {
|
||||
setCollapsed((c) => {
|
||||
const next = !c;
|
||||
localStorage.setItem("spin.sidebarCollapsed", next ? "1" : "0");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-canvas">
|
||||
<Sidebar />
|
||||
<Sidebar collapsed={collapsed} />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar />
|
||||
<Topbar collapsed={collapsed} onToggleSidebar={toggle} />
|
||||
<main className="flex-1 min-w-0 p-6 max-w-[1600px] w-full mx-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@ -61,7 +61,7 @@ export function Gantt({ tasks }: { tasks: ProjectTask[] }) {
|
||||
const s = parse(t.start), e = parse(t.end) || s + DAY;
|
||||
const left = ((s - min) / span) * 100;
|
||||
const width = Math.max(1.5, ((e - s) / span) * 100);
|
||||
const color = LANE_COLOR[t.lane] ?? "#11224F";
|
||||
const color = LANE_COLOR[t.lane] ?? "#03143F";
|
||||
return (
|
||||
<div key={t.id} className="flex items-center h-9 group">
|
||||
<div className="w-[200px] shrink-0 pr-3 text-sm text-ink truncate">{t.title}</div>
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator,
|
||||
Wallet, Users, Settings, type LucideIcon,
|
||||
Wallet, Users, Settings, FolderCog, Inbox, UserCircle, ClipboardList,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getNav, getApprovals } from "@/lib/api";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { SpinLogo } from "./SpinLogo";
|
||||
import { WorkStatusMenu } from "./WorkStatusMenu";
|
||||
import { classNames } from "@/lib/format";
|
||||
import type { NavItem } from "@/types";
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, Wallet, Users, Settings,
|
||||
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, Wallet, Users, Settings, FolderCog,
|
||||
Inbox, UserCircle, ClipboardList,
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({ collapsed = false }: { collapsed?: boolean }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const navQ = useQuery({ queryKey: ["nav"], queryFn: getNav, staleTime: 5 * 60_000 });
|
||||
const apprQ = useQuery({ queryKey: ["approvals-count"], queryFn: getApprovals, enabled: isAdmin, staleTime: 60_000 });
|
||||
@ -24,15 +27,27 @@ export function Sidebar() {
|
||||
const sections = Array.from(new Set(items.map((i) => i.section)));
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 bg-navy-sidebar text-white flex flex-col h-screen sticky top-0">
|
||||
<div className="px-5 pt-6 pb-5">
|
||||
<SpinLogo variant="light" />
|
||||
<aside
|
||||
className={classNames(
|
||||
"shrink-0 bg-navy-sidebar text-white flex flex-col h-screen sticky top-0 transition-[width] duration-200 ease-out",
|
||||
collapsed ? "w-[68px]" : "w-60"
|
||||
)}
|
||||
>
|
||||
<div className={classNames("pt-6 pb-4", collapsed ? "px-0 flex justify-center" : "px-5")}>
|
||||
<SpinLogo variant="light" markOnly={collapsed} />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 space-y-4 overflow-y-auto pb-4">
|
||||
{sections.map((section) => (
|
||||
{/* 출근/퇴근/휴식/미팅/이동 빠른 상태 설정 (로고 바로 아래) */}
|
||||
<div className={classNames("pb-3", collapsed ? "px-2" : "px-5")}>
|
||||
<WorkStatusMenu collapsed={collapsed} />
|
||||
</div>
|
||||
|
||||
<nav className={classNames("flex-1 overflow-y-auto overflow-x-hidden pb-4", collapsed ? "px-2 space-y-2" : "px-3 space-y-4")}>
|
||||
{sections.map((section, si) => (
|
||||
<div key={section}>
|
||||
<div className="px-3 text-[10px] uppercase tracking-widest text-white/35 mb-1.5">{section}</div>
|
||||
{collapsed
|
||||
? si > 0 && <div className="mx-2 my-2 h-px bg-white/10" />
|
||||
: <div className="px-3 text-[10px] uppercase tracking-widest text-white/35 mb-1.5">{section}</div>}
|
||||
<div className="space-y-0.5">
|
||||
{items.filter((i) => i.section === section).map((item: NavItem) => {
|
||||
const Icon = ICONS[item.icon] ?? LayoutDashboard;
|
||||
@ -41,19 +56,25 @@ export function Sidebar() {
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
end={item.path === "/"}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-control text-sm font-medium transition-colors",
|
||||
"relative flex items-center rounded-control text-sm font-medium transition-colors",
|
||||
collapsed ? "justify-center h-10" : "gap-3 px-3 py-2",
|
||||
isActive ? "bg-white/10 text-white" : "text-white/60 hover:text-white hover:bg-white/5"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<Icon size={18} strokeWidth={2} />
|
||||
{!collapsed && <span className="flex-1">{item.label}</span>}
|
||||
{item.key === "approvals" && approvalCount > 0 && (
|
||||
<span className="font-num text-[11px] font-bold bg-[#C99A2E] text-[#1A1305] rounded-pill min-w-[20px] h-5 px-1.5 flex items-center justify-center">
|
||||
{approvalCount}
|
||||
</span>
|
||||
collapsed ? (
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[#C99A2E]" />
|
||||
) : (
|
||||
<span className="font-num text-[11px] font-bold bg-[#C99A2E] text-[#1A1305] rounded-pill min-w-[20px] h-5 px-1.5 flex items-center justify-center">
|
||||
{approvalCount}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
@ -63,12 +84,21 @@ export function Sidebar() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="px-5 py-4 border-t border-white/10">
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/40 mb-2">Powered by</div>
|
||||
<div className="bg-white rounded-control px-3 py-2 inline-flex">
|
||||
<img src="/special-partners.jpg" alt="Special Partners" className="h-6 object-contain" />
|
||||
{!collapsed && (
|
||||
<div className="px-5 py-4 border-t border-white/10">
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/40 mb-2">Powered by</div>
|
||||
{/* Logo sits directly on the rail (sidebar shares the logo's #03143F
|
||||
navy), cropped 1px left / 3px bottom and scaled up. */}
|
||||
<div className="overflow-hidden" style={{ width: 144, height: 37 }}>
|
||||
<img
|
||||
src="/special-partners.jpg"
|
||||
alt="Special Partners"
|
||||
className="block"
|
||||
style={{ height: 40, width: "auto", maxWidth: "none", marginLeft: -1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,20 +1,30 @@
|
||||
// spin wordmark: a navy "spin" set in Lora, paired with a small gold dot — the
|
||||
// "spin" of the consulting flywheel. Reuses the Special Partners navy.
|
||||
export function SpinLogo({ variant = "light" }: { variant?: "light" | "dark" }) {
|
||||
const fg = variant === "light" ? "#FFFFFF" : "#11224F";
|
||||
// "spin" of the consulting flywheel. Reuses the Special Partners brand navy
|
||||
// (#03143F). markOnly renders just the glyph for the collapsed sidebar rail.
|
||||
export function SpinLogo({
|
||||
variant = "light",
|
||||
markOnly = false,
|
||||
}: {
|
||||
variant?: "light" | "dark";
|
||||
markOnly?: boolean;
|
||||
}) {
|
||||
const fg = variant === "light" ? "#FFFFFF" : "#03143F";
|
||||
const markBg = variant === "light" ? "#0C2356" : "#03143F";
|
||||
return (
|
||||
<div className="flex items-center gap-2 select-none">
|
||||
<svg width="30" height="30" viewBox="0 0 64 64" fill="none" aria-hidden>
|
||||
<rect width="64" height="64" rx="14" fill={variant === "light" ? "#1B2F66" : "#11224F"} />
|
||||
<rect width="64" height="64" rx="14" fill={markBg} />
|
||||
<path
|
||||
d="M40 22c-2.5-2.2-6-3.5-9.5-3.5-6.6 0-11 3.4-11 8.4 0 4.4 3.2 6.6 9 7.8 4.4.9 5.7 1.8 5.7 3.5 0 1.8-1.9 3-5 3-3 0-5.6-1.2-7.7-3.2"
|
||||
stroke="#FFFFFF" strokeWidth="4.5" strokeLinecap="round" fill="none"
|
||||
/>
|
||||
<circle cx="44" cy="42" r="3" fill="#C99A2E" />
|
||||
</svg>
|
||||
<span className="font-wordmark text-2xl font-bold tracking-tight" style={{ color: fg }}>
|
||||
spin
|
||||
</span>
|
||||
{!markOnly && (
|
||||
<span className="font-wordmark text-2xl font-bold tracking-tight" style={{ color: fg }}>
|
||||
spin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,39 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ShieldCheck, User as UserIcon, PanelLeftClose, PanelLeftOpen, Bell } from "lucide-react";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { ShieldCheck, User as UserIcon } from "lucide-react";
|
||||
import { getUnreadCount, avatarUrl } from "@/lib/api";
|
||||
|
||||
export function Topbar() {
|
||||
export function Topbar({
|
||||
collapsed,
|
||||
onToggleSidebar,
|
||||
}: {
|
||||
collapsed?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}) {
|
||||
const { me, isAdmin } = useAuth();
|
||||
const name = me?.member?.displayName || me?.user.name || "사용자";
|
||||
const rank = me?.member?.rank;
|
||||
const initial = name.slice(0, 1);
|
||||
const avatar = avatarUrl(me?.member?.id, me?.member?.avatarKey);
|
||||
|
||||
const unreadQ = useQuery({ queryKey: ["unread-count"], queryFn: getUnreadCount, refetchInterval: 60_000 });
|
||||
const unread = unreadQ.data ?? 0;
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-surface border-b border-border flex items-center justify-between px-6 sticky top-0 z-30">
|
||||
<div className="text-sm text-ink-secondary">
|
||||
<span className="font-semibold text-ink">Special Partners</span> 내부 운영 플랫폼
|
||||
<header className="h-14 bg-surface border-b border-border flex items-center justify-between px-4 sticky top-0 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
title={collapsed ? "메뉴 펼치기" : "메뉴 접기"}
|
||||
aria-label="사이드바 토글"
|
||||
className="p-2 rounded-control text-ink-secondary hover:bg-canvas hover:text-ink transition-colors"
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||
</button>
|
||||
<div className="text-sm text-ink-secondary">
|
||||
<span className="font-semibold text-ink">Special Partners</span> 내부 운영 플랫폼
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && (
|
||||
@ -18,15 +41,27 @@ export function Topbar() {
|
||||
<ShieldCheck size={13} /> 관리자
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-navy text-white flex items-center justify-center text-sm font-bold">
|
||||
{initial || <UserIcon size={15} />}
|
||||
</div>
|
||||
<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">
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link to="/profile" className="flex items-center gap-2.5 hover:opacity-90" title="내 프로필">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt={name} className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-navy text-white flex items-center justify-center text-sm font-bold">
|
||||
{initial || <UserIcon size={15} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold text-ink">{name}{rank ? ` · ${rank}` : ""}</div>
|
||||
<div className="text-[11px] text-ink-muted">{me?.user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
109
src/components/WorkStatusMenu.tsx
Normal file
109
src/components/WorkStatusMenu.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, LogIn, LogOut, Coffee, Users, Footprints } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { setWorkStatus } from "@/lib/api";
|
||||
import { classNames } from "@/lib/format";
|
||||
import type { WorkStatusKind } from "@/types";
|
||||
|
||||
// 근무 상태 빠른 설정. 출근/퇴근은 실제 출퇴근 punch API를 호출하고, 휴식/미팅/이동은
|
||||
// 현재 상태 표시(프레즌스)로 사용합니다. 선택값은 localStorage에 보관됩니다.
|
||||
type StatusKey = "off" | "in" | "break" | "meeting" | "move" | "out";
|
||||
|
||||
const OPTIONS: { key: StatusKey; label: string; color: string; icon: typeof LogIn }[] = [
|
||||
{ key: "in", label: "출근", color: "#12B76A", icon: LogIn },
|
||||
{ key: "break", label: "휴식", color: "#F79009", icon: Coffee },
|
||||
{ key: "meeting", label: "미팅", color: "#2E90FA", icon: Users },
|
||||
{ key: "move", label: "이동", color: "#7A5AF8", icon: Footprints },
|
||||
{ key: "out", label: "퇴근", color: "#98A2B3", icon: LogOut },
|
||||
];
|
||||
const META: Record<StatusKey, { label: string; color: string }> = {
|
||||
off: { label: "미출근", color: "#667085" },
|
||||
in: { label: "근무중", color: "#12B76A" },
|
||||
break: { label: "휴식", color: "#F79009" },
|
||||
meeting: { label: "미팅", color: "#2E90FA" },
|
||||
move: { label: "이동", color: "#7A5AF8" },
|
||||
out: { label: "퇴근", color: "#98A2B3" },
|
||||
};
|
||||
|
||||
export function WorkStatusMenu({ collapsed = false }: { collapsed?: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [status, setStatus] = useState<StatusKey>(
|
||||
() => (localStorage.getItem("spin.workStatus") as StatusKey) || "off"
|
||||
);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
const statusM = useMutation({
|
||||
mutationFn: (k: WorkStatusKind) => setWorkStatus(k),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["attendance"] });
|
||||
qc.invalidateQueries({ queryKey: ["work-status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const select = (key: StatusKey) => {
|
||||
setStatus(key);
|
||||
localStorage.setItem("spin.workStatus", key);
|
||||
setOpen(false);
|
||||
if (key !== "off") statusM.mutate(key as WorkStatusKind); // record every presence change
|
||||
};
|
||||
|
||||
const cur = META[status];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={collapsed ? `근무 상태: ${cur.label}` : undefined}
|
||||
className={classNames(
|
||||
"w-full flex items-center rounded-control bg-white/5 hover:bg-white/10 transition-colors border border-white/10",
|
||||
collapsed ? "justify-center h-10" : "gap-2 px-3 py-2"
|
||||
)}
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: cur.color }} />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left text-sm font-medium text-white">{cur.label}</span>
|
||||
<ChevronDown size={15} className="text-white/50" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute z-50 bg-surface rounded-control shadow-pop border border-border py-1",
|
||||
collapsed ? "left-full ml-2 top-0 w-36" : "left-0 right-0 mt-1"
|
||||
)}
|
||||
>
|
||||
{OPTIONS.map((o) => {
|
||||
const Icon = o.icon;
|
||||
return (
|
||||
<button
|
||||
key={o.key}
|
||||
onClick={() => select(o.key)}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-ink hover:bg-canvas transition-colors"
|
||||
>
|
||||
<Icon size={15} style={{ color: o.color }} />
|
||||
<span className="flex-1 text-left">{o.label}</span>
|
||||
{((status === "in" && o.key === "in") || status === o.key) && (
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: o.color }} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -205,7 +205,7 @@ export function Stat({
|
||||
}
|
||||
|
||||
/* ---------- Progress bar ---------- */
|
||||
export function Progress({ pct, color = "#11224F" }: { pct: number; color?: string }) {
|
||||
export function Progress({ pct, color = "#03143F" }: { pct: number; color?: string }) {
|
||||
const v = Math.max(0, Math.min(100, pct));
|
||||
return (
|
||||
<div className="h-2 rounded-pill bg-divider overflow-hidden">
|
||||
|
||||
@ -44,8 +44,8 @@ body {
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: #11224f;
|
||||
box-shadow: 0 0 0 3px rgba(17, 34, 79, 0.08);
|
||||
border-color: #03143f;
|
||||
box-shadow: 0 0 0 3px rgba(3, 20, 63, 0.12);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
|
||||
@ -2,9 +2,10 @@ import axios from "axios";
|
||||
import type {
|
||||
Account, AcctSummary, ApprovalQueue, Attendance, AuditLog, ClientContact,
|
||||
Company, Contract, ContractFile, Dashboard, Department, IncentiveConfig,
|
||||
LeaveRequest, Me, Member, MyIncentive, NavItem, OvertimeRequest, PaymentSplit,
|
||||
PaymentStage, Product, Project, ProjectMember, ProjectTask, Settlement,
|
||||
SimResult, TaxRecord, Timesheet, Transaction, UserIncentive, Version, WorkPolicy,
|
||||
LeaveBalance, LeaveRequest, Me, Member, MyIncentive, NavItem, Notification,
|
||||
OvertimeRequest, PaymentSplit, PaymentStage, Product, Project, ProjectMember,
|
||||
ProjectTask, Settlement, SimResult, TaxRecord, Timesheet, Transaction,
|
||||
UserIncentive, Version, WorkPolicy, WorkStatusEvent, WorkStatusKind,
|
||||
} from "@/types";
|
||||
|
||||
export const api = axios.create({
|
||||
@ -21,6 +22,26 @@ export const getMe = () => api.get<Me>("/me").then((r) => r.data);
|
||||
export const getNav = () => api.get<NavItem[]>("/me/nav").then((r) => r.data);
|
||||
export const getDashboard = () => api.get<Dashboard>("/dashboard").then((r) => r.data);
|
||||
|
||||
/* ---- inbox / notifications ---- */
|
||||
export const getNotifications = (unread?: boolean) =>
|
||||
api.get<Notification[]>("/notifications", { params: { unread } }).then((r) => r.data);
|
||||
export const getUnreadCount = () =>
|
||||
api.get<{ count: number }>("/notifications/unread-count").then((r) => r.data.count);
|
||||
export const markNotificationRead = (id: string) =>
|
||||
api.post(`/notifications/${id}/read`).then((r) => r.data);
|
||||
export const markAllNotificationsRead = () =>
|
||||
api.post("/notifications/read-all").then((r) => r.data);
|
||||
|
||||
/* ---- profile photo ---- */
|
||||
export const uploadAvatar = (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return api.post<Member>("/me/avatar", form, { headers: { "Content-Type": "multipart/form-data" } }).then((r) => r.data);
|
||||
};
|
||||
// Avatar image URL (cache-busted by avatarKey). Empty avatarKey → no image.
|
||||
export const avatarUrl = (memberId?: string, avatarKey?: string) =>
|
||||
memberId && avatarKey ? `/api/members/${memberId}/avatar?v=${encodeURIComponent(avatarKey)}` : "";
|
||||
|
||||
/* ---- members / org ---- */
|
||||
export const getMembers = () => api.get<Member[]>("/members").then((r) => r.data);
|
||||
export const getMember = (id: string) => api.get<Member>(`/members/${id}`).then((r) => r.data);
|
||||
@ -40,6 +61,12 @@ export const getAudit = () => api.get<AuditLog[]>("/audit").then((r) => r.data);
|
||||
export const getAttendance = (params: { month?: string; email?: string }) =>
|
||||
api.get<Attendance[]>("/attendance", { params }).then((r) => r.data);
|
||||
export const punch = () => api.post<Attendance>("/attendance/punch").then((r) => r.data);
|
||||
export const setWorkStatus = (status: WorkStatusKind, note?: string) =>
|
||||
api.post("/attendance/status", { status, note }).then((r) => r.data);
|
||||
export const getWorkStatusLog = (params?: { email?: string; date?: string }) =>
|
||||
api.get<WorkStatusEvent[]>("/attendance/status", { params }).then((r) => r.data);
|
||||
export const getLeaveBalance = (params?: { year?: number; email?: string }) =>
|
||||
api.get<LeaveBalance>("/leave/balance", { params }).then((r) => r.data);
|
||||
export const getTimesheet = (params: { year?: number; month?: number; email?: string }) =>
|
||||
api.get<Timesheet>("/attendance/timesheet", { params }).then((r) => r.data);
|
||||
export const getLeave = (params?: { status?: string; email?: string }) =>
|
||||
@ -70,7 +97,7 @@ export const getVersions = (productId?: string) =>
|
||||
api.get<Version[]>("/versions", { params: { productId } }).then((r) => r.data);
|
||||
export const createVersion = (b: Partial<Version>) => api.post<Version>("/versions", b).then((r) => r.data);
|
||||
|
||||
export const getProjects = (params?: { companyId?: string; status?: string }) =>
|
||||
export const getProjects = (params?: { companyId?: string; status?: string; scope?: "mine" }) =>
|
||||
api.get<Project[]>("/projects", { params }).then((r) => r.data);
|
||||
export const getProject = (id: string) => api.get<Project>(`/projects/${id}`).then((r) => r.data);
|
||||
export const createProject = (b: Partial<Project>) => api.post<Project>("/projects", b).then((r) => r.data);
|
||||
|
||||
@ -1,64 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarDays, Plus, LogIn, LogOut } from "lucide-react";
|
||||
import { CalendarDays, Plus } from "lucide-react";
|
||||
import {
|
||||
getTimesheet, getAttendance, getLeave, getOvertime, punch, createLeave,
|
||||
createOvertime, cancelLeave,
|
||||
getAttendance, getLeave, getOvertime, getLeaveBalance, createLeave, createOvertime, cancelLeave,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Card, Button, Badge, Stat, PageHeader, Modal, Field, Input, Select,
|
||||
Textarea, Tabs, Progress, EmptyState, LoadingState,
|
||||
Textarea, Tabs, EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
formatDate, formatTime, minutesToHM, REQ_STATUS_META, LEAVE_LABELS, classNames,
|
||||
formatDate, formatTime, minutesToHM, REQ_STATUS_META, LEAVE_LABELS,
|
||||
} from "@/lib/format";
|
||||
import type { LeaveType } from "@/types";
|
||||
|
||||
const THIS_MONTH = new Date().toISOString().slice(0, 7);
|
||||
|
||||
// 유저 근무 화면: 본인 근무 기록 · 휴가/공가 · 남은 연차(소수점)만. 전사/소정근로/
|
||||
// 달성률 같은 집계 수치는 관리자 근무관리에서만 표시.
|
||||
export function AttendancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState("timesheet");
|
||||
const [tab, setTab] = useState("records");
|
||||
const [leaveOpen, setLeaveOpen] = useState(false);
|
||||
const [otOpen, setOtOpen] = useState(false);
|
||||
|
||||
const tsQ = useQuery({ queryKey: ["timesheet"], queryFn: () => getTimesheet({}) });
|
||||
const balQ = useQuery({ queryKey: ["leave-balance"], queryFn: () => getLeaveBalance() });
|
||||
const attQ = useQuery({ queryKey: ["attendance", THIS_MONTH], queryFn: () => getAttendance({ month: THIS_MONTH }) });
|
||||
const leaveQ = useQuery({ queryKey: ["leave-mine"], queryFn: () => getLeave() });
|
||||
const otQ = useQuery({ queryKey: ["ot-mine"], queryFn: () => getOvertime() });
|
||||
|
||||
const punchM = useMutation({
|
||||
mutationFn: punch,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["attendance", THIS_MONTH] }); qc.invalidateQueries({ queryKey: ["timesheet"] }); },
|
||||
});
|
||||
|
||||
const ts = tsQ.data;
|
||||
const bal = balQ.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="근무"
|
||||
description="출퇴근 체크와 휴가·초과근무 신청을 관리합니다. 본인 기록만 표시됩니다."
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" icon={<LogIn size={16} />} onClick={() => punchM.mutate()}>출근</Button>
|
||||
<Button icon={<LogOut size={16} />} onClick={() => punchM.mutate()}>퇴근</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PageHeader title="근무" description="본인 근무 기록과 휴가·공가 신청 내역입니다. 출퇴근은 좌측 메뉴 상단에서 설정하세요." />
|
||||
|
||||
{ts && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<Stat label={`${ts.year}.${String(ts.month).padStart(2, "0")} 인정 근무`} value={minutesToHM(ts.recognizedTotal)} sub={`근무 ${minutesToHM(ts.workedMinutes)} + 인정휴가 ${minutesToHM(ts.leaveMinutes)}`} />
|
||||
<Stat label="월 소정근로" value={minutesToHM(ts.standardMinutes)} sub={`영업일 ${ts.businessDays}일`} />
|
||||
<Stat label="출근일" value={`${ts.daysPresent}일`} sub={`초과근무 ${minutesToHM(ts.overtimeMinutes)}`} />
|
||||
<Card className="p-5">
|
||||
<div className="text-xs font-medium text-ink-secondary">월 근로 달성률</div>
|
||||
<div className="mt-2 text-2xl font-bold font-num text-navy">{ts.fulfillmentPct.toFixed(0)}%</div>
|
||||
<div className="mt-2"><Progress pct={ts.fulfillmentPct} color={ts.fulfillmentPct >= 100 ? "#12B76A" : "#11224F"} /></div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<Stat label="남은 연차" value={`${bal ? bal.remaining : "—"}일`} sub={bal ? `부여 ${bal.granted}일 · 사용 ${bal.used}일` : ""} accent="#03143F" />
|
||||
<Stat label="이번 달 출근일" value={`${(attQ.data ?? []).filter((a) => a.workMinutes > 0).length}일`} sub="본인 기록 기준" />
|
||||
<Stat label="대기중 신청" value={(leaveQ.data ?? []).filter((l) => l.status === "pending").length + (otQ.data ?? []).filter((o) => o.status === "pending").length} sub="승인 대기" accent="#B54708" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="px-3 pt-2 flex items-center justify-between">
|
||||
@ -66,7 +46,7 @@ export function AttendancePage() {
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
{ key: "timesheet", label: "근무 기록" },
|
||||
{ key: "records", label: "근무 기록" },
|
||||
{ key: "leave", label: "휴가/공가", badge: leaveQ.data?.filter((l) => l.status === "pending").length },
|
||||
{ key: "overtime", label: "초과근무", badge: otQ.data?.filter((o) => o.status === "pending").length },
|
||||
]}
|
||||
@ -76,24 +56,22 @@ export function AttendancePage() {
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{tab === "timesheet" && (
|
||||
{tab === "records" && (
|
||||
attQ.isLoading ? <LoadingState /> : (attQ.data?.length ?? 0) === 0 ? <EmptyState title="이번 달 근무 기록이 없습니다" icon={<CalendarDays size={28} />} /> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>날짜</th><th>출근</th><th>퇴근</th><th>근무시간</th><th>비고</th></tr></thead>
|
||||
<tbody>
|
||||
{attQ.data!.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="tabular">{formatDate(a.date)}</td>
|
||||
<td className="tabular">{formatTime(a.clockIn)}</td>
|
||||
<td className="tabular">{formatTime(a.clockOut)}</td>
|
||||
<td className="tabular">{minutesToHM(a.workMinutes)}</td>
|
||||
<td className="text-ink-muted">{a.note || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>날짜</th><th>출근</th><th>퇴근</th><th>근무시간</th><th>비고</th></tr></thead>
|
||||
<tbody>
|
||||
{attQ.data!.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="tabular">{formatDate(a.date)}</td>
|
||||
<td className="tabular">{formatTime(a.clockIn)}</td>
|
||||
<td className="tabular">{formatTime(a.clockOut)}</td>
|
||||
<td className="tabular">{minutesToHM(a.workMinutes)}</td>
|
||||
<td className="text-ink-muted">{a.note || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
|
||||
@ -143,7 +121,7 @@ export function AttendancePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<LeaveModal open={leaveOpen} onClose={() => setLeaveOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["leave-mine"] })} />
|
||||
<LeaveModal open={leaveOpen} onClose={() => setLeaveOpen(false)} onDone={() => { qc.invalidateQueries({ queryKey: ["leave-mine"] }); qc.invalidateQueries({ queryKey: ["leave-balance"] }); }} />
|
||||
<OvertimeModal open={otOpen} onClose={() => setOtOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["ot-mine"] })} />
|
||||
</div>
|
||||
);
|
||||
@ -195,7 +173,7 @@ function OvertimeModal({ open, onClose, onDone }: { open: boolean; onClose: () =
|
||||
<Field label="날짜"><Input type="date" value={date} onChange={(e) => setDate(e.target.value)} /></Field>
|
||||
<Field label="시간(시간 단위)"><Input type="number" step="0.5" value={hours} onChange={(e) => setHours(e.target.value)} /></Field>
|
||||
<Field label="사유"><Textarea value={reason} onChange={(e) => setReason(e.target.value)} /></Field>
|
||||
<p className={classNames("text-xs text-ink-muted")}>※ 초과근무는 관리자만 확인·승인합니다.</p>
|
||||
<p className="text-xs text-ink-muted">※ 초과근무는 관리자만 확인·승인합니다.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Clock, FolderKanban, Coins, CheckSquare, Wallet, TrendingUp } from "lucide-react";
|
||||
import { getDashboard, punch } from "@/lib/api";
|
||||
import { Clock, FolderKanban, Coins, User } from "lucide-react";
|
||||
import { getDashboard } from "@/lib/api";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { Card, CardHeader, Stat, PageHeader, Button, LoadingState, EmptyState } from "@/components/ui";
|
||||
import { formatKRW, formatPoints, formatDate, formatWon } from "@/lib/format";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui";
|
||||
import { formatPoints } from "@/lib/format";
|
||||
|
||||
// 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고
|
||||
// 회계·전사 위젯은 넣지 않습니다. (전사 현황은 각 관리자 메뉴에서 확인)
|
||||
export function DashboardPage() {
|
||||
const { me, isAdmin } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const { me } = useAuth();
|
||||
const q = useQuery({ queryKey: ["dashboard"], queryFn: getDashboard });
|
||||
const punchM = useMutation({
|
||||
mutationFn: punch,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["dashboard"] }),
|
||||
});
|
||||
|
||||
if (q.isLoading) return <LoadingState />;
|
||||
const d = q.data!;
|
||||
@ -22,74 +18,33 @@ export function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={`안녕하세요, ${name}님`}
|
||||
description={isAdmin ? "전사 운영 현황을 한눈에 확인하세요." : "오늘도 좋은 하루 되세요."}
|
||||
action={<Button icon={<Clock size={16} />} onClick={() => punchM.mutate()}>출퇴근 체크</Button>}
|
||||
/>
|
||||
<PageHeader title={`안녕하세요, ${name}님`} description="오늘도 좋은 하루 되세요. 내 업무 현황을 확인하세요." />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link to="/projects"><Stat label="참여 프로젝트" value={d.myProjects} sub="내가 속한 프로젝트" /></Link>
|
||||
<Link to="/incentive"><Stat label="올해 인센티브 포인트" value={formatPoints(d.myPoints)} sub="반영완료 기준" accent="#5925DC" /></Link>
|
||||
<Link to="/attendance"><Stat label="대기중 신청" value={d.myPendingRequests} sub="승인 대기" accent="#B54708" /></Link>
|
||||
{isAdmin
|
||||
? <Link to="/admin/approvals"><Stat label="승인 대기 (전사)" value={d.pendingApprovals ?? 0} sub="휴가 · 초과근무" accent="#B54708" /></Link>
|
||||
: <Stat label="이번 달 근무" value="타임시트" sub="근무 메뉴에서 확인" />}
|
||||
<Link to="/attendance"><Stat label="대기중 신청" value={d.myPendingRequests} sub="휴가·초과근무 승인 대기" accent="#B54708" /></Link>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mt-4">
|
||||
<Stat label="올해 수입" value={formatKRW(d.cashIn)} accent="#067647" sub={<span className="inline-flex items-center gap-1"><TrendingUp size={12} /> 누적 입금</span>} />
|
||||
<Stat label="올해 지출" value={formatKRW(d.cashOut)} accent="#B42318" />
|
||||
<Stat label="순현금" value={formatKRW(d.cashNet)} accent={(d.cashNet ?? 0) >= 0 ? "#067647" : "#B42318"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
<Card>
|
||||
<CardHeader title="바로가기" />
|
||||
<div className="p-5 grid grid-cols-2 gap-3">
|
||||
<QuickLink to="/attendance" icon={<Clock size={18} />} label="근무 / 휴가" />
|
||||
<QuickLink to="/projects" icon={<FolderKanban size={18} />} label="프로젝트" />
|
||||
<QuickLink to="/projects" icon={<FolderKanban size={18} />} label="내 프로젝트" />
|
||||
<QuickLink to="/incentive" icon={<Coins size={18} />} label="내 인센티브" />
|
||||
{isAdmin
|
||||
? <QuickLink to="/admin/incentive" icon={<CheckSquare size={18} />} label="인센티브 관리" />
|
||||
: <QuickLink to="/profile" icon={<CheckSquare size={18} />} label="내 프로필" />}
|
||||
<QuickLink to="/profile" icon={<User size={18} />} label="내 프로필" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isAdmin && (
|
||||
<Card>
|
||||
<CardHeader title="입금 예정" subtitle="미입금 분할 항목" action={<Link to="/admin/accounting" className="text-xs text-navy font-medium">회계 보기</Link>} />
|
||||
<div className="p-2">
|
||||
{(!d.upcomingPayments || d.upcomingPayments.length === 0) ? (
|
||||
<EmptyState title="예정된 입금이 없습니다" />
|
||||
) : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>항목</th><th>예상일</th><th className="text-right">금액</th></tr></thead>
|
||||
<tbody>
|
||||
{d.upcomingPayments.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.label}</td>
|
||||
<td className="tabular">{formatDate(p.expectedDate)}</td>
|
||||
<td className="text-right tabular font-medium">{formatWon(p.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
<Card className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<Wallet size={28} className="mx-auto text-ink-muted mb-2" />
|
||||
<p className="text-sm text-ink-secondary">회사 자료는 본인 데이터만 표시됩니다.</p>
|
||||
<p className="text-xs text-ink-muted mt-1">전체 현황은 관리자만 확인할 수 있습니다.</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader title="내 정보" />
|
||||
<div className="p-5 space-y-2.5">
|
||||
<InfoRow label="직급" value={me?.member?.rank || "—"} />
|
||||
<InfoRow label="권한" value={me?.isAdmin ? "관리자" : "구성원"} />
|
||||
<InfoRow label="이메일" value={me?.user.email || "—"} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -103,3 +58,12 @@ function QuickLink({ to, icon, label }: { to: string; icon: React.ReactNode; lab
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex text-sm">
|
||||
<span className="w-20 shrink-0 text-ink-muted">{label}</span>
|
||||
<span className="text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
src/pages/Inbox.tsx
Normal file
80
src/pages/Inbox.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Inbox as InboxIcon, FolderKanban, CalendarCheck, Coins, Wallet, CheckCheck } from "lucide-react";
|
||||
import { getNotifications, markNotificationRead, markAllNotificationsRead } from "@/lib/api";
|
||||
import { Card, Button, PageHeader, EmptyState, LoadingState } from "@/components/ui";
|
||||
import { formatDateTime, classNames } from "@/lib/format";
|
||||
import type { Notification } from "@/types";
|
||||
|
||||
const ICON: Record<string, typeof FolderKanban> = {
|
||||
project: FolderKanban, leave: CalendarCheck, overtime: CalendarCheck, incentive: Coins, settlement: Wallet,
|
||||
};
|
||||
const TINT: Record<string, string> = {
|
||||
project: "#175CD3", leave: "#067647", overtime: "#B54708", incentive: "#5925DC", settlement: "#03143F",
|
||||
};
|
||||
|
||||
// 메일함: 프로젝트 추가·휴가/초과근무 승인·인센티브 반영/정산 등 본인 관련 이벤트.
|
||||
export function InboxPage() {
|
||||
const qc = useQueryClient();
|
||||
const nav = useNavigate();
|
||||
const q = useQuery({ queryKey: ["notifications"], queryFn: () => getNotifications() });
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["notifications"] });
|
||||
qc.invalidateQueries({ queryKey: ["unread-count"] });
|
||||
};
|
||||
const readM = useMutation({ mutationFn: (id: string) => markNotificationRead(id), onSuccess: invalidate });
|
||||
const allM = useMutation({ mutationFn: markAllNotificationsRead, onSuccess: invalidate });
|
||||
|
||||
const open = (n: Notification) => {
|
||||
if (!n.read) readM.mutate(n.id);
|
||||
if (n.link) nav(n.link);
|
||||
};
|
||||
|
||||
const items = q.data ?? [];
|
||||
const unread = items.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<PageHeader
|
||||
title="메일함"
|
||||
description="나의 근무·프로젝트·인센티브 관련 알림"
|
||||
action={unread > 0 && <Button variant="secondary" size="sm" icon={<CheckCheck size={15} />} onClick={() => allM.mutate()}>모두 읽음</Button>}
|
||||
/>
|
||||
<Card>
|
||||
{q.isLoading ? <LoadingState /> : items.length === 0 ? (
|
||||
<EmptyState title="새 알림이 없습니다" icon={<InboxIcon size={28} />} description="관련 이벤트가 생기면 여기에 표시됩니다." />
|
||||
) : (
|
||||
<div className="divide-y divide-divider">
|
||||
{items.map((n) => {
|
||||
const Icon = ICON[n.type] ?? InboxIcon;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => open(n)}
|
||||
className={classNames(
|
||||
"w-full text-left flex gap-3 px-5 py-4 hover:bg-canvas transition-colors",
|
||||
!n.read && "bg-navy-subtle/30"
|
||||
)}
|
||||
>
|
||||
<span className="mt-0.5 w-9 h-9 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{ background: (TINT[n.type] ?? "#03143F") + "1A", color: TINT[n.type] ?? "#03143F" }}>
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{!n.read && <span className="w-1.5 h-1.5 rounded-full bg-navy shrink-0" />}
|
||||
<span className={classNames("text-sm", n.read ? "font-medium text-ink-strong" : "font-bold text-ink")}>{n.title}</span>
|
||||
</div>
|
||||
<p className="text-sm text-ink-secondary mt-0.5">{n.body}</p>
|
||||
<p className="text-[11px] text-ink-muted mt-1 tabular">{formatDateTime(n.createdAt)}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,109 +1,96 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend,
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { getMyIncentive } from "@/lib/api";
|
||||
import {
|
||||
Card, CardHeader, Stat, PageHeader, Progress, LoadingState, EmptyState, Badge,
|
||||
Card, CardHeader, Stat, PageHeader, LoadingState, EmptyState, Badge,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
formatPoints, formatWon, FIX_STATUS_META, STAGE_KIND_LABELS, SCOPE_LABELS,
|
||||
formatPoints, formatWon, FIX_STATUS_META, STAGE_KIND_LABELS,
|
||||
} from "@/lib/format";
|
||||
import type { FixStatus } from "@/types";
|
||||
|
||||
const COLORS = ["#11224F", "#2E90FA", "#7A5AF8", "#12B76A", "#C99A2E", "#F04438"];
|
||||
// 유저 인센티브: BE/non-BE 개념과 포인트 환율은 노출하지 않습니다(관리자 전용).
|
||||
// 할당량 달성률은 4색 세그먼트 게이지로 표현 — 채우는 순서: 지급완료→반영완료→반영중→예정,
|
||||
// 할당량 위치는 화살표(▼)로 표시.
|
||||
const SEG: { key: FixStatus; label: string; color: string }[] = [
|
||||
{ key: "paid", label: "지급완료", color: "#F04438" }, // 빨강
|
||||
{ key: "applied", label: "반영완료", color: "#12B76A" }, // 초록
|
||||
{ key: "applying", label: "반영중", color: "#F79009" }, // 노랑
|
||||
{ key: "planned", label: "예정", color: "#98A2B3" }, // 회색
|
||||
];
|
||||
|
||||
export function IncentivePage() {
|
||||
const q = useQuery({ queryKey: ["my-incentive"], queryFn: () => getMyIncentive() });
|
||||
if (q.isLoading) return <LoadingState />;
|
||||
const d = q.data!;
|
||||
|
||||
const quotaPct = d.quota > 0 ? (d.pointsApplied / d.quota) * 100 : 0;
|
||||
const byStatus = (st: FixStatus) => d.items.filter((i) => i.fixStatus === st).reduce((s, i) => s + i.points, 0);
|
||||
const seg = SEG.map((s) => ({ ...s, pts: byStatus(s.key) }));
|
||||
const realized = byStatus("paid") + byStatus("applied"); // 달성(반영완료 이상)
|
||||
|
||||
// points by project (bar)
|
||||
const byProject = Object.entries(d.byProject).map(([pid, pts]) => ({
|
||||
name: pid.slice(0, 6), points: Math.round(pts * 10) / 10,
|
||||
}));
|
||||
|
||||
// points by scope (pie)
|
||||
const beTotal = d.items.filter((i) => i.scope === "be").reduce((s, i) => s + i.points, 0);
|
||||
const nonBeTotal = d.items.filter((i) => i.scope === "non_be").reduce((s, i) => s + i.points, 0);
|
||||
const scopeData = [
|
||||
{ name: "BE", value: Math.round(beTotal) },
|
||||
{ name: "non-BE", value: Math.round(nonBeTotal) },
|
||||
].filter((x) => x.value > 0);
|
||||
const byProject = Object.entries(d.byProject).map(([pid, pts]) => ({ name: pid.slice(0, 6), points: Math.round(pts * 10) / 10 }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="내 인센티브" description={`${d.year}년 · 직급 ${d.rank || "—"} · 포인트 환율 ${formatWon(d.pointRate)} / 1P`} />
|
||||
<PageHeader title="내 인센티브" description={`${d.year}년 · 직급 ${d.rank || "—"}`} />
|
||||
|
||||
<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="#5925DC" sub="정산 기준" />
|
||||
<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={formatWon(d.estPayout)} accent="#067647" sub="초과분 × 환율" />
|
||||
<Stat label="예상 인센티브" value={formatWon(d.estPayout)} accent="#067647" sub="할당량 초과분" />
|
||||
</div>
|
||||
|
||||
<Card className="p-5 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-sm font-bold text-ink">할당량 달성률</h3>
|
||||
<span className="text-sm font-num font-semibold text-navy">{quotaPct.toFixed(0)}%</span>
|
||||
<span className="text-sm font-num font-semibold text-navy">
|
||||
{d.quota > 0 ? Math.round((realized / d.quota) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress pct={quotaPct} color={quotaPct >= 100 ? "#12B76A" : "#11224F"} />
|
||||
<div className="flex justify-between text-xs text-ink-muted mt-1.5">
|
||||
<span>{formatPoints(d.pointsApplied)}</span>
|
||||
<span>할당량 {formatPoints(d.quota)} · 초과 {formatPoints(d.excessPoints)}</span>
|
||||
<QuotaGauge seg={seg} quota={d.quota} />
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 mt-3">
|
||||
{seg.map((s) => (
|
||||
<span key={s.key} className="inline-flex items-center gap-1.5 text-xs text-ink-secondary">
|
||||
<span className="w-2.5 h-2.5 rounded-sm" style={{ background: s.color }} />
|
||||
{s.label} <span className="font-num text-ink">{formatPoints(s.pts)}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-ink-muted">할당량 초과 <span className="font-num text-money-in">{formatPoints(d.excessPoints)}</span></span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="프로젝트별 포인트" />
|
||||
<div className="p-4 h-64">
|
||||
{byProject.length === 0 ? <EmptyState title="데이터 없음" /> : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={byProject}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v) => formatPoints(Number(v))} />
|
||||
<Bar dataKey="points" fill="#11224F" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="BE / non-BE 구성" />
|
||||
<div className="p-4 h-64">
|
||||
{scopeData.length === 0 ? <EmptyState title="데이터 없음" /> : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={scopeData} dataKey="value" nameKey="name" innerRadius={45} outerRadius={75} paddingAngle={2}>
|
||||
{scopeData.map((_, i) => <Cell key={i} fill={COLORS[i]} />)}
|
||||
</Pie>
|
||||
<Legend />
|
||||
<Tooltip formatter={(v) => formatPoints(Number(v))} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mb-4">
|
||||
<CardHeader title="프로젝트별 포인트" />
|
||||
<div className="p-4 h-64">
|
||||
{byProject.length === 0 ? <EmptyState title="데이터 없음" /> : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={byProject}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v) => formatPoints(Number(v))} />
|
||||
<Bar dataKey="points" fill="#03143F" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="상세 내역" subtitle="프로젝트 · 단계별 기여도와 포인트" />
|
||||
<CardHeader title="상세 내역" subtitle="프로젝트 단계별 기여도와 포인트 (마우스를 올리면 상세 표시)" />
|
||||
<div className="p-2 overflow-x-auto">
|
||||
{d.items.length === 0 ? <EmptyState title="내역이 없습니다" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>단계</th><th>구분</th><th className="text-right">기여도</th><th className="text-right">금액</th><th className="text-right">포인트</th><th>반영 상태</th></tr></thead>
|
||||
<thead><tr><th>단계</th><th className="text-right">기여도</th><th className="text-right">포인트</th><th>반영 상태</th></tr></thead>
|
||||
<tbody>
|
||||
{d.items.map((it) => {
|
||||
const m = FIX_STATUS_META[it.fixStatus];
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<tr key={it.id} title={`${STAGE_KIND_LABELS[it.kind] ?? it.kind} · 기여도 ${it.portion}% · ${formatPoints(it.points)} · ${m.label}`}>
|
||||
<td>{STAGE_KIND_LABELS[it.kind] ?? it.kind}</td>
|
||||
<td>{SCOPE_LABELS[it.scope] ?? it.scope}</td>
|
||||
<td className="text-right tabular">{it.portion}%</td>
|
||||
<td className="text-right tabular">{formatWon(it.amount)}</td>
|
||||
<td className="text-right tabular font-medium">{formatPoints(it.points)}</td>
|
||||
<td><Badge label={m.label} fg={m.fg} bg={m.bg} dot /></td>
|
||||
</tr>
|
||||
@ -117,3 +104,34 @@ export function IncentivePage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Segmented gauge filled paid→applied→applying→planned, with a ▼ arrow at the quota.
|
||||
function QuotaGauge({ seg, quota }: { seg: { key: string; label: string; color: string; pts: number }[]; quota: number }) {
|
||||
const total = seg.reduce((s, x) => s + x.pts, 0);
|
||||
const denom = Math.max(quota, total, 1);
|
||||
const quotaPct = Math.min(100, (quota / denom) * 100);
|
||||
return (
|
||||
<div className="relative pt-5">
|
||||
{/* quota arrow marker */}
|
||||
{quota > 0 && (
|
||||
<div className="absolute top-0 -translate-x-1/2 flex flex-col items-center" style={{ left: `${quotaPct}%` }}>
|
||||
<span className="text-[10px] font-medium text-ink-secondary whitespace-nowrap">할당량 {formatPointsShort(quota)}</span>
|
||||
<span className="text-navy leading-none" style={{ marginTop: -1 }}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-5 rounded-pill bg-divider overflow-hidden">
|
||||
{seg.map((s) =>
|
||||
s.pts > 0 ? (
|
||||
<div key={s.key} title={`${s.label} ${Math.round(s.pts * 10) / 10}P`} style={{ width: `${(s.pts / denom) * 100}%`, background: s.color }} />
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
{/* quota tick line */}
|
||||
{quota > 0 && <div className="absolute bottom-0 w-px h-5 bg-navy/40" style={{ left: `${quotaPct}%` }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPointsShort(n: number) {
|
||||
return `${Math.round(n * 10) / 10}P`;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateMember } from "@/lib/api";
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Camera, User as UserIcon } 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";
|
||||
@ -9,37 +10,66 @@ export function ProfilePage() {
|
||||
const { me, loading } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const member = me?.member;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [phone, setPhone] = useState(member?.phone ?? "");
|
||||
const [position, setPosition] = useState(member?.position ?? "");
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => updateMember(member!.id, { phone, position }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }),
|
||||
});
|
||||
const deptQ = useQuery({ queryKey: ["departments"], queryFn: getDepartments });
|
||||
const dept = deptQ.data?.find((d) => d.id === member?.departmentId)?.name;
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ["me"] });
|
||||
const save = useMutation({ mutationFn: () => updateMember(member!.id, { phone, position }), onSuccess: refresh });
|
||||
const avatarM = useMutation({ mutationFn: (f: File) => uploadAvatar(f), onSuccess: refresh });
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (!member) return <Card className="p-8 text-center text-ink-secondary">구성원 정보가 없습니다. 관리자에게 문의하세요.</Card>;
|
||||
const avatar = avatarUrl(member.id, member.avatarKey);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<PageHeader title="내 프로필" description="기본 정보는 관리자/Keycloak가 관리하며, 연락처 등 일부만 직접 수정할 수 있습니다." />
|
||||
<PageHeader title="내 프로필" description="기본 정보는 관리자/Keycloak가 관리하며, 사진·연락처 등 일부만 직접 수정할 수 있습니다." />
|
||||
|
||||
<Card>
|
||||
<CardHeader title="기본 정보" />
|
||||
<div className="p-5 grid grid-cols-2 gap-x-8 gap-y-1">
|
||||
<Info label="이름" value={member.displayName} />
|
||||
<Info label="이메일" value={member.email} />
|
||||
<Info label="직급" value={<Badge label={member.rank || "—"} fg="#11224F" bg="#E8ECF5" />} />
|
||||
<Info label="권한" value={member.role === "admin" ? "관리자" : "구성원"} />
|
||||
<Info label="파트너 여부" value={member.isPartner ? "예" : "아니오"} />
|
||||
<Info label="입사일" value={formatDate(member.joinDate)} />
|
||||
<div className="p-5 flex gap-6">
|
||||
<div className="shrink-0 flex flex-col items-center gap-2">
|
||||
<div className="relative">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt={member.displayName} className="w-24 h-24 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>
|
||||
)}
|
||||
<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"
|
||||
title="프로필 사진 변경"
|
||||
>
|
||||
<Camera size={15} />
|
||||
</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>
|
||||
|
||||
<div className="flex-1 grid 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>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader title="연락처 정보" subtitle="직접 수정 가능" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>저장</Button>} />
|
||||
<div className="p-5 grid grid-cols-2 gap-4">
|
||||
<Field label="전화번호"><Input value={phone} onChange={(e) => setPhone(e.target.value)} /></Field>
|
||||
<Field label="직책"><Input value={position} onChange={(e) => setPosition(e.target.value)} /></Field>
|
||||
<Field label="전화번호"><Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="010-0000-0000" /></Field>
|
||||
<Field label="직책"><Input value={position} onChange={(e) => setPosition(e.target.value)} placeholder="예: 선임 컨설턴트" /></Field>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@ -49,7 +79,7 @@ export function ProfilePage() {
|
||||
function Info({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex py-2.5 border-b border-divider">
|
||||
<div className="w-24 shrink-0 text-sm text-ink-muted">{label}</div>
|
||||
<div className="w-20 shrink-0 text-sm text-ink-muted">{label}</div>
|
||||
<div className="text-sm text-ink">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,24 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Search, FolderKanban } from "lucide-react";
|
||||
import { Search, FolderKanban } from "lucide-react";
|
||||
import {
|
||||
getProjects, createProject, getCompanies, getProducts, getVersions,
|
||||
createCompany, createProduct, createVersion,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import {
|
||||
Card, Button, Badge, PageHeader, Modal, Field, Input, Select, Textarea,
|
||||
EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import { formatDate, PROJECT_STATUS_META, SCOPE_LABELS } from "@/lib/format";
|
||||
|
||||
// 나의 업무 > 프로젝트: 본인이 참여한 프로젝트만 보는 read-only 뷰 (관리자·유저 동일).
|
||||
// 생성/관리는 관리자 전용 페이지(/admin/projects)에서만 가능.
|
||||
export function ProjectsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [q, setQ] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const projQ = useQuery({ queryKey: ["projects", status], queryFn: () => getProjects({ status: status || undefined }) });
|
||||
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())
|
||||
@ -28,8 +27,7 @@ export function ProjectsPage() {
|
||||
<div>
|
||||
<PageHeader
|
||||
title="프로젝트"
|
||||
description={isAdmin ? "회사·제품·버전별 인허가 컨설팅 프로젝트" : "내가 참여 중인 프로젝트만 표시됩니다."}
|
||||
action={isAdmin && <Button icon={<Plus size={16} />} onClick={() => setOpen(true)}>프로젝트 생성</Button>}
|
||||
description="내가 참여 중인 프로젝트입니다. (생성·관리는 관리자 전용)"
|
||||
/>
|
||||
|
||||
<Card className="mb-4 p-3 flex flex-wrap items-center gap-3">
|
||||
@ -44,7 +42,7 @@ export function ProjectsPage() {
|
||||
</Card>
|
||||
|
||||
{projQ.isLoading ? <LoadingState /> : filtered.length === 0 ? (
|
||||
<EmptyState title="프로젝트가 없습니다" icon={<FolderKanban size={28} />} description={isAdmin ? "새 프로젝트를 생성하세요." : "참여 중인 프로젝트가 없습니다."} />
|
||||
<EmptyState title="참여 중인 프로젝트가 없습니다" icon={<FolderKanban size={28} />} description="프로젝트에 배정되면 여기에 표시됩니다." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((p) => {
|
||||
@ -72,13 +70,11 @@ export function ProjectsPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && <CreateProjectModal onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const compQ = useQuery({ queryKey: ["companies"], queryFn: getCompanies });
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
|
||||
@ -57,7 +57,7 @@ export function AccountingPage() {
|
||||
<Legend />
|
||||
<Bar dataKey="income" name="수입" fill="#12B76A" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="expense" name="지출" fill="#F04438" radius={[3, 3, 0, 0]} />
|
||||
<Line dataKey="net" name="순익" stroke="#11224F" strokeWidth={2} dot={false} />
|
||||
<Line dataKey="net" name="순익" stroke="#03143F" strokeWidth={2} dot={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
97
src/pages/admin/AttendanceAdmin.tsx
Normal file
97
src/pages/admin/AttendanceAdmin.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { getMembers, getTimesheet, getAttendance, getWorkStatusLog } from "@/lib/api";
|
||||
import {
|
||||
Card, Stat, Select, Tabs, PageHeader, Progress, EmptyState, LoadingState, Badge,
|
||||
} from "@/components/ui";
|
||||
import { formatDate, formatTime, formatDateTime, minutesToHM } from "@/lib/format";
|
||||
import type { WorkStatusKind } from "@/types";
|
||||
|
||||
const now = new Date();
|
||||
const STATUS_META: Record<WorkStatusKind, { label: string; fg: string; bg: string }> = {
|
||||
in: { label: "출근", fg: "#067647", bg: "#DCFAE6" },
|
||||
out: { label: "퇴근", fg: "#475467", bg: "#F2F4F7" },
|
||||
break: { label: "휴식", fg: "#B54708", bg: "#FEF0C7" },
|
||||
meeting: { label: "미팅", fg: "#175CD3", bg: "#D1E9FF" },
|
||||
move: { label: "이동", fg: "#5925DC", bg: "#EBE9FE" },
|
||||
};
|
||||
|
||||
// 관리자 전용 근무 관리: 구성원별 월 근무시간·소정근로·달성률·출근일 + 근무 기록 +
|
||||
// 출근/퇴근/휴식/미팅/이동 프레즌스 로그.
|
||||
export function AttendanceAdminPage() {
|
||||
const [tab, setTab] = useState("summary");
|
||||
const [email, setEmail] = useState("");
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
const membersQ = useQuery({ queryKey: ["members"], queryFn: getMembers });
|
||||
|
||||
const selected = email || membersQ.data?.[0]?.email || "";
|
||||
const monthStr = `${now.getFullYear()}-${String(month).padStart(2, "0")}`;
|
||||
const tsQ = useQuery({ queryKey: ["ts-admin", selected, month], queryFn: () => getTimesheet({ email: selected, month }), enabled: !!selected });
|
||||
const attQ = useQuery({ queryKey: ["att-admin", selected, monthStr], queryFn: () => getAttendance({ email: selected, month: monthStr }), enabled: !!selected && tab === "records" });
|
||||
const logQ = useQuery({ queryKey: ["wslog-admin", selected], queryFn: () => getWorkStatusLog({ email: selected }), enabled: !!selected && tab === "log" });
|
||||
|
||||
const ts = tsQ.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="근무 관리" description="구성원별 월 근무 집계·근무 기록·프레즌스 로그를 확인합니다. (관리자 전용)" />
|
||||
|
||||
<Card className="mb-4 p-3 flex flex-wrap items-center gap-3">
|
||||
<Select value={selected} onChange={(e) => setEmail(e.target.value)} className="w-64">
|
||||
{membersQ.data?.map((m) => <option key={m.id} value={m.email}>{m.displayName} ({m.email})</option>)}
|
||||
</Select>
|
||||
<Select value={month} onChange={(e) => setMonth(+e.target.value)} className="w-32">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((mm) => <option key={mm} value={mm}>{mm}월</option>)}
|
||||
</Select>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="px-3 pt-2"><Tabs active={tab} onChange={setTab} tabs={[{ key: "summary", label: "월 집계" }, { key: "records", label: "근무 기록" }, { key: "log", label: "프레즌스 로그" }]} /></div>
|
||||
<div className="p-5">
|
||||
{tab === "summary" && (
|
||||
tsQ.isLoading ? <LoadingState /> : !ts ? <EmptyState title="데이터 없음" icon={<ClipboardList size={28} />} /> : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Stat label="인정 근무" value={minutesToHM(ts.recognizedTotal)} sub={`근무 ${minutesToHM(ts.workedMinutes)} + 인정휴가 ${minutesToHM(ts.leaveMinutes)}`} />
|
||||
<Stat label="월 소정근로" value={minutesToHM(ts.standardMinutes)} sub={`영업일 ${ts.businessDays}일`} />
|
||||
<Stat label="출근일" value={`${ts.daysPresent}일`} sub={`초과근무 ${minutesToHM(ts.overtimeMinutes)}`} />
|
||||
<Card className="p-5">
|
||||
<div className="text-xs font-medium text-ink-secondary">월 근로 달성률</div>
|
||||
<div className="mt-2 text-2xl font-bold font-num text-navy">{ts.fulfillmentPct.toFixed(0)}%</div>
|
||||
<div className="mt-2"><Progress pct={ts.fulfillmentPct} color={ts.fulfillmentPct >= 100 ? "#12B76A" : "#03143F"} /></div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{tab === "records" && (
|
||||
attQ.isLoading ? <LoadingState /> : (attQ.data?.length ?? 0) === 0 ? <EmptyState title="근무 기록 없음" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>날짜</th><th>출근</th><th>퇴근</th><th>근무시간</th></tr></thead>
|
||||
<tbody>
|
||||
{attQ.data!.map((a) => (
|
||||
<tr key={a.id}><td className="tabular">{formatDate(a.date)}</td><td className="tabular">{formatTime(a.clockIn)}</td><td className="tabular">{formatTime(a.clockOut)}</td><td className="tabular">{minutesToHM(a.workMinutes)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
{tab === "log" && (
|
||||
logQ.isLoading ? <LoadingState /> : (logQ.data?.length ?? 0) === 0 ? <EmptyState title="상태 로그 없음" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>시각</th><th>상태</th><th>비고</th></tr></thead>
|
||||
<tbody>
|
||||
{logQ.data!.map((e) => {
|
||||
const m = STATUS_META[e.status];
|
||||
return <tr key={e.id}><td className="tabular">{formatDateTime(e.at)}</td><td>{m ? <Badge label={m.label} fg={m.fg} bg={m.bg} dot size="sm" /> : e.status}</td><td className="text-ink-muted">{e.note || "—"}</td></tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -40,7 +40,7 @@ export function MembersPage() {
|
||||
<tr key={m.id} className="cursor-pointer" onClick={() => setEdit(m)}>
|
||||
<td className="font-medium">{m.displayName}</td>
|
||||
<td>{m.email}</td>
|
||||
<td><Badge label={m.rank || "—"} fg="#11224F" bg="#E8ECF5" size="sm" /></td>
|
||||
<td><Badge label={m.rank || "—"} fg="#03143F" bg="#E9ECF3" size="sm" /></td>
|
||||
<td>{m.role === "admin" ? <Badge label="관리자" fg="#5925DC" bg="#EBE9FE" size="sm" /> : "구성원"}</td>
|
||||
<td>{m.isPartner ? "✓" : ""}</td>
|
||||
<td className="tabular">{formatDate(m.joinDate)}</td>
|
||||
|
||||
90
src/pages/admin/ProjectsAdmin.tsx
Normal file
90
src/pages/admin/ProjectsAdmin.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
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 { getProjects, deleteProject } from "@/lib/api";
|
||||
import { CreateProjectModal } from "@/pages/Projects";
|
||||
import {
|
||||
Card, Button, Badge, PageHeader, EmptyState, LoadingState, Select,
|
||||
} from "@/components/ui";
|
||||
import { formatDate, PROJECT_STATUS_META, SCOPE_LABELS } 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 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
|
||||
title="프로젝트 관리"
|
||||
description="전체 프로젝트를 생성·관리합니다. 작업자·계약·분할입금·일정은 각 프로젝트 상세에서 관리하세요."
|
||||
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>
|
||||
|
||||
<Card>
|
||||
{projQ.isLoading ? <LoadingState /> : rows.length === 0 ? (
|
||||
<EmptyState title="프로젝트가 없습니다" icon={<FolderCog size={28} />} description="새 프로젝트를 생성하세요." />
|
||||
) : (
|
||||
<table className="dense-table">
|
||||
<thead>
|
||||
<tr><th>프로젝트</th><th>업체 · 제품</th><th>컨설팅</th><th>국가</th><th>범위</th><th>PM</th><th>기간</th><th>상태</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((p) => {
|
||||
const m = PROJECT_STATUS_META[p.status];
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td className="font-medium">
|
||||
<Link to={`/projects/${p.id}`} className="text-navy hover:underline inline-flex items-center gap-1">
|
||||
{p.name} <ExternalLink size={12} />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="text-ink-secondary">{p.companyName} · {p.productName} {p.versionName}</td>
|
||||
<td>{p.consultingType}</td>
|
||||
<td>{p.country}</td>
|
||||
<td>{SCOPE_LABELS[p.scope] ?? p.scope}</td>
|
||||
<td className="text-ink-secondary">{p.pmEmail?.split("@")[0] || "—"}</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">
|
||||
<button className="text-ink-muted hover:text-money-out" title="삭제"
|
||||
onClick={() => { if (confirm(`'${p.name}' 프로젝트를 삭제하시겠습니까?`)) del.mutate(p.id); }}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{open && <CreateProjectModal onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/types.ts
29
src/types.ts
@ -24,10 +24,39 @@ export interface Member {
|
||||
status: string;
|
||||
joinDate?: string | null;
|
||||
annualLeave: number;
|
||||
avatarKey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
recipient: string;
|
||||
type: "project" | "leave" | "overtime" | "incentive" | "settlement";
|
||||
title: string;
|
||||
body: string;
|
||||
link: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type WorkStatusKind = "in" | "out" | "break" | "meeting" | "move";
|
||||
export interface WorkStatusEvent {
|
||||
id: string;
|
||||
memberEmail: string;
|
||||
date: string;
|
||||
status: WorkStatusKind;
|
||||
at: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface LeaveBalance {
|
||||
year: number;
|
||||
granted: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
user: User;
|
||||
member: Member | null;
|
||||
|
||||
@ -7,11 +7,14 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand point color = the Special Partners logo navy (#03143F). The
|
||||
// whole palette is derived from it: sidebar matches it exactly so the
|
||||
// logo (same navy background) blends seamlessly onto the rail.
|
||||
navy: {
|
||||
DEFAULT: "#11224F",
|
||||
hover: "#1B2F66",
|
||||
sidebar: "#0C1733",
|
||||
subtle: "#E8ECF5",
|
||||
DEFAULT: "#03143F",
|
||||
hover: "#0C2356",
|
||||
sidebar: "#03143F",
|
||||
subtle: "#E9ECF3",
|
||||
},
|
||||
canvas: "#F5F6F8",
|
||||
surface: "#FFFFFF",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user