feat: spin 프론트엔드 전체 구현 (React+TS+Vite+Tailwind)
All checks were successful
build-and-push / build (push) Successful in 36s
All checks were successful
build-and-push / build (push) Successful in 36s
- AppShell·사이드바(역할별 네비)·탑바·UI킷, react-query·axios·recharts·dnd-kit - SP 디자인 토큰 재사용(navy/canvas/Noto Sans KR) + 회계용 고밀도 확장 - 페이지: 대시보드, 근무(타임시트·휴가/초과 신청), 프로젝트 목록/상세 (간트·칸반·캘린더·작업자portion·업체담당자·계약/분할입금 admin), 인센티브(유저 대시보드), 인센티브 관리 콘솔(단계 stepper·시뮬레이터·오버라이드), 회계(현금-인센티브 갭·원장·세금), 구성원·설정·승인·프로필 - 권한 가드: 관리자 전용 라우트, ?as=user 로 구성원 시점 미리보기 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
69e641e271
commit
7cab590fe2
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,4 +6,5 @@ __pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@ -1,8 +1,18 @@
|
||||
# Placeholder Dockerfile — nginx-unprivileged 기반 static serve, port 8080.
|
||||
# 실제 코드 추가 후 본인 stack(Node/Python/Go 등)으로 교체 권장.
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# 정적 파일 (html/css/js) 또는 SPA build output 을 root 에 복사.
|
||||
COPY --chown=nginx:nginx . /usr/share/nginx/html/
|
||||
# --- Stage 1: build ---
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: serve ---
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# The image entrypoint runs envsubst over /etc/nginx/templates/*.template at
|
||||
# startup. Only SPIN_BACKEND_URL is substituted so nginx's own $vars survive.
|
||||
ENV NGINX_ENVSUBST_FILTER=SPIN_BACKEND_URL
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
EXPOSE 8080
|
||||
|
||||
19
index.html
Normal file
19
index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<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" />
|
||||
<title>spin · Special Partners</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&family=Noto+Sans+KR:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
nginx.conf.template
Normal file
29
nginx.conf.template
Normal file
@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location ~ \.mjs$ {
|
||||
types { text/javascript mjs; }
|
||||
default_type text/javascript;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Backend API — upstream host injected from SPIN_BACKEND_URL (envsubst).
|
||||
# local docker-compose: backend:8080 ; cluster: spin-backend.internal.svc.cluster.local:80
|
||||
location /api/ {
|
||||
proxy_pass http://${SPIN_BACKEND_URL};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
}
|
||||
3475
package-lock.json
generated
Normal file
3475
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "spin-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.51.1",
|
||||
"axios": "^1.7.2",
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
public/special-partners.jpg
Normal file
BIN
public/special-partners.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
6
public/spin.svg
Normal file
6
public/spin.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="14" fill="#11224F"/>
|
||||
<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" stroke-width="4.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="44" cy="42" r="3" fill="#C99A2E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
58
src/App.tsx
Normal file
58
src/App.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { AuthProvider, useAuth } from "@/context/Auth";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { LoadingState } from "@/components/ui";
|
||||
import { DashboardPage } from "@/pages/Dashboard";
|
||||
import { AttendancePage } from "@/pages/Attendance";
|
||||
import { ProjectsPage } from "@/pages/Projects";
|
||||
import { ProjectDetailPage } from "@/pages/ProjectDetail";
|
||||
import { IncentivePage } from "@/pages/Incentive";
|
||||
import { ProfilePage } from "@/pages/Profile";
|
||||
import { ApprovalsPage } from "@/pages/admin/Approvals";
|
||||
import { IncentiveAdminPage } from "@/pages/admin/IncentiveAdmin";
|
||||
import { AccountingPage } from "@/pages/admin/Accounting";
|
||||
import { MembersPage } from "@/pages/admin/Members";
|
||||
import { SettingsPage } from "@/pages/admin/Settings";
|
||||
|
||||
function RequireAdmin({ children }: { children: JSX.Element }) {
|
||||
const { isAdmin, loading } = useAuth();
|
||||
if (loading) return <LoadingState />;
|
||||
return isAdmin ? children : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const { loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-canvas">
|
||||
<LoadingState label="spin 로딩 중…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/attendance" element={<AttendancePage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetailPage />} />
|
||||
<Route path="/incentive" element={<IncentivePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/admin/approvals" element={<RequireAdmin><ApprovalsPage /></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>} />
|
||||
<Route path="/admin/settings" element={<RequireAdmin><SettingsPage /></RequireAdmin>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Shell />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
17
src/components/AppShell.tsx
Normal file
17
src/components/AppShell.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-canvas">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar />
|
||||
<main className="flex-1 min-w-0 p-6 max-w-[1600px] w-full mx-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/Gantt.tsx
Normal file
84
src/components/Gantt.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ProjectTask } from "@/types";
|
||||
import { LANE_LABELS } from "@/lib/format";
|
||||
|
||||
const LANE_COLOR: Record<string, string> = {
|
||||
todo: "#98A2B3", doing: "#2E90FA", review: "#7A5AF8", done: "#12B76A",
|
||||
};
|
||||
|
||||
const DAY = 86400000;
|
||||
|
||||
function parse(d: string): number {
|
||||
const t = new Date(d).getTime();
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
// Lightweight SVG-free Gantt: a day-scaled track with one bar per task. Matches
|
||||
// the real calendar by positioning bars on an absolute date axis.
|
||||
export function Gantt({ tasks }: { tasks: ProjectTask[] }) {
|
||||
const { min, max, months } = useMemo(() => {
|
||||
const starts = tasks.map((t) => parse(t.start)).filter(Boolean);
|
||||
const ends = tasks.map((t) => parse(t.end)).filter(Boolean);
|
||||
if (!starts.length) return { min: 0, max: 0, months: [] as { label: string; left: number; width: number }[] };
|
||||
let lo = Math.min(...starts), hi = Math.max(...ends, ...starts);
|
||||
lo = lo - DAY * 3; hi = hi + DAY * 3;
|
||||
const span = hi - lo;
|
||||
// month ticks
|
||||
const months: { label: string; left: number; width: number }[] = [];
|
||||
const d = new Date(lo);
|
||||
d.setDate(1);
|
||||
while (d.getTime() < hi) {
|
||||
const start = Math.max(d.getTime(), lo);
|
||||
const next = new Date(d); next.setMonth(d.getMonth() + 1);
|
||||
const end = Math.min(next.getTime(), hi);
|
||||
months.push({
|
||||
label: `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`,
|
||||
left: ((start - lo) / span) * 100,
|
||||
width: ((end - start) / span) * 100,
|
||||
});
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
}
|
||||
return { min: lo, max: hi, months };
|
||||
}, [tasks]);
|
||||
|
||||
if (!tasks.length || max === min) {
|
||||
return <div className="text-sm text-ink-muted py-10 text-center">일정이 있는 작업이 없습니다.</div>;
|
||||
}
|
||||
const span = max - min;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[720px]">
|
||||
{/* month axis */}
|
||||
<div className="flex h-7 border-b border-border mb-2 ml-[200px] relative text-[11px] text-ink-muted">
|
||||
{months.map((m, i) => (
|
||||
<div key={i} className="absolute top-0 h-full border-l border-divider pl-1" style={{ left: `${m.left}%`, width: `${m.width}%` }}>
|
||||
{m.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tasks.map((t) => {
|
||||
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";
|
||||
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>
|
||||
<div className="relative flex-1 h-full">
|
||||
<div
|
||||
className="absolute top-1.5 h-6 rounded-md flex items-center px-2 text-[11px] text-white font-medium overflow-hidden"
|
||||
style={{ left: `${left}%`, width: `${width}%`, background: color }}
|
||||
title={`${LANE_LABELS[t.lane]} · ${t.progress}%`}
|
||||
>
|
||||
<span className="truncate">{t.progress > 0 ? `${t.progress}%` : ""}</span>
|
||||
<div className="absolute left-0 bottom-0 h-1 bg-white/40" style={{ width: `${t.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/components/Kanban.tsx
Normal file
78
src/components/Kanban.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import {
|
||||
DndContext, PointerSensor, useSensor, useSensors, closestCorners,
|
||||
type DragEndEvent, useDroppable, useDraggable,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Lane, ProjectTask } from "@/types";
|
||||
import { LANE_LABELS, formatDate, classNames } from "@/lib/format";
|
||||
|
||||
const LANES: Lane[] = ["todo", "doing", "review", "done"];
|
||||
const LANE_DOT: Record<Lane, string> = {
|
||||
todo: "#98A2B3", doing: "#2E90FA", review: "#7A5AF8", done: "#12B76A",
|
||||
};
|
||||
|
||||
export function Kanban({
|
||||
tasks, onMove, readOnly,
|
||||
}: { tasks: ProjectTask[]; onMove: (taskId: string, lane: Lane) => void; readOnly?: boolean }) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const lane = e.over?.id as Lane | undefined;
|
||||
const taskId = e.active.id as string;
|
||||
if (lane && LANES.includes(lane)) {
|
||||
const t = tasks.find((x) => x.id === taskId);
|
||||
if (t && t.lane !== lane) onMove(taskId, lane);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={readOnly ? undefined : onDragEnd}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
{LANES.map((lane) => (
|
||||
<Column key={lane} lane={lane} tasks={tasks.filter((t) => t.lane === lane)} readOnly={readOnly} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({ lane, tasks, readOnly }: { lane: Lane; tasks: ProjectTask[]; readOnly?: boolean }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: lane });
|
||||
return (
|
||||
<div ref={setNodeRef} className={classNames("rounded-card border bg-canvas/60 min-h-[200px] transition-colors", isOver ? "border-navy bg-navy-subtle/40" : "border-border")}>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: LANE_DOT[lane] }} />
|
||||
<span className="text-sm font-semibold text-ink">{LANE_LABELS[lane]}</span>
|
||||
<span className="ml-auto text-xs text-ink-muted font-num">{tasks.length}</span>
|
||||
</div>
|
||||
<div className="p-2 space-y-2">
|
||||
{tasks.map((t) => <KanbanCard key={t.id} task={t} readOnly={readOnly} />)}
|
||||
{tasks.length === 0 && <div className="text-xs text-ink-muted text-center py-6">비어 있음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCard({ task, readOnly }: { task: ProjectTask; readOnly?: boolean }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, disabled: readOnly });
|
||||
const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined;
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef} style={style} {...(readOnly ? {} : listeners)} {...attributes}
|
||||
className={classNames(
|
||||
"bg-surface border border-border rounded-control p-3 shadow-card",
|
||||
readOnly ? "" : "cursor-grab active:cursor-grabbing", isDragging && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-medium text-ink">{task.title}</div>
|
||||
<div className="flex items-center justify-between mt-2 text-[11px] text-ink-muted">
|
||||
<span className="tabular">{formatDate(task.start)}</span>
|
||||
<span>{task.assignee ? task.assignee.split("@")[0] : ""}</span>
|
||||
</div>
|
||||
{task.progress > 0 && (
|
||||
<div className="h-1 rounded-pill bg-divider mt-2 overflow-hidden">
|
||||
<div className="h-full bg-navy" style={{ width: `${task.progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/Sidebar.tsx
Normal file
74
src/components/Sidebar.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator,
|
||||
Wallet, Users, Settings, 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 { classNames } from "@/lib/format";
|
||||
import type { NavItem } from "@/types";
|
||||
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, Wallet, Users, Settings,
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
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 });
|
||||
const approvalCount = (apprQ.data?.leave.length ?? 0) + (apprQ.data?.overtime.length ?? 0);
|
||||
|
||||
const items = navQ.data ?? [];
|
||||
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" />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 space-y-4 overflow-y-auto 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 === "/"}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-control text-sm font-medium transition-colors",
|
||||
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>
|
||||
{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="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" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
20
src/components/SpinLogo.tsx
Normal file
20
src/components/SpinLogo.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// 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";
|
||||
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"} />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/Topbar.tsx
Normal file
33
src/components/Topbar.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import { ShieldCheck, User as UserIcon } from "lucide-react";
|
||||
|
||||
export function Topbar() {
|
||||
const { me, isAdmin } = useAuth();
|
||||
const name = me?.member?.displayName || me?.user.name || "사용자";
|
||||
const rank = me?.member?.rank;
|
||||
const initial = name.slice(0, 1);
|
||||
|
||||
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> 내부 운영 플랫폼
|
||||
</div>
|
||||
<div className="flex items-center 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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
292
src/components/ui.tsx
Normal file
292
src/components/ui.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes,
|
||||
} from "react";
|
||||
import { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { classNames } from "@/lib/format";
|
||||
|
||||
/* ---------- Card ---------- */
|
||||
export function Card({
|
||||
children, className, ...rest
|
||||
}: { children: ReactNode; className?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={classNames("bg-surface border border-border rounded-card shadow-card", className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title, subtitle, action, className,
|
||||
}: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={classNames("flex items-center justify-between px-5 py-4 border-b border-divider", className)}>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-ink">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-ink-muted mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Button ---------- */
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
size?: "sm" | "md";
|
||||
icon?: ReactNode;
|
||||
}
|
||||
export function Button({
|
||||
variant = "primary", size = "md", icon, className, children, ...rest
|
||||
}: ButtonProps) {
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-2 rounded-control font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap";
|
||||
const sizes = { sm: "text-xs px-3 h-8", md: "text-sm px-4 h-10" };
|
||||
const variants = {
|
||||
primary: "bg-navy text-white hover:bg-navy-hover",
|
||||
secondary: "bg-surface text-ink-strong border border-border-strong hover:bg-canvas",
|
||||
ghost: "bg-transparent text-ink-secondary hover:bg-canvas",
|
||||
danger: "bg-white text-[#B42318] border border-[#FDA29B] hover:bg-[#FFFBFA]",
|
||||
};
|
||||
return (
|
||||
<button className={classNames(base, sizes[size], variants[variant], className)} {...rest}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Badge / Pill ---------- */
|
||||
export function Badge({
|
||||
label, fg, bg, dot, size = "md",
|
||||
}: { label: ReactNode; fg: string; bg: string; dot?: boolean; size?: "sm" | "md" }) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
"inline-flex items-center gap-1.5 rounded-pill font-medium",
|
||||
size === "sm" ? "text-[11px] px-2 py-0.5" : "text-xs px-2.5 py-1"
|
||||
)}
|
||||
style={{ color: fg, background: bg }}
|
||||
>
|
||||
{dot && <span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: fg }} />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pill({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className={classNames("inline-flex items-center gap-1 rounded-pill bg-chip-bg text-navy text-[11px] font-medium px-2.5 py-1", className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Form controls ---------- */
|
||||
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="form-label">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="text-[11px] text-ink-muted mt-1 block">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={classNames("form-input", props.className)} />;
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select {...props} className={classNames("form-select", props.className)} />;
|
||||
}
|
||||
|
||||
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
{...props}
|
||||
className={classNames("form-input", props.className)}
|
||||
style={{ height: "auto", minHeight: 72, paddingTop: 8, paddingBottom: 8, ...props.style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Modal ---------- */
|
||||
export function Modal({
|
||||
open, onClose, title, children, footer, wide,
|
||||
}: {
|
||||
open: boolean; onClose: () => void; title: ReactNode; children: ReactNode;
|
||||
footer?: ReactNode; wide?: boolean;
|
||||
}) {
|
||||
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;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center 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="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">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 overflow-y-auto">{children}</div>
|
||||
{footer && <div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-divider">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Drawer (right) ---------- */
|
||||
export function Drawer({
|
||||
open, onClose, title, children, footer,
|
||||
}: { open: boolean; onClose: () => void; title: ReactNode; children: ReactNode; footer?: ReactNode }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal>
|
||||
<div className="absolute inset-0 bg-ink/40" onClick={onClose} />
|
||||
<div className="relative bg-surface w-full max-w-md h-full flex flex-col shadow-pop">
|
||||
<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">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 overflow-y-auto flex-1">{children}</div>
|
||||
{footer && <div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-divider">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Tabs ---------- */
|
||||
export function Tabs({
|
||||
tabs, active, onChange,
|
||||
}: { tabs: { key: string; label: string; badge?: number }[]; active: string; onChange: (k: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => onChange(t.key)}
|
||||
className={classNames(
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors flex items-center gap-2",
|
||||
active === t.key
|
||||
? "border-navy text-navy"
|
||||
: "border-transparent text-ink-secondary hover:text-ink"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{t.badge != null && t.badge > 0 && (
|
||||
<span className="font-num text-[10px] bg-chip-bg text-navy rounded-pill px-1.5 py-0.5">{t.badge}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Stat tile ---------- */
|
||||
export function Stat({
|
||||
label, value, sub, accent,
|
||||
}: { label: string; value: ReactNode; sub?: ReactNode; accent?: string }) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="text-xs font-medium text-ink-secondary">{label}</div>
|
||||
<div className="mt-2 text-2xl font-bold font-num" style={{ color: accent }}>{value}</div>
|
||||
{sub && <div className="mt-1 text-xs text-ink-muted">{sub}</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Progress bar ---------- */
|
||||
export function Progress({ pct, color = "#11224F" }: { pct: number; color?: string }) {
|
||||
const v = Math.max(0, Math.min(100, pct));
|
||||
return (
|
||||
<div className="h-2 rounded-pill bg-divider overflow-hidden">
|
||||
<div className="h-full rounded-pill transition-all" style={{ width: `${v}%`, background: color }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Stepper (incentive fix lifecycle) ---------- */
|
||||
export function Stepper({
|
||||
steps, activeIndex,
|
||||
}: { steps: { label: string; color: string }[]; activeIndex: number }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{steps.map((s, i) => (
|
||||
<div key={s.label} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold text-white"
|
||||
style={{ background: i <= activeIndex ? s.color : "#D0D5DD" }}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="text-[11px] mt-1 whitespace-nowrap" style={{ color: i <= activeIndex ? s.color : "#98A2B3" }}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < steps.length - 1 && (
|
||||
<div className="flex-1 h-0.5 mx-1 mb-4" style={{ background: i < activeIndex ? s.color : "#E4E7EC" }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- States ---------- */
|
||||
export function LoadingState({ label = "불러오는 중…" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-ink-muted text-sm gap-3">
|
||||
<div className="w-6 h-6 border-2 border-border-strong border-t-navy rounded-full animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title = "데이터가 없습니다", description, icon, action }: {
|
||||
title?: string; description?: string; icon?: ReactNode; action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
{icon && <div className="text-ink-muted mb-3">{icon}</div>}
|
||||
<div className="text-sm font-medium text-ink-secondary">{title}</div>
|
||||
{description && <div className="text-xs text-ink-muted mt-1 max-w-sm">{description}</div>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ label = "데이터를 불러오지 못했습니다.", onRetry }: { label?: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center gap-3">
|
||||
<div className="text-sm font-medium text-ink-secondary">{label}</div>
|
||||
{onRetry && <Button variant="secondary" size="sm" onClick={onRetry}>다시 시도</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={classNames("animate-pulse bg-divider rounded-md", className)} />;
|
||||
}
|
||||
|
||||
/* ---------- Page header ---------- */
|
||||
export function PageHeader({ title, description, action }: { title: ReactNode; description?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-ink">{title}</h1>
|
||||
{description && <p className="text-sm text-ink-secondary mt-1">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/context/Auth.tsx
Normal file
26
src/context/Auth.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMe } from "@/lib/api";
|
||||
import type { Me } from "@/types";
|
||||
|
||||
interface AuthCtx {
|
||||
me: Me | null;
|
||||
isAdmin: boolean;
|
||||
email: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthCtx>({ me: null, isAdmin: false, email: "", loading: true });
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const q = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 5 * 60_000 });
|
||||
const value: AuthCtx = {
|
||||
me: q.data ?? null,
|
||||
isAdmin: q.data?.isAdmin ?? false,
|
||||
email: q.data?.user.email ?? "",
|
||||
loading: q.isLoading,
|
||||
};
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(Ctx);
|
||||
100
src/index.css
Normal file
100
src/index.css
Normal file
@ -0,0 +1,100 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: "Noto Sans KR", system-ui, sans-serif;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f5f6f8;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.font-num {
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
/* ---------- form inputs ---------- */
|
||||
.form-input,
|
||||
.form-select {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 13px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
color: #101828;
|
||||
}
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: #11224f;
|
||||
box-shadow: 0 0 0 3px rgba(17, 34, 79, 0.08);
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475467;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ---------- dense accounting tables ---------- */
|
||||
.dense-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dense-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f2f4f7;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #475467;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e4e7ec;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
.dense-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f2f4f7;
|
||||
color: #344054;
|
||||
}
|
||||
.dense-table tbody tr:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.tabular {
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
/* thin scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d0d5dd;
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
173
src/lib/api.ts
Normal file
173
src/lib/api.ts
Normal file
@ -0,0 +1,173 @@
|
||||
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,
|
||||
} from "@/types";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: "/api",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
// In dev, ?as=user can be appended to simulate a non-admin (see backend auth).
|
||||
const asParam = new URLSearchParams(window.location.search).get("as");
|
||||
if (asParam === "user") api.defaults.params = { as: "user" };
|
||||
|
||||
/* ---- identity / nav ---- */
|
||||
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);
|
||||
|
||||
/* ---- 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);
|
||||
export const createMember = (b: Partial<Member>) => api.post<Member>("/members", b).then((r) => r.data);
|
||||
export const updateMember = (id: string, b: Partial<Member>) =>
|
||||
api.patch<Member>(`/members/${id}`, b).then((r) => r.data);
|
||||
export const deleteMember = (id: string) => api.delete(`/members/${id}`).then((r) => r.data);
|
||||
export const getDepartments = () => api.get<Department[]>("/departments").then((r) => r.data);
|
||||
export const createDepartment = (b: Partial<Department>) =>
|
||||
api.post<Department>("/departments", b).then((r) => r.data);
|
||||
export const updateDepartment = (id: string, b: Partial<Department>) =>
|
||||
api.patch<Department>(`/departments/${id}`, b).then((r) => r.data);
|
||||
export const deleteDepartment = (id: string) => api.delete(`/departments/${id}`).then((r) => r.data);
|
||||
export const getAudit = () => api.get<AuditLog[]>("/audit").then((r) => r.data);
|
||||
|
||||
/* ---- attendance ---- */
|
||||
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 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 }) =>
|
||||
api.get<LeaveRequest[]>("/leave", { params }).then((r) => r.data);
|
||||
export const createLeave = (b: Partial<LeaveRequest>) =>
|
||||
api.post<LeaveRequest>("/leave", b).then((r) => r.data);
|
||||
export const decideLeave = (id: string, approve: boolean, memo?: string) =>
|
||||
api.post(`/leave/${id}/decide`, { approve, memo }).then((r) => r.data);
|
||||
export const cancelLeave = (id: string) => api.post(`/leave/${id}/cancel`).then((r) => r.data);
|
||||
export const getOvertime = (params?: { email?: string }) =>
|
||||
api.get<OvertimeRequest[]>("/overtime", { params }).then((r) => r.data);
|
||||
export const createOvertime = (b: Partial<OvertimeRequest>) =>
|
||||
api.post<OvertimeRequest>("/overtime", b).then((r) => r.data);
|
||||
export const decideOvertime = (id: string, approve: boolean, memo?: string) =>
|
||||
api.post(`/overtime/${id}/decide`, { approve, memo }).then((r) => r.data);
|
||||
export const getWorkPolicy = () => api.get<WorkPolicy>("/work-policy").then((r) => r.data);
|
||||
export const putWorkPolicy = (b: Partial<WorkPolicy>) =>
|
||||
api.put<WorkPolicy>("/work-policy", b).then((r) => r.data);
|
||||
export const getApprovals = () => api.get<ApprovalQueue>("/approvals").then((r) => r.data);
|
||||
|
||||
/* ---- projects ---- */
|
||||
export const getCompanies = () => api.get<Company[]>("/companies").then((r) => r.data);
|
||||
export const createCompany = (b: Partial<Company>) => api.post<Company>("/companies", b).then((r) => r.data);
|
||||
export const getProducts = (companyId?: string) =>
|
||||
api.get<Product[]>("/products", { params: { companyId } }).then((r) => r.data);
|
||||
export const createProduct = (b: Partial<Product>) => api.post<Product>("/products", b).then((r) => r.data);
|
||||
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 }) =>
|
||||
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);
|
||||
export const updateProject = (id: string, b: Partial<Project>) =>
|
||||
api.patch<Project>(`/projects/${id}`, b).then((r) => r.data);
|
||||
export const deleteProject = (id: string) => api.delete(`/projects/${id}`).then((r) => r.data);
|
||||
|
||||
export const getProjectMembers = (id: string) =>
|
||||
api.get<ProjectMember[]>(`/projects/${id}/members`).then((r) => r.data);
|
||||
export const upsertProjectMember = (id: string, b: Partial<ProjectMember>) =>
|
||||
api.post<ProjectMember>(`/projects/${id}/members`, b).then((r) => r.data);
|
||||
export const deleteProjectMember = (pmId: string) =>
|
||||
api.delete(`/project-members/${pmId}`).then((r) => r.data);
|
||||
|
||||
export const getContacts = (id: string) =>
|
||||
api.get<ClientContact[]>(`/projects/${id}/contacts`).then((r) => r.data);
|
||||
export const upsertContact = (id: string, b: Partial<ClientContact>) =>
|
||||
api.post<ClientContact>(`/projects/${id}/contacts`, b).then((r) => r.data);
|
||||
export const deleteContact = (cId: string) => api.delete(`/contacts/${cId}`).then((r) => r.data);
|
||||
|
||||
export const getTasks = (id: string) =>
|
||||
api.get<ProjectTask[]>(`/projects/${id}/tasks`).then((r) => r.data);
|
||||
export const createTask = (id: string, b: Partial<ProjectTask>) =>
|
||||
api.post<ProjectTask>(`/projects/${id}/tasks`, b).then((r) => r.data);
|
||||
export const updateTask = (tId: string, b: Partial<ProjectTask>) =>
|
||||
api.patch<ProjectTask>(`/tasks/${tId}`, b).then((r) => r.data);
|
||||
export const deleteTask = (tId: string) => api.delete(`/tasks/${tId}`).then((r) => r.data);
|
||||
|
||||
/* ---- contract (admin) ---- */
|
||||
export const getContract = (id: string) =>
|
||||
api.get<Contract | null>(`/projects/${id}/contract`).then((r) => r.data);
|
||||
export const putContract = (id: string, b: Partial<Contract>) =>
|
||||
api.put<Contract>(`/projects/${id}/contract`, b).then((r) => r.data);
|
||||
export const getContractFiles = (id: string) =>
|
||||
api.get<ContractFile[]>(`/projects/${id}/files`).then((r) => r.data);
|
||||
export const uploadContractFile = (id: string, file: File, kind = "contract") => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("kind", kind);
|
||||
return api
|
||||
.post<ContractFile>(`/projects/${id}/files`, form, { headers: { "Content-Type": "multipart/form-data" } })
|
||||
.then((r) => r.data);
|
||||
};
|
||||
export const getFileDownloadUrl = (fId: string) =>
|
||||
api.get<{ url: string }>(`/files/${fId}/download`).then((r) => r.data.url);
|
||||
export const deleteContractFile = (fId: string) => api.delete(`/files/${fId}`).then((r) => r.data);
|
||||
|
||||
export const getPayments = (id: string) =>
|
||||
api.get<PaymentSplit[]>(`/projects/${id}/payments`).then((r) => r.data);
|
||||
export const createPayment = (id: string, b: Partial<PaymentSplit>) =>
|
||||
api.post<PaymentSplit>(`/projects/${id}/payments`, b).then((r) => r.data);
|
||||
export const updatePayment = (payId: string, b: Partial<PaymentSplit>) =>
|
||||
api.patch<PaymentSplit>(`/payments/${payId}`, b).then((r) => r.data);
|
||||
export const deletePayment = (payId: string) => api.delete(`/payments/${payId}`).then((r) => r.data);
|
||||
|
||||
/* ---- incentive ---- */
|
||||
export const getIncentiveConfig = (year?: number) =>
|
||||
api.get<IncentiveConfig>("/incentive/config", { params: { year } }).then((r) => r.data);
|
||||
export const putIncentiveConfig = (b: Partial<IncentiveConfig>) =>
|
||||
api.put<IncentiveConfig>("/incentive/config", b).then((r) => r.data);
|
||||
export const getMyIncentive = (params?: { year?: number; email?: string }) =>
|
||||
api.get<MyIncentive>("/incentive/me", { params }).then((r) => r.data);
|
||||
export const getStages = (projectId: string) =>
|
||||
api.get<PaymentStage[]>("/incentive/stages", { params: { projectId } }).then((r) => r.data);
|
||||
export const recomputeProject = (id: string, year?: number) =>
|
||||
api.post(`/incentive/projects/${id}/recompute`, null, { params: { year } }).then((r) => r.data);
|
||||
export const setStageStatus = (stId: string, status: string, fixedDate?: string) =>
|
||||
api.post(`/incentive/stages/${stId}/status`, { status, fixedDate }).then((r) => r.data);
|
||||
export const getUserIncentives = (params: { projectId?: string; email?: string }) =>
|
||||
api.get<UserIncentive[]>("/incentive/user-incentives", { params }).then((r) => r.data);
|
||||
export const patchUserIncentive = (uiId: string, b: Partial<UserIncentive>) =>
|
||||
api.patch<UserIncentive>(`/incentive/user-incentives/${uiId}`, b).then((r) => r.data);
|
||||
export const getSettlements = (year?: number) =>
|
||||
api.get<Settlement[]>("/incentive/settlements", { params: { year } }).then((r) => r.data);
|
||||
export const runSettlement = (year: number, quarter: number) =>
|
||||
api.post<Settlement[]>("/incentive/settlements/run", { year, quarter }).then((r) => r.data);
|
||||
export const fixSettlement = (sId: string) =>
|
||||
api.post<Settlement>(`/incentive/settlements/${sId}/fix`).then((r) => r.data);
|
||||
export const simulate = (b: {
|
||||
total: number; be: number;
|
||||
members: { email: string; portion: number; isPartner: boolean }[];
|
||||
config?: Partial<IncentiveConfig>;
|
||||
}) => api.post<SimResult>("/incentive/simulate", b).then((r) => r.data);
|
||||
|
||||
/* ---- accounting ---- */
|
||||
export const getAccounts = () => api.get<Account[]>("/accounts").then((r) => r.data);
|
||||
export const createAccount = (b: Partial<Account>) => api.post<Account>("/accounts", b).then((r) => r.data);
|
||||
export const getTransactions = (params?: { kind?: string; projectId?: string; from?: string; to?: string }) =>
|
||||
api.get<Transaction[]>("/transactions", { params }).then((r) => r.data);
|
||||
export const createTransaction = (b: Partial<Transaction>) =>
|
||||
api.post<Transaction>("/transactions", b).then((r) => r.data);
|
||||
export const updateTransaction = (txId: string, b: Partial<Transaction>) =>
|
||||
api.patch<Transaction>(`/transactions/${txId}`, b).then((r) => r.data);
|
||||
export const deleteTransaction = (txId: string) => api.delete(`/transactions/${txId}`).then((r) => r.data);
|
||||
export const getTaxes = () => api.get<TaxRecord[]>("/taxes").then((r) => r.data);
|
||||
export const createTax = (b: Partial<TaxRecord>) => api.post<TaxRecord>("/taxes", b).then((r) => r.data);
|
||||
export const updateTax = (taxId: string, b: Partial<TaxRecord>) =>
|
||||
api.patch<TaxRecord>(`/taxes/${taxId}`, b).then((r) => r.data);
|
||||
export const getAccountingSummary = (year?: number) =>
|
||||
api.get<AcctSummary>("/accounting/summary", { params: { year } }).then((r) => r.data);
|
||||
140
src/lib/format.ts
Normal file
140
src/lib/format.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import type { FixStatus, LeaveType, ReqStatus, TxnKind } from "@/types";
|
||||
|
||||
export function classNames(...xs: (string | false | null | undefined)[]) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}.${m}.${day}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return `${formatDate(value)} ${String(d.getHours()).padStart(2, "0")}:${String(
|
||||
d.getMinutes()
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatTime(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Korean-style money: 1,2300,0000 → "1억 2,300만". Compact for dashboards.
|
||||
export function formatKRW(n?: number): string {
|
||||
if (n == null) return "—";
|
||||
const neg = n < 0;
|
||||
let v = Math.abs(Math.round(n));
|
||||
if (v === 0) return "₩0";
|
||||
const eok = Math.floor(v / 100_000_000);
|
||||
v = v % 100_000_000;
|
||||
const man = Math.floor(v / 10_000);
|
||||
const rest = v % 10_000;
|
||||
const parts: string[] = [];
|
||||
if (eok) parts.push(`${eok}억`);
|
||||
if (man) parts.push(`${man.toLocaleString()}만`);
|
||||
if (rest && !eok) parts.push(`${rest.toLocaleString()}`);
|
||||
const s = parts.join(" ") || "0";
|
||||
return `${neg ? "-" : ""}₩${s}`;
|
||||
}
|
||||
|
||||
// Full numeric KRW with grouping (for ledgers / inputs).
|
||||
export function formatWon(n?: number): string {
|
||||
if (n == null) return "—";
|
||||
return `₩${Math.round(n).toLocaleString()}`;
|
||||
}
|
||||
|
||||
export function formatPoints(n?: number): string {
|
||||
if (n == null) return "—";
|
||||
return `${(Math.round(n * 10) / 10).toLocaleString()}P`;
|
||||
}
|
||||
|
||||
export function minutesToHM(min?: number): string {
|
||||
if (!min || min <= 0) return "0시간";
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return m ? `${h}시간 ${m}분` : `${h}시간`;
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes || bytes <= 0) return "—";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let n = bytes;
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/* ---- status metadata ---- */
|
||||
export const REQ_STATUS_META: Record<ReqStatus, { label: string; fg: string; bg: string }> = {
|
||||
pending: { label: "대기", fg: "#B54708", bg: "#FEF0C7" },
|
||||
approved: { label: "승인", fg: "#067647", bg: "#DCFAE6" },
|
||||
rejected: { label: "반려", fg: "#B42318", bg: "#FEE4E2" },
|
||||
canceled: { label: "취소", fg: "#475467", bg: "#F2F4F7" },
|
||||
};
|
||||
|
||||
export const FIX_STATUS_META: Record<FixStatus, { label: string; fg: string; bg: string }> = {
|
||||
planned: { label: "예정", fg: "#475467", bg: "#F2F4F7" },
|
||||
applying: { label: "반영중", fg: "#175CD3", bg: "#D1E9FF" },
|
||||
applied: { label: "반영완료", fg: "#5925DC", bg: "#EBE9FE" },
|
||||
paid: { label: "지급완료", fg: "#067647", bg: "#DCFAE6" },
|
||||
};
|
||||
|
||||
export const FIX_ORDER: FixStatus[] = ["planned", "applying", "applied", "paid"];
|
||||
|
||||
export const LEAVE_LABELS: Record<LeaveType, string> = {
|
||||
annual: "연차",
|
||||
half_am: "오전 반차",
|
||||
half_pm: "오후 반차",
|
||||
public: "공가",
|
||||
sick: "병가",
|
||||
family: "경조사",
|
||||
unpaid: "무급",
|
||||
};
|
||||
|
||||
export const TXN_LABELS: Record<TxnKind, string> = {
|
||||
income: "수입",
|
||||
expense: "비용",
|
||||
tax: "세금",
|
||||
payroll: "급여",
|
||||
incentive: "인센티브",
|
||||
};
|
||||
|
||||
export const STAGE_KIND_LABELS: Record<string, string> = {
|
||||
deposit: "계약금",
|
||||
middle: "중도금",
|
||||
final: "잔금",
|
||||
};
|
||||
|
||||
export const SCOPE_LABELS: Record<string, string> = {
|
||||
be: "BE",
|
||||
non_be: "non-BE",
|
||||
};
|
||||
|
||||
export const PROJECT_STATUS_META: Record<string, { label: string; fg: string; bg: string }> = {
|
||||
planned: { label: "예정", fg: "#475467", bg: "#F2F4F7" },
|
||||
active: { label: "진행중", fg: "#175CD3", bg: "#D1E9FF" },
|
||||
hold: { label: "보류", fg: "#B54708", bg: "#FEF0C7" },
|
||||
done: { label: "완료", fg: "#067647", bg: "#DCFAE6" },
|
||||
dropped: { label: "중단", fg: "#B42318", bg: "#FEE4E2" },
|
||||
};
|
||||
|
||||
export const LANE_LABELS: Record<string, string> = {
|
||||
todo: "할 일",
|
||||
doing: "진행중",
|
||||
review: "검토",
|
||||
done: "완료",
|
||||
};
|
||||
26
src/main.tsx
Normal file
26
src/main.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
202
src/pages/Attendance.tsx
Normal file
202
src/pages/Attendance.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarDays, Plus, LogIn, LogOut } from "lucide-react";
|
||||
import {
|
||||
getTimesheet, getAttendance, getLeave, getOvertime, punch, createLeave,
|
||||
createOvertime, cancelLeave,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Card, Button, Badge, Stat, PageHeader, Modal, Field, Input, Select,
|
||||
Textarea, Tabs, Progress, EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
formatDate, formatTime, minutesToHM, REQ_STATUS_META, LEAVE_LABELS, classNames,
|
||||
} 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 [leaveOpen, setLeaveOpen] = useState(false);
|
||||
const [otOpen, setOtOpen] = useState(false);
|
||||
|
||||
const tsQ = useQuery({ queryKey: ["timesheet"], queryFn: () => getTimesheet({}) });
|
||||
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;
|
||||
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="px-3 pt-2 flex items-center justify-between">
|
||||
<Tabs
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
tabs={[
|
||||
{ key: "timesheet", 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 },
|
||||
]}
|
||||
/>
|
||||
{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">
|
||||
{tab === "timesheet" && (
|
||||
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>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "leave" && (
|
||||
leaveQ.isLoading ? <LoadingState /> : (leaveQ.data?.length ?? 0) === 0 ? <EmptyState title="신청 내역이 없습니다" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>종류</th><th>기간</th><th>일수</th><th>사유</th><th>상태</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{leaveQ.data!.map((l) => {
|
||||
const m = REQ_STATUS_META[l.status];
|
||||
return (
|
||||
<tr key={l.id}>
|
||||
<td>{LEAVE_LABELS[l.type]}</td>
|
||||
<td className="tabular">{formatDate(l.startDate)}{l.endDate && l.endDate !== l.startDate ? ` ~ ${formatDate(l.endDate)}` : ""}</td>
|
||||
<td className="tabular">{l.days}일</td>
|
||||
<td className="text-ink-secondary max-w-[240px] truncate">{l.reason}</td>
|
||||
<td><Badge label={m.label} fg={m.fg} bg={m.bg} dot /></td>
|
||||
<td className="text-right">{l.status === "pending" && <button className="text-xs text-[#B42318]" onClick={() => cancelLeave(l.id).then(() => qc.invalidateQueries({ queryKey: ["leave-mine"] }))}>취소</button>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</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"] })} />
|
||||
<OvertimeModal open={otOpen} onClose={() => setOtOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["ot-mine"] })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaveModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) {
|
||||
const [type, setType] = useState<LeaveType>("annual");
|
||||
const [startDate, setStart] = useState("");
|
||||
const [endDate, setEnd] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const m = useMutation({
|
||||
mutationFn: () => createLeave({ type, startDate, endDate: endDate || startDate, reason }),
|
||||
onSuccess: () => { onDone(); onClose(); setReason(""); },
|
||||
});
|
||||
return (
|
||||
<Modal
|
||||
open={open} onClose={onClose} title="휴가 / 공가 신청"
|
||||
footer={<><Button variant="secondary" onClick={onClose}>취소</Button><Button disabled={!startDate || m.isPending} onClick={() => m.mutate()}>신청</Button></>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<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>
|
||||
<Field label="사유"><Textarea value={reason} onChange={(e) => setReason(e.target.value)} placeholder="사유를 입력하세요" /></Field>
|
||||
<p className="text-xs text-ink-muted">※ 신청 후 관리자 승인이 필요합니다.</p>
|
||||
</div>
|
||||
</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={classNames("text-xs text-ink-muted")}>※ 초과근무는 관리자만 확인·승인합니다.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
105
src/pages/Dashboard.tsx
Normal file
105
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
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 { 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";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { me, isAdmin } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
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!;
|
||||
const name = me?.member?.displayName || me?.user.name;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={`안녕하세요, ${name}님`}
|
||||
description={isAdmin ? "전사 운영 현황을 한눈에 확인하세요." : "오늘도 좋은 하루 되세요."}
|
||||
action={<Button icon={<Clock size={16} />} onClick={() => punchM.mutate()}>출퇴근 체크</Button>}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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="근무 메뉴에서 확인" />}
|
||||
</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="/incentive" icon={<Coins size={18} />} label="내 인센티브" />
|
||||
{isAdmin
|
||||
? <QuickLink to="/admin/incentive" icon={<CheckSquare size={18} />} label="인센티브 관리" />
|
||||
: <QuickLink to="/profile" icon={<CheckSquare 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLink({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<Link to={to} className="flex items-center gap-3 p-3 rounded-control border border-border hover:border-navy hover:bg-navy-subtle/40 transition-colors">
|
||||
<span className="text-navy">{icon}</span>
|
||||
<span className="text-sm font-medium text-ink">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
119
src/pages/Incentive.tsx
Normal file
119
src/pages/Incentive.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend,
|
||||
} from "recharts";
|
||||
import { getMyIncentive } from "@/lib/api";
|
||||
import {
|
||||
Card, CardHeader, Stat, PageHeader, Progress, LoadingState, EmptyState, Badge,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
formatPoints, formatWon, FIX_STATUS_META, STAGE_KIND_LABELS, SCOPE_LABELS,
|
||||
} from "@/lib/format";
|
||||
|
||||
const COLORS = ["#11224F", "#2E90FA", "#7A5AF8", "#12B76A", "#C99A2E", "#F04438"];
|
||||
|
||||
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;
|
||||
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="내 인센티브" description={`${d.year}년 · 직급 ${d.rank || "—"} · 포인트 환율 ${formatWon(d.pointRate)} / 1P`} />
|
||||
|
||||
<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.quota)} sub={`${d.rank || "—"} 기준`} />
|
||||
<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">
|
||||
<h3 className="text-sm font-bold text-ink">할당량 달성률</h3>
|
||||
<span className="text-sm font-num font-semibold text-navy">{quotaPct.toFixed(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>
|
||||
</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>
|
||||
<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>
|
||||
<tbody>
|
||||
{d.items.map((it) => {
|
||||
const m = FIX_STATUS_META[it.fixStatus];
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/pages/Profile.tsx
Normal file
56
src/pages/Profile.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateMember } 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";
|
||||
|
||||
export function ProfilePage() {
|
||||
const { me, loading } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const member = me?.member;
|
||||
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"] }),
|
||||
});
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (!member) return <Card className="p-8 text-center text-ink-secondary">구성원 정보가 없습니다. 관리자에게 문의하세요.</Card>;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="text-sm text-ink">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
src/pages/ProjectDetail.tsx
Normal file
390
src/pages/ProjectDetail.tsx
Normal file
@ -0,0 +1,390 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getProject, getProjectMembers, getContacts, getTasks, getContract, getContractFiles,
|
||||
getPayments, upsertProjectMember, deleteProjectMember, createTask, updateTask,
|
||||
upsertContact, deleteContact, putContract, uploadContractFile, getFileDownloadUrl,
|
||||
deleteContractFile, createPayment, updatePayment, deletePayment, recomputeProject,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/context/Auth";
|
||||
import {
|
||||
Card, CardHeader, Button, Badge, Tabs, Modal, Field, Input, Select,
|
||||
PageHeader, EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import { Gantt } from "@/components/Gantt";
|
||||
import { Kanban } from "@/components/Kanban";
|
||||
import {
|
||||
formatDate, formatWon, formatSize, PROJECT_STATUS_META, SCOPE_LABELS, LANE_LABELS, classNames,
|
||||
} from "@/lib/format";
|
||||
import type { Lane, PaymentSplit, Project, ProjectTask } from "@/types";
|
||||
|
||||
export function ProjectDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const { isAdmin } = useAuth();
|
||||
const [tab, setTab] = useState("overview");
|
||||
const projQ = useQuery({ queryKey: ["project", id], queryFn: () => getProject(id) });
|
||||
|
||||
if (projQ.isLoading) return <LoadingState />;
|
||||
if (projQ.isError || !projQ.data) return <EmptyState title="프로젝트를 찾을 수 없습니다" />;
|
||||
const p = projQ.data;
|
||||
const m = PROJECT_STATUS_META[p.status];
|
||||
|
||||
const tabs = [
|
||||
{ key: "overview", label: "개요" },
|
||||
{ key: "members", label: "작업자" },
|
||||
{ key: "timeline", label: "타임라인" },
|
||||
{ key: "contacts", label: "업체 담당자" },
|
||||
...(isAdmin ? [{ key: "contract", label: "계약 · 정산" }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to="/projects" className="inline-flex items-center gap-1 text-sm text-ink-secondary hover:text-ink mb-3"><ArrowLeft size={15} /> 프로젝트 목록</Link>
|
||||
<PageHeader
|
||||
title={<span className="flex items-center gap-2">{p.name} {m && <Badge label={m.label} fg={m.fg} bg={m.bg} dot size="sm" />}</span>}
|
||||
description={`${p.companyName} · ${p.productName} ${p.versionName} · ${p.consultingType} · ${p.country} · ${SCOPE_LABELS[p.scope] ?? p.scope}`}
|
||||
/>
|
||||
<Card>
|
||||
<div className="px-3 pt-2"><Tabs tabs={tabs} active={tab} onChange={setTab} /></div>
|
||||
<div className="p-5">
|
||||
{tab === "overview" && <Overview project={p} />}
|
||||
{tab === "members" && <Members projectId={id} isAdmin={isAdmin} />}
|
||||
{tab === "timeline" && <Timeline projectId={id} isAdmin={isAdmin} />}
|
||||
{tab === "contacts" && <Contacts projectId={id} isAdmin={isAdmin} />}
|
||||
{tab === "contract" && isAdmin && <ContractTab projectId={id} />}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex py-2.5 border-b border-divider last:border-0">
|
||||
<div className="w-32 shrink-0 text-sm text-ink-muted">{label}</div>
|
||||
<div className="text-sm text-ink">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Overview({ project: p }: { project: Project }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10">
|
||||
<div>
|
||||
<Row label="컨설팅 종류" value={p.consultingType} />
|
||||
<Row label="제출 국가" value={p.country} />
|
||||
<Row label="계약 범위" value={SCOPE_LABELS[p.scope] ?? p.scope} />
|
||||
<Row label="PM" value={p.pmEmail} />
|
||||
</div>
|
||||
<div>
|
||||
<Row label="업체 / 제품" value={`${p.companyName} · ${p.productName}`} />
|
||||
<Row label="버전" value={p.versionName} />
|
||||
<Row label="기간" value={`${formatDate(p.startDate)} ~ ${formatDate(p.dueDate)}`} />
|
||||
<Row label="주의사항" value={p.cautions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- members & portion ---- */
|
||||
function Members({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({ queryKey: ["pm", projectId], queryFn: () => getProjectMembers(projectId) });
|
||||
const [email, setEmail] = useState("");
|
||||
const [portion, setPortion] = useState("");
|
||||
const [role, setRole] = useState("작업자");
|
||||
const add = useMutation({
|
||||
mutationFn: () => upsertProjectMember(projectId, { memberEmail: email, portion: parseFloat(portion) || 0, role }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["pm", projectId] }); setEmail(""); setPortion(""); },
|
||||
});
|
||||
const del = useMutation({ mutationFn: (pmId: string) => deleteProjectMember(pmId), onSuccess: () => qc.invalidateQueries({ queryKey: ["pm", projectId] }) });
|
||||
|
||||
const total = (q.data ?? []).reduce((s, m) => s + m.portion, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-ink-secondary">작업자별 기여도(portion)의 합: <span className={classNames("font-num font-semibold", total === 100 ? "text-money-in" : "text-status-pending-fg")}>{total}%</span></p>
|
||||
</div>
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>작업자</th><th>역할</th><th className="text-right">기여도</th>{isAdmin && <th></th>}</tr></thead>
|
||||
<tbody>
|
||||
{(q.data ?? []).map((pm) => (
|
||||
<tr key={pm.id}>
|
||||
<td>{pm.memberEmail}</td>
|
||||
<td>{pm.role}</td>
|
||||
<td className="text-right tabular font-medium">{pm.portion}%</td>
|
||||
{isAdmin && <td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(pm.id)}><Trash2 size={15} /></button></td>}
|
||||
</tr>
|
||||
))}
|
||||
{(q.data?.length ?? 0) === 0 && <tr><td colSpan={4} className="text-center text-ink-muted py-6">작업자가 없습니다</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-end gap-2 mt-4 p-3 bg-canvas rounded-control">
|
||||
<Field label="작업자 이메일"><Input value={email} onChange={(e) => setEmail(e.target.value)} className="w-56" /></Field>
|
||||
<Field label="역할"><Input value={role} onChange={(e) => setRole(e.target.value)} className="w-32" /></Field>
|
||||
<Field label="기여도 %"><Input type="number" value={portion} onChange={(e) => setPortion(e.target.value)} className="w-24" /></Field>
|
||||
<Button icon={<Plus size={15} />} disabled={!email || add.isPending} onClick={() => add.mutate()}>추가</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- timeline: gantt / kanban / calendar ---- */
|
||||
function Timeline({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [view, setView] = useState<"gantt" | "kanban" | "calendar">("gantt");
|
||||
const [open, setOpen] = useState(false);
|
||||
const q = useQuery({ queryKey: ["tasks", projectId], queryFn: () => getTasks(projectId) });
|
||||
const move = useMutation({
|
||||
mutationFn: ({ taskId, lane }: { taskId: string; lane: Lane }) => updateTask(taskId, { lane }),
|
||||
onMutate: ({ taskId, lane }) => {
|
||||
qc.setQueryData<ProjectTask[]>(["tasks", projectId], (old) => old?.map((t) => t.id === taskId ? { ...t, lane } : t));
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: ["tasks", projectId] }),
|
||||
});
|
||||
const tasks = q.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="inline-flex rounded-control border border-border overflow-hidden">
|
||||
<ViewBtn active={view === "gantt"} onClick={() => setView("gantt")} icon={<GanttChartSquare size={15} />} label="간트" />
|
||||
<ViewBtn active={view === "kanban"} onClick={() => setView("kanban")} icon={<Columns3 size={15} />} label="칸반" />
|
||||
<ViewBtn active={view === "calendar"} onClick={() => setView("calendar")} icon={<CalendarDays size={15} />} label="캘린더" />
|
||||
</div>
|
||||
<Button size="sm" icon={<Plus size={14} />} onClick={() => setOpen(true)}>작업 추가</Button>
|
||||
</div>
|
||||
{q.isLoading ? <LoadingState /> : tasks.length === 0 ? <EmptyState title="작업이 없습니다" /> : (
|
||||
<>
|
||||
{view === "gantt" && <Gantt tasks={tasks} />}
|
||||
{view === "kanban" && <Kanban tasks={tasks} onMove={(taskId, lane) => move.mutate({ taskId, lane })} readOnly={!isAdmin && false} />}
|
||||
{view === "calendar" && <CalendarView tasks={tasks} />}
|
||||
</>
|
||||
)}
|
||||
{open && <TaskModal projectId={projectId} onClose={() => setOpen(false)} onDone={() => qc.invalidateQueries({ queryKey: ["tasks", projectId] })} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewBtn({ active, onClick, icon, label }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<button onClick={onClick} className={classNames("flex items-center gap-1.5 px-3 h-9 text-sm font-medium", active ? "bg-navy text-white" : "bg-surface text-ink-secondary hover:bg-canvas")}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarView({ tasks }: { tasks: ProjectTask[] }) {
|
||||
const now = new Date();
|
||||
const [ym, setYm] = useState({ y: now.getFullYear(), m: now.getMonth() });
|
||||
const first = new Date(ym.y, ym.m, 1);
|
||||
const startDow = first.getDay();
|
||||
const daysInMonth = new Date(ym.y, ym.m + 1, 0).getDate();
|
||||
const cells: (number | null)[] = [...Array(startDow).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)];
|
||||
|
||||
function tasksOn(day: number) {
|
||||
const date = new Date(ym.y, ym.m, day).getTime();
|
||||
return tasks.filter((t) => {
|
||||
const s = new Date(t.start).getTime(), e = new Date(t.end || t.start).getTime();
|
||||
return date >= new Date(new Date(s).toDateString()).getTime() && date <= new Date(new Date(e).toDateString()).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button className="px-2 text-ink-secondary" onClick={() => setYm((s) => ({ y: s.m === 0 ? s.y - 1 : s.y, m: s.m === 0 ? 11 : s.m - 1 }))}>‹</button>
|
||||
<div className="font-semibold text-ink">{ym.y}.{String(ym.m + 1).padStart(2, "0")}</div>
|
||||
<button className="px-2 text-ink-secondary" onClick={() => setYm((s) => ({ y: s.m === 11 ? s.y + 1 : s.y, m: s.m === 11 ? 0 : s.m + 1 }))}>›</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-px bg-border rounded-card overflow-hidden border border-border">
|
||||
{["일", "월", "화", "수", "목", "금", "토"].map((d) => <div key={d} className="bg-canvas text-center text-xs font-semibold text-ink-secondary py-2">{d}</div>)}
|
||||
{cells.map((day, i) => (
|
||||
<div key={i} className="bg-surface min-h-[88px] p-1.5">
|
||||
{day && <>
|
||||
<div className="text-xs text-ink-muted mb-1">{day}</div>
|
||||
<div className="space-y-1">
|
||||
{tasksOn(day).slice(0, 3).map((t) => (
|
||||
<div key={t.id} className="text-[10px] px-1.5 py-0.5 rounded bg-navy-subtle text-navy truncate">{t.title}</div>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskModal({ projectId, onClose, onDone }: { projectId: string; onClose: () => void; onDone: () => void }) {
|
||||
const [form, setForm] = useState({ title: "", lane: "todo", start: "", end: "", assignee: "", progress: "0" });
|
||||
const m = useMutation({
|
||||
mutationFn: () => createTask(projectId, { ...form, lane: form.lane as Lane, progress: parseInt(form.progress) || 0 }),
|
||||
onSuccess: () => { onDone(); onClose(); },
|
||||
});
|
||||
return (
|
||||
<Modal open onClose={onClose} title="작업 추가"
|
||||
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">
|
||||
<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>
|
||||
<Field label="진척 %"><Input type="number" value={form.progress} onChange={(e) => setForm({ ...form, progress: e.target.value })} /></Field>
|
||||
<Field label="시작일"><Input type="date" value={form.start} onChange={(e) => setForm({ ...form, start: e.target.value })} /></Field>
|
||||
<Field label="종료일"><Input type="date" value={form.end} onChange={(e) => setForm({ ...form, end: e.target.value })} /></Field>
|
||||
</div>
|
||||
<Field label="담당자 이메일"><Input value={form.assignee} onChange={(e) => setForm({ ...form, assignee: e.target.value })} /></Field>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- contacts ---- */
|
||||
function Contacts({ projectId, isAdmin }: { projectId: string; isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({ queryKey: ["contacts", projectId], queryFn: () => getContacts(projectId) });
|
||||
const [form, setForm] = useState({ name: "", title: "", phone: "", email: "" });
|
||||
const add = useMutation({ mutationFn: () => upsertContact(projectId, form), onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts", projectId] }); setForm({ name: "", title: "", phone: "", email: "" }); } });
|
||||
const del = useMutation({ mutationFn: (cId: string) => deleteContact(cId), onSuccess: () => qc.invalidateQueries({ queryKey: ["contacts", projectId] }) });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>이름</th><th>직무</th><th>연락처</th><th>이메일</th>{isAdmin && <th></th>}</tr></thead>
|
||||
<tbody>
|
||||
{(q.data ?? []).map((c) => (
|
||||
<tr key={c.id}><td>{c.name}</td><td>{c.title}</td><td className="tabular">{c.phone}</td><td>{c.email}</td>
|
||||
{isAdmin && <td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(c.id)}><Trash2 size={15} /></button></td>}</tr>
|
||||
))}
|
||||
{(q.data?.length ?? 0) === 0 && <tr><td colSpan={5} className="text-center text-ink-muted py-6">담당자가 없습니다</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-end gap-2 mt-4 p-3 bg-canvas rounded-control">
|
||||
<Field label="이름"><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-32" /></Field>
|
||||
<Field label="직무"><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className="w-32" /></Field>
|
||||
<Field label="연락처"><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="w-36" /></Field>
|
||||
<Field label="이메일"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="w-44" /></Field>
|
||||
<Button icon={<Plus size={15} />} disabled={!form.name || add.isPending} onClick={() => add.mutate()}>추가</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- contract / payments (ADMIN ONLY) ---- */
|
||||
function ContractTab({ projectId }: { projectId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const cQ = useQuery({ queryKey: ["contract", projectId], queryFn: () => getContract(projectId) });
|
||||
const fQ = useQuery({ queryKey: ["cfiles", projectId], queryFn: () => getContractFiles(projectId) });
|
||||
const pQ = useQuery({ queryKey: ["payments", projectId], queryFn: () => getPayments(projectId) });
|
||||
|
||||
const [form, setForm] = useState({ totalAmount: "", beAmount: "", adminCaution: "", memo: "" });
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
if (cQ.data && !loaded) {
|
||||
setForm({ totalAmount: String(cQ.data.totalAmount || ""), beAmount: String(cQ.data.beAmount || ""), adminCaution: cQ.data.adminCaution || "", memo: cQ.data.memo || "" });
|
||||
setLoaded(true);
|
||||
}
|
||||
const save = useMutation({
|
||||
mutationFn: () => putContract(projectId, { totalAmount: parseFloat(form.totalAmount) || 0, beAmount: parseFloat(form.beAmount) || 0, adminCaution: form.adminCaution, memo: form.memo }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["contract", projectId] }),
|
||||
});
|
||||
const recompute = useMutation({ mutationFn: () => recomputeProject(projectId), onSuccess: () => alert("인센티브 단계/배분을 재계산했습니다.") });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-status-pending-fg bg-status-pending-bg rounded-control px-3 py-2 w-fit">
|
||||
<Lock size={13} /> 관리자 전용 정보 — 일반 구성원에게 노출되지 않습니다.
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="계약 정보" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}>저장</Button>} />
|
||||
<div className="p-5 grid 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>
|
||||
<Field label="메모"><Input value={form.memo} onChange={(e) => setForm({ ...form, memo: e.target.value })} /></Field>
|
||||
</div>
|
||||
<div className="px-5 pb-5">
|
||||
<Button variant="secondary" size="sm" onClick={() => recompute.mutate()} disabled={recompute.isPending}>인센티브 재계산 (BE / non-BE 단계 생성)</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Payments projectId={projectId} payments={pQ.data ?? []} onChange={() => qc.invalidateQueries({ queryKey: ["payments", projectId] })} />
|
||||
|
||||
<Card>
|
||||
<CardHeader title="첨부 자료" subtitle="계약서 · 기타 자료 (여러 개 가능)" action={
|
||||
<label className="inline-flex items-center gap-1.5 text-sm font-medium text-navy cursor-pointer">
|
||||
<Upload size={15} /> 업로드
|
||||
<input type="file" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadContractFile(projectId, f).then(() => qc.invalidateQueries({ queryKey: ["cfiles", projectId] })); }} />
|
||||
</label>
|
||||
} />
|
||||
<div className="p-2">
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>파일명</th><th>종류</th><th>크기</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(fQ.data ?? []).map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td>{f.filename}</td><td>{f.kind}</td><td className="tabular">{formatSize(f.size)}</td>
|
||||
<td className="text-right flex justify-end gap-3 py-2">
|
||||
<button className="text-navy" onClick={() => getFileDownloadUrl(f.id).then((u) => window.open(u, "_blank"))}><Download size={15} /></button>
|
||||
<button className="text-ink-muted hover:text-money-out" onClick={() => deleteContractFile(f.id).then(() => qc.invalidateQueries({ queryKey: ["cfiles", projectId] }))}><Trash2 size={15} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(fQ.data?.length ?? 0) === 0 && <tr><td colSpan={4} className="text-center text-ink-muted py-6">첨부 자료가 없습니다</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Payments({ projectId, payments, onChange }: { projectId: string; payments: PaymentSplit[]; onChange: () => void }) {
|
||||
const [form, setForm] = useState({ label: "", amount: "", expectedDate: "" });
|
||||
const add = useMutation({ mutationFn: () => createPayment(projectId, { label: form.label, amount: parseFloat(form.amount) || 0, expectedDate: form.expectedDate }), onSuccess: () => { onChange(); setForm({ label: "", amount: "", expectedDate: "" }); } });
|
||||
const togglePaid = useMutation({ mutationFn: (p: PaymentSplit) => updatePayment(p.id, { paid: !p.paid, paidDate: !p.paid ? new Date().toISOString().slice(0, 10) : "" }), onSuccess: onChange });
|
||||
const del = useMutation({ mutationFn: (id: string) => deletePayment(id), onSuccess: onChange });
|
||||
const total = payments.reduce((s, p) => s + p.amount, 0);
|
||||
const paid = payments.filter((p) => p.paid).reduce((s, p) => s + p.amount, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="분할 입금" subtitle={`총 ${formatWon(total)} · 입금 완료 ${formatWon(paid)}`} />
|
||||
<div className="p-2">
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>항목</th><th className="text-right">금액</th><th>예상일</th><th>실입금일</th><th>상태</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.label}</td>
|
||||
<td className="text-right tabular font-medium">{formatWon(p.amount)}</td>
|
||||
<td className="tabular">{formatDate(p.expectedDate)}</td>
|
||||
<td className="tabular">{p.paid ? formatDate(p.paidDate) : "—"}</td>
|
||||
<td><button onClick={() => togglePaid.mutate(p)}>{p.paid ? <Badge label="입금완료" fg="#067647" bg="#DCFAE6" /> : <Badge label="대기" fg="#475467" bg="#F2F4F7" />}</button></td>
|
||||
<td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => del.mutate(p.id)}><Trash2 size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{payments.length === 0 && <tr><td colSpan={6} className="text-center text-ink-muted py-6">분할 항목이 없습니다</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2 px-4 pb-4">
|
||||
<Field label="항목명"><Input value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} className="w-36" placeholder="예: 계약금" /></Field>
|
||||
<Field label="금액"><Input type="number" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} className="w-40" /></Field>
|
||||
<Field label="예상일"><Input type="date" value={form.expectedDate} onChange={(e) => setForm({ ...form, expectedDate: e.target.value })} /></Field>
|
||||
<Button icon={<Plus size={15} />} disabled={!form.label || add.isPending} onClick={() => add.mutate()}>추가</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
169
src/pages/Projects.tsx
Normal file
169
src/pages/Projects.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
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 {
|
||||
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";
|
||||
|
||||
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 filtered = (projQ.data ?? []).filter((p) =>
|
||||
!q || p.name.toLowerCase().includes(q.toLowerCase()) || p.companyName.toLowerCase().includes(q.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="프로젝트"
|
||||
description={isAdmin ? "회사·제품·버전별 인허가 컨설팅 프로젝트" : "내가 참여 중인 프로젝트만 표시됩니다."}
|
||||
action={isAdmin && <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>
|
||||
|
||||
{projQ.isLoading ? <LoadingState /> : filtered.length === 0 ? (
|
||||
<EmptyState title="프로젝트가 없습니다" icon={<FolderKanban size={28} />} description={isAdmin ? "새 프로젝트를 생성하세요." : "참여 중인 프로젝트가 없습니다."} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((p) => {
|
||||
const m = PROJECT_STATUS_META[p.status];
|
||||
return (
|
||||
<Link key={p.id} to={`/projects/${p.id}`}>
|
||||
<Card className="p-5 h-full hover:border-navy transition-colors">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-bold text-ink leading-snug">{p.name}</h3>
|
||||
{m && <Badge label={m.label} fg={m.fg} bg={m.bg} dot size="sm" />}
|
||||
</div>
|
||||
<p className="text-sm text-ink-secondary mt-1">{p.companyName} · {p.productName} {p.versionName}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
<span className="text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5">{p.consultingType}</span>
|
||||
<span className="text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5">{p.country}</span>
|
||||
<span className="text-[11px] bg-chip-bg text-navy rounded-pill px-2 py-0.5">{SCOPE_LABELS[p.scope] ?? p.scope}</span>
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted mt-3 flex justify-between">
|
||||
<span>PM {p.pmEmail?.split("@")[0] || "—"}</span>
|
||||
<span className="tabular">{formatDate(p.startDate)} ~ {formatDate(p.dueDate)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && <CreateProjectModal onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const compQ = useQuery({ queryKey: ["companies"], queryFn: getCompanies });
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
const prodQ = useQuery({ queryKey: ["products", companyId], queryFn: () => getProducts(companyId), enabled: !!companyId });
|
||||
const [productId, setProductId] = useState("");
|
||||
const verQ = useQuery({ queryKey: ["versions", productId], queryFn: () => getVersions(productId), enabled: !!productId });
|
||||
const [versionId, setVersionId] = useState("");
|
||||
const [form, setForm] = useState({ name: "", consultingType: "", country: "", scope: "both", pmEmail: "", cautions: "", startDate: "", dueDate: "" });
|
||||
|
||||
// quick-add master data
|
||||
const [newCompany, setNewCompany] = useState("");
|
||||
const [newProduct, setNewProduct] = useState("");
|
||||
const [newVersion, setNewVersion] = useState("");
|
||||
|
||||
const comp = compQ.data?.find((c) => c.id === companyId);
|
||||
const prod = prodQ.data?.find((p) => p.id === productId);
|
||||
const ver = verQ.data?.find((v) => v.id === versionId);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => createProject({
|
||||
...form, companyId, productId, versionId,
|
||||
companyName: comp?.name, productName: prod?.name, versionName: ver?.label,
|
||||
scope: form.scope as any,
|
||||
}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["projects"] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="프로젝트 생성" wide
|
||||
footer={<><Button variant="secondary" onClick={onClose}>취소</Button><Button disabled={!form.name || !companyId || create.isPending} onClick={() => create.mutate()}>생성</Button></>}
|
||||
>
|
||||
<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">
|
||||
<Field label="업체">
|
||||
<div className="flex gap-1">
|
||||
<Select value={companyId} onChange={(e) => { setCompanyId(e.target.value); setProductId(""); setVersionId(""); }}>
|
||||
<option value="">선택</option>
|
||||
{compQ.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Input value={newCompany} onChange={(e) => setNewCompany(e.target.value)} placeholder="새 업체" className="h-8 text-xs" />
|
||||
<Button size="sm" variant="secondary" onClick={() => newCompany && createCompany({ name: newCompany }).then((c) => { setNewCompany(""); compQ.refetch().then(() => setCompanyId(c.id)); })}>+</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="제품">
|
||||
<Select value={productId} onChange={(e) => { setProductId(e.target.value); setVersionId(""); }} disabled={!companyId}>
|
||||
<option value="">선택</option>
|
||||
{prodQ.data?.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Input value={newProduct} onChange={(e) => setNewProduct(e.target.value)} placeholder="새 제품" className="h-8 text-xs" disabled={!companyId} />
|
||||
<Button size="sm" variant="secondary" onClick={() => newProduct && companyId && createProduct({ companyId, name: newProduct }).then((p) => { setNewProduct(""); prodQ.refetch().then(() => setProductId(p.id)); })}>+</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="버전">
|
||||
<Select value={versionId} onChange={(e) => setVersionId(e.target.value)} disabled={!productId}>
|
||||
<option value="">선택</option>
|
||||
{verQ.data?.map((v) => <option key={v.id} value={v.id}>{v.label}</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<Input value={newVersion} onChange={(e) => setNewVersion(e.target.value)} placeholder="새 버전" className="h-8 text-xs" disabled={!productId} />
|
||||
<Button size="sm" variant="secondary" onClick={() => newVersion && productId && createVersion({ productId, label: newVersion }).then((v) => { setNewVersion(""); verQ.refetch().then(() => setVersionId(v.id)); })}>+</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid 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="계약 범위">
|
||||
<Select value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })}>
|
||||
<option value="text">글</option><option value="graphic">그림</option><option value="both">글+그림</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid 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>
|
||||
</div>
|
||||
<Field label="주의사항"><Textarea value={form.cautions} onChange={(e) => setForm({ ...form, cautions: e.target.value })} /></Field>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
152
src/pages/admin/Accounting.tsx
Normal file
152
src/pages/admin/Accounting.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||
} from "recharts";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
getAccountingSummary, getTransactions, createTransaction, deleteTransaction, getTaxes, createTax,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Card, Button, Stat, Badge, Tabs, PageHeader, Modal, Field, Input, Select,
|
||||
EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import { formatKRW, formatWon, formatDate, TXN_LABELS } from "@/lib/format";
|
||||
import type { TxnKind } from "@/types";
|
||||
|
||||
export function AccountingPage() {
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState("overview");
|
||||
const [txOpen, setTxOpen] = useState(false);
|
||||
const sumQ = useQuery({ queryKey: ["acct-summary"], queryFn: () => getAccountingSummary() });
|
||||
const txQ = useQuery({ queryKey: ["transactions"], queryFn: () => getTransactions() });
|
||||
const taxQ = useQuery({ queryKey: ["taxes"], queryFn: getTaxes });
|
||||
|
||||
const s = sumQ.data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="회계" description="실제 현금 흐름과 인센티브(가상 포인트)의 괴리를 한눈에 확인하고 비용·세금을 관리합니다."
|
||||
action={<Button icon={<Plus size={16} />} onClick={() => setTxOpen(true)}>거래 입력</Button>} />
|
||||
|
||||
{s && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-4">
|
||||
<Stat label="올해 수입" value={formatKRW(s.cashIn)} accent="#067647" />
|
||||
<Stat label="올해 지출" value={formatKRW(s.cashOut)} accent="#B42318" />
|
||||
<Stat label="순현금" value={formatKRW(s.net)} accent={s.net >= 0 ? "#067647" : "#B42318"} />
|
||||
<Stat label="인센티브 반영액" value={formatKRW(s.incentiveApplied)} accent="#5925DC" sub="포인트×환율" />
|
||||
<Stat label="현금-인센티브 갭" value={formatKRW(s.gap)} accent="#B54708" sub="미지급 인센티브" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="px-3 pt-2"><Tabs active={tab} onChange={setTab} tabs={[{ key: "overview", label: "현황" }, { key: "ledger", label: "거래 원장" }, { key: "tax", label: "세금" }]} /></div>
|
||||
<div className="p-5">
|
||||
{tab === "overview" && (
|
||||
sumQ.isLoading ? <LoadingState /> : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-ink mb-2">월별 손익</h3>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={s?.monthly ?? []}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F2F4F7" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} tickFormatter={(m) => m.slice(5)} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${Math.round(v / 10000)}만`} />
|
||||
<Tooltip formatter={(v) => formatWon(Number(v))} />
|
||||
<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} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-canvas rounded-control p-4 text-sm text-ink-secondary">
|
||||
<span className="font-semibold text-ink">현금–인센티브 괴리:</span> 인센티브로 반영된 금액 {formatWon(s?.incentiveApplied)} 중
|
||||
실제 지급(급여 반영) {formatWon(s?.incentivePaid)} — 미지급(부채성) <span className="font-semibold text-status-pending-fg">{formatWon(s?.gap)}</span>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "ledger" && (
|
||||
txQ.isLoading ? <LoadingState /> : (txQ.data?.length ?? 0) === 0 ? <EmptyState title="거래 내역이 없습니다" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>날짜</th><th>구분</th><th>거래처</th><th>메모</th><th className="text-right">금액</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{txQ.data!.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="tabular">{formatDate(t.date)}</td>
|
||||
<td><Badge label={TXN_LABELS[t.kind]} fg={t.kind === "income" ? "#067647" : "#B42318"} bg={t.kind === "income" ? "#DCFAE6" : "#FEE4E2"} size="sm" /></td>
|
||||
<td>{t.counterparty || "—"}</td>
|
||||
<td className="text-ink-secondary">{t.memo}</td>
|
||||
<td className="text-right tabular font-medium" style={{ color: t.amount >= 0 ? "#067647" : "#B42318" }}>{formatWon(t.amount)}</td>
|
||||
<td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={() => deleteTransaction(t.id).then(() => qc.invalidateQueries({ queryKey: ["transactions"] }))}><Trash2 size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "tax" && <TaxTab taxes={taxQ.data ?? []} onChange={() => qc.invalidateQueries({ queryKey: ["taxes"] })} />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{txOpen && <TxModal onClose={() => setTxOpen(false)} onDone={() => { qc.invalidateQueries({ queryKey: ["transactions"] }); qc.invalidateQueries({ queryKey: ["acct-summary"] }); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TxModal({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
|
||||
const [f, setF] = useState({ date: new Date().toISOString().slice(0, 10), kind: "income" as TxnKind, amount: "", counterparty: "", memo: "" });
|
||||
const m = useMutation({
|
||||
mutationFn: () => {
|
||||
let amt = parseFloat(f.amount) || 0;
|
||||
if (f.kind !== "income" && amt > 0) amt = -amt; // expenses stored negative
|
||||
return createTransaction({ ...f, amount: amt });
|
||||
},
|
||||
onSuccess: () => { onDone(); onClose(); },
|
||||
});
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<Field label="금액 (KRW)" hint="비용/세금/급여는 자동으로 음수 처리됩니다"><Input type="number" value={f.amount} onChange={(e) => setF({ ...f, amount: e.target.value })} /></Field>
|
||||
<Field label="거래처"><Input value={f.counterparty} onChange={(e) => setF({ ...f, counterparty: e.target.value })} /></Field>
|
||||
<Field label="메모"><Input value={f.memo} onChange={(e) => setF({ ...f, memo: e.target.value })} /></Field>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TaxTab({ taxes, onChange }: { taxes: any[]; onChange: () => void }) {
|
||||
const [f, setF] = useState({ period: "", type: "부가세", amount: "", dueDate: "" });
|
||||
const add = useMutation({ mutationFn: () => createTax({ period: f.period, type: f.type, amount: parseFloat(f.amount) || 0, dueDate: f.dueDate }), onSuccess: () => { onChange(); setF({ period: "", type: "부가세", amount: "", dueDate: "" }); } });
|
||||
return (
|
||||
<div>
|
||||
<table className="dense-table mb-4">
|
||||
<thead><tr><th>기간</th><th>종류</th><th className="text-right">금액</th><th>납부기한</th><th>상태</th></tr></thead>
|
||||
<tbody>
|
||||
{taxes.map((t) => (
|
||||
<tr key={t.id}><td>{t.period}</td><td>{t.type}</td><td className="text-right tabular">{formatWon(t.amount)}</td><td className="tabular">{formatDate(t.dueDate)}</td>
|
||||
<td>{t.paid ? <Badge label="납부완료" fg="#067647" bg="#DCFAE6" size="sm" /> : <Badge label="예정" fg="#B54708" bg="#FEF0C7" size="sm" />}</td></tr>
|
||||
))}
|
||||
{taxes.length === 0 && <tr><td colSpan={5} className="text-center text-ink-muted py-6">세금 항목이 없습니다</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-wrap items-end gap-2 p-3 bg-canvas rounded-control">
|
||||
<Field label="기간"><Input value={f.period} onChange={(e) => setF({ ...f, period: e.target.value })} placeholder="2026-1Q" className="w-28" /></Field>
|
||||
<Field label="종류"><Input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} className="w-28" /></Field>
|
||||
<Field label="금액"><Input type="number" value={f.amount} onChange={(e) => setF({ ...f, amount: e.target.value })} className="w-36" /></Field>
|
||||
<Field label="납부기한"><Input type="date" value={f.dueDate} onChange={(e) => setF({ ...f, dueDate: e.target.value })} /></Field>
|
||||
<Button icon={<Plus size={15} />} disabled={!f.period || add.isPending} onClick={() => add.mutate()}>추가</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/pages/admin/Approvals.tsx
Normal file
98
src/pages/admin/Approvals.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
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 {
|
||||
Card, Button, Tabs, PageHeader, EmptyState, LoadingState, Input,
|
||||
} from "@/components/ui";
|
||||
import { formatDate, minutesToHM, LEAVE_LABELS } from "@/lib/format";
|
||||
|
||||
export function ApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState("queue");
|
||||
const q = useQuery({ queryKey: ["approvals"], queryFn: getApprovals });
|
||||
const [email, setEmail] = useState("");
|
||||
const attQ = useQuery({ queryKey: ["att-admin", email], queryFn: () => getAttendance({ email: email || undefined }), enabled: tab === "records" });
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="승인 관리" description="구성원의 휴가·초과근무 신청을 검토하고 전체 근무 기록을 확인합니다." />
|
||||
<Card>
|
||||
<div className="px-3 pt-2">
|
||||
<Tabs active={tab} onChange={setTab} tabs={[{ key: "queue", label: "승인 대기", badge: pendingCount }, { key: "records", label: "전체 근무 기록" }]} />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{tab === "queue" && (
|
||||
q.isLoading ? <LoadingState /> : (
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-ink mb-2">휴가 / 공가</h3>
|
||||
{(q.data?.leave.length ?? 0) === 0 ? <EmptyState title="대기중 휴가 신청 없음" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>신청자</th><th>종류</th><th>기간</th><th>일수</th><th>사유</th><th className="text-right">처리</th></tr></thead>
|
||||
<tbody>
|
||||
{q.data!.leave.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.memberEmail}</td><td>{LEAVE_LABELS[l.type]}</td>
|
||||
<td className="tabular">{formatDate(l.startDate)}{l.endDate !== l.startDate ? `~${formatDate(l.endDate)}` : ""}</td>
|
||||
<td className="tabular">{l.days}일</td><td className="text-ink-secondary max-w-[220px] truncate">{l.reason}</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<Button size="sm" variant="secondary" icon={<Check size={14} />} className="mr-1" onClick={() => decL.mutate({ id: l.id, ok: true })}>승인</Button>
|
||||
<Button size="sm" variant="danger" icon={<X size={14} />} onClick={() => decL.mutate({ id: l.id, ok: false })}>반려</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "records" && (
|
||||
<div>
|
||||
<div className="mb-3 max-w-xs"><Input placeholder="이메일로 필터 (비우면 전체)" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
{attQ.isLoading ? <LoadingState /> : (attQ.data?.length ?? 0) === 0 ? <EmptyState title="근무 기록 없음" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>구성원</th><th>날짜</th><th>근무시간</th></tr></thead>
|
||||
<tbody>
|
||||
{attQ.data!.map((a) => (
|
||||
<tr key={a.id}><td>{a.memberEmail}</td><td className="tabular">{formatDate(a.date)}</td><td className="tabular">{minutesToHM(a.workMinutes)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
src/pages/admin/IncentiveAdmin.tsx
Normal file
246
src/pages/admin/IncentiveAdmin.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Play, Lock, RefreshCw, Beaker } from "lucide-react";
|
||||
import {
|
||||
getSettlements, runSettlement, fixSettlement, getProjects, getStages, setStageStatus,
|
||||
getUserIncentives, patchUserIncentive, recomputeProject, simulate,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Card, Button, Badge, Tabs, PageHeader, Stepper, Field, Input, Select,
|
||||
EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import {
|
||||
formatWon, formatPoints, FIX_STATUS_META, FIX_ORDER, STAGE_KIND_LABELS, SCOPE_LABELS,
|
||||
} from "@/lib/format";
|
||||
import type { FixStatus, PaymentStage, UserIncentive } from "@/types";
|
||||
|
||||
const QUARTERS = [1, 2, 3, 4];
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
export function IncentiveAdminPage() {
|
||||
const [tab, setTab] = useState("settlement");
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="인센티브 관리" description="프로젝트 단계 픽스, 유저별 인센티브 반영, 분기 정산, 시뮬레이션을 모두 관리합니다." />
|
||||
<Card>
|
||||
<div className="px-3 pt-2">
|
||||
<Tabs active={tab} onChange={setTab} tabs={[
|
||||
{ key: "settlement", label: "분기 정산" },
|
||||
{ key: "project", label: "프로젝트 단계·배분" },
|
||||
{ key: "sim", label: "시뮬레이터" },
|
||||
]} />
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{tab === "settlement" && <SettlementTab />}
|
||||
{tab === "project" && <ProjectTab />}
|
||||
{tab === "sim" && <SimulatorTab />}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- quarterly settlement ---- */
|
||||
function SettlementTab() {
|
||||
const qc = useQueryClient();
|
||||
const [quarter, setQuarter] = useState((Math.floor(new Date().getMonth() / 3) + 1));
|
||||
const q = useQuery({ queryKey: ["settlements", YEAR], queryFn: () => getSettlements(YEAR) });
|
||||
const run = useMutation({ mutationFn: () => runSettlement(YEAR, quarter), onSuccess: () => qc.invalidateQueries({ queryKey: ["settlements", YEAR] }) });
|
||||
const fix = useMutation({ mutationFn: (id: string) => fixSettlement(id), onSuccess: () => qc.invalidateQueries({ queryKey: ["settlements", YEAR] }) });
|
||||
|
||||
const rows = (q.data ?? []).filter((s) => s.quarter === quarter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-sm text-ink-secondary">{YEAR}년</span>
|
||||
<Select value={quarter} onChange={(e) => setQuarter(+e.target.value)} className="w-28">
|
||||
{QUARTERS.map((q) => <option key={q} value={q}>{q * 3}월 ({q}분기)</option>)}
|
||||
</Select>
|
||||
<Button icon={<Play size={15} />} onClick={() => run.mutate()} disabled={run.isPending}>정산 계산</Button>
|
||||
<span className="text-xs text-ink-muted">3·6·9·12월 초에 누적 포인트 − 할당량 초과분을 계산합니다.</span>
|
||||
</div>
|
||||
{q.isLoading ? <LoadingState /> : rows.length === 0 ? <EmptyState title="정산 데이터가 없습니다" description="정산 계산을 실행하세요." /> : (
|
||||
<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 className="text-right">기지급</th><th className="text-right">지급 포인트</th><th className="text-right">지급액</th><th>상태</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.memberEmail}</td><td>{s.rank}</td>
|
||||
<td className="text-right tabular">{formatPoints(s.pointsCumul)}</td>
|
||||
<td className="text-right tabular">{formatPoints(s.quota)}</td>
|
||||
<td className="text-right tabular">{formatPoints(s.excessPoints)}</td>
|
||||
<td className="text-right tabular text-ink-muted">{formatPoints(s.paidPointsYtd)}</td>
|
||||
<td className="text-right tabular font-medium text-stage-paid">{formatPoints(s.payoutPoints)}</td>
|
||||
<td className="text-right tabular font-medium">{formatWon(s.payoutAmount)}</td>
|
||||
<td>{s.fixed ? <Badge label="확정" fg="#067647" bg="#DCFAE6" size="sm" /> : <Badge label="미확정" fg="#475467" bg="#F2F4F7" size="sm" />}</td>
|
||||
<td className="text-right">{!s.fixed && s.payoutPoints > 0 && <Button size="sm" variant="secondary" icon={<Lock size={13} />} onClick={() => fix.mutate(s.id)}>확정</Button>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- project stages + user allocations ---- */
|
||||
function ProjectTab() {
|
||||
const qc = useQueryClient();
|
||||
const projQ = useQuery({ queryKey: ["projects"], queryFn: () => getProjects() });
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const stagesQ = useQuery({ queryKey: ["stages", projectId], queryFn: () => getStages(projectId), enabled: !!projectId });
|
||||
const uiQ = useQuery({ queryKey: ["uis", projectId], queryFn: () => getUserIncentives({ projectId }), enabled: !!projectId });
|
||||
|
||||
const recompute = useMutation({ mutationFn: () => recomputeProject(projectId), onSuccess: () => { qc.invalidateQueries({ queryKey: ["stages", projectId] }); qc.invalidateQueries({ queryKey: ["uis", projectId] }); } });
|
||||
const setStatus = useMutation({
|
||||
mutationFn: ({ stId, status }: { stId: string; status: FixStatus }) => setStageStatus(stId, status, new Date().toISOString().slice(0, 10)),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["stages", projectId] }); qc.invalidateQueries({ queryKey: ["uis", projectId] }); },
|
||||
});
|
||||
const override = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: Partial<UserIncentive> }) => patchUserIncentive(id, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["uis", projectId] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Select value={projectId} onChange={(e) => setProjectId(e.target.value)} className="w-72">
|
||||
<option value="">프로젝트 선택</option>
|
||||
{projQ.data?.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</Select>
|
||||
{projectId && <Button variant="secondary" icon={<RefreshCw size={15} />} onClick={() => recompute.mutate()} disabled={recompute.isPending}>재계산</Button>}
|
||||
</div>
|
||||
|
||||
{!projectId ? <EmptyState title="프로젝트를 선택하세요" /> : (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-ink mb-3">결제 단계 (BE / non-BE × 계약금·중도금·잔금)</h3>
|
||||
{(stagesQ.data?.length ?? 0) === 0 ? <EmptyState title="단계가 없습니다" description="계약 정보 입력 후 재계산하세요." /> : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{["be", "non_be"].map((scope) => (
|
||||
<Card key={scope} className="p-4">
|
||||
<div className="text-sm font-semibold text-ink mb-3">{SCOPE_LABELS[scope]}</div>
|
||||
<div className="space-y-4">
|
||||
{(stagesQ.data ?? []).filter((s) => s.scope === scope).map((st) => (
|
||||
<StageRow key={st.id} stage={st} onSet={(status) => setStatus.mutate({ stId: st.id, status })} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-ink mb-3">유저별 인센티브 (커스텀 오버라이드 가능)</h3>
|
||||
{(uiQ.data?.length ?? 0) === 0 ? <EmptyState title="배분 내역이 없습니다" /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>구성원</th><th>단계</th><th>구분</th><th className="text-right">기여도</th><th className="text-right">포인트</th><th>반영상태</th><th>비고</th></tr></thead>
|
||||
<tbody>
|
||||
{uiQ.data!.map((ui) => (
|
||||
<tr key={ui.id}>
|
||||
<td>{ui.memberEmail}</td>
|
||||
<td>{STAGE_KIND_LABELS[ui.kind]}</td>
|
||||
<td>{SCOPE_LABELS[ui.scope]}</td>
|
||||
<td className="text-right tabular">{ui.portion}%</td>
|
||||
<td className="text-right tabular font-medium">{formatPoints(ui.points)}</td>
|
||||
<td>
|
||||
<select className="text-xs border border-border rounded px-1 py-0.5 bg-white"
|
||||
value={ui.fixStatus}
|
||||
onChange={(e) => override.mutate({ id: ui.id, body: { fixStatus: e.target.value as FixStatus } })}>
|
||||
{FIX_ORDER.map((f) => <option key={f} value={f}>{FIX_STATUS_META[f].label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>{ui.override && <Badge label="수동" fg="#B54708" bg="#FEF0C7" size="sm" />}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="text-xs text-ink-muted mt-2">※ 반영 상태를 직접 변경하면 "수동(override)"으로 고정되어 재계산 시 보존됩니다. (특정 유저만 픽스 제외 등 모든 케이스 처리)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageRow({ stage, onSet }: { stage: PaymentStage; onSet: (s: FixStatus) => void }) {
|
||||
const idx = FIX_ORDER.indexOf(stage.status);
|
||||
return (
|
||||
<div className="border border-divider rounded-control p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-medium text-ink">{STAGE_KIND_LABELS[stage.kind]} <span className="text-ink-muted">({stage.pct}%)</span></div>
|
||||
<div className="text-sm tabular font-semibold">{formatWon(stage.amount)}</div>
|
||||
</div>
|
||||
<Stepper activeIndex={idx} steps={FIX_ORDER.map((f) => ({ label: FIX_STATUS_META[f].label, color: FIX_STATUS_META[f].fg }))} />
|
||||
<div className="flex gap-1 mt-3">
|
||||
{FIX_ORDER.map((f) => (
|
||||
<button key={f} onClick={() => onSet(f)}
|
||||
className={`text-[11px] px-2 py-1 rounded-control border ${stage.status === f ? "bg-navy text-white border-navy" : "border-border text-ink-secondary hover:bg-canvas"}`}>
|
||||
{FIX_STATUS_META[f].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- simulator ---- */
|
||||
function SimulatorTab() {
|
||||
const [total, setTotal] = useState("100000000");
|
||||
const [be, setBe] = useState("60000000");
|
||||
const [members, setMembers] = useState([
|
||||
{ email: "member@special-partners.com", portion: 40, isPartner: false },
|
||||
{ email: "choi@special-partners.com", portion: 60, isPartner: true },
|
||||
]);
|
||||
const sim = useMutation({ mutationFn: () => simulate({ total: +total, be: +be, members }) });
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<div className="text-sm font-semibold text-ink mb-2">작업자</div>
|
||||
<div className="space-y-2">
|
||||
{members.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input value={m.email} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, email: e.target.value } : x))} className="flex-1" />
|
||||
<Input type="number" value={m.portion} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, portion: +e.target.value } : x))} className="w-20" />
|
||||
<label className="text-xs flex items-center gap-1 whitespace-nowrap"><input type="checkbox" checked={m.isPartner} onChange={(e) => setMembers(members.map((x, j) => j === i ? { ...x, isPartner: e.target.checked } : x))} />파트너</label>
|
||||
<button className="text-ink-muted" onClick={() => setMembers(members.filter((_, j) => j !== i))}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" variant="secondary" onClick={() => setMembers([...members, { email: "", portion: 0, isPartner: false }])}>+ 작업자</Button>
|
||||
</div>
|
||||
<Button className="mt-4" icon={<Play size={15} />} onClick={() => sim.mutate()} disabled={sim.isPending}>시뮬레이션 실행</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-bold text-ink mb-3">결과</div>
|
||||
{!sim.data ? <EmptyState title="실행 결과가 여기에 표시됩니다" /> : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-ink-secondary mb-1">유저별 총 포인트</div>
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>구성원</th><th className="text-right">포인트</th></tr></thead>
|
||||
<tbody>{Object.entries(sim.data.byMember).map(([e, p]) => <tr key={e}><td>{e}</td><td className="text-right tabular font-medium">{formatPoints(p)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-ink-secondary mb-1">단계별 금액</div>
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>단계</th><th>구분</th><th className="text-right">금액</th></tr></thead>
|
||||
<tbody>{sim.data.stages.map((s, i) => <tr key={i}><td>{STAGE_KIND_LABELS[s.kind]}</td><td>{SCOPE_LABELS[s.scope]}</td><td className="text-right tabular">{formatWon(s.amount)}</td></tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/pages/admin/Members.tsx
Normal file
125
src/pages/admin/Members.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2, Users } from "lucide-react";
|
||||
import {
|
||||
getMembers, createMember, updateMember, deleteMember, getDepartments, createDepartment,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
Card, Button, Badge, PageHeader, Modal, Drawer, Field, Input, Select,
|
||||
Tabs, EmptyState, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { Member } from "@/types";
|
||||
|
||||
const RANKS = ["주임", "선임", "책임", "파트너"];
|
||||
|
||||
export function MembersPage() {
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState("members");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [edit, setEdit] = useState<Member | null>(null);
|
||||
const mQ = useQuery({ queryKey: ["members"], queryFn: getMembers });
|
||||
const dQ = useQuery({ queryKey: ["departments"], queryFn: getDepartments });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="구성원 관리"
|
||||
description="계정 생성/해제는 Keycloak에서, 여기서는 회사 구성원 정보(직급·부서·권한)를 관리합니다."
|
||||
action={tab === "members" && <Button icon={<Plus size={16} />} onClick={() => setCreateOpen(true)}>구성원 추가</Button>}
|
||||
/>
|
||||
<Card>
|
||||
<div className="px-3 pt-2"><Tabs active={tab} onChange={setTab} tabs={[{ key: "members", label: "구성원" }, { key: "departments", label: "부서" }]} /></div>
|
||||
<div className="p-2">
|
||||
{tab === "members" && (
|
||||
mQ.isLoading ? <LoadingState /> : (mQ.data?.length ?? 0) === 0 ? <EmptyState title="구성원이 없습니다" icon={<Users size={28} />} /> : (
|
||||
<table className="dense-table">
|
||||
<thead><tr><th>이름</th><th>이메일</th><th>직급</th><th>권한</th><th>파트너</th><th>입사일</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{mQ.data!.map((m) => (
|
||||
<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>{m.role === "admin" ? <Badge label="관리자" fg="#5925DC" bg="#EBE9FE" size="sm" /> : "구성원"}</td>
|
||||
<td>{m.isPartner ? "✓" : ""}</td>
|
||||
<td className="tabular">{formatDate(m.joinDate)}</td>
|
||||
<td className="text-right"><button className="text-ink-muted hover:text-money-out" onClick={(e) => { e.stopPropagation(); if (confirm("삭제하시겠습니까?")) deleteMember(m.id).then(() => qc.invalidateQueries({ queryKey: ["members"] })); }}><Trash2 size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
{tab === "departments" && <Departments depts={dQ.data ?? []} onChange={() => qc.invalidateQueries({ queryKey: ["departments"] })} />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{createOpen && <MemberCreateModal onClose={() => setCreateOpen(false)} depts={dQ.data ?? []} onDone={() => qc.invalidateQueries({ queryKey: ["members"] })} />}
|
||||
{edit && <MemberEditDrawer member={edit} depts={dQ.data ?? []} onClose={() => setEdit(null)} onDone={() => { qc.invalidateQueries({ queryKey: ["members"] }); setEdit(null); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCreateModal({ onClose, onDone, depts }: { onClose: () => void; onDone: () => void; depts: { id: string; name: string }[] }) {
|
||||
const [f, setF] = useState({ email: "", displayName: "", rank: "주임", role: "user", departmentId: "", isPartner: false, joinDate: "" });
|
||||
const m = useMutation({ mutationFn: () => createMember({ ...f, role: f.role as any, rank: f.rank as any, departmentId: f.departmentId || null, joinDate: f.joinDate || null }), onSuccess: () => { onDone(); onClose(); } });
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<Field label="권한"><Select value={f.role} onChange={(e) => setF({ ...f, role: e.target.value })}><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="date" value={f.joinDate} onChange={(e) => setF({ ...f, joinDate: 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 })} /> 파트너 (non-BE 수익 분배 대상)</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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) }),
|
||||
onSuccess: onDone,
|
||||
});
|
||||
return (
|
||||
<Drawer open onClose={onClose} title={`${member.displayName} 정보`}
|
||||
footer={<><Button variant="secondary" onClick={onClose}>닫기</Button><Button disabled={m.isPending} onClick={() => m.mutate()}>저장</Button></>}>
|
||||
<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">
|
||||
<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>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function Departments({ depts, onChange }: { depts: { id: string; name: string }[]; onChange: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const add = useMutation({ mutationFn: () => createDepartment({ name }), onSuccess: () => { onChange(); setName(""); } });
|
||||
return (
|
||||
<div className="p-3">
|
||||
<table className="dense-table mb-3"><thead><tr><th>부서명</th></tr></thead>
|
||||
<tbody>{depts.map((d) => <tr key={d.id}><td>{d.name}</td></tr>)}{depts.length === 0 && <tr><td className="text-center text-ink-muted py-6">부서가 없습니다</td></tr>}</tbody>
|
||||
</table>
|
||||
<div className="flex items-end gap-2">
|
||||
<Field label="새 부서"><Input value={name} onChange={(e) => setName(e.target.value)} className="w-56" /></Field>
|
||||
<Button icon={<Plus size={15} />} disabled={!name || add.isPending} onClick={() => add.mutate()}>추가</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/pages/admin/Settings.tsx
Normal file
114
src/pages/admin/Settings.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getIncentiveConfig, putIncentiveConfig, getWorkPolicy, putWorkPolicy } from "@/lib/api";
|
||||
import {
|
||||
Card, CardHeader, Button, Field, Input, PageHeader, LoadingState,
|
||||
} from "@/components/ui";
|
||||
import { formatWon } from "@/lib/format";
|
||||
|
||||
const RANKS = ["주임", "선임", "책임", "파트너"];
|
||||
|
||||
export function SettingsPage() {
|
||||
const qc = useQueryClient();
|
||||
const cfgQ = useQuery({ queryKey: ["incentive-config"], queryFn: () => getIncentiveConfig() });
|
||||
const polQ = useQuery({ queryKey: ["work-policy"], queryFn: getWorkPolicy });
|
||||
|
||||
if (cfgQ.isLoading || polQ.isLoading) return <LoadingState />;
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<PageHeader title="설정" description="인센티브 규칙과 근무 정책을 관리합니다. 규칙은 연도별로 적용되며 확정(freeze)할 수 있습니다." />
|
||||
<IncentiveConfigCard initial={cfgQ.data!} onSaved={() => qc.invalidateQueries({ queryKey: ["incentive-config"] })} />
|
||||
<WorkPolicyCard initial={polQ.data!} onSaved={() => qc.invalidateQueries({ queryKey: ["work-policy"] })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncentiveConfigCard({ initial, onSaved }: { initial: any; onSaved: () => void }) {
|
||||
const [f, setF] = useState({
|
||||
year: initial.year,
|
||||
pointRate: String(initial.pointRate),
|
||||
depositPct: String(initial.depositPct),
|
||||
middlePct: String(initial.middlePct),
|
||||
finalPct: String(initial.finalPct),
|
||||
nonBeCompanyPct: String(initial.nonBeCompanyPct),
|
||||
nonBePartnerPct: String(initial.nonBePartnerPct),
|
||||
frozen: initial.frozen,
|
||||
});
|
||||
const [quota, setQuota] = useState<Record<string, string>>(
|
||||
Object.fromEntries(RANKS.map((r) => [r, String(initial.rankQuota?.[r] ?? 0)]))
|
||||
);
|
||||
const save = useMutation({
|
||||
mutationFn: () => putIncentiveConfig({
|
||||
year: f.year, pointRate: +f.pointRate, depositPct: +f.depositPct, middlePct: +f.middlePct,
|
||||
finalPct: +f.finalPct, nonBeCompanyPct: +f.nonBeCompanyPct, nonBePartnerPct: +f.nonBePartnerPct,
|
||||
frozen: f.frozen, rankQuota: Object.fromEntries(RANKS.map((r) => [r, +quota[r]])),
|
||||
}),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
|
||||
const stageSum = +f.depositPct + +f.middlePct + +f.finalPct;
|
||||
const nonBeSum = +f.nonBeCompanyPct + +f.nonBePartnerPct;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
{RANKS.map((r) => (
|
||||
<Field key={r} label={r}><Input type="number" value={quota[r]} onChange={(e) => setQuota({ ...quota, [r]: e.target.value })} /></Field>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={f.frozen} onChange={(e) => setF({ ...f, frozen: e.target.checked })} /> 올해 규칙 확정(freeze)</label>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkPolicyCard({ initial, onSaved }: { initial: any; onSaved: () => void }) {
|
||||
const [f, setF] = useState({
|
||||
weeklyHours: String(initial.weeklyHours), dailyStandardMin: String(initial.dailyStandardMin),
|
||||
coreStart: initial.coreStart, coreEnd: initial.coreEnd, lunchMinutes: String(initial.lunchMinutes),
|
||||
annualLeaveBase: String(initial.annualLeaveBase),
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: () => putWorkPolicy({
|
||||
name: "기본 근무제", weeklyHours: +f.weeklyHours, dailyStandardMin: +f.dailyStandardMin,
|
||||
coreStart: f.coreStart, coreEnd: f.coreEnd, lunchMinutes: +f.lunchMinutes, annualLeaveBase: +f.annualLeaveBase, active: true,
|
||||
}),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
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">
|
||||
<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>
|
||||
<Field label="코어타임 시작"><Input value={f.coreStart} onChange={(e) => setF({ ...f, coreStart: e.target.value })} /></Field>
|
||||
<Field label="코어타임 종료"><Input value={f.coreEnd} onChange={(e) => setF({ ...f, coreEnd: e.target.value })} /></Field>
|
||||
<Field label="연차 기본 부여일"><Input type="number" value={f.annualLeaveBase} onChange={(e) => setF({ ...f, annualLeaveBase: e.target.value })} /></Field>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
367
src/types.ts
Normal file
367
src/types.ts
Normal file
@ -0,0 +1,367 @@
|
||||
// Types mirror the Go models (camelCase JSON). Kept in one file for clarity.
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
groups?: string[];
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export type Rank = "주임" | "선임" | "책임" | "파트너";
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
rank: Rank | "";
|
||||
departmentId?: string | null;
|
||||
role: "admin" | "user";
|
||||
isPartner: boolean;
|
||||
phone: string;
|
||||
position: string;
|
||||
status: string;
|
||||
joinDate?: string | null;
|
||||
annualLeave: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
user: User;
|
||||
member: Member | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
adminOnly: boolean;
|
||||
section: string;
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
leadEmail: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/* ---- attendance ---- */
|
||||
export type ReqStatus = "pending" | "approved" | "rejected" | "canceled";
|
||||
|
||||
export interface Attendance {
|
||||
id: string;
|
||||
memberEmail: string;
|
||||
date: string;
|
||||
clockIn?: string | null;
|
||||
clockOut?: string | null;
|
||||
workMinutes: number;
|
||||
source: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export type LeaveType =
|
||||
| "annual" | "half_am" | "half_pm" | "public" | "sick" | "family" | "unpaid";
|
||||
|
||||
export interface LeaveRequest {
|
||||
id: string;
|
||||
memberEmail: string;
|
||||
type: LeaveType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
days: number;
|
||||
reason: string;
|
||||
status: ReqStatus;
|
||||
approver: string;
|
||||
decidedAt?: string | null;
|
||||
decisionMemo: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OvertimeRequest {
|
||||
id: string;
|
||||
memberEmail: string;
|
||||
date: string;
|
||||
minutes: number;
|
||||
reason: string;
|
||||
status: ReqStatus;
|
||||
approver: string;
|
||||
decidedAt?: string | null;
|
||||
decisionMemo: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorkPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
weeklyHours: number;
|
||||
dailyStandardMin: number;
|
||||
coreStart: string;
|
||||
coreEnd: string;
|
||||
lunchMinutes: number;
|
||||
annualLeaveBase: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Timesheet {
|
||||
year: number;
|
||||
month: number;
|
||||
businessDays: number;
|
||||
standardMinutes: number;
|
||||
workedMinutes: number;
|
||||
leaveMinutes: number;
|
||||
overtimeMinutes: number;
|
||||
recognizedTotal: number;
|
||||
fulfillmentPct: number;
|
||||
daysPresent: number;
|
||||
}
|
||||
|
||||
export interface ApprovalQueue {
|
||||
leave: LeaveRequest[];
|
||||
overtime: OvertimeRequest[];
|
||||
}
|
||||
|
||||
/* ---- projects ---- */
|
||||
export interface Company { id: string; name: string; code: string; note: string; }
|
||||
export interface Product { id: string; companyId: string; name: string; code: string; }
|
||||
export interface Version { id: string; productId: string; label: string; }
|
||||
|
||||
export type ProjectStatus = "planned" | "active" | "hold" | "done" | "dropped";
|
||||
export type Scope = "text" | "graphic" | "both";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
companyId: string;
|
||||
productId: string;
|
||||
versionId: string;
|
||||
companyName: string;
|
||||
productName: string;
|
||||
versionName: string;
|
||||
consultingType: string;
|
||||
country: string;
|
||||
scope: Scope;
|
||||
pmEmail: string;
|
||||
cautions: string;
|
||||
status: ProjectStatus;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
id: string;
|
||||
projectId: string;
|
||||
memberEmail: string;
|
||||
portion: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface ClientContact {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
title: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type Lane = "todo" | "doing" | "review" | "done";
|
||||
export interface ProjectTask {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
lane: Lane;
|
||||
start: string;
|
||||
end: string;
|
||||
assignee: string;
|
||||
orderIdx: number;
|
||||
progress: number;
|
||||
dependsOn: string[] | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Contract {
|
||||
id: string;
|
||||
projectId: string;
|
||||
totalAmount: number;
|
||||
beAmount: number;
|
||||
adminCaution: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export interface ContractFile {
|
||||
id: string;
|
||||
projectId: string;
|
||||
kind: string;
|
||||
filename: string;
|
||||
s3Key: string;
|
||||
size: number;
|
||||
uploadedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaymentSplit {
|
||||
id: string;
|
||||
projectId: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
expectedDate: string;
|
||||
paidDate: string;
|
||||
paid: boolean;
|
||||
memo: string;
|
||||
orderIdx: number;
|
||||
}
|
||||
|
||||
/* ---- incentive ---- */
|
||||
export type StageKind = "deposit" | "middle" | "final";
|
||||
export type StageScope = "be" | "non_be";
|
||||
export type FixStatus = "planned" | "applying" | "applied" | "paid";
|
||||
|
||||
export interface IncentiveConfig {
|
||||
id: string;
|
||||
year: number;
|
||||
pointRate: number;
|
||||
depositPct: number;
|
||||
middlePct: number;
|
||||
finalPct: number;
|
||||
nonBeCompanyPct: number;
|
||||
nonBePartnerPct: number;
|
||||
rankQuota: Record<string, number>;
|
||||
frozen: boolean;
|
||||
}
|
||||
|
||||
export interface PaymentStage {
|
||||
id: string;
|
||||
projectId: string;
|
||||
kind: StageKind;
|
||||
scope: StageScope;
|
||||
amount: number;
|
||||
pct: number;
|
||||
expectedDate: string;
|
||||
fixedDate: string;
|
||||
status: FixStatus;
|
||||
}
|
||||
|
||||
export interface UserIncentive {
|
||||
id: string;
|
||||
projectId: string;
|
||||
memberEmail: string;
|
||||
stageId: string;
|
||||
kind: StageKind;
|
||||
scope: StageScope;
|
||||
year: number;
|
||||
quarter: number;
|
||||
portion: number;
|
||||
amount: number;
|
||||
points: number;
|
||||
fixStatus: FixStatus;
|
||||
override: boolean;
|
||||
memo: string;
|
||||
appliedAt?: string | null;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
|
||||
export interface MyIncentive {
|
||||
year: number;
|
||||
rank: string;
|
||||
quota: number;
|
||||
pointsTotal: number;
|
||||
pointsApplied: number;
|
||||
excessPoints: number;
|
||||
pointRate: number;
|
||||
estPayout: number;
|
||||
items: UserIncentive[];
|
||||
byProject: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Settlement {
|
||||
id: string;
|
||||
memberEmail: string;
|
||||
year: number;
|
||||
quarter: number;
|
||||
rank: string;
|
||||
quota: number;
|
||||
pointsCumul: number;
|
||||
excessPoints: number;
|
||||
paidPointsYtd: number;
|
||||
payoutPoints: number;
|
||||
payoutAmount: number;
|
||||
fixed: boolean;
|
||||
fixedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface SimResult {
|
||||
stages: { kind: StageKind; scope: StageScope; amount: number; pct: number }[];
|
||||
allocs: { email: string; kind: StageKind; scope: StageScope; portion: number; amount: number; points: number }[];
|
||||
byMember: Record<string, number>;
|
||||
}
|
||||
|
||||
/* ---- accounting ---- */
|
||||
export type TxnKind = "income" | "expense" | "tax" | "payroll" | "incentive";
|
||||
|
||||
export interface Account { id: string; code: string; name: string; type: string; }
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
date: string;
|
||||
kind: TxnKind;
|
||||
accountId?: string | null;
|
||||
amount: number;
|
||||
projectId?: string | null;
|
||||
memberEmail?: string | null;
|
||||
counterparty: string;
|
||||
memo: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface TaxRecord {
|
||||
id: string;
|
||||
period: string;
|
||||
type: string;
|
||||
base: number;
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
paid: boolean;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
export interface AcctSummary {
|
||||
year: number;
|
||||
cashIn: number;
|
||||
cashOut: number;
|
||||
net: number;
|
||||
incentiveApplied: number;
|
||||
incentivePaid: number;
|
||||
gap: number;
|
||||
monthly: { month: string; income: number; expense: number; net: number }[];
|
||||
byKind: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
isAdmin: boolean;
|
||||
year: number;
|
||||
myProjects: number;
|
||||
myPoints: number;
|
||||
myPendingRequests: number;
|
||||
pendingApprovals?: number;
|
||||
activeProjects?: number;
|
||||
cashIn?: number;
|
||||
cashOut?: number;
|
||||
cashNet?: number;
|
||||
upcomingPayments?: PaymentSplit[];
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
71
tailwind.config.js
Normal file
71
tailwind.config.js
Normal file
@ -0,0 +1,71 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
// spin reuses the Special Partners design tokens (shared with eQMS) so the
|
||||
// internal tools feel like one product, then extends them for a dense,
|
||||
// professional accounting/ops surface (status & stage colors, wide tables).
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
navy: {
|
||||
DEFAULT: "#11224F",
|
||||
hover: "#1B2F66",
|
||||
sidebar: "#0C1733",
|
||||
subtle: "#E8ECF5",
|
||||
},
|
||||
canvas: "#F5F6F8",
|
||||
surface: "#FFFFFF",
|
||||
border: {
|
||||
DEFAULT: "#E4E7EC",
|
||||
strong: "#D0D5DD",
|
||||
},
|
||||
ink: {
|
||||
DEFAULT: "#101828",
|
||||
secondary: "#475467",
|
||||
strong: "#344054",
|
||||
muted: "#98A2B3",
|
||||
},
|
||||
// request / approval statuses
|
||||
status: {
|
||||
"pending-fg": "#B54708",
|
||||
"pending-bg": "#FEF0C7",
|
||||
"approved-fg": "#067647",
|
||||
"approved-bg": "#DCFAE6",
|
||||
"rejected-fg": "#B42318",
|
||||
"rejected-bg": "#FEE4E2",
|
||||
"neutral-fg": "#475467",
|
||||
"neutral-bg": "#F2F4F7",
|
||||
},
|
||||
// incentive fix lifecycle (예정 → 반영중 → 반영완료 → 지급완료)
|
||||
stage: {
|
||||
planned: "#98A2B3",
|
||||
applying: "#2E90FA",
|
||||
applied: "#7A5AF8",
|
||||
paid: "#12B76A",
|
||||
},
|
||||
// accounting semantics
|
||||
money: {
|
||||
in: "#067647",
|
||||
out: "#B42318",
|
||||
},
|
||||
chip: { bg: "#EEF1F8" },
|
||||
divider: "#F2F4F7",
|
||||
},
|
||||
fontFamily: {
|
||||
wordmark: ['"Lora"', "serif"],
|
||||
sans: ['"Noto Sans KR"', "system-ui", "sans-serif"],
|
||||
num: ['"Inter"', "system-ui", "sans-serif"],
|
||||
},
|
||||
borderRadius: {
|
||||
card: "12px",
|
||||
control: "8px",
|
||||
pill: "999px",
|
||||
},
|
||||
boxShadow: {
|
||||
card: "0 1px 2px rgba(16,24,40,0.06)",
|
||||
pop: "0 8px 24px rgba(16,24,40,0.12)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
17
tsconfig.node.json
Normal file
17
tsconfig.node.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
vite.config.ts
Normal file
21
vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5380,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8380",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user