@@ -432,6 +436,7 @@ function AttachmentsFormSection({
{
void handleEssentialFileUpload(
file.esntlAtchFileId,
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 82386ab06..a77663e58 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
@@ -5,7 +5,7 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
-import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
+import { Field, FieldLabel } from "@/shared/ui/field";
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
import { useCodeList } from "@/entities/code";
import {
@@ -196,31 +196,33 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
검토항목
주요 검토내용
-
검토
+
검토 결과
-
-
-
-
-
+
+
+
+
+
@@ -236,64 +238,72 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
style={{
paddingLeft: `${Math.max(0, item.treeLevel - 1) * 16 + 8}px`,
}}
- colSpan={showRadios ? 0 : 2}
+ colSpan={showRadios ? 0 : 3}
>
{item.treeLevel > 1 ? "└ " : ""}
{item.content}
{showRadios && resultIdx >= 0 ? (
-
- (
-
- )}
- />
-
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ />
) : null}
);
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
index 7787f7a40..1d6e31a16 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
@@ -70,6 +70,8 @@ function ContactInfoFormSection({ fieldPrefix }: ContactInfoFormSectionProps) {
id={field.name}
placeholder="설계자 전화번호"
aria-invalid={fieldState.invalid}
+ maxLength={13}
+ onChange={(e) => field.onChange(e.target.value.replace(/[^0-9-]/g, ""))}
/>
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
new file mode 100644
index 000000000..9373285c2
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import { useEffect, useId, useState } from "react";
+
+import { getDefaultFileKey, useTus } from "@/shared/lib";
+import { attachService } from "@/entities/attach";
+import { CodeSelect } from "@/entities/code";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
+const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
+
+interface UploadedSplmntFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface SplmntDmndRow {
+ rowKey: string;
+ splmntArtclComCd: string;
+ splmntDmndCn: string;
+ splmntDmndAtchFileGroupId: string;
+ uploadedFile: UploadedSplmntFile | null;
+}
+
+interface SplmntDmndDialogConfirmItem {
+ splmntArtclComCd: string;
+ splmntDmndCn: string;
+ splmntDmndAtchFileGroupId?: string;
+}
+
+interface SplmntDmndDialogConfirmPayload {
+ items: SplmntDmndDialogConfirmItem[];
+}
+
+interface SplmntDmndDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ isSubmitting?: boolean;
+ onConfirm: (payload: SplmntDmndDialogConfirmPayload) => void;
+}
+
+function createRowKey() {
+ return `splmnt-row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+}
+
+function createEmptyRow(): SplmntDmndRow {
+ return {
+ rowKey: createRowKey(),
+ splmntArtclComCd: "",
+ splmntDmndCn: "",
+ splmntDmndAtchFileGroupId: "",
+ uploadedFile: null,
+ };
+}
+
+function createUploadKey(rowKey: string, file: File) {
+ return `${SPLMNT_DMND_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
+}
+
+function getRowKeyFromFileKey(fileKey: string): string | null {
+ if (!fileKey.startsWith(SPLMNT_DMND_FILE_KEY_PREFIX)) return null;
+ const rest = fileKey.slice(SPLMNT_DMND_FILE_KEY_PREFIX.length);
+ const separatorIndex = rest.indexOf("::");
+ if (separatorIndex < 0) return null;
+ return rest.slice(0, separatorIndex);
+}
+
+function isRowsValid(rows: SplmntDmndRow[]) {
+ return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
+}
+
+function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) {
+ const formId = useId();
+ const [rows, setRows] = useState
([createEmptyRow()]);
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setRows((prev) =>
+ prev.map((row) => {
+ if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
+ return {
+ ...row,
+ splmntDmndAtchFileGroupId: atflCd,
+ uploadedFile: { ...row.uploadedFile, atflCd, atflId },
+ };
+ }),
+ );
+ },
+ onError: (_error, _file, fileKey) => {
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setRows((prev) =>
+ prev.map((row) => {
+ if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
+ return { ...row, uploadedFile: null };
+ }),
+ );
+ },
+ });
+
+ useEffect(() => {
+ if (!open) return;
+ setRows([createEmptyRow()]);
+ }, [open]);
+
+ const isAnyUploading = rows.some(
+ (row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
+ );
+ const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
+
+ function updateRow(rowKey: string, patch: Partial) {
+ setRows((prev) => prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
+ }
+
+ function handleAddRow() {
+ setRows((prev) => [...prev, createEmptyRow()]);
+ }
+
+ function handleRemoveRow(rowKey: string) {
+ setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.rowKey !== rowKey)));
+ }
+
+ async function handleFileUpload(rowKey: string, files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const file = files[0];
+ const fileKey = createUploadKey(rowKey, file);
+ const targetRow = rows.find((row) => row.rowKey === rowKey);
+ const existingAtflCd = targetRow?.splmntDmndAtchFileGroupId;
+
+ updateRow(rowKey, {
+ uploadedFile: { fileName: file.name, fileKey },
+ });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove(rowKey: string) {
+ const targetRow = rows.find((row) => row.rowKey === rowKey);
+ if (!targetRow?.uploadedFile) return;
+
+ const { fileKey, atflId } = targetRow.uploadedFile;
+ uploads[fileKey]?.abort();
+
+ if (atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(atflId);
+ } catch (error) {
+ console.error("보완요청 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ updateRow(rowKey, {
+ uploadedFile: null,
+ splmntDmndAtchFileGroupId: "",
+ });
+ }
+
+ function handleConfirm() {
+ if (!canSubmit) return;
+
+ onConfirm({
+ items: rows.map((row) => ({
+ splmntArtclComCd: row.splmntArtclComCd.trim(),
+ splmntDmndCn: row.splmntDmndCn.trim(),
+ splmntDmndAtchFileGroupId: row.splmntDmndAtchFileGroupId || undefined,
+ })),
+ });
+ }
+
+ return (
+
+ );
+}
+
+export { SplmntDmndDialog };
+export type { SplmntDmndDialogConfirmItem, SplmntDmndDialogConfirmPayload };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx
new file mode 100644
index 000000000..28cb40497
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx
@@ -0,0 +1,324 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import { Controller, useFormContext, useWatch } from "react-hook-form";
+
+import { Paperclip } from "lucide-react";
+
+import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
+import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import {
+ Badge,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import { SectionCollapsible } from "./section-collapsible";
+
+const SPLMNT_CMPTN_FILE_KEY_PREFIX = "splmnt-cmptn::";
+
+interface UploadedSplmntFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface SplmntDmndFormItem {
+ splmntDmndSn?: number;
+ splmntArtclComCd?: string;
+ splmntArtclCdNm?: string | null;
+ splmntDmndCn?: string;
+ splmntDmndDt?: string;
+ splmntDmndAtchFileGroupId?: string;
+ splmntDmndAtchFileNm?: string;
+ splmntCn?: string;
+ splmntPicNm?: string;
+ splmntCmptnDt?: string;
+ splmntAtchFileGroupId?: string;
+ splmntAtchFileNm?: string;
+ splmntAtchFileAtflId?: string;
+ cmptnYn?: string;
+}
+
+interface SplmntDmndFormData {
+ splmntDmndList: SplmntDmndFormItem[];
+}
+
+interface SplmntDmndFormSectionProps {
+ readOnly?: boolean;
+}
+
+function createUploadKey(rowKey: string, file: File) {
+ return `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
+}
+
+function getRowKeyFromFileKey(fileKey: string): string | null {
+ if (!fileKey.startsWith(SPLMNT_CMPTN_FILE_KEY_PREFIX)) return null;
+ const rest = fileKey.slice(SPLMNT_CMPTN_FILE_KEY_PREFIX.length);
+ const separatorIndex = rest.indexOf("::");
+ if (separatorIndex < 0) return null;
+ return rest.slice(0, separatorIndex);
+}
+
+function getRowKey(item: SplmntDmndFormItem, index: number) {
+ return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
+}
+
+function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
+ const formId = useId();
+ const { control, setValue, getValues } = useFormContext();
+ const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
+ const [uploadedFiles, setUploadedFiles] = useState>({});
+ const initialFilesMappedRef = useRef(false);
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setUploadedFiles((prev) => {
+ const current = prev[rowKey];
+ if (!current || current.fileKey !== fileKey) return prev;
+ return {
+ ...prev,
+ [rowKey]: { ...current, atflCd, atflId },
+ };
+ });
+
+ const list = getValues("splmntDmndList") ?? [];
+ const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
+ if (index < 0) return;
+
+ setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
+ },
+ onError: (_error, _file, fileKey) => {
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setUploadedFiles((prev) => {
+ const current = prev[rowKey];
+ if (!current || current.fileKey !== fileKey) return prev;
+ return { ...prev, [rowKey]: null };
+ });
+ },
+ });
+
+ useEffect(() => {
+ if (initialFilesMappedRef.current) return;
+ if (splmntDmndList.length === 0) return;
+
+ const initial: Record = {};
+ for (let index = 0; index < splmntDmndList.length; index += 1) {
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
+ initial[rowKey] = {
+ fileName: item.splmntAtchFileNm,
+ fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
+ atflCd: item.splmntAtchFileGroupId,
+ atflId: item.splmntAtchFileAtflId,
+ };
+ } else {
+ initial[rowKey] = null;
+ }
+ }
+
+ setUploadedFiles(initial);
+ initialFilesMappedRef.current = true;
+ }, [splmntDmndList]);
+
+ if (splmntDmndList.length === 0) return null;
+
+ async function handleFileUpload(index: number, files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ const file = files[0];
+ const fileKey = createUploadKey(rowKey, file);
+ const existingAtflCd = item.splmntAtchFileGroupId;
+
+ setUploadedFiles((prev) => ({
+ ...prev,
+ [rowKey]: { fileName: file.name, fileKey },
+ }));
+ setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove(index: number) {
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ const uploaded = uploadedFiles[rowKey];
+ if (!uploaded) return;
+
+ const { fileKey, atflId } = uploaded;
+ uploads[fileKey]?.abort();
+
+ if (atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(atflId);
+ } catch (error) {
+ console.error("보완완료 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
+ setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { SplmntDmndFormSection };
+export type { SplmntDmndFormItem };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx
new file mode 100644
index 000000000..c4637fd4e
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { Paperclip } from "lucide-react";
+
+import { dayjs } from "@/shared/lib";
+import type { SplmntDmndResponseExtension, SplmntDmndSaveBase } from "@/shared/model";
+import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import {
+ Badge,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { SectionCollapsible } from "./section-collapsible";
+
+type SplmntDmndItem = SplmntDmndSaveBase & SplmntDmndResponseExtension;
+
+interface SupplementDemandSectionProps {
+ splmntDmndList: SplmntDmndItem[];
+}
+
+function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) {
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 보완항목
+ 보완요청일시
+ 보완요청내용
+ 보완완료일시
+ 보완완료내용
+ 완료여부
+
+
+
+ {splmntDmndList.map((item) => {
+ const sn = item.splmntDmndSn;
+ const cmptnYn = item.cmptnYn ?? "N";
+ const dmndAtchFileNm = item.splmntDmndAtchFileInfo?.orgnlFileNm;
+ const cmptnAtchFileNm = item.splmntAtchFileInfo?.orgnlFileNm;
+
+ return (
+
+
+ {item.splmntArtclCdNm || "-"}
+
+
+ {item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
+
+
+
+
+
+ {item.splmntCmptnDt
+ ? dayjs(item.splmntCmptnDt).format("YYYY-MM-DD HH:mm")
+ : "-"}
+
+
+
+
+
+
+ {cmptnYn === "Y" ? "완료" : "미완료"}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export { SupplementDemandSection };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx b/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx
deleted file mode 100644
index 168012416..000000000
--- a/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-import {
- Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- Textarea,
-} from "@common/components";
-
-interface TextInputDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- title: string;
- placeholder?: string;
- defaultValue?: string;
- isSubmitting?: boolean;
- onConfirm: (text: string) => void;
-}
-
-function TextInputDialog({
- open,
- onOpenChange,
- title,
- placeholder,
- defaultValue,
- isSubmitting,
- onConfirm,
-}: TextInputDialogProps) {
- const [value, setValue] = useState("");
-
- useEffect(() => {
- if (!open) return;
- setValue(defaultValue ?? "");
- }, [defaultValue, open]);
-
- function handleConfirm() {
- onConfirm(value.trim());
- }
-
- return (
-
- );
-}
-
-export { TextInputDialog };
diff --git a/apps/cm/app/api/proxy/[...url]/route.ts b/apps/cm/app/api/proxy/[...url]/route.ts
index 105b36fa7..c0f646cd2 100644
--- a/apps/cm/app/api/proxy/[...url]/route.ts
+++ b/apps/cm/app/api/proxy/[...url]/route.ts
@@ -19,6 +19,10 @@ async function handler(
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
+ const deviceId = request.headers.get("X-Device-Id");
+ if (deviceId) {
+ headers["X-Device-Id"] = deviceId;
+ }
let body = {};
const isRefreshHandler = params.url.includes("refresh");
@@ -57,40 +61,57 @@ async function handler(
});
console.log(`[Proxy] Response status: ${result.status}`);
- console.log(`[Proxy] Response headers:`, Object.fromEntries(result.headers.entries()));
+ console.log(
+ `[Proxy] Response headers:`,
+ Object.fromEntries(result.headers.entries()),
+ );
// 2FA 관련 엔드포인트인 경우 응답 본문도 확인
- const is2FAEndpoint = params.url.includes("2fa") || params.url.includes("auth");
+ const is2FAEndpoint =
+ params.url.includes("2fa") || params.url.includes("auth");
// 배너 API는 인증 없이 호출 가능
const isBannerEndpoint = params.url.includes("bnrs");
let responseData = {};
-
+
if (is2FAEndpoint || isBannerEndpoint) {
try {
responseData = await result.json();
- console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} Response data:`, responseData);
+ console.log(
+ `[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} Response data:`,
+ responseData,
+ );
} catch {
// JSON 파싱 실패 시 빈 객체 사용
console.log(`[Proxy] Failed to parse JSON response`);
}
}
- const accessToken = result.headers.get(ACCESS_TOKEN) || result.headers.get('ss-token');
+ const accessToken =
+ result.headers.get(ACCESS_TOKEN) || result.headers.get("ss-token");
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
- console.log(`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`);
- console.log(`[Proxy] Available headers:`, Array.from(result.headers.keys()));
+ console.log(
+ `[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`,
+ );
+ console.log(
+ `[Proxy] Available headers:`,
+ Array.from(result.headers.keys()),
+ );
// 2FA 또는 배너 엔드포인트이고 실패한 경우 실제 오류 메시지 반환
if (!result.ok && (is2FAEndpoint || isBannerEndpoint)) {
- console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint failed, returning actual error`);
+ console.log(
+ `[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} endpoint failed, returning actual error`,
+ );
return NextResponse.json(responseData, { status: result.status });
}
// 2FA 또는 배너 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음)
if (result.ok && (is2FAEndpoint || isBannerEndpoint)) {
- console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint succeeded, returning response data`);
+ console.log(
+ `[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} endpoint succeeded, returning response data`,
+ );
return NextResponse.json(responseData, { status: result.status });
}
diff --git a/apps/cm/package.json b/apps/cm/package.json
index e9c722d39..64eb7edb4 100644
--- a/apps/cm/package.json
+++ b/apps/cm/package.json
@@ -47,6 +47,7 @@
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
+ "uuid": "^14.0.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
diff --git a/apps/cm/src/entities/auth/api/auth-service.ts b/apps/cm/src/entities/auth/api/auth-service.ts
index a8fa70622..ddb3dffac 100644
--- a/apps/cm/src/entities/auth/api/auth-service.ts
+++ b/apps/cm/src/entities/auth/api/auth-service.ts
@@ -12,11 +12,11 @@ import {
import {
clearRefreshTimer,
- removeToken,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
+import { clearDeviceId } from "../lib/device-id";
function resolveLoginError(message: string | undefined): string {
switch (message) {
@@ -102,8 +102,7 @@ async function silentRefresh(): Promise {
async function logout(): Promise {
tokenManager.clearAuthToken();
- removeToken("site-id-key");
- removeToken("site-theme-key");
+ clearDeviceId();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
diff --git a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
index d1a2a48c3..87f932817 100644
--- a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
+++ b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx
@@ -18,10 +18,9 @@ import { getRemainingMs } from "@common/auth";
import { isAtLoginRedirectUrl } from "@common/network";
import { authService } from "../api/auth-service";
-
import { getToken, setupRefreshTimer } from "./token-manager";
-const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
+export function AuthWrapper({ children }: { children: React.ReactNode }) {
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const setUserInfo = useUserStore((state) => state.setUserInfo);
@@ -31,9 +30,6 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
if (token) {
const remainingMs = getRemainingMs(token);
if (remainingMs > 0) {
- console.log(
- `[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
- );
setupRefreshTimer(authService.silentRefresh, remainingMs);
}
}
@@ -79,6 +75,4 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
}, []);
return <>{children}>;
-};
-
-export { AuthWrapper };
+}
diff --git a/apps/cm/src/entities/auth/lib/device-id.ts b/apps/cm/src/entities/auth/lib/device-id.ts
new file mode 100644
index 000000000..3683c7189
--- /dev/null
+++ b/apps/cm/src/entities/auth/lib/device-id.ts
@@ -0,0 +1,20 @@
+import { v4 as uuidv4 } from "uuid";
+
+const DEVICE_ID_KEY = "x-device-id";
+
+export function getOrCreateDeviceId(): string {
+ if (typeof window === "undefined") return "";
+
+ const existing = localStorage.getItem(DEVICE_ID_KEY);
+ if (existing) return existing;
+
+ const id = uuidv4();
+ localStorage.setItem(DEVICE_ID_KEY, id);
+ return id;
+}
+
+export function clearDeviceId(): void {
+ if (typeof window === "undefined") return;
+
+ localStorage.removeItem(DEVICE_ID_KEY);
+}
diff --git a/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx
index 8faf518f1..e4a542cfa 100644
--- a/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx
+++ b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx
@@ -882,11 +882,6 @@ function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
type="submit"
className="user-signup-submit-button"
disabled={isLoading}
- onClick={(e) => {
- e.preventDefault();
- console.log("회원가입 버튼 클릭됨 - onClick");
- handleSubmit(e);
- }}
>
{isLoading ? "가입 중..." : "회원가입"}
diff --git a/apps/cm/src/shared/config/index.ts b/apps/cm/src/shared/config/index.ts
index cd83447d5..ea6a6f16a 100644
--- a/apps/cm/src/shared/config/index.ts
+++ b/apps/cm/src/shared/config/index.ts
@@ -1,2 +1,3 @@
export * from "./api";
+export * from "./auth";
export * from "./env";
diff --git a/apps/cs/app/api/proxy/[...url]/route.ts b/apps/cs/app/api/proxy/[...url]/route.ts
index 0929ca57c..4385956d3 100644
--- a/apps/cs/app/api/proxy/[...url]/route.ts
+++ b/apps/cs/app/api/proxy/[...url]/route.ts
@@ -1,3 +1,6 @@
+import { cookies } from "next/headers";
+import { NextResponse } from "next/server";
+
import {
ACCESS_TOKEN,
AUTH_USER_ID,
@@ -7,8 +10,6 @@ import {
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
-import { cookies } from "next/headers";
-import { NextResponse } from "next/server";
async function handler(
request: Request,
@@ -20,6 +21,10 @@ async function handler(
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
+ const deviceId = request.headers.get("X-Device-Id");
+ if (deviceId) {
+ headers["X-Device-Id"] = deviceId;
+ }
let body = {};
const isRefreshHandler = params.url.includes("refresh");
diff --git a/apps/cs/package.json b/apps/cs/package.json
index d362d1178..710032627 100644
--- a/apps/cs/package.json
+++ b/apps/cs/package.json
@@ -45,6 +45,7 @@
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
+ "uuid": "^14.0.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
diff --git a/apps/cs/src/entities/auth/api/auth-service.ts b/apps/cs/src/entities/auth/api/auth-service.ts
index 3efb1bf85..cf7281ff9 100644
--- a/apps/cs/src/entities/auth/api/auth-service.ts
+++ b/apps/cs/src/entities/auth/api/auth-service.ts
@@ -10,9 +10,9 @@ import {
getRemainingMs,
} from "@common/auth";
+import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
- removeToken,
setToken,
setupRefreshTimer,
tokenManager,
@@ -36,7 +36,10 @@ function resolveLoginError(message: string | undefined): string {
async function login(payload: AuthPayload): Promise {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ "X-Device-Id": getOrCreateDeviceId(),
+ },
body: JSON.stringify(payload),
});
@@ -67,7 +70,12 @@ async function silentRefresh(): Promise {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
- { method: "PUT" },
+ {
+ method: "PUT",
+ headers: {
+ "X-Device-Id": getOrCreateDeviceId(),
+ },
+ },
);
const data = await response.json();
@@ -105,8 +113,7 @@ async function silentRefresh(): Promise {
async function logout(): Promise {
tokenManager.clearAuthToken();
- removeToken("site-id-key");
- removeToken("site-theme-key");
+ clearDeviceId();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
diff --git a/apps/cs/src/entities/auth/lib/device-id.ts b/apps/cs/src/entities/auth/lib/device-id.ts
new file mode 100644
index 000000000..3683c7189
--- /dev/null
+++ b/apps/cs/src/entities/auth/lib/device-id.ts
@@ -0,0 +1,20 @@
+import { v4 as uuidv4 } from "uuid";
+
+const DEVICE_ID_KEY = "x-device-id";
+
+export function getOrCreateDeviceId(): string {
+ if (typeof window === "undefined") return "";
+
+ const existing = localStorage.getItem(DEVICE_ID_KEY);
+ if (existing) return existing;
+
+ const id = uuidv4();
+ localStorage.setItem(DEVICE_ID_KEY, id);
+ return id;
+}
+
+export function clearDeviceId(): void {
+ if (typeof window === "undefined") return;
+
+ localStorage.removeItem(DEVICE_ID_KEY);
+}
diff --git a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx
index 86262be44..fb893c8fb 100644
--- a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx
+++ b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx
@@ -5,11 +5,13 @@
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { useRouter } from "next/navigation";
+import { sha512 } from "js-sha512";
import SubPageHeader from "@/components/SubPageHeader";
import PostcodeModal from "@/components/PostcodeModal";
import type { AddrItem } from "@/app/store/sdmMng/postcode-service";
import { useDmStore } from "@/app/store";
import { fetchType, getFetch, postFetch, putFetch } from "@/components/ui/api";
+import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { getCookie } from "@common/components/lib";
type Option = { value: string; label: string };
@@ -165,7 +167,7 @@ function Hr({ className = "" }: { className?: string }) {
export default function DemandPlaceRegisterPage() {
const router = useRouter();
- const { Common, initializeCommonData } = useDmStore((state) => state);
+ const { Common, initializeCommonData, actionLogin } = useDmStore((state) => state);
const { selectSidoData, selectSigunData } = Common;
const [editMode, setEditMode] = useState(false);
@@ -424,7 +426,7 @@ export default function DemandPlaceRegisterPage() {
};
if (password) {
- payload.dmdAplyPswd = password;
+ payload.dmdAplyPswd = sha512.hex(password);
}
try {
@@ -443,21 +445,20 @@ export default function DemandPlaceRegisterPage() {
alert("수요처 수정 완료!");
} else {
- const response = await postFetch("/reuseFc/request", payload);
- const responseJson = await response.json().catch(() => null);
- const savedUser = responseJson?.data ?? payload;
+ await postFetch("/reuseFc/request", payload);
- if (typeof window !== "undefined") {
- const nextSessionUser = {
- ...(sessionUser ?? {}),
- ...savedUser,
- };
- delete nextSessionUser.dmdAplyPswd;
- sessionStorage.setItem("user", JSON.stringify(nextSessionUser));
+ const loginSuccess = await actionLogin({
+ dmdInfoId: fullDmdInfoId,
+ dmdAplyPswd: sha512.hex(password),
+ } as DemandCompType);
+
+ if (!loginSuccess) {
+ alert("수요처 등록은 완료되었지만 자동 로그인에 실패하였습니다. 다시 로그인해 주세요.");
+ return;
}
alert("수요처 등록 완료!");
- router.push("/apply/dmdinfo"); // 수요정보등록
+ router.push("/apply/dmdinfo");
}
setPw("");
diff --git a/apps/dm/app/store/index.tsx b/apps/dm/app/store/index.tsx
index 47170b3db..4291adfba 100644
--- a/apps/dm/app/store/index.tsx
+++ b/apps/dm/app/store/index.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ReactNode, createContext, useContext, useRef } from 'react'
+import { type ReactNode, createContext, useContext, useEffect, useRef } from 'react'
import { useStore } from 'zustand'
import { type DmStore, StoreZus } from './store'
@@ -23,6 +23,11 @@ export const StoreProvider = ({
// Create store instance if not initialized
storeRef.current = StoreZus() as StoreApi
}
+
+ useEffect(() => {
+ storeRef.current?.getState().hydrateLoginUser();
+ }, [])
+
return (
{children}
@@ -39,4 +44,4 @@ export const useDmStore = (
}
return useStore(storeContext, selector)
-}
\ No newline at end of file
+}
diff --git a/apps/dm/app/store/sdmMng/index.tsx b/apps/dm/app/store/sdmMng/index.tsx
index c51209515..904c0a6b0 100644
--- a/apps/dm/app/store/sdmMng/index.tsx
+++ b/apps/dm/app/store/sdmMng/index.tsx
@@ -43,6 +43,20 @@ export const sdmInfoFormSchema = z.object({
export type SdmInfoFormData = z.infer;
+export type LoginUser = {
+ dmdCoNm?: string;
+ dmdInfoId?: string;
+ dmdCoSn?: number;
+ zip?: string;
+ daddr?: string;
+ daddr2?: string;
+ telno?: string;
+ ctpvCd?: string;
+ sggCd?: string;
+ rgtr?: string;
+ [key: string]: unknown;
+};
+
const formatTelno = (telno?: string | null) => {
const digits = (telno ?? '').replace(/\D/g, '');
@@ -512,7 +526,7 @@ const fetchUpdateSdmInfoData = async (data: SdmInfoFormData): Promise =
}
}
-const postSdmLoginData = async (data: DemandCompType) => {
+const postSdmLoginData = async (data: DemandCompType): Promise => {
try {
const res = await postFetch("/reuseFc/request/login", data);
const jsonData = await res.json();
@@ -539,9 +553,23 @@ const postSdmLoginData = async (data: DemandCompType) => {
} catch (error) {
console.log(error);
alert("수요처 로그인에 실패하였습니다.");
+ return false;
}
}
+const getStoredLoginUser = (): LoginUser | null => {
+ if (typeof window === "undefined") return null;
+
+ const sessionUser = sessionStorage.getItem("user");
+ if (!sessionUser) return null;
+
+ try {
+ return JSON.parse(sessionUser) as LoginUser;
+ } catch {
+ return null;
+ }
+};
+
const getDemandCompList = async (searchText: string, pageCnt: number) => {
try {
const url = '/reuseFc/request/' + (searchText ? searchText : '') + '/' + pageCnt;
@@ -586,11 +614,12 @@ const fetchGetSimilarCaseList = async (part: string, value: number | string) =>
====================================*/
export type SdmActions = {
initializeSdmData: () => void;
+ hydrateLoginUser: () => void;
insertSdmData: (data: DemandCompType) => Promise;
insertSdmInfoData: (data: SdmInfoFormData) => Promise;
updateSdmInfoData: (data: SdmInfoFormData) => Promise;
getDemandCompList: (searchText: string, pageCnt: number) => Promise;
- actionLogin: (data: DemandCompType) => Promise;
+ actionLogin: (data: DemandCompType) => Promise;
getSdmInfoStatusList: (dmdInfoId: string) => Promise;
getSimilarCaseList: (part: string, value: number | string) => Promise;
actionLogout: () => Promise;
@@ -616,6 +645,7 @@ export type SdmType = {
// 로그인관련
successNextPage: boolean;
successLogin: boolean;
+ loginUser: LoginUser | null;
}
}
@@ -637,6 +667,7 @@ export const SdmState: SdmType = {
// 로그인관련
successNextPage: false,
successLogin: false,
+ loginUser: null,
}
}
@@ -644,6 +675,15 @@ export const SdmReducer = (set: any): SdmActions => {
return {
initializeSdmData: async () => {
},
+ hydrateLoginUser: () => {
+ const loginUser = getStoredLoginUser();
+ set((state: SdmType) => ({
+ Sdm: {
+ ...state.Sdm,
+ loginUser,
+ }
+ }))
+ },
insertSdmData: async (data: DemandCompType) => {
const preRes = await getSdmInfoStatusList(data.dmdInfoId);
@@ -743,14 +783,20 @@ export const SdmReducer = (set: any): SdmActions => {
}))
},
- actionLogin: async (data: DemandCompType) => {
+ actionLogin: async (data: DemandCompType): Promise => {
const loginSuccess = await postSdmLoginData(data);
+ const loginUser = loginSuccess ? getStoredLoginUser() : undefined;
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
successLogin: loginSuccess,
+ loginUser:
+ loginSuccess
+ ? loginUser
+ : state.Sdm.loginUser,
}
}))
+ return loginSuccess;
},
// 유사사례 조회
@@ -792,6 +838,7 @@ export const SdmReducer = (set: any): SdmActions => {
Sdm: {
...state.Sdm,
successLogin: false,
+ loginUser: null,
}
}))
},
diff --git a/apps/dm/components/TopNav.tsx b/apps/dm/components/TopNav.tsx
index 5e24ead78..741dbed2a 100644
--- a/apps/dm/components/TopNav.tsx
+++ b/apps/dm/components/TopNav.tsx
@@ -8,9 +8,6 @@ import LoginModal from "@/components/LoginModal";
import { fetchAnonymousMenu, isMenuActive, type MenuItem } from "@/app/store/common";
import { useDmStore } from "@/app/store";
-// const LS_TOKEN_KEY = "reuse_accessToken";
-const LS_USER_KEY = "user";
-
const DEFAULT_NAV_ITEMS: Pick