feat: 기준정보 CRUD 페이지·부서 수정/삭제·프로젝트 수정 + 대시보드 인센티브 게이지·메일함 넓게·근무상태 디폴트 퇴근·인센티브 연도 선택
All checks were successful
build-and-push / build (push) Successful in 31s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-28 11:45:43 +09:00
parent 581fd7a19f
commit 851a19ea5f
13 changed files with 332 additions and 105 deletions

View File

@ -13,6 +13,7 @@ import { InboxPage } from "@/pages/Inbox";
import { ApprovalsPage } from "@/pages/admin/Approvals";
import { AttendanceAdminPage } from "@/pages/admin/AttendanceAdmin";
import { ProjectsAdminPage } from "@/pages/admin/ProjectsAdmin";
import { MasterDataPage } from "@/pages/admin/MasterData";
import { IncentiveAdminPage } from "@/pages/admin/IncentiveAdmin";
import { AccountingPage } from "@/pages/admin/Accounting";
import { MembersPage } from "@/pages/admin/Members";
@ -47,6 +48,7 @@ function Shell() {
<Route path="/admin/approvals" element={<RequireAdmin><ApprovalsPage /></RequireAdmin>} />
<Route path="/admin/attendance" element={<RequireAdmin><AttendanceAdminPage /></RequireAdmin>} />
<Route path="/admin/projects" element={<RequireAdmin><ProjectsAdminPage /></RequireAdmin>} />
<Route path="/admin/master" element={<RequireAdmin><MasterDataPage /></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>} />

View File

@ -0,0 +1,56 @@
import { Card } from "./ui";
import { formatPoints } from "@/lib/format";
import type { FixStatus, MyIncentive } from "@/types";
// 4색 세그먼트 게이지: 채우는 순서 지급완료→반영완료→반영중→예정, 할당량 위치는 ▼ 화살표.
export const SEG: { key: FixStatus; label: string; color: string }[] = [
{ key: "paid", label: "지급완료", color: "#F04438" },
{ key: "applied", label: "반영완료", color: "#12B76A" },
{ key: "applying", label: "반영중", color: "#F79009" },
{ key: "planned", label: "예정", color: "#98A2B3" },
];
export function QuotaGauge({ seg, quota }: { seg: { key: string; color: string; pts: number }[]; quota: number }) {
const total = seg.reduce((s, x) => s + x.pts, 0);
const denom = Math.max(quota, total, 1);
const quotaPct = Math.min(100, (quota / denom) * 100);
return (
<div className="relative pt-5">
{quota > 0 && (
<div className="absolute top-0 -translate-x-1/2 flex flex-col items-center" style={{ left: `${quotaPct}%` }}>
<span className="text-[10px] font-medium text-ink-secondary whitespace-nowrap"> {formatPoints(quota)}</span>
<span className="text-navy leading-none" style={{ marginTop: -1 }}></span>
</div>
)}
<div className="flex h-5 rounded-pill bg-divider overflow-hidden">
{seg.map((s) => (s.pts > 0 ? <div key={s.key} title={`${Math.round(s.pts * 10) / 10}P`} style={{ width: `${(s.pts / denom) * 100}%`, background: s.color }} /> : null))}
</div>
{quota > 0 && <div className="absolute bottom-0 w-px h-5 bg-navy/40" style={{ left: `${quotaPct}%` }} />}
</div>
);
}
// 할당량 달성률 카드 — 대시보드 상단·인센티브 페이지 공용.
export function IncentiveGaugeCard({ data, className }: { data: MyIncentive; className?: string }) {
const byStatus = (st: FixStatus) => data.items.filter((i) => i.fixStatus === st).reduce((s, i) => s + i.points, 0);
const seg = SEG.map((s) => ({ ...s, pts: byStatus(s.key) }));
const realized = byStatus("paid") + byStatus("applied");
return (
<Card className={"p-5 " + (className ?? "")}>
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-bold text-ink"> <span className="text-ink-muted font-medium">· {data.year}</span></h3>
<span className="text-sm font-num font-semibold text-navy">{data.quota > 0 ? Math.round((realized / data.quota) * 100) : 0}%</span>
</div>
<QuotaGauge seg={seg} quota={data.quota} />
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 mt-3">
{seg.map((s) => (
<span key={s.key} className="inline-flex items-center gap-1.5 text-xs text-ink-secondary">
<span className="w-2.5 h-2.5 rounded-sm" style={{ background: s.color }} />
{s.label} <span className="font-num text-ink">{formatPoints(s.pts)}</span>
</span>
))}
<span className="ml-auto text-xs text-ink-muted"> <span className="font-num text-money-in">{formatPoints(data.excessPoints)}</span></span>
</div>
</Card>
);
}

View File

@ -1,7 +1,7 @@
import { NavLink } from "react-router-dom";
import {
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator,
Wallet, Users, Settings, FolderCog, Inbox, UserCircle, ClipboardList,
Wallet, Users, Settings, FolderCog, Inbox, UserCircle, ClipboardList, Database,
type LucideIcon,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
@ -14,7 +14,7 @@ import type { NavItem } from "@/types";
export const ICONS: Record<string, LucideIcon> = {
LayoutDashboard, Clock, FolderKanban, Coins, CheckSquare, Calculator, Wallet, Users, Settings, FolderCog,
Inbox, UserCircle, ClipboardList,
Inbox, UserCircle, ClipboardList, Database,
};
export function Sidebar({ collapsed = false, className }: { collapsed?: boolean; className?: string }) {

View File

@ -7,7 +7,7 @@ import type { WorkStatusKind } from "@/types";
// 근무 상태 빠른 설정. 출근/퇴근은 실제 출퇴근 punch API를 호출하고, 휴식/미팅/이동은
// 현재 상태 표시(프레즌스)로 사용합니다. 선택값은 localStorage에 보관됩니다.
type StatusKey = "off" | "in" | "break" | "meeting" | "move" | "out";
type StatusKey = "in" | "break" | "meeting" | "move" | "out";
const OPTIONS: { key: StatusKey; label: string; color: string; icon: typeof LogIn }[] = [
{ key: "in", label: "출근", color: "#12B76A", icon: LogIn },
@ -17,7 +17,6 @@ const OPTIONS: { key: StatusKey; label: string; color: string; icon: typeof LogI
{ key: "out", label: "퇴근", color: "#98A2B3", icon: LogOut },
];
const META: Record<StatusKey, { label: string; color: string }> = {
off: { label: "미출근", color: "#667085" },
in: { label: "근무중", color: "#12B76A" },
break: { label: "휴식", color: "#F79009" },
meeting: { label: "미팅", color: "#2E90FA" },
@ -29,7 +28,7 @@ export function WorkStatusMenu({ collapsed = false }: { collapsed?: boolean }) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<StatusKey>(
() => (localStorage.getItem("spin.workStatus") as StatusKey) || "off"
() => (localStorage.getItem("spin.workStatus") as StatusKey) || "out"
);
const ref = useRef<HTMLDivElement>(null);
@ -55,7 +54,7 @@ export function WorkStatusMenu({ collapsed = false }: { collapsed?: boolean }) {
setStatus(key);
localStorage.setItem("spin.workStatus", key);
setOpen(false);
if (key !== "off") statusM.mutate(key as WorkStatusKind); // record every presence change
statusM.mutate(key as WorkStatusKind); // record every presence change
};
const cur = META[status];

View File

@ -99,12 +99,18 @@ export const getApprovals = () => api.get<ApprovalQueue>("/approvals").then((r)
/* ---- 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 updateCompany = (id: string, b: Partial<Company>) => api.patch<Company>(`/companies/${id}`, b).then((r) => r.data);
export const deleteCompany = (id: string) => api.delete(`/companies/${id}`).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 updateProduct = (id: string, b: Partial<Product>) => api.patch<Product>(`/products/${id}`, b).then((r) => r.data);
export const deleteProduct = (id: string) => api.delete(`/products/${id}`).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 updateVersion = (id: string, b: Partial<Version>) => api.patch<Version>(`/versions/${id}`, b).then((r) => r.data);
export const deleteVersion = (id: string) => api.delete(`/versions/${id}`).then((r) => r.data);
export const getProjects = (params?: { companyId?: string; status?: string; scope?: "mine" }) =>
api.get<Project[]>("/projects", { params }).then((r) => r.data);
@ -205,5 +211,6 @@ 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 deleteTax = (taxId: string) => api.delete(`/taxes/${taxId}`).then((r) => r.data);
export const getAccountingSummary = (year?: number) =>
api.get<AcctSummary>("/accounting/summary", { params: { year } }).then((r) => r.data);

View File

@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { Clock, FolderKanban, Coins, User } from "lucide-react";
import { getDashboard } from "@/lib/api";
import { getDashboard, getMyIncentive } from "@/lib/api";
import { useAuth } from "@/context/Auth";
import { Card, CardHeader, Stat, PageHeader, LoadingState } from "@/components/ui";
import { IncentiveGaugeCard } from "@/components/IncentiveGauge";
import { formatPoints } from "@/lib/format";
// 개요(대시보드)는 역할과 무관하게 동일하게 보입니다 — 본인 업무 요약만 표시하고
@ -11,6 +12,7 @@ import { formatPoints } from "@/lib/format";
export function DashboardPage() {
const { me } = useAuth();
const q = useQuery({ queryKey: ["dashboard"], queryFn: getDashboard });
const incQ = useQuery({ queryKey: ["my-incentive", "dash"], queryFn: () => getMyIncentive() });
if (q.isLoading) return <LoadingState />;
const d = q.data!;
@ -20,10 +22,13 @@ export function DashboardPage() {
<div>
<PageHeader title={`안녕하세요, ${name}`} description="오늘도 좋은 하루 되세요. 내 업무 현황을 확인하세요." />
{/* 최상단 인센티브 할당량 게이지 */}
{incQ.data && <IncentiveGaugeCard data={incQ.data} className="mb-4" />}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<Link to="/projects"><Stat label="참여 프로젝트" value={d.myProjects} sub="내가 속한 프로젝트" /></Link>
<Link to="/incentive"><Stat label="올해 인센티브 포인트" value={formatPoints(d.myPoints)} sub="반영완료 기준" accent="#5925DC" /></Link>
<Link to="/attendance"><Stat label="대기중 신청" value={d.myPendingRequests} sub="휴가·초과근무 승인 대기" accent="#B54708" /></Link>
<Link to="/attendance"><Stat label="대기중 신청" value={d.myPendingRequests} sub="휴가·공가 승인 대기" accent="#B54708" /></Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">

View File

@ -35,7 +35,7 @@ export function InboxPage() {
const unread = items.filter((n) => !n.read).length;
return (
<div className="max-w-3xl">
<div>
<PageHeader
title="메일함"
description="나의 근무·프로젝트·인센티브 관련 알림"

View File

@ -1,40 +1,37 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from "recharts";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
import { getMyIncentive } from "@/lib/api";
import {
Card, CardHeader, Stat, PageHeader, LoadingState, EmptyState, Badge,
Card, CardHeader, Stat, PageHeader, Select, LoadingState, EmptyState, Badge,
} from "@/components/ui";
import {
formatPoints, formatWon, FIX_STATUS_META, STAGE_KIND_LABELS,
} from "@/lib/format";
import type { FixStatus } from "@/types";
import { IncentiveGaugeCard } from "@/components/IncentiveGauge";
import { formatPoints, formatWon, FIX_STATUS_META, STAGE_KIND_LABELS } from "@/lib/format";
// 유저 인센티브: BE/non-BE 개념과 포인트 환율은 노출하지 않습니다(관리자 전용).
// 할당량 달성률은 4색 세그먼트 게이지로 표현 — 채우는 순서: 지급완료→반영완료→반영중→예정,
// 할당량 위치는 화살표(▼)로 표시.
const SEG: { key: FixStatus; label: string; color: string }[] = [
{ key: "paid", label: "지급완료", color: "#F04438" }, // 빨강
{ key: "applied", label: "반영완료", color: "#12B76A" }, // 초록
{ key: "applying", label: "반영중", color: "#F79009" }, // 노랑
{ key: "planned", label: "예정", color: "#98A2B3" }, // 회색
];
const YEARS = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
// 유저 인센티브: BE/non-BE·환율 비노출. 연도 선택으로 과거 연도도 조회.
export function IncentivePage() {
const q = useQuery({ queryKey: ["my-incentive"], queryFn: () => getMyIncentive() });
const [year, setYear] = useState(YEARS[0]);
const q = useQuery({ queryKey: ["my-incentive", year], queryFn: () => getMyIncentive({ year }) });
if (q.isLoading) return <LoadingState />;
const d = q.data!;
const byStatus = (st: FixStatus) => d.items.filter((i) => i.fixStatus === st).reduce((s, i) => s + i.points, 0);
const seg = SEG.map((s) => ({ ...s, pts: byStatus(s.key) }));
const realized = byStatus("paid") + byStatus("applied"); // 달성(반영완료 이상)
const byProject = Object.entries(d.byProject).map(([pid, pts]) => ({ name: pid.slice(0, 6), points: Math.round(pts * 10) / 10 }));
return (
<div>
<PageHeader title="내 인센티브" description={`${d.year}년 · 직급 ${d.rank || "—"}`} />
<PageHeader
title="내 인센티브"
description={`직급 ${d.rank || "—"}`}
action={
<Select value={year} onChange={(e) => setYear(Number(e.target.value))} className="w-28">
{YEARS.map((y) => <option key={y} value={y}>{y}</option>)}
</Select>
}
/>
<IncentiveGaugeCard data={d} className="mb-4" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Stat label="누적 포인트" value={formatPoints(d.pointsTotal)} sub="예정 포함" />
@ -43,25 +40,6 @@ export function IncentivePage() {
<Stat label="예상 인센티브" value={formatWon(d.estPayout)} accent="#067647" sub="할당량 초과분" />
</div>
<Card className="p-5 mb-4">
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-bold text-ink"> </h3>
<span className="text-sm font-num font-semibold text-navy">
{d.quota > 0 ? Math.round((realized / d.quota) * 100) : 0}%
</span>
</div>
<QuotaGauge seg={seg} quota={d.quota} />
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 mt-3">
{seg.map((s) => (
<span key={s.key} className="inline-flex items-center gap-1.5 text-xs text-ink-secondary">
<span className="w-2.5 h-2.5 rounded-sm" style={{ background: s.color }} />
{s.label} <span className="font-num text-ink">{formatPoints(s.pts)}</span>
</span>
))}
<span className="ml-auto text-xs text-ink-muted"> <span className="font-num text-money-in">{formatPoints(d.excessPoints)}</span></span>
</div>
</Card>
<Card className="mb-4">
<CardHeader title="프로젝트별 포인트" />
<div className="p-4 h-64">
@ -104,34 +82,3 @@ export function IncentivePage() {
</div>
);
}
// Segmented gauge filled paid→applied→applying→planned, with a ▼ arrow at the quota.
function QuotaGauge({ seg, quota }: { seg: { key: string; label: string; color: string; pts: number }[]; quota: number }) {
const total = seg.reduce((s, x) => s + x.pts, 0);
const denom = Math.max(quota, total, 1);
const quotaPct = Math.min(100, (quota / denom) * 100);
return (
<div className="relative pt-5">
{/* quota arrow marker */}
{quota > 0 && (
<div className="absolute top-0 -translate-x-1/2 flex flex-col items-center" style={{ left: `${quotaPct}%` }}>
<span className="text-[10px] font-medium text-ink-secondary whitespace-nowrap"> {formatPointsShort(quota)}</span>
<span className="text-navy leading-none" style={{ marginTop: -1 }}></span>
</div>
)}
<div className="flex h-5 rounded-pill bg-divider overflow-hidden">
{seg.map((s) =>
s.pts > 0 ? (
<div key={s.key} title={`${s.label} ${Math.round(s.pts * 10) / 10}P`} style={{ width: `${(s.pts / denom) * 100}%`, background: s.color }} />
) : null
)}
</div>
{/* quota tick line */}
{quota > 0 && <div className="absolute bottom-0 w-px h-5 bg-navy/40" style={{ left: `${quotaPct}%` }} />}
</div>
);
}
function formatPointsShort(n: number) {
return `${Math.round(n * 10) / 10}P`;
}

View File

@ -2,17 +2,18 @@ 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,
ArrowLeft, Plus, GanttChartSquare, Columns3, CalendarDays, Trash2, Upload, Download, Lock, Pencil,
} 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,
updateProject,
} from "@/lib/api";
import { useAuth } from "@/context/Auth";
import {
Card, CardHeader, Button, Badge, Tabs, Modal, Field, Input, Select,
Card, CardHeader, Button, Badge, Tabs, Modal, Field, Input, Select, Textarea,
PageHeader, EmptyState, LoadingState,
} from "@/components/ui";
import { Gantt } from "@/components/Gantt";
@ -26,6 +27,7 @@ export function ProjectDetailPage() {
const { id = "" } = useParams();
const { isAdmin } = useAuth();
const [tab, setTab] = useState("overview");
const [editOpen, setEditOpen] = useState(false);
const projQ = useQuery({ queryKey: ["project", id], queryFn: () => getProject(id) });
if (projQ.isLoading) return <LoadingState />;
@ -47,7 +49,9 @@ export function ProjectDetailPage() {
<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}`}
action={isAdmin && <Button variant="secondary" size="sm" icon={<Pencil size={14} />} onClick={() => setEditOpen(true)}> </Button>}
/>
{editOpen && <EditProjectModal project={p} onClose={() => setEditOpen(false)} />}
<Card>
<div className="px-3 pt-2"><Tabs tabs={tabs} active={tab} onChange={setTab} /></div>
<div className="p-5">
@ -389,3 +393,42 @@ function Payments({ projectId, payments, onChange }: { projectId: string; paymen
</Card>
);
}
/* ---- edit project basic info (admin) ---- */
function EditProjectModal({ project, onClose }: { project: Project; onClose: () => void }) {
const qc = useQueryClient();
const [f, setF] = useState({
name: project.name, consultingType: project.consultingType, country: project.country,
scopeText: project.scopeText, scopeGraphic: project.scopeGraphic, pmEmail: project.pmEmail,
cautions: project.cautions, status: project.status, startDate: project.startDate, dueDate: project.dueDate,
});
const save = useMutation({
mutationFn: () => updateProject(project.id, f as Partial<Project>),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["project", project.id] }); qc.invalidateQueries({ queryKey: ["projects"] }); onClose(); },
});
return (
<Modal open onClose={onClose} title="프로젝트 수정" wide
footer={<><Button variant="secondary" onClick={onClose}></Button><Button disabled={!f.name || save.isPending} onClick={() => save.mutate()}></Button></>}>
<div className="space-y-4">
<Field label="프로젝트명"><Input value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} /></Field>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="컨설팅 종류"><Input value={f.consultingType} onChange={(e) => setF({ ...f, consultingType: e.target.value })} /></Field>
<Field label="제출 국가"><Input value={f.country} onChange={(e) => setF({ ...f, country: e.target.value })} /></Field>
<Field label="상태"><Select value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as Project["status"] })}>
{Object.entries(PROJECT_STATUS_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</Select></Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="계약 범위 — 글"><Textarea value={f.scopeText} onChange={(e) => setF({ ...f, scopeText: e.target.value })} /></Field>
<Field label="계약 범위 — 그림"><Textarea value={f.scopeGraphic} onChange={(e) => setF({ ...f, scopeGraphic: e.target.value })} /></Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="PM 이메일"><Input value={f.pmEmail} onChange={(e) => setF({ ...f, pmEmail: e.target.value })} /></Field>
<Field label="시작일"><Input type="date" value={f.startDate} onChange={(e) => setF({ ...f, startDate: e.target.value })} /></Field>
<Field label="마감일"><Input type="date" value={f.dueDate} onChange={(e) => setF({ ...f, dueDate: e.target.value })} /></Field>
</div>
<Field label="주의사항"><Textarea value={f.cautions} onChange={(e) => setF({ ...f, cautions: e.target.value })} /></Field>
</div>
</Modal>
);
}

View File

@ -15,13 +15,22 @@ import {
import type { FixStatus, PaymentStage, UserIncentive } from "@/types";
const QUARTERS = [1, 2, 3, 4];
const YEAR = new Date().getFullYear();
const YEARS = Array.from({ length: 6 }, (_, i) => new Date().getFullYear() + 1 - i);
export function IncentiveAdminPage() {
const [tab, setTab] = useState("settlement");
const [year, setYear] = useState(new Date().getFullYear());
return (
<div>
<PageHeader title="인센티브 관리" description="프로젝트 단계 픽스, 유저별 인센티브 반영, 분기 정산, 시뮬레이션을 모두 관리합니다." />
<PageHeader
title="인센티브 관리"
description="프로젝트 단계 픽스, 유저별 인센티브 반영, 분기 정산, 시뮬레이션. 과거 연도도 선택해 입력 가능."
action={
<Select value={year} onChange={(e) => setYear(Number(e.target.value))} className="w-32">
{YEARS.map((y) => <option key={y} value={y}>{y}</option>)}
</Select>
}
/>
<Card>
<div className="px-3 pt-2">
<Tabs active={tab} onChange={setTab} tabs={[
@ -31,8 +40,8 @@ export function IncentiveAdminPage() {
]} />
</div>
<div className="p-5">
{tab === "settlement" && <SettlementTab />}
{tab === "project" && <ProjectTab />}
{tab === "settlement" && <SettlementTab year={year} />}
{tab === "project" && <ProjectTab year={year} />}
{tab === "sim" && <SimulatorTab />}
</div>
</Card>
@ -41,19 +50,19 @@ export function IncentiveAdminPage() {
}
/* ---- quarterly settlement ---- */
function SettlementTab() {
function SettlementTab({ year }: { year: number }) {
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 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>
<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>
@ -85,14 +94,14 @@ function SettlementTab() {
}
/* ---- project stages + user allocations ---- */
function ProjectTab() {
function ProjectTab({ year }: { year: number }) {
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 recompute = useMutation({ mutationFn: () => recomputeProject(projectId, year), 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] }); },

View File

@ -0,0 +1,125 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Pencil, Trash2, Check, X, ChevronRight } from "lucide-react";
import {
getCompanies, createCompany, updateCompany, deleteCompany,
getProducts, createProduct, updateProduct, deleteProduct,
getVersions, createVersion, updateVersion, deleteVersion,
} from "@/lib/api";
import { Card, CardHeader, Button, Input, PageHeader, EmptyState, LoadingState } from "@/components/ui";
import { classNames } from "@/lib/format";
// 기준정보: 회사 → 제품 → 버전 계층을 생성·수정·삭제. 좌측에서 선택하면 우측이 펼쳐짐.
export function MasterDataPage() {
const [companyId, setCompanyId] = useState("");
const [productId, setProductId] = useState("");
const qc = useQueryClient();
const compQ = useQuery({ queryKey: ["companies"], queryFn: getCompanies });
const prodQ = useQuery({ queryKey: ["products", companyId], queryFn: () => getProducts(companyId), enabled: !!companyId });
const verQ = useQuery({ queryKey: ["versions", productId], queryFn: () => getVersions(productId), enabled: !!productId });
const inv = (k: string) => qc.invalidateQueries({ queryKey: [k] });
return (
<div>
<PageHeader title="기준정보" description="회사 · 제품 · 버전을 생성·수정·삭제합니다. 항목을 선택하면 하위 항목이 펼쳐집니다." />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* 회사 */}
<Column
title="회사"
items={(compQ.data ?? []).map((c) => ({ id: c.id, label: c.name, sub: c.code }))}
loading={compQ.isLoading}
selectedId={companyId}
onSelect={(id) => { setCompanyId(id); setProductId(""); }}
onAdd={(name) => createCompany({ name }).then(() => inv("companies"))}
onRename={(id, name) => updateCompany(id, { name }).then(() => inv("companies"))}
onDelete={(id) => deleteCompany(id).then(() => { inv("companies"); if (companyId === id) { setCompanyId(""); setProductId(""); } })}
/>
{/* 제품 */}
<Column
title="제품"
disabled={!companyId}
disabledHint="회사를 먼저 선택하세요"
items={(prodQ.data ?? []).map((p) => ({ id: p.id, label: p.name, sub: p.code }))}
loading={prodQ.isLoading}
selectedId={productId}
onSelect={(id) => setProductId(id)}
onAdd={(name) => createProduct({ companyId, name }).then(() => inv("products"))}
onRename={(id, name) => updateProduct(id, { name }).then(() => inv("products"))}
onDelete={(id) => deleteProduct(id).then(() => { inv("products"); if (productId === id) setProductId(""); })}
/>
{/* 버전 */}
<Column
title="버전"
disabled={!productId}
disabledHint="제품을 먼저 선택하세요"
items={(verQ.data ?? []).map((v) => ({ id: v.id, label: v.label }))}
loading={verQ.isLoading}
onAdd={(label) => createVersion({ productId, label }).then(() => inv("versions"))}
onRename={(id, label) => updateVersion(id, { label }).then(() => inv("versions"))}
onDelete={(id) => deleteVersion(id).then(() => inv("versions"))}
/>
</div>
</div>
);
}
interface Item { id: string; label: string; sub?: string }
function Column({
title, items, loading, selectedId, onSelect, onAdd, onRename, onDelete, disabled, disabledHint,
}: {
title: string; items: Item[]; loading?: boolean; selectedId?: string;
onSelect?: (id: string) => void;
onAdd: (name: string) => Promise<unknown>;
onRename: (id: string, name: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
disabled?: boolean; disabledHint?: string;
}) {
const [adding, setAdding] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const [editVal, setEditVal] = useState("");
return (
<Card>
<CardHeader title={title} />
{disabled ? (
<EmptyState title={disabledHint} />
) : loading ? <LoadingState /> : (
<div className="p-2">
<div className="divide-y divide-divider">
{items.map((it) => (
<div key={it.id} className="flex items-center gap-2 px-2 py-2 group">
{editId === it.id ? (
<>
<Input value={editVal} onChange={(e) => setEditVal(e.target.value)} className="h-8" autoFocus />
<button className="text-money-in p-1" onClick={() => editVal && onRename(it.id, editVal).then(() => setEditId(null))}><Check size={16} /></button>
<button className="text-ink-muted p-1" onClick={() => setEditId(null)}><X size={16} /></button>
</>
) : (
<>
<button
className={classNames("flex-1 flex items-center gap-2 text-left text-sm", onSelect ? "cursor-pointer" : "cursor-default", selectedId === it.id ? "text-navy font-semibold" : "text-ink")}
onClick={() => onSelect?.(it.id)}
>
<span className="flex-1">{it.label}{it.sub ? <span className="text-ink-muted font-normal"> · {it.sub}</span> : ""}</span>
{onSelect && <ChevronRight size={14} className={selectedId === it.id ? "text-navy" : "text-ink-muted"} />}
</button>
<button className="text-ink-muted hover:text-ink p-1 opacity-0 group-hover:opacity-100" onClick={() => { setEditId(it.id); setEditVal(it.label); }}><Pencil size={14} /></button>
<button className="text-ink-muted hover:text-money-out p-1 opacity-0 group-hover:opacity-100" onClick={() => { if (confirm(`'${it.label}' 삭제하시겠습니까?`)) onDelete(it.id); }}><Trash2 size={14} /></button>
</>
)}
</div>
))}
{items.length === 0 && <div className="text-center text-ink-muted text-sm py-6"> </div>}
</div>
<div className="flex items-end gap-2 mt-2 px-2">
<Input value={adding} onChange={(e) => setAdding(e.target.value)} placeholder={`${title}`} className="h-9"
onKeyDown={(e) => { if (e.key === "Enter" && adding) onAdd(adding).then(() => setAdding("")); }} />
<Button size="sm" icon={<Plus size={15} />} disabled={!adding} onClick={() => onAdd(adding).then(() => setAdding(""))}></Button>
</div>
</div>
)}
</Card>
);
}

View File

@ -1,8 +1,9 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, Users } from "lucide-react";
import { Plus, Trash2, Users, Pencil, Check, X } from "lucide-react";
import {
getMembers, createMember, updateMember, deleteMember, getDepartments, createDepartment,
getMembers, createMember, updateMember, deleteMember,
getDepartments, createDepartment, updateDepartment, deleteDepartment,
} from "@/lib/api";
import {
Card, Button, Badge, PageHeader, Modal, Drawer, Field, Input, Select,
@ -109,12 +110,33 @@ function MemberEditDrawer({ member, depts, onClose, onDone }: { member: Member;
function Departments({ depts, onChange }: { depts: { id: string; name: string }[]; onChange: () => void }) {
const [name, setName] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const [editVal, setEditVal] = useState("");
const add = useMutation({ mutationFn: () => createDepartment({ name }), onSuccess: () => { onChange(); setName(""); } });
const ren = useMutation({ mutationFn: (id: string) => updateDepartment(id, { name: editVal }), onSuccess: () => { onChange(); setEditId(null); } });
const del = useMutation({ mutationFn: (id: string) => deleteDepartment(id), onSuccess: onChange });
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="divide-y divide-divider mb-3 max-w-md">
{depts.map((d) => (
<div key={d.id} className="flex items-center gap-2 py-2 group">
{editId === d.id ? (
<>
<Input value={editVal} onChange={(e) => setEditVal(e.target.value)} className="h-8" autoFocus />
<button className="text-money-in p-1" onClick={() => editVal && ren.mutate(d.id)}><Check size={16} /></button>
<button className="text-ink-muted p-1" onClick={() => setEditId(null)}><X size={16} /></button>
</>
) : (
<>
<span className="flex-1 text-sm text-ink">{d.name}</span>
<button className="text-ink-muted hover:text-ink p-1 opacity-0 group-hover:opacity-100" onClick={() => { setEditId(d.id); setEditVal(d.name); }}><Pencil size={14} /></button>
<button className="text-ink-muted hover:text-money-out p-1 opacity-0 group-hover:opacity-100" onClick={() => { if (confirm(`'${d.name}' 삭제?`)) del.mutate(d.id); }}><Trash2 size={14} /></button>
</>
)}
</div>
))}
{depts.length === 0 && <div className="text-center text-ink-muted text-sm py-6"> </div>}
</div>
<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>

View File

@ -2,22 +2,34 @@ 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,
Card, CardHeader, Button, Field, Input, Select, PageHeader, LoadingState,
} from "@/components/ui";
import { formatWon } from "@/lib/format";
const RANKS = ["인턴", "주임", "선임", "책임", "파트너"];
const YEARS = Array.from({ length: 6 }, (_, i) => new Date().getFullYear() + 1 - i); // 내년~과거
export function SettingsPage() {
const qc = useQueryClient();
const cfgQ = useQuery({ queryKey: ["incentive-config"], queryFn: () => getIncentiveConfig() });
const [year, setYear] = useState(new Date().getFullYear());
const cfgQ = useQuery({ queryKey: ["incentive-config", year], queryFn: () => getIncentiveConfig(year) });
const polQ = useQuery({ queryKey: ["work-policy"], queryFn: getWorkPolicy });
if (cfgQ.isLoading || polQ.isLoading) return <LoadingState />;
if (polQ.isLoading) return <LoadingState />;
return (
<div className="max-w-4xl">
<PageHeader title="설정" description="인센티브 규칙과 근무 정책을 관리합니다. 규칙은 연도별로 적용되며 확정(freeze)할 수 있습니다." />
<IncentiveConfigCard initial={cfgQ.data!} onSaved={() => qc.invalidateQueries({ queryKey: ["incentive-config"] })} />
<PageHeader
title="설정"
description="인센티브 규칙과 근무 정책을 관리합니다. 인센티브는 연도별로 적용되며, 과거 연도도 선택해 입력/확정할 수 있습니다."
action={
<Select value={year} onChange={(e) => setYear(Number(e.target.value))} className="w-32">
{YEARS.map((y) => <option key={y} value={y}>{y}</option>)}
</Select>
}
/>
{cfgQ.isLoading || !cfgQ.data ? <LoadingState /> : (
<IncentiveConfigCard key={year} initial={cfgQ.data} onSaved={() => qc.invalidateQueries({ queryKey: ["incentive-config"] })} />
)}
<WorkPolicyCard initial={polQ.data!} onSaved={() => qc.invalidateQueries({ queryKey: ["work-policy"] })} />
</div>
);