jskim-node #420
|
|
@ -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'"];
|
||||
|
|
|
|||
|
|
@ -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: "모든 컬럼 설정을 성공적으로 저장했습니다.",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "🖼" },
|
||||
};
|
||||
|
||||
/** 컬럼 그룹 판별 */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
||||
|
|
|
|||
Loading…
Reference in New Issue