diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 6118e073..58486dcb 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -86,10 +86,10 @@ export const RepeaterInput: React.FC = ({ // 접힌 상태 관리 (각 항목별) const [collapsedItems, setCollapsedItems] = useState>(new Set()); - + // 🆕 초기 계산 완료 여부 추적 (무한 루프 방지) const initialCalcDoneRef = useRef(false); - + // 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영) const deletedItemIdsRef = useRef([]); @@ -121,45 +121,45 @@ export const RepeaterInput: React.FC = ({ return; } - // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) + // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) const calculatedFields = fields.filter((f) => f.type === "calculated"); - - if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { + + if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { const updatedValue = value.map((item) => { - const updatedItem = { ...item }; - let hasChange = false; - + 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; + 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; }); - - // 🆕 기존 레코드임을 표시 (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 { - // 🆕 기존 레코드 플래그 추가 + + 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); + ...item, + _existingRecord: !!item.id, + })); + setItems(valueWithFlag); } }, [value]); @@ -184,14 +184,14 @@ export const RepeaterInput: React.FC = ({ const handleRemoveItem = (index: number) => { // 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용) // minItems 체크 제거 - 모든 항목 삭제 허용 - + // 🆕 삭제되는 항목의 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); @@ -199,15 +199,26 @@ export const RepeaterInput: React.FC = ({ // 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용) const currentDeletedIds = deletedItemIdsRef.current; console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds); - - const dataWithMeta = config.targetTable - ? newItems.map((item, idx) => ({ - ...item, + + // 🆕 빈 배열일 때도 삭제 ID를 전달해야 함 + let dataWithMeta: any[]; + if (config.targetTable) { + if (newItems.length > 0) { + // 항목이 있으면 첫 번째 항목에 삭제 ID 포함 + dataWithMeta = newItems.map((item, idx) => ({ + ...item, _targetTable: config.targetTable, - // 첫 번째 항목에만 삭제 ID 목록 포함 ...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}), - })) - : newItems; + })); + } else if (currentDeletedIds.length > 0) { + // 🆕 모든 항목 삭제 시 삭제 ID만 포함된 메타 객체 전달 + dataWithMeta = [{ _targetTable: config.targetTable, _deletedItemIds: currentDeletedIds, _deleteOnly: true }]; + } else { + dataWithMeta = []; + } + } else { + dataWithMeta = newItems; + } console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta); onChange?.(dataWithMeta); @@ -225,7 +236,7 @@ export const RepeaterInput: React.FC = ({ ...newItems[itemIndex], [fieldName]: value, }; - + // 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산 const calculatedFields = fields.filter((f) => f.type === "calculated"); calculatedFields.forEach((calcField) => { @@ -234,7 +245,7 @@ export const RepeaterInput: React.FC = ({ newItems[itemIndex][calcField.name] = calculatedValue; } }); - + setItems(newItems); console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { itemIndex, @@ -247,8 +258,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 } : {}), @@ -308,12 +319,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); - + let result: number; - + switch (formula.operator) { case "+": result = value1 + value2; @@ -349,7 +360,7 @@ export const RepeaterInput: React.FC = ({ default: result = value1; } - + return result; }; @@ -361,27 +372,27 @@ export const RepeaterInput: React.FC = ({ */ 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(); - + ? 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; }; @@ -392,7 +403,7 @@ export const RepeaterInput: React.FC = ({ // 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성 // "id(를) 입력하세요" 같은 잘못된 기본값 방지 const defaultPlaceholder = field.placeholder || `${field.label || field.name}`; - + const commonProps = { value: value || "", disabled: isReadonly, @@ -405,21 +416,21 @@ export const RepeaterInput: React.FC = ({ const item = items[itemIndex]; const calculatedValue = calculateValue(field.formula, item); const formattedValue = formatNumber(calculatedValue, field.numberFormat); - + 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, @@ -428,12 +439,12 @@ export const RepeaterInput: React.FC = ({ displayLabel, displayColor, }); - + // 색상이 "none"이면 일반 텍스트로 표시 if (displayColor === "none") { return {displayLabel}; } - + return ( = ({ const option = field.options.find((opt) => opt.value === value); return {option?.label || value}; } - + // 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드) const mapping = categoryMappings[field.name]; if (mapping && value) { @@ -480,7 +491,7 @@ export const RepeaterInput: React.FC = ({ return {categoryData.label}; } } - + // 일반 텍스트 return {value || "-"}; } @@ -555,12 +566,12 @@ export const RepeaterInput: React.FC = ({ 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 (
@@ -576,7 +587,7 @@ export const RepeaterInput: React.FC = ({
); } - + return ( = ({ // 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성) 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] = { @@ -657,9 +668,9 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, @@ -669,29 +680,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)); - + if (fieldsToLoadFromJoinedTable.length > 0) { // 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] = { @@ -699,9 +710,9 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 394e15c2..4b3a5de1 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -802,6 +802,7 @@ export class ButtonActionExecutor { } } + // 🆕 배열이 비어있지 않거나, 하나라도 항목이 있으면 처리 if (Array.isArray(parsedValue) && parsedValue.length > 0) { const firstItem = parsedValue[0]; const deletedItemIds = firstItem?._deletedItemIds; @@ -811,6 +812,7 @@ export class ButtonActionExecutor { firstItemKeys: firstItem ? Object.keys(firstItem) : [], deletedItemIds, targetTable, + isDeleteOnly: firstItem?._deleteOnly, }); if (deletedItemIds && deletedItemIds.length > 0 && targetTable) { @@ -868,6 +870,12 @@ export class ButtonActionExecutor { // _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리) if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue; + // 🆕 _deleteOnly 플래그가 있으면 INSERT/UPDATE 건너뛰기 (삭제 전용) + if (firstItem?._deleteOnly) { + console.log(`⏭️ [handleSave] 삭제 전용 데이터 - INSERT/UPDATE 스킵: ${fieldKey}`); + continue; + } + console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey} → ${repeaterTargetTable}`, { itemCount: parsedData.length, }); @@ -2839,10 +2847,7 @@ export class ButtonActionExecutor { * EditModal 등 외부에서도 호출 가능하도록 public으로 변경 * 다중 제어 순차 실행 지원 */ - public static async executeAfterSaveControl( - config: ButtonActionConfig, - context: ButtonActionContext, - ): Promise { + public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { console.log("🎯 저장 후 제어 실행:", { enableDataflowControl: config.enableDataflowControl, dataflowConfig: config.dataflowConfig, @@ -4285,7 +4290,7 @@ export class ButtonActionExecutor { // 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정) const isTrackingActive = !!this.trackingIntervalId; - + if (!isTrackingActive) { // 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원) console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행"); @@ -4301,25 +4306,26 @@ export class ButtonActionExecutor { let dbDeparture: string | null = null; let dbArrival: string | null = null; let dbVehicleId: string | null = null; - + const userId = context.userId || this.trackingUserId; if (userId) { try { const { apiClient } = await import("@/lib/api/client"); - const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles"; + const statusTableName = + config.trackingStatusTableName || + this.trackingConfig?.trackingStatusTableName || + context.tableName || + "vehicles"; const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id"; - + // DB에서 현재 차량 정보 조회 - const vehicleResponse = await apiClient.post( - `/table-management/tables/${statusTableName}/data`, - { - page: 1, - size: 1, - search: { [keyField]: userId }, - autoFilter: true, - }, - ); - + const vehicleResponse = await apiClient.post(`/table-management/tables/${statusTableName}/data`, { + page: 1, + size: 1, + search: { [keyField]: userId }, + autoFilter: true, + }); + const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0]; if (vehicleData) { dbDeparture = vehicleData.departure || null; @@ -4335,14 +4341,18 @@ export class ButtonActionExecutor { // 마지막 위치 저장 (추적 중이었던 경우에만) if (isTrackingActive) { // DB 값 우선, 없으면 formData 사용 - const departure = dbDeparture || - this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; - const arrival = dbArrival || - this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; + const departure = + dbDeparture || + this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || + null; + const arrival = + dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null; - const vehicleId = dbVehicleId || - this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; + const vehicleId = + dbVehicleId || + this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || + null; await this.saveLocationToHistory( tripId,