From 31ee8400723256e056dbcb58c69c277762697782 Mon Sep 17 00:00:00 2001 From: jangwoong Date: Fri, 22 May 2026 12:50:23 +0900 Subject: [PATCH 01/16] =?UTF-8?q?[as=20instl]=20=20file=20upload=20?= =?UTF-8?q?=EC=8B=9C=20Middleware=20=EC=88=98=EC=A0=95=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/middleware.ts | 26 +++++++++++++++++-- .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 11 +++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index 0b7be3348..94a1d0381 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; const serverApiUrl = process.env.SERVER_API_URL; @@ -34,6 +34,28 @@ export function middleware(req: NextRequest) { return addSecurityHeaders(response); } + + // tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리 + // (예: /attach-service/api/v1/tus/upload/{id}) + if (pathname.startsWith("/attach-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` [TUS Proxy] Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + const response = NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + return addSecurityHeaders(response); + } + // if (pathname === "/") { // const response = NextResponse.redirect(new URL("/dashboard", req.url)); // return addSecurityHeaders(response); @@ -47,5 +69,5 @@ export function middleware(req: NextRequest) { } export const config = { - matcher: ["/server/:path*", "/"], + matcher: ["/server/:path*", "/attach-service/:path*", "/"], }; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index e7f2e1261..6424f9f17 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -4,9 +4,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; import { type HttpResponse,Upload } from "tus-js-client"; -import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config"; import { getCookie, setCookie } from "@/shared/lib"; import type { AcntgSeMngTreeDto } from "@/entities/acntg"; import { acntgService } from "@/entities/acntg"; @@ -173,7 +174,7 @@ function CnfmRegistPage() { setCookie("atfl-cd-key", ""); const upload = new Upload(file, { - endpoint: ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD, + endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, chunkSize: 10 * 1024 * 1024, headers: { [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", @@ -575,7 +576,11 @@ function CnfmRegistPage() { alert(`저장되었습니다. 문서번호: ${issuedDocNo}`); } catch (error) { console.error("저장 API 호출 실패", error); - alert("저장 중 오류가 발생했습니다."); + const serverMessage = + axios.isAxiosError(error) && error.response?.data?.message + ? (error.response.data as { message: string }).message + : "저장 중 오류가 발생했습니다."; + alert(serverMessage); } } From da8f66fca107581313516e4bd67ac351050fda10 Mon Sep 17 00:00:00 2001 From: jangwoong Date: Fri, 22 May 2026 13:18:57 +0900 Subject: [PATCH 02/16] =?UTF-8?q?[as=20instl]=20=20tree=EB=A5=BC=20/list/t?= =?UTF-8?q?ree=EB=A1=9C=20url=20=EB=B3=80=EA=B2=BD=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/entities/acntg/api/acntg-service.ts | 2 +- apps/as/src/entities/cklst/api/cklst-mng-service.ts | 2 +- apps/as/src/entities/cnstp/api/cnstp-service.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/as/src/entities/acntg/api/acntg-service.ts b/apps/as/src/entities/acntg/api/acntg-service.ts index 60690a0b9..32afc1730 100644 --- a/apps/as/src/entities/acntg/api/acntg-service.ts +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -5,7 +5,7 @@ import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface"; async function findTree(): Promise { const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/tree`, + `${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/list/tree`, ); return response.data; } diff --git a/apps/as/src/entities/cklst/api/cklst-mng-service.ts b/apps/as/src/entities/cklst/api/cklst-mng-service.ts index d3b6713e8..fdae4e8ff 100644 --- a/apps/as/src/entities/cklst/api/cklst-mng-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-mng-service.ts @@ -24,7 +24,7 @@ async function findById(cklstSn: number): Promise { } async function findTree(params: CklstTreeSearchParams): Promise { - const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/tree`, { + const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, { params, }); return response.data; diff --git a/apps/as/src/entities/cnstp/api/cnstp-service.ts b/apps/as/src/entities/cnstp/api/cnstp-service.ts index 00eb80371..e8f264b23 100644 --- a/apps/as/src/entities/cnstp/api/cnstp-service.ts +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -5,7 +5,7 @@ import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface"; async function findTree(): Promise { const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`, + `${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/list/tree`, ); return response.data; } From 0e8e6465472a328379a28680d10f73c42c005edf Mon Sep 17 00:00:00 2001 From: jangwoong Date: Fri, 22 May 2026 14:11:13 +0900 Subject: [PATCH 03/16] =?UTF-8?q?[as=20instl]=20=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=ED=95=84=EC=88=98=20=EC=B2=A8=EB=B6=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/esntl/api/esntl-service.ts | 22 ++ apps/as/src/entities/esntl/index.ts | 6 + .../entities/esntl/model/esntl-interface.ts | 25 +++ .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 188 ++++++++++-------- apps/as/src/shared/config/api.ts | 1 + 5 files changed, 161 insertions(+), 81 deletions(-) create mode 100644 apps/as/src/entities/esntl/api/esntl-service.ts create mode 100644 apps/as/src/entities/esntl/index.ts create mode 100644 apps/as/src/entities/esntl/model/esntl-interface.ts diff --git a/apps/as/src/entities/esntl/api/esntl-service.ts b/apps/as/src/entities/esntl/api/esntl-service.ts new file mode 100644 index 000000000..a8e60bc57 --- /dev/null +++ b/apps/as/src/entities/esntl/api/esntl-service.ts @@ -0,0 +1,22 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + EsntlAtchFileMngSearchParams, + EsntlAtchFileMngService, + EsntlAtchFileMngTreeResponseDto, +} from "../model/esntl-interface"; + +async function findTree( + params: EsntlAtchFileMngSearchParams, +): Promise { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`, + { params }, + ); + return response.data; +} + +export const esntlAtchFileMngService = { + findTree, +} satisfies EsntlAtchFileMngService; diff --git a/apps/as/src/entities/esntl/index.ts b/apps/as/src/entities/esntl/index.ts new file mode 100644 index 000000000..dfe42c24b --- /dev/null +++ b/apps/as/src/entities/esntl/index.ts @@ -0,0 +1,6 @@ +export type { + EsntlAtchFileMngSearchParams, + EsntlAtchFileMngService, + EsntlAtchFileMngTreeResponseDto, +} from "./model/esntl-interface"; +export { esntlAtchFileMngService } from "./api/esntl-service"; diff --git a/apps/as/src/entities/esntl/model/esntl-interface.ts b/apps/as/src/entities/esntl/model/esntl-interface.ts new file mode 100644 index 000000000..74f77ba72 --- /dev/null +++ b/apps/as/src/entities/esntl/model/esntl-interface.ts @@ -0,0 +1,25 @@ +/** 필수첨부파일관리 트리 조회 응답 DTO */ +export interface EsntlAtchFileMngTreeResponseDto { + /** 필수첨부파일아이디 */ + esntlAtchFileId: string; + /** 문서구분공통코드 */ + docSeComCd: string; + /** 필수첨부파일명 */ + esntlAtchFileNm: string; + /** 필수첨부여부 ("Y" | "N") */ + esntlAtchYn: "Y" | "N"; + /** 필수첨부파일비고 (파일형식 등) */ + esntlAtchFileRmrk: string; +} + +/** 필수첨부파일관리 조회 파라미터 */ +export interface EsntlAtchFileMngSearchParams { + /** 문서구분공통코드 (예: "INSTL") */ + docSeComCd: string; +} + +/** 필수첨부파일관리 서비스 인터페이스 */ +export interface EsntlAtchFileMngService { + /** 필수첨부파일 트리 조회 */ + findTree(params: EsntlAtchFileMngSearchParams): Promise; +} diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index 6424f9f17..866b37a8c 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; -import { type HttpResponse,Upload } from "tus-js-client"; +import { type HttpResponse, Upload } from "tus-js-client"; import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config"; import { getCookie, setCookie } from "@/shared/lib"; @@ -15,6 +15,10 @@ import type { CklstTreeNode } from "@/entities/cklst"; import { cklstMngService } from "@/entities/cklst"; import type { CnstpSeMngTreeDto } from "@/entities/cnstp"; import { cnstpService } from "@/entities/cnstp"; +import { + esntlAtchFileMngService, + type EsntlAtchFileMngTreeResponseDto, +} from "@/entities/esntl"; import { INSTL_SNCN_REGISTER_SELECTION_KEY, InstlSncnApi, @@ -42,34 +46,19 @@ type BzAmtRow = { thtiDsgn: string; }; -type RequiredAttachKey = - | "applyForm" - | "planGoalYear" - | "adequacyReview" - | "supervisionCost" - | "costBreakdown" - | "designDrawing" - | "etc"; +type RequiredAttachKey = string; type RequiredAttachItem = { key: RequiredAttachKey; - label: string; + esntlAtchFileNm: string; + esntlAtchYn: "Y" | "N"; + esntlAtchFileRmrk: 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: "" }); @@ -99,6 +88,7 @@ const ReadCell = ({ value }: { value: string | number }) => ( /* ------------------------------------------------------------------ */ function CnfmRegistPage() { const router = useRouter(); + const [docSeComCd] = useState("INSTL"); const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수"); const [taskSeComCd, setTaskSeComCd] = useState("RE"); @@ -139,32 +129,39 @@ function CnfmRegistPage() { const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); /* ── 필수 첨부파일 ── */ - const [requiredFiles, setRequiredFiles] = useState>(() => - REQUIRED_ATTACHMENTS.reduce( - (acc, item) => { - acc[item.key] = { - key: item.key, - label: item.label, - atflCd: "", - atflId: "", - fileName: "", - uploading: false, - }; - return acc; - }, - {} as Record, - ), - ); - const requiredFileInputRefs = useRef>({ - applyForm: null, - planGoalYear: null, - adequacyReview: null, - supervisionCost: null, - costBreakdown: null, - designDrawing: null, - etc: null, + const { data: requiredAttachmentDefs = [] } = useQuery({ + queryKey: ["esntlAtchFileTree", docSeComCd], + queryFn: () => esntlAtchFileMngService.findTree({ docSeComCd }), + enabled: Boolean(docSeComCd), + staleTime: Infinity, }); + const [requiredFiles, setRequiredFiles] = useState>({}); + const requiredFileInputRefs = useRef>({}); + + useEffect(() => { + if (requiredAttachmentDefs.length === 0) return; + + setRequiredFiles((prev) => { + const next: Record = {}; + for (const item of requiredAttachmentDefs) { + const key = item.esntlAtchFileId; + const prevItem = prev[key]; + next[key] = { + key, + esntlAtchFileNm: item.esntlAtchFileNm, + esntlAtchYn: item.esntlAtchYn, + esntlAtchFileRmrk: item.esntlAtchFileRmrk, + atflCd: prevItem?.atflCd ?? "", + atflId: prevItem?.atflId ?? "", + fileName: prevItem?.fileName ?? "", + uploading: prevItem?.uploading ?? false, + }; + } + return next; + }); + }, [requiredAttachmentDefs]); + const uploadRequiredFile = (file: File, key: RequiredAttachKey) => { setRequiredFiles((prev) => ({ ...prev, @@ -236,9 +233,9 @@ function CnfmRegistPage() { const cklstRowRefs = useRef>({}); const { data: cklstTree = [] } = useQuery({ - queryKey: ["cklstTree", "INSTL", taskSeComCd], - queryFn: () => cklstMngService.findTree({ docSeComCd: "INSTL", taskSeComCd }), - enabled: Boolean(taskSeComCd), + queryKey: ["cklstTree", docSeComCd, taskSeComCd], + queryFn: () => cklstMngService.findTree({ docSeComCd, taskSeComCd }), + enabled: Boolean(docSeComCd) && Boolean(taskSeComCd), staleTime: Infinity, }); @@ -479,7 +476,7 @@ function CnfmRegistPage() { /* ── 검증 ── */ function fnValidation(mode: "SAVE" | "PRESENTN") { - /* + if (!bzNm.trim()) { alert("사업명을 입력하세요."); return false; @@ -506,9 +503,19 @@ function CnfmRegistPage() { alert("체크리스트를 모두 체크하세요."); return false; } - // 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함 - */ + // 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증 if (mode === "SAVE") { + const missingRequiredFile = requiredAttachmentDefs.find((item) => { + if (item.esntlAtchYn !== "Y") return false; + const uploaded = requiredFiles[item.esntlAtchFileId]; + return !(uploaded?.atflCd && uploaded?.atflId); + }); + if (missingRequiredFile) { + alert(`${missingRequiredFile.esntlAtchFileNm} 파일을 첨부하세요.`); + return false; + } + + // 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함 const invalidYesRow = cklstRows.find( (row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row)] !== "Y", ); @@ -1116,39 +1123,58 @@ function CnfmRegistPage() { - {REQUIRED_ATTACHMENTS.map(({ key, label }) => ( - - {label} - hwp/pdf - -
- - { - requiredFileInputRefs.current[key] = el; - }} - type="file" - className="hidden" - onChange={(e) => handleRequiredFileChange(key, e)} - /> - -
+ {requiredAttachmentDefs.length === 0 ? ( + + + 조회된 필수 첨부파일 항목이 없습니다. - ))} + ) : ( + requiredAttachmentDefs.map((item) => { + const key = item.esntlAtchFileId; + const requiredFile = requiredFiles[key]; + const fileFormat = (item.esntlAtchFileRmrk || "").replaceAll(",", "/") || "-"; + + return ( + + +
+ {item.esntlAtchFileNm} + {item.esntlAtchYn === "Y" ? * : null} +
+ + {fileFormat} + +
+ + { + requiredFileInputRefs.current[key] = el; + }} + type="file" + className="hidden" + onChange={(e) => handleRequiredFileChange(key, e)} + /> + +
+ + + ); + }) + )} diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index 440f3e86b..bb6c53f53 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -6,6 +6,7 @@ export const ENDPOINT = { AS_COMMON_SERVICE: { ACNTG_SE: "/as-common-service/api/v1/acntg-se", CNSTP_SE: "/as-common-service/api/v1/cnstp-se", + ESNTL_ATCH_FILE: "/as-common-service/api/v1/esntl-atch-file", }, AS_INSTL_SERVICE: { INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn", From 44edac8fbbcebcd95aedd890b99c2299142e0c2f Mon Sep 17 00:00:00 2001 From: jangwoong Date: Fri, 22 May 2026 14:36:41 +0900 Subject: [PATCH 04/16] =?UTF-8?q?[as=20instl]=20=20[as=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4]=20dongId=20=3D>=20admdstCd=20=EB=A1=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EB=B3=80=EA=B2=BD=20ALTER=20TABLE=20sm=5F?= =?UTF-8?q?bs=5Fas.tb=5Fas=5Fdesign=5Fcnstn=5Fmng=20RENAME=20COLUMN=20dong?= =?UTF-8?q?=5Fid=20TO=20admdst=5Fcd;=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20ALTER=20TABLE?= =?UTF-8?q?=20sm=5Fbs=5Fas.tb=5Fas=5Ffnrs=5Fcnsltn=5Fmng=20RENAME=20COLUMN?= =?UTF-8?q?=20dong=5Fid=20TO=20admdst=5Fcd;=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20ALTER=20T?= =?UTF-8?q?ABLE=20sm=5Fbs=5Fas.tb=5Fas=5Finstl=5Fsncn=5Fmng=20RENAME=20COL?= =?UTF-8?q?UMN=20dong=5Fid=20TO=20admdst=5Fcd;=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20ALTER?= =?UTF-8?q?=20TABLE=20sm=5Fbs=5Fas.tb=5Fas=5Fsewer=5Fbsc=5Fplan=20RENAME?= =?UTF-8?q?=20COLUMN=20dong=5Fid=20TO=20admdst=5Fcd;=20COMMENT=20ON=20COLU?= =?UTF-8?q?MN=20sm=5Fbs=5Fas.tb=5Fas=5Fdesign=5Fcnstn=5Fmng.admdst=5Fcd=20?= =?UTF-8?q?IS=20'=ED=96=89=EC=A0=95=EA=B5=AC=EC=97=AD=EC=BD=94=EB=93=9C';?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20COMMENT=20ON=20COLUMN=20sm=5Fbs=5Fas.tb=5F?= =?UTF-8?q?as=5Ffnrs=5Fcnsltn=5Fmng.admdst=5Fcd=20IS=20'=ED=96=89=EC=A0=95?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=EC=BD=94=EB=93=9C';=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20COM?= =?UTF-8?q?MENT=20ON=20COLUMN=20sm=5Fbs=5Fas.tb=5Fas=5Finstl=5Fsncn=5Fmng.?= =?UTF-8?q?admdst=5Fcd=20IS=20'=ED=96=89=EC=A0=95=EA=B5=AC=EC=97=AD?= =?UTF-8?q?=EC=BD=94=EB=93=9C';=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20COMMENT=20ON=20COLUMN?= =?UTF-8?q?=20sm=5Fbs=5Fas.tb=5Fas=5Fsewer=5Fbsc=5Fplan.admdst=5Fcd=20IS?= =?UTF-8?q?=20'=ED=96=89=EC=A0=95=EA=B5=AC=EC=97=AD=EC=BD=94=EB=93=9C';?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts | 2 +- apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index 63d280d86..fbd58d39a 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -23,7 +23,7 @@ export interface InstlSncnMngSearchParams { export interface InstlSncnMng { instlSncnDocNo: string; - dongId: string; + admdstCd: string; sidoNm?: string; sggNm?: string; chrgrNm?: string; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index 866b37a8c..df6e713fd 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -537,7 +537,7 @@ function CnfmRegistPage() { const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"): InstlSncnMng => ({ instlSncnDocNo: "", - dongId: "", + admdstCd: "", sidoNm: "", sggNm: "", chrgrNm: "", From 50bc502251cae4d80abb5676f454c27cd5488b4e Mon Sep 17 00:00:00 2001 From: jangwoong Date: Fri, 22 May 2026 16:02:34 +0900 Subject: [PATCH 05/16] =?UTF-8?q?[as=20instl]=20=20=20=EA=B8=B0=ED=83=80?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EA=B7=B8=EB=A3=B9=20ID=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=98=EA=B2=8C=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 139 +++++++++++++++--- 1 file changed, 120 insertions(+), 19 deletions(-) diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index df6e713fd..188a80b2a 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -124,9 +124,20 @@ function CnfmRegistPage() { const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N"); /* ── 기타 첨부파일 ── */ - type EtcFile = { id: number; name: string; ext: string; size: string; uploader: string }; + type EtcFile = { + id: string; + name: string; + ext: string; + size: string; + uploader: string; + atflCd: string; + atflId: string; + }; const [etcFiles, setEtcFiles] = useState([]); const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); + const [etcFileGroupId, setEtcFileGroupId] = useState(""); + const [etcUploading, setEtcUploading] = useState(false); + const etcFileGroupIdRef = useRef(""); /* ── 필수 첨부파일 ── */ const { data: requiredAttachmentDefs = [] } = useQuery({ @@ -227,6 +238,93 @@ function CnfmRegistPage() { e.target.value = ""; }; + const toFileSizeText = (size: number): string => + size > 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(1)}MB` : `${(size / 1024).toFixed(0)}KB`; + + const uploadEtcFile = (file: File): Promise => + new Promise((resolve, reject) => { + let uploadedAtflCd = ""; + let uploadedAtflId = ""; + + const upload = new Upload(file, { + endpoint: `${PROXY_HOST}${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, + atflCd: etcFileGroupIdRef.current || "", + }, + onError(error: Error) { + reject(error); + }, + 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 }; + uploadedAtflCd = parsed.atflCd ?? ""; + uploadedAtflId = parsed.atflId ?? ""; + + if (uploadedAtflCd) { + etcFileGroupIdRef.current = uploadedAtflCd; + setEtcFileGroupId((prev) => prev || uploadedAtflCd); + } + }, + onSuccess() { + const ext = file.name.includes(".") ? file.name.split(".").pop() ?? "" : ""; + resolve({ + id: uploadedAtflId || `${Date.now()}_${Math.random()}`, + name: file.name, + ext, + size: toFileSizeText(file.size), + uploader: "(로그인 사용자)", + atflCd: uploadedAtflCd, + atflId: uploadedAtflId, + }); + }, + }); + + upload.start(); + }); + + const handleEtcFilesChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (files.length === 0) return; + + setEtcUploading(true); + const uploadedRows: EtcFile[] = []; + const failedNames: string[] = []; + + for (const file of files) { + try { + const uploaded = await uploadEtcFile(file); + uploadedRows.push(uploaded); + } catch (error) { + console.error("기타 첨부파일 업로드 실패", error); + failedNames.push(file.name); + } + } + + if (uploadedRows.length > 0) { + setEtcFiles((prev) => [...prev, ...uploadedRows]); + if (!etcFileGroupId && uploadedRows[0].atflCd) { + setEtcFileGroupId(uploadedRows[0].atflCd); + etcFileGroupIdRef.current = uploadedRows[0].atflCd; + } + } + + if (failedNames.length > 0) { + alert(`일부 파일 업로드에 실패했습니다. (${failedNames.join(", ")})`); + } + + setEtcUploading(false); + e.target.value = ""; + }; + /* ── 체크리스트 ── */ type CheckResultVal = "Y" | "N" | null; const [checkResults, setCheckResults] = useState>({}); @@ -560,7 +658,7 @@ function CnfmRegistPage() { dsgnrTelno, dsgnrOgdpNm: dsgnrPsitn, dsgnrEmlAddr, - atchFileGroupId: "", + atchFileGroupId: etcFileGroupId, prgrsSttsComCd, prgrsSttsCdNm: "", regDt: "", @@ -1202,29 +1300,23 @@ function CnfmRegistPage() { type="text" className="cnfm-input min-w-0 flex-1" placeholder="첨부문서를 등록해주세요" + value={ + etcUploading + ? "업로드 중..." + : etcFiles.length > 0 + ? `${etcFiles.length}개 파일 등록됨` + : "" + } readOnly /> @@ -1259,7 +1351,16 @@ function CnfmRegistPage() { From aa213ceeadf4b106cb985349d1868c3e2fec2bef Mon Sep 17 00:00:00 2001 From: jangwoong Date: Tue, 26 May 2026 16:39:48 +0900 Subject: [PATCH 06/16] =?UTF-8?q?[as=20instl]=20=20api=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20as-common-service,=20as-instl-service,=20as-checkli?= =?UTF-8?q?st-service=20=EC=9D=98=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=EA=B4=80=EB=A6=AC=20as-service=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=ED=95=98=EA=B2=8C=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/shared/config/api.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index bb6c53f53..84510a543 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -4,12 +4,12 @@ export const ENDPOINT = { 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", - ESNTL_ATCH_FILE: "/as-common-service/api/v1/esntl-atch-file", + ACNTG_SE: "/as-service/api/v1/acntg-se", + CNSTP_SE: "/as-service/api/v1/cnstp-se", + ESNTL_ATCH_FILE: "/as-service/api/v1/esntl-atch-file", }, AS_INSTL_SERVICE: { - INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn", + INSTL_SNCN: "/as-service/api/v1/instl-sncn", }, USER_SERVICE: { LOGIN: "/user-service/login", @@ -21,9 +21,9 @@ export const ENDPOINT = { ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln", }, AS_CHECKLIST_SERVICE: { - CKLST: "/as-checklist-service/api/v1/cklst", - CKLST_ARTCL: "/as-checklist-service/api/v1/cklst-artcl", - CKLST_RVW: "/as-checklist-service/api/v1/cklst-rvw", + CKLST: "/as-service/api/v1/cklst", + CKLST_ARTCL: "/as-service/api/v1/cklst-artcl", + CKLST_RVW: "/as-service/api/v1/cklst-rvw", }, CMS_SERVICE: { CODE: "/cms-service/api/v1/code/cache", From 215d742270b10f0df4d99685aa124f48f6ddc99f Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 26 May 2026 17:22:08 +0900 Subject: [PATCH 07/16] =?UTF-8?q?[st]=20docs:=20=ED=95=84=EC=88=98?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=8E=99=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B4=88=EC=95=88=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/docs/attachment-management/SPEC.md | 311 +++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 apps/as/docs/attachment-management/SPEC.md diff --git a/apps/as/docs/attachment-management/SPEC.md b/apps/as/docs/attachment-management/SPEC.md new file mode 100644 index 000000000..abc6416f4 --- /dev/null +++ b/apps/as/docs/attachment-management/SPEC.md @@ -0,0 +1,311 @@ +# 화면설계서: 필수첨부파일관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | ---------------------------------------------- | +| 버전 | 0.1 | +| 프로세스명 | UI설계 | +| 화면명 | 필수첨부파일관리 | +| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 | +| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 | + +--- + +## 2. 화면 목적 + +문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 서버에서 자동 발급된다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌───────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │ +├───────────────────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 검색 / 액션 │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 문서구분 [전체 ▼] [조회] [등록] │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │ +├───────────────────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 필수첨부파일 목록 │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │ +│ ├─────────────────────────────────────────────────────────────────────────────────────────┤ │ +│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ +│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ +│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────────────────────────┘ + + (모달) [다이얼로그 C] 필수첨부파일 등록 / 수정 + ┌──────────────────────────────────────────────────────────────┐ + │ 필수첨부파일 등록 [닫기] │ + ├──────────────────────────────────────────────────────────────┤ + │ 필수첨부파일아이디(자동발급) [EAF-0004] (읽기 전용) │ + │ 문서구분 [설치인가 ▼] │ + │ 필수첨부파일명 [예: 사업계획서] │ + │ 필수첨부여부 [예 ▼] │ + │ 비고 [비고를 입력하세요] │ + ├──────────────────────────────────────────────────────────────┤ + │ [취소] [저장] │ + └──────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + CALL 필수첨부파일 목록 조회 (TBD) + DISPLAY 필수첨부파일 그리드 (영역 B) + + // 영역 A: 검색 + ON [조회] 버튼 CLICK DO + CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, TBD) + DISPLAY 필수첨부파일 그리드 (영역 B) + END ON + + // 영역 A: 등록 + ON [등록] 버튼 CLICK DO + OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C) + DISPLAY 필수첨부파일아이디 = 자동발급 미리보기 또는 "저장 시 발급" (TBD) + ON [저장] DO + VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부 + CALL 필수첨부파일 등록 (TBD) + CALL 필수첨부파일 목록 조회 + CLOSE 다이얼로그 + TOAST "저장되었습니다." + END ON + ON [취소] / [닫기] DO + CLOSE 다이얼로그 + END ON + END ON + + // 영역 B: 수정 + ON 행 [수정] CLICK DO + OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화) + ON [저장] DO + VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부 + CALL 필수첨부파일 수정 (TBD) + CALL 필수첨부파일 목록 조회 + CLOSE 다이얼로그 + END ON + END ON + + // 영역 B: 삭제 + ON 행 [삭제] CLICK DO + CONFIRM "선택한 필수첨부파일을 삭제하시겠습니까?" + CALL 필수첨부파일 삭제 (TBD) + CALL 필수첨부파일 목록 조회 + END ON + +END +``` + +--- + +## 4. 영역 A: 검색 / 액션 + +문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다. + +### 4-1. 검색 필드 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------- | ---------------- | ---- | ------ | ----------------- | ------------------------------------ | +| 문서구분 | Select(공통코드) | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 (TBD) | + +### 4-2. 버튼 + +| 버튼명 | 위치 | 동작 | +| ------ | -------------- | ----------------------------------------------- | +| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 | +| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 | + +--- + +## 5. 영역 B: 필수첨부파일 목록 + +필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI는 스크린샷에 없음 — **TBD**(백엔드 Pageable 정책 확인). + +### 5-1. 헤더 + +| 항목 | 내용 | +| ----------- | ----------------- | +| 섹션 타이틀 | 필수첨부파일 목록 | + +### 5-2. 그리드 컬럼 + +| 컬럼명 | Field (제안) | 타입 | 비고 | +| ------------------ | ----------------- | ------ | ------------------------------------- | +| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 서버 자동 발급 | +| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) | +| 필수첨부파일명 | `esntlAtchFileNm` | string | | +| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 | +| 비고 | `rmk` | string | 없으면 `—` 표시 | +| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` | +| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` | +| 관리 | — | — | 행별 **[수정] · [삭제]** | + +- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다. + +### 5-3. 버튼 + +| 버튼명 | 위치 | 동작 | +| ------ | ---- | -------------------------------------- | +| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) | +| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 | + +--- + +## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정 + +등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**. + +### 6-1. 폼 필드 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------------------------- | ---------------- | ---- | ------ | ----------------- | --------------------------------------------- | +| 필수첨부파일아이디(자동발급) | Text (읽기 전용) | — | — | — | 등록: 저장 전 미리보기 또는 placeholder (TBD) | +| 문서구분 | Select(공통코드) | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 | +| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" | +| 필수첨부여부 | Select | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 | +| 비고 | Textarea | N | — | `rmk` | placeholder: "비고를 입력하세요" | + +> **TBD**: 등록 다이얼로그의 `EAF-0004` 표시가 저장 전 ID 미리보기인지, mock 데이터인지 백엔드 정책 확인. + +### 6-2. 버튼 + +| 버튼명 | 동작 | +| ------ | -------------------------------------- | +| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 | +| 취소 | 다이얼로그 닫기, 입력값 폐기 | +| 닫기 | 다이얼로그 닫기 (헤더 우측) | + +--- + +## 7. API/DTO 명세 + +> API 미확정. 백엔드 명세 확정 전까지 아래는 **TBD** placeholder이다. + +### 7-1. 공통 + +| 항목 | 내용 | +| ----------- | ------------------------------------- | +| 서비스 | TBD | +| Base prefix | TBD | +| 인증 | JWT (`Authorization`), `X-Site-Id` 등 | +| 페이징 | TBD — Spring `Pageable` 여부 확인 | +| 엔티티 | TBD — `@/entities/esntl-atch-file` 등 | + +### 7-2. 필수첨부파일 — TBD + +| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) | +| --------- | ------ | --- | ----------- | ------------ | ----------------------- | +| 목록 조회 | GET | TBD | TBD | TBD | TBD | +| 단건 조회 | GET | TBD | TBD | TBD | TBD | +| 등록 | POST | TBD | TBD | TBD | TBD | +| 수정 | PUT | TBD | TBD | TBD | TBD | +| 삭제 | DELETE | TBD | TBD | TBD | TBD | + +**응답 DTO (제안 필드)** + +| Field | 타입 | 설명 | +| ----------------- | ------- | ------------------ | +| `esntlAtchFileId` | string | 필수첨부파일아이디 | +| `docSeComCd` | string | 문서구분공통코드 | +| `esntlAtchFileNm` | string | 필수첨부파일명 | +| `esntlAtchYn` | string? | 필수첨부여부 (Y/N) | +| `rmk` | string? | 비고 | +| `regDt` | string? | 등록일시 | +| `mdfcnDt` | string? | 수정일시 | + +**저장 Request (제안)** + +| Field | 필수 | 설명 | +| ----------------- | ---- | -------------- | +| `docSeComCd` | Y | 문서구분 | +| `esntlAtchFileNm` | Y | 필수첨부파일명 | +| `esntlAtchYn` | Y | 필수첨부여부 | +| `rmk` | N | 비고 | + +**검색 Query (제안)** + +| Field | 필수 | 설명 | +| ------------ | ---- | ------------------------- | +| `docSeComCd` | N | 문서구분 (전체 시 미전송) | +| `page` | N | TBD | +| `size` | N | TBD | +| `sort` | N | TBD — 예: `regDt,desc` | + +### 7-3. 공통코드 (보조) + +| UI 필드 | 코드 그룹 (TBD) | 예시 값 (스크린샷) | +| ------------ | --------------- | ------------------ | +| 문서구분 | TBD | 설치인가, 기본계획 | +| 필수첨부여부 | 고정 | Y(예), N(아니오) | + +공통코드 조회: `@/entities/code` — `codeService`, `CodeSelect` + +--- + +## 8. UI 구현 가이드 + +### 8-1. 기술 스택·원칙 + +- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등) +- **테이블**: `@tanstack/react-table` + `useDataTable` +- **폼**: `react-hook-form` +- **데이터**: React Query — TBD 엔티티 hooks +- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel` +- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (삭제 확인) +- **모의 데이터**: API 연동 전 `@faker-js/faker` (ID 형식 `EAF-####`) + +### 8-2. 모듈·파일 배치(제안) + +| 구분 | 경로 | +| ------ | ------------------------------------------------------------------------------------------------------ | +| 라우트 | `apps/as/app/(with-base-layout)/attachment-management/page.tsx` | +| 페이지 | `apps/as/src/pages/attachment-management/` (Public API: page만 export) | +| UI | `.../ui/attachment-management-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` | +| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` | +| 엔티티 | `apps/as/src/entities/esntl-atch-file/` (TBD — service·hooks) | +| 문서 | `apps/as/docs/attachment-management/SPEC.md` | + +### 8-3. 영역별 UI 매핑 + +| 영역 | 주요 UI | +| ---- | --------------------------------------------- | +| A | `CodeSelect`(문서구분) + [조회] + [등록] | +| B | `DataTable` + 행별 [수정][삭제] | +| C | `Dialog` + `react-hook-form` (등록/수정 공용) | + +### 8-4. 다이얼로그·라우팅 + +| 다이얼로그명 | 트리거 | API 연동 (TBD) | +| ----------------- | --------- | --------------- | +| 필수첨부파일 등록 | [등록] | create mutation | +| 필수첨부파일 수정 | 행 [수정] | update mutation | +| 삭제 확인 | 행 [삭제] | delete mutation | + +라우트: `/attachment-management` (메뉴 URL은 CMS 등록값과 일치시킴) + +### 8-5. 데이터 흐름(요약) + +- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable. +- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch. +- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate. +- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate. +- **삭제**: confirm → DELETE → 목록 invalidate. + +### 8-6. 미확정 / 검증 필요 + +- [ ] 백엔드 서비스명·Base URL·리소스 path +- [ ] `esntlAtchFileId` 자동발급 시점 (등록 API 응답 vs 저장 전 미리보기 API) +- [ ] 문서구분 공통코드 상위코드 ID +- [ ] 목록 페이지네이션·정렬 UI 노출 여부 +- [ ] 동일 문서구분 내 필수첨부파일명 중복 허용 여부 +- [ ] 권한(역할별 CRUD) From 153f3b7fa76f662b20cd583af0676a7a9d2aa012 Mon Sep 17 00:00:00 2001 From: jangwoong Date: Wed, 27 May 2026 17:28:37 +0900 Subject: [PATCH 08/16] =?UTF-8?q?[as=20instl]=20=20=20user-store.ts=20?= =?UTF-8?q?=EC=97=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 62 +++++++++++-------- apps/as/src/shared/store/user-store.ts | 10 +++ 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index 188a80b2a..7c5978b4b 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -8,7 +8,8 @@ import axios from "axios"; import { type HttpResponse, Upload } from "tus-js-client"; import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config"; -import { getCookie, setCookie } from "@/shared/lib"; +import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib"; + import type { AcntgSeMngTreeDto } from "@/entities/acntg"; import { acntgService } from "@/entities/acntg"; import type { CklstTreeNode } from "@/entities/cklst"; @@ -27,6 +28,7 @@ import { type InstlSncnBzScaleSaveDto, type InstlSncnMng, } from "@/entities/instl-sncn"; + import { Button, Input } from "@common/components"; import "./cnfm-page.css"; @@ -88,6 +90,9 @@ const ReadCell = ({ value }: { value: string | number }) => ( /* ------------------------------------------------------------------ */ function CnfmRegistPage() { const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const alert = useAlertStore((state) => state.setMessage); + const [docSeComCd] = useState("INSTL"); const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수"); const [taskSeComCd, setTaskSeComCd] = useState("RE"); @@ -742,10 +747,10 @@ function CnfmRegistPage() { - - - - - - - - + - + - - +
+ * 사업명 +
🔍 사업찾기
+ * 사업기간 +
+ * 사업내용 @@ -793,19 +796,21 @@ function CnfmRegistPage() {
구분구분
{taskSeComCdNm}
신청 지자체신청 지자체 - (로그인 정보 자동 세팅) + + {userInfo?.enoNm ?? "\u00A0"} +
사업구분 + 사업구분
{[ ["CNFM", "설치인가"], @@ -836,27 +841,31 @@ function CnfmRegistPage() { - + + - + - + - + + + - + + - + - + - + +
신청자 이름신청자이름 - (로그인 사용자 자동 세팅) + {userInfo?.userName ?? "\u00A0"} 전화번호전화번호 - (로그인 사용자 자동 세팅) + {userInfo?.mphn ?? userInfo?.telno ?? "\u00A0"}
부서부서 - (로그인 사용자 자동 세팅) + {userInfo?.instSeNm ?? "\u00A0"} 이메일이메일 - (로그인 사용자 자동 세팅) + {userInfo?.email ?? "\u00A0"}
설계자 이름설계자이름 setDsgnrNm(e.target.value)} /> 전화번호전화번호
소속(용역사)소속 setDsgnrPsitn(e.target.value)} /> 이메일이메일 Date: Wed, 27 May 2026 18:42:47 +0900 Subject: [PATCH 09/16] =?UTF-8?q?[as=20instl]=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=EC=8B=9C=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=B2=A8=EB=B6=80=20=ED=8C=8C=EC=9D=BC=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=B4=EC=84=9C=20=EC=82=AD=EC=A0=9C=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20Y=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/ui/cnfm-regist-page.tsx | 108 ++++++++++++------ apps/as/src/shared/lib/attach.ts | 17 +++ 2 files changed, 87 insertions(+), 38 deletions(-) create mode 100644 apps/as/src/shared/lib/attach.ts diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index 7c5978b4b..8e1e9af50 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -8,6 +8,7 @@ import axios from "axios"; import { type HttpResponse, Upload } from "tus-js-client"; import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config"; +import { deleteAttachment } from "@/shared/lib/attach"; import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib"; import type { AcntgSeMngTreeDto } from "@/entities/acntg"; @@ -236,6 +237,28 @@ function CnfmRegistPage() { upload.start(); }; + /** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */ + const handleDeleteEtcFile = async (fileId: string, atflId: string) => { + try { + if (atflId) { + await deleteAttachment(atflId); + } + } catch (error) { + console.error("기타 첨부파일 삭제 실패", error); + alert("파일 삭제 중 오류가 발생했습니다."); + return; + } + + setEtcFiles((prev) => { + const next = prev.filter((x) => x.id !== fileId); + if (next.length === 0) { + setEtcFileGroupId(""); + etcFileGroupIdRef.current = ""; + } + return next; + }); + }; + const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -286,7 +309,7 @@ function CnfmRegistPage() { name: file.name, ext, size: toFileSizeText(file.size), - uploader: "(로그인 사용자)", + uploader: userInfo?.userName ?? "알 수 없음", atflCd: uploadedAtflCd, atflId: uploadedAtflId, }); @@ -747,7 +770,7 @@ function CnfmRegistPage() { - - - - + - + - +
+ * 사업명 @@ -761,7 +784,7 @@ function CnfmRegistPage() { + * 사업기간 @@ -783,7 +806,7 @@ function CnfmRegistPage() {
+ * 사업내용 @@ -796,12 +819,12 @@ function CnfmRegistPage() {
구분구분
{taskSeComCdNm}
신청 지자체신청 지자체 {userInfo?.enoNm ?? "\u00A0"} @@ -809,7 +832,7 @@ function CnfmRegistPage() {
사업구분*사업구분
{[ @@ -841,31 +864,31 @@ function CnfmRegistPage() { - - + + - + - + - + - - + + - + - + - +
신청자이름신청자이름 {userInfo?.userName ?? "\u00A0"} 전화번호전화번호 {userInfo?.mphn ?? userInfo?.telno ?? "\u00A0"}
부서부서 {userInfo?.instSeNm ?? "\u00A0"} 이메일이메일 {userInfo?.email ?? "\u00A0"}
설계자이름설계자이름 setDsgnrNm(e.target.value)} /> 전화번호전화번호
소속소속 이메일이메일

개인정보 동의

-
-

[개인정보 수집·이용 목적]

-
    -
  • 수집항목: 소속기관, 성명, 전화번호, 이메일, IP Address, 접속 기기 정보
  • -
  • 수집목적: 하수도 시설물 설치인가 신청 및 처리 목적
  • -
  • 보유기간: 관련 법령에 따른 의무 보유 기간 준수
  • -
-

- ※ 개인정보 제공에 동의하지 않을 수 있으나, 미동의 시 서비스 이용이 제한될 수 있습니다. -

+
+

[ 개인정보 수집항목 ]

+
+ * 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호
+ ․ 선택항목 : 휴대폰 번호, 이메일 주소
+ ․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록
+ ※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다. +
+ +

[ 개인정보 이용목적 ]

+
+ ․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용
+ ․ 국가하수도정보시스템 사용에 필요한 권한 부여
+ ․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성 / 예산편성 등) 제공에 이용 +
+ +
● 개인정보의 보유 및 이용기간
+
+ ․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.
+ ․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다. +
+ +
+
● 개인정보 제공 거부에 따른 제한사항
+
+ ․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다. +
+
-