- Backend (FastAPI): - Attachment and AttachmentVersion models with migration - FileStorageService with SHA-256 checksum validation - File type validation (whitelist/blacklist) - Full CRUD API with version control support - Audit trail integration for upload/download/delete - Configurable upload directory and file size limit - Frontend (React + Vite): - AttachmentUpload component with drag & drop - AttachmentList component with download/delete - TaskAttachments combined component - Attachments service for API calls - Testing: - 12 tests for storage service and API endpoints - OpenSpec: - add-document-management change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
198 lines
4.8 KiB
TypeScript
198 lines
4.8 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { attachmentService, Attachment } from '../services/attachments'
|
|
|
|
interface AttachmentListProps {
|
|
taskId: string
|
|
onRefresh?: () => void
|
|
}
|
|
|
|
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [deleting, setDeleting] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
loadAttachments()
|
|
}, [taskId])
|
|
|
|
const loadAttachments = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const response = await attachmentService.listAttachments(taskId)
|
|
setAttachments(response.attachments)
|
|
} catch (error) {
|
|
console.error('Failed to load attachments:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleDownload = async (attachment: Attachment) => {
|
|
try {
|
|
await attachmentService.downloadAttachment(attachment.id)
|
|
} catch (error) {
|
|
console.error('Failed to download attachment:', error)
|
|
alert('Failed to download file')
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (attachment: Attachment) => {
|
|
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
|
|
return
|
|
}
|
|
|
|
setDeleting(attachment.id)
|
|
try {
|
|
await attachmentService.deleteAttachment(attachment.id)
|
|
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
|
|
onRefresh?.()
|
|
} catch (error) {
|
|
console.error('Failed to delete attachment:', error)
|
|
alert('Failed to delete file')
|
|
} finally {
|
|
setDeleting(null)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <div style={styles.loading}>Loading attachments...</div>
|
|
}
|
|
|
|
if (attachments.length === 0) {
|
|
return <div style={styles.empty}>No attachments</div>
|
|
}
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
{attachments.map((attachment) => (
|
|
<div key={attachment.id} style={styles.item}>
|
|
<div style={styles.itemInfo}>
|
|
<span style={styles.icon}>
|
|
{attachmentService.getFileIcon(attachment.mime_type)}
|
|
</span>
|
|
<div style={styles.details}>
|
|
<div style={styles.filename}>{attachment.filename}</div>
|
|
<div style={styles.meta}>
|
|
{attachmentService.formatFileSize(attachment.file_size)}
|
|
{attachment.current_version > 1 && (
|
|
<span style={styles.version}>v{attachment.current_version}</span>
|
|
)}
|
|
<span style={styles.uploader}>
|
|
by {attachment.uploader_name || 'Unknown'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={styles.actions}>
|
|
<button
|
|
style={styles.downloadBtn}
|
|
onClick={() => handleDownload(attachment)}
|
|
title="Download"
|
|
>
|
|
Download
|
|
</button>
|
|
<button
|
|
style={styles.deleteBtn}
|
|
onClick={() => handleDelete(attachment)}
|
|
disabled={deleting === attachment.id}
|
|
title="Delete"
|
|
>
|
|
{deleting === attachment.id ? '...' : 'Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
container: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
},
|
|
item: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '12px',
|
|
backgroundColor: '#f8f9fa',
|
|
borderRadius: '8px',
|
|
border: '1px solid #e9ecef',
|
|
},
|
|
itemInfo: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
icon: {
|
|
fontSize: '24px',
|
|
},
|
|
details: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
filename: {
|
|
fontWeight: 500,
|
|
fontSize: '14px',
|
|
color: '#212529',
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
},
|
|
meta: {
|
|
fontSize: '12px',
|
|
color: '#6c757d',
|
|
display: 'flex',
|
|
gap: '8px',
|
|
marginTop: '2px',
|
|
},
|
|
version: {
|
|
backgroundColor: '#e9ecef',
|
|
padding: '1px 6px',
|
|
borderRadius: '4px',
|
|
fontSize: '11px',
|
|
},
|
|
uploader: {
|
|
fontStyle: 'italic',
|
|
},
|
|
actions: {
|
|
display: 'flex',
|
|
gap: '8px',
|
|
},
|
|
downloadBtn: {
|
|
padding: '6px 12px',
|
|
backgroundColor: '#007bff',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
fontSize: '12px',
|
|
cursor: 'pointer',
|
|
},
|
|
deleteBtn: {
|
|
padding: '6px 12px',
|
|
backgroundColor: '#dc3545',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
fontSize: '12px',
|
|
cursor: 'pointer',
|
|
},
|
|
loading: {
|
|
padding: '16px',
|
|
textAlign: 'center',
|
|
color: '#6c757d',
|
|
},
|
|
empty: {
|
|
padding: '16px',
|
|
textAlign: 'center',
|
|
color: '#6c757d',
|
|
fontSize: '14px',
|
|
},
|
|
}
|
|
|
|
export default AttachmentList
|