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

This commit is contained in:
2026-06-04 14:43:12 +09:00
3 changed files with 167 additions and 25 deletions
@@ -29,6 +29,13 @@ export class InstlSncnApiService implements InstlSncnMngService {
);
return response.data;
}
public async techReview(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`,
payload,
);
return response.data;
}
}
export const InstlSncnApi = new InstlSncnApiService();
@@ -159,7 +159,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
// userInfo?.dtlInstCd 테스트 코드 ENV.ROLE_USER03 , KECO 권한 테스트용 (실제 권한에 맞게 변경 필요)
const userInfo = {
...userInfo2,
dtlInstCd: "ROLE_USER05", //ROLE_USER03, ROLE_USER02, ROLE_USER01 KECO 권한 테스트용 (실제 권한에 맞게 변경 필요)
dtlInstCd: "ROLE_USER03", //ROLE_USER03, ROLE_USER02, ROLE_USER01 KECO 권한 테스트용 (실제 권한에 맞게 변경 필요)
};
const alert = useAlertStore((state) => state.setMessage);
const qc = useQueryClient();
@@ -826,7 +826,8 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
targetStepCodes.forEach((stepCode) => {
const k = cklstKey(row, stepCode);
const v = lookup.get(k);
if (v && next[k] !== v) {
// only apply server-provided value when we don't already have a user-entered value
if (v && (next[k] === null || next[k] === undefined)) {
next[k] = v as CheckResultVal;
changed = true;
}
@@ -839,7 +840,8 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const next: Record<string, string> = { ...prev };
let changed = false;
for (const [k, v] of commentLookup.entries()) {
if (next[k] !== v) {
// only set comment if none exists yet (preserve user edits)
if (!next[k]) {
next[k] = v;
changed = true;
}
@@ -1122,7 +1124,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
return true;
}
const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"): InstlSncnMng => ({
const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"| "ER"): InstlSncnMng => ({
instlSncnDocNo: docId ?? "", // 수정 시 기존 문서번호 유지, 신규 저장 시 빈값
admdstCd: "",
sidoNm: "",
@@ -1180,16 +1182,28 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
}
}
function handleSubmit() {
if (!fnValidation("PRESENTN")) return;
const payload = {
instlSncnMng: toInstlSncnMngPayload("ST"),
async function handleSubmit() {
if (!fnValidation("SAVE")) return;
const payload: InstlSncnApplySaveRequestDto = {
instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장)
bzScaleList: toBzScaleSaveDtoList(),
bizCstList: toBizCstSaveDtoList(),
cklstRvwRsltList: toCklstSaveDtoList(),
};
void payload;
alert("제출 API 연동 후 progrsSttus=ST 전송");
cklstResultList: toCklstSaveDtoList(),
esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
};
try {
const issuedDocNo = await InstlSncnApi.saveApply(payload);
alert(`제출 되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("저장 API 호출 실패", error);
const serverMessage =
axios.isAxiosError(error) && error.response?.data?.message
? (error.response.data as { message: string }).message
: "저장 중 오류가 발생했습니다.";
alert(serverMessage);
}
}
/* ---------------------------------------------------------------- */
@@ -7,10 +7,12 @@ import { Button } from "@common/components";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { InstlSncnApi } from "@/entities/instl-sncn";
// InstlSncnApi imported below with types
import { PROXY_HOST } from "@/shared/config";
import { formatFileSize } from "@/shared/lib/file-utils";
import { dayjs, useUserStore } from "@/shared/lib";
import axios from "axios";
import { dayjs, useUserStore, useAlertStore } from "@/shared/lib";
import { InstlSncnApi, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
import "./cnfm-page.css";
@@ -55,6 +57,13 @@ const getRoleStepCount = (dtlInstCd?: string): number => {
return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
};
// 업무구분 코드 -> 표시명 매핑 (체크리스트 그룹 표시용)
const TASK_CODE_LABELS: Record<string, string> = {
RE: "재이용수",
SP: "하수관로",
SF: "하수처리시설",
};
const normalizeChecklistResultMap = (
raw: unknown,
): Record<ReviewStepCode, any[]> => {
@@ -85,6 +94,8 @@ function CnfmViewPage({ id }: { id: string }) {
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const query = useQuery({
queryKey: ["instl-sncn-detail", id],
queryFn: () => InstlSncnApi.getById(id),
@@ -122,6 +133,13 @@ function CnfmViewPage({ id }: { id: string }) {
[visibleStepCount],
);
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>("LOCAL");
// selected task code for header filtering (null = none selected)
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
// 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)));
}, [cklstAll]);
useEffect(() => {
if (visibleReviewStepCodes.length === 0) return;
@@ -130,19 +148,97 @@ function CnfmViewPage({ id }: { id: string }) {
}
}, [selectedReviewStep, visibleReviewStepCodes]);
// when checklist result task codes change, default selectedTaskCode to first available code
useEffect(() => {
if (!taskCodes || taskCodes.length === 0) {
if (selectedTaskCode !== null) setSelectedTaskCode(null);
return;
}
if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
setSelectedTaskCode(taskCodes[0]);
}
}, [taskCodes, selectedTaskCode]);
const selectedChecklistRows = useMemo(() => {
if (visibleReviewStepCodes.length === 0) return [];
return cklstResultMap[selectedReviewStep] ?? [];
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length]);
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) =>
selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true,
);
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),
);
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length, selectedTaskCode]);
const [viewCheckResults, setViewCheckResults] = useState<Record<string, string>>({});
const [viewCheckComments, setViewCheckComments] = useState<Record<string, 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 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) ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}, [cklstResultMap, viewCheckResults, selectedReviewStep, selectedTaskCode]);
const fnValidation = (mode: "SAVE" | "SUBMIT") => {
// 기본 검증: 필요하면 확장
return true;
};
const hadleTechReviewSubmit = async () => {
if (!fnValidation("SAVE")) return;
const cklstResultList = (cklstAll ?? [])
.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;
return {
taskSeComCd: row.taskSeComCd ?? "",
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
rvwStpComCd: selectedReviewStep,
rvwRsltAnmlyYn,
picRvwCn,
};
});
const payload: InstlSncnApplySaveRequestDto = {
// only map core management DTO and checklist results; other lists empty per request
instlSncnMng: { ...(det ?? {}), prgrsSttsComCd: "ER" } as any,
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
} as unknown as InstlSncnApplySaveRequestDto;
try {
const issuedDocNo = await InstlSncnApi.techReview(payload);
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("기술검토 등록 API 호출 실패", error);
const serverMessage =
axios.isAxiosError(error) && error.response?.data?.message
? (error.response.data as { message: string }).message
: "저장 중 오류가 발생했습니다.";
alert(serverMessage);
}
};
useEffect(() => {
const results: Record<string, string> = {};
const comments: Record<string, string> = {};
(selectedChecklistRows ?? []).forEach((row: any) => {
const key = `${row.cklstSn}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
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.picRvwCn) comments[key] = String(row.picRvwCn);
});
@@ -385,9 +481,32 @@ function CnfmViewPage({ id }: { id: string }) {
</div>
{/* 체크리스트 */}
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<div className="mb-3 mt-2 flex flex-wrap gap-2">
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<div className="flex items-center gap-4">
<div className="flex gap-2 items-center">
{Array.from(new Set(taskCodes)).map((code) => {
const name = TASK_CODE_LABELS[code] ?? code;
const active = selectedTaskCode === code;
return (
<button
key={code}
type="button"
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
active ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
}`}
onClick={() => setSelectedTaskCode(code)}
>
{name}
</button>
);
})}
</div>
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{visibleReviewStepCodes.length === 0 ? (
<span className="text-[1.2rem] text-gray-500">
현재 사용자 권한/진행상태 조합에서 표시 가능한 체크리스트 단계가 없습니다.
@@ -409,20 +528,21 @@ function CnfmViewPage({ id }: { id: string }) {
))
)}
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> · 완료: <strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
</div>
<div className="cnfm-table-wrap mt-3">
<table className="cnfm-table">
<thead>
<tr>
<th className="w-[20rem]">검토항목</th>
<th>주요 검토내용</th>
<th className="w-[20rem]" rowSpan={2}>검토항목</th>
<th rowSpan={2}>주요 검토내용</th>
<th colSpan={2}>검토 결과(✓)</th>
{selectedReviewStep !== "LOCAL" ? (
<th rowSpan={2} className="w-[20rem]">기술검토 의견</th>
) : null}
</tr>
<tr>
<th />
<th />
<th className="w-[9rem]">
<button
type="button"
@@ -446,7 +566,7 @@ function CnfmViewPage({ id }: { id: string }) {
</tr>
) : (
selectedChecklistRows.map((row: any) => {
const key = `${row.cklstSn}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
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" ;
@@ -581,6 +701,7 @@ function CnfmViewPage({ id }: { id: string }) {
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
<Button onClick={() => void hadleTechReviewSubmit()}>기술검토등록</Button>
</div>
</div>
);