[as instl] 파일 업로드 로직 추가

This commit is contained in:
2026-05-21 18:58:51 +09:00
parent 09d4750a67
commit e3d022fcd7
4 changed files with 165 additions and 13 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": {
@@ -88,12 +88,19 @@ export interface InstlSncnCklstResultSaveDto {
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 {
@@ -4,7 +4,10 @@ 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";
@@ -38,6 +41,34 @@ 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: "" });
@@ -106,6 +137,98 @@ function CnfmRegistPage() {
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
/* ── 필수 첨부파일 ── */
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 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>>({});
@@ -212,6 +335,14 @@ function CnfmRegistPage() {
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>>({});
@@ -436,6 +567,7 @@ function CnfmRegistPage() {
bzScaleList: toBzScaleSaveDtoList(),
bizCstList: toBizCstSaveDtoList(),
cklstResultList: toCklstSaveDtoList(),
esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
};
try {
@@ -966,7 +1098,7 @@ function CnfmRegistPage() {
</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">
@@ -979,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">
@@ -997,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>
+4
View File
@@ -1,4 +1,8 @@
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",