Implemented complete review/edit workflow for voice commands with ML feedback collection: **Backend:** - Created V012 migration for voice_feedback table with user action tracking - Added VoiceFeedback entity with approval/edit/reject actions - Implemented voice feedback API endpoint (POST /api/v1/voice/feedback) - Fixed user ID extraction bug (req.user.userId vs req.user.sub) **Frontend:** - Built VoiceActivityReview component with field-specific editors - Integrated review dialog into voice command workflow - Added approve/edit/reject handlers with feedback submission - Fixed infinite loop by tracking processed classification IDs **Features:** - Users can review AI-extracted data before saving - Quick-edit capabilities for all activity fields - Feedback data stored for ML model improvement - Activity creation only happens after user approval/edit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
332 lines
9.4 KiB
TypeScript
332 lines
9.4 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Button,
|
|
Box,
|
|
Typography,
|
|
TextField,
|
|
Select,
|
|
MenuItem,
|
|
FormControl,
|
|
InputLabel,
|
|
Alert,
|
|
Chip,
|
|
Grid,
|
|
InputAdornment,
|
|
} from '@mui/material';
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import CloseIcon from '@mui/icons-material/Close';
|
|
|
|
interface VoiceActivityReviewProps {
|
|
open: boolean;
|
|
transcript: string;
|
|
classification: {
|
|
type: string;
|
|
details: Record<string, any>;
|
|
confidence: number;
|
|
timestamp?: Date;
|
|
};
|
|
onApprove: (data: any) => void;
|
|
onEdit: (editedData: any) => void;
|
|
onReject: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function VoiceActivityReview({
|
|
open,
|
|
transcript,
|
|
classification,
|
|
onApprove,
|
|
onEdit,
|
|
onReject,
|
|
onClose,
|
|
}: VoiceActivityReviewProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editedData, setEditedData] = useState<Record<string, any>>({});
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setEditedData(classification.details || {});
|
|
setIsEditing(false);
|
|
}
|
|
}, [open, classification]);
|
|
|
|
const handleApprove = () => {
|
|
onApprove({
|
|
type: classification.type,
|
|
details: classification.details,
|
|
timestamp: classification.timestamp,
|
|
});
|
|
};
|
|
|
|
const handleSaveEdit = () => {
|
|
onEdit({
|
|
type: classification.type,
|
|
details: editedData,
|
|
timestamp: classification.timestamp,
|
|
});
|
|
};
|
|
|
|
const renderFieldEditor = (key: string, value: any) => {
|
|
// Render different input types based on field
|
|
if (key === 'notes') {
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
multiline
|
|
rows={2}
|
|
label="Notes"
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: e.target.value })}
|
|
variant="outlined"
|
|
size="small"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (key === 'amount') {
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
type="number"
|
|
label="Amount"
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: Number(e.target.value) })}
|
|
variant="outlined"
|
|
size="small"
|
|
InputProps={{
|
|
endAdornment: <InputAdornment position="end">{editedData.unit || 'ml'}</InputAdornment>,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (key === 'duration') {
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
type="number"
|
|
label="Duration"
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: Number(e.target.value) })}
|
|
variant="outlined"
|
|
size="small"
|
|
InputProps={{
|
|
endAdornment: <InputAdornment position="end">min</InputAdornment>,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (key === 'feedingType') {
|
|
return (
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Feeding Type</InputLabel>
|
|
<Select
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: e.target.value })}
|
|
label="Feeding Type"
|
|
>
|
|
<MenuItem value="bottle">Bottle</MenuItem>
|
|
<MenuItem value="breast">Breast</MenuItem>
|
|
<MenuItem value="solids">Solids</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
if (key === 'diaperType') {
|
|
return (
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Diaper Type</InputLabel>
|
|
<Select
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: e.target.value })}
|
|
label="Diaper Type"
|
|
>
|
|
<MenuItem value="wet">Wet</MenuItem>
|
|
<MenuItem value="dirty">Dirty</MenuItem>
|
|
<MenuItem value="both">Both</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
if (key === 'quality') {
|
|
return (
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Sleep Quality</InputLabel>
|
|
<Select
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: e.target.value })}
|
|
label="Sleep Quality"
|
|
>
|
|
<MenuItem value="peaceful">Peaceful</MenuItem>
|
|
<MenuItem value="restless">Restless</MenuItem>
|
|
<MenuItem value="fussy">Fussy</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
if (key === 'location') {
|
|
return (
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Location</InputLabel>
|
|
<Select
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: e.target.value })}
|
|
label="Location"
|
|
>
|
|
<MenuItem value="crib">Crib</MenuItem>
|
|
<MenuItem value="bassinet">Bassinet</MenuItem>
|
|
<MenuItem value="arms">Arms</MenuItem>
|
|
<MenuItem value="bed">Bed</MenuItem>
|
|
<MenuItem value="stroller">Stroller</MenuItem>
|
|
<MenuItem value="car seat">Car Seat</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
if (key === 'side') {
|
|
return (
|
|
<FormControl fullWidth size="small">
|
|
<InputLabel>Side</InputLabel>
|
|
<Select
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: e.target.value })}
|
|
label="Side"
|
|
>
|
|
<MenuItem value="left">Left</MenuItem>
|
|
<MenuItem value="right">Right</MenuItem>
|
|
<MenuItem value="both">Both</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
// Default text input
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
label={key.charAt(0).toUpperCase() + key.slice(1)}
|
|
value={editedData[key] || ''}
|
|
onChange={(e) => setEditedData({ ...editedData, [key]: e.target.value })}
|
|
variant="outlined"
|
|
size="small"
|
|
/>
|
|
);
|
|
};
|
|
|
|
const getActivityLabel = () => {
|
|
return classification.type.charAt(0).toUpperCase() + classification.type.slice(1);
|
|
};
|
|
|
|
const getConfidenceColor = () => {
|
|
if (classification.confidence >= 0.7) return 'success';
|
|
if (classification.confidence >= 0.4) return 'warning';
|
|
return 'error';
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
<DialogTitle>
|
|
Review Voice Command
|
|
<Chip
|
|
label={`${getActivityLabel()}`}
|
|
color="primary"
|
|
size="small"
|
|
sx={{ ml: 2 }}
|
|
/>
|
|
<Chip
|
|
label={`${Math.round(classification.confidence * 100)}% confidence`}
|
|
color={getConfidenceColor()}
|
|
size="small"
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
</DialogTitle>
|
|
|
|
<DialogContent>
|
|
{/* Transcript */}
|
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
<strong>You said:</strong> "{transcript}"
|
|
</Typography>
|
|
</Alert>
|
|
|
|
{/* Extracted Data */}
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
{isEditing ? 'Edit Activity Details' : 'Extracted Details'}
|
|
</Typography>
|
|
|
|
<Box sx={{ mt: 2 }}>
|
|
{isEditing ? (
|
|
<Grid container spacing={2}>
|
|
{Object.entries(editedData).map(([key, value]) => (
|
|
<Grid item xs={12} sm={6} key={key}>
|
|
{renderFieldEditor(key, value)}
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
) : (
|
|
<Box>
|
|
{Object.entries(classification.details).map(([key, value]) => (
|
|
<Box key={key} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1')}:
|
|
</Typography>
|
|
<Typography variant="body2">
|
|
{value === null || value === undefined ? '-' : String(value)}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Help text */}
|
|
{!isEditing && (
|
|
<Alert severity="warning" sx={{ mt: 2 }}>
|
|
<Typography variant="caption">
|
|
Please review the extracted information. Click <strong>Edit</strong> to make changes or <strong>Approve</strong> if it looks correct.
|
|
</Typography>
|
|
</Alert>
|
|
)}
|
|
</DialogContent>
|
|
|
|
<DialogActions>
|
|
<Button onClick={onReject} startIcon={<CloseIcon />} color="error">
|
|
Reject
|
|
</Button>
|
|
|
|
{isEditing ? (
|
|
<>
|
|
<Button onClick={() => setIsEditing(false)} color="inherit">
|
|
Cancel Edit
|
|
</Button>
|
|
<Button onClick={handleSaveEdit} variant="contained" startIcon={<CheckCircleIcon />}>
|
|
Save & Approve
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button onClick={() => setIsEditing(true)} startIcon={<EditIcon />} color="inherit">
|
|
Edit
|
|
</Button>
|
|
<Button onClick={handleApprove} variant="contained" startIcon={<CheckCircleIcon />} color="success">
|
|
Approve
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
}
|