spin-frontend/src/pages/admin/Settings.tsx
theorose49 851a19ea5f
All checks were successful
build-and-push / build (push) Successful in 31s
feat: 기준정보 CRUD 페이지·부서 수정/삭제·프로젝트 수정 + 대시보드 인센티브 게이지·메일함 넓게·근무상태 디폴트 퇴근·인센티브 연도 선택
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:45:43 +09:00

127 lines
7.2 KiB
TypeScript

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, 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 [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 (polQ.isLoading) return <LoadingState />;
return (
<div className="max-w-4xl">
<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>
);
}
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-1 sm:grid-cols-3 gap-4">
<Field label="포인트 환율 (1P = ? KRW)" hint={formatWon(+f.pointRate)}><Input type="number" value={f.pointRate} onChange={(e) => setF({ ...f, pointRate: e.target.value })} /></Field>
</div>
<div>
<div className="text-sm font-semibold text-ink mb-2"> ( {stageSum}% {stageSum !== 100 && <span className="text-status-pending-fg"> 100% </span>})</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Field label="계약금 %"><Input type="number" value={f.depositPct} onChange={(e) => setF({ ...f, depositPct: e.target.value })} /></Field>
<Field label="중도금 %"><Input type="number" value={f.middlePct} onChange={(e) => setF({ ...f, middlePct: e.target.value })} /></Field>
<Field label="잔금 %"><Input type="number" value={f.finalPct} onChange={(e) => setF({ ...f, finalPct: e.target.value })} /></Field>
</div>
</div>
<div>
<div className="text-sm font-semibold text-ink mb-2">non-BE ( {nonBeSum}%) BE 회사 : 파트너 </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="회사 몫 %"><Input type="number" value={f.nonBeCompanyPct} onChange={(e) => setF({ ...f, nonBeCompanyPct: e.target.value })} /></Field>
<Field label="파트너 몫 %"><Input type="number" value={f.nonBePartnerPct} onChange={(e) => setF({ ...f, nonBePartnerPct: e.target.value })} /></Field>
</div>
</div>
<div>
<div className="text-sm font-semibold text-ink mb-2"> ( )</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{RANKS.map((r) => (
<Field key={r} label={r}><Input type="number" value={quota[r]} onChange={(e) => setQuota({ ...quota, [r]: e.target.value })} /></Field>
))}
</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-1 sm:grid-cols-3 gap-4">
<Field label="주 소정근로시간"><Input type="number" value={f.weeklyHours} onChange={(e) => setF({ ...f, weeklyHours: e.target.value })} /></Field>
<Field label="일 소정근로(분)"><Input type="number" value={f.dailyStandardMin} onChange={(e) => setF({ ...f, dailyStandardMin: e.target.value })} /></Field>
<Field label="휴게시간(분)"><Input type="number" value={f.lunchMinutes} onChange={(e) => setF({ ...f, lunchMinutes: e.target.value })} /></Field>
<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>
);
}