엑셀 다운로드, 업로드, 사진촬영(바코드 스캔기능) 추가
This commit is contained in:
parent
94e5a5de0b
commit
7425c37094
|
|
@ -1502,6 +1502,9 @@ export class TableManagementService {
|
|||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
logger.info(`🔍 실행할 SQL: ${dataQuery}`);
|
||||
logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`);
|
||||
|
||||
let data = await query<any>(dataQuery, [...searchValues, size, offset]);
|
||||
|
||||
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -142,15 +142,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
};
|
||||
|
||||
// 폼 데이터 변경 핸들러
|
||||
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||
// console.log(`🎯 InteractiveScreenViewerDynamic handleFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
// console.log(`📋 onFormDataChange 존재 여부:`, !!onFormDataChange);
|
||||
|
||||
const handleFormDataChange = (fieldName: string | any, value?: any) => {
|
||||
// 일반 필드 변경
|
||||
if (onFormDataChange) {
|
||||
// console.log(`📤 InteractiveScreenViewerDynamic -> onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
onFormDataChange(fieldName, value);
|
||||
} else {
|
||||
// console.log(`💾 InteractiveScreenViewerDynamic 로컬 상태 업데이트: ${fieldName} = "${value}"`);
|
||||
setLocalFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -267,6 +267,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -709,6 +712,132 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 엑셀 다운로드 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "excel_download" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">엑셀 다운로드 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="excel-filename">파일명 (선택사항)</Label>
|
||||
<Input
|
||||
id="excel-filename"
|
||||
placeholder="예: 데이터목록 (기본값: export)"
|
||||
value={config.action?.excelFileName || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.excelFileName", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">확장자(.xlsx)는 자동으로 추가됩니다</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="excel-sheetname">시트명 (선택사항)</Label>
|
||||
<Input
|
||||
id="excel-sheetname"
|
||||
placeholder="예: Sheet1 (기본값)"
|
||||
value={config.action?.excelSheetName || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.excelSheetName", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="excel-include-headers">헤더 포함</Label>
|
||||
<Switch
|
||||
id="excel-include-headers"
|
||||
checked={config.action?.excelIncludeHeaders !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.excelIncludeHeaders", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 엑셀 업로드 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "excel_upload" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📤 엑셀 업로드 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="excel-upload-mode">업로드 모드</Label>
|
||||
<Select
|
||||
value={config.action?.excelUploadMode || "insert"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.excelUploadMode", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="insert">신규 삽입 (INSERT)</SelectItem>
|
||||
<SelectItem value="update">기존 수정 (UPDATE)</SelectItem>
|
||||
<SelectItem value="upsert">삽입/수정 (UPSERT)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(config.action?.excelUploadMode === "update" || config.action?.excelUploadMode === "upsert") && (
|
||||
<div>
|
||||
<Label htmlFor="excel-key-column">
|
||||
키 컬럼명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="excel-key-column"
|
||||
placeholder="예: id, code"
|
||||
value={config.action?.excelKeyColumn || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.excelKeyColumn", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">UPDATE/UPSERT 시 기준이 되는 컬럼명</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 바코드 스캔 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "barcode_scan" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📷 바코드 스캔 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="barcode-target-field">
|
||||
대상 필드명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="barcode-target-field"
|
||||
placeholder="예: barcode, qr_code"
|
||||
value={config.action?.barcodeTargetField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.barcodeTargetField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">스캔 결과가 입력될 폼 필드명</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="barcode-format">바코드 형식</Label>
|
||||
<Select
|
||||
value={config.action?.barcodeFormat || "all"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.barcodeFormat", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">모든 형식</SelectItem>
|
||||
<SelectItem value="1d">1D 바코드만 (CODE128, EAN13 등)</SelectItem>
|
||||
<SelectItem value="2d">2D 바코드만 (QR코드, DataMatrix 등)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="barcode-auto-submit">스캔 후 자동 저장</Label>
|
||||
<Switch
|
||||
id="barcode-auto-submit"
|
||||
checked={config.action?.barcodeAutoSubmit === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.barcodeAutoSubmit", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제어 기능 섹션 */}
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
|
|
|
|||
|
|
@ -410,6 +410,128 @@ export class DynamicFormApi {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
* @param tableName 테이블명
|
||||
* @param params 검색 파라미터
|
||||
* @returns 테이블 데이터
|
||||
*/
|
||||
static async getTableData(
|
||||
tableName: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
filters?: Record<string, any>;
|
||||
},
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("📊 테이블 데이터 조회 요청:", { tableName, params });
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {});
|
||||
|
||||
console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data);
|
||||
console.log("🔍 response.data 상세:", {
|
||||
type: typeof response.data,
|
||||
isArray: Array.isArray(response.data),
|
||||
keys: response.data ? Object.keys(response.data) : [],
|
||||
hasData: response.data?.data !== undefined,
|
||||
dataType: response.data?.data ? typeof response.data.data : "N/A",
|
||||
dataIsArray: response.data?.data ? Array.isArray(response.data.data) : false,
|
||||
dataLength: response.data?.data ? (Array.isArray(response.data.data) ? response.data.data.length : "not array") : "no data",
|
||||
// 중첩 구조 확인
|
||||
dataDataExists: response.data?.data?.data !== undefined,
|
||||
dataDataIsArray: response.data?.data?.data ? Array.isArray(response.data.data.data) : false,
|
||||
dataDataLength: response.data?.data?.data ? (Array.isArray(response.data.data.data) ? response.data.data.data.length : "not array") : "no nested data",
|
||||
});
|
||||
|
||||
// API 응답 구조: { data: [...], total, page, size, totalPages }
|
||||
// 또는 중첩: { success: true, data: { data: [...], total, ... } }
|
||||
// data 배열만 추출
|
||||
let tableData: any[] = [];
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
// 케이스 1: 응답이 배열이면 그대로 사용
|
||||
console.log("✅ 케이스 1: 응답이 배열");
|
||||
tableData = response.data;
|
||||
} else if (response.data && Array.isArray(response.data.data)) {
|
||||
// 케이스 2: 응답이 { data: [...] } 구조면 data 배열 추출
|
||||
console.log("✅ 케이스 2: 응답이 { data: [...] } 구조");
|
||||
tableData = response.data.data;
|
||||
} else if (response.data?.data?.data && Array.isArray(response.data.data.data)) {
|
||||
// 케이스 2-1: 중첩 구조 { success: true, data: { data: [...] } }
|
||||
console.log("✅ 케이스 2-1: 중첩 구조 { data: { data: [...] } }");
|
||||
tableData = response.data.data.data;
|
||||
} else if (response.data && typeof response.data === "object") {
|
||||
// 케이스 3: 응답이 객체면 배열로 감싸기 (최후의 수단)
|
||||
console.log("⚠️ 케이스 3: 응답이 객체 (배열로 감싸기)");
|
||||
tableData = [response.data];
|
||||
}
|
||||
|
||||
console.log("✅ 테이블 데이터 추출 완료:", {
|
||||
originalType: typeof response.data,
|
||||
isArray: Array.isArray(response.data),
|
||||
hasDataProperty: response.data?.data !== undefined,
|
||||
extractedCount: tableData.length,
|
||||
firstRow: tableData[0],
|
||||
allRows: tableData,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tableData,
|
||||
message: "테이블 데이터 조회가 완료되었습니다.",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
|
||||
const errorMessage = error.response?.data?.message || error.message || "테이블 데이터 조회 중 오류가 발생했습니다.";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 (대량 데이터 삽입/업데이트)
|
||||
* @param payload 업로드 데이터
|
||||
* @returns 업로드 결과
|
||||
*/
|
||||
static async uploadExcelData(payload: {
|
||||
tableName: string;
|
||||
data: any[];
|
||||
uploadMode: "insert" | "update" | "upsert";
|
||||
keyColumn?: string;
|
||||
}): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
console.log("📤 엑셀 업로드 요청:", payload);
|
||||
|
||||
const response = await apiClient.post(`/dynamic-form/excel-upload`, payload);
|
||||
|
||||
console.log("✅ 엑셀 업로드 성공:", response.data);
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
message: "엑셀 파일이 성공적으로 업로드되었습니다.",
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("❌ 엑셀 업로드 실패:", error);
|
||||
|
||||
const errorMessage = error.response?.data?.message || error.message || "엑셀 업로드 중 오류가 발생했습니다.";
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 편의를 위한 기본 export
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 추가 안전장치: 모든 로딩 토스트 제거
|
||||
toast.dismiss();
|
||||
|
||||
// UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시
|
||||
const silentActions = ["edit", "modal", "navigate"];
|
||||
// UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음
|
||||
const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
if (!silentActions.includes(actionConfig.type)) {
|
||||
currentLoadingToastRef.current = toast.loading(
|
||||
actionConfig.type === "save"
|
||||
|
|
@ -274,9 +274,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 실패한 경우 오류 처리
|
||||
if (!success) {
|
||||
// UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리
|
||||
const silentActions = ["edit", "modal", "navigate"];
|
||||
if (silentActions.includes(actionConfig.type)) {
|
||||
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
|
||||
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
if (silentErrorActions.includes(actionConfig.type)) {
|
||||
return;
|
||||
}
|
||||
// 기본 에러 메시지 결정
|
||||
|
|
@ -302,8 +302,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
|
||||
// 성공한 경우에만 성공 토스트 표시
|
||||
// edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요)
|
||||
if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") {
|
||||
// edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리
|
||||
// (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시)
|
||||
const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"];
|
||||
if (!silentSuccessActions.includes(actionConfig.type)) {
|
||||
// 기본 성공 메시지 결정
|
||||
const defaultSuccessMessage =
|
||||
actionConfig.type === "save"
|
||||
|
|
|
|||
|
|
@ -531,7 +531,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData });
|
||||
onFormDataChange({
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
selectedRowsData,
|
||||
});
|
||||
}
|
||||
|
||||
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
|
|
@ -549,7 +552,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onSelectedRowsChange(Array.from(newSelectedRows), data);
|
||||
}
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data });
|
||||
onFormDataChange({
|
||||
selectedRows: Array.from(newSelectedRows),
|
||||
selectedRowsData: data,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ export type ButtonActionType =
|
|||
| "navigate" // 페이지 이동
|
||||
| "modal" // 모달 열기
|
||||
| "control" // 제어 흐름
|
||||
| "view_table_history"; // 테이블 이력 보기
|
||||
| "view_table_history" // 테이블 이력 보기
|
||||
| "excel_download" // 엑셀 다운로드
|
||||
| "excel_upload" // 엑셀 업로드
|
||||
| "barcode_scan"; // 바코드 스캔
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
|
|
@ -56,6 +59,20 @@ export interface ButtonActionConfig {
|
|||
historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스
|
||||
historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항)
|
||||
historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
|
||||
|
||||
// 엑셀 다운로드 관련
|
||||
excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx)
|
||||
excelSheetName?: string; // 시트명 (기본: "Sheet1")
|
||||
excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true)
|
||||
|
||||
// 엑셀 업로드 관련
|
||||
excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드
|
||||
excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼
|
||||
|
||||
// 바코드 스캔 관련
|
||||
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
|
||||
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
|
||||
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -121,6 +138,15 @@ export class ButtonActionExecutor {
|
|||
case "view_table_history":
|
||||
return this.handleViewTableHistory(config, context);
|
||||
|
||||
case "excel_download":
|
||||
return await this.handleExcelDownload(config, context);
|
||||
|
||||
case "excel_upload":
|
||||
return await this.handleExcelUpload(config, context);
|
||||
|
||||
case "barcode_scan":
|
||||
return await this.handleBarcodeScan(config, context);
|
||||
|
||||
default:
|
||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||
return false;
|
||||
|
|
@ -1646,6 +1672,226 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드 액션 처리
|
||||
*/
|
||||
private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📥 엑셀 다운로드 시작:", { config, context });
|
||||
|
||||
// 동적 import로 엑셀 유틸리티 로드
|
||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||
|
||||
let dataToExport: any[] = [];
|
||||
|
||||
// 1순위: 선택된 행 데이터
|
||||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||
dataToExport = context.selectedRowsData;
|
||||
console.log("✅ 선택된 행 데이터 사용:", dataToExport.length);
|
||||
}
|
||||
// 2순위: 테이블 전체 데이터 (API 호출)
|
||||
else if (context.tableName) {
|
||||
console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName);
|
||||
try {
|
||||
const { dynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const response = await dynamicFormApi.getTableData(context.tableName, {
|
||||
page: 1,
|
||||
pageSize: 10000, // 최대 10,000개 행
|
||||
sortBy: "id", // 기본 정렬: id 컬럼
|
||||
sortOrder: "asc", // 오름차순
|
||||
});
|
||||
|
||||
console.log("📦 API 응답 구조:", {
|
||||
response,
|
||||
responseSuccess: response.success,
|
||||
responseData: response.data,
|
||||
responseDataType: typeof response.data,
|
||||
responseDataIsArray: Array.isArray(response.data),
|
||||
responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A",
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
dataToExport = response.data;
|
||||
console.log("✅ 테이블 전체 데이터 조회 완료:", {
|
||||
count: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
});
|
||||
} else {
|
||||
console.error("❌ API 응답에 데이터가 없습니다:", response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
// 4순위: 폼 데이터
|
||||
else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||
dataToExport = [context.formData];
|
||||
console.log("✅ 폼 데이터 사용:", dataToExport);
|
||||
}
|
||||
|
||||
console.log("📊 최종 다운로드 데이터:", {
|
||||
selectedRowsData: context.selectedRowsData,
|
||||
selectedRowsLength: context.selectedRowsData?.length,
|
||||
formData: context.formData,
|
||||
tableName: context.tableName,
|
||||
dataToExport,
|
||||
dataToExportType: typeof dataToExport,
|
||||
dataToExportIsArray: Array.isArray(dataToExport),
|
||||
dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A",
|
||||
});
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
if (!Array.isArray(dataToExport)) {
|
||||
console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport);
|
||||
|
||||
// 객체인 경우 배열로 감싸기
|
||||
if (typeof dataToExport === "object" && dataToExport !== null) {
|
||||
dataToExport = [dataToExport];
|
||||
} else {
|
||||
toast.error("다운로드할 데이터 형식이 올바르지 않습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (dataToExport.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 파일명 생성
|
||||
const fileName = config.excelFileName || `${context.tableName || "데이터"}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||||
const sheetName = config.excelSheetName || "Sheet1";
|
||||
const includeHeaders = config.excelIncludeHeaders !== false;
|
||||
|
||||
console.log("📥 엑셀 다운로드 실행:", {
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
dataCount: dataToExport.length,
|
||||
firstRow: dataToExport[0],
|
||||
});
|
||||
|
||||
// 엑셀 다운로드 실행
|
||||
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
|
||||
|
||||
toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다.");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 다운로드 실패:", error);
|
||||
toast.error(config.errorMessage || "엑셀 다운로드 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 액션 처리
|
||||
*/
|
||||
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📤 엑셀 업로드 모달 열기:", { config, context });
|
||||
|
||||
// 동적 import로 모달 컴포넌트 로드
|
||||
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
|
||||
// 모달 컨테이너 생성
|
||||
const modalContainer = document.createElement("div");
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
const root = createRoot(modalContainer);
|
||||
|
||||
const closeModal = () => {
|
||||
root.unmount();
|
||||
document.body.removeChild(modalContainer);
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(ExcelUploadModal, {
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) closeModal();
|
||||
},
|
||||
tableName: context.tableName || "",
|
||||
uploadMode: config.excelUploadMode || "insert",
|
||||
keyColumn: config.excelKeyColumn,
|
||||
onSuccess: () => {
|
||||
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
|
||||
context.onRefresh?.();
|
||||
closeModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 업로드 모달 열기 실패:", error);
|
||||
toast.error(config.errorMessage || "엑셀 업로드 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 바코드 스캔 액션 처리
|
||||
*/
|
||||
private static async handleBarcodeScan(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
try {
|
||||
console.log("📷 바코드 스캔 모달 열기:", { config, context });
|
||||
|
||||
// 동적 import로 모달 컴포넌트 로드
|
||||
const { BarcodeScanModal } = await import("@/components/common/BarcodeScanModal");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
|
||||
// 모달 컨테이너 생성
|
||||
const modalContainer = document.createElement("div");
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
const root = createRoot(modalContainer);
|
||||
|
||||
const closeModal = () => {
|
||||
root.unmount();
|
||||
document.body.removeChild(modalContainer);
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(BarcodeScanModal, {
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) closeModal();
|
||||
},
|
||||
targetField: config.barcodeTargetField,
|
||||
barcodeFormat: config.barcodeFormat || "all",
|
||||
autoSubmit: config.barcodeAutoSubmit || false,
|
||||
onScanSuccess: (barcode: string) => {
|
||||
console.log("✅ 바코드 스캔 성공:", barcode);
|
||||
|
||||
// 대상 필드에 값 입력
|
||||
if (config.barcodeTargetField && context.onFormDataChange) {
|
||||
context.onFormDataChange({
|
||||
...context.formData,
|
||||
[config.barcodeTargetField]: barcode,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(`바코드 스캔 완료: ${barcode}`);
|
||||
|
||||
// 자동 제출 옵션이 켜져있으면 저장
|
||||
if (config.barcodeAutoSubmit) {
|
||||
this.handleSave(config, context);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ 바코드 스캔 모달 열기 실패:", error);
|
||||
toast.error("바코드 스캔 중 오류가 발생했습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 유효성 검사
|
||||
*/
|
||||
|
|
@ -1717,4 +1963,22 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
|||
historyRecordIdField: "id",
|
||||
historyRecordIdSource: "selected_row",
|
||||
},
|
||||
excel_download: {
|
||||
type: "excel_download",
|
||||
excelIncludeHeaders: true,
|
||||
successMessage: "엑셀 파일이 다운로드되었습니다.",
|
||||
errorMessage: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||
},
|
||||
excel_upload: {
|
||||
type: "excel_upload",
|
||||
excelUploadMode: "insert",
|
||||
confirmMessage: "엑셀 파일을 업로드하시겠습니까?",
|
||||
successMessage: "엑셀 파일이 업로드되었습니다.",
|
||||
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
|
||||
},
|
||||
barcode_scan: {
|
||||
type: "barcode_scan",
|
||||
barcodeFormat: "all",
|
||||
barcodeAutoSubmit: false,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* 엑셀 내보내기 유틸리티
|
||||
* xlsx 라이브러리를 사용하여 데이터를 엑셀 파일로 변환
|
||||
*/
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
/**
|
||||
* 데이터를 엑셀 파일로 내보내기
|
||||
* @param data 내보낼 데이터 배열
|
||||
* @param fileName 파일명 (기본: "export.xlsx")
|
||||
* @param sheetName 시트명 (기본: "Sheet1")
|
||||
* @param includeHeaders 헤더 포함 여부 (기본: true)
|
||||
*/
|
||||
export async function exportToExcel(
|
||||
data: Record<string, any>[],
|
||||
fileName: string = "export.xlsx",
|
||||
sheetName: string = "Sheet1",
|
||||
includeHeaders: boolean = true
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log("📥 엑셀 내보내기 시작:", {
|
||||
dataCount: data.length,
|
||||
fileName,
|
||||
sheetName,
|
||||
includeHeaders,
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new Error("내보낼 데이터가 없습니다.");
|
||||
}
|
||||
|
||||
// 워크북 생성
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// 데이터를 워크시트로 변환
|
||||
const worksheet = XLSX.utils.json_to_sheet(data, {
|
||||
header: includeHeaders ? undefined : [],
|
||||
skipHeader: !includeHeaders,
|
||||
});
|
||||
|
||||
// 컬럼 너비 자동 조정
|
||||
const columnWidths = autoSizeColumns(data);
|
||||
worksheet["!cols"] = columnWidths;
|
||||
|
||||
// 워크시트를 워크북에 추가
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// 파일 다운로드
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
|
||||
console.log("✅ 엑셀 내보내기 완료:", fileName);
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 내보내기 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 너비 자동 조정
|
||||
*/
|
||||
function autoSizeColumns(data: Record<string, any>[]): Array<{ wch: number }> {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const keys = Object.keys(data[0]);
|
||||
const columnWidths: Array<{ wch: number }> = [];
|
||||
|
||||
keys.forEach((key) => {
|
||||
// 헤더 길이
|
||||
let maxWidth = key.length;
|
||||
|
||||
// 데이터 길이 확인
|
||||
data.forEach((row) => {
|
||||
const value = row[key];
|
||||
const valueLength = value ? String(value).length : 0;
|
||||
maxWidth = Math.max(maxWidth, valueLength);
|
||||
});
|
||||
|
||||
// 최소 10, 최대 50으로 제한
|
||||
columnWidths.push({ wch: Math.min(Math.max(maxWidth, 10), 50) });
|
||||
});
|
||||
|
||||
return columnWidths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일을 읽어서 JSON 데이터로 변환
|
||||
* @param file 읽을 파일
|
||||
* @param sheetName 읽을 시트명 (기본: 첫 번째 시트)
|
||||
* @returns JSON 데이터 배열
|
||||
*/
|
||||
export async function importFromExcel(
|
||||
file: File,
|
||||
sheetName?: string
|
||||
): Promise<Record<string, any>[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
if (!data) {
|
||||
reject(new Error("파일을 읽을 수 없습니다."));
|
||||
return;
|
||||
}
|
||||
|
||||
// 워크북 읽기
|
||||
const workbook = XLSX.read(data, { type: "binary" });
|
||||
|
||||
// 시트 선택 (지정된 시트 또는 첫 번째 시트)
|
||||
const targetSheetName = sheetName || workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[targetSheetName];
|
||||
|
||||
if (!worksheet) {
|
||||
reject(new Error(`시트 "${targetSheetName}"를 찾을 수 없습니다.`));
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON으로 변환
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
console.log("✅ 엑셀 가져오기 완료:", {
|
||||
sheetName: targetSheetName,
|
||||
rowCount: jsonData.length,
|
||||
});
|
||||
|
||||
resolve(jsonData as Record<string, any>[]);
|
||||
} catch (error) {
|
||||
console.error("❌ 엑셀 가져오기 실패:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("파일 읽기 중 오류가 발생했습니다."));
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일의 시트 목록 가져오기
|
||||
*/
|
||||
export async function getExcelSheetNames(file: File): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
if (!data) {
|
||||
reject(new Error("파일을 읽을 수 없습니다."));
|
||||
return;
|
||||
}
|
||||
|
||||
const workbook = XLSX.read(data, { type: "binary" });
|
||||
resolve(workbook.SheetNames);
|
||||
} catch (error) {
|
||||
console.error("❌ 시트 목록 가져오기 실패:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("파일 읽기 중 오류가 발생했습니다."));
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -52,6 +52,7 @@
|
|||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"@zxing/library": "^0.21.3",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -77,6 +78,7 @@
|
|||
"react-hot-toast": "^2.6.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-webcam": "^7.2.0",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.2.1",
|
||||
|
|
|
|||
Loading…
Reference in New Issue