Files
SteelXR/src/components/InspectionChecklist.tsx
T
gpt-engineer-app[bot] 39943064a3 Changes
2026-02-27 01:04:27 +00:00

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