From 93443c98eec483074d701d6474c23d17a539d7be Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 15:40:29 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90=20Repea?= =?UTF-8?q?terFieldGroup=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20DB=20webType?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=ED=95=91=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../externalDbConnectionPoolService.ts | 4 +- .../components/webtypes/RepeaterInput.tsx | 334 +++++------ .../webtypes/config/RepeaterConfigPanel.tsx | 255 ++++++--- frontend/contexts/ScreenContext.tsx | 93 ++-- .../button-primary/ButtonPrimaryComponent.tsx | 21 +- .../RepeaterFieldGroupRenderer.tsx | 517 ++++++++++++++---- frontend/lib/utils/buttonActions.ts | 165 +++++- frontend/types/repeater.ts | 37 +- 8 files changed, 1034 insertions(+), 392 deletions(-) diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 73077ef1..f35150ac 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } try { - const [rows] = await this.pool.execute(sql, params); - return rows; + const [rows] = await this.pool.execute(sql, params); + return rows; } catch (error: any) { // 연결 닫힘 오류 감지 if ( diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 3116b2c6..0b5a1328 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; -import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater"; +import { + RepeaterFieldGroupConfig, + RepeaterData, + RepeaterItemData, + RepeaterFieldDefinition, + CalculationFormula, +} from "@/types/repeater"; import { cn } from "@/lib/utils"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; @@ -46,7 +52,9 @@ export const RepeaterInput: React.FC = ({ const breakpoint = previewBreakpoint || globalBreakpoint; // 카테고리 매핑 데이터 (값 -> {label, color}) - const [categoryMappings, setCategoryMappings] = useState>>({}); + const [categoryMappings, setCategoryMappings] = useState< + Record> + >({}); // 설정 기본값 const { @@ -78,10 +86,10 @@ export const RepeaterInput: React.FC = ({ // 접힌 상태 관리 (각 항목별) const [collapsedItems, setCollapsedItems] = useState>(new Set()); - + // 🆕 초기 계산 완료 여부 추적 (무한 루프 방지) const initialCalcDoneRef = useRef(false); - + // 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영) const deletedItemIdsRef = useRef([]); @@ -98,47 +106,60 @@ export const RepeaterInput: React.FC = ({ // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트 useEffect(() => { - if (value.length > 0) { - // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) - const calculatedFields = fields.filter(f => f.type === "calculated"); - - if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { - const updatedValue = value.map(item => { - const updatedItem = { ...item }; - let hasChange = false; - - calculatedFields.forEach(calcField => { - const calculatedValue = calculateValue(calcField.formula, updatedItem); - if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { - updatedItem[calcField.name] = calculatedValue; - hasChange = true; - } - }); - - // 🆕 기존 레코드임을 표시 (id가 있는 경우) - if (updatedItem.id) { - updatedItem._existingRecord = true; - } - - return hasChange ? updatedItem : item; - }); - - setItems(updatedValue); - initialCalcDoneRef.current = true; - - // 계산된 값이 있으면 onChange 호출 (초기 1회만) - const dataWithMeta = config.targetTable - ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) - : updatedValue; - onChange?.(dataWithMeta); + // 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음) + if (value.length === 0) { + // minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화 + if (minItems > 0) { + const emptyItems = Array(minItems) + .fill(null) + .map(() => createEmptyItem()); + setItems(emptyItems); } else { - // 🆕 기존 레코드 플래그 추가 - const valueWithFlag = value.map(item => ({ - ...item, - _existingRecord: !!item.id, - })); - setItems(valueWithFlag); + setItems([]); } + initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행 + return; + } + + // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) + const calculatedFields = fields.filter((f) => f.type === "calculated"); + + if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { + const updatedValue = value.map((item) => { + const updatedItem = { ...item }; + let hasChange = false; + + calculatedFields.forEach((calcField) => { + const calculatedValue = calculateValue(calcField.formula, updatedItem); + if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { + updatedItem[calcField.name] = calculatedValue; + hasChange = true; + } + }); + + // 🆕 기존 레코드임을 표시 (id가 있는 경우) + if (updatedItem.id) { + updatedItem._existingRecord = true; + } + + return hasChange ? updatedItem : item; + }); + + setItems(updatedValue); + initialCalcDoneRef.current = true; + + // 계산된 값이 있으면 onChange 호출 (초기 1회만) + const dataWithMeta = config.targetTable + ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) + : updatedValue; + onChange?.(dataWithMeta); + } else { + // 🆕 기존 레코드 플래그 추가 + const valueWithFlag = value.map((item) => ({ + ...item, + _existingRecord: !!item.id, + })); + setItems(valueWithFlag); } }, [value]); @@ -164,14 +185,14 @@ export const RepeaterInput: React.FC = ({ if (items.length <= minItems) { return; } - + // 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요) const removedItem = items[index]; if (removedItem?.id) { console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id); deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id]; } - + const newItems = items.filter((_, i) => i !== index); setItems(newItems); @@ -179,10 +200,10 @@ export const RepeaterInput: React.FC = ({ // 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용) const currentDeletedIds = deletedItemIdsRef.current; console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds); - + const dataWithMeta = config.targetTable - ? newItems.map((item, idx) => ({ - ...item, + ? newItems.map((item, idx) => ({ + ...item, _targetTable: config.targetTable, // 첫 번째 항목에만 삭제 ID 목록 포함 ...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}), @@ -205,16 +226,16 @@ export const RepeaterInput: React.FC = ({ ...newItems[itemIndex], [fieldName]: value, }; - + // 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산 - const calculatedFields = fields.filter(f => f.type === "calculated"); - calculatedFields.forEach(calcField => { + const calculatedFields = fields.filter((f) => f.type === "calculated"); + calculatedFields.forEach((calcField) => { const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]); if (calculatedValue !== null) { newItems[itemIndex][calcField.name] = calculatedValue; } }); - + setItems(newItems); console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { itemIndex, @@ -227,8 +248,8 @@ export const RepeaterInput: React.FC = ({ // 🆕 삭제된 항목 ID 목록도 유지 const currentDeletedIds = deletedItemIdsRef.current; const dataWithMeta = config.targetTable - ? newItems.map((item, idx) => ({ - ...item, + ? newItems.map((item, idx) => ({ + ...item, _targetTable: config.targetTable, // 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만) ...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}), @@ -288,14 +309,12 @@ export const RepeaterInput: React.FC = ({ */ const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => { if (!formula || !formula.field1) return null; - + const value1 = parseFloat(item[formula.field1]) || 0; - const value2 = formula.field2 - ? (parseFloat(item[formula.field2]) || 0) - : (formula.constantValue ?? 0); - + const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0); + let result: number; - + switch (formula.operator) { case "+": result = value1 + value2; @@ -331,7 +350,7 @@ export const RepeaterInput: React.FC = ({ default: result = value1; } - + return result; }; @@ -341,42 +360,44 @@ export const RepeaterInput: React.FC = ({ * @param format 포맷 설정 * @returns 포맷된 문자열 */ - const formatNumber = ( - value: number | null, - format?: RepeaterFieldDefinition["numberFormat"] - ): string => { + const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => { if (value === null || isNaN(value)) return "-"; - + let formattedValue = value; - + // 소수점 자릿수 적용 if (format?.decimalPlaces !== undefined) { formattedValue = parseFloat(value.toFixed(format.decimalPlaces)); } - + // 천 단위 구분자 - let result = format?.useThousandSeparator !== false - ? formattedValue.toLocaleString("ko-KR", { - minimumFractionDigits: format?.minimumFractionDigits ?? 0, - maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, - }) - : formattedValue.toString(); - + let result = + format?.useThousandSeparator !== false + ? formattedValue.toLocaleString("ko-KR", { + minimumFractionDigits: format?.minimumFractionDigits ?? 0, + maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, + }) + : formattedValue.toString(); + // 접두사/접미사 추가 if (format?.prefix) result = format.prefix + result; if (format?.suffix) result = result + format.suffix; - + return result; }; // 개별 필드 렌더링 const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const isReadonly = disabled || readonly || field.readonly; - + + // 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성 + // "id(를) 입력하세요" 같은 잘못된 기본값 방지 + const defaultPlaceholder = field.placeholder || `${field.label || field.name}`; + const commonProps = { value: value || "", disabled: isReadonly, - placeholder: field.placeholder, + placeholder: defaultPlaceholder, required: field.required, }; @@ -385,25 +406,21 @@ export const RepeaterInput: React.FC = ({ const item = items[itemIndex]; const calculatedValue = calculateValue(field.formula, item); const formattedValue = formatNumber(calculatedValue, field.numberFormat); - - return ( - - {formattedValue} - - ); + + return {formattedValue}; } // 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용) if (field.type === "category") { if (!value) return -; - + // field.name을 키로 사용 (테이블 리스트와 동일) const mapping = categoryMappings[field.name]; const valueStr = String(value); // 값을 문자열로 변환 const categoryData = mapping?.[valueStr]; const displayLabel = categoryData?.label || valueStr; const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate) - + console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, { fieldName: field.name, value: valueStr, @@ -412,12 +429,12 @@ export const RepeaterInput: React.FC = ({ displayLabel, displayColor, }); - + // 색상이 "none"이면 일반 텍스트로 표시 if (displayColor === "none") { return {displayLabel}; } - + return ( = ({ if (field.displayMode === "readonly") { // select 타입인 경우 옵션에서 라벨 찾기 if (field.type === "select" && value && field.options) { - const option = field.options.find(opt => opt.value === value); + const option = field.options.find((opt) => opt.value === value); return {option?.label || value}; } - + // 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드) const mapping = categoryMappings[field.name]; if (mapping && value) { @@ -461,16 +478,12 @@ export const RepeaterInput: React.FC = ({ ); } // 색상이 없으면 텍스트로 표시 - return {categoryData.label}; + return {categoryData.label}; } } - + // 일반 텍스트 - return ( - - {value || "-"} - - ); + return {value || "-"}; } switch (field.type) { @@ -500,35 +513,46 @@ export const RepeaterInput: React.FC = ({ {...commonProps} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} rows={3} - className="resize-none min-w-[100px]" + className="min-w-[100px] resize-none" /> ); - case "date": + case "date": { + // 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 + let dateValue = value || ""; + if (dateValue && typeof dateValue === "string") { + // ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출 + if (dateValue.includes("T")) { + dateValue = dateValue.split("T")[0]; + } + // 유효한 날짜인지 확인 + const parsedDate = new Date(dateValue); + if (isNaN(parsedDate.getTime())) { + dateValue = ""; // 유효하지 않은 날짜면 빈 값 + } + } return ( handleFieldChange(itemIndex, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)} className="min-w-[120px]" /> ); + } case "number": // 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시 if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) { const numValue = parseFloat(value) || 0; const formattedDisplay = formatNumber(numValue, field.numberFormat); - + // 읽기 전용이면 포맷팅된 텍스트만 표시 if (isReadonly) { - return ( - - {formattedDisplay} - - ); + return {formattedDisplay}; } - + // 편집 가능: 입력은 숫자로, 표시는 포맷팅 return (
@@ -540,15 +564,11 @@ export const RepeaterInput: React.FC = ({ max={field.validation?.max} className="pr-1" /> - {value && ( -
- {formattedDisplay} -
- )} + {value &&
{formattedDisplay}
}
); } - + return ( = ({ // 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values useEffect(() => { // 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성) - const categoryFields = fields.filter(f => f.type === "category"); - const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text"); - + const categoryFields = fields.filter((f) => f.type === "category"); + const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text"); + if (categoryFields.length === 0 && readonlyFields.length === 0) return; const loadCategoryMappings = async () => { const apiClient = (await import("@/lib/api/client")).apiClient; - + // 1. 카테고리 타입 필드 매핑 로드 for (const field of categoryFields) { const columnName = field.name; - + if (categoryMappings[columnName]) continue; - + try { const tableName = config.targetTable; if (!tableName) continue; - + console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`); - + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -629,10 +649,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -641,29 +661,29 @@ export const RepeaterInput: React.FC = ({ console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error); } } - + // 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드 // material, division 등 조인된 테이블의 카테고리 필드 - const joinedTableFields = ['material', 'division', 'status', 'currency_code']; - const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name)); - + const joinedTableFields = ["material", "division", "status", "currency_code"]; + const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name)); + if (fieldsToLoadFromJoinedTable.length > 0) { // item_info 테이블에서 카테고리 매핑 로드 - const joinedTableName = 'item_info'; - + const joinedTableName = "item_info"; + for (const field of fieldsToLoadFromJoinedTable) { const columnName = field.name; - + if (categoryMappings[columnName]) continue; - + try { console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`); - + const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -671,10 +691,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -694,9 +714,9 @@ export const RepeaterInput: React.FC = ({ if (fields.length === 0) { return (
-
-

필드가 정의되지 않았습니다

-

속성 패널에서 필드를 추가하세요.

+
+

필드가 정의되지 않았습니다

+

속성 패널에서 필드를 추가하세요.

); @@ -706,8 +726,8 @@ export const RepeaterInput: React.FC = ({ if (items.length === 0) { return (
-
-

{emptyMessage}

+
+

{emptyMessage}

{!readonly && !disabled && items.length < maxItems && (