엑셀 다운로드, 업로드, 사진촬영(바코드 스캔기능) 추가

This commit is contained in:
leeheejin
2025-11-04 09:41:58 +09:00
parent 94e5a5de0b
commit 7425c37094
12 changed files with 2342 additions and 702 deletions

View File

@@ -0,0 +1,371 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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;
}
export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
open,
onOpenChange,
targetField,
barcodeFormat = "all",
autoSubmit = false,
onScanSuccess,
}) => {
const [isScanning, setIsScanning] = useState(false);
const [scannedCode, setScannedCode] = 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);
// 바코드 리더 초기화
useEffect(() => {
if (open) {
codeReaderRef.current = new BrowserMultiFormatReader();
// 자동 권한 요청 제거 - 사용자가 버튼을 클릭해야 권한 요청
}
return () => {
stopScanning();
if (codeReaderRef.current) {
codeReaderRef.current.reset();
}
};
}, [open]);
// 카메라 권한 요청
const requestCameraPermission = async () => {
console.log("🎥 카메라 권한 요청 시작...");
// navigator.mediaDevices 지원 확인
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error("❌ navigator.mediaDevices를 사용할 수 없습니다.");
console.log("현재 프로토콜:", window.location.protocol);
console.log("현재 호스트:", window.location.host);
setHasPermission(false);
setError(
"이 브라우저는 카메라 접근을 지원하지 않거나, 보안 컨텍스트(HTTPS 또는 localhost)가 아닙니다. " +
"현재 프로토콜: " + window.location.protocol
);
toast.error("카메라 접근이 불가능합니다. 콘솔을 확인해주세요.");
return;
}
try {
console.log("🔄 getUserMedia 호출 중...");
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
console.log("✅ 카메라 권한 허용됨!");
setHasPermission(true);
stream.getTracks().forEach((track) => track.stop()); // 권한 확인 후 스트림 종료
toast.success("카메라 권한이 허용되었습니다.");
} catch (err: any) {
console.error("❌ 카메라 권한 오류:", err);
console.error("에러 이름:", err.name);
console.error("에러 메시지:", err.message);
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("");
// 주기적으로 스캔 시도 (500ms마다)
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;
// 이미지를 HTMLImageElement로 변환
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();
console.log("✅ 바코드 스캔 성공:", barcode);
setScannedCode(barcode);
stopScanning();
toast.success(`바코드 스캔 완료: ${barcode}`);
// 자동 제출 옵션이 켜져있으면 바로 콜백 실행
if (autoSubmit) {
onScanSuccess(barcode);
}
}
} catch (err) {
// NotFoundException은 정상적인 상황 (바코드가 아직 인식되지 않음)
if (!(err instanceof NotFoundException)) {
console.error("바코드 스캔 오류:", err);
}
}
};
// 수동 확인 버튼
const handleConfirm = () => {
if (scannedCode) {
onScanSuccess(scannedCode);
} 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> <strong>🔒 </strong> </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: "environment", // 후면 카메라 우선
}}
className="h-full w-full object-cover"
/>
{/* 스캔 가이드 오버레이 */}
{isScanning && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-32 w-32 border-4 border-primary animate-pulse rounded-lg" />
<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/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 && !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>
);
};

View File

@@ -0,0 +1,349 @@
"use client";
import React, { useState, useRef } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2 } from "lucide-react";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
export interface ExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tableName: string;
uploadMode?: "insert" | "update" | "upsert";
keyColumn?: string;
onSuccess?: () => void;
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open,
onOpenChange,
tableName,
uploadMode = "insert",
keyColumn,
onSuccess,
}) => {
const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);
const [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
// 파일 선택 핸들러
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
// 파일 확장자 검증
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
return;
}
setFile(selectedFile);
try {
// 시트 목록 가져오기
const sheets = await getExcelSheetNames(selectedFile);
setSheetNames(sheets);
setSelectedSheet(sheets[0] || "");
// 미리보기 데이터 로드 (첫 5행)
const data = await importFromExcel(selectedFile, sheets[0]);
setPreviewData(data.slice(0, 5));
toast.success(`파일이 선택되었습니다: ${selectedFile.name}`);
} catch (error) {
console.error("파일 읽기 오류:", error);
toast.error("파일을 읽는 중 오류가 발생했습니다.");
setFile(null);
}
};
// 시트 변경 핸들러
const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
if (!file) return;
try {
const data = await importFromExcel(file, sheetName);
setPreviewData(data.slice(0, 5));
} catch (error) {
console.error("시트 읽기 오류:", error);
toast.error("시트를 읽는 중 오류가 발생했습니다.");
}
};
// 업로드 핸들러
const handleUpload = async () => {
if (!file) {
toast.error("파일을 선택해주세요.");
return;
}
if (!tableName) {
toast.error("테이블명이 지정되지 않았습니다.");
return;
}
setIsUploading(true);
try {
// 엑셀 데이터 읽기
const data = await importFromExcel(file, selectedSheet);
console.log("📤 엑셀 업로드 시작:", {
tableName,
uploadMode,
rowCount: data.length,
});
// 업로드 모드에 따라 처리
let successCount = 0;
let failCount = 0;
for (const row of data) {
try {
if (uploadMode === "insert") {
// 삽입 모드
const formData = { screenId: 0, tableName, data: row };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
console.error("저장 실패:", result.message, row);
failCount++;
}
} else if (uploadMode === "update" && keyColumn) {
// 업데이트 모드
const keyValue = row[keyColumn];
if (keyValue) {
await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row);
successCount++;
} else {
failCount++;
}
} else if (uploadMode === "upsert" && keyColumn) {
// Upsert 모드 (있으면 업데이트, 없으면 삽입)
const keyValue = row[keyColumn];
if (keyValue) {
try {
const updateResult = await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row);
if (!updateResult.success) {
// 업데이트 실패 시 삽입 시도
const formData = { screenId: 0, tableName, data: row };
const insertResult = await DynamicFormApi.saveFormData(formData);
if (insertResult.success) {
successCount++;
} else {
console.error("Upsert 실패:", insertResult.message, row);
failCount++;
}
} else {
successCount++;
}
} catch {
const formData = { screenId: 0, tableName, data: row };
const insertResult = await DynamicFormApi.saveFormData(formData);
if (insertResult.success) {
successCount++;
} else {
console.error("Upsert 실패:", insertResult.message, row);
failCount++;
}
}
} else {
const formData = { screenId: 0, tableName, data: row };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
console.error("저장 실패:", result.message, row);
failCount++;
}
}
}
} catch (error) {
console.error("행 처리 오류:", row, error);
failCount++;
}
}
console.log("✅ 엑셀 업로드 완료:", {
successCount,
failCount,
totalCount: data.length,
});
if (successCount > 0) {
toast.success(`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`);
// onSuccess 내부에서 closeModal이 호출되므로 여기서는 호출하지 않음
onSuccess?.();
// onOpenChange(false); // 제거: onSuccess에서 이미 모달을 닫음
} else {
toast.error("업로드에 실패했습니다.");
}
} catch (error) {
console.error("❌ 엑셀 업로드 실패:", error);
toast.error("엑셀 업로드 중 오류가 발생했습니다.");
} finally {
setIsUploading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-[900px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 파일 선택 */}
<div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<div className="mt-2 flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
>
<Upload className="mr-2 h-4 w-4" />
{file ? file.name : "파일 선택"}
</Button>
<input
ref={fileInputRef}
id="file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
형식: .xlsx, .xls, .csv
</p>
</div>
{/* 시트 선택 */}
{sheetNames.length > 0 && (
<div>
<Label htmlFor="sheet-select" className="text-xs sm:text-sm">
</Label>
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시트를 선택하세요" />
</SelectTrigger>
<SelectContent>
{sheetNames.map((sheetName) => (
<SelectItem key={sheetName} value={sheetName} className="text-xs sm:text-sm">
<FileSpreadsheet className="mr-2 inline h-4 w-4" />
{sheetName}
</SelectItem>
))}
</SelectContent>
</Select>
</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"> : {uploadMode === "insert" ? "삽입" : uploadMode === "update" ? "업데이트" : "Upsert"}</p>
<p className="mt-1">
{uploadMode === "insert" && "새로운 데이터로 삽입됩니다."}
{uploadMode === "update" && `기존 데이터를 업데이트합니다. (키: ${keyColumn})`}
{uploadMode === "upsert" && `있으면 업데이트, 없으면 삽입합니다. (키: ${keyColumn})`}
</p>
</div>
</div>
</div>
{/* 미리보기 */}
{previewData.length > 0 && (
<div>
<Label className="text-xs sm:text-sm"> ( 5)</Label>
<div className="mt-2 max-h-[300px] overflow-auto rounded-md border border-border">
<table className="min-w-full text-[10px] sm:text-xs">
<thead className="sticky top-0 bg-muted">
<tr>
{Object.keys(previewData[0]).map((key) => (
<th key={key} className="whitespace-nowrap border-b border-border px-2 py-1 text-left font-medium">
{key}
</th>
))}
</tr>
</thead>
<tbody>
{previewData.map((row, index) => (
<tr key={index} className="border-b border-border last:border-0">
{Object.values(row).map((value, i) => (
<td key={i} className="max-w-[150px] truncate whitespace-nowrap px-2 py-1" title={String(value)}>
{String(value)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground sm:text-xs">
<CheckCircle2 className="h-3 w-3 text-success" />
<span> {previewData.length} ()</span>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleUpload}
disabled={!file || isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? "업로드 중..." : "업로드"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};