Merge branch 'iipbs/as/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user