feat: 기준정보 CRUD 페이지·부서 수정/삭제·프로젝트 수정 + 대시보드 인센티브 게이지·메일함 넓게·근무상태 디폴트 퇴근·인센티브 연도 선택
All checks were successful
build-and-push / build (push) Successful in 31s
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:
parent
581fd7a19f
commit
851a19ea5f
@ -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>} />
|
||||
|
||||
56
src/components/IncentiveGauge.tsx
Normal file
56
src/components/IncentiveGauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }) {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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="나의 근무·프로젝트·인센티브 관련 알림"
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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] }); },
|
||||
|
||||
125
src/pages/admin/MasterData.tsx
Normal file
125
src/pages/admin/MasterData.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user