feat: 전 화면 반응형(모바일) + 인턴 직급 + 초과근무 탭 제거 + 로그아웃 URL + 로고 정렬
All checks were successful
build-and-push / build (push) Successful in 31s
All checks were successful
build-and-push / build (push) Successful in 31s
- 모바일 셸: 하단 탭바 + 더보기 드로어, 사이드바/탑바 반응형, safe-area, 폼/모달/테이블 반응형 - 근무: 유저 초과근무 탭 제거(관리자만 집계), 승인 관리 초과근무 섹션 제거 - 직급 인턴 추가, 직책 필드 제거, 부서는 관리자 설정→드롭다운(기존) - 로그아웃: /me의 LOGOUT_URL 사용(SSO 완전 로그아웃), 회사 로고 가운데 정렬 - 디바이스 등록(FCM)·계정 메뉴·계정 설정 (이전 커밋 포함) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a6d1b0440
commit
e3b5a874b3
@ -3,8 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/spin.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#03143F" />
|
||||
<title>spin · Special Partners</title>
|
||||
<!-- spin Flutter 셸은 UA에 "spinApp"을 포함 → 앱 전용 보정(노치 safe-area 등) 클래스 부여 -->
|
||||
<script>
|
||||
if (/spinApp/i.test(navigator.userAgent)) document.documentElement.classList.add("spin-app");
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
@ -33,13 +33,13 @@ export function AccountMenu() {
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-2.5 pl-1 pr-2 py-1 rounded-control hover:bg-canvas transition-colors">
|
||||
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-2.5 pl-1 pr-1 sm:pr-2 py-1 rounded-control hover:bg-canvas transition-colors">
|
||||
<Avatar size={32} />
|
||||
<div className="leading-tight text-left">
|
||||
<div className="hidden sm:block leading-tight text-left">
|
||||
<div className="text-sm font-semibold text-ink">{name}{rank ? ` · ${rank}` : ""}</div>
|
||||
<div className="text-[11px] text-ink-muted">{email}</div>
|
||||
</div>
|
||||
<ChevronDown size={15} className="text-ink-muted" />
|
||||
<ChevronDown size={15} className="hidden sm:block text-ink-muted" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@ -2,12 +2,15 @@ import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
import { MobileTabBar } from "./MobileTabBar";
|
||||
import { MobileDrawer } from "./MobileDrawer";
|
||||
|
||||
export function AppShell() {
|
||||
// Sidebar collapse state, persisted so it survives reloads.
|
||||
// Desktop sidebar collapse (md+), persisted.
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("spin.sidebarCollapsed") === "1"
|
||||
);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const toggle = () => {
|
||||
setCollapsed((c) => {
|
||||
const next = !c;
|
||||
@ -18,13 +21,24 @@ export function AppShell() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-canvas">
|
||||
<Sidebar collapsed={collapsed} />
|
||||
{/* desktop sidebar (hidden on mobile) */}
|
||||
<Sidebar collapsed={collapsed} className="hidden md:flex" />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar collapsed={collapsed} onToggleSidebar={toggle} />
|
||||
<main className="flex-1 min-w-0 p-6 max-w-[1600px] w-full mx-auto">
|
||||
<Topbar
|
||||
collapsed={collapsed}
|
||||
onToggleSidebar={toggle}
|
||||
onOpenMobileMenu={() => setDrawerOpen(true)}
|
||||
/>
|
||||
{/* extra bottom padding on mobile so content clears the tab bar */}
|
||||
<main className="flex-1 min-w-0 p-4 sm:p-6 max-w-[1600px] w-full mx-auto pb-tabbar md:pb-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* mobile-only nav */}
|
||||
<MobileTabBar onMore={() => setDrawerOpen(true)} />
|
||||
<MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/components/MobileDrawer.tsx
Normal file
92
src/components/MobileDrawer.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { useEffect } from "react";
|
||||
import { NavLink, Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { X, UserCircle, Settings, LogOut, LayoutDashboard } from "lucide-react";
|
||||
import { getNav, getApprovals, logout, avatarUrl } from "@/lib/api";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { SpinLogo } from "./SpinLogo";
|
||||
import { WorkStatusMenu } from "./WorkStatusMenu";
|
||||
import { ICONS } from "./Sidebar";
|
||||
import { classNames } from "@/lib/format";
|
||||
import type { NavItem } from "@/types";
|
||||
|
||||
// 모바일 '더보기' 드로어: 전체 nav(관리자 포함) + 근무상태 + 계정/로그아웃.
|
||||
export function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { me, 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 });
|
||||
const approvalCount = (apprQ.data?.leave.length ?? 0) + (apprQ.data?.overtime.length ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onEsc = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onEsc);
|
||||
return () => window.removeEventListener("keydown", onEsc);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
const items = navQ.data ?? [];
|
||||
const sections = Array.from(new Set(items.map((i) => i.section)));
|
||||
const name = me?.member?.displayName || me?.user.name || "사용자";
|
||||
const avatar = avatarUrl(me?.member?.id, me?.member?.avatarKey);
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex" role="dialog" aria-modal>
|
||||
<div className="absolute inset-0 bg-ink/40" onClick={onClose} />
|
||||
<div className="relative ml-auto w-[84%] max-w-xs bg-navy-sidebar text-white h-full flex flex-col pt-safe">
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<SpinLogo variant="light" />
|
||||
<button onClick={onClose} className="p-1.5 rounded-control text-white/70 hover:bg-white/10"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-3"><WorkStatusMenu /></div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 space-y-4 pb-4">
|
||||
{sections.map((section) => (
|
||||
<div key={section}>
|
||||
<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;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
end={item.path === "/"}
|
||||
onClick={onClose}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-control text-sm font-medium",
|
||||
isActive ? "bg-white/10 text-white" : "text-white/65 hover:bg-white/5"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={18} strokeWidth={2} />
|
||||
<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>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/10 p-3 pb-safe space-y-0.5">
|
||||
<div className="flex items-center gap-2.5 px-3 py-2">
|
||||
{avatar ? <img src={avatar} alt={name} className="w-8 h-8 rounded-full object-cover" /> : <div className="w-8 h-8 rounded-full bg-white/15 flex items-center justify-center text-sm font-bold">{name.slice(0, 1)}</div>}
|
||||
<div className="leading-tight min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-[11px] text-white/50 truncate">{me?.user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/profile" onClick={onClose} className="flex items-center gap-3 px-3 py-2.5 rounded-control text-sm text-white/65 hover:bg-white/5"><UserCircle size={18} /> 내 프로필</Link>
|
||||
<Link to="/account" onClick={onClose} className="flex items-center gap-3 px-3 py-2.5 rounded-control text-sm text-white/65 hover:bg-white/5"><Settings size={18} /> 계정 설정</Link>
|
||||
<button onClick={() => { onClose(); logout(); }} className="w-full flex items-center gap-3 px-3 py-2.5 rounded-control text-sm text-[#FDA29B] hover:bg-white/5"><LogOut size={18} /> 로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/MobileTabBar.tsx
Normal file
43
src/components/MobileTabBar.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { LayoutDashboard, Clock, FolderKanban, Coins, Menu, type LucideIcon } from "lucide-react";
|
||||
import { classNames } from "@/lib/format";
|
||||
|
||||
// 모바일 하단 탭바 (Toss식). 주요 4개 + '더보기'(드로어 열기). md 이상에선 숨김.
|
||||
const TABS: { to: string; label: string; icon: LucideIcon; end?: boolean }[] = [
|
||||
{ to: "/", label: "홈", icon: LayoutDashboard, end: true },
|
||||
{ to: "/attendance", label: "근무", icon: Clock },
|
||||
{ to: "/projects", label: "프로젝트", icon: FolderKanban },
|
||||
{ to: "/incentive", label: "인센티브", icon: Coins },
|
||||
];
|
||||
|
||||
export function MobileTabBar({ onMore }: { onMore: () => void }) {
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-40 bg-surface border-t border-border pb-safe">
|
||||
<div className="grid grid-cols-5 h-14">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
"flex flex-col items-center justify-center gap-0.5 text-[11px] font-medium",
|
||||
isActive ? "text-navy" : "text-ink-muted"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
{t.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
<button onClick={onMore} className="flex flex-col items-center justify-center gap-0.5 text-[11px] font-medium text-ink-muted">
|
||||
<Menu size={20} strokeWidth={2} />
|
||||
더보기
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -12,12 +12,12 @@ import { WorkStatusMenu } from "./WorkStatusMenu";
|
||||
import { classNames } from "@/lib/format";
|
||||
import type { NavItem } from "@/types";
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
export const ICONS: Record<string, LucideIcon> = {
|
||||
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, Wallet, Users, Settings, FolderCog,
|
||||
Inbox, UserCircle, ClipboardList,
|
||||
};
|
||||
|
||||
export function Sidebar({ collapsed = false }: { collapsed?: boolean }) {
|
||||
export function Sidebar({ collapsed = false, className }: { collapsed?: boolean; className?: string }) {
|
||||
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 });
|
||||
@ -30,7 +30,8 @@ export function Sidebar({ collapsed = false }: { collapsed?: boolean }) {
|
||||
<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"
|
||||
collapsed ? "w-[68px]" : "w-60",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={classNames("pt-6 pb-4", collapsed ? "px-0 flex justify-center" : "px-5")}>
|
||||
@ -85,11 +86,11 @@ export function Sidebar({ collapsed = false }: { collapsed?: boolean }) {
|
||||
</nav>
|
||||
|
||||
{!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>
|
||||
<div className="px-5 py-4 border-t border-white/10 flex flex-col items-center">
|
||||
<div className="text-[10px] uppercase tracking-widest text-white/40 mb-2 text-center">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 }}>
|
||||
navy), cropped 1px left / 3px bottom and scaled up. Centered. */}
|
||||
<div className="overflow-hidden mx-auto" style={{ width: 143, height: 37 }}>
|
||||
<img
|
||||
src="/special-partners.jpg"
|
||||
alt="Special Partners"
|
||||
|
||||
@ -4,6 +4,7 @@ import { ShieldCheck, PanelLeftClose, PanelLeftOpen, Bell } from "lucide-react";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { getUnreadCount } from "@/lib/api";
|
||||
import { AccountMenu } from "./AccountMenu";
|
||||
import { SpinLogo } from "./SpinLogo";
|
||||
|
||||
export function Topbar({
|
||||
collapsed,
|
||||
@ -11,29 +12,33 @@ export function Topbar({
|
||||
}: {
|
||||
collapsed?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
onOpenMobileMenu?: () => void;
|
||||
}) {
|
||||
const { isAdmin } = useAuth();
|
||||
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-4 sticky top-0 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<header className="h-14 bg-surface border-b border-border flex items-center justify-between px-3 sm:px-4 sticky top-0 z-30 pt-safe">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* desktop: collapse toggle */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
title={collapsed ? "메뉴 펼치기" : "메뉴 접기"}
|
||||
aria-label="사이드바 토글"
|
||||
className="p-2 rounded-control text-ink-secondary hover:bg-canvas hover:text-ink transition-colors"
|
||||
className="hidden md:inline-flex 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">
|
||||
{/* mobile: brand (sidebar hidden) */}
|
||||
<div className="md:hidden"><SpinLogo variant="dark" /></div>
|
||||
<div className="hidden md:block text-sm text-ink-secondary truncate">
|
||||
<span className="font-semibold text-ink">Special Partners</span> 내부 운영 플랫폼
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{isAdmin && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-navy bg-navy-subtle rounded-pill px-2.5 py-1">
|
||||
<span className="hidden sm:inline-flex items-center gap-1.5 text-xs font-medium text-navy bg-navy-subtle rounded-pill px-2.5 py-1">
|
||||
<ShieldCheck size={13} /> 관리자
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -125,10 +125,13 @@ export function Modal({
|
||||
return () => window.removeEventListener("keydown", onEsc);
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
// mobile: bottom sheet (full width, rounded top); sm+: centered dialog
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal>
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4" role="dialog" aria-modal>
|
||||
<div className="absolute inset-0 bg-ink/40" onClick={onClose} />
|
||||
<div className={classNames("relative bg-surface rounded-card shadow-pop w-full max-h-[88vh] flex flex-col", wide ? "max-w-3xl" : "max-w-lg")}>
|
||||
<div className={classNames(
|
||||
"relative bg-surface shadow-pop w-full flex flex-col rounded-t-card sm:rounded-card max-h-[92vh] sm:max-h-[88vh] pb-safe sm:pb-0",
|
||||
wide ? "sm:max-w-3xl" : "sm:max-w-lg")}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-divider">
|
||||
<h3 className="text-base font-bold text-ink">{title}</h3>
|
||||
<button onClick={onClose} className="text-ink-muted hover:text-ink p-1 rounded-control hover:bg-canvas">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { getMe, setLogoutUrl } from "@/lib/api";
|
||||
import type { Me } from "@/types";
|
||||
|
||||
interface AuthCtx {
|
||||
@ -14,6 +14,7 @@ const Ctx = createContext<AuthCtx>({ me: null, isAdmin: false, email: "", loadin
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const q = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 5 * 60_000 });
|
||||
useEffect(() => { setLogoutUrl(q.data?.logoutUrl); }, [q.data?.logoutUrl]);
|
||||
const value: AuthCtx = {
|
||||
me: q.data ?? null,
|
||||
isAdmin: q.data?.isAdmin ?? false,
|
||||
|
||||
@ -86,6 +86,23 @@ body {
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
/* On phones, dense tables scroll horizontally within their card instead of
|
||||
forcing the whole page to scroll. */
|
||||
@media (max-width: 767px) {
|
||||
.dense-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- mobile safe areas (notch / Flutter shell) ---------- */
|
||||
.pt-safe { padding-top: env(safe-area-inset-top); }
|
||||
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
|
||||
/* bottom tab height (48) + home-indicator inset */
|
||||
.pb-tabbar { padding-bottom: calc(56px + env(safe-area-inset-bottom)); }
|
||||
|
||||
/* thin scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@ -18,10 +18,13 @@ const asParam = new URLSearchParams(window.location.search).get("as");
|
||||
if (asParam === "user") api.defaults.params = { as: "user" };
|
||||
|
||||
/* ---- auth / session ---- */
|
||||
// Logout: oauth2-proxy(Keycloak) 세션을 종료. (프론트 앞단 VirtualService가 /oauth2/* 를
|
||||
// oauth2-proxy로 라우팅) — 로컬 DEV_AUTH mock 환경에선 동작하지 않습니다.
|
||||
export const SIGN_OUT_URL = "/oauth2/sign_out?rd=%2F";
|
||||
export const logout = () => { window.location.href = SIGN_OUT_URL; };
|
||||
// Logout URL은 infra가 모든 앱에 공통 주입하는 LOGOUT_URL(/me 에서 전달)을 사용한다:
|
||||
// edge oauth2-proxy 세션 종료 → Keycloak end-session 으로 SSO까지 완전 로그아웃.
|
||||
// /me 응답 전이나 로컬 fallback 용 기본값(공통값과 동일).
|
||||
let _logoutUrl =
|
||||
"/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout";
|
||||
export const setLogoutUrl = (u?: string) => { if (u) _logoutUrl = u; };
|
||||
export const logout = () => { window.location.href = _logoutUrl; };
|
||||
|
||||
/* ---- identity / nav ---- */
|
||||
export const getMe = () => api.get<Me>("/me").then((r) => r.data);
|
||||
|
||||
@ -40,7 +40,7 @@ export function AccountSettingsPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader title="기본" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>저장</Button>} />
|
||||
<div className="p-5 grid grid-cols-2 gap-4">
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="표시 이름"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} /></Field>
|
||||
<Field label="이메일 (변경 불가)"><Input value={member.email} disabled /></Field>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarDays, Plus } from "lucide-react";
|
||||
import {
|
||||
getAttendance, getLeave, getOvertime, getLeaveBalance, createLeave, createOvertime, cancelLeave,
|
||||
} from "@/lib/api";
|
||||
import { getAttendance, getLeave, getLeaveBalance, createLeave, cancelLeave } from "@/lib/api";
|
||||
import {
|
||||
Card, Button, Badge, Stat, PageHeader, Modal, Field, Input, Select,
|
||||
Textarea, Tabs, EmptyState, LoadingState,
|
||||
@ -15,20 +13,19 @@ import type { LeaveType } from "@/types";
|
||||
|
||||
const THIS_MONTH = new Date().toISOString().slice(0, 7);
|
||||
|
||||
// 유저 근무 화면: 본인 근무 기록 · 휴가/공가 · 남은 연차(소수점)만. 전사/소정근로/
|
||||
// 달성률 같은 집계 수치는 관리자 근무관리에서만 표시.
|
||||
// 유저 근무 화면: 본인 근무 기록 · 휴가/공가 · 남은 연차(소수점)만.
|
||||
// 초과근무·전사 집계는 노출하지 않음 — 관리자만 '근무 관리'에서 확인.
|
||||
export function AttendancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState("records");
|
||||
const [leaveOpen, setLeaveOpen] = useState(false);
|
||||
const [otOpen, setOtOpen] = useState(false);
|
||||
|
||||
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 bal = balQ.data;
|
||||
const pending = (leaveQ.data ?? []).filter((l) => l.status === "pending").length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -37,7 +34,7 @@ export function AttendancePage() {
|
||||
<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" />
|
||||
<Stat label="대기중 신청" value={pending} sub="휴가·공가 승인 대기" accent="#B54708" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@ -47,12 +44,10 @@ export function AttendancePage() {
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
{ 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 },
|
||||
{ key: "leave", label: "휴가/공가", badge: pending },
|
||||
]}
|
||||
/>
|
||||
{tab === "leave" && <Button size="sm" icon={<Plus size={14} />} onClick={() => setLeaveOpen(true)}>휴가 신청</Button>}
|
||||
{tab === "overtime" && <Button size="sm" icon={<Plus size={14} />} onClick={() => setOtOpen(true)}>초과근무 신청</Button>}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
@ -97,32 +92,10 @@ export function AttendancePage() {
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "overtime" && (
|
||||
otQ.isLoading ? <LoadingState /> : (otQ.data?.length ?? 0) === 0 ? <EmptyState title="신청 내역이 없습니다" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>날짜</th><th>시간</th><th>사유</th><th>상태</th></tr></thead>
|
||||
<tbody>
|
||||
{otQ.data!.map((o) => {
|
||||
const m = REQ_STATUS_META[o.status];
|
||||
return (
|
||||
<tr key={o.id}>
|
||||
<td className="tabular">{formatDate(o.date)}</td>
|
||||
<td className="tabular">{minutesToHM(o.minutes)}</td>
|
||||
<td className="text-ink-secondary">{o.reason}</td>
|
||||
<td><Badge label={m.label} fg={m.fg} bg={m.bg} dot /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -145,7 +118,7 @@ function LeaveModal({ open, onClose, onDone }: { open: boolean; onClose: () => v
|
||||
<Field label="종류"><Select value={type} onChange={(e) => setType(e.target.value as LeaveType)}>
|
||||
{Object.entries(LEAVE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="시작일"><Input type="date" value={startDate} onChange={(e) => setStart(e.target.value)} /></Field>
|
||||
<Field label="종료일"><Input type="date" value={endDate} onChange={(e) => setEnd(e.target.value)} /></Field>
|
||||
</div>
|
||||
@ -155,26 +128,3 @@ function LeaveModal({ open, onClose, onDone }: { open: boolean; onClose: () => v
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function OvertimeModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) {
|
||||
const [date, setDate] = useState("");
|
||||
const [hours, setHours] = useState("2");
|
||||
const [reason, setReason] = useState("");
|
||||
const m = useMutation({
|
||||
mutationFn: () => createOvertime({ date, minutes: Math.round(parseFloat(hours) * 60), reason }),
|
||||
onSuccess: () => { onDone(); onClose(); setReason(""); },
|
||||
});
|
||||
return (
|
||||
<Modal
|
||||
open={open} onClose={onClose} title="초과근무 신청"
|
||||
footer={<><Button variant="secondary" onClick={onClose}>취소</Button><Button disabled={!date || m.isPending} onClick={() => m.mutate()}>신청</Button></>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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="text-xs text-ink-muted">※ 초과근무는 관리자만 확인·승인합니다.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ export function DashboardPage() {
|
||||
<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">
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<QuickLink to="/attendance" icon={<Clock size={18} />} label="근무 / 휴가" />
|
||||
<QuickLink to="/projects" icon={<FolderKanban size={18} />} label="내 프로젝트" />
|
||||
<QuickLink to="/incentive" icon={<Coins size={18} />} label="내 인센티브" />
|
||||
|
||||
@ -12,13 +12,12 @@ export function ProfilePage() {
|
||||
const member = me?.member;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [phone, setPhone] = useState(member?.phone ?? "");
|
||||
const [position, setPosition] = useState(member?.position ?? "");
|
||||
|
||||
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 save = useMutation({ mutationFn: () => updateMember(member!.id, { phone }), onSuccess: refresh });
|
||||
const avatarM = useMutation({ mutationFn: (f: File) => uploadAvatar(f), onSuccess: refresh });
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
@ -54,7 +53,7 @@ export function ProfilePage() {
|
||||
<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">
|
||||
<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" /> : "미지정"} />
|
||||
@ -67,9 +66,8 @@ export function ProfilePage() {
|
||||
|
||||
<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">
|
||||
<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={position} onChange={(e) => setPosition(e.target.value)} placeholder="예: 선임 컨설턴트" /></Field>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -234,7 +234,7 @@ function TaskModal({ projectId, onClose, onDone }: { projectId: string; onClose:
|
||||
footer={<><Button variant="secondary" onClick={onClose}>취소</Button><Button disabled={!form.title || m.isPending} onClick={() => m.mutate()}>추가</Button></>}>
|
||||
<div className="space-y-4">
|
||||
<Field label="작업명"><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="상태"><Select value={form.lane} onChange={(e) => setForm({ ...form, lane: e.target.value })}>
|
||||
{Object.entries(LANE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select></Field>
|
||||
@ -308,7 +308,7 @@ function ContractTab({ projectId }: { projectId: string }) {
|
||||
|
||||
<Card>
|
||||
<CardHeader title="계약 정보" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>저장</Button>} />
|
||||
<div className="p-5 grid grid-cols-2 gap-4">
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="계약 금액 (KRW)"><Input type="number" value={form.totalAmount} onChange={(e) => setForm({ ...form, totalAmount: e.target.value })} /></Field>
|
||||
<Field label="BE — 손익분기 최소 금액 (KRW)"><Input type="number" value={form.beAmount} onChange={(e) => setForm({ ...form, beAmount: e.target.value })} /></Field>
|
||||
<Field label="관리자 주의사항"><Input value={form.adminCaution} onChange={(e) => setForm({ ...form, adminCaution: e.target.value })} /></Field>
|
||||
|
||||
@ -44,7 +44,7 @@ export function ProjectsPage() {
|
||||
{projQ.isLoading ? <LoadingState /> : filtered.length === 0 ? (
|
||||
<EmptyState title="참여 중인 프로젝트가 없습니다" icon={<FolderKanban size={28} />} description="프로젝트에 배정되면 여기에 표시됩니다." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{filtered.map((p) => {
|
||||
const m = PROJECT_STATUS_META[p.status];
|
||||
return (
|
||||
@ -109,7 +109,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
<div className="space-y-4">
|
||||
<Field label="프로젝트명"><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="예: CardioScan FDA 510(k)" /></Field>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Field label="업체">
|
||||
<div className="flex gap-1">
|
||||
<Select value={companyId} onChange={(e) => { setCompanyId(e.target.value); setProductId(""); setVersionId(""); }}>
|
||||
@ -144,7 +144,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Field label="컨설팅 종류"><Input value={form.consultingType} onChange={(e) => setForm({ ...form, consultingType: e.target.value })} placeholder="예: 510(k)" /></Field>
|
||||
<Field label="제출 국가"><Input value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} placeholder="예: 미국(FDA)" /></Field>
|
||||
<Field label="계약 범위">
|
||||
@ -153,7 +153,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Field label="PM 이메일"><Input value={form.pmEmail} onChange={(e) => setForm({ ...form, pmEmail: e.target.value })} /></Field>
|
||||
<Field label="시작일"><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></Field>
|
||||
<Field label="마감일"><Input type="date" value={form.dueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value })} /></Field>
|
||||
|
||||
@ -113,7 +113,7 @@ function TxModal({ onClose, onDone }: { onClose: () => void; onDone: () => void
|
||||
<Modal open onClose={onClose} title="거래 입력"
|
||||
footer={<><Button variant="secondary" onClick={onClose}>취소</Button><Button disabled={!f.amount || m.isPending} onClick={() => m.mutate()}>저장</Button></>}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="날짜"><Input type="date" value={f.date} onChange={(e) => setF({ ...f, date: e.target.value })} /></Field>
|
||||
<Field label="구분"><Select value={f.kind} onChange={(e) => setF({ ...f, kind: e.target.value as TxnKind })}>{Object.entries(TXN_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</Select></Field>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { getApprovals, decideLeave, decideOvertime, getAttendance } from "@/lib/api";
|
||||
import { getApprovals, decideLeave, getAttendance } from "@/lib/api";
|
||||
import {
|
||||
Card, Button, Tabs, PageHeader, EmptyState, LoadingState, Input,
|
||||
} from "@/components/ui";
|
||||
@ -16,13 +16,12 @@ export function ApprovalsPage() {
|
||||
|
||||
const refresh = () => { qc.invalidateQueries({ queryKey: ["approvals"] }); qc.invalidateQueries({ queryKey: ["approvals-count"] }); };
|
||||
const decL = useMutation({ mutationFn: ({ id, ok }: { id: string; ok: boolean }) => decideLeave(id, ok), onSuccess: refresh });
|
||||
const decO = useMutation({ mutationFn: ({ id, ok }: { id: string; ok: boolean }) => decideOvertime(id, ok), onSuccess: refresh });
|
||||
|
||||
const pendingCount = (q.data?.leave.length ?? 0) + (q.data?.overtime.length ?? 0);
|
||||
const pendingCount = q.data?.leave.length ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="승인 관리" description="구성원의 휴가·초과근무 신청을 검토하고 전체 근무 기록을 확인합니다." />
|
||||
<PageHeader title="승인 관리" description="구성원의 휴가·공가 신청을 검토하고 전체 근무 기록을 확인합니다. (초과근무는 근무 관리에서 자동 집계)" />
|
||||
<Card>
|
||||
<div className="px-3 pt-2">
|
||||
<Tabs active={tab} onChange={setTab} tabs={[{ key: "queue", label: "승인 대기", badge: pendingCount }, { key: "records", label: "전체 근무 기록" }]} />
|
||||
@ -52,26 +51,6 @@ export function ApprovalsPage() {
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-ink mb-2">초과근무</h3>
|
||||
{(q.data?.overtime.length ?? 0) === 0 ? <EmptyState title="대기중 초과근무 신청 없음" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>신청자</th><th>날짜</th><th>시간</th><th>사유</th><th className="text-right">처리</th></tr></thead>
|
||||
<tbody>
|
||||
{q.data!.overtime.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<td>{o.memberEmail}</td><td className="tabular">{formatDate(o.date)}</td><td className="tabular">{minutesToHM(o.minutes)}</td>
|
||||
<td className="text-ink-secondary">{o.reason}</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<Button size="sm" variant="secondary" icon={<Check size={14} />} className="mr-1" onClick={() => decO.mutate({ id: o.id, ok: true })}>승인</Button>
|
||||
<Button size="sm" variant="danger" icon={<X size={14} />} onClick={() => decO.mutate({ id: o.id, ok: false })}>반려</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@ -201,7 +201,7 @@ function SimulatorTab() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3 text-sm font-bold text-ink"><Beaker size={16} /> 입력</div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
||||
<Field label="계약 금액"><Input type="number" value={total} onChange={(e) => setTotal(e.target.value)} /></Field>
|
||||
<Field label="BE (손익분기)"><Input type="number" value={be} onChange={(e) => setBe(e.target.value)} /></Field>
|
||||
</div>
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { Member } from "@/types";
|
||||
|
||||
const RANKS = ["주임", "선임", "책임", "파트너"];
|
||||
const RANKS = ["인턴", "주임", "선임", "책임", "파트너"];
|
||||
|
||||
export function MembersPage() {
|
||||
const qc = useQueryClient();
|
||||
@ -68,7 +68,7 @@ function MemberCreateModal({ onClose, onDone, depts }: { onClose: () => void; on
|
||||
<Modal open onClose={onClose} title="구성원 추가"
|
||||
footer={<><Button variant="secondary" onClick={onClose}>취소</Button><Button disabled={!f.email || m.isPending} onClick={() => m.mutate()}>추가</Button></>}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="이름"><Input value={f.displayName} onChange={(e) => setF({ ...f, displayName: e.target.value })} /></Field>
|
||||
<Field label="이메일"><Input value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} /></Field>
|
||||
<Field label="직급"><Select value={f.rank} onChange={(e) => setF({ ...f, rank: e.target.value })}>{RANKS.map((r) => <option key={r}>{r}</option>)}</Select></Field>
|
||||
@ -85,7 +85,7 @@ function MemberCreateModal({ onClose, onDone, depts }: { onClose: () => void; on
|
||||
function MemberEditDrawer({ member, depts, onClose, onDone }: { member: Member; depts: { id: string; name: string }[]; onClose: () => void; onDone: () => void }) {
|
||||
const [f, setF] = useState({ ...member });
|
||||
const m = useMutation({
|
||||
mutationFn: () => updateMember(member.id, { displayName: f.displayName, rank: f.rank, role: f.role, departmentId: f.departmentId || null, isPartner: f.isPartner, phone: f.phone, position: f.position, annualLeave: Number(f.annualLeave) }),
|
||||
mutationFn: () => updateMember(member.id, { displayName: f.displayName, rank: f.rank, role: f.role, departmentId: f.departmentId || null, isPartner: f.isPartner, phone: f.phone, annualLeave: Number(f.annualLeave) }),
|
||||
onSuccess: onDone,
|
||||
});
|
||||
return (
|
||||
@ -94,13 +94,12 @@ function MemberEditDrawer({ member, depts, onClose, onDone }: { member: Member;
|
||||
<div className="space-y-4">
|
||||
<Field label="이름"><Input value={f.displayName} onChange={(e) => setF({ ...f, displayName: e.target.value })} /></Field>
|
||||
<Field label="이메일 (Keycloak 매칭)"><Input value={f.email} disabled /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="직급"><Select value={f.rank} onChange={(e) => setF({ ...f, rank: e.target.value as any })}>{RANKS.map((r) => <option key={r}>{r}</option>)}</Select></Field>
|
||||
<Field label="권한"><Select value={f.role} onChange={(e) => setF({ ...f, role: e.target.value as any })}><option value="user">구성원</option><option value="admin">관리자</option></Select></Field>
|
||||
<Field label="부서"><Select value={f.departmentId ?? ""} onChange={(e) => setF({ ...f, departmentId: e.target.value })}><option value="">미지정</option>{depts.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}</Select></Field>
|
||||
<Field label="연차 부여일"><Input type="number" value={f.annualLeave} onChange={(e) => setF({ ...f, annualLeave: Number(e.target.value) })} /></Field>
|
||||
<Field label="전화번호"><Input value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} /></Field>
|
||||
<Field label="직책"><Input value={f.position} onChange={(e) => setF({ ...f, position: e.target.value })} /></Field>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={f.isPartner} onChange={(e) => setF({ ...f, isPartner: e.target.checked })} /> 파트너</label>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
} from "@/components/ui";
|
||||
import { formatWon } from "@/lib/format";
|
||||
|
||||
const RANKS = ["주임", "선임", "책임", "파트너"];
|
||||
const RANKS = ["인턴", "주임", "선임", "책임", "파트너"];
|
||||
|
||||
export function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
@ -53,12 +53,12 @@ function IncentiveConfigCard({ initial, onSaved }: { initial: any; onSaved: () =
|
||||
<Card className="mb-4">
|
||||
<CardHeader title={`인센티브 규칙 (${f.year}년)`} action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>저장</Button>} />
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Field label="포인트 환율 (1P = ? KRW)" hint={formatWon(+f.pointRate)}><Input type="number" value={f.pointRate} onChange={(e) => setF({ ...f, pointRate: e.target.value })} /></Field>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink mb-2">계약 단계 비율 (합 {stageSum}% {stageSum !== 100 && <span className="text-status-pending-fg">⚠ 100%가 아님</span>})</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Field label="계약금 %"><Input type="number" value={f.depositPct} onChange={(e) => setF({ ...f, depositPct: e.target.value })} /></Field>
|
||||
<Field label="중도금 %"><Input type="number" value={f.middlePct} onChange={(e) => setF({ ...f, middlePct: e.target.value })} /></Field>
|
||||
<Field label="잔금 %"><Input type="number" value={f.finalPct} onChange={(e) => setF({ ...f, finalPct: e.target.value })} /></Field>
|
||||
@ -66,14 +66,14 @@ function IncentiveConfigCard({ initial, onSaved }: { initial: any; onSaved: () =
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink mb-2">non-BE 분배 (합 {nonBeSum}%) — BE 초과분의 회사 : 파트너 분배</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="회사 몫 %"><Input type="number" value={f.nonBeCompanyPct} onChange={(e) => setF({ ...f, nonBeCompanyPct: e.target.value })} /></Field>
|
||||
<Field label="파트너 몫 %"><Input type="number" value={f.nonBePartnerPct} onChange={(e) => setF({ ...f, nonBePartnerPct: e.target.value })} /></Field>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink mb-2">직급별 포인트 할당량 (초과분이 인센티브로 지급)</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{RANKS.map((r) => (
|
||||
<Field key={r} label={r}><Input type="number" value={quota[r]} onChange={(e) => setQuota({ ...quota, [r]: e.target.value })} /></Field>
|
||||
))}
|
||||
@ -101,7 +101,7 @@ function WorkPolicyCard({ initial, onSaved }: { initial: any; onSaved: () => voi
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="근무 정책 (근로기준법 기준)" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>저장</Button>} />
|
||||
<div className="p-5 grid grid-cols-3 gap-4">
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Field label="주 소정근로시간"><Input type="number" value={f.weeklyHours} onChange={(e) => setF({ ...f, weeklyHours: e.target.value })} /></Field>
|
||||
<Field label="일 소정근로(분)"><Input type="number" value={f.dailyStandardMin} onChange={(e) => setF({ ...f, dailyStandardMin: e.target.value })} /></Field>
|
||||
<Field label="휴게시간(분)"><Input type="number" value={f.lunchMinutes} onChange={(e) => setF({ ...f, lunchMinutes: e.target.value })} /></Field>
|
||||
|
||||
@ -9,7 +9,7 @@ export interface User {
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export type Rank = "주임" | "선임" | "책임" | "파트너";
|
||||
export type Rank = "인턴" | "주임" | "선임" | "책임" | "파트너";
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
@ -61,6 +61,7 @@ export interface Me {
|
||||
user: User;
|
||||
member: Member | null;
|
||||
isAdmin: boolean;
|
||||
logoutUrl: string;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user