Files
sam-react-prod/src/components/ui/file-list.tsx
유병철 ea6ca335f1 feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬
- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결)
- frame-src에 'self' 추가
- 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto)
- HR 사원관리, 결재, 품목, 생산 등 다수 개선
- API 에러 핸들링 및 JSON 파싱 안정화
2026-03-11 22:32:58 +09:00

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;