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.
This commit is contained in:
parent
4e65af4919
commit
7c96461f59
|
|
@ -10,7 +10,7 @@ export const getAuditLogs = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
const isSuperAdmin = userCompanyCode === "*";
|
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
companyCode,
|
companyCode,
|
||||||
|
|
@ -63,7 +63,7 @@ export const getAuditLogStats = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
const isSuperAdmin = userCompanyCode === "*";
|
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||||
const { companyCode, days } = req.query;
|
const { companyCode, days } = req.query;
|
||||||
|
|
||||||
const targetCompany = isSuperAdmin
|
const targetCompany = isSuperAdmin
|
||||||
|
|
@ -91,7 +91,7 @@ export const getAuditLogUsers = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const userCompanyCode = req.user?.companyCode;
|
const userCompanyCode = req.user?.companyCode;
|
||||||
const isSuperAdmin = userCompanyCode === "*";
|
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||||
const { companyCode } = req.query;
|
const { companyCode } = req.query;
|
||||||
|
|
||||||
const conditions: string[] = ["LOWER(u.status) = 'active'"];
|
const conditions: string[] = ["LOWER(u.status) = 'active'"];
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,31 @@ export async function updateColumnSettings(
|
||||||
`컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}`
|
`컬럼 설정 업데이트 완료: ${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> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "컬럼 설정을 성공적으로 저장했습니다.",
|
message: "컬럼 설정을 성공적으로 저장했습니다.",
|
||||||
|
|
@ -339,6 +364,29 @@ export async function updateAllColumnSettings(
|
||||||
`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
|
`전체 컬럼 설정 일괄 업데이트 완료: ${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> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "모든 컬럼 설정을 성공적으로 저장했습니다.",
|
message: "모든 컬럼 설정을 성공적으로 저장했습니다.",
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,28 @@ class AuditLogService {
|
||||||
[...params, limit, offset]
|
[...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 };
|
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: "⊟" },
|
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: "≡" },
|
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: "◉" },
|
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 [hasBlurred, setHasBlurred] = useState(false);
|
||||||
const [validationError, setValidationError] = useState<string>("");
|
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(
|
const formatValue = useCallback(
|
||||||
(val: string): string => {
|
(val: string): string => {
|
||||||
|
|
@ -154,11 +162,15 @@ const TextInput = forwardRef<
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = e.target;
|
||||||
|
const cursorPos = input.selectionStart ?? 0;
|
||||||
let newValue = e.target.value;
|
let newValue = e.target.value;
|
||||||
|
const oldValue = input.value;
|
||||||
|
|
||||||
|
const needsCursorFix = format === "biz_no" || format === "tel" || format === "currency";
|
||||||
|
|
||||||
// 형식에 따른 자동 포맷팅
|
// 형식에 따른 자동 포맷팅
|
||||||
if (format === "currency") {
|
if (format === "currency") {
|
||||||
// 숫자와 쉼표만 허용
|
|
||||||
newValue = newValue.replace(/[^\d,]/g, "");
|
newValue = newValue.replace(/[^\d,]/g, "");
|
||||||
newValue = formatCurrency(newValue);
|
newValue = formatCurrency(newValue);
|
||||||
} else if (format === "biz_no") {
|
} else if (format === "biz_no") {
|
||||||
|
|
@ -167,6 +179,20 @@ const TextInput = forwardRef<
|
||||||
newValue = formatTel(newValue);
|
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) {
|
if (hasBlurred && validationError) {
|
||||||
const { isValid } = validateInputFormat(newValue, format);
|
const { isValid } = validateInputFormat(newValue, format);
|
||||||
|
|
@ -244,7 +270,7 @@ const TextInput = forwardRef<
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={combinedRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
|
|
||||||
|
|
@ -154,48 +154,53 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
|
||||||
const prevRecordIdRef = useRef<any>(null);
|
const prevRecordIdRef = useRef<any>(null);
|
||||||
|
const prevIsRecordModeRef = useRef<boolean | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevRecordIdRef.current !== recordId) {
|
const recordIdChanged = prevRecordIdRef.current !== null && prevRecordIdRef.current !== recordId;
|
||||||
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
|
const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode;
|
||||||
prev: prevRecordIdRef.current,
|
|
||||||
current: recordId,
|
if (recordIdChanged || modeChanged) {
|
||||||
isRecordMode,
|
|
||||||
});
|
|
||||||
prevRecordIdRef.current = recordId;
|
prevRecordIdRef.current = recordId;
|
||||||
|
prevIsRecordModeRef.current = isRecordMode;
|
||||||
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
|
|
||||||
if (isRecordMode) {
|
// 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화
|
||||||
setUploadedFiles([]);
|
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에서 파일 복원
|
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!component?.id) return;
|
if (!component?.id) return;
|
||||||
|
|
||||||
|
// 새 등록 모드(레코드 없음)에서는 localStorage 복원 스킵 - 빈 상태 유지
|
||||||
|
if (!isRecordMode || !recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🔑 레코드별 고유 키 사용
|
// 🔑 레코드별 고유 키 사용
|
||||||
const backupKey = getUniqueKey();
|
const backupKey = getUniqueKey();
|
||||||
const backupFiles = localStorage.getItem(backupKey);
|
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) {
|
if (backupFiles) {
|
||||||
const parsedFiles = JSON.parse(backupFiles);
|
const parsedFiles = JSON.parse(backupFiles);
|
||||||
if (parsedFiles.length > 0) {
|
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);
|
setUploadedFiles(parsedFiles);
|
||||||
|
|
||||||
// 전역 상태에도 복원 (레코드별 고유 키 사용)
|
// 전역 상태에도 복원 (레코드별 고유 키 사용)
|
||||||
|
|
@ -210,7 +215,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
||||||
}
|
}
|
||||||
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
|
}, [component.id, getUniqueKey, recordId, isRecordMode]);
|
||||||
|
|
||||||
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
|
// 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -325,9 +330,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const loadComponentFiles = useCallback(async () => {
|
const loadComponentFiles = useCallback(async () => {
|
||||||
if (!component?.id) return false;
|
if (!component?.id) return false;
|
||||||
|
|
||||||
|
// 새 등록 모드(레코드 없음)에서는 파일 조회 스킵 - 빈 상태 유지
|
||||||
|
if (!isRecordMode || !recordId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🔑 레코드 모드: 해당 행의 파일만 조회
|
// 🔑 레코드 모드: 해당 행의 파일만 조회
|
||||||
if (isRecordMode && recordTableName && recordId) {
|
if (recordTableName) {
|
||||||
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
|
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
|
||||||
tableName: recordTableName,
|
tableName: recordTableName,
|
||||||
recordId: recordId,
|
recordId: recordId,
|
||||||
|
|
@ -457,17 +467,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const componentFiles = (component as any)?.uploadedFiles || [];
|
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에서 최신 파일 목록을 조회 (멀티테넌시 격리)
|
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
|
||||||
loadComponentFiles().then((dbLoadSuccess) => {
|
loadComponentFiles().then((dbLoadSuccess) => {
|
||||||
|
|
@ -475,15 +474,22 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
return; // DB 로드 성공 시 localStorage 무시
|
return; // DB 로드 성공 시 localStorage 무시
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
// 새 등록 모드(레코드 없음)에서는 fallback 로드도 스킵 - 항상 빈 상태 유지
|
||||||
|
if (!isRecordMode || !recordId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 전역 상태에서 최신 파일 정보 가져오기
|
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
|
||||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
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;
|
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||||
|
|
||||||
|
if (currentFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 최신 파일과 현재 파일 비교
|
// 최신 파일과 현재 파일 비교
|
||||||
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||||
|
|
@ -491,7 +497,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
setForceUpdate((prev) => prev + 1);
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -147,13 +147,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
prevRecordIdRef.current = recordId;
|
prevRecordIdRef.current = recordId;
|
||||||
prevIsRecordModeRef.current = isRecordMode;
|
prevIsRecordModeRef.current = isRecordMode;
|
||||||
|
|
||||||
// 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화
|
// 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화
|
||||||
// 등록 모드에서는 항상 빈 상태로 시작해야 함
|
setUploadedFiles([]);
|
||||||
if (isRecordMode || !recordId) {
|
setRepresentativeImageUrl(null);
|
||||||
setUploadedFiles([]);
|
filesLoadedFromObjidRef.current = false;
|
||||||
setRepresentativeImageUrl(null);
|
|
||||||
filesLoadedFromObjidRef.current = false;
|
|
||||||
}
|
|
||||||
} else if (prevIsRecordModeRef.current === null) {
|
} else if (prevIsRecordModeRef.current === null) {
|
||||||
// 초기 마운트 시 모드 저장
|
// 초기 마운트 시 모드 저장
|
||||||
prevIsRecordModeRef.current = isRecordMode;
|
prevIsRecordModeRef.current = isRecordMode;
|
||||||
|
|
@ -198,7 +195,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const imageObjidFromFormData = formData?.[columnName];
|
const imageObjidFromFormData = formData?.[columnName];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageObjidFromFormData) return;
|
if (!imageObjidFromFormData) {
|
||||||
|
// formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시)
|
||||||
|
if (uploadedFiles.length > 0 && !isRecordMode) {
|
||||||
|
setUploadedFiles([]);
|
||||||
|
filesLoadedFromObjidRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등록 모드(새 레코드)일 때는 이전 파일을 로드하지 않음
|
||||||
|
if (!isRecordMode) return;
|
||||||
|
|
||||||
const rawValue = String(imageObjidFromFormData);
|
const rawValue = String(imageObjidFromFormData);
|
||||||
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
|
||||||
|
|
@ -255,7 +262,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [imageObjidFromFormData, columnName, component.id]);
|
}, [imageObjidFromFormData, columnName, component.id, isRecordMode]);
|
||||||
|
|
||||||
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
|
||||||
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,196 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
});
|
});
|
||||||
TableCellImage.displayName = "TableCellImage";
|
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 로딩 헬퍼
|
// 이미지 blob 로딩 헬퍼
|
||||||
function loadImageBlob(
|
function loadImageBlob(
|
||||||
objid: string,
|
objid: string,
|
||||||
|
|
@ -4303,8 +4493,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return <TableCellImage value={String(value)} />;
|
return <TableCellImage value={String(value)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
|
// 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원)
|
||||||
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
|
|
||||||
const isAttachmentColumn =
|
const isAttachmentColumn =
|
||||||
inputType === "file" ||
|
inputType === "file" ||
|
||||||
inputType === "attachment" ||
|
inputType === "attachment" ||
|
||||||
|
|
@ -4312,41 +4501,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
column.columnName?.toLowerCase().includes("attachment") ||
|
column.columnName?.toLowerCase().includes("attachment") ||
|
||||||
column.columnName?.toLowerCase().includes("file");
|
column.columnName?.toLowerCase().includes("file");
|
||||||
|
|
||||||
if (isAttachmentColumn) {
|
if (isAttachmentColumn && value) {
|
||||||
// JSONB 배열 또는 JSON 문자열 파싱
|
return <TableCellFile value={String(value)} />;
|
||||||
let files: any[] = [];
|
}
|
||||||
try {
|
if (isAttachmentColumn && !value) {
|
||||||
if (typeof value === "string" && value.trim()) {
|
return <span className="text-muted-foreground text-xs">-</span>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue