Merge branch 'iipbs/as/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev

This commit is contained in:
2026-05-21 17:39:07 +09:00
5 changed files with 807 additions and 37 deletions
@@ -0,0 +1,15 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface";
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
const response = await client.get<AcntgSeMngTreeDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/tree`,
);
return response.data;
}
export const acntgService = {
findTree,
} satisfies AcntgService;
+2
View File
@@ -0,0 +1,2 @@
export type { AcntgSeMngTreeDto, AcntgService } from "./model/acntg-interface";
export { acntgService } from "./api/acntg-service";
@@ -0,0 +1,23 @@
/** 회계구분 트리 노드 DTO */
export interface AcntgSeMngTreeDto {
/** 들여쓰기 포함 표시명 (앞 공백 수 = 레벨 인디케이터) */
indAcntgSeCdNm: string;
/** 회계구분코드 */
acntgSeCd: string;
/** 회계구분코드명 */
acntgSeCdNm: string;
/** 상위 회계구분코드 (null = 최상위) */
upAcntgSeCd: string | null;
/** 사용여부 */
useYn: string;
/** 트리 레벨 (1 = 총사업비, 2 = 1.공사비/2.보상비/3.부대경비, …) */
treeLevel: number;
/** 최상위~현재 노드까지 "|" 구분 경로 */
treePath: string;
}
/** 회계구분 서비스 인터페이스 */
export interface AcntgService {
/** 회계구분 트리 전체 조회 */
findTree(): Promise<AcntgSeMngTreeDto[]>;
}
@@ -1,86 +1,813 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
import { acntgService } from "@/entities/acntg";
import { Button, Input } from "@common/components";
import "./cnfm-page.css";
/* ------------------------------------------------------------------ */
/* 타입 */
/* ------------------------------------------------------------------ */
type BzScaleRow = {
bscPl: string; // 예산반영(A)
budgetReflct: string; // 표준사업비(B)
thtiDsgn: string; // 금회설계시(C)
};
type BzAmtRow = {
bscPl: string;
budgetReflct: string;
thtiDsgn: string;
};
const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0");
const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString());
/* ------------------------------------------------------------------ */
/* 메인 컴포넌트 */
/* ------------------------------------------------------------------ */
function CnfmRegistPage() {
const router = useRouter();
/* ── 기본 정보 ── */
const [bzNm, setBzNm] = useState("");
const [bzBgngYr, setBzBgngYr] = useState("");
const [bzEndYr, setBzEndYr] = useState("");
const [bzCn, setBzCn] = useState("");
const [instlCnfmApr, setInstlCnfmApr] = useState("CNFM");
/* ── 설계자 정보 ── */
const [dsgnrNm, setDsgnrNm] = useState("");
const [dsgnrPsitn, setDsgnrPsitn] = useState("");
const [dsgnrTelno, setDsgnrTelno] = useState("");
const [dsgnrEmlAddr, setDsgnrEmlAddr] = useState("");
/* ── 개인정보 동의 ── */
const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N");
/* ── 기타 첨부파일 ── */
type EtcFile = { id: number; name: string; ext: string; size: string; uploader: string };
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
/* ── 체크리스트 ── */
const CHECKLIST_ITEMS = [
"사업계획의 적정성",
"사업규모의 적정성",
"사업방식의 적정성",
"사업비의 적정성",
"사업효과의 적정성",
"사업일정의 적정성",
];
const [checkResults, setCheckResults] = useState<Record<string, "Y" | "N" | "">>(() =>
Object.fromEntries(CHECKLIST_ITEMS.map((item) => [item, "" as "Y" | "N" | ""])),
);
/* ── 사업규모 ── */
const [bzSpecWwtp, setBzSpecWwtp] = useState<BzScaleRow>(emptyScale()); // 처리장용량
const [bzSpecArea, setBzSpecArea] = useState<BzScaleRow>(emptyScale()); // 면적
const [bzSpecNcr, setBzSpecNcr] = useState<BzScaleRow>(emptyScale()); // 하수관로 신설
const [bzSpecRplc, setBzSpecRplc] = useState<BzScaleRow>(emptyScale()); // 교체(보수)
const [bzSpecPump, setBzSpecPump] = useState<BzScaleRow>(emptyScale()); // 펌프장
const [bzSpecDrng, setBzSpecDrng] = useState<BzScaleRow>(emptyScale()); // 배수설비
/* ── 총사업비 (회계구분 트리 연동) ── */
// acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
const [amtMap, setAmtMap] = useState<Record<string, BzAmtRow>>({});
/* ── 회계구분 트리 로드 ── */
const { data: acntgTree = [] } = useQuery<AcntgSeMngTreeDto[]>({
queryKey: ["acntgTree"],
queryFn: acntgService.findTree,
staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요
});
/* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */
const leafSet = useMemo(() => {
const parentCodes = new Set(acntgTree.map((n) => n.upAcntgSeCd).filter(Boolean) as string[]);
return new Set(acntgTree.filter((n) => !parentCodes.has(n.acntgSeCd)).map((n) => n.acntgSeCd));
}, [acntgTree]);
/**
* treePath 기반 소계 계산
* - 이 노드의 acntgSeCd 가 treePath 안에 포함된 리프 노드들의 합산
* - 새로운 하위 레벨이 추가돼도 자동으로 포함됨
*/
const subtotal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number =>
acntgTree
.filter((n) => leafSet.has(n.acntgSeCd) && n.treePath.split("|").includes(node.acntgSeCd))
.reduce((sum, n) => sum + num((amtMap[n.acntgSeCd] ?? emptyAmt())[col]), 0);
/** 노드 값 반환 — 리프이면 입력값, 부모이면 소계 */
const nodeVal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number =>
leafSet.has(node.acntgSeCd)
? num((amtMap[node.acntgSeCd] ?? emptyAmt())[col])
: subtotal(node, col);
/** 단일 셀 입력 핸들러 */
const handleAmtInput = (acntgSeCd: string, col: keyof BzAmtRow, val: string) =>
setAmtMap((prev) => ({
...prev,
[acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val },
}));
/**
* indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환
* (공백 4칸 = 1 depth = 1rem)
*/
const indentPx = (indNm: string): number => {
const spaces = indNm.length - indNm.trimStart().length;
return (spaces / 4) * 16; // 4공백 = 16px
};
/** 트리 행 스타일 분류 */
const rowStyle = (node: AcntgSeMngTreeDto) => {
if (node.treeLevel === 1) return "bg-blue-50 font-bold";
if (node.treeLevel === 2) return "bg-gray-100 font-semibold";
if (!leafSet.has(node.acntgSeCd)) return "font-semibold"; // 중간 부모 (예: 하수관로)
return "";
};
/* ── 증감 라벨 ── */
const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v));
/* ── 검증 ── */
function fnValidation(mode: "SAVE" | "PRESENTN") {
if (!bzNm.trim()) {
alert("사업명을 입력하세요.");
return false;
}
if (!bzBgngYr || !bzEndYr) {
alert("사업기간을 입력하세요.");
return false;
}
if (!bzCn.trim()) {
alert("사업내용을 입력하세요.");
return false;
}
if (mode === "PRESENTN" && privacyAgree !== "Y") {
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
return false;
}
return true;
}
function handleSave() {
if (!fnValidation("SAVE")) return;
alert("저장(임시) API 연동 후 progrsSttus=SV 전송");
}
function handleSubmit() {
if (!fnValidation("PRESENTN")) return;
alert("제출 API 연동 후 progrsSttus=ST 전송");
}
/* ── 공통 입력 핸들러 ── */
function scaleInput(
setter: React.Dispatch<React.SetStateAction<BzScaleRow>>,
col: keyof BzScaleRow,
val: string,
) {
setter((prev) => ({ ...prev, [col]: val }));
}
/* ── 공통 숫자 셀 ── */
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<td>
<input
type="text"
className="cnfm-input w-full text-right"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
const ReadCell = ({ value }: { value: string | number }) => (
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
);
/* ---------------------------------------------------------------- */
return (
<div className="mt-6 space-y-4">
{/* ── 프로세스 스텝 바 ── */}
<div className="cnfm-card p-4">
<h2 className="cnfm-section-title">설치인가 신청</h2>
<p className="mt-2 text-[1.3rem] text-gray-600">
AS-IS 화면 설계(신청 화면)를 반영한 입력 레이아웃입니다.
</p>
<div className="cnfm-step-row flex-wrap gap-y-2">
{["설치인가 신청", "기술검토/조치계획 검토", "기술검토 회신", "인가승인"].map(
(label, i) => (
<span key={label} className="flex items-center gap-1">
<span
className={`cnfm-step-badge ${i === 0 ? "cnfm-step-badge--current" : "cnfm-step-badge--pending"}`}
>
<span
className={`cnfm-step-dot ${i === 0 ? "cnfm-step-dot--current" : "cnfm-step-dot--pending"}`}
/>
{label}
</span>
{i < 3 && <span className="text-[1.2rem] text-gray-400">›</span>}
</span>
),
)}
</div>
</div>
{/* ── 타이틀 + 액션 버튼 ── */}
<div className="cnfm-card flex items-center justify-between p-4">
<h2 className="cnfm-section-title">■ 설치인가 신청/수정</h2>
<div className="flex gap-2">
<Button color="white" onClick={handleSave}>
저장
</Button>
<Button onClick={handleSubmit}>제출</Button>
</div>
</div>
{/* ── 기본 정보 ── */}
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">기본 정보</h3>
<table className="cnfm-form-table mt-3">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">기본 정보</h3>
<table className="cnfm-form-table">
<tbody>
<tr>
<th>사업명</th>
<td>
<Input placeholder="사업명 입력" className="!text-[1.3rem]" />
<th>
<span className="text-red-500">*</span> 사업명
</th>
<td colSpan={3}>
<div className="flex gap-2">
<Input
placeholder="사업명을 입력하거나 사업찾기를 이용하세요"
className="flex-1 !text-[1.3rem]"
value={bzNm}
onChange={(e) => setBzNm(e.target.value)}
/>
<button className="cnfm-btn-search whitespace-nowrap">🔍 사업찾기</button>
</div>
</td>
</tr>
<tr>
<th>
<span className="text-red-500">*</span> 사업기간
</th>
<td colSpan={3}>
<div className="flex items-center gap-2">
<Input
placeholder="시작연도(YYYY)"
className="w-[14rem] !text-[1.3rem]"
value={bzBgngYr}
onChange={(e) => setBzBgngYr(e.target.value)}
/>
<span className="text-gray-500">~</span>
<Input
placeholder="종료연도(YYYY)"
className="w-[14rem] !text-[1.3rem]"
value={bzEndYr}
onChange={(e) => setBzEndYr(e.target.value)}
/>
</div>
</td>
</tr>
<tr>
<th>
<span className="text-red-500">*</span> 사업내용
</th>
<td colSpan={3}>
<textarea
className="cnfm-input h-[6rem] w-full resize-y"
placeholder="사업 내용을 입력하세요"
value={bzCn}
onChange={(e) => setBzCn(e.target.value)}
/>
</td>
</tr>
<tr>
<th>구분</th>
<td>
<div className="flex gap-4 text-[1.3rem]">하수관로</div>
</td>
<th>신청 지자체</th>
<td>
<span className="text-[1.3rem] text-gray-500">(로그인 정보 자동 세팅)</span>
</td>
</tr>
<tr>
<th>사업구분</th>
<td>
<select className="h-[3.6rem] w-full rounded-md border border-gray-300 px-3 text-[1.3rem]">
<option value="CNFM">설치인가</option>
<option value="APR">설치승인</option>
<option value="AFA">폐지인가</option>
</select>
</td>
</tr>
<tr>
<th>사업기간(시작)</th>
<td>
<Input placeholder="YYYY" className="!text-[1.3rem]" />
</td>
<th>사업기간(종료)</th>
<td>
<Input placeholder="YYYY" className="!text-[1.3rem]" />
<div className="flex gap-4 text-[1.3rem]">
{[
["CNFM", "설치인가"],
["APR", "설치승인"],
["AFA", "폐지인가"],
].map(([val, label]) => (
<label key={val} className="flex cursor-pointer items-center gap-1">
<input
type="radio"
name="instlCnfmApr"
value={val}
checked={instlCnfmApr === val}
onChange={() => setInstlCnfmApr(val)}
/>
{label}
</label>
))}
</div>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── 신청자 / 설계자 정보 ── */}
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">신청자 / 설계자 정보</h3>
<table className="cnfm-form-table mt-3">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">신청자 정보</h3>
<table className="cnfm-form-table">
<tbody>
<tr>
<th>신청자명</th>
<th>신청자 이름</th>
<td>
<Input placeholder="신청자명" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
<th>신청자 연락처</th>
<th>전화번호</th>
<td>
<Input placeholder="010-0000-0000" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
</tr>
<tr>
<th>설계자명</th>
<th>부서</th>
<td>
<Input placeholder="설계자명" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
<th>설계자 소속</th>
<th>이메일</th>
<td>
<Input placeholder="설계자 소속" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
</tr>
<tr>
<th>설계자 이름</th>
<td>
<Input
placeholder="설계자 성명"
className="!text-[1.3rem]"
value={dsgnrNm}
onChange={(e) => setDsgnrNm(e.target.value)}
/>
</td>
<th>전화번호</th>
<td>
<Input
placeholder="설계자 전화번호"
className="!text-[1.3rem]"
value={dsgnrTelno}
onChange={(e) => setDsgnrTelno(e.target.value)}
/>
</td>
</tr>
<tr>
<th>소속(용역사)</th>
<td>
<Input
placeholder="용역사명"
className="!text-[1.3rem]"
value={dsgnrPsitn}
onChange={(e) => setDsgnrPsitn(e.target.value)}
/>
</td>
<th>이메일</th>
<td>
<Input
placeholder="설계자 이메일"
className="!text-[1.3rem]"
value={dsgnrEmlAddr}
onChange={(e) => setDsgnrEmlAddr(e.target.value)}
/>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── 개인정보 동의 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">개인정보 동의</h3>
<div className="mb-3 rounded border bg-gray-50 p-3 text-[1.25rem] leading-relaxed text-gray-600">
<p className="mb-1 font-semibold">[개인정보 수집·이용 목적]</p>
<ul className="ml-4 list-disc space-y-1">
<li>수집항목: 소속기관, 성명, 전화번호, 이메일, IP Address, 접속 기기 정보</li>
<li>수집목적: 하수도 시설물 설치인가 신청 및 처리 목적</li>
<li>보유기간: 관련 법령에 따른 의무 보유 기간 준수</li>
</ul>
<p className="mt-2 text-[1.2rem] text-gray-500">
※ 개인정보 제공에 동의하지 않을 수 있으나, 미동의 시 서비스 이용이 제한될 수 있습니다.
</p>
</div>
<div className="flex gap-6 text-[1.3rem]">
<label className="flex cursor-pointer items-center gap-2 font-semibold text-blue-600">
<input
type="radio"
name="privacyAgree"
value="Y"
checked={privacyAgree === "Y"}
onChange={() => setPrivacyAgree("Y")}
/>
예 (동의)
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="privacyAgree"
value="N"
checked={privacyAgree === "N"}
onChange={() => setPrivacyAgree("N")}
/>
아니오 (미동의)
</label>
</div>
</div>
{/* ── 사업규모 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">사업규모</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th rowSpan={2} className="w-[12rem]">
구분
</th>
<th>기본계획(A)</th>
<th>예산반영(B)</th>
<th>금회설계시(C)</th>
<th>증감(C-B)</th>
</tr>
</thead>
<tbody>
{[
{ label: "처리장용량(㎥/일)", state: bzSpecWwtp, setter: setBzSpecWwtp },
{ label: "면적(㎡)", state: bzSpecArea, setter: setBzSpecArea },
{ label: "하수관로 신설(m)", state: bzSpecNcr, setter: setBzSpecNcr },
{ label: "교체/보수(m)", state: bzSpecRplc, setter: setBzSpecRplc },
{ label: "펌프장(개소)", state: bzSpecPump, setter: setBzSpecPump },
{ label: "배수설비(개소)", state: bzSpecDrng, setter: setBzSpecDrng },
].map(({ label, state, setter }) => (
<tr key={label}>
<td className="bg-gray-50 pl-2 text-left font-semibold">{label}</td>
{(["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
<td key={col}>
<input
type="text"
className="cnfm-input w-full text-right"
value={state[col]}
onChange={(e) => scaleInput(setter, col, e.target.value)}
/>
</td>
))}
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">
{diffLabel(num(state.thtiDsgn) - num(state.budgetReflct))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── 총사업비 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">
총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span>
</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th colSpan={2} className="w-[20rem]">
구분
</th>
<th>예산반영(A)</th>
<th>표준사업비(B)</th>
<th>금회설계시(C)</th>
<th colSpan={2}>증감</th>
</tr>
<tr>
<th colSpan={2}></th>
<th></th>
<th></th>
<th></th>
<th>(C-B)</th>
<th>%</th>
</tr>
</thead>
<tbody>
{acntgTree.map((node) => {
const isLeaf = leafSet.has(node.acntgSeCd);
// level 1,2 는 colSpan=2, level 3+ 는 왼쪽 빈셀 + 이름
const useColSpan = node.treeLevel <= 2;
const bscPl = nodeVal(node, "bscPl");
const budgetReflct = nodeVal(node, "budgetReflct");
const thtiDsgn = nodeVal(node, "thtiDsgn");
const diffVal = thtiDsgn - budgetReflct;
// indAcntgSeCdNm 압 공백으로 paddig 계산
const pl = indentPx(node.indAcntgSeCdNm);
// level 4+ (신설, 교체/보수 등) 서브 아이콘
const showSubIcon = node.treeLevel >= 4;
return (
<tr key={node.acntgSeCd} className={rowStyle(node)}>
{useColSpan ? (
<td colSpan={2} className="pl-2 text-left">
{node.acntgSeCdNm}
</td>
) : (
<>
<td className="w-[4rem]"></td>
<td
className={`bg-gray-50 text-left ${isLeaf ? "text-gray-700" : ""}`}
style={{ paddingLeft: `${pl + 8}px` }}
>
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
{node.acntgSeCdNm}
</td>
</>
)}
{/* 리프 노드: 직접 입력 / 부모 노드: 소계 읽기 전용 */}
{isLeaf
? [
["bscPl", bscPl] as const,
["budgetReflct", budgetReflct] as const,
["thtiDsgn", thtiDsgn] as const,
].map(([col]) => (
<NumCell
key={col}
value={(amtMap[node.acntgSeCd] ?? emptyAmt())[col]}
onChange={(v) => handleAmtInput(node.acntgSeCd, col, v)}
/>
))
: (
[
["bscPl", bscPl],
["budgetReflct", budgetReflct],
["thtiDsgn", thtiDsgn],
] as [keyof BzAmtRow, number][]
).map(([col, val]) => <ReadCell key={col} value={fmt(val)} />)}
{/* 증감 (C-B) */}
<ReadCell value={diffLabel(diffVal)} />
{/* % 증감률 전체 행 표시 */}
<ReadCell
value={
budgetReflct !== 0
? fmt(Math.round((diffVal / budgetReflct) * 100)) + "%"
: "-"
}
/>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* ── 체크리스트 ── */}
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<button className="cnfm-btn-search text-[1.2rem]">하수관로(0/8)</button>
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 — 총합: <strong>0</strong> · 완료: <strong>0</strong> · 진행률:{" "}
<strong>0%</strong>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th className="w-[20rem]" rowSpan={2}>
검토항목
</th>
<th rowSpan={2}>주요 검토내용</th>
<th colSpan={2}>검토 결과(-)</th>
</tr>
<tr>
<th className="w-[9rem]">
<button
type="button"
className="w-full rounded border border-green-300 bg-green-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-green-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "Y" as const])),
)
}
>
전체 예
</button>
</th>
<th className="w-[9rem]">
<button
type="button"
className="w-full rounded border border-red-300 bg-red-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-red-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "N" as const])),
)
}
>
전체 아니오
</button>
</th>
</tr>
</thead>
<tbody>
{CHECKLIST_ITEMS.map((item) => (
<tr key={item}>
<td className="pl-2 text-left">{item}</td>
<td className="pl-2 text-left text-[1.2rem] text-gray-400">
금회 총사업비가 확정예산 범위 내
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
<input
type="radio"
name={`chk_${item}`}
value="Y"
checked={checkResults[item] === "Y"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "Y" }))}
/>
</label>
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
<input
type="radio"
name={`chk_${item}`}
value="N"
checked={checkResults[item] === "N"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "N" }))}
/>
</label>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── 첨부파일 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">첨부파일</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>필수 자료명</th>
<th>파일형식</th>
<th>파일첨부</th>
</tr>
</thead>
<tbody>
{[
"인가신청서",
"계획수립 기준 및 목표연도 계획",
"하수도 시설계획 적정성 검토 결과",
"관로설계시설물 감리비용 산출 계획서",
"사업비 내역서",
"설계도면",
"기타 첨부자료",
].map((name) => (
<tr key={name}>
<td className="pl-2 text-left">{name}</td>
<td>hwp/pdf</td>
<td>
<div className="flex items-center justify-center gap-2">
<input
type="text"
placeholder="첨부문서를 올려주세요"
className="cnfm-input flex-1"
readOnly
/>
<button className="cnfm-btn-search text-[1.2rem]">첨부하기</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── 기타 첨부파일 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">기타 첨부파일</h3>
{/* 안내 문구 토글 */}
<div className="mb-3 overflow-hidden rounded border">
<button
type="button"
className="flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-[1.25rem] text-gray-600 hover:bg-gray-100"
onClick={() => setEtcNoticeOpen((prev) => !prev)}
>
<span className="text-gray-400">{etcNoticeOpen ? "▼" : "▶"}</span>
복수개의 파일을 등록하시려면 Zip파일 형식으로 압축하여 올려주십시오.
</button>
</div>
{/* 파일 업로드 영역 */}
<div className="mb-3 flex flex-nowrap items-center gap-2">
<input
type="text"
className="cnfm-input min-w-0 flex-1"
placeholder="첨부문서를 등록해주세요"
readOnly
/>
<label className="cnfm-btn-search shrink-0 cursor-pointer whitespace-nowrap text-[1.2rem]">
첨부파일
<input
type="file"
className="hidden"
multiple
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
const mapped: EtcFile[] = files.map((f, i) => ({
id: Date.now() + i,
name: f.name.replace(/\.[^.]+$/, ""),
ext: f.name.split(".").pop() ?? "",
size:
f.size > 1024 * 1024
? `${(f.size / 1024 / 1024).toFixed(1)}MB`
: `${(f.size / 1024).toFixed(0)}KB`,
uploader: "(로그인 사용자)",
}));
setEtcFiles((prev) => [...prev, ...mapped]);
e.target.value = "";
}}
/>
</label>
</div>
{/* 첨부된 파일 목록 테이블 */}
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>파일명</th>
<th className="w-[8rem]">확장자</th>
<th className="w-[8rem]">크기</th>
<th className="w-[12rem]">등록자</th>
<th className="w-[6rem]">삭제</th>
</tr>
</thead>
<tbody>
{etcFiles.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">
첨부된 파일이 없습니다.
</td>
</tr>
) : (
etcFiles.map((f) => (
<tr key={f.id}>
<td className="pl-2 text-left">{f.name}</td>
<td>{f.ext}</td>
<td>{f.size}</td>
<td>{f.uploader}</td>
<td>
<button
type="button"
className="text-[1.2rem] text-red-500 hover:text-red-700"
onClick={() => setEtcFiles((prev) => prev.filter((x) => x.id !== f.id))}
>
✕
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* ── 액션 버튼 ── */}
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
<Button type="button" onClick={() => window.alert("저장 API 연동은 추후 확장 가능합니다.")}>저장</Button>
<Button type="button" onClick={() => window.alert("제출 API 연동은 추후 확장 가능합니다.")}>제출</Button>
<Button color="white" onClick={() => router.push("/instl-sncn")}>
목록
</Button>
<Button color="white" onClick={handleSave}>
저장
</Button>
<Button onClick={handleSubmit}>제출</Button>
</div>
</div>
);
+3
View File
@@ -1,4 +1,7 @@
export const ENDPOINT = {
AS_COMMON_SERVICE: {
ACNTG_SE: "/as-common-service/api/v1/acntg-se",
},
AS_INSTL_SERVICE: {
INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn",
},