Files
maternal-app/maternal-web/components/voice/VoiceActivityReview.tsx
Andrei e94a1018c4
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Add voice command review/edit system with user feedback tracking
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>
2025-10-02 11:03:54 +00:00

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>
);
}