Merge branch 'iipbs/as/dev'

This commit is contained in:
2026-05-22 13:53:22 +09:00
18 changed files with 887 additions and 172 deletions
+1
View File
@@ -28,6 +28,7 @@
"lucide-react": "^0.474.0",
"next": "15.3.8",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -7,6 +7,8 @@ import type {
CklstMngSaveRequest,
CklstMngSearchParams,
CklstMngService,
CklstTreeNode,
CklstTreeSearchParams,
} from "../model/cklst-mng-interface";
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
@@ -21,6 +23,13 @@ async function findById(cklstSn: number): Promise<CklstMng> {
return response.data;
}
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/tree`, {
params,
});
return response.data;
}
async function create(body: CklstMngSaveRequest): Promise<number> {
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
return response.data;
@@ -42,6 +51,7 @@ async function remove(cklstSn: number): Promise<number> {
export const cklstMngService = {
findPage,
findById,
findTree,
create,
update,
remove,
+2
View File
@@ -33,6 +33,8 @@ export type {
CklstMngSaveRequest,
CklstMngSearchParams,
CklstMngService,
CklstTreeNode,
CklstTreeSearchParams,
} from "./model/cklst-mng-interface";
export type {
CklstRvw,
@@ -13,6 +13,24 @@ export interface CklstMng {
mdfcnDt?: string;
}
export interface CklstTreeNode {
cklstSn: number;
cklstNm: string;
cklstArtclSn: number;
cklstArtclNm: string;
cklstRvwSn: number;
upCklstRvwSn: number | null;
treeLevel: number;
rvwCn: string;
bchckUseYn: "Y" | "N";
sortSeq: number;
}
export interface CklstTreeSearchParams {
docSeComCd?: string;
taskSeComCd?: string;
}
export interface CklstMngSearchParams extends PageableParams {
cklstNm?: string;
taskSeComCd?: string;
@@ -30,6 +48,7 @@ export interface CklstMngSaveRequest {
export interface CklstMngService {
findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>>;
findById(cklstSn: number): Promise<CklstMng>;
findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]>;
create(body: CklstMngSaveRequest): Promise<number>;
update(cklstSn: number, body: CklstMngSaveRequest): Promise<number>;
remove(cklstSn: number): Promise<number>;
@@ -0,0 +1,15 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface";
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
const response = await client.get<CnstpSeMngTreeDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`,
);
return response.data;
}
export const cnstpService = {
findTree,
} satisfies CnstpService;
+2
View File
@@ -0,0 +1,2 @@
export type { CnstpSeMngTreeDto, CnstpService } from "./model/cnstp-interface";
export { cnstpService } from "./api/cnstp-service";
@@ -0,0 +1,23 @@
/** 공종구분 트리 노드 DTO */
export interface CnstpSeMngTreeDto {
/** 들여쓰기 포함 표시명 */
indCnstpSeCdNm: string;
/** 공종구분코드 */
cnstpSeCd: string;
/** 공종구분코드명 */
cnstpSeCdNm: string;
/** 상위 공종구분코드 (null = 최상위) */
upCnstpCd: string | null;
/** 사용여부 */
useYn: string;
/** 트리 레벨 */
treeLevel: number;
/** 최상위~현재 노드까지 "|" 구분 경로 */
treePath: string;
}
/** 공종구분 서비스 인터페이스 */
export interface CnstpService {
/** 공종구분 트리 전체 조회 */
findTree(): Promise<CnstpSeMngTreeDto[]>;
}
@@ -1,6 +1,7 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
InstlSncnApplySaveRequestDto,
InstlSncnMng,
InstlSncnMngSearchParams,
InstlSncnMngService,
@@ -19,6 +20,14 @@ export class InstlSncnApiService implements InstlSncnMngService {
const response = await client.get<InstlSncnMng>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
return response.data;
}
public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`,
payload,
);
return response.data;
}
}
export const InstlSncnApi = new InstlSncnApiService();
+9 -4
View File
@@ -1,6 +1,11 @@
export { InstlSncnApi } from "./api/instl-sncn-service";
export type {
InstlSncnMng,
InstlSncnMngSearchParams,
SpringPage,
export {
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnCklstResultSaveDto,
type InstlSncnMng,
type InstlSncnMngSearchParams,
type SpringPage,
} from "./model/instl-sncn-interface";
@@ -1,3 +1,5 @@
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
export interface SpringPage<T> {
content: T[];
totalElements: number;
@@ -52,7 +54,57 @@ export interface InstlSncnMng {
rgtrNm: string;
}
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
export interface InstlSncnBzScaleSaveDto {
/** 공종구분코드 */
cnstpSeCd: string;
/** 기본계획(A) */
bscPl: number;
/** 예산반영(B) */
budgetReflct: number;
/** 금회설계시(C) */
thtiDsgn: number;
/** 증감(C-B) */
diff: number;
}
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
export interface InstlSncnBizCstSaveRequestDto {
/** 회계구분코드 */
acntgSeCd: string;
/** 기본계획금액 */
bscPlanAmt: number;
/** 예산반영금액 */
bgtRfltAmt: number;
/** 금회설계금액 */
tstmDesignAmt: number;
}
/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */
export interface InstlSncnCklstResultSaveDto {
cklstSn: number;
cklstArtclSn: number;
cklstRvwSn: number;
selected: "Y" | "N" | null;
}
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
export interface InstlSncnEsntlAtchFileSaveDto {
esntlAtchFileId: string;
atchFileGroupId: string;
}
/** 설치인가 신청 - 통합 저장 요청 DTO */
export interface InstlSncnApplySaveRequestDto {
instlSncnMng: InstlSncnMng;
bzScaleList: InstlSncnBzScaleSaveDto[];
bizCstList: InstlSncnBizCstSaveRequestDto[];
cklstResultList: InstlSncnCklstResultSaveDto[];
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
}
export interface InstlSncnMngService {
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
getById: (id: string) => Promise<InstlSncnMng>;
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
}
@@ -15,11 +15,17 @@ export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
const visibleRows = getVisibleRows(rows);
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
if (receiverCount === 0) {
return "접수자는 반드시 등록해야 합니다.";
}
if (receiverCount > 1) {
return "접수자는 한 명만 등록할 수 있습니다.";
}
const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length;
if (finalCount === 0) {
return "최종승인자는 반드시 등록해야 합니다.";
}
if (finalCount > 1) {
return "최종승인자는 한 명만 등록할 수 있습니다.";
}
@@ -98,7 +98,6 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
};
const handleRowDelete = async (row: ElaprAplnRow) => {
if (!(await confirm("선택한 결재선을 삭제하시겠습니까?"))) return;
markDeleted(row.rowKey);
};
@@ -129,6 +128,8 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
return;
}
if (!(await confirm("결재선을 저장하시겠습니까?"))) return;
await saveMultiMutation.mutateAsync({ body: payload, elaprTmpltId });
alert("결재선이 저장되었습니다.");
await aplnQuery.refetch();
@@ -158,12 +159,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
</Button>
<Button
type="button"
disabled={
isDisabled ||
isEditingLocked ||
!canSave ||
saveMultiMutation.isPending
}
disabled={isDisabled || isEditingLocked || !canSave || saveMultiMutation.isPending}
onClick={handleSaveAll}
>
결재선 저장
@@ -6,6 +6,9 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
@@ -14,7 +17,7 @@ import {
TableRow,
} from "@common/components";
import { buildRvwDisplayRows, formatRvwCnLvNo, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
import { buildRvwDisplayRows, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
interface CklstPreviewDialogProps {
@@ -49,36 +52,48 @@ function CklstPreviewDialog({
<p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p>
) : (
<Table>
<colgroup>
<col width="25%" />
<col width="50%" />
<col width="25%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>점검항목</TableHead>
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>구분</TableHead>
<TableHead align="left">점검항목</TableHead>
<TableHead align="left">주요 검토내용</TableHead>
<TableHead>검토 여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
if (row.useYn === "N") return null;
return (
<TableRow key={row.rowKey}>
<TableCell>
<span className="inline-flex items-center gap-1">
{branchPrefix ? (
<span className="font-mono text-muted-foreground">{branchPrefix}</span>
) : null}
<span>{getArtclLabel(row.cklstArtclSn)}</span>
</span>
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
{row.useYn === "Y" ? "예" : row.useYn === "N" ? "아니오" : "—"}
</TableCell>
<TableCell align="center">
{formatRvwCnLvNo(row.rvwCnLvNo)}
</TableCell>
</TableRow>
<TableRow key={row.rowKey}>
<TableCell align="left">
<span className="inline-flex items-center gap-1">
{branchPrefix ? (
<span className="font-mono text-muted">{branchPrefix}</span>
) : null}
<span>{getArtclLabel(row.cklstArtclSn)}</span>
</span>
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
<RadioGroup className="flex items-center justify-center">
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
</TableRow>
);
})}
</TableBody>
@@ -155,17 +155,31 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
);
const handleDelete = async (row: CklstRvwRow) => {
if (!(await confirm("선택한 항목을 삭제하시겠습니까?"))) return;
if (
!(await confirm(
"선택한 항목을 삭제하시겠습니까?\n삭제한 항목은 되돌릴 수 없으며, 하위 항목까지 모두 삭제됩니다.",
))
)
return;
if (row.isNew) {
removeRowLocal(row.rowKey);
return;
}
if (row.cklstRvwSn == null) return;
const children = rows.filter((r) => r.upCklstRvwSn === row.cklstRvwSn);
if (children.length > 0) {
for (const child of children) {
await deleteMutation.mutateAsync({
cklstRvwSn: child.cklstRvwSn!,
cklstArtclSn: child.cklstArtclSn,
cklstSn: child.cklstSn,
});
}
}
await deleteMutation.mutateAsync({
cklstRvwSn: row.cklstRvwSn,
cklstRvwSn: row.cklstRvwSn!,
cklstArtclSn: row.cklstArtclSn,
cklstSn: row.cklstSn,
});
@@ -184,6 +198,8 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
return;
}
if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
if (row.isNew) {
await createMutation.mutateAsync({
cklstSn: row.cklstSn,
@@ -233,9 +249,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
미리보기
</Button>
<Button type="button" color="white" disabled={isDisabled} onClick={addRootRow}>
추가
+ 체크리스트 추가
</Button>
<Button type="button" disabled={isDisabled || isSaving} onClick={handleSave}>
<Button
type="button"
disabled={getDirtyRows().length === 0 || isDisabled || isSaving}
onClick={handleSave}
>
저장
</Button>
</div>
@@ -248,7 +268,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
</div>
) : isDisabled ? (
<p className="py-8 text-center text-lg text-muted-foreground">
상단에서 점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다.
점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다.
</p>
) : displayRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
@@ -16,7 +16,11 @@ import {
TableRow,
} from "@common/components";
import { canAddChildRow, formatRvwCnLvNo, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
import {
canAddChildRow,
formatRvwCnLvNo,
formatTreeBranchPrefix,
} from "../model/build-rvw-display-rows";
import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
interface CklstRvwRowProps {
@@ -44,11 +48,12 @@ function CklstRvwRowComponent({
<TableCell>
<div className="flex min-w-[12rem] items-center gap-1">
{branchPrefix ? (
<span className="shrink-0 font-mono text-muted-foreground">{branchPrefix}</span>
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
) : null}
<Select
value={row.cklstArtclSn ? String(row.cklstArtclSn) : ""}
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
disabled={!row.isNew}
>
<SelectTrigger className="w-full flex-1">
<SelectValue placeholder="점검항목 선택" />
@@ -1,12 +1,17 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { dayjs } from "@/shared/lib";
import { InstlSncnApi, type InstlSncnMngSearchParams } from "@/entities/instl-sncn";
import { useCodeList } from "@/entities/code";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
type InstlSncnMngSearchParams,
} from "@/entities/instl-sncn";
import "./cnfm-page.css";
@@ -65,6 +70,17 @@ function CnfmListPage() {
});
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState("");
const { data: fnrsCodeList = [] } = useCodeList("INSTL");
useEffect(() => {
if (!taskDialogOpen) return;
if (selectedTaskSeComCd) return;
if (fnrsCodeList.length === 0) return;
setSelectedTaskSeComCd(fnrsCodeList[0].lwpoCd);
}, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]);
const query = useQuery({
queryKey: ["instl-sncn-list", search, pageIndex, pageSize],
@@ -91,6 +107,26 @@ function CnfmListPage() {
setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd });
};
const handleOpenTaskDialog = () => {
setSelectedTaskSeComCd((prev) => prev || fnrsCodeList[0]?.lwpoCd || "");
setTaskDialogOpen(true);
};
const handleConfirmTaskDialog = () => {
const selected = fnrsCodeList.find((item) => item.lwpoCd === selectedTaskSeComCd);
if (!selected) return;
sessionStorage.setItem(
INSTL_SNCN_REGISTER_SELECTION_KEY,
JSON.stringify({
taskSeComCd: selected.lwpoCd,
taskSeComCdNm: selected.lwpoCdNm,
}),
);
setTaskDialogOpen(false);
router.push("/instl-sncn/register");
};
return (
<div className="cnfm-page-wrap">
{/* 검색 영역 */}
@@ -160,7 +196,7 @@ function CnfmListPage() {
<button
type="button"
className="cnfm-btn-register"
onClick={() => router.push("/instl-sncn/register")}
onClick={handleOpenTaskDialog}
>
✎ 신청
</button>
@@ -311,6 +347,58 @@ function CnfmListPage() {
</button>
</div>
</div>
{taskDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-[72rem] max-w-[95vw] overflow-hidden rounded-md border bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-6 py-4">
<h4 className="text-[2rem] font-bold text-gray-700">업무구분 선택</h4>
<button
type="button"
className="rounded border border-gray-300 px-4 py-1 text-[1.2rem] text-gray-600 hover:bg-gray-50"
onClick={() => setTaskDialogOpen(false)}
>
닫기
</button>
</div>
<div className="px-6 py-5">
<div className="flex flex-wrap items-center gap-8 text-[1.5rem]">
{fnrsCodeList.map((item) => (
<label key={item.lwpoCd} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="taskSeComCdSelect"
value={item.lwpoCd}
checked={selectedTaskSeComCd === item.lwpoCd}
onChange={() => setSelectedTaskSeComCd(item.lwpoCd)}
/>
{item.lwpoCdNm}
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 border-t px-6 py-4">
<button
type="button"
className="rounded border border-gray-300 px-5 py-2 text-[1.3rem] text-gray-700 hover:bg-gray-50"
onClick={() => setTaskDialogOpen(false)}
>
취소
</button>
<button
type="button"
className="rounded bg-blue-500 px-5 py-2 text-[1.3rem] font-semibold text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
onClick={handleConfirmTaskDialog}
disabled={!selectedTaskSeComCd}
>
확인
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,12 +1,27 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { type HttpResponse,Upload } from "tus-js-client";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { getCookie, setCookie } from "@/shared/lib";
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
import { acntgService } from "@/entities/acntg";
import type { CklstTreeNode } from "@/entities/cklst";
import { cklstMngService } from "@/entities/cklst";
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
import { cnstpService } from "@/entities/cnstp";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnMng,
} from "@/entities/instl-sncn";
import { Button, Input } from "@common/components";
import "./cnfm-page.css";
@@ -26,17 +41,80 @@ type BzAmtRow = {
thtiDsgn: string;
};
type RequiredAttachKey =
| "applyForm"
| "planGoalYear"
| "adequacyReview"
| "supervisionCost"
| "costBreakdown"
| "designDrawing"
| "etc";
type RequiredAttachItem = {
key: RequiredAttachKey;
label: string;
atflCd: string;
atflId: string;
fileName: string;
uploading: boolean;
};
const REQUIRED_ATTACHMENTS: Array<{ key: RequiredAttachKey; label: string }> = [
{ key: "applyForm", label: "인가신청서" },
{ key: "planGoalYear", label: "계획수립 기준 및 목표연도 계획" },
{ key: "adequacyReview", label: "하수도 시설계획 적정성 검토 결과" },
{ key: "supervisionCost", label: "관로설계시설물 감리비용 산출 계획서" },
{ key: "costBreakdown", label: "사업비 내역서" },
{ key: "designDrawing", label: "설계도면" },
{ key: "etc", label: "기타 첨부자료" },
];
const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0");
const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString());
/* ------------------------------------------------------------------ */
/* 공통 셀 컴포넌트 (컴포넌트 외부에 선언해야 re-mount 방지) */
/* ------------------------------------------------------------------ */
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<td>
<input
type="text"
className="cnfm-input w-full min-w-0 text-right"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
const ReadCell = ({ value }: { value: string | number }) => (
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
);
/* ------------------------------------------------------------------ */
/* 메인 컴포넌트 */
/* ------------------------------------------------------------------ */
function CnfmRegistPage() {
const router = useRouter();
const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수");
const [taskSeComCd, setTaskSeComCd] = useState("RE");
useEffect(() => {
const raw = sessionStorage.getItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { taskSeComCd?: string; taskSeComCdNm?: string };
setTaskSeComCd(parsed.taskSeComCd || "RE");
setTaskSeComCdNm(parsed.taskSeComCdNm || "재이용수");
} catch {
// noop
} finally {
sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
}
}, []);
/* ── 기본 정보 ── */
const [bzNm, setBzNm] = useState("");
@@ -59,26 +137,263 @@ function CnfmRegistPage() {
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
/* ── 체크리스트 ── */
const CHECKLIST_ITEMS = [
"사업계획의 적정성",
"사업규모의 적정성",
"사업방식의 적정성",
"사업비의 적정성",
"사업효과의 적정성",
"사업일정의 적정성",
];
const [checkResults, setCheckResults] = useState<Record<string, "Y" | "N" | "">>(() =>
Object.fromEntries(CHECKLIST_ITEMS.map((item) => [item, "" as "Y" | "N" | ""])),
/* ── 필수 첨부파일 ── */
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(() =>
REQUIRED_ATTACHMENTS.reduce(
(acc, item) => {
acc[item.key] = {
key: item.key,
label: item.label,
atflCd: "",
atflId: "",
fileName: "",
uploading: false,
};
return acc;
},
{} as Record<RequiredAttachKey, RequiredAttachItem>,
),
);
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({
applyForm: null,
planGoalYear: null,
adequacyReview: null,
supervisionCost: null,
costBreakdown: null,
designDrawing: null,
etc: null,
});
/* ── 사업규모 ── */
const [bzSpecWwtp, setBzSpecWwtp] = useState<BzScaleRow>(emptyScale()); // 처리장용량
const [bzSpecArea, setBzSpecArea] = useState<BzScaleRow>(emptyScale()); // 면적
const [bzSpecNcr, setBzSpecNcr] = useState<BzScaleRow>(emptyScale()); // 하수관로 신설
const [bzSpecRplc, setBzSpecRplc] = useState<BzScaleRow>(emptyScale()); // 교체(보수)
const [bzSpecPump, setBzSpecPump] = useState<BzScaleRow>(emptyScale()); // 펌프장
const [bzSpecDrng, setBzSpecDrng] = useState<BzScaleRow>(emptyScale()); // 배수설비
const uploadRequiredFile = (file: File, key: RequiredAttachKey) => {
setRequiredFiles((prev) => ({
...prev,
[key]: { ...prev[key], uploading: true },
}));
setCookie("atfl-cd-key", "");
const upload = new Upload(file, {
endpoint: ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata: {
filename: file.name,
filetype: file.type,
},
onError(error: Error) {
console.error("필수 첨부파일 업로드 실패", error);
setRequiredFiles((prev) => ({
...prev,
[key]: { ...prev[key], uploading: false },
}));
alert("필수 첨부파일 업로드 중 오류가 발생했습니다.");
},
onAfterResponse(_req: unknown, res: HttpResponse) {
const fileInfo = res.getHeader("X-File-Info");
if (typeof fileInfo !== "string") return;
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
const atflCd = parsed.atflCd ?? "";
const atflId = parsed.atflId ?? "";
if (atflCd) setCookie("atfl-cd-key", atflCd);
setRequiredFiles((prev) => ({
...prev,
[key]: {
...prev[key],
atflCd,
atflId,
fileName: file.name,
uploading: false,
},
}));
},
onSuccess() {
setRequiredFiles((prev) => ({
...prev,
[key]: { ...prev[key], uploading: false },
}));
},
});
upload.start();
};
const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
uploadRequiredFile(file, key);
e.target.value = "";
};
/* ── 체크리스트 ── */
type CheckResultVal = "Y" | "N" | null;
const [checkResults, setCheckResults] = useState<Record<string, CheckResultVal>>({});
const cklstRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const { data: cklstTree = [] } = useQuery<CklstTreeNode[]>({
queryKey: ["cklstTree", "INSTL", taskSeComCd],
queryFn: () => cklstMngService.findTree({ docSeComCd: "INSTL", taskSeComCd }),
enabled: Boolean(taskSeComCd),
staleTime: Infinity,
});
const cklstRows = useMemo(() => {
// bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함
const useRows = cklstTree;
if (useRows.length === 0) return [] as CklstTreeNode[];
// artcl 단위로 분리하지 않고 전체 rows에서 단일 childMap을 구성한다.
// upCklstRvwSn이 다른 artcl 소속 노드를 부모로 가질 수 있으므로
// artcl 경계를 넘는 부모-자식 관계도 처리해야 한다.
const childMap = new Map<number | null, CklstTreeNode[]>();
for (const row of useRows) {
const parent = row.upCklstRvwSn ?? null;
if (!childMap.has(parent)) childMap.set(parent, []);
childMap.get(parent)!.push(row);
}
for (const list of childMap.values()) {
list.sort((a, b) =>
a.cklstArtclSn !== b.cklstArtclSn
? a.cklstArtclSn - b.cklstArtclSn
: a.sortSeq !== b.sortSeq
? a.sortSeq - b.sortSeq
: a.cklstRvwSn - b.cklstRvwSn,
);
}
const ordered: CklstTreeNode[] = [];
const walk = (parent: number | null) => {
const children = childMap.get(parent) ?? [];
for (const child of children) {
ordered.push(child);
walk(child.cklstRvwSn);
}
};
walk(null);
return ordered;
}, [cklstTree]);
const cklstKey = (row: CklstTreeNode) =>
`${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`;
useEffect(() => {
setCheckResults((prev) => {
const next: Record<string, CheckResultVal> = {};
let changed = false;
cklstRows.forEach((row) => {
const key = cklstKey(row);
if (Object.prototype.hasOwnProperty.call(prev, key)) {
next[key] = prev[key];
} else {
next[key] = null;
changed = true;
}
});
// 기존 key가 사라졌거나 값이 달라진 경우만 상태 업데이트
if (!changed) {
const prevKeys = Object.keys(prev);
if (prevKeys.length !== Object.keys(next).length) {
changed = true;
} else {
for (const key of prevKeys) {
if (!(key in next) || prev[key] !== next[key]) {
changed = true;
break;
}
}
}
}
return changed ? next : prev;
});
}, [cklstRows]);
const cklstStats = useMemo(() => {
const total = cklstRows.length;
const completed = cklstRows.reduce((acc, row) => {
const val = checkResults[cklstKey(row)] ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}, [cklstRows, checkResults]);
const toCklstSaveDtoList = () =>
cklstRows
.map((row) => ({
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
selected: checkResults[cklstKey(row)] ?? null,
}));
const toEsntlAtchFileSaveDtoList = () =>
Object.values(requiredFiles)
.filter((item) => Boolean(item.atflCd) && Boolean(item.atflId))
.map((item) => ({
esntlAtchFileId: item.key,
atchFileGroupId: item.atflCd,
}));
/* ── 사업규모 (공종구분 트리 연동) ── */
// cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
const [bzScaleMap, setBzScaleMap] = useState<Record<string, BzScaleRow>>({});
/* ── 공종구분 트리 로드 ── */
const { data: cnstpTree = [] } = useQuery<CnstpSeMngTreeDto[]>({
queryKey: ["cnstpTree"],
queryFn: cnstpService.findTree,
staleTime: Infinity,
});
/* ── 공종구분 리프 노드 집합 ── */
const cnstpLeafSet = useMemo(() => {
const parentCodes = new Set(cnstpTree.map((n) => n.upCnstpCd).filter(Boolean) as string[]);
return new Set(cnstpTree.filter((n) => !parentCodes.has(n.cnstpSeCd)).map((n) => n.cnstpSeCd));
}, [cnstpTree]);
const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
cnstpTree
.filter((n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd))
.reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0);
const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
cnstpLeafSet.has(node.cnstpSeCd)
? num((bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col])
: subtotalScale(node, col);
const handleScaleInput = (cnstpSeCd: string, col: keyof BzScaleRow, val: string) =>
setBzScaleMap((prev) => ({
...prev,
[cnstpSeCd]: { ...(prev[cnstpSeCd] ?? emptyScale()), [col]: val },
}));
/** 사업규모 맵 -> 백엔드 저장용 배열 DTO 변환 */
const toBzScaleSaveDtoList = (): InstlSncnBzScaleSaveDto[] =>
cnstpTree
.filter((node) => cnstpLeafSet.has(node.cnstpSeCd))
.map((node) => {
const row = bzScaleMap[node.cnstpSeCd] ?? emptyScale();
const bscPl = num(row.bscPl);
const budgetReflct = num(row.budgetReflct);
const thtiDsgn = num(row.thtiDsgn);
return {
cnstpSeCd: node.cnstpSeCd,
bscPl,
budgetReflct,
thtiDsgn,
diff: thtiDsgn - budgetReflct,
};
});
/* ── 총사업비 (회계구분 트리 연동) ── */
// acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
@@ -120,6 +435,20 @@ function CnfmRegistPage() {
[acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val },
}));
/** 총사업비 맵 -> 백엔드 저장용 배열 DTO 변환 */
const toBizCstSaveDtoList = (): InstlSncnBizCstSaveRequestDto[] =>
acntgTree
.filter((node) => leafSet.has(node.acntgSeCd))
.map((node) => {
const row = amtMap[node.acntgSeCd] ?? emptyAmt();
return {
acntgSeCd: node.acntgSeCd,
bscPlanAmt: num(row.bscPl),
bgtRfltAmt: num(row.budgetReflct),
tstmDesignAmt: num(row.thtiDsgn),
};
});
/**
* indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환
* (공백 4칸 = 1 depth = 1rem)
@@ -137,11 +466,19 @@ function CnfmRegistPage() {
return "";
};
/** 공종 트리 행 스타일 분류 */
const scaleRowStyle = (node: CnstpSeMngTreeDto) => {
if (node.treeLevel === 1) return "bg-gray-50 font-semibold";
if (!cnstpLeafSet.has(node.cnstpSeCd)) return "font-semibold";
return "";
};
/* ── 증감 라벨 ── */
const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v));
/* ── 검증 ── */
function fnValidation(mode: "SAVE" | "PRESENTN") {
/*
if (!bzNm.trim()) {
alert("사업명을 입력하세요.");
return false;
@@ -158,44 +495,102 @@ function CnfmRegistPage() {
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
return false;
}
// 체크리스트 사용 항목(Y) 중 미선택 항목이 있으면 진행 불가
const hasUncheckedChecklist = cklstRows.some(
(row) => !checkResults[cklstKey(row)],
);
if (hasUncheckedChecklist) {
alert("체크리스트를 모두 체크하세요.");
return false;
}
// 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함
*/
if (mode === "SAVE") {
const invalidYesRow = cklstRows.find(
(row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row)] !== "Y",
);
if (invalidYesRow) {
const invalidKey = cklstKey(invalidYesRow);
const invalidRowEl = cklstRowRefs.current[invalidKey];
invalidRowEl?.scrollIntoView({ behavior: "smooth", block: "center" });
const yesRadio = invalidRowEl?.querySelector<HTMLInputElement>(
`input[name="chk_${invalidKey}"][value="Y"]`,
);
yesRadio?.focus();
alert(`${invalidYesRow.rvwCn} 검토내용은 예(Y)여야 합니다.`);
return false;
}
}
return true;
}
function handleSave() {
const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"): InstlSncnMng => ({
instlSncnDocNo: "",
dongId: "",
sidoNm: "",
sggNm: "",
chrgrNm: "",
instNm: "",
envoSeComCd: instlCnfmApr,
envoSeCdNm: "",
taskSeComCd,
taskSeCdNm: taskSeComCdNm,
bgtBizCd: "",
bgtAplyYr: "",
bizNm: bzNm,
bizBgngYr: bzBgngYr,
bizEndYr: bzEndYr,
bizCn: bzCn,
aplcntNm: "",
aplcntMblTelno: "",
aplcntOgdpNm: "",
aplcntEmlAddr: "",
dsgnrNm,
dsgnrTelno,
dsgnrOgdpNm: dsgnrPsitn,
dsgnrEmlAddr,
atchFileGroupId: "",
prgrsSttsComCd,
prgrsSttsCdNm: "",
regDt: "",
mdfcnDt: "",
rgtrNm: "",
});
async function handleSave() {
if (!fnValidation("SAVE")) return;
alert("저장(임시) API 연동 후 progrsSttus=SV 전송");
const payload: InstlSncnApplySaveRequestDto = {
instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장)
bzScaleList: toBzScaleSaveDtoList(),
bizCstList: toBizCstSaveDtoList(),
cklstResultList: toCklstSaveDtoList(),
esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
};
try {
const issuedDocNo = await InstlSncnApi.saveApply(payload);
alert(`저장되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("저장 API 호출 실패", error);
alert("저장 중 오류가 발생했습니다.");
}
}
function handleSubmit() {
if (!fnValidation("PRESENTN")) return;
const payload = {
instlSncnMng: toInstlSncnMngPayload("ST"),
bzScaleList: toBzScaleSaveDtoList(),
bizCstList: toBizCstSaveDtoList(),
cklstRvwRsltList: toCklstSaveDtoList(),
};
void payload;
alert("제출 API 연동 후 progrsSttus=ST 전송");
}
/* ── 공통 입력 핸들러 ── */
function scaleInput(
setter: React.Dispatch<React.SetStateAction<BzScaleRow>>,
col: keyof BzScaleRow,
val: string,
) {
setter((prev) => ({ ...prev, [col]: val }));
}
/* ── 공통 숫자 셀 ── */
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<td>
<input
type="text"
className="cnfm-input w-full text-right"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
const ReadCell = ({ value }: { value: string | number }) => (
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
);
/* ---------------------------------------------------------------- */
return (
<div className="mt-6 space-y-4">
@@ -290,7 +685,8 @@ function CnfmRegistPage() {
<tr>
<th>구분</th>
<td>
<div className="flex gap-4 text-[1.3rem]">하수관로</div>
<div className="flex gap-4 text-[1.3rem]">{taskSeComCdNm}</div>
<input type="hidden" name="taskSeComCd" value={taskSeComCd} />
</td>
<th>신청 지자체</th>
<td>
@@ -438,7 +834,7 @@ function CnfmRegistPage() {
<table className="cnfm-table">
<thead>
<tr>
<th rowSpan={2} className="w-[12rem]">
<th rowSpan={2} className="w-[30rem]">
구분
</th>
<th>기본계획(A)</th>
@@ -448,31 +844,52 @@ function CnfmRegistPage() {
</tr>
</thead>
<tbody>
{[
{ label: "처리장용량(㎥/일)", state: bzSpecWwtp, setter: setBzSpecWwtp },
{ label: "면적(㎡)", state: bzSpecArea, setter: setBzSpecArea },
{ label: "하수관로 신설(m)", state: bzSpecNcr, setter: setBzSpecNcr },
{ label: "교체/보수(m)", state: bzSpecRplc, setter: setBzSpecRplc },
{ label: "펌프장(개소)", state: bzSpecPump, setter: setBzSpecPump },
{ label: "배수설비(개소)", state: bzSpecDrng, setter: setBzSpecDrng },
].map(({ label, state, setter }) => (
<tr key={label}>
<td className="bg-gray-50 pl-2 text-left font-semibold">{label}</td>
{(["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
<td key={col}>
<input
type="text"
className="cnfm-input w-full text-right"
value={state[col]}
onChange={(e) => scaleInput(setter, col, e.target.value)}
/>
</td>
))}
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">
{diffLabel(num(state.thtiDsgn) - num(state.budgetReflct))}
{cnstpTree.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">
조회된 사업규모 항목이 없습니다.
</td>
</tr>
))}
) : (
cnstpTree.map((node) => {
const isLeaf = cnstpLeafSet.has(node.cnstpSeCd);
const bscPl = scaleNodeVal(node, "bscPl");
const budgetReflct = scaleNodeVal(node, "budgetReflct");
const thtiDsgn = scaleNodeVal(node, "thtiDsgn");
const diffVal = thtiDsgn - budgetReflct;
const pl = indentPx(node.indCnstpSeCdNm);
return (
<tr key={node.cnstpSeCd} className={scaleRowStyle(node)}>
<td
className="bg-gray-50 pl-2 text-left"
style={{ paddingLeft: `${pl + 8}px` }}
>
{node.cnstpSeCdNm}
</td>
{isLeaf
? (["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
<td key={col}>
<input
type="text"
className="cnfm-input w-full text-right"
value={(bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col]}
onChange={(e) =>
handleScaleInput(node.cnstpSeCd, col, e.target.value)
}
/>
</td>
))
: ([bscPl, budgetReflct, thtiDsgn] as number[]).map((val, idx) => (
<ReadCell key={`${node.cnstpSeCd}_${idx}`} value={fmt(val)} />
))}
<ReadCell value={diffLabel(diffVal)} />
</tr>
);
})
)}
</tbody>
</table>
</div>
@@ -484,24 +901,20 @@ function CnfmRegistPage() {
총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span>
</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<table className="cnfm-table w-full table-fixed">
<thead>
<tr>
<th colSpan={2} className="w-[20rem]">
<th colSpan={2} className="w-[30rem]" rowSpan={2}>
구분
</th>
<th>예산반영(A)</th>
<th>표준사업비(B)</th>
<th>금회설계시(C)</th>
<th rowSpan={2}>예산반영(A)</th>
<th rowSpan={2}>표준사업비(B)</th>
<th rowSpan={2}>금회설계시(C)</th>
<th colSpan={2}>증감</th>
</tr>
<tr>
<th colSpan={2}></th>
<th></th>
<th></th>
<th></th>
<th>(C-B)</th>
<th>%</th>
<th className="w-[12rem]">(C-B)</th>
<th className="w-[6rem]">%</th>
</tr>
</thead>
<tbody>
@@ -583,11 +996,13 @@ function CnfmRegistPage() {
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<button className="cnfm-btn-search text-[1.2rem]">하수관로(0/8)</button>
<button className="cnfm-btn-search text-[1.2rem]">
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) + `(${cklstStats.completed}/${cklstStats.total})`}
</button>
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 — 총합: <strong>0</strong> · 완료: <strong>0</strong> · 진행률:{" "}
<strong>0%</strong>
체크리스트 진행상황 — 총합: <strong>{cklstStats.total}</strong> · 완료:{" "}
<strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
@@ -606,7 +1021,7 @@ function CnfmRegistPage() {
className="w-full rounded border border-green-300 bg-green-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-green-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "Y" as const])),
Object.fromEntries(cklstRows.map((row) => [cklstKey(row), "Y" as const])),
)
}
>
@@ -619,7 +1034,7 @@ function CnfmRegistPage() {
className="w-full rounded border border-red-300 bg-red-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-red-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "N" as const])),
Object.fromEntries(cklstRows.map((row) => [cklstKey(row), "N" as const])),
)
}
>
@@ -629,42 +1044,61 @@ function CnfmRegistPage() {
</tr>
</thead>
<tbody>
{CHECKLIST_ITEMS.map((item) => (
<tr key={item}>
<td className="pl-2 text-left">{item}</td>
<td className="pl-2 text-left text-[1.2rem] text-gray-400">
금회 총사업비가 확정예산 범위 내
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
<input
type="radio"
name={`chk_${item}`}
value="Y"
checked={checkResults[item] === "Y"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "Y" }))}
/>
</label>
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
<input
type="radio"
name={`chk_${item}`}
value="N"
checked={checkResults[item] === "N"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "N" }))}
/>
</label>
{cklstRows.length === 0 ? (
<tr>
<td colSpan={4} className="cnfm-td-empty">
조회된 체크리스트 항목이 없습니다.
</td>
</tr>
))}
) : (
cklstRows.map((row) => {
const key = cklstKey(row);
const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`;
return (
<tr
key={key}
ref={(el) => {
cklstRowRefs.current[key] = el;
}}
>
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: rvwPaddingLeft }}>
{row.treeLevel > 1 ? "└ " : ""}
{row.rvwCn}
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
<input
type="radio"
name={`chk_${key}`}
value="Y"
checked={checkResults[key] === "Y"}
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
/>
</label>
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
<input
type="radio"
name={`chk_${key}`}
value="N"
checked={checkResults[key] === "N"}
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "N" }))}
/>
</label>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* ── 첨부파일 ── */}
{/* ── 필수 첨부파일 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">첨부파일</h3>
<div className="cnfm-table-wrap">
@@ -677,17 +1111,9 @@ function CnfmRegistPage() {
</tr>
</thead>
<tbody>
{[
"인가신청서",
"계획수립 기준 및 목표연도 계획",
"하수도 시설계획 적정성 검토 결과",
"관로설계시설물 감리비용 산출 계획서",
"사업비 내역서",
"설계도면",
"기타 첨부자료",
].map((name) => (
<tr key={name}>
<td className="pl-2 text-left">{name}</td>
{REQUIRED_ATTACHMENTS.map(({ key, label }) => (
<tr key={key}>
<td className="pl-2 text-left">{label}</td>
<td>hwp/pdf</td>
<td>
<div className="flex items-center justify-center gap-2">
@@ -695,9 +1121,25 @@ function CnfmRegistPage() {
type="text"
placeholder="첨부문서를 올려주세요"
className="cnfm-input flex-1"
value={requiredFiles[key]?.uploading ? "업로드 중..." : requiredFiles[key]?.fileName || ""}
readOnly
/>
<button className="cnfm-btn-search text-[1.2rem]">첨부하기</button>
<input
ref={(el) => {
requiredFileInputRefs.current[key] = el;
}}
type="file"
className="hidden"
onChange={(e) => handleRequiredFileChange(key, e)}
/>
<button
type="button"
className="cnfm-btn-search text-[1.2rem]"
onClick={() => requiredFileInputRefs.current[key]?.click()}
disabled={requiredFiles[key]?.uploading}
>
{requiredFiles[key]?.uploading ? "업로드중" : "첨부하기"}
</button>
</div>
</td>
</tr>
+5
View File
@@ -1,6 +1,11 @@
export const ENDPOINT = {
ATTACH_SERVICE: {
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
},
AS_COMMON_SERVICE: {
ACNTG_SE: "/as-common-service/api/v1/acntg-se",
CNSTP_SE: "/as-common-service/api/v1/cnstp-se",
},
AS_INSTL_SERVICE: {
INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn",