"use client"; import { useState, useRef } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Slider } from "@/components/ui/slider"; import { Trash2, Settings, Database, Link2, Upload, Loader2, X } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { QueryManager } from "./QueryManager"; import { SignaturePad } from "./SignaturePad"; import { SignatureGenerator } from "./SignatureGenerator"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; export function ReportDesignerRightPanel() { const context = useReportDesigner(); const { selectedComponentId, components, updateComponent, removeComponent, queries, currentPage, currentPageId, updatePageSettings, getQueryResult, layoutConfig, updateWatermark, } = context; const [activeTab, setActiveTab] = useState("properties"); const [uploadingImage, setUploadingImage] = useState(false); const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false); const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw"); const fileInputRef = useRef(null); const watermarkFileInputRef = useRef(null); const { toast } = useToast(); const selectedComponent = components.find((c) => c.id === selectedComponentId); // 이미지 업로드 핸들러 const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !selectedComponent) return; // 파일 타입 체크 if (!file.type.startsWith("image/")) { toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive", }); return; } // 파일 크기 체크 (10MB) if (file.size > 10 * 1024 * 1024) { toast({ title: "오류", description: "파일 크기는 10MB 이하여야 합니다.", variant: "destructive", }); return; } try { setUploadingImage(true); const result = await reportApi.uploadImage(file); if (result.success) { // 업로드된 이미지 URL을 컴포넌트에 설정 updateComponent(selectedComponent.id, { imageUrl: result.data.fileUrl, }); toast({ title: "성공", description: "이미지가 업로드되었습니다.", }); } } catch { toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive", }); } finally { setUploadingImage(false); // input 초기화 if (fileInputRef.current) { fileInputRef.current.value = ""; } } }; // 워터마크 이미지 업로드 핸들러 const handleWatermarkImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // 파일 타입 체크 if (!file.type.startsWith("image/")) { toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive", }); return; } // 파일 크기 체크 (5MB) if (file.size > 5 * 1024 * 1024) { toast({ title: "오류", description: "파일 크기는 5MB 이하여야 합니다.", variant: "destructive", }); return; } try { setUploadingWatermarkImage(true); const result = await reportApi.uploadImage(file); if (result.success) { // 업로드된 이미지 URL을 전체 워터마크에 설정 updateWatermark({ ...layoutConfig.watermark!, imageUrl: result.data.fileUrl, }); toast({ title: "성공", description: "워터마크 이미지가 업로드되었습니다.", }); } } catch { toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive", }); } finally { setUploadingWatermarkImage(false); // input 초기화 if (watermarkFileInputRef.current) { watermarkFileInputRef.current.value = ""; } } }; // 선택된 쿼리의 결과 필드 가져오기 const getQueryFields = (queryId: string): string[] => { const result = context.getQueryResult(queryId); return result ? result.fields : []; }; return (
페이지 속성 쿼리
{/* 속성 탭 */}
{!selectedComponent ? (
컴포넌트를 선택하면 속성을 편집할 수 있습니다
) : (
컴포넌트 속성
{/* 타입 */}
{selectedComponent.type}
{/* 위치 */}
updateComponent(selectedComponent.id, { x: parseInt(e.target.value) || 0, }) } className="h-8" />
updateComponent(selectedComponent.id, { y: parseInt(e.target.value) || 0, }) } className="h-8" />
{/* 크기 */}
updateComponent(selectedComponent.id, { width: parseInt(e.target.value) || 50, }) } className="h-8" />
updateComponent(selectedComponent.id, { height: parseInt(e.target.value) || 30, }) } className="h-8" />
{/* 스타일링 섹션 */}

스타일

{/* 글꼴 크기 */}
updateComponent(selectedComponent.id, { fontSize: parseInt(e.target.value) || 13, }) } className="h-8" />
{/* 글꼴 색상 */}
updateComponent(selectedComponent.id, { fontColor: e.target.value, }) } className="h-8 w-16" /> updateComponent(selectedComponent.id, { fontColor: e.target.value, }) } className="h-8 flex-1 font-mono text-xs" />
{/* 텍스트 정렬 (텍스트/라벨만) */} {(selectedComponent.type === "text" || selectedComponent.type === "label") && (
)} {/* 글꼴 굵기 (텍스트/라벨만) */} {(selectedComponent.type === "text" || selectedComponent.type === "label") && (
)} {/* 배경 색상 */}
updateComponent(selectedComponent.id, { backgroundColor: e.target.value, }) } className="h-8 w-16" /> updateComponent(selectedComponent.id, { backgroundColor: e.target.value, }) } placeholder="transparent" className="h-8 flex-1 font-mono text-xs" />
{/* 테두리 */}
updateComponent(selectedComponent.id, { borderWidth: parseInt(e.target.value) || 0, }) } className="h-8" />
updateComponent(selectedComponent.id, { borderColor: e.target.value, }) } className="h-8" />
{/* 테이블 스타일 */} {selectedComponent.type === "table" && ( 테이블 스타일 {/* 헤더 배경색 */}
updateComponent(selectedComponent.id, { headerBackgroundColor: e.target.value, }) } className="h-8 w-16" /> updateComponent(selectedComponent.id, { headerBackgroundColor: e.target.value, }) } className="h-8 flex-1 font-mono text-xs" />
{/* 헤더 텍스트 색상 */}
updateComponent(selectedComponent.id, { headerTextColor: e.target.value, }) } className="h-8 w-16" /> updateComponent(selectedComponent.id, { headerTextColor: e.target.value, }) } className="h-8 flex-1 font-mono text-xs" />
{/* 테두리 표시 */}
updateComponent(selectedComponent.id, { showBorder: e.target.checked, }) } className="h-4 w-4" />
{/* 행 높이 */}
updateComponent(selectedComponent.id, { rowHeight: parseInt(e.target.value), }) } className="h-8" />
)} {/* 이미지 속성 */} {selectedComponent.type === "image" && ( 이미지 설정 {/* 파일 업로드 */}

JPG, PNG, GIF, WEBP (최대 10MB)

{selectedComponent.imageUrl && (

현재: {selectedComponent.imageUrl}

)}
)} {/* 구분선 속성 */} {selectedComponent.type === "divider" && ( 구분선 설정
updateComponent(selectedComponent.id, { lineWidth: Number(e.target.value), }) } className="h-8" />
updateComponent(selectedComponent.id, { lineColor: e.target.value, }) } className="h-8 w-16" /> updateComponent(selectedComponent.id, { lineColor: e.target.value, }) } className="h-8 flex-1 font-mono text-xs" />
)} {/* 서명/도장 속성 */} {(selectedComponent.type === "signature" || selectedComponent.type === "stamp") && ( {selectedComponent.type === "signature" ? "서명란 설정" : "도장란 설정"} {/* 서명란: 드롭다운으로 직접 서명 / 이미지 업로드 / 서명 만들기 선택 */} {selectedComponent.type === "signature" ? ( <>
{/* 직접 서명 */} {signatureMethod === "draw" && (
{ updateComponent(selectedComponent.id, { imageUrl: dataUrl, }); }} />
)} {/* 이미지 업로드 */} {signatureMethod === "upload" && (

JPG, PNG, GIF, WEBP (최대 10MB)

{selectedComponent.imageUrl && !selectedComponent.imageUrl.startsWith("data:") && (

현재: {selectedComponent.imageUrl}

)}
)} {/* 서명 만들기 */} {signatureMethod === "generate" && (
{ updateComponent(selectedComponent.id, { imageUrl: dataUrl, }); toast({ title: "성공", description: "서명이 적용되었습니다.", }); }} />
)} ) : ( // 도장란: 기존 방식 유지

JPG, PNG, GIF, WEBP (최대 10MB)

{selectedComponent.imageUrl && !selectedComponent.imageUrl.startsWith("data:") && (

현재: {selectedComponent.imageUrl}

)}
)} {/* 맞춤 방식 */}
{/* 레이블 표시 */}
updateComponent(selectedComponent.id, { showLabel: e.target.checked, }) } className="h-4 w-4" />
{/* 레이블 텍스트 */} {selectedComponent.showLabel !== false && ( <>
updateComponent(selectedComponent.id, { labelText: e.target.value, }) } className="h-8" />
{/* 레이블 위치 (서명란만) */} {selectedComponent.type === "signature" && (
)} )} {/* 이름 입력 (도장란만) */} {selectedComponent.type === "stamp" && (
updateComponent(selectedComponent.id, { personName: e.target.value, }) } placeholder="예: 홍길동" className="h-8" />

도장 옆에 표시될 이름

)}
)} {/* 페이지 번호 설정 */} {selectedComponent.type === "pageNumber" && ( 페이지 번호 설정
)} {/* 카드 컴포넌트 설정 */} {selectedComponent.type === "card" && ( 카드 설정 {/* 제목 표시 여부 */}
updateComponent(selectedComponent.id, { showCardTitle: e.target.checked, }) } className="h-4 w-4" />
{/* 제목 텍스트 */} {selectedComponent.showCardTitle !== false && (
updateComponent(selectedComponent.id, { cardTitle: e.target.value, }) } placeholder="정보 카드" className="h-8" />
)} {/* 라벨 너비 */}
updateComponent(selectedComponent.id, { labelWidth: Number(e.target.value), }) } min={40} max={200} className="h-8" />
{/* 테두리 표시 */}
updateComponent(selectedComponent.id, { showCardBorder: e.target.checked, borderWidth: e.target.checked ? 1 : 0, }) } className="h-4 w-4" />
{/* 폰트 크기 설정 */}
updateComponent(selectedComponent.id, { titleFontSize: Number(e.target.value), }) } min={10} max={24} className="h-8" />
updateComponent(selectedComponent.id, { labelFontSize: Number(e.target.value), }) } min={10} max={20} className="h-8" />
updateComponent(selectedComponent.id, { valueFontSize: Number(e.target.value), }) } min={10} max={20} className="h-8" />
{/* 색상 설정 */}
updateComponent(selectedComponent.id, { titleColor: e.target.value, }) } className="h-8 w-full cursor-pointer p-1" />
updateComponent(selectedComponent.id, { labelColor: e.target.value, }) } className="h-8 w-full cursor-pointer p-1" />
updateComponent(selectedComponent.id, { valueColor: e.target.value, }) } className="h-8 w-full cursor-pointer p-1" />
{/* 항목 목록 관리 */}
{/* 쿼리 선택 (데이터 바인딩용) */}
{/* 항목 리스트 */}
{(selectedComponent.cardItems || []).map( (item: { label: string; value: string; fieldName?: string }, index: number) => (
항목 {index + 1}
{ const currentItems = [...(selectedComponent.cardItems || [])]; currentItems[index] = { ...item, label: e.target.value }; updateComponent(selectedComponent.id, { cardItems: currentItems, }); }} className="h-6 text-xs" placeholder="항목명" />
{selectedComponent.queryId ? (
) : (
{ const currentItems = [...(selectedComponent.cardItems || [])]; currentItems[index] = { ...item, value: e.target.value }; updateComponent(selectedComponent.id, { cardItems: currentItems, }); }} className="h-6 text-xs" placeholder="내용" />
)}
), )}
)} {/* 계산 컴포넌트 설정 */} {selectedComponent.type === "calculation" && ( 계산 설정 {/* 결과 라벨 */}
updateComponent(selectedComponent.id, { resultLabel: e.target.value, }) } placeholder="합계 금액" className="h-8" />
{/* 라벨 너비 */}
updateComponent(selectedComponent.id, { labelWidth: Number(e.target.value), }) } min={60} max={200} className="h-8" />
{/* 숫자 포맷 */}
{/* 통화 접미사 */} {selectedComponent.numberFormat === "currency" && (
updateComponent(selectedComponent.id, { currencySuffix: e.target.value, }) } placeholder="원" className="h-8" />
)} {/* 폰트 크기 설정 */}
updateComponent(selectedComponent.id, { labelFontSize: Number(e.target.value), }) } min={10} max={20} className="h-8" />
updateComponent(selectedComponent.id, { valueFontSize: Number(e.target.value), }) } min={10} max={20} className="h-8" />
updateComponent(selectedComponent.id, { resultFontSize: Number(e.target.value), }) } min={12} max={24} className="h-8" />
{/* 색상 설정 */}
updateComponent(selectedComponent.id, { labelColor: e.target.value, }) } className="h-8 w-full cursor-pointer p-1" />
updateComponent(selectedComponent.id, { valueColor: e.target.value, }) } className="h-8 w-full cursor-pointer p-1" />
updateComponent(selectedComponent.id, { resultColor: e.target.value, }) } className="h-8 w-full cursor-pointer p-1" />
{/* 계산 항목 목록 관리 */}
{/* 쿼리 선택 (데이터 바인딩용) */}
{/* 항목 리스트 */}
{(selectedComponent.calcItems || []).map((item, index: number) => (
항목 {index + 1}
{ const currentItems = [...(selectedComponent.calcItems || [])]; currentItems[index] = { ...currentItems[index], label: e.target.value }; updateComponent(selectedComponent.id, { calcItems: currentItems, }); }} className="h-6 text-xs" placeholder="항목명" />
{/* 두 번째 항목부터 연산자 표시 */} {index > 0 && (
)}
{selectedComponent.queryId ? (
) : (
{ const currentItems = [...(selectedComponent.calcItems || [])]; currentItems[index] = { ...currentItems[index], value: Number(e.target.value), }; updateComponent(selectedComponent.id, { calcItems: currentItems, }); }} className="h-6 text-xs" placeholder="0" />
)}
))}
)} {/* 바코드 컴포넌트 설정 */} {selectedComponent.type === "barcode" && ( 바코드 설정 {/* 바코드 타입 */}
{/* 바코드 값 입력 (쿼리 연결 없을 때) */} {!selectedComponent.queryId && (
updateComponent(selectedComponent.id, { barcodeValue: e.target.value, }) } placeholder={ selectedComponent.barcodeType === "EAN13" ? "13자리 숫자" : selectedComponent.barcodeType === "EAN8" ? "8자리 숫자" : selectedComponent.barcodeType === "UPC" ? "12자리 숫자" : "바코드에 표시할 값" } className="h-8" /> {(selectedComponent.barcodeType === "EAN13" || selectedComponent.barcodeType === "EAN8" || selectedComponent.barcodeType === "UPC") && (

{selectedComponent.barcodeType === "EAN13" && "EAN-13: 12~13자리 숫자 필요"} {selectedComponent.barcodeType === "EAN8" && "EAN-8: 7~8자리 숫자 필요"} {selectedComponent.barcodeType === "UPC" && "UPC: 11~12자리 숫자 필요"}

)}
)} {/* 쿼리 연결 시 필드 선택 */} {selectedComponent.queryId && ( <> {/* QR코드: 다중 필드 모드 토글 */} {selectedComponent.barcodeType === "QR" && (
updateComponent(selectedComponent.id, { qrUseMultiField: e.target.checked, // 다중 필드 모드 활성화 시 단일 필드 초기화 ...(e.target.checked && { barcodeFieldName: "" }), }) } className="h-4 w-4 rounded border-gray-300" />
)} {/* 단일 필드 모드 (1D 바코드 또는 QR 단일 모드) */} {(selectedComponent.barcodeType !== "QR" || !selectedComponent.qrUseMultiField) && (
)} {/* QR코드 다중 필드 모드 UI */} {selectedComponent.barcodeType === "QR" && selectedComponent.qrUseMultiField && (
{/* 필드 목록 */}
{(selectedComponent.qrDataFields || []).map((field, index) => (
{ const newFields = [...(selectedComponent.qrDataFields || [])]; newFields[index] = { ...newFields[index], label: e.target.value }; updateComponent(selectedComponent.id, { qrDataFields: newFields }); }} placeholder="JSON 키 이름" className="h-7 text-xs" />
))}
{(selectedComponent.qrDataFields || []).length === 0 && (

필드를 추가하세요

)}

결과: {selectedComponent.qrIncludeAllRows ? `[{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}"}, ...]` : `{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}":"값"}` }

)} )} {/* QR코드 모든 행 포함 옵션 (다중 필드와 독립) */} {selectedComponent.barcodeType === "QR" && selectedComponent.queryId && (
updateComponent(selectedComponent.id, { qrIncludeAllRows: e.target.checked, }) } className="h-4 w-4 rounded border-gray-300" />
)} {/* 1D 바코드 전용 옵션 */} {selectedComponent.barcodeType !== "QR" && (
updateComponent(selectedComponent.id, { showBarcodeText: e.target.checked, }) } className="h-4 w-4 rounded border-gray-300" />
)} {/* QR 오류 보정 수준 */} {selectedComponent.barcodeType === "QR" && (

높을수록 손상에 강하지만 크기 증가

)} {/* 색상 설정 */}
updateComponent(selectedComponent.id, { barcodeColor: e.target.value, }) } className="h-8 w-full" />
updateComponent(selectedComponent.id, { barcodeBackground: e.target.value, }) } className="h-8 w-full" />
{/* 여백 */}
updateComponent(selectedComponent.id, { barcodeMargin: Number(e.target.value), }) } min={0} max={50} className="h-8" />
{/* 쿼리 연결 안내 */} {!selectedComponent.queryId && (
쿼리를 연결하면 데이터베이스 값으로 바코드를 생성할 수 있습니다.
)}
)} {/* 체크박스 컴포넌트 전용 설정 */} {selectedComponent.type === "checkbox" && ( 체크박스 설정 {/* 체크 상태 (쿼리 연결 없을 때) */} {!selectedComponent.queryId && (
updateComponent(selectedComponent.id, { checkboxChecked: e.target.checked, }) } className="h-4 w-4 rounded border-gray-300" />
)} {/* 쿼리 연결 시 필드 선택 */} {selectedComponent.queryId && (

true, "Y", 1 등 truthy 값이면 체크됨

)} {/* 레이블 텍스트 */}
updateComponent(selectedComponent.id, { checkboxLabel: e.target.value, }) } placeholder="체크박스 옆 텍스트" className="h-8" />
{/* 레이블 위치 */}
{/* 체크박스 크기 */}
updateComponent(selectedComponent.id, { checkboxSize: Number(e.target.value), }) } min={12} max={40} className="h-8" />
{/* 색상 설정 */}
updateComponent(selectedComponent.id, { checkboxColor: e.target.value, }) } className="h-8 w-full" />
updateComponent(selectedComponent.id, { checkboxBorderColor: e.target.value, }) } className="h-8 w-full" />
{/* 쿼리 연결 안내 */} {!selectedComponent.queryId && (
쿼리를 연결하면 데이터베이스 값으로 체크 상태를 결정할 수 있습니다.
)}
)} {/* 데이터 바인딩 (텍스트/라벨/테이블/바코드/체크박스 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || selectedComponent.type === "table" || selectedComponent.type === "barcode" || selectedComponent.type === "checkbox") && (
데이터 바인딩
{/* 쿼리 선택 */}
{/* 필드 선택 (텍스트/라벨만) */} {selectedComponent.queryId && (selectedComponent.type === "text" || selectedComponent.type === "label") && (
)} {/* 테이블 컬럼 설정 */} {selectedComponent.queryId && selectedComponent.type === "table" && ( 컬럼 설정 {selectedComponent.tableColumns && selectedComponent.tableColumns.length > 0 && (
{selectedComponent.tableColumns.map((col, idx) => (
컬럼 {idx + 1}
{ const newColumns = [...selectedComponent.tableColumns!]; newColumns[idx].field = e.target.value; updateComponent(selectedComponent.id, { tableColumns: newColumns, }); }} className="h-7 text-xs" />
{ const newColumns = [...selectedComponent.tableColumns!]; newColumns[idx].header = e.target.value; updateComponent(selectedComponent.id, { tableColumns: newColumns, }); }} className="h-7 text-xs" />
{ const newColumns = [...selectedComponent.tableColumns!]; newColumns[idx].width = e.target.value ? parseInt(e.target.value) : undefined; updateComponent(selectedComponent.id, { tableColumns: newColumns, }); }} placeholder="자동" className="h-7 text-xs" />
))}
)}
)} {/* 기본값 (텍스트/라벨만) */} {(selectedComponent.type === "text" || selectedComponent.type === "label") && (