- | 설계자 |
- 이름 |
+
+ 설계자
+ |
+ 이름 |
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;
@@ -124,6 +125,156 @@ 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년도 진행분 결제 요청 입니다."*/,
+ };
+ // ElaprDmndApi 서비스를 통해 백엔드 연동
+ await ElaprDmndApi.create(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 +289,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 +319,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 +332,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 +359,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 +382,7 @@ function CnfmViewPage({ id }: { id: string }) {
} as unknown as InstlSncnApplySaveRequestDto;
try {
- const issuedDocNo = await InstlSncnApi.techReview(payload);
+ const issuedDocNo = await InstlSncnApi.techReview(det.instlSncnDocNo, payload);
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("기술검토 등록 API 호출 실패", error);
@@ -239,7 +399,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 +428,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 +491,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 +589,9 @@ function CnfmViewPage({ id }: { id: string }) {
{bizSclList.length === 0 ? (
- | 조회된 사업규모 항목이 없습니다. |
+
+ 조회된 사업규모 항목이 없습니다.
+ |
) : (
bizSclList.map((node: any) => {
@@ -386,12 +601,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 +634,9 @@ function CnfmViewPage({ id }: { id: string }) {
{/* 총사업비 */}
- 총사업비 (단위: 백만원)
+
+ 총사업비 (단위: 백만원)
+
@@ -423,7 +656,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 +670,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 +704,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 +717,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 +767,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 +878,7 @@ function CnfmViewPage({ id }: { id: string }) {
- | 필수 자료명 |
+ 필수 자료명 |
파일명 |
크기 |
@@ -636,7 +886,9 @@ function CnfmViewPage({ id }: { id: string }) {
{esntlFileList.length === 0 ? (
- | 조회된 필수 첨부파일 항목이 없습니다. |
+
+ 조회된 필수 첨부파일 항목이 없습니다.
+ |
) : (
esntlFileList.map((f: any) => (
@@ -644,17 +896,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 +940,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 +973,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..de262cf62
--- /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", "EE"] as const;
+
+const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
+ string,
+ Record
+> = {
+ ER: {
+ ATRZ01: [INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr],
+ },
+ EE: {
+ 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..53480aa76
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts
@@ -0,0 +1,120 @@
+import type {
+ CklstRvwRsltTreeDto,
+ InstlSncnApplySaveRequestDto,
+ InstlSncnCklstResultSaveDto,
+ InstlSncnMngResponseDto,
+ ReviewStepCode,
+} from "@/entities/instl-sncn";
+
+import {
+ type ChecklistFormState,
+ type ChecklistRowKey,
+ type ChecklistStepMap,
+ INSTL_PROGRESS_ORDER,
+ REVIEW_STEP_CODES,
+ REVIEW_STEP_ROLE_RANK,
+ ROLE_TO_REVIEW_STEP,
+} from "../model/view-types";
+
+export function buildChecklistRowKey(row: CklstRvwRsltTreeDto): ChecklistRowKey {
+ return `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
+}
+
+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 getProgressStepCount(prgrsSttsComCd?: string): number {
+ const idx = INSTL_PROGRESS_ORDER.indexOf(
+ (prgrsSttsComCd ?? "ST") as (typeof INSTL_PROGRESS_ORDER)[number],
+ );
+ if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1;
+ if (idx <= INSTL_PROGRESS_ORDER.indexOf("TP")) 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 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()
+ .sort(
+ (a, b) =>
+ (a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) ||
+ (a.sortSeq ?? 0) - (b.sortSeq ?? 0) ||
+ (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
+ );
+}
+
+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 };
+}
+
+export function toCklstSaveDto(
+ rows: CklstRvwRsltTreeDto[],
+ formState: ChecklistFormState,
+ reviewStep: ReviewStepCode,
+): InstlSncnCklstResultSaveDto[] {
+ return rows
+ .filter((row) => row.bchckUseYn === "Y")
+ .map((row) => {
+ const key = buildChecklistRowKey(row);
+ const selected =
+ formState.results[key] ??
+ (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N" ? row.rvwRsltAnmlyYn : null);
+ return {
+ cklstSn: row.cklstSn,
+ cklstArtclSn: row.cklstArtclSn,
+ cklstRvwSn: row.cklstRvwSn,
+ rvwStpComCd: reviewStep,
+ selected,
+ picRvwCn: formState.comments[key] ?? row.picRvwCn ?? null,
+ };
+ });
+}
+
+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/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..6110d7be7
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts
@@ -0,0 +1,118 @@
+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): CnfmRegistFormData {
+ return {
+ instlSncnMng: {
+ bizNm: "",
+ bizBgngYr: "",
+ bizEndYr: "",
+ bizCn: "",
+ 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/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/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..0a555e8a4
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/model/view-types.ts
@@ -0,0 +1,75 @@
+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 ChecklistFormState {
+ results: Record;
+ comments: Record;
+}
+
+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 = [
+ "SV",
+ "ST",
+ "LE",
+ "ER",
+ "EE",
+ "TE",
+ "TS",
+ "TR",
+ "TP",
+ "EP",
+ "TA",
+ "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({});
+
+ const { data: sidoData } = useFindSidoCombo();
+ const { data: gugunData } = useFindGugunCombo(sido);
+ const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
+ const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
+
+ const listQueryParams = searchParams ?? { cPage: 1 };
+
+ const listQuery = useBnsList(listQueryParams, {
+ enabled: searchParams !== null,
+ });
+
+ const pageQuery = useMemo(
+ () =>
+ ({
+ isPending: listQuery.isPending,
+ isError: listQuery.isError,
+ data:
+ listQuery.data && searchParams
+ ? toBnsListPageResponse(listQuery.data, searchParams.cPage)
+ : undefined,
+ }) as UseQueryResult>,
+ [listQuery.isPending, listQuery.isError, listQuery.data, searchParams],
+ );
+
+ const columns = useMemo(() => createBnsListColumns(), []);
+
+ const pagination = useMemo(
+ () => ({
+ pageIndex: Math.max(0, (searchParams?.cPage ?? 1) - 1),
+ pageSize: BNS_LIST_PAGE_SIZE,
+ }),
+ [searchParams?.cPage],
+ );
+
+ const { tableComponent } = useDataTable({
+ query: pageQuery,
+ tableOptions: {
+ columns,
+ state: { pagination, rowSelection },
+ enableRowSelection: true,
+ enableMultiRowSelection: false,
+ getRowId: (row) => getBnsListRowKey(row),
+ onRowSelectionChange: (updater) => {
+ const next = typeof updater === "function" ? updater(rowSelection) : updater;
+ setRowSelection(next);
+ },
+ onPaginationChange: (updater) => {
+ if (!searchParams) return;
+ const next = typeof updater === "function" ? updater(pagination) : updater;
+ setRowSelection({});
+ setSearchParams({
+ ...searchParams,
+ cPage: next.pageIndex + 1,
+ });
+ },
+ getCoreRowModel: getCoreRowModel(),
+ manualPagination: true,
+ },
+ dataTableOptions: {
+ viewPagination: true,
+ onRowClick: (row) => {
+ setRowSelection({ [getBnsListRowKey(row)]: true });
+ },
+ },
+ });
+
+ const selectedRow = useMemo(() => {
+ const selectedId = Object.keys(rowSelection)[0];
+ if (!selectedId) return null;
+ return listQuery.data?.list?.find((row) => getBnsListRowKey(row) === selectedId) ?? null;
+ }, [rowSelection, listQuery.data?.list]);
+
+ const detailGbn = selectedRow ? String(selectedRow.gbn) : "";
+ const detailYr = selectedRow?.yr ?? "";
+ const detailSeqNo = selectedRow?.seqNo ?? "";
+
+ const {
+ data: detailData,
+ isPending: isDetailPending,
+ isError: isDetailError,
+ } = useBnsDetail(detailGbn, detailYr, detailSeqNo);
+
+ const detail = detailData?.detail;
+
+ useEffect(() => {
+ if (!open) {
+ resetState();
+ }
+ }, [open]);
+
+ function resetState() {
+ setSido("");
+ setGugun("");
+ setYear(String(currentYear));
+ setSearchParams(null);
+ setRowSelection({});
+ }
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ onClose();
+ }
+ }
+
+ function handleSearch() {
+ setRowSelection({});
+ setSearchParams(
+ buildBnsSearchDto({
+ cPage: 1,
+ searchStartYear: year,
+ searchSido: sido || undefined,
+ searchGugun: gugun || undefined,
+ }),
+ );
+ }
+
+ async function handleApply() {
+ if (!selectedRow || !detail) return;
+
+ if (
+ !(await confirm(
+ "해당 사업은 예산신청 당시의 정보로\n확정예산 정보와는 다를 수 있습니다.\n적용하시겠습니까?",
+ ))
+ )
+ return;
+
+ onSelect({
+ bizNm: selectedRow.bzNm,
+ bizBgngYr: selectedRow.strtYr,
+ bizEndYr: selectedRow.edYr,
+ bizCn: detail.bssCont,
+ bgtBizCd: selectedRow.bzCd,
+ bgtAplyYr: selectedRow.yr,
+ });
+ onClose();
+ }
+
+ return (
+
+ );
+}
+
+function BnsBasicInfoTable({ detail }: { detail: BnsDetailResponseDto }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function BnsOperationTable({ detail }: { detail: BnsDetailResponseDto }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface DetailPairRowProps {
+ leftLabel: string;
+ leftValue?: string;
+ rightLabel: string;
+ rightValue?: string;
+}
+
+function DetailPairRow({ leftLabel, leftValue, rightLabel, rightValue }: DetailPairRowProps) {
+ return (
+
+ {leftLabel}
+ {leftValue || "-"}
+ {rightLabel}
+ {rightValue || "-"}
+
+ );
+}
+
+interface DetailFullRowProps {
+ label: string;
+ value?: string;
+}
+
+function DetailFullRow({ label, value }: DetailFullRowProps) {
+ return (
+
+ {label}
+ {value || "-"}
+
+ );
+}
+
+function DetailSkeleton() {
+ return (
+
+
+
+
+
+ );
+}
+
+export { BnsPickerDialog };
+export type { BnsApplyData };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/checklist-task-panel.tsx b/apps/as/src/pages/instl-sncn/ui/components/checklist-task-panel.tsx
new file mode 100644
index 000000000..9ba74c0ec
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/components/checklist-task-panel.tsx
@@ -0,0 +1,156 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
+import {
+ cn,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import { buildChecklistRowKey } from "../../lib/checklist-utils";
+import { type ChecklistFormState, ROLE_TO_REVIEW_STEP } from "../../model/view-types";
+
+interface ChecklistTaskPanelProps {
+ selectedReviewStep: ReviewStepCode;
+ selectedRows: CklstRvwRsltTreeDto[];
+ formState: ChecklistFormState;
+ setFormState: Dispatch>;
+ userRoleCode?: string;
+}
+
+function ChecklistTaskPanel({
+ selectedReviewStep,
+ selectedRows,
+ formState,
+ setFormState,
+ userRoleCode,
+}: ChecklistTaskPanelProps) {
+ const currentUserReviewStep = userRoleCode ? ROLE_TO_REVIEW_STEP[userRoleCode] : undefined;
+ const isStepEditable = currentUserReviewStep === selectedReviewStep;
+
+ return (
+ <>
+
+ >
+ );
+}
+
+export { ChecklistTaskPanel };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-picker-dialog.tsx b/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-picker-dialog.tsx
new file mode 100644
index 000000000..d388c4791
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/components/elapr-tmplt-picker-dialog.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { Fragment } from "react";
+
+import { type ElaprTmplt, useElaprAplnPage, useElaprTmpltPage } from "@/entities/elapr";
+import {
+ Badge,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+const PAGE_SIZE = 100;
+
+interface ElaprTmpltPickerDialogProps {
+ open: boolean;
+ onClose: () => void;
+ onSelect: (tmplt: ElaprTmplt) => void;
+}
+
+function ElaprTmpltPickerDialog({ open, onClose, onSelect }: ElaprTmpltPickerDialogProps) {
+ const { data, isPending, isError } = useElaprTmpltPage({
+ page: 0,
+ size: PAGE_SIZE,
+ sort: "regDt,desc",
+ docSeComCd: "INSTL",
+ });
+
+ const items = data?.content ?? [];
+
+ function handleSelect(tmplt: ElaprTmplt) {
+ onSelect(tmplt);
+ onClose();
+ }
+
+ return (
+
+ );
+}
+
+function ElaprAplnCell({ elaprTmpltId }: { elaprTmpltId: string }) {
+ const { data } = useElaprAplnPage({
+ elaprTmpltId,
+ page: 0,
+ size: PAGE_SIZE,
+ sort: "atrzSeqNo,asc",
+ });
+
+ const items = data?.content ?? [];
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.atrzSeComNm}
+ {item.aprvrNm}
+
+
+ ))}
+
+ );
+}
+
+export { ElaprTmpltPickerDialog };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
new file mode 100644
index 000000000..39eb698a1
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx
@@ -0,0 +1,456 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { useConfirm } from "@frontend-opensource/use-react-hooks";
+import { useQueryClient } from "@tanstack/react-query";
+
+import { useAlertStore, useUserStore } from "@/shared/lib";
+import { ElaprDmndApi, ElaprDmndSaveRequest, type ElaprTmplt } from "@/entities/elapr";
+import { instlSncnKeys, type InstlSncnMngDetResponse } from "@/entities/instl-sncn";
+import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
+import { Button } from "@common/components";
+
+import {
+ getButtonConfigs,
+ INSTL_SNCN_VIEW_BUTTON_ACTION,
+ type InstlSncnViewButtonAction,
+ needsRoleUser01AtrzResolve,
+} from "../../lib/button-config";
+
+import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog";
+import { TextInputDialog } from "./text-input-dialog";
+
+interface FloatingActionButtonsProps {
+ data: InstlSncnMngDetResponse;
+ instlSncnDocNo: string;
+ prgrsSttsComCd: string;
+ dtlInstCd: string | undefined;
+}
+
+type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | null;
+
+function FloatingActionButtons({
+ data,
+ instlSncnDocNo,
+ prgrsSttsComCd,
+ dtlInstCd,
+}: FloatingActionButtonsProps) {
+ const router = useRouter();
+ const { confirm } = useConfirm();
+ const queryClient = useQueryClient();
+ const userInfo = useUserStore((state) => state.userInfo);
+ const alert = useAlertStore((state) => state.setMessage);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [activeDialog, setActiveDialog] = useState(null);
+
+ const isRegister = data.detDto.rgtr === userInfo?.userUnqId;
+
+ const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
+ const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
+ {},
+ { enabled: needsAtrzResolve },
+ );
+
+ const atrzSeComCdList = useMemo(
+ () => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
+ [rvwLoginUserData],
+ );
+
+ const actionButtons = useMemo(() => {
+ if (needsAtrzResolve && isRvwLoginUserLoading) return [];
+ return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList);
+ }, [atrzSeComCdList, dtlInstCd, isRvwLoginUserLoading, needsAtrzResolve, prgrsSttsComCd]);
+
+ const canEdit =
+ isRegister && actionButtons.some((button) => button === INSTL_SNCN_VIEW_BUTTON_ACTION.strg);
+
+ function handleGoToList() {
+ router.push("/instl-sncn");
+ }
+
+ function handleEdit() {
+ router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
+ }
+
+ function shouldShowButton(action: InstlSncnViewButtonAction) {
+ return actionButtons.some((button) => button === action);
+ }
+
+ async function executeAction({
+ action,
+ request,
+ successMessage,
+ }: {
+ action: InstlSncnViewButtonAction;
+ request?: unknown;
+ successMessage?: string;
+ }) {
+ const handler = buttonActionHandlers[action];
+ if (!handler) return;
+
+ setIsProcessing(true);
+ try {
+ await handler({
+ request,
+ data,
+ onSuccess: () => {
+ alert(successMessage ?? "처리가 완료되었습니다.");
+ queryClient.invalidateQueries({
+ queryKey: instlSncnKeys.detail(instlSncnDocNo),
+ });
+ },
+ onError: (error) => {
+ alert(error.message || "처리 중 오류가 발생했습니다.");
+ },
+ });
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ async function handleDelClick() {
+ if (!(await confirm("정말 삭제하시겠습니까?"))) return;
+ await executeAction({
+ action: INSTL_SNCN_VIEW_BUTTON_ACTION.del,
+ successMessage: "삭제되었습니다.",
+ });
+ router.push("/instl-sncn");
+ }
+
+ function handleRjctClick() {
+ setActiveDialog("rjct");
+ }
+
+ async function handleRjctConfirm(rjctRsn: string) {
+ setActiveDialog(null);
+ await executeAction({
+ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
+ request: rjctRsn,
+ successMessage: "반려 처리되었습니다.",
+ });
+ }
+
+ async function handleElaprTmpltSelect(tmplt: ElaprTmplt) {
+ const payload = {
+ elaprDocNo: data.detDto.instlSncnDocNo,
+ elaprTmpltId: tmplt.elaprTmpltId,
+ dmndTtl: data.detDto.bizNm,
+ dmndCn: data.detDto.bizCn,
+ };
+ await executeAction({
+ action: INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr,
+ request: payload,
+ successMessage: "검토자가 지정되었습니다.",
+ });
+ }
+
+ function handleDmndSplmntClick() {
+ setActiveDialog("dmndSplmnt");
+ }
+
+ async function handleDmndSplmntConfirm(splmntCn: string) {
+ setActiveDialog(null);
+ await executeAction({
+ action: INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
+ request: splmntCn,
+ successMessage: "보완요청이 처리되었습니다.",
+ });
+ }
+
+ return (
+ <>
+
+
+
+ {canEdit && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.strg) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.aply) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.del) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.atrzAprv) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.atrzRply) && (
+
+ )}
+ {shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck) && (
+
+ )}
+
+
+ setActiveDialog(null)}
+ onSelect={handleElaprTmpltSelect}
+ />
+ setActiveDialog(null)}
+ title="반려 사유 입력"
+ placeholder="반려 사유를 입력하세요."
+ isSubmitting={isProcessing}
+ onConfirm={(value) => void handleRjctConfirm(value)}
+ />
+ setActiveDialog(null)}
+ title="보완요청 내용 입력"
+ placeholder="보완요청 내용을 입력하세요."
+ isSubmitting={isProcessing}
+ onConfirm={(value) => void handleDmndSplmntConfirm(value)}
+ />
+ >
+ );
+}
+
+export { FloatingActionButtons };
+
+async function handleStrg(context: ActionHandlerContext): Promise {
+ // TODO: 저장 API 호출
+ context.onSuccess?.();
+}
+
+async function handleAply(context: ActionHandlerContext): Promise {
+ // TODO: 신청완료 API 호출
+ context.onSuccess?.();
+}
+
+async function handleDel(context: ActionHandlerContext): Promise {
+ // TODO: 삭제 API 호출
+ context.onSuccess?.();
+}
+
+async function handleSplmntCmptn(context: ActionHandlerContext): Promise {
+ // TODO: 보완완료 API 호출
+ context.onSuccess?.();
+}
+
+async function handleInstlSncnAncmnt(context: ActionHandlerContext): Promise {
+ // TODO: 고시 처리 API 호출
+ context.onSuccess?.();
+}
+
+async function handleEnvoSbmsn(context: ActionHandlerContext): Promise {
+ // TODO: 환경청제출 API 호출
+ context.onSuccess?.();
+}
+
+async function handleRjct(context: ActionHandlerContext): Promise {
+ // TODO: 반려 API 호출
+ context.onSuccess?.();
+}
+
+async function handleSncnAprv(context: ActionHandlerContext): Promise {
+ // TODO: 인가승인 API 호출
+ context.onSuccess?.();
+}
+
+async function handleRvwRply(context: ActionHandlerContext): Promise {
+ // TODO: 검토결과 회신 API 호출
+ context.onSuccess?.();
+}
+
+async function handleRqstTechRvw(context: ActionHandlerContext): Promise {
+ // TODO: 기술검토 의뢰 API 호출
+ context.onSuccess?.();
+}
+
+async function handleDsgntRvwr(context: ActionHandlerContext): Promise {
+ try {
+ await ElaprDmndApi.create(context.request);
+ context.onSuccess?.();
+ } catch (error) {
+ context.onError?.(error as Error);
+ }
+}
+
+async function handleRvwStrg(context: ActionHandlerContext): Promise {
+ // TODO: 검토저장 API 호출
+ context.onSuccess?.();
+}
+
+async function handleRvwCmptn(context: ActionHandlerContext): Promise {
+ // TODO: 검토완료 API 호출
+ context.onSuccess?.();
+}
+
+async function handleDmndSplmnt(context: ActionHandlerContext): Promise {
+ // TODO: 보완요청 API 호출
+ context.onSuccess?.();
+}
+
+async function handleAtrzAprv(context: ActionHandlerContext): Promise {
+ // TODO: 결재 승인 처리 API 호출
+ context.onSuccess?.();
+}
+
+async function handleAtrzRply(context: ActionHandlerContext): Promise {
+ // TODO: 결재 회신 처리 API 호출
+ context.onSuccess?.();
+}
+
+async function handleGrndsChck(context: ActionHandlerContext): Promise {
+ // TODO: 현장점검 등록 API 호출
+ context.onSuccess?.();
+}
+
+const buttonActionHandlers: Record = {
+ strg: handleStrg,
+ aply: handleAply,
+ del: handleDel,
+ splmntCmptn: handleSplmntCmptn,
+ instlSncnAncmnt: handleInstlSncnAncmnt,
+ envoSbmsn: handleEnvoSbmsn,
+ rjct: handleRjct,
+ sncnAprv: handleSncnAprv,
+ rvwRply: handleRvwRply,
+ rqstTechRvw: handleRqstTechRvw,
+ dsgntRvwr: handleDsgntRvwr,
+ rvwStrg: handleRvwStrg,
+ rvwCmptn: handleRvwCmptn,
+ dmndSplmnt: handleDmndSplmnt,
+ atrzAprv: handleAtrzAprv,
+ atrzRply: handleAtrzRply,
+ grndsChck: handleGrndsChck,
+};
+
+interface ActionHandlerContext {
+ request: T;
+ data: InstlSncnMngDetResponse;
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+}
+
+type ActionHandler = (context: ActionHandlerContext) => Promise;
diff --git a/apps/as/src/pages/instl-sncn/ui/components/progress-step-indicator.tsx b/apps/as/src/pages/instl-sncn/ui/components/progress-step-indicator.tsx
new file mode 100644
index 000000000..e3cff5d35
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/components/progress-step-indicator.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { Fragment } from "react";
+
+import { Badge } from "@common/components";
+
+interface ProgressStepIndicatorProps {
+ prgrsSttsComCd: string;
+ prgrsSttsCdNm: string;
+}
+
+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 ProgressStepIndicator({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepIndicatorProps) {
+ const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm);
+
+ return (
+
+ 진행 단계
+
+ {badges.map((badge, index) => (
+
+ {index > 0 && >}
+ {badge.label}
+
+ ))}
+
+
+ );
+}
+
+export { ProgressStepIndicator };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/section-collapsible.tsx b/apps/as/src/pages/instl-sncn/ui/components/section-collapsible.tsx
new file mode 100644
index 000000000..2410ff325
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/components/section-collapsible.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { ChevronDown } from "lucide-react";
+
+import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@common/components";
+
+interface SectionCollapsibleProps {
+ title: ReactNode;
+ defaultOpen?: boolean;
+ children: ReactNode;
+}
+
+function SectionCollapsible({ title, defaultOpen = true, children }: SectionCollapsibleProps) {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
+export { SectionCollapsible };
diff --git a/apps/as/src/pages/instl-sncn/ui/components/text-input-dialog.tsx b/apps/as/src/pages/instl-sncn/ui/components/text-input-dialog.tsx
new file mode 100644
index 000000000..168012416
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/components/text-input-dialog.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Textarea,
+} from "@common/components";
+
+interface TextInputDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ placeholder?: string;
+ defaultValue?: string;
+ isSubmitting?: boolean;
+ onConfirm: (text: string) => void;
+}
+
+function TextInputDialog({
+ open,
+ onOpenChange,
+ title,
+ placeholder,
+ defaultValue,
+ isSubmitting,
+ onConfirm,
+}: TextInputDialogProps) {
+ const [value, setValue] = useState("");
+
+ useEffect(() => {
+ if (!open) return;
+ setValue(defaultValue ?? "");
+ }, [defaultValue, open]);
+
+ function handleConfirm() {
+ onConfirm(value.trim());
+ }
+
+ return (
+
+ );
+}
+
+export { TextInputDialog };
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-edit-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-edit-page.tsx
new file mode 100644
index 000000000..ba18c95af
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/instl-edit-page.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useInstlSncnDetail } from "@/entities/instl-sncn";
+
+import { InstlFormPage } from "./instl-form-page";
+
+interface InstlEditPageProps {
+ id: string;
+}
+
+function InstlEditPage({ id }: InstlEditPageProps) {
+ const { data, isLoading, isError } = useInstlSncnDetail(id);
+
+ if (isLoading) {
+ return 수정 정보를 불러오는 중... ;
+ }
+
+ if (isError || !data) {
+ return 수정 대상을 찾을 수 없습니다. ;
+ }
+
+ return ;
+}
+
+export { InstlEditPage };
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx
new file mode 100644
index 000000000..41bc100ab
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+import { useRouter } from "next/navigation";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import { useAlertStore, useUserStore } from "@/shared/lib";
+import { InstlSncnApi, type InstlSncnMngDetResponse } from "@/entities/instl-sncn";
+import { Button } from "@common/components";
+
+import {
+ type CnfmRegistFormData,
+ cnfmRegistSchema,
+ formatTaskSeComCdForApi,
+} from "../lib/cnfm-regist-schema";
+import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
+
+import { AttachmentsFormSection } from "./sections/attachments-form-section";
+import { BasicInfoFormSection } from "./sections/basic-info-form-section";
+import { BusinessCostFormSection } from "./sections/business-cost-form-section";
+import { BusinessScaleFormSection } from "./sections/business-scale-form-section";
+import { ChecklistFormSection } from "./sections/checklist-form-section";
+import { ContactInfoFormSection } from "./sections/contact-info-form-section";
+import { PrivacyConsentSection } from "./sections/privacy-consent-section";
+
+interface InstlFormPageProps {
+ mode: "create" | "edit";
+ initialData?: InstlSncnMngDetResponse;
+}
+
+function InstlFormPage({ mode, initialData }: InstlFormPageProps) {
+ const router = useRouter();
+ const userInfo = useUserStore((state) => state.userInfo);
+ const alert = useAlertStore((state) => state.setMessage);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const isEditMode = mode === "edit";
+
+ const form = useForm({
+ resolver: zodResolver(cnfmRegistSchema),
+ mode: "onChange",
+ defaultValues:
+ isEditMode && initialData
+ ? mapDetailToFormData(initialData)
+ : getCreateDefaultValues(userInfo),
+ });
+
+ async function onSave(data: CnfmRegistFormData) {
+ if (isSubmitting) return;
+
+ try {
+ setIsSubmitting(true);
+
+ const { taskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
+
+ const payload = {
+ instlSncnMng: {
+ ...instlSncnMngRest,
+ instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
+ taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
+ prgrsSttsComCd: "SV",
+ },
+ bzScaleList: data.bzScaleList,
+ bizCstList: data.bizCstList,
+ cklstResultList: data.cklstResultList,
+ esntlAtchFileList: data.esntlAtchFileList,
+ };
+
+ const instlSncnDocNo = await InstlSncnApi.saveApply(payload);
+
+ alert(isEditMode ? "수정되었습니다." : "저장되었습니다.");
+ router.push(`/instl-sncn/${instlSncnDocNo}`);
+ } catch (error) {
+ console.error(isEditMode ? "수정 실패:" : "저장 실패:", error);
+ alert(isEditMode ? "수정 중 오류가 발생했습니다." : "저장 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { InstlFormPage };
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-list-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-list-page.tsx
new file mode 100644
index 000000000..fe3bb4417
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/instl-list-page.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useState } from "react";
+
+import type { PaginationState } from "@tanstack/react-table";
+
+import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
+import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
+import { CodeSelect } from "@/entities/code";
+import type { InstlSncnMngSearchParams } from "@/entities/instl-sncn";
+import {
+ Button,
+ Input,
+ Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@common/components";
+
+import { InstlList } from "./instl-list";
+
+const DEFAULT_PAGE_INDEX = 0;
+const DEFAULT_PAGE_SIZE = 10;
+const DEFAULT_SORT = "instlSncnDocNo,desc";
+const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
+
+const DEFAULT_SEARCH_PARAMS: InstlSncnMngSearchParams = {
+ bizNm: "",
+ taskSeComCd: "",
+ sidoComCd: "",
+ sggComCd: "",
+ page: DEFAULT_PAGE_INDEX,
+ size: DEFAULT_PAGE_SIZE,
+ sort: DEFAULT_SORT,
+};
+
+function InstlListPage() {
+ const [bizNm, setBizNm] = useState("");
+ const [taskSeComCd, setTaskSeComCd] = useState("");
+ const [sidoComCd, setSidoComCd] = useState("");
+ const [sggComCd, setSggComCd] = useState("");
+ const [pagination, setPagination] = useState({
+ pageIndex: DEFAULT_PAGE_INDEX,
+ pageSize: DEFAULT_PAGE_SIZE,
+ });
+ const [searchParams, setSearchParams] = useState(DEFAULT_SEARCH_PARAMS);
+
+ const { data: sidoData } = useFindSidoCombo();
+ const { data: gugunData } = useFindGugunCombo(sidoComCd);
+ const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
+ const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
+
+ const handleSearch = (event: React.FormEvent) => {
+ event.preventDefault();
+ setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
+ setSearchParams({
+ ...DEFAULT_SEARCH_PARAMS,
+ bizNm,
+ taskSeComCd,
+ sidoComCd,
+ sggComCd,
+ });
+ };
+
+ return (
+
+ );
+}
+
+export { InstlListPage };
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-list.tsx b/apps/as/src/pages/instl-sncn/ui/instl-list.tsx
new file mode 100644
index 000000000..4f3660dcc
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/instl-list.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useMemo } from "react";
+import { useRouter } from "next/navigation";
+
+import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
+
+import { useDataTable } from "@/shared/lib";
+import { type InstlSncnMngSearchParams, useInstlSncnList } from "@/entities/instl-sncn";
+import { Button } from "@common/components";
+
+import { createInstlSncnColumns } from "../model/columns";
+
+interface InstlListProps {
+ searchParams: InstlSncnMngSearchParams;
+ pagination: PaginationState;
+ onPaginationChange: (pagination: PaginationState) => void;
+}
+
+function InstlList({ searchParams, pagination, onPaginationChange }: InstlListProps) {
+ const router = useRouter();
+
+ const queryParams = useMemo(
+ () => ({
+ bizNm: searchParams.bizNm,
+ taskSeComCd: searchParams.taskSeComCd,
+ sidoComCd: searchParams.sidoComCd,
+ sggComCd: searchParams.sggComCd,
+ page: pagination.pageIndex,
+ size: pagination.pageSize,
+ sort: searchParams.sort ?? "regDt,desc",
+ }),
+ [searchParams, pagination],
+ );
+
+ const pageQuery = useInstlSncnList(queryParams);
+ const columns = useMemo(() => createInstlSncnColumns(), []);
+
+ const { tableComponent } = useDataTable({
+ query: pageQuery,
+ tableOptions: {
+ columns,
+ state: { pagination },
+ onPaginationChange: (updater) => {
+ const next = typeof updater === "function" ? updater(pagination) : updater;
+ onPaginationChange(next);
+ },
+ getCoreRowModel: getCoreRowModel(),
+ manualPagination: true,
+ },
+ dataTableOptions: {
+ viewPagination: true,
+ onRowClick: (row) => router.push(`/instl-sncn/${row.instlSncnDocNo}`),
+ },
+ });
+
+ const handleRegisterClick = () => {
+ router.push("/instl-sncn/regist");
+ };
+
+ return (
+
+
+ 설치인가 목록
+
+
+ {tableComponent}
+
+ );
+}
+
+export { InstlList };
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx
new file mode 100644
index 000000000..ba4044e8a
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx
@@ -0,0 +1,7 @@
+import { InstlFormPage } from "./instl-form-page";
+
+function InstlRegistPage() {
+ return ;
+}
+
+export { InstlRegistPage };
diff --git a/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx
new file mode 100644
index 000000000..4a8886dd1
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useUserStore } from "@/shared/lib";
+import { useInstlSncnDetail } from "@/entities/instl-sncn";
+import { Skeleton } from "@common/components";
+
+import { FloatingActionButtons } from "./components/floating-action-buttons";
+import { AttachmentsSection } from "./sections/attachments-section";
+import { BasicInfoSection } from "./sections/basic-info-section";
+import { BusinessCostSection } from "./sections/business-cost-section";
+import { BusinessScaleSection } from "./sections/business-scale-section";
+import { ChecklistSection } from "./sections/checklist-section";
+import { ContactInfoSection } from "./sections/contact-info-section";
+import { TechReviewOpinionSection } from "./sections/tech-review-opinion-section";
+
+interface InstlViewPageProps {
+ id: string;
+}
+
+function InstlViewPage({ id }: InstlViewPageProps) {
+ const userInfo = useUserStore((state) => state.userInfo);
+ const { data, isLoading, isError } = useInstlSncnDetail(id);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return 상세 정보를 찾을 수 없습니다. ;
+ }
+
+ const {
+ detDto,
+ bizSclList,
+ bizCstList,
+ cklstResultList = [],
+ esntlFileList,
+ etcFileList,
+ rvwList = [],
+ } = data;
+
+ return (
+
+ );
+}
+
+export { InstlViewPage };
diff --git a/apps/as/src/pages/instl-sncn/ui/sections/approval-info-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/approval-info-section.tsx
new file mode 100644
index 000000000..3da85e7f7
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/sections/approval-info-section.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { Button } from "@common/components";
+
+import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
+import { ElaprDmndApi } from "@/entities/elapr/api/elapr-dmnd-service";
+import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
+import { dayjs, useAlertStore, useUserStore } from "@/shared/lib";
+
+import { getApproverStatus, sortByAtrzSeqNo } from "../../lib/approval-utils";
+
+interface ApprovalInfoSectionProps {
+ docNo: string;
+ docTitle: string;
+ docContent: string;
+ elaprDocPrgrsList: ElaprDocPrgrsClientResponseDto[];
+ onRefresh: () => Promise;
+}
+
+const DEFAULT_APPROVER_COLUMNS = [
+ { code: "ATRZ01", label: "접수자" },
+ { code: "ATRZ02", label: "검토자" },
+ { code: "ATRZ03", label: "승인자" },
+ { code: "ATRZ99", label: "최종승인자" },
+] as const;
+
+function ApprovalInfoSection({
+ docNo,
+ docTitle,
+ docContent,
+ elaprDocPrgrsList,
+ onRefresh,
+}: ApprovalInfoSectionProps) {
+ const alert = useAlertStore((state) => state.setMessage);
+ const userInfo = useUserStore((state) => state.userInfo);
+
+ const orderedApprovers = sortByAtrzSeqNo(elaprDocPrgrsList);
+
+ const renderApproverCell = (row: ElaprDocPrgrsClientResponseDto | undefined) => {
+ if (!row) return - ;
+
+ const status = row.atrzPrgrsSttsComCd ?? "";
+ const st = getApproverStatus(status);
+ const canApprove =
+ status === "ATRZ_PRGRS_002" &&
+ row.elaprPrgrsId &&
+ userInfo?.userUnqId === row.aprvrUuid;
+
+ return (
+
+ {row.aprvrUuidNm ?? "-"}
+
+ {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}
+
+ {canApprove ? (
+
+ ) : null}
+
+
+ );
+ };
+
+ const handleApprove = async (elaprPrgrsId: string) => {
+ try {
+ await processElaprPrgrs(elaprPrgrsId, "ATRZ_PRGRS_003");
+ alert("결재가 완료되었습니다.");
+ await onRefresh();
+ } catch {
+ alert("결재 처리 중 오류가 발생했습니다.");
+ }
+ };
+
+ const handleAssignApprover = async () => {
+ try {
+ await ElaprDmndApi.create({
+ elaprDocNo: docNo,
+ elaprTmpltId: "TMPLT20260611000001",
+ dmndTtl: docTitle,
+ dmndCn: docContent,
+ });
+ alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
+ await onRefresh();
+ } catch {
+ alert("결재자 지정 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+
+ 결재 정보
+
+
+
+
+ {orderedApprovers.length === 0
+ ? DEFAULT_APPROVER_COLUMNS.map((col) => (
+ |
+ {col.label}
+ |
+ ))
+ : orderedApprovers.map((row) => (
+
+ {row.atrzSeComNm ?? row.atrzSeComCd}
+ |
+ ))}
+
+
+
+
+ {orderedApprovers.length === 0
+ ? DEFAULT_APPROVER_COLUMNS.map((col) => (
+ |
+ {renderApproverCell(
+ elaprDocPrgrsList.find((row) => row.atrzSeComCd === col.code),
+ )}
+ |
+ ))
+ : orderedApprovers.map((row) => (
+
+ {renderApproverCell(row)}
+ |
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+export { ApprovalInfoSection };
diff --git a/apps/as/src/pages/instl-sncn/ui/sections/attachments-form-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/attachments-form-section.tsx
new file mode 100644
index 000000000..04928b1fb
--- /dev/null
+++ b/apps/as/src/pages/instl-sncn/ui/sections/attachments-form-section.tsx
@@ -0,0 +1,492 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import { useQueryClient } from "@tanstack/react-query";
+
+import { ENDPOINT } from "@/shared/config";
+import { getDefaultFileKey, useTus } from "@/shared/lib";
+import { deleteAttachment } from "@/shared/lib/attach";
+import { useEsntlAtchFileMngTree } from "@/entities/esntl";
+import type {
+ AtflMngClientResponseDto,
+ EsntlAtchFileMngTreeResponseDto,
+} from "@/entities/instl-sncn";
+import {
+ Button,
+ Skeleton,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import type { CnfmRegistFormData } from "../../lib/cnfm-regist-schema";
+import { SectionCollapsible } from "../components/section-collapsible";
+
+interface AttachmentsFormSectionProps {
+ docSeComCd: string;
+ initialEsntlFiles?: EsntlAtchFileMngTreeResponseDto[];
+ initialEtcFiles?: AtflMngClientResponseDto[];
+}
+
+interface UploadedFileItem {
+ file?: File;
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface EtcFileItem {
+ file?: File;
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface UploadMapping {
+ esntlAtchFileId: string;
+ file: File;
+}
+
+const ETC_FILE_KEY_PREFIX = "etc::";
+
+function createUploadKey(esntlAtchFileId: string, file: File) {
+ return `${esntlAtchFileId}::${getDefaultFileKey(file)}`;
+}
+
+function createEtcUploadKey(file: File) {
+ return `${ETC_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
+}
+
+function isEtcFileKey(fileKey: string) {
+ return fileKey.startsWith(ETC_FILE_KEY_PREFIX);
+}
+
+function AttachmentsFormSection({
+ docSeComCd,
+ initialEsntlFiles,
+ initialEtcFiles,
+}: AttachmentsFormSectionProps) {
+ const { control, getValues, setValue } = useFormContext();
+ const queryClient = useQueryClient();
+ const [uploadedFiles, setUploadedFiles] = useState>({});
+ const [etcFiles, setEtcFiles] = useState([]);
+ const initialFilesMappedRef = useRef(false);
+
+ const uploadMappingRef = useRef |