238 lines
7.9 KiB
TypeScript
238 lines
7.9 KiB
TypeScript
import { CheckCircle, XCircle, Clock, MessageSquare, Mic, Square, Play, Trash2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { useModelStore, type ChecklistItem } from '@/stores/useModelStore';
|
|
import { useState, useRef, useCallback } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
function VoiceRecorder({ itemId, audioUrl }: { itemId: string; audioUrl: string | null }) {
|
|
const { setChecklistItemAudio } = useModelStore();
|
|
const [recording, setRecording] = useState(false);
|
|
const [playing, setPlaying] = useState(false);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const startRecording = useCallback(async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
|
mediaRecorderRef.current = mediaRecorder;
|
|
chunksRef.current = [];
|
|
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
};
|
|
|
|
mediaRecorder.onstop = () => {
|
|
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
|
const url = URL.createObjectURL(blob);
|
|
setChecklistItemAudio(itemId, url);
|
|
stream.getTracks().forEach(t => t.stop());
|
|
toast.success('Anotação de voz gravada');
|
|
};
|
|
|
|
mediaRecorder.start();
|
|
setRecording(true);
|
|
} catch {
|
|
toast.error('Não foi possível acessar o microfone');
|
|
}
|
|
}, [itemId, setChecklistItemAudio]);
|
|
|
|
const stopRecording = useCallback(() => {
|
|
mediaRecorderRef.current?.stop();
|
|
setRecording(false);
|
|
}, []);
|
|
|
|
const playAudio = useCallback(() => {
|
|
if (!audioUrl) return;
|
|
const audio = new Audio(audioUrl);
|
|
audioRef.current = audio;
|
|
setPlaying(true);
|
|
audio.onended = () => setPlaying(false);
|
|
audio.play();
|
|
}, [audioUrl]);
|
|
|
|
const deleteAudio = useCallback(() => {
|
|
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
|
setChecklistItemAudio(itemId, null);
|
|
setPlaying(false);
|
|
}, [itemId, audioUrl, setChecklistItemAudio]);
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
{!recording && !audioUrl && (
|
|
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-[10px]" onClick={startRecording}>
|
|
<Mic className="h-3 w-3 text-destructive" />
|
|
Gravar Voz
|
|
</Button>
|
|
)}
|
|
|
|
{recording && (
|
|
<Button variant="destructive" size="sm" className="h-7 gap-1.5 text-[10px] animate-pulse" onClick={stopRecording}>
|
|
<Square className="h-3 w-3" />
|
|
Parar
|
|
</Button>
|
|
)}
|
|
|
|
{audioUrl && !recording && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={playAudio}
|
|
disabled={playing}
|
|
title="Reproduzir"
|
|
>
|
|
<Play className="h-3 w-3 text-primary" />
|
|
</Button>
|
|
<span className="font-mono text-[10px] text-muted-foreground">
|
|
{playing ? '▶ Reproduzindo…' : '● Gravação salva'}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 ml-auto"
|
|
onClick={deleteAudio}
|
|
title="Excluir gravação"
|
|
>
|
|
<Trash2 className="h-3 w-3 text-destructive" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChecklistRow({ item }: { item: ChecklistItem }) {
|
|
const { setChecklistItemStatus, setChecklistItemNote } = useModelStore();
|
|
const [showNote, setShowNote] = useState(item.status === 'rejected');
|
|
|
|
const handleApprove = () => {
|
|
setChecklistItemStatus(item.id, 'approved');
|
|
setShowNote(false);
|
|
toast.success(`"${item.label}" aprovado`);
|
|
};
|
|
|
|
const handleReject = () => {
|
|
setChecklistItemStatus(item.id, 'rejected');
|
|
setShowNote(true);
|
|
toast.error(`"${item.label}" reprovado — adicione uma anotação`);
|
|
};
|
|
|
|
const statusColor =
|
|
item.status === 'approved' ? 'text-success' :
|
|
item.status === 'rejected' ? 'text-destructive' :
|
|
'text-muted-foreground';
|
|
|
|
const StatusIcon =
|
|
item.status === 'approved' ? CheckCircle :
|
|
item.status === 'rejected' ? XCircle :
|
|
Clock;
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<StatusIcon className={`h-4 w-4 shrink-0 ${statusColor}`} />
|
|
<span className="font-mono text-xs text-foreground truncate">{item.label}</span>
|
|
</div>
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button
|
|
variant={item.status === 'approved' ? 'default' : 'ghost'}
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleApprove}
|
|
title="Aprovar"
|
|
>
|
|
<CheckCircle className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant={item.status === 'rejected' ? 'destructive' : 'ghost'}
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={handleReject}
|
|
title="Reprovar"
|
|
>
|
|
<XCircle className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{(showNote || item.status === 'rejected') && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-1">
|
|
<MessageSquare className="h-3 w-3 text-muted-foreground" />
|
|
<span className="font-mono text-[10px] text-muted-foreground">Anotação</span>
|
|
</div>
|
|
<Textarea
|
|
className="h-16 font-mono text-xs resize-none bg-background"
|
|
placeholder="Descreva o problema encontrado…"
|
|
value={item.note}
|
|
onChange={(e) => setChecklistItemNote(item.id, e.target.value)}
|
|
/>
|
|
<VoiceRecorder itemId={item.id} audioUrl={item.audioUrl} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function InspectionChecklist() {
|
|
const { checklist, resetChecklist } = useModelStore();
|
|
|
|
const approved = checklist.filter(i => i.status === 'approved').length;
|
|
const rejected = checklist.filter(i => i.status === 'rejected').length;
|
|
const total = checklist.length;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
|
Checklist de Inspeção
|
|
</h3>
|
|
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={resetChecklist}>
|
|
Resetar
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Progress summary */}
|
|
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
|
|
<div className="flex-1">
|
|
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-all"
|
|
style={{ width: `${((approved + rejected) / total) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="font-mono text-[10px] text-muted-foreground shrink-0">
|
|
{approved + rejected}/{total}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Status badges */}
|
|
<div className="flex gap-2">
|
|
{approved > 0 && (
|
|
<span className="font-mono text-[10px] text-success flex items-center gap-1">
|
|
<CheckCircle className="h-3 w-3" /> {approved}
|
|
</span>
|
|
)}
|
|
{rejected > 0 && (
|
|
<span className="font-mono text-[10px] text-destructive flex items-center gap-1">
|
|
<XCircle className="h-3 w-3" /> {rejected}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Items */}
|
|
<div className="space-y-2">
|
|
{checklist.map(item => (
|
|
<ChecklistRow key={item.id} item={item} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|