jskim-node #420
|
|
@ -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,
|
|
||||||
isRecordMode,
|
|
||||||
});
|
|
||||||
prevRecordIdRef.current = recordId;
|
|
||||||
|
|
||||||
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
|
if (recordIdChanged || modeChanged) {
|
||||||
if (isRecordMode) {
|
prevRecordIdRef.current = recordId;
|
||||||
|
prevIsRecordModeRef.current = 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 {}
|
||||||
}
|
}
|
||||||
}, [recordId, isRecordMode]);
|
} else if (prevRecordIdRef.current === null) {
|
||||||
|
prevRecordIdRef.current = recordId;
|
||||||
|
prevIsRecordModeRef.current = 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)로 전환되면 파일 목록 초기화
|
// 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화
|
||||||
// 등록 모드에서는 항상 빈 상태로 시작해야 함
|
|
||||||
if (isRecordMode || !recordId) {
|
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
setRepresentativeImageUrl(null);
|
setRepresentativeImageUrl(null);
|
||||||
filesLoadedFromObjidRef.current = false;
|
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,43 +4501,13 @@ 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 (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) {
|
if (isAttachmentColumn && !value) {
|
||||||
// 파싱 실패 시 빈 배열
|
|
||||||
console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
return <span className="text-muted-foreground text-xs">-</span>;
|
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 (inputType === "category") {
|
if (inputType === "category") {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue