- | 설계자 |
- 이름 |
+
+ 설계자
+ |
+ 이름 |
setDsgnrNm(e.target.value)}
/>
|
- 전화번호 |
+ 전화번호 |
setDsgnrPsitn(e.target.value)}
/>
|
-
- 이메일 |
+
+ 이메일 |
[ 개인정보 수집항목 ]
- * 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호
- ․ 선택항목 : 휴대폰 번호, 이메일 주소
- ․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록
- ※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
+ * 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호
+
+ ․ 선택항목 : 휴대폰 번호, 이메일 주소
+
+ ․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록
+ ※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
[ 개인정보 이용목적 ]
- ․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용
- ․ 국가하수도정보시스템 사용에 필요한 권한 부여
- ․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성 / 예산편성 등) 제공에 이용
+ ․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용
+
+ ․ 국가하수도정보시스템 사용에 필요한 권한 부여
+ ․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성
+ / 예산편성 등) 제공에 이용
● 개인정보의 보유 및 이용기간
- ․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.
- ․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
+ ․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.
+ ․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
● 개인정보 제공 거부에 따른 제한사항
- ․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다.
+ ․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용
+ 서비스에 제한이 있을 수 있습니다.
@@ -1608,25 +1633,27 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
체크리스트
-
- {Array.from(new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean))).map(
- (code) => {
- const name = TASK_CODE_LABELS[code] ?? code;
- const active = selectedTaskCode === code;
- return (
-
- );
- },
- )}
+
+ {Array.from(
+ new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)),
+ ).map((code) => {
+ const name = TASK_CODE_LABELS[code] ?? code;
+ const active = selectedTaskCode === code;
+ return (
+
+ );
+ })}
{`(${cklstStats.completed}/${cklstStats.total})`}
@@ -1658,8 +1685,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
)}
- 체크리스트 진행상황 [{selectedReviewStep}] — 총합: {cklstStats.total} · 완료: {" "}
- {cklstStats.completed} · 진행률: {cklstStats.percent}%
+ 체크리스트 진행상황 [{selectedReviewStep}] — 총합: {cklstStats.total} ·
+ 완료: {cklstStats.completed} · 진행률:{" "}
+ {cklstStats.percent}%
@@ -1724,7 +1752,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
cklstRows.map((row) => {
const key = cklstKey(row, selectedReviewStep);
const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`;
- //bchckUseYn 의 값이 "Y"인 항목은 라디오 박스가 보이고 없으면 안보이게 처리
+ //bchckUseYn 의 값이 "Y"인 항목은 라디오 박스가 보이고 없으면 안보이게 처리
const showRadios = row.bchckUseYn === "Y";
return (
| {row.cklstArtclNm} |
-
+ |
{row.treeLevel > 1 ? "└ " : ""}
{row.rvwCn}
|
@@ -1753,7 +1784,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
value="Y"
checked={checkResults[key] === "Y"}
disabled={editableReviewStep !== selectedReviewStep}
- onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
+ onChange={() =>
+ setCheckResults((prev) => ({ ...prev, [key]: "Y" }))
+ }
/>
@@ -1765,14 +1798,16 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
value="N"
checked={checkResults[key] === "N"}
disabled={editableReviewStep !== selectedReviewStep}
- onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "N" }))}
+ onChange={() =>
+ setCheckResults((prev) => ({ ...prev, [key]: "N" }))
+ }
/>
>
)}
-
- {showRadios && selectedReviewStep !== "LOCAL" ? (
+
+ {showRadios && selectedReviewStep !== "LOCAL" ? (
|
{fileFormat} |
@@ -1833,7 +1870,11 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
type="text"
placeholder="첨부문서를 올려주세요"
className="cnfm-input flex-1"
- value={requiredFile?.uploading ? "업로드 중..." : requiredFile?.fileName || ""}
+ value={
+ requiredFile?.uploading
+ ? "업로드 중..."
+ : requiredFile?.fileName || ""
+ }
readOnly
/>
= {
SF: "하수처리시설",
};
-const normalizeChecklistResultMap = (
- raw: unknown,
-): Record => {
+const normalizeChecklistResultMap = (raw: unknown): Record => {
const base: Record = { LOCAL: [], ENV: [], KECO: [] };
if (!raw) return base;
@@ -98,7 +99,7 @@ function CnfmViewPage({ id }: { id: string }) {
const query = useQuery({
queryKey: ["instl-sncn-detail", id],
- queryFn: () => InstlSncnApi.getById(id),
+ queryFn: () => instlSncnService.getById(id),
});
const data = (query.data ?? {}) as any;
@@ -124,6 +125,155 @@ function CnfmViewPage({ id }: { id: string }) {
}, [cklstAll]);
const esntlFileList = data.esntlFileList ?? [];
const etcFileList = data.etcFileList ?? [];
+ const elaprDocPrgrsList = (data.elaprDocPrgrsList ?? []) as any[];
+ const renderApproverCell = (code: string) => {
+ const row = elaprDocPrgrsList.find((r) => r.atrzSeComCd === code);
+ if (!row) return - ;
+ const status = row.atrzPrgrsSttsComCd ?? "";
+ const statusMap: Record = {
+ ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
+ ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
+ ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
+ ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
+ };
+ const st = statusMap[status] ?? {
+ label: status || "",
+ badgeClass: "bg-gray-100 text-gray-700",
+ icon: "",
+ };
+ return (
+
+ {row.aprvrUuidNm ?? "-"}
+
+ {row.ognzNm ? `${row.ognzNm}` : ""}
+ {row.jbpsSeNm || row.jbgdSeNm
+ ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
+ : ""}
+
+
+ {row.atrzDt ? (
+ {dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}
+ ) : null}
+ {st.label ? (
+
+ {st.icon}
+ {st.label}
+
+ ) : null}
+
+
+ );
+ };
+ const orderedApprovers = useMemo(() => {
+ return (elaprDocPrgrsList ?? []).slice().sort((a: any, b: any) => {
+ const A = Number(a?.atrzSeqNo ?? 0);
+ const B = Number(b?.atrzSeqNo ?? 0);
+ return A - B;
+ });
+ }, [elaprDocPrgrsList]);
+
+ const handleApproveClick = async (elaprPrgrsId: any, atrzSeComCd: any) => {
+ if (!elaprPrgrsId) {
+ alert("결재 정보가 올바르지 않습니다.");
+ return;
+ }
+ try {
+ await processElaprPrgrs(String(elaprPrgrsId), "ATRZ_PRGRS_003"); //진행상태를 승인으로 업데이트
+ alert("결재가 완료되었습니다.");
+ try {
+ await query.refetch();
+ } catch (e) {
+ /* ignore refetch errors */
+ }
+ } catch (err: any) {
+ console.error("결재 처리 실패", err);
+ const msg =
+ axios.isAxiosError(err) && err.response?.data?.message
+ ? err.response.data.message
+ : "결재 처리 중 오류가 발생했습니다.";
+ alert(msg);
+ }
+ };
+
+ const handleAssignApproverClick = async (elaprTmpltId: string) => {
+ try {
+ const payload = {
+ elaprDocNo: det.instlSncnDocNo,
+ elaprTmpltId: elaprTmpltId /* 팝업에서 선택된 전자결제 템플릿 */,
+ dmndTtl: det.bizNm /* "구례 하수도 처리시설 2026", */,
+ dmndCn: det.bizCn /*"구례 발주 하수도 처리시설 사업 2026년도 진행분 결제 요청 입니다."*/,
+ };
+ await elaprDmndService.save(payload);
+ alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
+ try {
+ await query.refetch();
+ } catch (e) {
+ /* ignore refetch errors */
+ }
+ } catch (err: any) {
+ console.error("결재자 지정 실패", err);
+ const msg =
+ axios.isAxiosError(err) && err.response?.data?.message
+ ? err.response.data.message
+ : "결재자 지정 중 오류가 발생했습니다.";
+ alert(msg);
+ }
+ };
+
+ const renderApproverRow = (row: any) => {
+ if (!row) return - ;
+ const status = row.atrzPrgrsSttsComCd ?? "";
+ const statusMap: Record = {
+ ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
+ ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
+ ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
+ ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
+ };
+ const st = statusMap[status] ?? {
+ label: status || "",
+ badgeClass: "bg-gray-100 text-gray-700",
+ icon: "",
+ };
+ return (
+
+ {row.aprvrUuidNm ?? "-"}
+
+ {row.ognzNm ? `${row.ognzNm}` : ""}
+ {row.jbpsSeNm || row.jbgdSeNm
+ ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
+ : ""}
+
+
+ {row.atrzDt ? (
+ {dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}
+ ) : null}
+ {st.label ? (
+ <>
+
+ {st.icon}
+ {st.label}
+
+ {status === "ATRZ_PRGRS_002" && userInfo?.userUnqId === row.aprvrUuid ? (
+
+ ) : null}
+ >
+ ) : null}
+
+
+ );
+ };
const currentPrgrsSttsComCd = (det.prgrsSttsComCd as string | undefined) ?? "ST";
const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd);
const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd);
@@ -138,7 +288,9 @@ function CnfmViewPage({ id }: { id: string }) {
// derive available task codes from checklist results
const taskCodes = useMemo(() => {
- return Array.from(new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)));
+ return Array.from(
+ new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)),
+ );
}, [cklstAll]);
useEffect(() => {
@@ -166,8 +318,11 @@ function CnfmViewPage({ id }: { id: string }) {
);
return rows
.slice()
- .sort((a: any, b: any) =>
- (a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) || (a.sortSeq ?? 0) - (b.sortSeq ?? 0) || (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
+ .sort(
+ (a: any, b: any) =>
+ (a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) ||
+ (a.sortSeq ?? 0) - (b.sortSeq ?? 0) ||
+ (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
);
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length, selectedTaskCode]);
@@ -176,11 +331,15 @@ function CnfmViewPage({ id }: { id: string }) {
const cklstStats = useMemo(() => {
if (!selectedReviewStep) return { total: 0, completed: 0, percent: 0 };
- const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) => r.bchckUseYn === "Y" && (selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true));
+ const rows = (cklstResultMap[selectedReviewStep] ?? []).filter(
+ (r: any) =>
+ r.bchckUseYn === "Y" &&
+ (selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true),
+ );
const total = rows.length;
const completed = rows.reduce((acc: number, row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
- const val = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? "";
+ const val = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -199,8 +358,8 @@ function CnfmViewPage({ id }: { id: string }) {
.filter((r: any) => r.bchckUseYn === "Y")
.map((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
- const rvwRsltAnmlyYn = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? null;
- const picRvwCn = (viewCheckComments[key] ?? row.picRvwCn) ?? null;
+ const rvwRsltAnmlyYn = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? null;
+ const picRvwCn = viewCheckComments[key] ?? row.picRvwCn ?? null;
return {
taskSeComCd: row.taskSeComCd ?? "",
cklstSn: row.cklstSn,
@@ -222,7 +381,7 @@ function CnfmViewPage({ id }: { id: string }) {
} as unknown as InstlSncnApplySaveRequestDto;
try {
- const issuedDocNo = await InstlSncnApi.techReview(payload);
+ const issuedDocNo = await instlSncnService.techReview(det.instlSncnDocNo, payload);
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("기술검토 등록 API 호출 실패", error);
@@ -239,7 +398,8 @@ function CnfmViewPage({ id }: { id: string }) {
const comments: Record = {};
(selectedChecklistRows ?? []).forEach((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
- if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") results[key] = row.rvwRsltAnmlyYn;
+ if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N")
+ results[key] = row.rvwRsltAnmlyYn;
if (row.picRvwCn) comments[key] = String(row.picRvwCn);
});
setViewCheckResults(results);
@@ -267,9 +427,60 @@ function CnfmViewPage({ id }: { id: string }) {
return (
+ {/* orderedApprovers.length > 0 && ( */}
+
+ 결재 정보
+
+
+
+
+ {orderedApprovers.length === 0 ? (
+ <>
+ | 접수자 |
+ 검토자 |
+ 승인자 |
+ 최종승인자 |
+ >
+ ) : (
+ orderedApprovers.map((r: any) => (
+
+ {r.atrzSeComNm ?? r.atrzSeComCd}
+ |
+ ))
+ )}
+
+
+
+
+ {orderedApprovers.length === 0 ? (
+ <>
+ | {renderApproverCell("ATRZ01")} |
+ {renderApproverCell("ATRZ02")} |
+ {renderApproverCell("ATRZ03")} |
+ {renderApproverCell("ATRZ99")} |
+ >
+ ) : (
+ orderedApprovers.map((r: any) => (
+ {renderApproverRow(r)} |
+ ))
+ )}
+
+
+
+ {/** 결제자 지정 버튼 */}
+
+
+
+
+
+ {/* )} */}
설치인가 상세보기
- 문서번호: {det.instlSncnDocNo}
+
+ 문서번호: {det.instlSncnDocNo}
+
@@ -279,30 +490,31 @@ function CnfmViewPage({ id }: { id: string }) {
| 사업명 |
{det.bizNm || "-"} |
- 사업기간 |
- {(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")} |
+ 사업기간 |
+ {(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")} |
| 사업내용 |
{det.bizCn || "-"} |
- | 업무구분 |
+ 구분 |
{det.taskSeCdNm || det.taskSeComCd || "-"} |
- 행정동ID |
- {det.admdstCd || "-"} |
+ 신청지자체 |
+
+ {det.sido || "-"} {det.gugun || "-"}
+ |
- | 사업구분(인가구분) |
- {det.sncnSeComCd || "-"} |
+ 사업구분 |
+ {det.sncnSeCdNm || "-"} |
| 진행상태 |
- {det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"} |
+ {det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"} |
예산사업코드 |
{det.bgtBizCd || "-"} |
-
@@ -376,7 +588,9 @@ function CnfmViewPage({ id }: { id: string }) {
{bizSclList.length === 0 ? (
- | 조회된 사업규모 항목이 없습니다. |
+
+ 조회된 사업규모 항목이 없습니다.
+ |
) : (
bizSclList.map((node: any) => {
@@ -386,12 +600,28 @@ function CnfmViewPage({ id }: { id: string }) {
const tstmDesignQty = Number(node.tstmDesignQty) || 0;
const diff = tstmDesignQty - budgetReflct;
return (
-
- | {node.cnstpSeCdNm} |
- {fmt(bscPlanQty)} |
- {fmt(budgetReflct)} |
- {fmt(tstmDesignQty)} |
- {diff > 0 ? `+${fmt(diff)}` : fmt(diff)} |
+
+ |
+ {node.cnstpSeCdNm}
+ |
+
+ {fmt(bscPlanQty)}
+ |
+
+ {fmt(budgetReflct)}
+ |
+
+ {fmt(tstmDesignQty)}
+ |
+
+ {diff > 0 ? `+${fmt(diff)}` : fmt(diff)}
+ |
);
})
@@ -403,7 +633,9 @@ function CnfmViewPage({ id }: { id: string }) {
{/* 총사업비 */}
- 총사업비 (단위: 백만원)
+
+ 총사업비 (단위: 백만원)
+
@@ -423,7 +655,6 @@ function CnfmViewPage({ id }: { id: string }) {
{bizCstList.map((node: any) => {
-
const pl = indentPx(node.indAcntgSeCdNm);
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
const budgetReflct = Number(node.bgtRfltAmt) || 0;
@@ -438,12 +669,14 @@ function CnfmViewPage({ id }: { id: string }) {
node.treeLevel === 1
? "bg-blue-50 font-bold"
: node.treeLevel === 2
- ? "bg-gray-100 font-semibold"
- : ""
+ ? "bg-gray-100 font-semibold"
+ : ""
}
>
{node.treeLevel <= 2 ? (
- | {node.acntgSeCdNm} |
+
+ {node.acntgSeCdNm}
+ |
) : (
<>
|
@@ -470,7 +703,9 @@ function CnfmViewPage({ id }: { id: string }) {
{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}
- {budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}
+
+ {budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}
+
|
);
@@ -481,31 +716,33 @@ function CnfmViewPage({ id }: { id: string }) {
{/* 체크리스트 */}
-
-
- 체크리스트
-
-
- {Array.from(new Set(taskCodes)).map((code) => {
- const name = TASK_CODE_LABELS[code] ?? code;
- const active = selectedTaskCode === code;
- return (
-
- );
- })}
-
- {`(${cklstStats.completed}/${cklstStats.total})`}
+
+
+ 체크리스트
+
+
+ {Array.from(new Set(taskCodes)).map((code) => {
+ const name = TASK_CODE_LABELS[code] ?? code;
+ const active = selectedTaskCode === code;
+ return (
+
+ );
+ })}
+ {`(${cklstStats.completed}/${cklstStats.total})`}
+
{visibleReviewStepCodes.length === 0 ? (
@@ -529,89 +766,101 @@ function CnfmViewPage({ id }: { id: string }) {
)}
- 체크리스트 진행상황 [{selectedReviewStep}] — 총합: {cklstStats.total} · 완료: {cklstStats.completed} · 진행률: {cklstStats.percent}%
+ 체크리스트 진행상황 [{selectedReviewStep}] — 총합: {cklstStats.total} ·
+ 완료: {cklstStats.completed} · 진행률:{" "}
+ {cklstStats.percent}%
- | 검토항목 |
+
+ 검토항목
+ |
주요 검토내용 |
검토 결과(✓) |
{selectedReviewStep !== "LOCAL" ? (
- 기술검토 의견 |
+
+ 기술검토 의견
+ |
) : null}
|
-
+
|
-
+
|
{selectedChecklistRows.length === 0 ? (
- | 조회된 체크리스트 항목이 없습니다. |
+
+ 조회된 체크리스트 항목이 없습니다.
+ |
) : (
selectedChecklistRows.map((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
const hasResult = row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N";
- const showRadios = row.bchckUseYn === "Y" ;
+ const showRadios = row.bchckUseYn === "Y";
return (
| {row.cklstArtclNm} |
- {row.treeLevel > 1 ? "└ " : ""}{row.rvwCn} |
- {!showRadios ? (
- <>
- - |
- - |
- >
- ) : (
- <>
-
- setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
- />
- |
-
- setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))}
- />
- |
- >
- )}
- {showRadios && selectedReviewStep !== "LOCAL" ? (
-
- |
- ) : null}
+
+ {row.treeLevel > 1 ? "└ " : ""}
+ {row.rvwCn}
+ |
+ {!showRadios ? (
+ <>
+ - |
+ - |
+ >
+ ) : (
+ <>
+
+
+ setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))
+ }
+ />
+ |
+
+
+ setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))
+ }
+ />
+ |
+ >
+ )}
+ {showRadios && selectedReviewStep !== "LOCAL" ? (
+
+ |
+ ) : null}
);
})
@@ -628,7 +877,7 @@ function CnfmViewPage({ id }: { id: string }) {
- | 필수 자료명 |
+ 필수 자료명 |
파일명 |
크기 |
@@ -636,7 +885,9 @@ function CnfmViewPage({ id }: { id: string }) {
{esntlFileList.length === 0 ? (
- | 조회된 필수 첨부파일 항목이 없습니다. |
+
+ 조회된 필수 첨부파일 항목이 없습니다.
+ |
) : (
esntlFileList.map((f: any) => (
@@ -644,17 +895,28 @@ function CnfmViewPage({ id }: { id: string }) {
{f.esntlAtchFileNm}
- {f.esntlAtchYn === "Y" ? * : null}
+ {f.esntlAtchYn === "Y" ? (
+ *
+ ) : null}
|
{f.atflCd ? (
- {f.orgnlFileNm || "다운로드"}
+
+ {f.orgnlFileNm || "다운로드"}
+
) : (
첨부없음
)}
|
- {formatFileSize(f.atflFlieSz)} |
+
+ {formatFileSize(f.atflFlieSz)}
+ |
))
)}
@@ -677,19 +939,30 @@ function CnfmViewPage({ id }: { id: string }) {
{etcFileList.length === 0 ? (
- | 등록된 기타첨부파일이 없습니다. |
+
+ 등록된 기타첨부파일이 없습니다.
+ |
) : (
etcFileList.map((f: any) => (
|
{f.orgnlFileNm ? (
- {f.orgnlFileNm}
+
+ {f.orgnlFileNm}
+
) : (
{f.physFileNm || f.atflId}
)}
|
- {formatFileSize(f.atflFlieSz)} |
+
+ {formatFileSize(f.atflFlieSz)}
+ |
))
)}
@@ -699,7 +972,9 @@ function CnfmViewPage({ id }: { id: string }) {
-
+
diff --git a/apps/as/src/pages/instl-sncn/index.ts b/apps/as/src/pages/instl-sncn/index.ts
index 570133ba4..3957b0c66 100644
--- a/apps/as/src/pages/instl-sncn/index.ts
+++ b/apps/as/src/pages/instl-sncn/index.ts
@@ -1,4 +1,5 @@
-export { CnfmListPage } from "./ui/cnfm-list-page";
-export { CnfmViewPage } from "./ui/cnfm-view-page";
-export { CnfmRegistPage } from "./ui/cnfm-regist-page";
-export { CnfmModifyPage } from "./ui/cnfm-modify-page";
+export { InstlEditPage } from "./ui/instl-edit-page";
+export { InstlFormPage } from "./ui/instl-form-page";
+export { InstlListPage } from "./ui/instl-list-page";
+export { InstlRegistPage } from "./ui/instl-regist-page";
+export { InstlViewPage } from "./ui/instl-view-page";
diff --git a/apps/as/src/pages/instl-sncn/lib/approval-utils.ts b/apps/as/src/pages/instl-sncn/lib/approval-utils.ts
new file mode 100644
index 000000000..83f22a011
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/approval-utils.ts
@@ -0,0 +1,28 @@
+import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
+
+export interface ApproverStatusConfig {
+ label: string;
+ badgeClass: string;
+}
+
+export const APPROVER_STATUS_MAP: Record = {
+ ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
+ ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
+ ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
+ ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
+};
+
+export function sortByAtrzSeqNo(
+ list: ElaprDocPrgrsClientResponseDto[],
+): ElaprDocPrgrsClientResponseDto[] {
+ return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
+}
+
+export function getApproverStatus(status: string | undefined): ApproverStatusConfig {
+ return (
+ APPROVER_STATUS_MAP[status ?? ""] ?? {
+ label: status || "-",
+ badgeClass: "bg-gray-100 text-gray-700",
+ }
+ );
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/business-cost-form-utils.ts b/apps/as/src/pages/instl-sncn/lib/business-cost-form-utils.ts
new file mode 100644
index 000000000..97d6f4276
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/business-cost-form-utils.ts
@@ -0,0 +1,158 @@
+import type { InstlSncnBizCstSaveRequestDto } from "@/entities/instl-sncn";
+
+export interface CostItemDef {
+ code: string;
+ readonly?: boolean;
+ children?: string[];
+}
+
+function sumChildAmounts(list: InstlSncnBizCstSaveRequestDto[], childCodes: string[]) {
+ let bscPlanAmt = 0;
+ let bgtRfltAmt = 0;
+ let tstmDesignAmt = 0;
+
+ childCodes.forEach((childCode) => {
+ const child = list.find((c) => c.acntgSeCd === childCode);
+ if (child) {
+ bscPlanAmt += child.bscPlanAmt || 0;
+ bgtRfltAmt += child.bgtRfltAmt || 0;
+ tstmDesignAmt += child.tstmDesignAmt || 0;
+ }
+ });
+
+ return { bscPlanAmt, bgtRfltAmt, tstmDesignAmt };
+}
+
+export function recalculateBizCstTotals(
+ list: InstlSncnBizCstSaveRequestDto[],
+ items: CostItemDef[],
+): InstlSncnBizCstSaveRequestDto[] {
+ const newList = [...list];
+ let hasChanges = false;
+
+ for (let i = items.length - 1; i >= 0; i--) {
+ const item = items[i];
+ if (!item.readonly || !item.children) continue;
+
+ const idx = newList.findIndex((c) => c.acntgSeCd === item.code);
+ if (idx < 0) continue;
+
+ const { bscPlanAmt, bgtRfltAmt, tstmDesignAmt } = sumChildAmounts(newList, item.children);
+ const current = newList[idx];
+
+ if (
+ (current.bscPlanAmt || 0) !== bscPlanAmt ||
+ (current.bgtRfltAmt || 0) !== bgtRfltAmt ||
+ (current.tstmDesignAmt || 0) !== tstmDesignAmt
+ ) {
+ newList[idx] = { ...current, bscPlanAmt, bgtRfltAmt, tstmDesignAmt };
+ hasChanges = true;
+ }
+ }
+
+ return hasChanges ? newList : list;
+}
+
+export function calculateSewerPipeCost(
+ bizCstList: InstlSncnBizCstSaveRequestDto[],
+ newPipeCode: string,
+ replaceCode: string,
+ targetCode: string,
+): InstlSncnBizCstSaveRequestDto {
+ const newPipe = bizCstList.find((item) => item.acntgSeCd === newPipeCode);
+ const replace = bizCstList.find((item) => item.acntgSeCd === replaceCode);
+
+ const bscPlanAmt = (newPipe?.bscPlanAmt || 0) + (replace?.bscPlanAmt || 0);
+ const bgtRfltAmt = (newPipe?.bgtRfltAmt || 0) + (replace?.bgtRfltAmt || 0);
+ const tstmDesignAmt = (newPipe?.tstmDesignAmt || 0) + (replace?.tstmDesignAmt || 0);
+
+ return {
+ acntgSeCd: targetCode,
+ bscPlanAmt,
+ bgtRfltAmt,
+ tstmDesignAmt,
+ };
+}
+
+export function calculateConstructionCost(
+ bizCstList: InstlSncnBizCstSaveRequestDto[],
+ codes: string[],
+ targetCode: string,
+): InstlSncnBizCstSaveRequestDto {
+ let bscPlanAmt = 0;
+ let bgtRfltAmt = 0;
+ let tstmDesignAmt = 0;
+
+ codes.forEach((code) => {
+ const item = bizCstList.find((i) => i.acntgSeCd === code);
+ if (item) {
+ bscPlanAmt += item.bscPlanAmt || 0;
+ bgtRfltAmt += item.bgtRfltAmt || 0;
+ tstmDesignAmt += item.tstmDesignAmt || 0;
+ }
+ });
+
+ return {
+ acntgSeCd: targetCode,
+ bscPlanAmt,
+ bgtRfltAmt,
+ tstmDesignAmt,
+ };
+}
+
+export function calculateIncidentalExpenses(
+ bizCstList: InstlSncnBizCstSaveRequestDto[],
+ codes: string[],
+ targetCode: string,
+): InstlSncnBizCstSaveRequestDto {
+ let bscPlanAmt = 0;
+ let bgtRfltAmt = 0;
+ let tstmDesignAmt = 0;
+
+ codes.forEach((code) => {
+ const item = bizCstList.find((i) => i.acntgSeCd === code);
+ if (item) {
+ bscPlanAmt += item.bscPlanAmt || 0;
+ bgtRfltAmt += item.bgtRfltAmt || 0;
+ tstmDesignAmt += item.tstmDesignAmt || 0;
+ }
+ });
+
+ return {
+ acntgSeCd: targetCode,
+ bscPlanAmt,
+ bgtRfltAmt,
+ tstmDesignAmt,
+ };
+}
+
+export function calculateTotalCost(
+ bizCstList: InstlSncnBizCstSaveRequestDto[],
+ codes: string[],
+ targetCode: string,
+): InstlSncnBizCstSaveRequestDto {
+ let bscPlanAmt = 0;
+ let bgtRfltAmt = 0;
+ let tstmDesignAmt = 0;
+
+ codes.forEach((code) => {
+ const item = bizCstList.find((i) => i.acntgSeCd === code);
+ if (item) {
+ bscPlanAmt += item.bscPlanAmt || 0;
+ bgtRfltAmt += item.bgtRfltAmt || 0;
+ tstmDesignAmt += item.tstmDesignAmt || 0;
+ }
+ });
+
+ return {
+ acntgSeCd: targetCode,
+ bscPlanAmt,
+ bgtRfltAmt,
+ tstmDesignAmt,
+ };
+}
+
+export function calculateDiffPercent(diff: number, baseline: number): string {
+ if (baseline === 0) return "-";
+ return `${Math.round((diff / baseline) * 100)}%`;
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/business-cost-utils.ts b/apps/as/src/pages/instl-sncn/lib/business-cost-utils.ts
new file mode 100644
index 000000000..4a21680f0
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/business-cost-utils.ts
@@ -0,0 +1,35 @@
+import type { BizCstTreeDto } from "@/entities/instl-sncn";
+
+const ACNTG_TREE_PATH_SEPARATOR = "|";
+
+/** treePath를 회계구분 코드 배열로 파싱 */
+export function parseAcntgTreePath(treePath: string): string[] {
+ return treePath.split(ACNTG_TREE_PATH_SEPARATOR).filter(Boolean);
+}
+
+/** treePath에서 직계 부모 acntgSeCd 반환 (루트면 null) */
+export function getParentAcntgSeCd(node: Pick): string | null {
+ const segments = parseAcntgTreePath(node.treePath);
+ if (segments.length <= 1) return null;
+ return segments[segments.length - 2];
+}
+
+/** treePath 기준으로 bizCstList 정렬 (원본 불변) */
+export function sortBizCstListByTreePath(list: BizCstTreeDto[]): BizCstTreeDto[] {
+ return [...list].sort((a, b) => {
+ const byPath = a.treePath.localeCompare(b.treePath);
+ if (byPath !== 0) return byPath;
+ return a.acntgSeCd.localeCompare(b.acntgSeCd);
+ });
+}
+
+/** 자식 노드가 없는 leaf 여부 (treePath | 구분자 기준) */
+export function isBizCstLeafNode(
+ node: Pick,
+ allNodes: Pick[],
+): boolean {
+ const childPrefix = `${node.treePath}${ACNTG_TREE_PATH_SEPARATOR}`;
+ return !allNodes.some(
+ (other) => other.acntgSeCd !== node.acntgSeCd && other.treePath.startsWith(childPrefix),
+ );
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/button-config.ts b/apps/as/src/pages/instl-sncn/lib/button-config.ts
new file mode 100644
index 000000000..6b1a542b7
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/button-config.ts
@@ -0,0 +1,133 @@
+export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
+ strg: "strg",
+ aply: "aply",
+ del: "del",
+ splmntCmptn: "splmntCmptn",
+ instlSncnAncmnt: "instlSncnAncmnt",
+ envoSbmsn: "envoSbmsn",
+ rjct: "rjct",
+ sncnAprv: "sncnAprv",
+ rvwRply: "rvwRply",
+ rqstTechRvw: "rqstTechRvw",
+ dsgntRvwr: "dsgntRvwr",
+ rvwStrg: "rvwStrg",
+ rvwCmptn: "rvwCmptn",
+ dmndSplmnt: "dmndSplmnt",
+ atrzAprv: "atrzAprv",
+ atrzRply: "atrzRply",
+ grndsChck: "grndsChck",
+} as const;
+
+export type InstlSncnViewButtonAction =
+ (typeof INSTL_SNCN_VIEW_BUTTON_ACTION)[keyof typeof INSTL_SNCN_VIEW_BUTTON_ACTION];
+
+const ROLE_STATUS_BUTTON_ACTIONS: Record> = {
+ ROLE_USER05: {
+ "": [
+ INSTL_SNCN_VIEW_BUTTON_ACTION.strg,
+ INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
+ INSTL_SNCN_VIEW_BUTTON_ACTION.del,
+ ],
+ SV: [
+ INSTL_SNCN_VIEW_BUTTON_ACTION.strg,
+ INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
+ INSTL_SNCN_VIEW_BUTTON_ACTION.del,
+ ],
+ SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
+ TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
+ },
+ ROLE_USER04: {
+ ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct],
+ EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
+ },
+ ROLE_USER03: {
+ LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
+ TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
+ },
+ ROLE_USER01: {
+ TE: [INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck],
+ SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
+ },
+};
+
+const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
+
+const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
+ string,
+ Record
+> = {
+ ER: {
+ ATRZ01: [INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr],
+ },
+ TE: {
+ ATRZ02: [
+ INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg,
+ INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
+ INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
+ ],
+ ATRZ03: [INSTL_SNCN_VIEW_BUTTON_ACTION.atrzAprv],
+ ATRZ99: [INSTL_SNCN_VIEW_BUTTON_ACTION.atrzRply],
+ },
+};
+
+function resolveButtonConfigs(actions: InstlSncnViewButtonAction[]): InstlSncnViewButtonAction[] {
+ return actions.map((action) => INSTL_SNCN_VIEW_BUTTON_ACTION[action]);
+}
+
+function resolveRoleUser01AtrzButtonActions(
+ prgrsSttsComCd: string,
+ atrzSeComCdList: string[],
+): InstlSncnViewButtonAction[] {
+ const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
+ if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
+
+ const seen = new Set();
+ const actions: InstlSncnViewButtonAction[] = [];
+
+ for (const atrzSeComCd of atrzSeComCdList) {
+ const mappedActions = atrzStatusMap[atrzSeComCd];
+ if (!mappedActions) continue;
+
+ for (const action of mappedActions) {
+ if (seen.has(action)) continue;
+ seen.add(action);
+ actions.push(action);
+ }
+ }
+
+ return actions;
+}
+
+export function needsRoleUser01AtrzResolve(
+ dtlInstCd: string | undefined,
+ prgrsSttsComCd: string,
+): boolean {
+ return (
+ dtlInstCd === "ROLE_USER01" &&
+ ROLE_USER01_ATRZ_STATUS_CODES.includes(
+ prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number],
+ )
+ );
+}
+
+export function getButtonConfigs(
+ dtlInstCd: string | undefined,
+ prgrsSttsComCd: string,
+ atrzSeComCdList?: string[],
+): InstlSncnViewButtonAction[] {
+ if (!dtlInstCd) return [];
+
+ if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
+ return resolveButtonConfigs(
+ resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? []),
+ );
+ }
+
+ const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd];
+ if (!statusMap) return [];
+
+ const actions = statusMap[prgrsSttsComCd];
+ if (!actions) return [];
+
+ return resolveButtonConfigs(actions);
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/checklist-form-utils.ts b/apps/as/src/pages/instl-sncn/lib/checklist-form-utils.ts
new file mode 100644
index 000000000..4b5e9e9f9
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/checklist-form-utils.ts
@@ -0,0 +1,118 @@
+import type { CklstTreeNode } from "@/entities/cklst";
+
+interface CklstResultListItem {
+ cklstSn: number;
+ cklstArtclSn: number;
+ cklstRvwSn: number;
+ rvwStpComCd: string;
+ selected: "Y" | "N" | null;
+ picRvwCn?: string | null;
+}
+
+function compareCklstSiblings(a: CklstTreeNode, b: CklstTreeNode): number {
+ if (a.cklstArtclSn !== b.cklstArtclSn) {
+ return a.cklstArtclSn - b.cklstArtclSn;
+ }
+ if (a.sortSeq !== b.sortSeq) {
+ return b.sortSeq - a.sortSeq;
+ }
+ return a.cklstRvwSn - b.cklstRvwSn;
+}
+
+export function orderCklstTreeNodes(rows: CklstTreeNode[]): CklstTreeNode[] {
+ const childMap = new Map();
+
+ rows.forEach((row) => {
+ const parent = row.upCklstRvwSn ?? null;
+ if (!childMap.has(parent)) childMap.set(parent, []);
+ childMap.get(parent)!.push(row);
+ });
+
+ childMap.forEach((siblings) => siblings.sort(compareCklstSiblings));
+
+ const ordered: CklstTreeNode[] = [];
+
+ function walk(parent: number | null) {
+ (childMap.get(parent) ?? []).forEach((child) => {
+ ordered.push(child);
+ walk(child.cklstRvwSn);
+ });
+ }
+
+ walk(null);
+
+ return ordered;
+}
+
+function groupCklstTreeByTaskSeComCd(cklstTree: CklstTreeNode[]): Map {
+ const grouped = new Map();
+
+ cklstTree.forEach((node) => {
+ const taskCode = node.taskSeComCd ?? "";
+ if (!taskCode) return;
+ if (!grouped.has(taskCode)) grouped.set(taskCode, []);
+ grouped.get(taskCode)!.push(node);
+ });
+
+ return grouped;
+}
+
+function getOrderedSelectableNodes(
+ cklstTree: CklstTreeNode[],
+ taskSeComCdArray: string[],
+): CklstTreeNode[] {
+ const grouped = groupCklstTreeByTaskSeComCd(cklstTree);
+
+ return taskSeComCdArray.flatMap((code) =>
+ orderCklstTreeNodes(grouped.get(code) ?? []).filter((node) => node.bchckUseYn === "Y"),
+ );
+}
+
+export function isSameCklstResultList(
+ prev: CklstResultListItem[],
+ next: CklstResultListItem[],
+): boolean {
+ if (prev.length !== next.length) return false;
+
+ return prev.every((item, index) => {
+ const candidate = next[index];
+ return (
+ item.cklstRvwSn === candidate.cklstRvwSn &&
+ item.cklstSn === candidate.cklstSn &&
+ item.cklstArtclSn === candidate.cklstArtclSn &&
+ item.rvwStpComCd === candidate.rvwStpComCd &&
+ item.selected === candidate.selected &&
+ (item.picRvwCn ?? null) === (candidate.picRvwCn ?? null)
+ );
+ });
+}
+
+export function syncCklstResultListFromTree(
+ cklstTree: CklstTreeNode[],
+ taskSeComCdArray: string[],
+ prev: CklstResultListItem[],
+): CklstResultListItem[] {
+ if (!taskSeComCdArray.length) {
+ return prev.length === 0 ? prev : [];
+ }
+
+ const prevByRvwSn = new Map(prev.map((item) => [item.cklstRvwSn, item]));
+ const orderedSelectable = getOrderedSelectableNodes(cklstTree, taskSeComCdArray);
+
+ const next = orderedSelectable.map((node) => {
+ const existing = prevByRvwSn.get(node.cklstRvwSn);
+ return (
+ existing ?? {
+ cklstSn: node.cklstSn,
+ cklstArtclSn: node.cklstArtclSn,
+ cklstRvwSn: node.cklstRvwSn,
+ rvwStpComCd: "LOCAL",
+ selected: null,
+ picRvwCn: null,
+ }
+ );
+ });
+
+ if (isSameCklstResultList(prev, next)) return prev;
+ return next;
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts
new file mode 100644
index 000000000..cb31e7297
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts
@@ -0,0 +1,181 @@
+import type {
+ CklstRvwRsltTreeDto,
+ InstlSncnCklstResultSaveDto,
+ ReviewStepCode,
+} from "@/entities/instl-sncn";
+
+import {
+ type ChecklistFormState,
+ type ChecklistProgress,
+ type ChecklistRowKey,
+ type ChecklistStepMap,
+ REVIEW_STEP_CODES,
+} from "../model/view-types";
+
+export function buildChecklistRowKey(
+ row: CklstRvwRsltTreeDto,
+ reviewStep?: ReviewStepCode,
+): ChecklistRowKey {
+ const step = reviewStep ?? (row.rvwStpComCd as ReviewStepCode) ?? "LOCAL";
+ return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`;
+}
+
+export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
+ const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] };
+ rows.forEach((row) => {
+ const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
+ if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
+ });
+ return base;
+}
+
+export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
+ return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean)));
+}
+
+export function filterChecklistRows(
+ rows: CklstRvwRsltTreeDto[],
+ taskCode: string | null,
+): CklstRvwRsltTreeDto[] {
+ return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice();
+}
+
+export function getChecklistDisplayRows(
+ rows: CklstRvwRsltTreeDto[],
+ taskCode?: string | null,
+): CklstRvwRsltTreeDto[] {
+ const filtered = filterChecklistRows(rows, taskCode ?? null);
+ const seen = new Set();
+ const deduped: CklstRvwRsltTreeDto[] = [];
+
+ for (const row of filtered) {
+ const dedupeKey = `${row.taskSeComCd}_${row.cklstRvwSn}`;
+ if (seen.has(dedupeKey)) continue;
+ seen.add(dedupeKey);
+ deduped.push(row);
+ }
+
+ return deduped;
+}
+
+export function computeChecklistProgress(
+ rows: CklstRvwRsltTreeDto[],
+ results: ChecklistFormState["results"],
+): ChecklistProgress {
+ const activeRows = rows.filter((row) => row.bchckUseYn === "Y");
+ const total = activeRows.length;
+ const completed = activeRows.reduce((acc, row) => {
+ const key = buildChecklistRowKey(row);
+ const val = results[key] ?? row.rvwRsltAnmlyYn ?? "";
+ return acc + (val ? 1 : 0);
+ }, 0);
+ const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
+ return { total, completed, percent };
+}
+
+export function filterChecklistRowsByReviewStep(
+ rows: CklstRvwRsltTreeDto[],
+ reviewStep: ReviewStepCode,
+): CklstRvwRsltTreeDto[] {
+ return rows.filter((row) => row.rvwStpComCd === reviewStep);
+}
+
+export function computeReviewStepChecklistProgress(
+ rows: CklstRvwRsltTreeDto[],
+ results: ChecklistFormState["results"],
+ reviewStep: ReviewStepCode,
+): ChecklistProgress {
+ const activeRows = getChecklistDisplayRows(rows).filter((row) => row.bchckUseYn === "Y");
+ const total = activeRows.length;
+ const completed = activeRows.reduce((acc, row) => {
+ const key = buildChecklistRowKey(row, reviewStep);
+ const val = results[key];
+ return acc + (val === "Y" || val === "N" ? 1 : 0);
+ }, 0);
+ const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
+ return { total, completed, percent };
+}
+
+export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState {
+ const results: ChecklistFormState["results"] = {};
+ const comments: ChecklistFormState["comments"] = {};
+ rows.forEach((row) => {
+ const key = buildChecklistRowKey(row);
+ if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") {
+ results[key] = row.rvwRsltAnmlyYn;
+ }
+ if (row.picRvwCn) comments[key] = row.picRvwCn;
+ });
+ return {
+ results,
+ comments,
+ progress: computeChecklistProgress(rows, results),
+ };
+}
+
+export function mergeChecklistFormStateFromServer(
+ prev: ChecklistFormState,
+ allRows: CklstRvwRsltTreeDto[],
+): ChecklistFormState {
+ const results = { ...prev.results };
+ const comments = { ...prev.comments };
+
+ for (const row of allRows) {
+ const step = row.rvwStpComCd as ReviewStepCode | undefined;
+ if (!step || !REVIEW_STEP_CODES.includes(step)) continue;
+
+ const key = buildChecklistRowKey(row);
+ if ((row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") && results[key] === undefined) {
+ results[key] = row.rvwRsltAnmlyYn;
+ }
+ if (row.picRvwCn && !comments[key]) {
+ comments[key] = row.picRvwCn;
+ }
+ }
+
+ return {
+ results,
+ comments,
+ progress: computeChecklistProgress(getChecklistDisplayRows(allRows), results),
+ };
+}
+
+export function patchChecklistFormState(
+ prev: ChecklistFormState,
+ patch: Partial>,
+ allRows: CklstRvwRsltTreeDto[],
+ reviewStep?: ReviewStepCode,
+): ChecklistFormState {
+ const results = patch.results ?? prev.results;
+ const comments = patch.comments ?? prev.comments;
+ const progress = reviewStep
+ ? computeReviewStepChecklistProgress(allRows, results, reviewStep)
+ : computeChecklistProgress(getChecklistDisplayRows(allRows), results);
+
+ return {
+ results,
+ comments,
+ progress,
+ };
+}
+
+export function toCklstSaveDto(
+ rows: CklstRvwRsltTreeDto[],
+ formState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+): InstlSncnCklstResultSaveDto[] {
+ return getChecklistDisplayRows(rows)
+ .filter((row) => row.bchckUseYn === "Y")
+ .map((row) => {
+ const key = buildChecklistRowKey(row, reviewStep);
+ const selected = formState.results[key] ?? null;
+ return {
+ cklstSn: row.cklstSn,
+ cklstArtclSn: row.cklstArtclSn,
+ cklstRvwSn: row.cklstRvwSn,
+ rvwStpComCd: reviewStep,
+ selected,
+ picRvwCn: formState.comments[key] ?? null,
+ };
+ });
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/cnfm-regist-schema.ts b/apps/as/src/pages/instl-sncn/lib/cnfm-regist-schema.ts
new file mode 100644
index 000000000..a0c5b92b8
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/cnfm-regist-schema.ts
@@ -0,0 +1,103 @@
+import { z } from "zod";
+
+export const cnfmRegistSchema = z.object({
+ instlSncnMng: z.object({
+ bizNm: z.string().min(1, "사업명은 필수입니다"),
+ bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
+ bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
+ bizCn: z.string().min(1, "사업내용은 필수입니다"),
+ taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
+ sncnSeComCd: z.string().min(1, "사업구분은 필수입니다"),
+ dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
+ dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
+ dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
+ dsgnrEmlAddr: z
+ .string()
+ .min(1, "설계자 이메일은 필수입니다")
+ .email("올바른 이메일 형식이 아닙니다"),
+ bgtBizCd: z.string().optional(),
+ bgtAplyYr: z.string().optional(),
+ aplcntNm: z.string().optional(),
+ aplcntMblTelno: z.string().optional(),
+ aplcntOgdpNm: z.string().optional(),
+ aplcntEmlAddr: z.string().optional(),
+ instlSncnDocNo: z.string().optional(),
+ admdstCd: z.string().optional(),
+ envoSeComCd: z.string().optional(),
+ prgrsSttsComCd: z.string().optional(),
+ atchFileGroupId: z.string().optional(),
+ rtlztnFcltSeComCd: z.string().optional(),
+ rtlztnUsgSeComCd: z.string().optional(),
+ envoSeCdNm: z.string().optional(),
+ sncnSeCdNm: z.string().optional(),
+ taskSeCdNm: z.string().optional(),
+ prgrsSttsCdNm: z.string().optional(),
+ sido: z.string().optional(),
+ gugun: z.string().optional(),
+ regDt: z.string().optional(),
+ rgtr: z.string().optional(),
+ rgtrNm: z.string().optional(),
+ mdfcnDt: z.string().optional(),
+ }),
+ bzScaleList: z.array(
+ z.object({
+ cnstpSeCd: z.string(),
+ bscPl: z.number(),
+ budgetReflct: z.number(),
+ thtiDsgn: z.number(),
+ diff: z.number(),
+ }),
+ ),
+ bizCstList: z.array(
+ z.object({
+ acntgSeCd: z.string(),
+ bscPlanAmt: z.number(),
+ bgtRfltAmt: z.number(),
+ tstmDesignAmt: z.number(),
+ }),
+ ),
+ cklstResultList: z.array(
+ z.object({
+ cklstSn: z.number(),
+ cklstArtclSn: z.number(),
+ cklstRvwSn: z.number(),
+ rvwStpComCd: z.string(),
+ selected: z.enum(["Y", "N"]).nullable(),
+ picRvwCn: z.string().nullable().optional(),
+ }),
+ ),
+ esntlAtchFileList: z.array(
+ z.object({
+ esntlAtchFileId: z.string(),
+ atchFileGroupId: z.string(),
+ }),
+ ),
+ privacyConsent: z.enum(["Y", "N"], {
+ required_error: "개인정보 수집/이용 동의는 필수입니다",
+ }),
+});
+
+export type CnfmRegistFormData = z.infer;
+
+export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
+ return taskSeComCd.join(",");
+}
+
+export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
+ if (!taskSeComCd) return [];
+ return taskSeComCd.split(",").filter(Boolean);
+}
+
+export const cnfmRegistSaveSchema = cnfmRegistSchema.pick({
+ instlSncnMng: true,
+ bzScaleList: true,
+ bizCstList: true,
+});
+
+export const cnfmRegistSubmitSchema = cnfmRegistSchema.refine(
+ (data) => data.privacyConsent === "Y",
+ {
+ message: "개인정보 수집/이용에 동의해야 제출할 수 있습니다",
+ path: ["privacyConsent"],
+ },
+);
diff --git a/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts
new file mode 100644
index 000000000..ece716bb3
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts
@@ -0,0 +1,121 @@
+import type { IUserInfo } from "@/shared/store/user-store";
+import type { InstlSncnMngDetResponse } from "@/entities/instl-sncn";
+
+import type { CnfmRegistFormData } from "./cnfm-regist-schema";
+import { parseTaskSeComCdFromApi } from "./cnfm-regist-schema";
+
+function mapSelectedValue(value?: string | null): "Y" | "N" | null {
+ if (value === "Y" || value === "N") return value;
+ return null;
+}
+
+function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["esntlFileList"]) {
+ const esntlMap = new Map();
+
+ for (const item of esntlFileList) {
+ if (!item.esntlAtchFileId || !item.atflCd) continue;
+ esntlMap.set(item.esntlAtchFileId, item.atflCd);
+ }
+
+ return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
+ esntlAtchFileId,
+ atchFileGroupId,
+ }));
+}
+
+export function mapDetailToFormData(data: InstlSncnMngDetResponse): CnfmRegistFormData {
+ const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList } = data;
+
+ return {
+ instlSncnMng: {
+ instlSncnDocNo: detDto.instlSncnDocNo,
+ bizNm: detDto.bizNm,
+ bizBgngYr: detDto.bizBgngYr,
+ bizEndYr: detDto.bizEndYr,
+ bizCn: detDto.bizCn,
+ taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
+ sncnSeComCd: detDto.sncnSeComCd,
+ dsgnrNm: detDto.dsgnrNm,
+ dsgnrTelno: detDto.dsgnrTelno,
+ dsgnrOgdpNm: detDto.dsgnrOgdpNm,
+ dsgnrEmlAddr: detDto.dsgnrEmlAddr,
+ aplcntNm: detDto.aplcntNm ?? "",
+ aplcntMblTelno: detDto.aplcntMblTelno ?? "",
+ aplcntOgdpNm: detDto.aplcntOgdpNm ?? "",
+ aplcntEmlAddr: detDto.aplcntEmlAddr ?? "",
+ bgtBizCd: detDto.bgtBizCd,
+ bgtAplyYr: detDto.bgtAplyYr,
+ admdstCd: detDto.admdstCd,
+ envoSeComCd: detDto.envoSeComCd,
+ prgrsSttsComCd: detDto.prgrsSttsComCd,
+ atchFileGroupId: detDto.atchFileGroupId ?? "",
+ rtlztnFcltSeComCd: detDto.rtlztnFcltSeComCd ?? "",
+ rtlztnUsgSeComCd: detDto.rtlztnUsgSeComCd ?? "",
+ envoSeCdNm: detDto.envoSeCdNm,
+ sncnSeCdNm: detDto.sncnSeCdNm,
+ taskSeCdNm: detDto.taskSeCdNm,
+ prgrsSttsCdNm: detDto.prgrsSttsCdNm,
+ sido: detDto.sido,
+ gugun: detDto.gugun,
+ regDt: detDto.regDt,
+ rgtr: detDto.rgtr,
+ rgtrNm: detDto.rgtrNm,
+ mdfcnDt: detDto.mdfcnDt,
+ },
+ bzScaleList: bizSclList.map((item) => ({
+ cnstpSeCd: item.cnstpSeCd,
+ bscPl: item.bscPlanQty,
+ budgetReflct: item.bgtRfltQty,
+ thtiDsgn: item.tstmDesignQty,
+ diff: item.tstmDesignQty - item.bgtRfltQty,
+ })),
+ bizCstList: bizCstList.map((item) => ({
+ acntgSeCd: item.acntgSeCd,
+ bscPlanAmt: item.bscPlanAmt,
+ bgtRfltAmt: item.bgtRfltAmt,
+ tstmDesignAmt: item.tstmDesignAmt,
+ })),
+ cklstResultList:
+ cklstResultList?.map((item) => ({
+ cklstSn: item.cklstSn,
+ cklstArtclSn: item.cklstArtclSn,
+ cklstRvwSn: item.cklstRvwSn,
+ rvwStpComCd: item.rvwStpComCd,
+ selected: mapSelectedValue(item.rvwRsltAnmlyYn),
+ picRvwCn: item.picRvwCn,
+ })) ?? [],
+ esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
+ privacyConsent: "Y",
+ };
+}
+
+export function getCreateDefaultValues(
+ userInfo: IUserInfo | null,
+ taskSeComCd?: string[],
+): CnfmRegistFormData {
+ return {
+ instlSncnMng: {
+ bizNm: "",
+ bizBgngYr: "",
+ bizEndYr: "",
+ bizCn: "",
+ taskSeComCd: taskSeComCd ?? [],
+ sncnSeComCd: "",
+ dsgnrNm: "",
+ dsgnrTelno: "",
+ dsgnrOgdpNm: "",
+ dsgnrEmlAddr: "",
+ aplcntNm: userInfo?.userName ?? "",
+ aplcntMblTelno: userInfo?.mphn ?? "",
+ aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
+ aplcntEmlAddr: userInfo?.email ?? "",
+ prgrsSttsComCd: "SV",
+ atchFileGroupId: "",
+ },
+ bzScaleList: [],
+ bizCstList: [],
+ cklstResultList: [],
+ esntlAtchFileList: [],
+ privacyConsent: "N",
+ };
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/format-utils.ts b/apps/as/src/pages/instl-sncn/lib/format-utils.ts
new file mode 100644
index 000000000..b67ad9860
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/format-utils.ts
@@ -0,0 +1,16 @@
+export function fmtNumber(value: number | null | undefined): string {
+ if (typeof value === "number") return value.toLocaleString();
+ if (value === null || value === undefined) return "";
+ return String(value);
+}
+
+export function indentPx(indNm: string | undefined): number {
+ if (!indNm) return 0;
+ const spaces = indNm.length - indNm.trimStart().length;
+ return (spaces / 4) * 16;
+}
+
+export function calcDiffPercent(diff: number, baseline: number): string {
+ if (baseline === 0) return "-";
+ return `${Math.round((diff / baseline) * 100)}%`;
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts b/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts
new file mode 100644
index 000000000..56e34a880
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts
@@ -0,0 +1,37 @@
+import type {
+ InstlSncnMngDetResponse,
+ InstlSncnRvwOpnnSaveRequestDto,
+} from "@/entities/instl-sncn";
+
+export interface ReviewOpinionFormData {
+ rvwRsltCn: string;
+ rvwRsltAnmlyYn: "Y" | "N" | "";
+ atchFileGroupId: string;
+ opnrSeComCd: string;
+}
+
+export function toRvwOpnnSaveDto(
+ data: InstlSncnMngDetResponse,
+ reviewOpinionFormData: ReviewOpinionFormData | null | undefined,
+ opnrNm?: string,
+): InstlSncnRvwOpnnSaveRequestDto[] | undefined {
+ if (!reviewOpinionFormData) return undefined;
+
+ const hasContent =
+ reviewOpinionFormData.rvwRsltCn.trim() ||
+ reviewOpinionFormData.rvwRsltAnmlyYn ||
+ reviewOpinionFormData.atchFileGroupId;
+
+ if (!hasContent) return undefined;
+
+ return [
+ {
+ instlSncnDocNo: data.detDto.instlSncnDocNo,
+ opnrSeComCd: reviewOpinionFormData.opnrSeComCd,
+ opnrNm: opnrNm ?? "",
+ rvwRsltCn: reviewOpinionFormData.rvwRsltCn,
+ rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined,
+ atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined,
+ },
+ ];
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts b/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts
new file mode 100644
index 000000000..68f60f4c9
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts
@@ -0,0 +1,43 @@
+import type { ReviewStepCode } from "@/entities/instl-sncn";
+
+import {
+ INSTL_PROGRESS_ORDER,
+ REVIEW_STEP_CODES,
+ REVIEW_STEP_ROLE_RANK,
+ ROLE_TO_REVIEW_STEP,
+} from "../model/view-types";
+
+export function getProgressStepCount(prgrsSttsComCd?: string): number {
+ const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]);
+ if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1;
+ if (idx <= INSTL_PROGRESS_ORDER.indexOf("LE")) return 2;
+ return 3;
+}
+
+export function getRoleStepCount(dtlInstCd?: string): number {
+ const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined;
+ return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
+}
+
+export function getVisibleReviewSteps(
+ prgrsSttsComCd: string,
+ userRoleCode?: string,
+): ReviewStepCode[] {
+ const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode));
+ return REVIEW_STEP_CODES.slice(0, count);
+}
+
+export function getEditableReviewStep(
+ dtlInstCd?: string,
+ prgrsSttsComCd?: string,
+): ReviewStepCode | undefined {
+ if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
+ return "ENV";
+ }
+
+ if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") {
+ return "KECO";
+ }
+
+ return undefined;
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts
new file mode 100644
index 000000000..adea42a99
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts
@@ -0,0 +1,104 @@
+import type {
+ BizCstTreeDto,
+ BizSclTreeDto,
+ EsntlAtchFileMngTreeResponseDto,
+ InstlSncnApplySaveRequestDto,
+ InstlSncnBizCstSaveRequestDto,
+ InstlSncnBzScaleSaveDto,
+ InstlSncnCklstResultSaveDto,
+ InstlSncnEsntlAtchFileSaveDto,
+ InstlSncnMngDetResponse,
+ InstlSncnMngResponseDto,
+ ReviewStepCode,
+} from "@/entities/instl-sncn";
+
+import type { ChecklistFormState } from "../model/view-types";
+
+import { toCklstSaveDto } from "./checklist-utils";
+import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
+
+export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
+ return bizSclList
+ .filter((node) => node.useYn === "Y")
+ .map((node) => ({
+ cnstpSeCd: node.cnstpSeCd,
+ bscPl: node.bscPlanQty,
+ budgetReflct: node.bgtRfltQty,
+ thtiDsgn: node.tstmDesignQty,
+ diff: node.tstmDesignQty - node.bgtRfltQty,
+ }));
+}
+
+export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
+ return bizCstList
+ .filter((node) => node.useYn === "Y")
+ .map((node) => ({
+ acntgSeCd: node.acntgSeCd,
+ bscPlanAmt: node.bscPlanAmt,
+ bgtRfltAmt: node.bgtRfltAmt,
+ tstmDesignAmt: node.tstmDesignAmt,
+ }));
+}
+
+export function toEsntlAtchFileSaveDto(
+ esntlFileList: EsntlAtchFileMngTreeResponseDto[],
+): InstlSncnEsntlAtchFileSaveDto[] {
+ const esntlMap = new Map();
+
+ for (const item of esntlFileList) {
+ if (!item.esntlAtchFileId || !item.atflCd) continue;
+ esntlMap.set(item.esntlAtchFileId, item.atflCd);
+ }
+
+ return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
+ esntlAtchFileId,
+ atchFileGroupId,
+ }));
+}
+
+export function buildSavePayload(
+ data: InstlSncnMngDetResponse,
+ checklistFormState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+ prgrsSttsComCd: string,
+): InstlSncnApplySaveRequestDto {
+ const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
+
+ return {
+ instlSncnMng: { ...detDto, prgrsSttsComCd },
+ bzScaleList: toBzScaleSaveDto(bizSclList),
+ bizCstList: toBizCstSaveDto(bizCstList),
+ cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
+ esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
+ };
+}
+
+export function buildTechReviewReplyPayload(
+ data: InstlSncnMngDetResponse,
+ checklistFormState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+ prgrsSttsComCd: string,
+ techReViewSaveCd: string,
+ reviewOpinionFormData?: ReviewOpinionFormData | null,
+ opnrNm?: string,
+): InstlSncnApplySaveRequestDto {
+ return {
+ ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
+ techReViewSaveCd,
+ rvwStpCd: reviewStep,
+ rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
+ };
+}
+
+export function buildTechReviewPayload(
+ det: InstlSncnMngResponseDto,
+ cklstResultList: InstlSncnCklstResultSaveDto[],
+): InstlSncnApplySaveRequestDto {
+ return {
+ instlSncnMng: { ...det, prgrsSttsComCd: "ER" },
+ bzScaleList: [],
+ bizCstList: [],
+ cklstResultList,
+ esntlAtchFileList: [],
+ };
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/to-bns-list-page-response.ts b/apps/as/src/pages/instl-sncn/lib/to-bns-list-page-response.ts
new file mode 100644
index 000000000..3393a12e7
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/to-bns-list-page-response.ts
@@ -0,0 +1,37 @@
+import type { PageResponse } from "@/shared/model";
+import type { BnsListResponseDto, BnsListResponseWrapperDto } from "@/entities/bns-if";
+
+const EMPTY_SORT = { empty: true, sorted: false, unsorted: true };
+
+export const BNS_LIST_PAGE_SIZE = 10;
+
+export function toBnsListPageResponse(
+ wrapper: BnsListResponseWrapperDto,
+ cPage: number,
+ pageSize: number = BNS_LIST_PAGE_SIZE,
+): PageResponse {
+ const content = wrapper.list ?? [];
+ const pageIndex = cPage - 1;
+ const totalPages = pageSize > 0 ? Math.ceil(wrapper.cnt / pageSize) : 0;
+
+ return {
+ content,
+ totalElements: wrapper.cnt,
+ number: pageIndex,
+ numberOfElements: content.length,
+ size: pageSize,
+ totalPages,
+ first: cPage <= 1,
+ last: cPage >= totalPages || content.length === 0,
+ empty: content.length === 0,
+ pageable: {
+ sort: EMPTY_SORT,
+ offset: pageIndex * pageSize,
+ pageNumber: pageIndex,
+ pageSize,
+ paged: true,
+ unpaged: false,
+ },
+ sort: EMPTY_SORT,
+ };
+}
diff --git a/apps/as/src/pages/instl-sncn/lib/use-checklist-form-state.ts b/apps/as/src/pages/instl-sncn/lib/use-checklist-form-state.ts
new file mode 100644
index 000000000..819f4ed82
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/use-checklist-form-state.ts
@@ -0,0 +1,57 @@
+import { useCallback, useEffect, useState } from "react";
+
+import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
+
+import { type ChecklistFormState, DEFAULT_CHECKLIST_PROGRESS } from "../model/view-types";
+
+import { mergeChecklistFormStateFromServer, patchChecklistFormState } from "./checklist-utils";
+
+const EMPTY_CKLST_RESULT_LIST: CklstRvwRsltTreeDto[] = [];
+
+function isSameChecklistFormState(a: ChecklistFormState, b: ChecklistFormState): boolean {
+ if (
+ a.progress.total !== b.progress.total ||
+ a.progress.completed !== b.progress.completed ||
+ a.progress.percent !== b.progress.percent
+ ) {
+ return false;
+ }
+
+ const aResultKeys = Object.keys(a.results);
+ if (aResultKeys.length !== Object.keys(b.results).length) return false;
+ if (!aResultKeys.every((key) => a.results[key] === b.results[key])) return false;
+
+ const aCommentKeys = Object.keys(a.comments);
+ if (aCommentKeys.length !== Object.keys(b.comments).length) return false;
+
+ return aCommentKeys.every((key) => a.comments[key] === b.comments[key]);
+}
+
+function useChecklistFormState(cklstResultList: CklstRvwRsltTreeDto[] = EMPTY_CKLST_RESULT_LIST) {
+ const [formState, setFormState] = useState({
+ results: {},
+ comments: {},
+ progress: DEFAULT_CHECKLIST_PROGRESS,
+ });
+
+ useEffect(() => {
+ setFormState((prev) => {
+ const next = mergeChecklistFormStateFromServer(prev, cklstResultList);
+ return isSameChecklistFormState(prev, next) ? prev : next;
+ });
+ }, [cklstResultList]);
+
+ const patchFormState = useCallback(
+ (
+ patch: Partial>,
+ reviewStep?: ReviewStepCode,
+ ) => {
+ setFormState((prev) => patchChecklistFormState(prev, patch, cklstResultList, reviewStep));
+ },
+ [cklstResultList],
+ );
+
+ return { formState, setFormState, patchFormState };
+}
+
+export { EMPTY_CKLST_RESULT_LIST, useChecklistFormState };
diff --git a/apps/as/src/pages/instl-sncn/model/bns-picker-columns.tsx b/apps/as/src/pages/instl-sncn/model/bns-picker-columns.tsx
new file mode 100644
index 000000000..7faee86d6
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/model/bns-picker-columns.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { createColumnHelper } from "@tanstack/react-table";
+
+import type { BnsListResponseDto } from "@/entities/bns-if";
+
+const columnHelper = createColumnHelper();
+
+function formatOptional(value: string | undefined) {
+ return value?.trim() ? value : "-";
+}
+
+export function getBnsListRowKey(row: BnsListResponseDto) {
+ return `${row.gbn}-${row.seqNo}-${row.yr}`;
+}
+
+export function createBnsListColumns() {
+ return [
+ columnHelper.display({
+ id: "select",
+ header: "선택",
+ meta: { align: "center" },
+ cell: ({ row }) => (
+ row.toggleSelected(true)}
+ onClick={(event) => event.stopPropagation()}
+ aria-label="사업 선택"
+ />
+ ),
+ }),
+ columnHelper.accessor("rgnNm", {
+ header: "환경청",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("sido", {
+ header: "시도",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("gugun", {
+ header: "시군구",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("bzNm", {
+ header: "사업명",
+ meta: { align: "left" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("yr", {
+ header: "예산신청년도",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("strtYr", {
+ header: "사업시작년도",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("edYr", {
+ header: "사업종료년도",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("totBzamt", {
+ header: "총사업비",
+ meta: { align: "right" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ ];
+}
diff --git a/apps/as/src/pages/instl-sncn/model/columns.tsx b/apps/as/src/pages/instl-sncn/model/columns.tsx
new file mode 100644
index 000000000..772e20df0
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/model/columns.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { Fragment } from "react";
+
+import { createColumnHelper } from "@tanstack/react-table";
+
+import { dayjs } from "@/shared/lib";
+import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
+import { Badge } from "@common/components";
+
+const columnHelper = createColumnHelper();
+
+const MAIN_STEP_LABELS = {
+ save: "저장",
+ apply: "신청완료",
+ techReview: "기술검토",
+ reply: "검토결과 회신",
+} as const;
+
+const TECH_REVIEW_STATUS_CODES = new Set(["LE", "ER", "EE", "TE", "TS", "TR", "TP"]);
+
+type BadgeState = "completed" | "current" | "pending" | "action";
+
+interface ProgressBadge {
+ label: string;
+ state: BadgeState;
+}
+
+function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] {
+ const techReviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.techReview;
+ const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply;
+
+ if (prgrsSttsComCd === "SV") {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "current" },
+ { label: MAIN_STEP_LABELS.apply, state: "pending" },
+ { label: MAIN_STEP_LABELS.techReview, state: "pending" },
+ { label: MAIN_STEP_LABELS.reply, state: "pending" },
+ ];
+ }
+
+ if (prgrsSttsComCd === "ST") {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "completed" },
+ { label: MAIN_STEP_LABELS.apply, state: "current" },
+ { label: MAIN_STEP_LABELS.techReview, state: "pending" },
+ { label: MAIN_STEP_LABELS.reply, state: "pending" },
+ ];
+ }
+
+ if (TECH_REVIEW_STATUS_CODES.has(prgrsSttsComCd)) {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "completed" },
+ { label: MAIN_STEP_LABELS.apply, state: "completed" },
+ { label: techReviewLabel, state: "action" },
+ { label: MAIN_STEP_LABELS.reply, state: "pending" },
+ ];
+ }
+
+ if (prgrsSttsComCd === "EP") {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "completed" },
+ { label: MAIN_STEP_LABELS.apply, state: "completed" },
+ { label: MAIN_STEP_LABELS.techReview, state: "completed" },
+ { label: replyLabel, state: "action" },
+ ];
+ }
+
+ if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
+ return [
+ { label: MAIN_STEP_LABELS.save, state: "completed" },
+ { label: MAIN_STEP_LABELS.apply, state: "completed" },
+ { label: MAIN_STEP_LABELS.techReview, state: "completed" },
+ { label: MAIN_STEP_LABELS.reply, state: "completed" },
+ ];
+ }
+
+ return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }];
+}
+
+function getBadgeColor(state: BadgeState) {
+ switch (state) {
+ case "completed":
+ return "green";
+ case "current":
+ return "blue";
+ case "pending":
+ return "lightGray";
+ case "action":
+ return "orange";
+ }
+}
+
+function formatOptional(value: string | undefined) {
+ return value?.trim() ? value : "-";
+}
+
+interface ProgressStepBadgeProps {
+ prgrsSttsComCd: string;
+ prgrsSttsCdNm: string;
+}
+
+function ProgressStepBadge({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepBadgeProps) {
+ const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm);
+
+ return (
+
+ {badges.map((badge, index) => (
+
+ {index > 0 && >}
+ {badge.label}
+
+ ))}
+
+ );
+}
+
+export function createInstlSncnColumns() {
+ return [
+ columnHelper.accessor("sido", {
+ header: "시도",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("gugun", {
+ header: "시군구",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("bizNm", {
+ header: "사업명",
+ meta: { align: "left" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("taskSeCdNm", {
+ header: "업무구분",
+ meta: { align: "center" },
+ cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
+ }),
+ columnHelper.accessor("aplcntNm", {
+ header: "담당자",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.accessor("envoSeCdNm", {
+ header: "해당기관",
+ meta: { align: "center" },
+ cell: ({ getValue }) => formatOptional(getValue()),
+ }),
+ columnHelper.display({
+ id: "progress",
+ header: "진행상황",
+ meta: { align: "center" },
+ cell: ({ row }) => (
+
+ ),
+ }),
+ columnHelper.accessor("regDt", {
+ header: "신청일",
+ meta: { align: "center" },
+ cell: ({ getValue }) => {
+ const value = getValue();
+ return value ? dayjs(value).format("YYYY-MM-DD") : "-";
+ },
+ }),
+ ];
+}
diff --git a/apps/as/src/pages/instl-sncn/model/view-types.ts b/apps/as/src/pages/instl-sncn/model/view-types.ts
new file mode 100644
index 000000000..a6fc0b08c
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/model/view-types.ts
@@ -0,0 +1,94 @@
+import type {
+ AtflMngClientResponseDto,
+ BizCstTreeDto,
+ BizSclTreeDto,
+ CklstRvwRsltTreeDto,
+ ElaprDocPrgrsClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+ InstlSncnMngDetResponse,
+ InstlSncnMngResponseDto,
+ InstlSncnRvwOpnnResponseDto,
+ ReviewStepCode,
+} from "@/entities/instl-sncn";
+
+export type {
+ AtflMngClientResponseDto,
+ BizCstTreeDto,
+ BizSclTreeDto,
+ CklstRvwRsltTreeDto,
+ ElaprDocPrgrsClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+ InstlSncnMngResponseDto as InstlSncnMng,
+ InstlSncnMngDetResponse,
+ InstlSncnRvwOpnnResponseDto,
+ ReviewStepCode,
+};
+
+export type ChecklistRowKey = string;
+
+export interface ChecklistProgress {
+ total: number;
+ completed: number;
+ percent: number;
+}
+
+export const DEFAULT_CHECKLIST_PROGRESS: ChecklistProgress = {
+ total: 0,
+ completed: 0,
+ percent: 0,
+};
+
+export interface ChecklistFormState {
+ results: Record;
+ comments: Record;
+ progress: ChecklistProgress;
+}
+
+export type ChecklistStepMap = Record;
+
+export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
+
+export const REVIEW_STEP_LABELS: Record = {
+ LOCAL: "지자체",
+ ENV: "환경청",
+ KECO: "환경공단",
+};
+
+export const TASK_CODE_LABELS: Record = {
+ RE: "재이용수",
+ SP: "하수관로",
+ SF: "하수처리시설",
+};
+
+export const INSTL_PROGRESS_ORDER = [
+ // ROLE_USER05
+ "SV",
+ "SL",
+ "TA",
+
+ // ROLE_USER04
+ "ST",
+ "EP",
+
+ // ROLE_USER03
+ "LE",
+ "TP",
+
+ // ROLE_USER01
+ "ER",
+ "TE",
+
+ "TB",
+] as const;
+
+export const ROLE_TO_REVIEW_STEP: Record = {
+ ROLE_USER05: "LOCAL",
+ ROLE_USER03: "ENV",
+ ROLE_USER01: "KECO",
+};
+
+export const REVIEW_STEP_ROLE_RANK: Record = {
+ LOCAL: 1,
+ ENV: 2,
+ KECO: 3,
+};
diff --git a/apps/as/src/pages/instl-sncn/ui/components/bns-picker-dialog.tsx b/apps/as/src/pages/instl-sncn/ui/components/bns-picker-dialog.tsx
new file mode 100644
index 000000000..d9df5240f
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/components/bns-picker-dialog.tsx
@@ -0,0 +1,511 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import type { UseQueryResult } from "@tanstack/react-query";
+import {
+ getCoreRowModel,
+ type PaginationState,
+ type RowSelectionState,
+} from "@tanstack/react-table";
+
+import { useDataTable } from "@/shared/lib";
+import type { PageResponse } from "@/shared/model";
+import { NoData } from "@/shared/ui/no-data";
+import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
+import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
+import type { BnsDetailResponseDto, BnsListResponseDto, BnsSearchDto } from "@/entities/bns-if";
+import { buildBnsSearchDto, useBnsDetail, useBnsList } from "@/entities/bns-if";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+} from "@common/components";
+
+import { BNS_LIST_PAGE_SIZE, toBnsListPageResponse } from "../../lib/to-bns-list-page-response";
+import { createBnsListColumns, getBnsListRowKey } from "../../model/bns-picker-columns";
+
+import { SectionCollapsible } from "./section-collapsible";
+
+const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
+const YEAR_START = 1995;
+
+interface BnsApplyData {
+ bizNm: string;
+ bizBgngYr: string;
+ bizEndYr: string;
+ bizCn: string;
+ bgtBizCd: string;
+ bgtAplyYr: string;
+}
+
+interface BnsPickerDialogProps {
+ open: boolean;
+ onClose: () => void;
+ onSelect: (data: BnsApplyData) => void;
+}
+
+function BnsPickerDialog({ open, onClose, onSelect }: BnsPickerDialogProps) {
+ const currentYear = new Date().getFullYear();
+ const yearOptions = useMemo(
+ () =>
+ Array.from({ length: currentYear + 1 - YEAR_START + 1 }, (_, index) => YEAR_START + index),
+ [currentYear],
+ );
+
+ const { confirm } = useConfirm();
+ const [sido, setSido] = useState("");
+ const [gugun, setGugun] = useState("");
+ const [year, setYear] = useState(String(currentYear));
+ const [searchParams, setSearchParams] = useState(null);
+ const [rowSelection, setRowSelection] = useState |