diff --git a/backend-node/src/controllers/auditLogController.ts b/backend-node/src/controllers/auditLogController.ts index cd59a435..30982af3 100644 --- a/backend-node/src/controllers/auditLogController.ts +++ b/backend-node/src/controllers/auditLogController.ts @@ -10,7 +10,7 @@ export const getAuditLogs = async ( ): Promise => { try { const userCompanyCode = req.user?.companyCode; - const isSuperAdmin = userCompanyCode === "*"; + const isSuperAdmin = req.user?.userType === "SUPER_ADMIN"; const { companyCode, @@ -63,7 +63,7 @@ export const getAuditLogStats = async ( ): Promise => { try { const userCompanyCode = req.user?.companyCode; - const isSuperAdmin = userCompanyCode === "*"; + const isSuperAdmin = req.user?.userType === "SUPER_ADMIN"; const { companyCode, days } = req.query; const targetCompany = isSuperAdmin @@ -91,7 +91,7 @@ export const getAuditLogUsers = async ( ): Promise => { try { const userCompanyCode = req.user?.companyCode; - const isSuperAdmin = userCompanyCode === "*"; + const isSuperAdmin = req.user?.userType === "SUPER_ADMIN"; const { companyCode } = req.query; const conditions: string[] = ["LOWER(u.status) = 'active'"]; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5c53094f..24ad771d 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -224,6 +224,31 @@ export async function updateColumnSettings( `컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}` ); + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "TABLE", + resourceId: `${tableName}.${columnName}`, + resourceName: settings.columnLabel || columnName, + tableName: "table_type_columns", + summary: `테이블 타입관리: ${tableName}.${columnName} 컬럼 설정 변경`, + changes: { + after: { + columnLabel: settings.columnLabel, + inputType: settings.inputType, + referenceTable: settings.referenceTable, + referenceColumn: settings.referenceColumn, + displayColumn: settings.displayColumn, + codeCategory: settings.codeCategory, + }, + fields: ["columnLabel", "inputType", "referenceTable", "referenceColumn", "displayColumn", "codeCategory"], + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + const response: ApiResponse = { success: true, message: "컬럼 설정을 성공적으로 저장했습니다.", @@ -339,6 +364,29 @@ export async function updateAllColumnSettings( `전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}` ); + const changedColumns = columnSettings + .filter((c) => c.columnName) + .map((c) => c.columnName) + .join(", "); + + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "BATCH_UPDATE", + resourceType: "TABLE", + resourceId: tableName, + resourceName: tableName, + tableName: "table_type_columns", + summary: `테이블 타입관리: ${tableName} 전체 컬럼 설정 일괄 변경 (${columnSettings.length}개)`, + changes: { + after: { columns: changedColumns, count: columnSettings.length }, + fields: columnSettings.filter((c) => c.columnName).map((c) => c.columnName!), + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + const response: ApiResponse = { success: true, message: "모든 컬럼 설정을 성공적으로 저장했습니다.", diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index c86a71fd..82c2566e 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -251,6 +251,28 @@ class AuditLogService { [...params, limit, offset] ); + const SECURITY_MASK = "(보안 항목 - 값 비공개)"; + const securedTables = ["table_type_columns"]; + + if (!isSuperAdmin) { + for (const entry of data) { + if (entry.table_name && securedTables.includes(entry.table_name) && entry.changes) { + const changes = typeof entry.changes === "string" ? JSON.parse(entry.changes) : entry.changes; + if (changes.before) { + for (const key of Object.keys(changes.before)) { + changes.before[key] = SECURITY_MASK; + } + } + if (changes.after) { + for (const key of Object.keys(changes.after)) { + changes.after[key] = SECURITY_MASK; + } + } + entry.changes = changes; + } + } + } + return { data, total }; } diff --git a/frontend/components/admin/table-type/types.ts b/frontend/components/admin/table-type/types.ts index 329b4049..c82f0d2b 100644 --- a/frontend/components/admin/table-type/types.ts +++ b/frontend/components/admin/table-type/types.ts @@ -66,6 +66,8 @@ export const INPUT_TYPE_COLORS: Record = { category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" }, textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" }, radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" }, + file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" }, + image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" }, }; /** 컬럼 그룹 판별 */ diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d6284b9b..3464f982 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -135,6 +135,14 @@ const TextInput = forwardRef< const [hasBlurred, setHasBlurred] = useState(false); const [validationError, setValidationError] = useState(""); + // 커서 위치 보존을 위한 내부 ref + const innerRef = useRef(null); + const combinedRef = (node: HTMLInputElement | null) => { + (innerRef as React.MutableRefObject).current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }; + // 형식에 따른 값 포맷팅 const formatValue = useCallback( (val: string): string => { @@ -154,11 +162,15 @@ const TextInput = forwardRef< const handleChange = useCallback( (e: React.ChangeEvent) => { + const input = e.target; + const cursorPos = input.selectionStart ?? 0; let newValue = e.target.value; + const oldValue = input.value; + + const needsCursorFix = format === "biz_no" || format === "tel" || format === "currency"; // 형식에 따른 자동 포맷팅 if (format === "currency") { - // 숫자와 쉼표만 허용 newValue = newValue.replace(/[^\d,]/g, ""); newValue = formatCurrency(newValue); } else if (format === "biz_no") { @@ -167,6 +179,20 @@ const TextInput = forwardRef< newValue = formatTel(newValue); } + // 포맷팅 후 커서 위치 보정 (하이픈/쉼표 개수 차이 기반) + if (needsCursorFix) { + const separator = format === "currency" ? /,/g : /-/g; + const oldSeps = (oldValue.slice(0, cursorPos).match(separator) || []).length; + const newSeps = (newValue.slice(0, cursorPos).match(separator) || []).length; + const adjustedCursor = Math.min(cursorPos + (newSeps - oldSeps), newValue.length); + + requestAnimationFrame(() => { + if (innerRef.current) { + innerRef.current.setSelectionRange(adjustedCursor, adjustedCursor); + } + }); + } + // 입력 중 에러 표시 해제 (입력 중에는 관대하게) if (hasBlurred && validationError) { const { isValid } = validateInputFormat(newValue, format); @@ -244,7 +270,7 @@ const TextInput = forwardRef< return (
= ({ // 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드 const prevRecordIdRef = useRef(null); + const prevIsRecordModeRef = useRef(null); useEffect(() => { - if (prevRecordIdRef.current !== recordId) { - console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", { - prev: prevRecordIdRef.current, - current: recordId, - isRecordMode, - }); + const recordIdChanged = prevRecordIdRef.current !== null && prevRecordIdRef.current !== recordId; + const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode; + + if (recordIdChanged || modeChanged) { prevRecordIdRef.current = recordId; - - // 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화 - if (isRecordMode) { - setUploadedFiles([]); + prevIsRecordModeRef.current = isRecordMode; + + // 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화 + setUploadedFiles([]); + setRepresentativeImageUrl(null); + + // localStorage 캐시도 정리 (새 등록 모드 전환 시) + if (!isRecordMode) { + try { + const backupKey = getUniqueKey(); + localStorage.removeItem(backupKey); + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + delete globalFileState[backupKey]; + (window as any).globalFileState = globalFileState; + } + } catch {} } + } else if (prevRecordIdRef.current === null) { + prevRecordIdRef.current = recordId; + prevIsRecordModeRef.current = isRecordMode; } - }, [recordId, isRecordMode]); + }, [recordId, isRecordMode, getUniqueKey]); // 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원 useEffect(() => { if (!component?.id) return; + // 새 등록 모드(레코드 없음)에서는 localStorage 복원 스킵 - 빈 상태 유지 + if (!isRecordMode || !recordId) { + return; + } + try { // 🔑 레코드별 고유 키 사용 const backupKey = getUniqueKey(); const backupFiles = localStorage.getItem(backupKey); - console.log("🔎 [DEBUG-MOUNT] localStorage 확인:", { - backupKey, - hasBackup: !!backupFiles, - componentId: component.id, - recordId: recordId, - formDataId: formData?.id, - stackTrace: new Error().stack?.split('\n').slice(1, 4).join(' <- '), - }); if (backupFiles) { const parsedFiles = JSON.parse(backupFiles); if (parsedFiles.length > 0) { - console.log("🚀 [DEBUG-MOUNT] 파일 즉시 복원:", { - uniqueKey: backupKey, - componentId: component.id, - recordId: recordId, - restoredFiles: parsedFiles.length, - files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), - }); setUploadedFiles(parsedFiles); // 전역 상태에도 복원 (레코드별 고유 키 사용) @@ -210,7 +215,7 @@ const FileUploadComponent: React.FC = ({ } catch (e) { console.warn("컴포넌트 마운트 시 파일 복원 실패:", e); } - }, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행 + }, [component.id, getUniqueKey, recordId, isRecordMode]); // 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지) useEffect(() => { @@ -325,9 +330,14 @@ const FileUploadComponent: React.FC = ({ const loadComponentFiles = useCallback(async () => { if (!component?.id) return false; + // 새 등록 모드(레코드 없음)에서는 파일 조회 스킵 - 빈 상태 유지 + if (!isRecordMode || !recordId) { + return false; + } + try { // 🔑 레코드 모드: 해당 행의 파일만 조회 - if (isRecordMode && recordTableName && recordId) { + if (recordTableName) { console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", { tableName: recordTableName, recordId: recordId, @@ -457,17 +467,6 @@ const FileUploadComponent: React.FC = ({ // 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조) useEffect(() => { const componentFiles = (component as any)?.uploadedFiles || []; - const lastUpdate = (component as any)?.lastFileUpdate; - - console.log("🔄 FileUploadComponent 파일 동기화 시작:", { - componentId: component.id, - componentFiles: componentFiles.length, - formData: formData, - screenId: formData?.screenId, - tableName: formData?.tableName, // 🔍 테이블명 확인 - recordId: formData?.id, // 🔍 레코드 ID 확인 - currentUploadedFiles: uploadedFiles.length, - }); // 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리) loadComponentFiles().then((dbLoadSuccess) => { @@ -475,15 +474,22 @@ const FileUploadComponent: React.FC = ({ return; // DB 로드 성공 시 localStorage 무시 } - // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) + // 새 등록 모드(레코드 없음)에서는 fallback 로드도 스킵 - 항상 빈 상태 유지 + if (!isRecordMode || !recordId) { + return; + } - // 전역 상태에서 최신 파일 정보 가져오기 + // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; + const uniqueKeyForFallback = getUniqueKey(); + const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || []; // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; + if (currentFiles.length === 0) { + return; + } // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { @@ -491,7 +497,7 @@ const FileUploadComponent: React.FC = ({ setForceUpdate((prev) => prev + 1); } }); - }, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]); + }, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate, isRecordMode, recordId, getUniqueKey]); // 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원) useEffect(() => { diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 0ba94b5d..58db0ad2 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -147,13 +147,10 @@ const FileUploadComponent: React.FC = ({ prevRecordIdRef.current = recordId; prevIsRecordModeRef.current = isRecordMode; - // 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화 - // 등록 모드에서는 항상 빈 상태로 시작해야 함 - if (isRecordMode || !recordId) { - setUploadedFiles([]); - setRepresentativeImageUrl(null); - filesLoadedFromObjidRef.current = false; - } + // 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화 + setUploadedFiles([]); + setRepresentativeImageUrl(null); + filesLoadedFromObjidRef.current = false; } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 prevIsRecordModeRef.current = isRecordMode; @@ -198,7 +195,17 @@ const FileUploadComponent: React.FC = ({ const imageObjidFromFormData = formData?.[columnName]; useEffect(() => { - if (!imageObjidFromFormData) return; + if (!imageObjidFromFormData) { + // formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시) + if (uploadedFiles.length > 0 && !isRecordMode) { + setUploadedFiles([]); + filesLoadedFromObjidRef.current = false; + } + return; + } + + // 등록 모드(새 레코드)일 때는 이전 파일을 로드하지 않음 + if (!isRecordMode) return; const rawValue = String(imageObjidFromFormData); // 콤마 구분 다중 objid 또는 단일 objid 모두 처리 @@ -255,7 +262,7 @@ const FileUploadComponent: React.FC = ({ console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); } })(); - }, [imageObjidFromFormData, columnName, component.id]); + }, [imageObjidFromFormData, columnName, component.id, isRecordMode]); // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 15b7a13b..4649350b 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -123,6 +123,196 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { }); TableCellImage.displayName = "TableCellImage"; +// 📎 테이블 셀 파일 컴포넌트 +// objid(콤마 구분 포함) 또는 JSON 배열 값을 받아 파일명 표시 + 클릭 시 읽기 전용 모달 +const TableCellFile: React.FC<{ value: string }> = React.memo(({ value }) => { + const [fileInfos, setFileInfos] = React.useState>([]); + const [loading, setLoading] = React.useState(true); + const [modalOpen, setModalOpen] = React.useState(false); + + React.useEffect(() => { + let mounted = true; + const rawValue = String(value).trim(); + if (!rawValue || rawValue === "-") { + setLoading(false); + return; + } + + // JSON 배열 형태인지 확인 + try { + const parsed = JSON.parse(rawValue); + if (Array.isArray(parsed)) { + const infos = parsed.map((f: any) => ({ + objid: String(f.objid || f.id || ""), + name: f.realFileName || f.real_file_name || f.name || "파일", + ext: f.fileExt || f.file_ext || "", + size: f.fileSize || f.file_size || 0, + })); + if (mounted) { + setFileInfos(infos); + setLoading(false); + } + return; + } + } catch { + // JSON 파싱 실패 → objid 문자열로 처리 + } + + // 콤마 구분 objid 또는 단일 objid + const objids = rawValue.split(",").map(s => s.trim()).filter(Boolean); + if (objids.length === 0) { + if (mounted) setLoading(false); + return; + } + + Promise.all( + objids.map(async (oid) => { + try { + const { getFileInfoByObjid } = await import("@/lib/api/file"); + const res = await getFileInfoByObjid(oid); + if (res.success && res.data) { + return { + objid: oid, + name: res.data.realFileName || "파일", + ext: res.data.fileExt || "", + size: res.data.fileSize || 0, + }; + } + } catch {} + return { objid: oid, name: `파일(${oid})`, ext: "" }; + }) + ).then((results) => { + if (mounted) { + setFileInfos(results); + setLoading(false); + } + }); + + return () => { mounted = false; }; + }, [value]); + + if (loading) { + return ...; + } + + if (fileInfos.length === 0) { + return -; + } + + const { Paperclip, Download: DownloadIcon, FileText: FileTextIcon } = require("lucide-react"); + const fileNames = fileInfos.map(f => f.name).join(", "); + + const getFileIconClass = (ext: string) => { + const e = (ext || "").toLowerCase().replace(".", ""); + if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(e)) return "text-primary"; + if (["pdf"].includes(e)) return "text-destructive"; + if (["doc", "docx", "hwp", "hwpx"].includes(e)) return "text-blue-500"; + if (["xls", "xlsx"].includes(e)) return "text-emerald-500"; + return "text-muted-foreground"; + }; + + const handleDownload = async (file: { objid: string; name: string }) => { + if (!file.objid) return; + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/files/download/${file.objid}`, { + responseType: "blob", + }); + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = file.name || "download"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error("파일 다운로드 오류:", err); + } + }; + + + return ( + <> +
{ + e.stopPropagation(); + setModalOpen(true); + }} + > + + + {fileInfos.length === 1 ? fileNames : `첨부파일 ${fileInfos.length}건`} + +
+ + {modalOpen && ( +
{ + e.stopPropagation(); + setModalOpen(false); + }} + > +
e.stopPropagation()} + > +
+
+ + 첨부파일 ({fileInfos.length}) +
+ +
+
+ {fileInfos.map((file, idx) => ( +
+ +
+

{file.name}

+ {file.size ? ( +

+ {file.size > 1048576 + ? `${(file.size / 1048576).toFixed(1)} MB` + : `${(file.size / 1024).toFixed(0)} KB`} +

+ ) : null} +
+ +
+ ))} +
+
+
+ )} + + ); +}); +TableCellFile.displayName = "TableCellFile"; + // 이미지 blob 로딩 헬퍼 function loadImageBlob( objid: string, @@ -4303,8 +4493,7 @@ export const TableListComponent: React.FC = ({ return ; } - // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 - // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 + // 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원) const isAttachmentColumn = inputType === "file" || inputType === "attachment" || @@ -4312,41 +4501,11 @@ export const TableListComponent: React.FC = ({ column.columnName?.toLowerCase().includes("attachment") || column.columnName?.toLowerCase().includes("file"); - if (isAttachmentColumn) { - // JSONB 배열 또는 JSON 문자열 파싱 - let files: any[] = []; - try { - if (typeof value === "string" && value.trim()) { - const parsed = JSON.parse(value); - files = Array.isArray(parsed) ? parsed : []; - } else if (Array.isArray(value)) { - files = value; - } else if (value && typeof value === "object") { - // 단일 객체인 경우 배열로 변환 - files = [value]; - } - } catch (e) { - // 파싱 실패 시 빈 배열 - console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e }); - } - - if (!files || files.length === 0) { - return -; - } - - // 파일 이름 표시 (여러 개면 쉼표로 구분) - const { Paperclip } = require("lucide-react"); - const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", "); - - return ( -
- - - {fileNames} - - {files.length > 1 && ({files.length})} -
- ); + if (isAttachmentColumn && value) { + return ; + } + if (isAttachmentColumn && !value) { + return -; } // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)