dstpabuaran.com/resources/js/components/camera-capture.tsx
Yoga Pangestu 67e5a6271f Add tests for CheckAttendancePenaltiesJob to validate attendance penalties logic
- Implement tests to ensure job skips execution on weekends, when penalties are zero, or when no payroll period exists.
- Validate late penalty creation for late check-ins and ensure no penalties for on-time or early check-ins.
- Test absent penalties for employees without attendance records.
- Ensure no duplicate penalties are created and that payroll recalculations are accurate after penalties are applied.
- Handle multiple employees and edge cases, including employees without associated users.
- Verify that the job can be dispatched to the queue and has the correct retry configuration.
2026-07-31 11:13:46 +07:00

124 lines
4.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Camera, RotateCcw, X } from 'lucide-react';
interface CameraCaptureProps {
onCapture: (dataUrl: string) => void;
onClose: () => void;
}
export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [stream, setStream] = useState<MediaStream | null>(null);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const startCamera = useCallback(async () => {
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: 640, height: 480 },
});
setStream(mediaStream);
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
}
setError(null);
} catch {
setError('Tidak dapat mengakses kamera. Pastikan izin kamera diberikan.');
}
}, []);
useEffect(() => {
startCamera();
return () => {
stream?.getTracks().forEach((track) => track.stop());
};
}, []);
const capture = () => {
if (!videoRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const video = videoRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(video, 0, 0);
ctx.setTransform(1, 0, 0, 1, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
setCapturedImage(dataUrl);
stream?.getTracks().forEach((track) => track.stop());
};
const retake = () => {
setCapturedImage(null);
startCamera();
};
const confirm = () => {
if (capturedImage) {
onCapture(capturedImage);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="relative w-full max-w-md rounded-lg bg-background p-4 shadow-lg">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Ambil Foto</h3>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{error ? (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-center text-sm text-muted-foreground">{error}</p>
<Button onClick={startCamera}>Coba Lagi</Button>
</div>
) : capturedImage ? (
<div className="flex flex-col gap-4">
<img src={capturedImage} alt="Captured" className="w-full rounded-lg" />
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={retake}>
<RotateCcw className="mr-2 h-4 w-4" />
Ulangi
</Button>
<Button className="flex-1" onClick={confirm}>
Gunakan Foto
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full"
style={{ transform: 'scaleX(-1)' }}
/>
</div>
<Button className="w-full" onClick={capture}>
<Camera className="mr-2 h-4 w-4" />
Ambil Foto
</Button>
</div>
)}
<canvas ref={canvasRef} className="hidden" />
</div>
</div>
);
}