fix(frontend): labels
This commit is contained in:
parent
2058e5db8d
commit
13ea0b3a5c
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Paper,
|
Paper,
|
||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
|
Divider,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
CloudUpload as UploadIcon,
|
CloudUpload as UploadIcon,
|
||||||
|
|
@ -29,6 +30,8 @@ import {
|
||||||
import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone';
|
import { useDropzone, FileRejection, DropzoneOptions } from 'react-dropzone';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useNotifications } from '../../contexts/NotificationContext';
|
import { useNotifications } from '../../contexts/NotificationContext';
|
||||||
|
import LabelSelector from '../Labels/LabelSelector';
|
||||||
|
import { type LabelData } from '../Labels/Label';
|
||||||
|
|
||||||
interface UploadedDocument {
|
interface UploadedDocument {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -59,6 +62,42 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<LabelData[]>([]);
|
||||||
|
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
|
||||||
|
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLabels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLabels = async () => {
|
||||||
|
try {
|
||||||
|
setLabelsLoading(true);
|
||||||
|
const response = await api.get('/labels?include_counts=false');
|
||||||
|
|
||||||
|
if (response.status === 200 && Array.isArray(response.data)) {
|
||||||
|
setAvailableLabels(response.data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch labels:', response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch labels:', error);
|
||||||
|
} finally {
|
||||||
|
setLabelsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLabel = async (labelData: Omit<LabelData, 'id' | 'is_system' | 'created_at' | 'updated_at' | 'document_count' | 'source_count'>) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/labels', labelData);
|
||||||
|
const newLabel = response.data;
|
||||||
|
setAvailableLabels(prev => [...prev, newLabel]);
|
||||||
|
return newLabel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create label:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
@ -105,6 +144,12 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
const uploadFile = async (fileItem: FileItem): Promise<void> => {
|
const uploadFile = async (fileItem: FileItem): Promise<void> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', fileItem.file);
|
formData.append('file', fileItem.file);
|
||||||
|
|
||||||
|
// Add selected labels to the form data
|
||||||
|
if (selectedLabels.length > 0) {
|
||||||
|
const labelIds = selectedLabels.map(label => label.id);
|
||||||
|
formData.append('label_ids', JSON.stringify(labelIds));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setFiles(prev => prev.map(f =>
|
setFiles(prev => prev.map(f =>
|
||||||
|
|
@ -135,6 +180,17 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
: f
|
: f
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Assign labels to the uploaded document if any are selected
|
||||||
|
if (selectedLabels.length > 0) {
|
||||||
|
try {
|
||||||
|
const labelIds = selectedLabels.map(label => label.id);
|
||||||
|
await api.put(`/labels/documents/${response.data.id}`, { label_ids: labelIds });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to assign labels to document:', error);
|
||||||
|
// Don't fail the upload if label assignment fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (onUploadComplete) {
|
if (onUploadComplete) {
|
||||||
onUploadComplete(response.data);
|
onUploadComplete(response.data);
|
||||||
}
|
}
|
||||||
|
|
@ -293,6 +349,32 @@ const UploadZone: React.FC<UploadZoneProps> = ({ onUploadComplete }) => {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Label Selection */}
|
||||||
|
<Card elevation={0} sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||||
|
📋 Label Assignment
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Select labels to automatically assign to all uploaded documents
|
||||||
|
</Typography>
|
||||||
|
<LabelSelector
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
availableLabels={availableLabels}
|
||||||
|
onLabelsChange={setSelectedLabels}
|
||||||
|
onCreateLabel={handleCreateLabel}
|
||||||
|
placeholder="Choose labels for your documents..."
|
||||||
|
size="medium"
|
||||||
|
disabled={labelsLoading}
|
||||||
|
/>
|
||||||
|
{selectedLabels.length > 0 && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||||
|
These labels will be applied to all uploaded documents
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* File List */}
|
{/* File List */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<Card elevation={0}>
|
<Card elevation={0}>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ import {
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Select,
|
Select,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import Grid from '@mui/material/GridLegacy';
|
import Grid from '@mui/material/GridLegacy';
|
||||||
import {
|
import {
|
||||||
|
|
@ -44,9 +48,13 @@ import {
|
||||||
Visibility as ViewIcon,
|
Visibility as ViewIcon,
|
||||||
ChevronLeft as ChevronLeftIcon,
|
ChevronLeft as ChevronLeftIcon,
|
||||||
ChevronRight as ChevronRightIcon,
|
ChevronRight as ChevronRightIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { documentService } from '../services/api';
|
import { documentService } from '../services/api';
|
||||||
import DocumentThumbnail from '../components/DocumentThumbnail';
|
import DocumentThumbnail from '../components/DocumentThumbnail';
|
||||||
|
import Label, { type LabelData } from '../components/Labels/Label';
|
||||||
|
import LabelSelector from '../components/Labels/LabelSelector';
|
||||||
|
import { useApi } from '../hooks/useApi';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -59,6 +67,7 @@ interface Document {
|
||||||
ocr_status?: string;
|
ocr_status?: string;
|
||||||
ocr_confidence?: number;
|
ocr_confidence?: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
labels?: LabelData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginationInfo {
|
interface PaginationInfo {
|
||||||
|
|
@ -79,6 +88,7 @@ type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
const DocumentsPage: React.FC = () => {
|
const DocumentsPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const api = useApi();
|
||||||
const [documents, setDocuments] = useState<Document[]>([]);
|
const [documents, setDocuments] = useState<Document[]>([]);
|
||||||
const [pagination, setPagination] = useState<PaginationInfo>({ total: 0, limit: 20, offset: 0, has_more: false });
|
const [pagination, setPagination] = useState<PaginationInfo>({ total: 0, limit: 20, offset: 0, has_more: false });
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
@ -89,6 +99,13 @@ const DocumentsPage: React.FC = () => {
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||||
const [ocrFilter, setOcrFilter] = useState<string>('');
|
const [ocrFilter, setOcrFilter] = useState<string>('');
|
||||||
|
|
||||||
|
// Labels state
|
||||||
|
const [availableLabels, setAvailableLabels] = useState<LabelData[]>([]);
|
||||||
|
const [labelsLoading, setLabelsLoading] = useState<boolean>(false);
|
||||||
|
const [labelEditDialogOpen, setLabelEditDialogOpen] = useState<boolean>(false);
|
||||||
|
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(null);
|
||||||
|
const [editingDocumentLabels, setEditingDocumentLabels] = useState<LabelData[]>([]);
|
||||||
|
|
||||||
// Menu states
|
// Menu states
|
||||||
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
|
const [sortMenuAnchor, setSortMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
const [docMenuAnchor, setDocMenuAnchor] = useState<null | HTMLElement>(null);
|
const [docMenuAnchor, setDocMenuAnchor] = useState<null | HTMLElement>(null);
|
||||||
|
|
@ -96,6 +113,7 @@ const DocumentsPage: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDocuments();
|
fetchDocuments();
|
||||||
|
fetchLabels();
|
||||||
}, [pagination.limit, pagination.offset, ocrFilter]);
|
}, [pagination.limit, pagination.offset, ocrFilter]);
|
||||||
|
|
||||||
const fetchDocuments = async (): Promise<void> => {
|
const fetchDocuments = async (): Promise<void> => {
|
||||||
|
|
@ -116,6 +134,63 @@ const DocumentsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchLabels = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setLabelsLoading(true);
|
||||||
|
const response = await api.get('/labels?include_counts=false');
|
||||||
|
|
||||||
|
if (response.status === 200 && Array.isArray(response.data)) {
|
||||||
|
setAvailableLabels(response.data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch labels:', response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch labels:', error);
|
||||||
|
} finally {
|
||||||
|
setLabelsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLabel = async (labelData: Omit<LabelData, 'id' | 'is_system' | 'created_at' | 'updated_at' | 'document_count' | 'source_count'>) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/labels', labelData);
|
||||||
|
const newLabel = response.data;
|
||||||
|
setAvailableLabels(prev => [...prev, newLabel]);
|
||||||
|
return newLabel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create label:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditDocumentLabels = (doc: Document) => {
|
||||||
|
setEditingDocumentId(doc.id);
|
||||||
|
setEditingDocumentLabels(doc.labels || []);
|
||||||
|
setLabelEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDocumentLabels = async () => {
|
||||||
|
if (!editingDocumentId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const labelIds = editingDocumentLabels.map(label => label.id);
|
||||||
|
await api.put(`/labels/documents/${editingDocumentId}`, { label_ids: labelIds });
|
||||||
|
|
||||||
|
// Update the document in the local state
|
||||||
|
setDocuments(prev => prev.map(doc =>
|
||||||
|
doc.id === editingDocumentId
|
||||||
|
? { ...doc, labels: editingDocumentLabels }
|
||||||
|
: doc
|
||||||
|
));
|
||||||
|
|
||||||
|
setLabelEditDialogOpen(false);
|
||||||
|
setEditingDocumentId(null);
|
||||||
|
setEditingDocumentLabels([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update document labels:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownload = async (doc: Document): Promise<void> => {
|
const handleDownload = async (doc: Document): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const response = await documentService.download(doc.id);
|
const response = await documentService.download(doc.id);
|
||||||
|
|
@ -396,6 +471,13 @@ const DocumentsPage: React.FC = () => {
|
||||||
<ListItemIcon><ViewIcon fontSize="small" /></ListItemIcon>
|
<ListItemIcon><ViewIcon fontSize="small" /></ListItemIcon>
|
||||||
<ListItemText>View Details</ListItemText>
|
<ListItemText>View Details</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
if (selectedDoc) handleEditDocumentLabels(selectedDoc);
|
||||||
|
handleDocMenuClose();
|
||||||
|
}}>
|
||||||
|
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||||
|
<ListItemText>Edit Labels</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{/* Documents Grid/List */}
|
{/* Documents Grid/List */}
|
||||||
|
|
@ -520,6 +602,27 @@ const DocumentsPage: React.FC = () => {
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{doc.labels && doc.labels.length > 0 && (
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ mb: 1, flexWrap: 'wrap' }}>
|
||||||
|
{doc.labels.slice(0, 3).map((label) => (
|
||||||
|
<Label
|
||||||
|
key={label.id}
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{doc.labels.length > 3 && (
|
||||||
|
<Chip
|
||||||
|
label={`+${doc.labels.length - 3}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: '0.7rem', height: '20px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{formatDate(doc.created_at)}
|
{formatDate(doc.created_at)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -558,6 +661,28 @@ const DocumentsPage: React.FC = () => {
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Label Edit Dialog */}
|
||||||
|
<Dialog open={labelEditDialogOpen} onClose={() => setLabelEditDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Edit Document Labels</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ pt: 2 }}>
|
||||||
|
<LabelSelector
|
||||||
|
selectedLabels={editingDocumentLabels}
|
||||||
|
availableLabels={availableLabels}
|
||||||
|
onLabelsChange={setEditingDocumentLabels}
|
||||||
|
onCreateLabel={handleCreateLabel}
|
||||||
|
placeholder="Select labels for this document..."
|
||||||
|
size="medium"
|
||||||
|
disabled={labelsLoading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setLabelEditDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSaveDocumentLabels} variant="contained">Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Results count and pagination */}
|
{/* Results count and pagination */}
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue