From 87fbf15e2b44db1c97adb56938dd69c78354a0c5 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 22 Sep 2026 10:23:10 +0900 Subject: [PATCH 01/66] =?UTF-8?q?[as]=20feat:=20=EC=9E=AC=EC=9B=90?= =?UTF-8?q?=ED=98=91=EC=9D=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/fnrs-cnsltn/lib/button-config.ts | 1 - .../pages/fnrs-cnsltn/ui/fnrs-form-page.tsx | 5 + .../pbadms-sprt/ui/fnrs-shar-form-section.tsx | 98 ++++++++++++------- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts index 3b0aca199..2c52dfa9d 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/button-config.ts @@ -51,7 +51,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record(); const fnrsSharList = useWatch({ control, name: "fnrsSharList" }) ?? []; - const [uploadedFileName, setUploadedFileName] = useState( - () => initialSharAtchFiles?.[0]?.orgnlFileNm ?? "", + const [uploadedFile, setUploadedFile] = useState(() => + toInitialSharFile(initialSharAtchFiles?.[0]), ); + const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId(); const { data: masterTree = [], isLoading: isMasterLoading } = useFnrsSharSeMngTree(); const { data: acntgTree = [] } = useAcntgSeMngTree(); @@ -78,9 +95,13 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar const isLoading = initialSharTree?.length ? false : isMasterLoading; const { uploadFileAsync, isUploading } = useTus({ - onSuccess: (atflCd) => { + onSuccess: (atflCd, atflId, _file, fileKey) => { if (!atflCd) return; setValue("fnrsCnsltnMng.sharAtchFileGroupId", atflCd, { shouldDirty: true }); + setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflId } : prev)); + }, + onError: (_error, _file, fileKey) => { + setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); }, }); const isSharUploading = Object.values(isUploading).some(Boolean); @@ -120,16 +141,32 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar applySharList(draft); } - async function handleSharFileChange(event: React.ChangeEvent) { - const file = event.target.files?.[0]; + async function handleSharFileAdd(files: FileList | null) { + const file = files?.[0]; if (!file) return; const fileKey = createSharUploadKey(file); - setUploadedFileName(file.name); - await uploadFileAsync(file, { fileKey }); - event.target.value = ""; + setUploadedFile({ fileName: file.name, fileKey }); + + try { + await uploadFileAsync(file, { fileKey }); + } catch { + // onError callback handles cleanup + } } + const sharAtchFiles = uploadedFile + ? [ + { + fileKey: uploadedFile.fileKey, + fileName: uploadedFile.fileName, + onDownload: uploadedFile.atflId + ? () => downloadByAtflId(uploadedFile.atflId!) + : undefined, + }, + ] + : []; + if (isLoading) { return ( @@ -257,31 +294,20 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar

첨부파일

-
- {isSharUploading && uploadedFileName ? ( - - {uploadedFileName} - - ) : ( - - )} - -
+ {!uploadedFile ? ( +

+ 총사업비 신청서(.hwp) 파일을 등록해주세요. +

+ ) : null} + void handleSharFileAdd(fileList)} + />
); From 0dd2111cb947c4d67f7c25dc0703183e4f7d458e Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 22 Sep 2026 10:57:41 +0900 Subject: [PATCH 02/66] =?UTF-8?q?[as]=20refactor:=20=EC=9E=AC=EC=9B=90?= =?UTF-8?q?=ED=98=91=EC=9D=98=20=ED=8F=BC=20useWatch=20+=20Controller=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/attachments-form-section.tsx | 160 +++++++++--------- .../ui/business-cost-form-section.tsx | 115 ++++++------- .../ui/business-scale-form-section.tsx | 93 +++++----- .../pbadms-sprt/ui/checklist-form-section.tsx | 105 +++++------- .../pbadms-sprt/ui/fnrs-shar-form-section.tsx | 79 ++++----- 5 files changed, 243 insertions(+), 309 deletions(-) diff --git a/apps/as/src/widgets/pbadms-sprt/ui/attachments-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/attachments-form-section.tsx index 987c1d6ab..064691f14 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/attachments-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/attachments-form-section.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Controller, useFormContext } from "react-hook-form"; +import { useFormContext } from "react-hook-form"; import { useQueryClient } from "@tanstack/react-query"; import { Download } from "lucide-react"; @@ -78,7 +78,7 @@ function AttachmentsFormSection({ initialEsntlFiles, initialEtcFiles, }: AttachmentsFormSectionProps) { - const { control, getValues, setValue } = useFormContext(); + const { getValues, setValue } = useFormContext(); const queryClient = useQueryClient(); const [uploadedFiles, setUploadedFiles] = useState>({}); const [etcFiles, setEtcFiles] = useState([]); @@ -373,89 +373,83 @@ function AttachmentsFormSection({ return (
- ( -
-

필수 첨부파일

- - - - - - - - - - 파일명 - - 파일 확장자 - 파일첨부 - - - - {essentialFiles.length === 0 ? ( - - - 조회된 필수 첨부파일 항목이 없습니다. +
+

필수 첨부파일

+
+ + + + + + + + + 파일명 + + 파일 확장자 + 파일첨부 + + + + {essentialFiles.length === 0 ? ( + + + 조회된 필수 첨부파일 항목이 없습니다. + + + ) : ( + essentialFiles.map((file) => { + const atflCd = file.atchFileGroupId ?? ""; + + return ( + + +
+ {file.esntlAtchFileNm} + {atflCd ? ( + + ) : null} +
+
+ {file.esntlAtchFileRmrk} + + ({ + fileKey: item.fileKey, + fileName: item.file?.name ?? item.fileName, + onDownload: item.atflId + ? () => downloadByAtflId(item.atflId!) + : undefined, + }))} + inputId={`${file.esntlAtchFileId}-file-input`} + isUploadingByKey={isUploading} + accept={toFileAccept(file.esntlAtchFileRmrk) || undefined} + onAdd={(fileList) => + void handleEssentialFileUpload(file.esntlAtchFileId, fileList) + } + onRemove={(index) => + void removeEssentialFile(file.esntlAtchFileId, index) + } + />
- ) : ( - essentialFiles.map((file) => { - const atflCd = file.atchFileGroupId ?? ""; - - return ( - - -
- {file.esntlAtchFileNm} - {atflCd ? ( - - ) : null} -
-
- {file.esntlAtchFileRmrk} - - ({ - fileKey: item.fileKey, - fileName: item.file?.name ?? item.fileName, - onDownload: item.atflId - ? () => downloadByAtflId(item.atflId!) - : undefined, - }))} - inputId={`${file.esntlAtchFileId}-file-input`} - isUploadingByKey={isUploading} - accept={toFileAccept(file.esntlAtchFileRmrk) || undefined} - onAdd={(fileList) => - void handleEssentialFileUpload(file.esntlAtchFileId, fileList) - } - onRemove={(index) => - void removeEssentialFile(file.esntlAtchFileId, index) - } - /> - -
- ); - }) - )} -
-
-
- )} - /> + ); + }) + )} + + +
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx index dc50504a5..9bc5ce92d 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo } from "react"; -import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; import { SectionCollapsible } from "@/shared/ui"; import { Field } from "@/shared/ui/field"; @@ -31,6 +31,16 @@ interface BusinessCostFormData { type CostFieldKey = "bscPlanAmt" | "bgtRfltAmt" | "tstmDesignAmt"; +function isSameBizCstCodes( + current: BusinessCostFormData["bizCstList"], + next: BusinessCostFormData["bizCstList"], +) { + return ( + current.length === next.length && + current.every((item, index) => item.acntgSeCd === next[index]?.acntgSeCd) + ); +} + function BusinessCostFormSection() { const { control, setValue, getValues } = useFormContext(); const bizCstList = useWatch({ control, name: "bizCstList" }) ?? []; @@ -42,22 +52,22 @@ function BusinessCostFormSection() { useEffect(() => { if (costItems.length === 0) return; - const existingMap = new Map(getValues("bizCstList").map((item) => [item.acntgSeCd, item])); + const current = getValues("bizCstList") ?? []; + const existingMap = new Map(current.map((item) => [item.acntgSeCd, item])); + const next = costItems.map((item) => { + const existing = existingMap.get(item.code); + if (existing) return existing; - setValue( - "bizCstList", - costItems.map((item) => { - const existing = existingMap.get(item.code); - if (existing) return existing; + return { + acntgSeCd: item.code, + bscPlanAmt: 0, + bgtRfltAmt: 0, + tstmDesignAmt: 0, + }; + }); - return { - acntgSeCd: item.code, - bscPlanAmt: 0, - bgtRfltAmt: 0, - tstmDesignAmt: 0, - }; - }), - ); + if (isSameBizCstCodes(current, next)) return; + setValue("bizCstList", next); }, [costItems, getValues, setValue]); function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) { @@ -119,58 +129,37 @@ function BusinessCostFormSection() {
- ( - - handleCostChange(idx, "bgtRfltAmt", e.target.value)} - readOnly={item.readonly} - aria-invalid={fieldState.invalid} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - )} - /> + + 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} - aria-invalid={fieldState.invalid} - 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} - aria-invalid={fieldState.invalid} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - )} - /> + + handleCostChange(idx, "tstmDesignAmt", e.target.value)} + readOnly={item.readonly} + className={`text-right ${item.readonly ? "bg-muted" : ""}`} + /> +
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 2bee5e104..de479a0cf 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 @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo } from "react"; -import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; import { SectionCollapsible } from "@/shared/ui"; import { Field } from "@/shared/ui/field"; @@ -17,7 +17,7 @@ import { TableRow, } from "@common/components"; -import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils"; +import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils"; interface BusinessScaleFormData { bzScaleList: Array<{ @@ -31,6 +31,16 @@ interface BusinessScaleFormData { type ScaleFieldKey = "bscPl" | "budgetReflct" | "thtiDsgn"; +function isSameBzScaleCodes( + current: BusinessScaleFormData["bzScaleList"], + next: BusinessScaleFormData["bzScaleList"], +) { + return ( + current.length === next.length && + current.every((item, index) => item.cnstpSeCd === next[index]?.cnstpSeCd) + ); +} + function BusinessScaleFormSection() { const { control, setValue, getValues } = useFormContext(); const bzScaleList = useWatch({ control, name: "bzScaleList" }) ?? []; @@ -42,7 +52,8 @@ function BusinessScaleFormSection() { useEffect(() => { if (scaleItems.length === 0) return; - const existingMap = new Map(getValues("bzScaleList").map((item) => [item.cnstpSeCd, item])); + const current = getValues("bzScaleList") ?? []; + const existingMap = new Map(current.map((item) => [item.cnstpSeCd, item])); const next = scaleItems.map((item) => { const existing = existingMap.get(item.code); @@ -57,6 +68,7 @@ function BusinessScaleFormSection() { }; }); + if (isSameBzScaleCodes(current, next)) return; setValue("bzScaleList", recalculateBzScaleTotals(next, scaleItems)); }, [scaleItems, getValues, setValue]); @@ -112,58 +124,37 @@ function BusinessScaleFormSection() {
- ( - - handleScaleChange(idx, "bscPl", e.target.value)} - readOnly={item.readonly} - aria-invalid={fieldState.invalid} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - )} - /> + + 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} - aria-invalid={fieldState.invalid} - 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} - aria-invalid={fieldState.invalid} - className={`text-right ${item.readonly ? "bg-muted" : ""}`} - /> - - )} - /> + + handleScaleChange(idx, "thtiDsgn", e.target.value)} + readOnly={item.readonly} + className={`text-right ${item.readonly ? "bg-muted" : ""}`} + /> +
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx index 1e4c324bb..0fb9e136a 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx @@ -1,18 +1,15 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; import { useQuery } from "@tanstack/react-query"; import { SectionCollapsible } from "@/shared/ui"; -import { Field, FieldLabel } from "@/shared/ui/field"; import { cklstMngService, type CklstTreeNode } from "@/entities/cklst"; import { useCodeList } from "@/entities/code"; import { Button, - RadioGroup, - RadioGroupItem, Skeleton, Table, TableBody, @@ -125,11 +122,21 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP } }, [checklistTabs, activeTab]); + function handleSelectChange(resultIdx: number, value: "Y" | "N") { + const currentList = getValues("cklstResultList") as CklstResultListItem[]; + const row = currentList[resultIdx]; + if (!row) return; + + const newList = [...currentList]; + newList[resultIdx] = { ...row, selected: value }; + setValue("cklstResultList", newList, { shouldDirty: true }); + } + function handleSelectAll(tabCode: string, value: "Y" | "N") { const tab = checklistTabs.find((t) => t.code === tabCode); if (!tab) return; - const currentList = getValues("cklstResultList"); + const currentList = getValues("cklstResultList") as CklstResultListItem[]; const newList = [...currentList]; tab.items @@ -141,7 +148,7 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP } }); - setValue("cklstResultList", newList); + setValue("cklstResultList", newList, { shouldDirty: true }); } const hasSelectedTaskCodes = Boolean(taskSeComCdArray?.length); @@ -234,6 +241,8 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP {tab.items.map((item) => { const resultIdx = cklstRvwSnIndexMap.get(item.cklstRvwSn) ?? -1; const showRadios = item.bchckUseYn === "Y"; + const selected = cklstResultList[resultIdx]?.selected; + const radioName = `check-${item.cklstRvwSn}`; return ( @@ -248,66 +257,30 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP {item.content} {showRadios && resultIdx >= 0 ? ( - ( - <> - - - - - - - - - - - - - - - - - - - - )} - /> + <> + + handleSelectChange(resultIdx, "Y")} + aria-label="예" + className="cursor-pointer" + /> + + + handleSelectChange(resultIdx, "N")} + aria-label="아니오" + className="cursor-pointer" + /> + + ) : null} ); diff --git a/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx index 5109dbfe7..baff36f8b 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; import { getDefaultFileKey, useTus } from "@/shared/lib"; import { AttachmentFileField, SectionCollapsible } from "@/shared/ui"; @@ -78,6 +78,13 @@ function toInitialSharFile(file?: AtflMngClientResponseDto): SharUploadedFile | }; } +function isSameFnrsSharCodes(current: FnrsSharListItem[], next: FnrsSharListItem[]) { + return ( + current.length === next.length && + current.every((item, index) => item.fnrsSharSeCd === next[index]?.fnrsSharSeCd) + ); +} + function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsSharFormSectionProps) { const { control, setValue, getValues } = useFormContext(); const fnrsSharList = useWatch({ control, name: "fnrsSharList" }) ?? []; @@ -117,6 +124,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar : mergeFnrsSharTreeWithForm(sharTree, []); const withTotals = recalculateFnrsSharTotals(merged, sharItems); + if (isSameFnrsSharCodes(existing, withTotals)) return; setValue("fnrsSharList", withTotals); }, [sharItems, sharTree, initialSharTree, getValues, setValue]); @@ -226,40 +234,26 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar {item.label} - ( - - handleSharChange(idx, "exstnAmt", e.target.value)} - readOnly={item.readonly} - aria-invalid={fieldState.invalid} - className={cn("text-right", item.readonly && "bg-muted")} - /> - - )} - /> + + handleSharChange(idx, "exstnAmt", e.target.value)} + readOnly={item.readonly} + className={cn("text-right", item.readonly && "bg-muted")} + /> + - ( - - handleSharChange(idx, "chgRqtAmt", e.target.value)} - readOnly={item.readonly} - aria-invalid={fieldState.invalid} - className={cn("text-right", item.readonly && "bg-muted")} - /> - - )} - /> + + handleSharChange(idx, "chgRqtAmt", e.target.value)} + readOnly={item.readonly} + className={cn("text-right", item.readonly && "bg-muted")} + /> +
@@ -270,19 +264,12 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
{calcDiffPercent(diff, data.exstnAmt || 0)}
- ( - - handleSharChange(idx, "rmrkCn", e.target.value)} - aria-invalid={fieldState.invalid} - /> - - )} - /> + + handleSharChange(idx, "rmrkCn", e.target.value)} + /> + ); From c9b7266959b58d566078377246c3c2bd9fac26b0 Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 22 Sep 2026 11:24:32 +0900 Subject: [PATCH 03/66] =?UTF-8?q?wr:=20=EC=9E=AC=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=EC=8B=A4=EC=A0=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/app/(with-layout)/jwttest/page.tsx | 384 +++++++++--------- .../wr/app/(with-layout)/jwttest/page.tsx.bak | 220 ++++++++++ 2 files changed, 413 insertions(+), 191 deletions(-) create mode 100644 apps/wr/app/(with-layout)/jwttest/page.tsx.bak diff --git a/apps/wr/app/(with-layout)/jwttest/page.tsx b/apps/wr/app/(with-layout)/jwttest/page.tsx index c9df447ef..3ad1ad55d 100644 --- a/apps/wr/app/(with-layout)/jwttest/page.tsx +++ b/apps/wr/app/(with-layout)/jwttest/page.tsx @@ -1,220 +1,222 @@ /** - * JWT를 decoding하면 만료시간을 읽을 수 있다. - * JWT는 3부분으로 구성 : Header.Payload.Signature - * Payload 부분에 만료시간(exp)이 있다. + * 티베로(하수도정보시스템) 조회 콘솔 + * + * - reuse-service 의 티베로 데이터소스로 조회 SQL 한 문장(SELECT, WITH)을 실행하고 결과를 표로 보여준다. + * - 서버에서 조회 문장만 통과시키고, 읽기 전용 연결과 최대 행 수(기본 100, 최대 1000) 제한을 적용한다. + * - 서버 설정 tibero.sql-console.enabled=true 일 때만 동작한다. + * + * 🔗 사용 API + * 티베로 조회 SQL 실행 POST /ones/sql body: { sql, maxRows } + * + * 이전 JWT 만료시간 테스트 화면은 같은 폴더의 page.tsx.bak 에 보관되어 있다. */ "use client"; -import { useEffect, useRef, useState } from "react"; -import { authService } from "@/src/entities/auth"; -import { CLAIM_NAME } from "@/src/shared/config"; -import { getCookie } from "@/src/shared/lib"; +import { useCallback, useMemo, useState } from "react"; +import { fetchType, postFectch } from "@/components/lib/api"; -interface JwtPayload { - sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보 - exp?: number; // 만료 시각(Unix timestamp, 초 단위) - iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위) - [key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임 -} - -interface JwtState { - token: string; // 쿠키에서 읽어온 JWT 원본 문자열 - payload: JwtPayload | null; // JWT를 파싱한 payload 객체 - exp: number | null; // payload에서 추출한 exp 값 - expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값 - remainingMs: number | null; // 현재 시각 기준 남은 시간(ms) - error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지 -} - -const initialJwtState: JwtState = { - token: "", - payload: null, - exp: null, - expDateLocal: null, - remainingMs: null, - error: null, +type TiberoSqlResult = { + success: boolean; + message: string | null; + columns: string[]; + rows: (string | null)[][]; + rowCount: number; + truncated: boolean; + maxRows: number; + elapsedMs: number; }; -function parseJwt(token: string): JwtPayload { - const parts = token.split("."); - if (parts.length !== 3) { - throw new Error("유효한 JWT 형식이 아닙니다."); - } +type TiberoSqlResponse = { + status: number; + data: TiberoSqlResult | null; + count: number; + msg: string | null; +}; - const base64Url = parts[1] ?? ""; // Payload를 가져온다. - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - const jsonPayload = decodeURIComponent( - atob(padded) - .split("") - .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`) - .join("") - ); +const DEFAULT_SQL = `SELECT BSC_FCT_CD, BSC_FCT_NM, RGN_CD, ADST_DSTR_CD, FCT_CPC +FROM ONSEMIRO_NEW.OM_MS_VW +WHERE ROWNUM <= 20`; - return JSON.parse(jsonPayload) as JwtPayload; -} +const MAX_ROWS_LIMIT = 1000; -function formatRemainingMs(remainingMs: number | null): string { - if (remainingMs === null) return "-"; - if (remainingMs <= 0) return "만료됨"; +const toTsv = (columns: string[], rows: (string | null)[][]) => + [columns, ...rows] + .map((row) => row.map((value) => (value ?? "").replace(/[\t\r\n]+/g, " ")).join("\t")) + .join("\n"); - const totalSeconds = Math.floor(remainingMs / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; +export default function TiberoSqlConsolePage() { + const [sql, setSql] = useState(DEFAULT_SQL); + const [maxRows, setMaxRows] = useState(100); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); - return `${hours}시간 ${minutes}분 ${seconds}초`; -} - -function readJwtState(): JwtState { - const token = getCookie(CLAIM_NAME) ?? ""; - - if (!token) { - return { - token: "", - payload: null, - exp: null, - expDateLocal: null, - remainingMs: null, - error: `${CLAIM_NAME} 쿠키가 없습니다.`, - }; - } - - try { - const payload = parseJwt(token); - - if (typeof payload.exp !== "number") { - return { - token, - payload, - exp: null, - expDateLocal: null, - remainingMs: null, - error: "JWT payload에 exp 값이 없습니다.", - }; + const runQuery = useCallback(async () => { + if (loading) return; + if (!sql.trim()) { + setError("SQL을 입력해주세요."); + return; } - - const expMs = payload.exp * 1000; - - return { - token, - payload, - exp: payload.exp, - expDateLocal: new Date(expMs).toLocaleString(), - remainingMs: Math.max(expMs - Date.now(), 0), - error: null, - }; - } catch (error) { - return { - token, - payload: null, - exp: null, - expDateLocal: null, - remainingMs: null, - error: - error instanceof Error - ? error.message - : "JWT 파싱 중 오류가 발생했습니다.", - }; - } -} - -export default function Page() { - const [jwtState, setJwtState] = useState(initialJwtState); - const isRefreshingRef = useRef(false); - - const handleSilentRefresh = async () => { + setLoading(true); + setError(null); + setCopied(false); try { - await authService.silentRefresh(); - } catch (error) { - console.error("Manual silent refresh failed:", error); + const response = (await postFectch( + "/ones/sql", + { sql, maxRows }, + fetchType.WR + )) as TiberoSqlResponse; + const data = response?.data ?? null; + setResult(data); + if (!data || !data.success) { + setError(response?.msg || data?.message || "조회에 실패했습니다."); + } + } catch (e) { + setResult(null); + setError(e instanceof Error ? e.message : "요청 중 오류가 발생했습니다."); } finally { - setJwtState(readJwtState()); + setLoading(false); } - }; + }, [loading, sql, maxRows]); - useEffect(() => { - const syncJwtState = async () => { - const nextState = readJwtState(); + const copyTsv = useCallback(async () => { + if (!result?.success) return; + try { + await navigator.clipboard.writeText(toTsv(result.columns, result.rows)); + setCopied(true); + } catch { + setError("클립보드 복사에 실패했습니다."); + } + }, [result]); - if ( - nextState.remainingMs !== null && - nextState.remainingMs > 0 && - nextState.remainingMs < 5 * 60 * 1000 && - !isRefreshingRef.current - ) { - isRefreshingRef.current = true; - try { - await authService.silentRefresh(); - } catch (error) { - console.error("JWT test silent refresh failed:", error); - } - } - - setJwtState(readJwtState()); - - if ( - nextState.remainingMs === null || - nextState.remainingMs >= 5 * 60 * 1000 || - nextState.remainingMs <= 0 - ) { - isRefreshingRef.current = false; - } - }; - - void syncJwtState(); - const timer = window.setInterval(() => { - void syncJwtState(); - }, 1000); - - return () => window.clearInterval(timer); - }, []); + const summary = useMemo(() => { + if (!result?.success) return null; + return `${result.rowCount.toLocaleString()}행 · ${result.elapsedMs.toLocaleString()}ms · 최대 ${result.maxRows.toLocaleString()}행`; + }, [result]); return ( -
-

JWT Exp Test

+
+
+

티베로 조회 콘솔

+ + SELECT, WITH 한 문장만 실행됩니다 · Ctrl+Enter 실행 + +
-