From 12152a681016d5a0fe0655dec2fb431b30e9fa96 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 29 Sep 2026 17:41:37 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[as]=20chore:=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=B3=B4=EC=99=84=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=BB=A8=ED=85=90=EC=B8=A0=20=EC=B5=9C=EB=8C=80=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/fnrs-cnsltn/ui/components/file-vrfc-splmnt-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/file-vrfc-splmnt-dialog.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/file-vrfc-splmnt-dialog.tsx index 1395883d2..51a8231c1 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/file-vrfc-splmnt-dialog.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/file-vrfc-splmnt-dialog.tsx @@ -145,7 +145,7 @@ function FileVrfcSplmntDialog({ 파일보완 -
{tableComponent}
+
{tableComponent}
diff --git a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx index dc904c38e..828a130f6 100644 --- a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx @@ -12,7 +12,7 @@ export function DashboardPage() { return (
-
+
diff --git a/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx index e355b0585..9badf1245 100644 --- a/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx +++ b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx @@ -33,7 +33,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar const itemByCd = new Map(items?.map((item) => [item.docSeComCd, item])); return ( -
+
{DOC_PRGRS_STTS_SLOTS.map((slot) => { const item = itemByCd.get(slot.docSeComCd); return ( diff --git a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx index 85242d34b..b55dd35a2 100644 --- a/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx +++ b/apps/as/src/pages/design-cnstn/ui/design-form-page.tsx @@ -106,6 +106,11 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro async function onApply(data: DesignCnstnRegistFormData) { if (isSubmitting) return; + if (data.privacyConsent !== "Y") { + alert("개인정보 수집/이용에 동의해야 신청이 가능합니다."); + return; + } + if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) { alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다."); return; diff --git a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx index aa13b9f2a..fa6d685aa 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx @@ -149,6 +149,11 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps async function onApply(data: InstlSncnRegistFormData) { if (isSubmitting) return; + if (data.privacyConsent !== "Y") { + alert("개인정보 수집/이용에 동의해야 신청이 가능합니다."); + return; + } + if (!isFormChecklistComplete(data.cklstResultList)) { alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다."); return; From 5331e10c96aae8dabc4a81eced9dd3c17a50c4d2 Mon Sep 17 00:00:00 2001 From: yangsein Date: Wed, 30 Sep 2026 16:59:05 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[refactor]=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20-=20=ED=86=B5=EA=B3=84=EC=B2=98=EB=A6=AC=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EB=B0=98=EB=A0=A4=EB=90=98=EC=96=B4=20=EC=A0=9C?= =?UTF-8?q?=EC=B6=9C=EC=A4=91=EC=9D=B8=20=EC=83=81=ED=83=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=20=EC=B2=98=EB=A6=AC=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/dashboard/dashboard-interface.ts | 3 + .../ui/panels/three-stage-status-panel.tsx | 144 ++++++++++++++---- 2 files changed, 120 insertions(+), 27 deletions(-) diff --git a/apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts b/apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts index cb3d69db7..603c7f103 100644 --- a/apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts +++ b/apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts @@ -69,10 +69,13 @@ export interface ThreeStageStatusResponse { statsYr: string; laeStatusCd: string; laeStatusNm: string; + laeRejected: boolean; ctpvStatusCd: string; ctpvStatusNm: string; + ctpvRejected: boolean; pbcpStatusCd: string; pbcpStatusNm: string; + pbcpRejected: boolean; } export interface DashboardService { diff --git a/apps/st/src/pages/statistics/ui/panels/three-stage-status-panel.tsx b/apps/st/src/pages/statistics/ui/panels/three-stage-status-panel.tsx index ac914a93f..d90ced782 100644 --- a/apps/st/src/pages/statistics/ui/panels/three-stage-status-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/three-stage-status-panel.tsx @@ -15,26 +15,62 @@ interface StepDef { label: string; subLabel: string; description: string; + rejectedDescription: string; } const STEPS: StepDef[] = [ - { key: "input", label: "입력", subLabel: "작성자", description: "담당자가 통계 자료를 입력합니다." }, - { key: "submit", label: "제출", subLabel: "작성자", description: "입력을 마친 자료를 제출합니다." }, - { key: "approve", label: "승인", subLabel: "승인자", description: "지자체 승인자가 제출 내용을 승인합니다." }, - { key: "review", label: "검토", subLabel: "시도", description: "광역시도 담당자가 자료를 검토합니다." }, - { key: "verify", label: "검증", subLabel: "공단", description: "환경공단이 최종 검증을 진행합니다." }, + { + key: "input", + label: "입력", + subLabel: "작성자", + description: "담당자가 통계 자료를 입력합니다.", + rejectedDescription: "반려된 자료에 대한 수정보완 후 제출합니다.", + }, + { + key: "submit", + label: "제출", + subLabel: "작성자", + description: "입력을 마친 자료를 제출합니다.", + rejectedDescription: "반려된 자료에 대한 수정보완 후 제출합니다.", + }, + { + key: "approve", + label: "승인", + subLabel: "승인자", + description: "지자체 승인자가 제출 내용을 승인합니다.", + rejectedDescription: "반려된 자료에 대한 수정보완 후 제출합니다.", + }, + { + key: "review", + label: "검토", + subLabel: "시도", + description: "광역시도 담당자가 자료를 검토합니다.", + rejectedDescription: "반려된 자료에 대한 수정보완 후 재검토를 요청합니다.", + }, + { + key: "verify", + label: "검증", + subLabel: "공단", + description: "환경공단이 최종 검증을 진행합니다.", + rejectedDescription: "반려된 자료에 대한 수정보완 후 재검증을 요청합니다.", + }, ]; +interface ThreeStageData { + laeStatusCd: string; + laeRejected: boolean; + ctpvStatusCd: string; + ctpvRejected: boolean; + pbcpStatusCd: string; + pbcpRejected: boolean; +} + /** * lae_sbmsn_stts_cd(지자체) : 0=미작성 1=작성중 2=제출완료 3=승인 * stat_pic_sbmsn_stts_cd(광역시도), pbcp_sbmsn_stts_cd(공단) 도 동일 코드 도메인 가정 * - 실제 코드값 의미는 STINPUTSTP 코드 그룹 기준 재확인 필요 */ -function resolveCheckedSteps(data?: { - laeStatusCd: string; - ctpvStatusCd: string; - pbcpStatusCd: string; -}): boolean[] { +function resolveCheckedSteps(data?: ThreeStageData): boolean[] { if (!data) return STEPS.map(() => false); const lae = Number(data.laeStatusCd); @@ -50,6 +86,18 @@ function resolveCheckedSteps(data?: { ]; } +// input/submit/approve → lae 반려여부, review → ctpv, verify → pbcp +function resolveRejectedFlags(data?: ThreeStageData): boolean[] { + if (!data) return STEPS.map(() => false); + return [ + data.laeRejected, + data.laeRejected, + data.laeRejected, + data.ctpvRejected, + data.pbcpRejected, + ]; +} + interface ThreeStageStatusPanelProps { params: DashboardSearchParams | null; } @@ -59,10 +107,23 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) { const { data, isLoading, isFetching } = useDashboardThreeStageStatus(params); const checkedList = resolveCheckedSteps(data); + const rejectedFlags = resolveRejectedFlags(data); const currentIndex = checkedList.findIndex((v) => !v); const isAllDone = currentIndex === -1; const currentStep = STEPS[isAllDone ? STEPS.length - 1 : currentIndex]!; - const currentLabel = isAllDone ? "전 단계 완료" : `${currentStep.label} 대기중`; + const isCurrentRejected = !isAllDone && rejectedFlags[currentIndex]; + + const currentLabel = isAllDone + ? "전 단계 완료" + : isCurrentRejected + ? `${currentStep.label} 반려` + : `${currentStep.label} 대기중`; + + const currentDescription = isAllDone + ? "모든 절차가 완료되었습니다." + : isCurrentRejected + ? currentStep.rejectedDescription + : currentStep.description; return (
@@ -76,7 +137,9 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) { className={`rounded-full px-4 py-1.5 text-base font-medium ${ isAllDone ? "bg-blue-50 text-blue-600" - : "bg-amber-50 text-amber-600" + : isCurrentRejected + ? "bg-red-50 text-red-600" + : "bg-amber-50 text-amber-600" }`} > {currentLabel} @@ -94,6 +157,7 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) { {STEPS.map((step, index) => { const isChecked = checkedList[index]; const isCurrent = index === currentIndex; + const isRejectedStep = isCurrent && rejectedFlags[index]; const isLast = index === STEPS.length - 1; const lineFilled = checkedList[index]; @@ -101,16 +165,29 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) {
- {isCurrent && ( + {isRejectedStep && ( +
+ + 반려 + + +
+ )} + {isCurrent && !isRejectedStep && ( )} + {isRejectedStep && ( + + )}
{isChecked ? ( @@ -128,7 +205,11 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) { ) : ( )} @@ -139,9 +220,11 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) { className={`text-xl font-bold ${ isChecked ? "text-blue-700" - : isCurrent - ? "text-blue-600" - : "text-gray-400" + : isRejectedStep + ? "text-red-600" + : isCurrent + ? "text-blue-600" + : "text-gray-400" }`} > {step.label} @@ -164,15 +247,22 @@ export function ThreeStageStatusPanel({ params }: ThreeStageStatusPanelProps) { })}
-
-

+

+

{isAllDone ? "현재 단계" : "다음 진행 단계"} · {currentStep.label} + {isCurrentRejected ? " (반려)" : ""}

-

- {isAllDone - ? "모든 절차가 완료되었습니다." - : currentStep.description} -

+

{currentDescription}

From f2ec9327fdc38d4b4024a5e190ac94cab06f086b Mon Sep 17 00:00:00 2001 From: rsone001 Date: Wed, 30 Sep 2026 17:17:01 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20[ST]=20-=20eval=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/shared/lib/csp-monitor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/st/src/shared/lib/csp-monitor.ts b/apps/st/src/shared/lib/csp-monitor.ts index 9ed635f12..b28855bd5 100644 --- a/apps/st/src/shared/lib/csp-monitor.ts +++ b/apps/st/src/shared/lib/csp-monitor.ts @@ -80,7 +80,7 @@ class CSPMonitor { // 인라인 스크립트 테스트 try { - eval('console.log("CSP test: inline script execution")'); + console.log('console.log("CSP test: inline script execution")'); } catch { console.log('CSP blocked inline script execution'); } From ba0964f77252d0edf483321f2412f114c2c14cec Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 1 Oct 2026 13:44:46 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[as]=20feat:=20useTus=20>=20handleError?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/entities/tus/index.ts | 2 +- apps/as/src/entities/tus/lib/utils.ts | 39 +++++++++++++++++-- apps/as/src/shared/lib/use-tus.ts | 15 ++++--- apps/as/src/shared/ui/file-add-button.tsx | 1 - .../hangulgiangi/ui/hangul-giangi-toolbar.tsx | 4 +- 5 files changed, 48 insertions(+), 13 deletions(-) diff --git a/apps/as/src/entities/tus/index.ts b/apps/as/src/entities/tus/index.ts index 5cf2e3fbf..58687d0ef 100644 --- a/apps/as/src/entities/tus/index.ts +++ b/apps/as/src/entities/tus/index.ts @@ -1 +1 @@ -export { handleTusUpload, type TusUploadOptions } from "./lib/utils"; +export { handleTusUpload, type TusErrorResponse, type TusUploadOptions } from "./lib/utils"; diff --git a/apps/as/src/entities/tus/lib/utils.ts b/apps/as/src/entities/tus/lib/utils.ts index 6bb40bfd7..96fcd5195 100644 --- a/apps/as/src/entities/tus/lib/utils.ts +++ b/apps/as/src/entities/tus/lib/utils.ts @@ -1,15 +1,29 @@ -import { Upload } from "tus-js-client"; +import { type DetailedError, Upload } from "tus-js-client"; import { ENDPOINT, PROXY_HOST } from "@/shared/config"; import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env"; import { getCookie } from "@/shared/lib"; +interface TusFieldError { + message: string; + field: string; + rejectedValue: string; +} + +interface TusErrorResponse { + timestamp: number[]; + message: string; + status: number; + code: string; + errors: TusFieldError[]; +} + interface TusUploadOptions { metadata?: Record; handleUploadStart?: (upload: Upload) => void; handleProgress?: (percentage: number) => void; handleSuccess?: (upload: Upload, atflCd: string, atflId: string) => void; - handleError?: (error: Error) => void; + handleError?: (error: Error, response: TusErrorResponse | null) => void; } function handleTusUpload(file: File, options?: TusUploadOptions) { @@ -32,7 +46,7 @@ function handleTusUpload(file: File, options?: TusUploadOptions) { metadata, onError(error) { console.error("TUS upload failed:", error); - options?.handleError?.(error); + options?.handleError?.(error, parseTusErrorResponse(error)); }, onProgress(bytesUploaded, bytesTotal) { const percentage = (bytesUploaded / bytesTotal) * 100; @@ -57,5 +71,22 @@ function handleTusUpload(file: File, options?: TusUploadOptions) { return upload; } +function parseTusErrorResponse(error: Error | DetailedError): TusErrorResponse | null { + if (!("originalResponse" in error)) return null; + + const body = error.originalResponse?.getBody(); + if (!body) return null; + + try { + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== "object" || parsed === null) return null; + if (typeof (parsed as TusErrorResponse).message !== "string") return null; + + return parsed as TusErrorResponse; + } catch { + return null; + } +} + export { handleTusUpload }; -export type { TusUploadOptions }; +export type { TusErrorResponse, TusUploadOptions }; diff --git a/apps/as/src/shared/lib/use-tus.ts b/apps/as/src/shared/lib/use-tus.ts index 7a48cad6c..da054fd82 100644 --- a/apps/as/src/shared/lib/use-tus.ts +++ b/apps/as/src/shared/lib/use-tus.ts @@ -4,7 +4,7 @@ import { useCallback, useState } from "react"; import { type Upload } from "tus-js-client"; -import { handleTusUpload } from "@/entities/tus"; +import { handleTusUpload, type TusErrorResponse } from "@/entities/tus"; import { useAlertStore } from "./use-alert-store"; @@ -15,7 +15,12 @@ interface UploadFileOptions { interface UseTusOptions { onSuccess?: (atflCd: string, atflId: string, file: File, fileKey: string) => void; - onError?: (error: Error, file: File, fileKey: string) => void; + onError?: ( + error: Error, + file: File, + fileKey: string, + response: TusErrorResponse | null, + ) => void; accept?: string; } @@ -80,10 +85,10 @@ function useTus(options: UseTusOptions = {}): UseTusReturn { options.onSuccess?.(atflCd, atflId, file, fileKey); resolve(atflCd); }, - handleError: (error) => { + handleError: (error, response) => { setIsUploading((prev) => ({ ...prev, [fileKey]: false })); - alert("파일 업로드 중 오류가 발생했습니다."); - options.onError?.(error, file, fileKey); + alert(response?.message ?? "파일 업로드 중 오류가 발생했습니다."); + options.onError?.(error, file, fileKey, response); reject(error); }, }); diff --git a/apps/as/src/shared/ui/file-add-button.tsx b/apps/as/src/shared/ui/file-add-button.tsx index 36c41406e..922e660b8 100644 --- a/apps/as/src/shared/ui/file-add-button.tsx +++ b/apps/as/src/shared/ui/file-add-button.tsx @@ -40,7 +40,6 @@ function FileAddButton({
- - - handleCostChange(idx, "bgtRfltAmt", e.target.value)} - readOnly={item.readonly} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - - - - handleCostChange(idx, "bscPlanAmt", e.target.value)} - readOnly={item.readonly} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - - - - handleCostChange(idx, "tstmDesignAmt", e.target.value)} - readOnly={item.readonly} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - + {COST_FIELDS.map((fieldKey) => ( + + + handleCostChange(idx, fieldKey, e.target.value)} + readOnly={item.readonly} + className={`text-right ${item.readonly ? "bg-muted" : ""}`} + /> + + + ))}
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()} diff --git a/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx index de479a0cf..4998e4a72 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/business-scale-form-section.tsx @@ -18,6 +18,7 @@ import { } from "@common/components"; import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils"; +import { truncateDecimals } from "../lib/format-utils"; interface BusinessScaleFormData { bzScaleList: Array<{ @@ -31,6 +32,8 @@ interface BusinessScaleFormData { type ScaleFieldKey = "bscPl" | "budgetReflct" | "thtiDsgn"; +const SCALE_FIELDS: ScaleFieldKey[] = ["bscPl", "budgetReflct", "thtiDsgn"]; + function isSameBzScaleCodes( current: BusinessScaleFormData["bzScaleList"], next: BusinessScaleFormData["bzScaleList"], @@ -73,7 +76,9 @@ function BusinessScaleFormSection() { }, [scaleItems, getValues, setValue]); function handleScaleChange(index: number, fieldKey: ScaleFieldKey, value: string) { - const numValue = Number(value) || 0; + const item = scaleItems[index]; + const normalized = item?.numYn ? truncateDecimals(value, item.dcptUnitCnt) : value; + const numValue = Number(normalized) || 0; const draft = [...getValues("bzScaleList")]; draft[index] = { ...draft[index], [fieldKey]: numValue }; const next = recalculateBzScaleTotals(draft, scaleItems); @@ -123,39 +128,20 @@ function BusinessScaleFormSection() { {item.label}
- - - handleScaleChange(idx, "bscPl", e.target.value)} - readOnly={item.readonly} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - - - - handleScaleChange(idx, "budgetReflct", e.target.value)} - readOnly={item.readonly} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - - - - handleScaleChange(idx, "thtiDsgn", e.target.value)} - readOnly={item.readonly} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - + {SCALE_FIELDS.map((fieldKey) => ( + + + handleScaleChange(idx, fieldKey, e.target.value)} + readOnly={item.readonly} + className={`text-right ${item.readonly ? "bg-muted" : ""}`} + /> + + + ))}
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()} From d2645a9c08f557433c3da5c0615e042cbc74a897 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 1 Oct 2026 15:36:02 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[as]=20feat:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=99=95=EC=9E=A5=EC=9E=90=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=A0=95=EC=B1=85=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/shared/lib/use-tus.ts | 34 +++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/as/src/shared/lib/use-tus.ts b/apps/as/src/shared/lib/use-tus.ts index da054fd82..4f13dafbd 100644 --- a/apps/as/src/shared/lib/use-tus.ts +++ b/apps/as/src/shared/lib/use-tus.ts @@ -32,6 +32,28 @@ interface UseTusReturn { uploads: Record; } +const ALLOWED_EXTENSIONS: readonly string[] = [ + // 문서 + ".pdf", + ".txt", + ".hwp", + ".hwpx", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + // 이미지 + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + // 압축 + ".zip", +]; + function getDefaultFileKey(file: File) { return `${file.name}::${file.lastModified}::${file.size}`; } @@ -44,12 +66,16 @@ function useTus(options: UseTusOptions = {}): UseTusReturn { const validateFileType = useCallback( (file: File): boolean => { - if (!options.accept) return true; - - const allowedExtensions = options.accept.split(","); const fileExtension = file.name.substring(file.name.lastIndexOf(".")).toLowerCase(); + const acceptExtensions = options.accept + ?.split(",") + .map((extension) => extension.trim().toLowerCase()); - if (!allowedExtensions.includes(fileExtension)) { + const isAllowed = + ALLOWED_EXTENSIONS.includes(fileExtension) && + (!acceptExtensions || acceptExtensions.includes(fileExtension)); + + if (!isAllowed) { alert(`허용되지 않은 파일 형식입니다. (${fileExtension})`); return false; } From 4167107a913bbfac357e10497a99a087223ec8cb Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 1 Oct 2026 16:41:10 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[as]=20feat:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=97=90=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/shared/lib/use-tus.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/as/src/shared/lib/use-tus.ts b/apps/as/src/shared/lib/use-tus.ts index 4f13dafbd..f07f009c0 100644 --- a/apps/as/src/shared/lib/use-tus.ts +++ b/apps/as/src/shared/lib/use-tus.ts @@ -87,12 +87,14 @@ function useTus(options: UseTusOptions = {}): UseTusReturn { const uploadFileAsync = useCallback( (file: File, uploadOptions?: UploadFileOptions): Promise => { - if (!validateFileType(file)) { - return Promise.reject(new Error("Invalid file type")); - } - const fileKey = uploadOptions?.fileKey ?? getDefaultFileKey(file); + if (!validateFileType(file)) { + const error = new Error("Invalid file type"); + options.onError?.(error, file, fileKey, null); + return Promise.reject(error); + } + setIsUploading((prev) => ({ ...prev, [fileKey]: true })); setProgress((prev) => ({ ...prev, [fileKey]: 0 })); From c5a0e378fd7abd74720c21e87b6f52ac3f4c493c Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 2 Oct 2026 11:37:45 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[as]=20feat:=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?-=20=EC=8B=A4=EB=AC=B4=ED=98=91=EC=9D=98=EC=84=9C=20=EB=82=B4?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84=EC=9A=94=EC=B2=AD=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/entities/dashboard/index.ts | 1 + .../dashboard/model/dashboard-interface.ts | 13 ++++++ .../dashboard/lib/use-national-as-map.ts | 46 ++++++++++++++----- apps/as/src/pages/dashboard/model/map-data.ts | 7 +++ .../src/pages/dashboard/ui/dashboard-page.tsx | 2 +- .../pages/dashboard/ui/national-as-map.tsx | 10 +++- .../ui/components/floating-action-buttons.tsx | 16 +++++-- .../components/pwrk-cnsltn-doc-mng-dialog.tsx | 3 ++ .../hangulgiangi/ui/hangul-giangi-toolbar.tsx | 10 +++- .../widgets/hangulgiangi/ui/hangul-giangi.tsx | 3 ++ 10 files changed, 91 insertions(+), 20 deletions(-) diff --git a/apps/as/src/entities/dashboard/index.ts b/apps/as/src/entities/dashboard/index.ts index 19d5b2c0b..46bd4250f 100644 --- a/apps/as/src/entities/dashboard/index.ts +++ b/apps/as/src/entities/dashboard/index.ts @@ -1,6 +1,7 @@ export { dashboardService } from "./api/dashboard-service"; export { dashboardBaseKey, useGetMainDashboard } from "./api/queries"; export type { + BasinDocPrgrsResponseDto, DashboardMainResponseDto, DashboardMainSearchDto, DashboardService, diff --git a/apps/as/src/entities/dashboard/model/dashboard-interface.ts b/apps/as/src/entities/dashboard/model/dashboard-interface.ts index fb592680e..abad209c5 100644 --- a/apps/as/src/entities/dashboard/model/dashboard-interface.ts +++ b/apps/as/src/entities/dashboard/model/dashboard-interface.ts @@ -18,6 +18,18 @@ export interface DocPrgrsSttsResponseDto { totCnt: number; } +/** 백엔드 BasinDocPrgrsResponseDto */ +export interface BasinDocPrgrsResponseDto { + basinKey: string; + basinNm: string; + docSeComCd: string; + docSeNm: string; + reqCnt: number; + prgrsCnt: number; + cmptnCnt: number; + totCnt: number; +} + /** 백엔드 NotiAsUserResponseDto */ export interface NotiAsUserResponseDto { notiId: number; @@ -48,6 +60,7 @@ export interface NotiAsStatResponseDto { /** 백엔드 DashboardMainResponseDto */ export interface DashboardMainResponseDto { docPrgrsSttsList: DocPrgrsSttsResponseDto[]; + basinDocPrgrsList: BasinDocPrgrsResponseDto[]; notiList: NotiAsUserResponseDto[]; notiStats: NotiAsStatResponseDto; } diff --git a/apps/as/src/pages/dashboard/lib/use-national-as-map.ts b/apps/as/src/pages/dashboard/lib/use-national-as-map.ts index 997c0154a..c46748ba2 100644 --- a/apps/as/src/pages/dashboard/lib/use-national-as-map.ts +++ b/apps/as/src/pages/dashboard/lib/use-national-as-map.ts @@ -1,7 +1,10 @@ import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react"; +import type { BasinDocPrgrsResponseDto } from "@/entities/dashboard"; + import { BASIN_DATA, + BASIN_DOC_SE_SLOTS, BASIN_IDS, type BasinKey, MANUAL_LABEL_POS, @@ -15,8 +18,6 @@ const POPUP_EDGE_PADDING_PX = 12; const LABEL_OFFSET_Y_PX = 18; const DEFAULT_VIEWBOX = "0 0 100 100"; -export const PLACEHOLDER_POPUP_ITEMS = [{ header: "현황", data: "-" }] as const; - export interface MapPopupItem { header: string; data: string; @@ -27,10 +28,28 @@ export interface MapPopupContent { items: MapPopupItem[]; } -const INITIAL_POPUP_CONTENT: MapPopupContent = { - title: "", - items: [...PLACEHOLDER_POPUP_ITEMS], -}; +interface ActiveBasin { + key: string | undefined; + title: string; +} + +const INITIAL_ACTIVE_BASIN: ActiveBasin = { key: undefined, title: "" }; + +function buildPopupItems( + basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined, + basinKey: string | undefined, +): MapPopupItem[] { + const itemByCd = new Map( + basinDocPrgrsList + ?.filter((item) => item.basinKey === basinKey) + .map((item) => [item.docSeComCd, item]), + ); + + return BASIN_DOC_SE_SLOTS.map((slot) => { + const item = itemByCd.get(slot.docSeComCd); + return { header: slot.label, data: item ? String(item.totCnt) : "-" }; + }); +} function isBasinKey(key: string | undefined): key is BasinKey { return Boolean(key && key in BASIN_DATA); @@ -172,22 +191,25 @@ function positionLabels( }); } -export function useNationalAsMap() { +export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined) { const stageRef = useRef(null); const svgLayerRef = useRef(null); const labelsLayerRef = useRef(null); const popupRef = useRef(null); - const [popupContent, setPopupContent] = useState(INITIAL_POPUP_CONTENT); + const [activeBasin, setActiveBasin] = useState(INITIAL_ACTIVE_BASIN); + const popupContent: MapPopupContent = { + title: activeBasin.title, + items: buildPopupItems(basinDocPrgrsList, activeBasin.key), + }; const showFor = useCallback((group: SVGGElement) => { const stage = stageRef.current; const popup = popupRef.current; if (!stage || !popup) return; - setPopupContent({ - title: resolveBasinName(group), - items: [...PLACEHOLDER_POPUP_ITEMS], - }); + const key = group.dataset.key; + const title = resolveBasinName(group); + setActiveBasin((prev) => (prev.key === key && prev.title === title ? prev : { key, title })); showPopupAtGroup(stage, popup, group); }, []); diff --git a/apps/as/src/pages/dashboard/model/map-data.ts b/apps/as/src/pages/dashboard/model/map-data.ts index 18f703161..594cdab03 100644 --- a/apps/as/src/pages/dashboard/model/map-data.ts +++ b/apps/as/src/pages/dashboard/model/map-data.ts @@ -19,6 +19,13 @@ export const BASIN_IDS = [ { key: "yeongsan", labels: ["영산강", "yeongsan"] }, ] as const; +export const BASIN_DOC_SE_SLOTS = [ + { docSeComCd: "FNRS", label: "재원협의" }, + { docSeComCd: "INSTL", label: "설치인가" }, + { docSeComCd: "CNFM", label: "설계자문" }, + { docSeComCd: "SEWER", label: "기본계획" }, +] as const; + export type BasinKey = keyof typeof BASIN_DATA; export interface ManualLabelPos { diff --git a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx index 828a130f6..2093dfc48 100644 --- a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx @@ -13,7 +13,7 @@ export function DashboardPage() { return (
- +
diff --git a/apps/as/src/pages/dashboard/ui/national-as-map.tsx b/apps/as/src/pages/dashboard/ui/national-as-map.tsx index 096bbed89..82e93676c 100644 --- a/apps/as/src/pages/dashboard/ui/national-as-map.tsx +++ b/apps/as/src/pages/dashboard/ui/national-as-map.tsx @@ -2,13 +2,19 @@ import type { CSSProperties } from "react"; +import type { BasinDocPrgrsResponseDto } from "@/entities/dashboard"; + import { useNationalAsMap } from "../lib/use-national-as-map"; import styles from "../lib/national-as-map.module.css"; -export function NationalAsMap() { +interface NationalAsMapProps { + basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined; +} + +export function NationalAsMap({ basinDocPrgrsList }: NationalAsMapProps) { const { stageRef, svgLayerRef, labelsLayerRef, popupRef, popupContent, popupGridStyle } = - useNationalAsMap(); + useNationalAsMap(basinDocPrgrsList); const popupColumnSpan = Math.max(1, popupContent.items.length); return ( diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx index 7d50b9dc8..cba9b1b19 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/floating-action-buttons.tsx @@ -97,7 +97,6 @@ type ActiveDialog = | "elaprTmplt" | "elaprDocPrgrs" | "rjct" - | "dmndSplmnt" | "fileSplmnt" | "elaprApproval" | "pwrkCnsltnDocMng" @@ -124,6 +123,7 @@ function FloatingActionButtons({ const alert = useAlertStore((state) => state.setMessage); const [isProcessing, setIsProcessing] = useState(false); const [activeDialog, setActiveDialog] = useState(null); + const [isDmndSplmntDialogOpen, setIsDmndSplmntDialogOpen] = useState(false); const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo; const isRegister = data.detDto?.rgtr === userInfo?.userUnqId; @@ -217,6 +217,10 @@ function FloatingActionButtons({ const canEdit = isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg); + const canDmndSplmntInPwrkCnsltnDoc = + prgrsSttsComCd === "RK" && + atrzSeComCdList.includes("ATRZ02") && + actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt); function handleGoToList() { router.push(getListHref("/fnrs-cnsltn")); @@ -533,6 +537,7 @@ function FloatingActionButtons({ } async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) { + setIsDmndSplmntDialogOpen(false); setActiveDialog(null); const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState); await executeAction({ @@ -763,7 +768,7 @@ function FloatingActionButtons({ {shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
{pjtcoAjmtPage?.content?.length ? ( diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx index b5bd5db0d..b11182436 100644 --- a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi-toolbar.tsx @@ -2,7 +2,7 @@ import { useId } from "react"; -import { Download, FolderOpen, Fullscreen, Save } from "lucide-react"; +import { Download, FilePen, FolderOpen, Fullscreen, Save } from "lucide-react"; import { Button, cn } from "@common/components"; @@ -15,6 +15,7 @@ interface HangulGiangiToolbarProps { onSave: () => void; onDownload: () => void; onFullscreen: () => void; + onDmndSplmnt?: () => void; } function HangulGiangiToolbar({ @@ -24,6 +25,7 @@ function HangulGiangiToolbar({ onSave, onDownload, onFullscreen, + onDmndSplmnt, }: HangulGiangiToolbarProps) { const openInputId = useId(); @@ -79,6 +81,12 @@ function HangulGiangiToolbar({ 전체화면 + {onDmndSplmnt && ( + + )}
); } diff --git a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx index db9db7c32..5a382d640 100644 --- a/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx +++ b/apps/as/src/widgets/hangulgiangi/ui/hangul-giangi.tsx @@ -21,6 +21,7 @@ function HangulGiangi({ documentFileName, onSaveDocument, onDownload, + onDmndSplmnt, canSave = true, bridgeUrl = DEFAULT_BRIDGE_URL, ctrlBaseUrl = WEBHYP_CTRL_URL, @@ -206,6 +207,7 @@ function HangulGiangi({ onSave={handleSave} onDownload={handleDownload} onFullscreen={handleFullscreen} + onDmndSplmnt={onDmndSplmnt} />