- 바코드 모달: 카메라 자동 실행 제거 (버튼 클릭 시 실행) - 바코드 모달: 수동 입력 필드 추가 (외장 스캐너/직접 입력) - 바코드 모달: facingMode fallback (후면→전면 카메라) - usePopSettings: pop_settings 테이블 없을 때 400 에러 무시 - RecentActivity: key 중복 에러 수정 (인덱스 추가)
421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useEffect } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { toast } from "sonner";
|
|
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
|
|
import Webcam from "react-webcam";
|
|
import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
|
|
|
export interface BarcodeScanModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
targetField?: string;
|
|
barcodeFormat?: "all" | "1d" | "2d";
|
|
autoSubmit?: boolean;
|
|
onScanSuccess: (barcode: string) => void;
|
|
userId?: string;
|
|
}
|
|
|
|
export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
|
open,
|
|
onOpenChange,
|
|
targetField,
|
|
barcodeFormat = "all",
|
|
autoSubmit = false,
|
|
onScanSuccess,
|
|
userId = "guest",
|
|
}) => {
|
|
const [isScanning, setIsScanning] = useState(false);
|
|
const [scannedCode, setScannedCode] = useState<string>("");
|
|
const [manualInput, setManualInput] = useState<string>("");
|
|
const [error, setError] = useState<string>("");
|
|
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
|
const webcamRef = useRef<Webcam>(null);
|
|
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
|
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
const manualInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
|
useEffect(() => {
|
|
if (open) {
|
|
setScannedCode("");
|
|
setManualInput("");
|
|
setError("");
|
|
setIsScanning(false);
|
|
setHasPermission(null);
|
|
codeReaderRef.current = new BrowserMultiFormatReader();
|
|
}
|
|
|
|
return () => {
|
|
stopScanning();
|
|
if (codeReaderRef.current) {
|
|
codeReaderRef.current.reset();
|
|
}
|
|
};
|
|
}, [open]);
|
|
|
|
// 카메라 권한 요청
|
|
const requestCameraPermission = async () => {
|
|
// navigator.mediaDevices 지원 확인
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
setHasPermission(false);
|
|
setError(
|
|
"이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " +
|
|
"현재 프로토콜: " + window.location.protocol
|
|
);
|
|
toast.error("카메라 접근이 불가능합니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 후면 카메라 먼저 시도, 실패하면 전면 카메라 fallback
|
|
let stream: MediaStream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
|
|
} catch {
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
}
|
|
setHasPermission(true);
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
} catch (err: any) {
|
|
setHasPermission(false);
|
|
|
|
if (err.name === "NotAllowedError") {
|
|
setError("카메라 접근이 거부되었습니다. 브라우저 설정에서 카메라 권한을 허용해주세요.");
|
|
toast.error("카메라 권한이 거부되었습니다.");
|
|
} else if (err.name === "NotFoundError") {
|
|
setError("카메라를 찾을 수 없습니다. 카메라가 연결되어 있는지 확인해주세요.");
|
|
toast.error("카메라를 찾을 수 없습니다.");
|
|
} else if (err.name === "NotReadableError") {
|
|
setError("카메라가 이미 다른 애플리케이션에서 사용 중입니다.");
|
|
toast.error("카메라가 사용 중입니다.");
|
|
} else if (err.name === "NotSupportedError") {
|
|
setError("보안 컨텍스트(HTTPS 또는 localhost)가 아니어서 카메라를 사용할 수 없습니다.");
|
|
toast.error("HTTPS 환경이 필요합니다.");
|
|
} else {
|
|
setError(`카메라 접근 오류: ${err.name} - ${err.message}`);
|
|
toast.error("카메라 접근 중 오류가 발생했습니다.");
|
|
}
|
|
}
|
|
};
|
|
|
|
// 스캔 시작
|
|
const startScanning = () => {
|
|
setIsScanning(true);
|
|
setError("");
|
|
setScannedCode("");
|
|
|
|
scanIntervalRef.current = setInterval(() => {
|
|
scanBarcode();
|
|
}, 500);
|
|
};
|
|
|
|
// 스캔 중지
|
|
const stopScanning = () => {
|
|
setIsScanning(false);
|
|
if (scanIntervalRef.current) {
|
|
clearInterval(scanIntervalRef.current);
|
|
scanIntervalRef.current = null;
|
|
}
|
|
};
|
|
|
|
// 바코드 스캔
|
|
const scanBarcode = async () => {
|
|
if (!webcamRef.current || !codeReaderRef.current) return;
|
|
|
|
try {
|
|
const imageSrc = webcamRef.current.getScreenshot();
|
|
if (!imageSrc) return;
|
|
|
|
const img = new Image();
|
|
img.src = imageSrc;
|
|
|
|
await new Promise((resolve) => {
|
|
img.onload = resolve;
|
|
});
|
|
|
|
const result = await codeReaderRef.current.decodeFromImageElement(img);
|
|
|
|
if (result) {
|
|
const barcode = result.getText();
|
|
|
|
setScannedCode(barcode);
|
|
stopScanning();
|
|
toast.success(`바코드 스캔 완료: ${barcode}`);
|
|
|
|
if (autoSubmit) {
|
|
onScanSuccess(barcode);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!(err instanceof NotFoundException)) {
|
|
// NotFoundException은 정상 (바코드 미인식)
|
|
}
|
|
}
|
|
};
|
|
|
|
// 수동 확인 버튼 (스캔 결과 또는 직접 입력)
|
|
const handleConfirm = () => {
|
|
const code = scannedCode || manualInput.trim();
|
|
if (code) {
|
|
onScanSuccess(code); // 호출 측에서 검색 필드를 덮어쓰기
|
|
onOpenChange(false);
|
|
} else {
|
|
toast.error("바코드를 스캔하거나 직접 입력해주세요.");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
카메라로 바코드를 스캔합니다.
|
|
{targetField && ` (대상 필드: ${targetField})`}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* 카메라 권한 요청 대기 중 */}
|
|
{hasPermission === null && (
|
|
<div className="rounded-md border border-primary bg-primary/10 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Camera className="mt-0.5 h-5 w-5 flex-shrink-0 text-primary" />
|
|
<div className="flex-1 space-y-3 text-xs sm:text-sm">
|
|
<div>
|
|
<p className="font-semibold text-primary">카메라 권한이 필요합니다</p>
|
|
<p className="mt-1 text-muted-foreground">
|
|
바코드를 스캔하려면 카메라 접근 권한을 허용해주세요.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-md bg-background/50 p-3">
|
|
<p className="mb-2 font-medium text-foreground">권한 요청 안내:</p>
|
|
<ul className="ml-4 list-disc space-y-1 text-muted-foreground">
|
|
<li>아래 버튼을 클릭하면 브라우저에서 권한 요청 팝업이 표시됩니다</li>
|
|
<li>팝업에서 <strong>"허용"</strong> 버튼을 클릭해주세요</li>
|
|
<li>권한은 언제든지 브라우저 설정에서 변경할 수 있습니다</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={requestCameraPermission}
|
|
className="h-9 text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
<Camera className="mr-2 h-4 w-4" />
|
|
카메라 권한 요청
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 카메라 권한 거부됨 */}
|
|
{hasPermission === false && (
|
|
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
|
|
<div className="flex-1 space-y-3 text-xs sm:text-sm">
|
|
<div>
|
|
<p className="font-semibold text-destructive">카메라 접근 권한이 필요합니다</p>
|
|
<p className="mt-1 text-destructive/80">{error}</p>
|
|
</div>
|
|
|
|
<div className="rounded-md bg-background/50 p-3">
|
|
<p className="mb-2 font-medium text-foreground">권한 허용 방법:</p>
|
|
<ol className="ml-4 list-decimal space-y-1 text-muted-foreground">
|
|
<li>브라우저 주소창 왼쪽의 자물쇠 아이콘을 클릭하세요</li>
|
|
<li><strong>"카메라"</strong> 항목을 찾아 <strong>"허용"</strong>으로 변경하세요</li>
|
|
<li>페이지를 새로고침하거나 다시 스캔을 시도하세요</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={requestCameraPermission}
|
|
className="h-8 text-xs"
|
|
>
|
|
다시 시도
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 웹캠 뷰 */}
|
|
{hasPermission && (
|
|
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
|
<Webcam
|
|
ref={webcamRef}
|
|
audio={false}
|
|
screenshotFormat="image/jpeg"
|
|
videoConstraints={{
|
|
facingMode: { ideal: "environment" },
|
|
}}
|
|
onUserMediaError={() => {
|
|
// environment 카메라 실패 시 자동 fallback (Webcam 내부 처리)
|
|
}}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
|
|
{/* 스캔 가이드 오버레이 */}
|
|
{isScanning && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="h-3/5 w-4/5 rounded-lg border-4 border-primary/70 animate-pulse" />
|
|
<div className="absolute bottom-4 left-0 right-0 text-center">
|
|
<div className="inline-flex items-center gap-2 rounded-full bg-background/80 px-4 py-2 text-xs font-medium">
|
|
<Scan className="h-4 w-4 animate-pulse text-primary" />
|
|
스캔 중...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 스캔 완료 오버레이 */}
|
|
{scannedCode && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
|
<div className="text-center">
|
|
<CheckCircle2 className="mx-auto h-16 w-16 text-success" />
|
|
<p className="mt-2 text-sm font-medium">스캔 완료!</p>
|
|
<p className="mt-1 font-mono text-lg font-bold text-primary">{scannedCode}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 수동 입력 (카메라 사용 불가 시 또는 외장 스캐너 사용 시) */}
|
|
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">직접 입력 또는 외장 스캐너</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
ref={manualInputRef}
|
|
type="text"
|
|
value={manualInput}
|
|
onChange={(e) => setManualInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && manualInput.trim()) {
|
|
e.preventDefault();
|
|
onScanSuccess(manualInput.trim());
|
|
onOpenChange(false);
|
|
}
|
|
}}
|
|
placeholder="바코드/QR 번호 입력 후 Enter"
|
|
className="flex-1 h-11 rounded-lg border border-border px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
autoFocus={hasPermission === false}
|
|
/>
|
|
<Button
|
|
onClick={() => {
|
|
if (manualInput.trim()) {
|
|
onScanSuccess(manualInput.trim());
|
|
onOpenChange(false);
|
|
}
|
|
}}
|
|
disabled={!manualInput.trim()}
|
|
className="h-11 px-4"
|
|
>
|
|
확인
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 바코드 포맷 정보 */}
|
|
<div className="rounded-md border border-border bg-muted/50 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
|
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
|
<p className="font-medium">지원 포맷</p>
|
|
<p className="mt-1">
|
|
{barcodeFormat === "all" && "1D/2D 바코드 모두 지원 (Code 128, QR Code 등)"}
|
|
{barcodeFormat === "1d" && "1D 바코드 (Code 128, Code 39, EAN-13, UPC-A)"}
|
|
{barcodeFormat === "2d" && "2D 바코드 (QR Code, Data Matrix)"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 에러 메시지 */}
|
|
{error && (
|
|
<div className="rounded-md border border-destructive bg-destructive/10 p-3">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
|
|
<p className="text-xs text-destructive">{error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
|
|
{!isScanning && !scannedCode && hasPermission && (
|
|
<Button
|
|
onClick={startScanning}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<Camera className="mr-2 h-4 w-4" />
|
|
스캔 시작
|
|
</Button>
|
|
)}
|
|
|
|
{isScanning && (
|
|
<Button
|
|
variant="destructive"
|
|
onClick={stopScanning}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<CameraOff className="mr-2 h-4 w-4" />
|
|
스캔 중지
|
|
</Button>
|
|
)}
|
|
|
|
{scannedCode && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setScannedCode("");
|
|
startScanning();
|
|
}}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<Camera className="mr-2 h-4 w-4" />
|
|
다시 스캔
|
|
</Button>
|
|
)}
|
|
|
|
{scannedCode && !autoSubmit && (
|
|
<Button
|
|
onClick={handleConfirm}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
확인
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|