버튼 수정

This commit is contained in:
dohyeons 2025-12-16 14:44:35 +09:00
parent eb56aec0a7
commit aacedc004c
2 changed files with 138 additions and 117 deletions

View File

@ -86,10 +86,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 접힌 상태 관리 (각 항목별)
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
const initialCalcDoneRef = useRef(false);
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
const deletedItemIdsRef = useRef<string[]>([]);
@ -121,45 +121,45 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
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<RepeaterInputProps> = ({
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<RepeaterInputProps> = ({
// 🆕 삭제된 항목 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<RepeaterInputProps> = ({
...newItems[itemIndex],
[fieldName]: value,
};
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
const calculatedFields = fields.filter((f) => f.type === "calculated");
calculatedFields.forEach((calcField) => {
@ -234,7 +245,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
newItems[itemIndex][calcField.name] = calculatedValue;
}
});
setItems(newItems);
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
itemIndex,
@ -247,8 +258,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 🆕 삭제된 항목 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<RepeaterInputProps> = ({
*/
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<RepeaterInputProps> = ({
default:
result = value1;
}
return result;
};
@ -361,27 +372,27 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
*/
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<RepeaterInputProps> = ({
// 🆕 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<RepeaterInputProps> = ({
const item = items[itemIndex];
const calculatedValue = calculateValue(field.formula, item);
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
}
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
if (field.type === "category") {
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
// 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<RepeaterInputProps> = ({
displayLabel,
displayColor,
});
// 색상이 "none"이면 일반 텍스트로 표시
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
@ -455,7 +466,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const option = field.options.find((opt) => opt.value === value);
return <span className="text-sm">{option?.label || value}</span>;
}
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
const mapping = categoryMappings[field.name];
if (mapping && value) {
@ -480,7 +491,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
return <span className="text-foreground text-sm">{categoryData.label}</span>;
}
}
// 일반 텍스트
return <span className="text-foreground text-sm">{value || "-"}</span>;
}
@ -555,12 +566,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
const numValue = parseFloat(value) || 0;
const formattedDisplay = formatNumber(numValue, field.numberFormat);
// 읽기 전용이면 포맷팅된 텍스트만 표시
if (isReadonly) {
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
}
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
return (
<div className="relative min-w-[80px]">
@ -576,7 +587,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</div>
);
}
return (
<Input
{...commonProps}
@ -627,29 +638,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 카테고리 타입 필드 + 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<string, { label: string; color: string }> = {};
response.data.data.forEach((item: any) => {
const key = String(item.valueCode);
mapping[key] = {
@ -657,9 +668,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
color: item.color || "#64748b",
};
});
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings((prev) => ({
...prev,
[columnName]: mapping,
@ -669,29 +680,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
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<string, { label: string; color: string }> = {};
response.data.data.forEach((item: any) => {
const key = String(item.valueCode);
mapping[key] = {
@ -699,9 +710,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
color: item.color || "#64748b",
};
});
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings((prev) => ({
...prev,
[columnName]: mapping,

View File

@ -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<void> {
public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<void> {
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,