jskim-node #420

Merged
kjs merged 16 commits from jskim-node into main 2026-03-17 21:09:11 +09:00
8 changed files with 364 additions and 94 deletions
Showing only changes of commit 7c96461f59 - Show all commits

View File

@ -10,7 +10,7 @@ export const getAuditLogs = async (
): Promise<void> => {
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<void> => {
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<void> => {
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'"];

View File

@ -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<null> = {
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<null> = {
success: true,
message: "모든 컬럼 설정을 성공적으로 저장했습니다.",

View File

@ -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 };
}

View File

@ -66,6 +66,8 @@ export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
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: "🖼" },
};
/** 컬럼 그룹 판별 */

View File

@ -135,6 +135,14 @@ const TextInput = forwardRef<
const [hasBlurred, setHasBlurred] = useState(false);
const [validationError, setValidationError] = useState<string>("");
// 커서 위치 보존을 위한 내부 ref
const innerRef = useRef<HTMLInputElement>(null);
const combinedRef = (node: HTMLInputElement | null) => {
(innerRef as React.MutableRefObject<HTMLInputElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
};
// 형식에 따른 값 포맷팅
const formatValue = useCallback(
(val: string): string => {
@ -154,11 +162,15 @@ const TextInput = forwardRef<
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="relative h-full w-full">
<Input
ref={ref}
ref={combinedRef}
type="text"
value={displayValue}
onChange={handleChange}

View File

@ -154,48 +154,53 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
const prevRecordIdRef = useRef<any>(null);
const prevIsRecordModeRef = useRef<boolean | null>(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<FileUploadComponentProps> = ({
} catch (e) {
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
}
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
}, [component.id, getUniqueKey, recordId, isRecordMode]);
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
useEffect(() => {
@ -325,9 +330,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
// 컴포넌트 파일 동기화 (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<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
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(() => {

View File

@ -147,13 +147,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
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<FileUploadComponentProps> = ({
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}, [imageObjidFromFormData, columnName, component.id]);
}, [imageObjidFromFormData, columnName, component.id, isRecordMode]);
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분

View File

@ -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<Array<{ objid: string; name: string; ext: string; size?: number }>>([]);
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 <span className="text-muted-foreground text-xs animate-pulse">...</span>;
}
if (fileInfos.length === 0) {
return <span className="text-muted-foreground text-xs">-</span>;
}
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 (
<>
<div
className="flex max-w-full cursor-pointer items-center gap-1.5 text-sm hover:underline"
title={`클릭하여 첨부파일 보기`}
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
>
<Paperclip className="h-4 w-4 shrink-0 text-amber-500" />
<span className="truncate text-blue-600">
{fileInfos.length === 1 ? fileNames : `첨부파일 ${fileInfos.length}`}
</span>
</div>
{modalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => {
e.stopPropagation();
setModalOpen(false);
}}
>
<div
className="w-full max-w-md rounded-lg border bg-card p-0 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-amber-500" />
<span className="text-sm font-semibold"> ({fileInfos.length})</span>
</div>
<button
type="button"
onClick={() => setModalOpen(false)}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<span className="sr-only"></span>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-60 overflow-y-auto p-2">
{fileInfos.map((file, idx) => (
<div
key={file.objid || idx}
className="flex items-center gap-3 rounded-md px-3 py-2 hover:bg-muted/50"
>
<FileTextIcon className={`h-5 w-5 shrink-0 ${getFileIconClass(file.ext)}`} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.size ? (
<p className="text-xs text-muted-foreground">
{file.size > 1048576
? `${(file.size / 1048576).toFixed(1)} MB`
: `${(file.size / 1024).toFixed(0)} KB`}
</p>
) : null}
</div>
<button
type="button"
title="다운로드"
onClick={(e) => { e.stopPropagation(); handleDownload(file); }}
className="shrink-0 rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<DownloadIcon className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
</>
);
});
TableCellFile.displayName = "TableCellFile";
// 이미지 blob 로딩 헬퍼
function loadImageBlob(
objid: string,
@ -4303,8 +4493,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return <TableCellImage value={String(value)} />;
}
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
// 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원)
const isAttachmentColumn =
inputType === "file" ||
inputType === "attachment" ||
@ -4312,41 +4501,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
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 <span className="text-muted-foreground text-xs">-</span>;
}
// 파일 이름 표시 (여러 개면 쉼표로 구분)
const { Paperclip } = require("lucide-react");
const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", ");
return (
<div className="flex max-w-full items-center gap-1.5 text-sm">
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
<span className="truncate text-blue-600" title={fileNames}>
{fileNames}
</span>
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
</div>
);
if (isAttachmentColumn && value) {
return <TableCellFile value={String(value)} />;
}
if (isAttachmentColumn && !value) {
return <span className="text-muted-foreground text-xs">-</span>;
}
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)