- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결) - frame-src에 'self' 추가 - 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto) - HR 사원관리, 결재, 품목, 생산 등 다수 개선 - API 에러 핸들링 및 JSON 파싱 안정화
276 lines
8.7 KiB
TypeScript
276 lines
8.7 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* FileList - 업로드된 파일 목록 표시 컴포넌트
|
|
*
|
|
* 첨부파일 목록 표시, 다운로드, 삭제 기능
|
|
*
|
|
* 사용 예시:
|
|
* <FileList
|
|
* files={uploadedFiles}
|
|
* onRemove={(index) => removeFile(index)}
|
|
* onDownload={(file) => downloadFile(file)}
|
|
* />
|
|
*/
|
|
|
|
import { useCallback } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { FileText, Download, Trash2, ExternalLink, Loader2 } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/** 새로 업로드된 파일 */
|
|
export interface NewFile {
|
|
file: File;
|
|
/** 업로드 진행률 (0-100) */
|
|
progress?: number;
|
|
/** 업로드 중 여부 */
|
|
uploading?: boolean;
|
|
/** 에러 메시지 */
|
|
error?: string;
|
|
}
|
|
|
|
/** 기존 파일 (서버에서 가져온) */
|
|
export interface ExistingFile {
|
|
id: string | number;
|
|
name: string;
|
|
url?: string;
|
|
size?: number;
|
|
/** 삭제 중 여부 */
|
|
deleting?: boolean;
|
|
}
|
|
|
|
export interface FileListProps {
|
|
/** 새로 업로드된 파일 목록 */
|
|
files?: NewFile[];
|
|
/** 기존 파일 목록 */
|
|
existingFiles?: ExistingFile[];
|
|
/** 새 파일 제거 콜백 */
|
|
onRemove?: (index: number) => void;
|
|
/** 기존 파일 제거 콜백 */
|
|
onRemoveExisting?: (id: string | number) => void;
|
|
/** 기존 파일 다운로드 콜백 */
|
|
onDownload?: (file: ExistingFile) => void;
|
|
/** 읽기 전용 */
|
|
readOnly?: boolean;
|
|
/** 삭제 버튼 표시 여부 (readOnly의 반대 개념) */
|
|
showRemove?: boolean;
|
|
/** 추가 클래스 */
|
|
className?: string;
|
|
/** 파일 없을 때 메시지 */
|
|
emptyMessage?: string;
|
|
/** 컴팩트 모드 */
|
|
compact?: boolean;
|
|
}
|
|
|
|
// 파일 크기 포맷
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
// 파일 확장자에 따른 아이콘 색상
|
|
function getFileColor(fileName: string): string {
|
|
const ext = (fileName || '').split('.').pop()?.toLowerCase();
|
|
switch (ext) {
|
|
case 'pdf':
|
|
return 'text-red-500';
|
|
case 'doc':
|
|
case 'docx':
|
|
return 'text-blue-500';
|
|
case 'xls':
|
|
case 'xlsx':
|
|
return 'text-green-500';
|
|
case 'jpg':
|
|
case 'jpeg':
|
|
case 'png':
|
|
case 'gif':
|
|
case 'webp':
|
|
return 'text-purple-500';
|
|
case 'dwg':
|
|
case 'dxf':
|
|
return 'text-orange-500';
|
|
default:
|
|
return 'text-gray-500';
|
|
}
|
|
}
|
|
|
|
export function FileList({
|
|
files = [],
|
|
existingFiles = [],
|
|
onRemove,
|
|
onRemoveExisting,
|
|
onDownload,
|
|
readOnly = false,
|
|
className,
|
|
emptyMessage = '파일이 없습니다',
|
|
compact = false,
|
|
}: FileListProps) {
|
|
const handleDownload = useCallback((file: ExistingFile) => {
|
|
if (onDownload) {
|
|
onDownload(file);
|
|
} else if (file.url) {
|
|
// 기본 다운로드 동작
|
|
const link = document.createElement('a');
|
|
link.href = file.url;
|
|
link.download = file.name;
|
|
link.click();
|
|
}
|
|
}, [onDownload]);
|
|
|
|
const totalFiles = files.length + existingFiles.length;
|
|
|
|
if (totalFiles === 0) {
|
|
return (
|
|
<div className={cn('text-sm text-muted-foreground text-center py-4', className)}>
|
|
{emptyMessage}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
{/* 기존 파일 목록 */}
|
|
{existingFiles.map((file) => (
|
|
<div
|
|
key={file.id}
|
|
className={cn(
|
|
'flex items-center justify-between gap-2 rounded-md border bg-muted/30',
|
|
compact ? 'p-2' : 'p-3',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<FileText className={cn('shrink-0', getFileColor(file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
|
|
<div className="min-w-0 flex-1">
|
|
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
|
|
{file.name}
|
|
</p>
|
|
{file.size && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatFileSize(file.size)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{/* 다운로드 버튼 */}
|
|
{(file.url || onDownload) && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size={compact ? 'sm' : 'default'}
|
|
onClick={() => handleDownload(file)}
|
|
className={cn('text-blue-600 hover:text-blue-700 hover:bg-blue-50', compact && 'h-7 w-7 p-0')}
|
|
title="다운로드"
|
|
>
|
|
<Download className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
|
</Button>
|
|
)}
|
|
|
|
{/* 새 탭에서 열기 (인라인 뷰) */}
|
|
{file.id && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size={compact ? 'sm' : 'default'}
|
|
onClick={() => window.open(`/api/proxy/files/${file.id}/view`, '_blank')}
|
|
className={cn('text-gray-600 hover:text-gray-700', compact && 'h-7 w-7 p-0')}
|
|
title="새 탭에서 열기"
|
|
>
|
|
<ExternalLink className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
|
</Button>
|
|
)}
|
|
|
|
{/* 삭제 버튼 */}
|
|
{!readOnly && onRemoveExisting && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size={compact ? 'sm' : 'default'}
|
|
onClick={() => onRemoveExisting(file.id)}
|
|
disabled={file.deleting}
|
|
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
|
|
title="삭제"
|
|
>
|
|
{file.deleting ? (
|
|
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
|
|
) : (
|
|
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* 새로 업로드된 파일 목록 */}
|
|
{files.map((item, index) => (
|
|
<div
|
|
key={`new-${index}`}
|
|
className={cn(
|
|
'flex items-center justify-between gap-2 rounded-md border',
|
|
item.error ? 'border-red-300 bg-red-50' : 'border-blue-200 bg-blue-50',
|
|
compact ? 'p-2' : 'p-3',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<FileText className={cn('shrink-0', getFileColor(item.file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
|
|
{item.file.name}
|
|
</p>
|
|
{!item.error && (
|
|
<span className={cn('text-blue-600 shrink-0', compact ? 'text-[10px]' : 'text-xs')}>
|
|
(새 파일)
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatFileSize(item.file.size)}
|
|
</p>
|
|
{/* 에러 메시지 */}
|
|
{item.error && (
|
|
<p className="text-xs text-red-500 mt-1">{item.error}</p>
|
|
)}
|
|
{/* 업로드 진행률 */}
|
|
{item.uploading && item.progress !== undefined && (
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
|
|
<div
|
|
className="bg-blue-600 h-1.5 rounded-full transition-all"
|
|
style={{ width: `${item.progress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{/* 삭제/취소 버튼 */}
|
|
{!readOnly && onRemove && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size={compact ? 'sm' : 'default'}
|
|
onClick={() => onRemove(index)}
|
|
disabled={item.uploading}
|
|
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
|
|
title={item.uploading ? '업로드 취소' : '삭제'}
|
|
>
|
|
{item.uploading ? (
|
|
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
|
|
) : (
|
|
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default FileList;
|