From 7c96461f5946403ddbf173a78dd81217bbd9567a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 11:31:54 +0900 Subject: [PATCH] feat: enhance audit log functionality and file upload components - Updated the audit log controller to determine super admin status based on user type instead of company code. - Added detailed logging for column settings updates and batch updates in the table management controller, capturing user actions and changes made. - Implemented security measures in the audit log service to mask sensitive data for non-super admin users. - Introduced a new TableCellFile component to handle file attachments, supporting both objid and JSON array formats for file information. - Enhanced the file upload component to manage file states more effectively during record changes and mode transitions. These updates aim to improve the audit logging capabilities and file management features within the ERP system, ensuring better security and user experience. --- .../src/controllers/auditLogController.ts | 6 +- .../controllers/tableManagementController.ts | 48 ++++ backend-node/src/services/auditLogService.ts | 22 ++ frontend/components/admin/table-type/types.ts | 2 + frontend/components/v2/V2Input.tsx | 30 ++- .../file-upload/FileUploadComponent.tsx | 92 +++---- .../v2-file-upload/FileUploadComponent.tsx | 25 +- .../v2-table-list/TableListComponent.tsx | 233 +++++++++++++++--- 8 files changed, 364 insertions(+), 94 deletions(-) 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 -; } // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)