feat: 전 화면 반응형(모바일) + 인턴 직급 + 초과근무 탭 제거 + 로그아웃 URL + 로고 정렬
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:
theorose49 2026-06-28 10:55:54 +09:00
parent 3a6d1b0440
commit e3b5a874b3
23 changed files with 248 additions and 137 deletions

View File

@ -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

View File

@ -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 && (

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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"

View File

@ -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>
)}

View File

@ -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">

View File

@ -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,

View File

@ -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;

View File

@ -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);

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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="내 인센티브" />

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
)
)}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 {