feat: implement document management module
- 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>
This commit is contained in:
197
frontend/src/components/AttachmentList.tsx
Normal file
197
frontend/src/components/AttachmentList.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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
|
||||
Reference in New Issue
Block a user