저장 버튼을 누르기 전 저장 x

This commit is contained in:
dohyeons 2025-10-20 11:26:50 +09:00
parent e2d99aef40
commit c5a4b0b10c
3 changed files with 223 additions and 181 deletions

View File

@ -153,18 +153,35 @@ export class YardLayoutService {
`;
const pool = getPool();
// NaN 방지를 위한 안전한 변환 함수
const safeParseInt = (
value: any,
defaultValue: number | null = null
): number | null => {
if (!value && value !== 0) return defaultValue;
const parsed = parseInt(String(value), 10);
return isNaN(parsed) ? defaultValue : parsed;
};
const safeParseFloat = (value: any, defaultValue: number): number => {
if (!value && value !== 0) return defaultValue;
const parsed = parseFloat(String(value));
return isNaN(parsed) ? defaultValue : parsed;
};
const result = await pool.query(query, [
layoutId,
data.material_code || null,
data.material_name || null,
data.quantity ? parseInt(String(data.quantity), 10) : null,
safeParseInt(data.quantity, null),
data.unit || null,
data.position_x ? parseFloat(String(data.position_x)) : 0,
data.position_y ? parseFloat(String(data.position_y)) : 0,
data.position_z ? parseFloat(String(data.position_z)) : 0,
data.size_x ? parseFloat(String(data.size_x)) : 5,
data.size_y ? parseFloat(String(data.size_y)) : 5,
data.size_z ? parseFloat(String(data.size_z)) : 5,
safeParseFloat(data.position_x, 0),
safeParseFloat(data.position_y, 0),
safeParseFloat(data.position_z, 0),
safeParseFloat(data.size_x, 5),
safeParseFloat(data.size_y, 5),
safeParseFloat(data.size_z, 5),
data.color || "#9ca3af", // 미설정 시 회색
data.data_source_type || null,
data.data_source_config ? JSON.stringify(data.data_source_config) : null,
@ -201,23 +218,31 @@ export class YardLayoutService {
`;
const pool = getPool();
// NaN 방지를 위한 안전한 변환 함수
const safeParseInt = (value: any): number | null => {
if (value === null || value === undefined) return null;
const parsed = parseInt(String(value), 10);
return isNaN(parsed) ? null : parsed;
};
const safeParseFloat = (value: any): number | null => {
if (value === null || value === undefined) return null;
const parsed = parseFloat(String(value));
return isNaN(parsed) ? null : parsed;
};
const result = await pool.query(query, [
data.material_code !== undefined ? data.material_code : null,
data.material_name !== undefined ? data.material_name : null,
data.quantity !== undefined ? parseInt(String(data.quantity), 10) : null,
data.quantity !== undefined ? safeParseInt(data.quantity) : null,
data.unit !== undefined ? data.unit : null,
data.position_x !== undefined
? parseFloat(String(data.position_x))
: null,
data.position_y !== undefined
? parseFloat(String(data.position_y))
: null,
data.position_z !== undefined
? parseFloat(String(data.position_z))
: null,
data.size_x !== undefined ? parseFloat(String(data.size_x)) : null,
data.size_y !== undefined ? parseFloat(String(data.size_y)) : null,
data.size_z !== undefined ? parseFloat(String(data.size_z)) : null,
data.position_x !== undefined ? safeParseFloat(data.position_x) : null,
data.position_y !== undefined ? safeParseFloat(data.position_y) : null,
data.position_z !== undefined ? safeParseFloat(data.position_z) : null,
data.size_x !== undefined ? safeParseFloat(data.size_x) : null,
data.size_y !== undefined ? safeParseFloat(data.size_y) : null,
data.size_z !== undefined ? safeParseFloat(data.size_z) : null,
data.color !== undefined ? data.color : null,
data.data_source_type !== undefined ? data.data_source_type : null,
data.data_source_config !== undefined

View File

@ -31,11 +31,14 @@ interface YardEditorProps {
export default function YardEditor({ layout, onBack }: YardEditorProps) {
const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showConfigPanel, setShowConfigPanel] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 미저장 변경사항 추적
const [nextPlacementId, setNextPlacementId] = useState(-1); // 임시 ID (음수 사용)
// 배치 목록 로드
useEffect(() => {
@ -44,7 +47,9 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
setIsLoading(true);
const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id);
if (response.success) {
setPlacements(response.data as YardPlacement[]);
const loadedData = response.data as YardPlacement[];
setPlacements(loadedData);
setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사
}
} catch (error) {
console.error("배치 목록 로드 실패:", error);
@ -57,36 +62,34 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
loadPlacements();
}, [layout.id]);
// 빈 요소 추가
const handleAddElement = async () => {
try {
const newPlacementData = {
position_x: 0,
position_y: 2.5, // size_y의 절반 (5 / 2 = 2.5)
position_z: 0,
size_x: 5,
size_y: 5,
size_z: 5,
color: "#9ca3af", // 회색 (미설정 상태)
};
// 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영)
const handleAddElement = () => {
const newPlacement: YardPlacement = {
id: nextPlacementId, // 임시 음수 ID
yard_layout_id: layout.id,
material_code: null,
material_name: null,
quantity: null,
unit: null,
position_x: 0,
position_y: 2.5,
position_z: 0,
size_x: 5,
size_y: 5,
size_z: 5,
color: "#9ca3af",
data_source_type: null,
data_source_config: null,
data_binding: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
console.log("요소 추가 요청:", { layoutId: layout.id, data: newPlacementData });
const response = await yardLayoutApi.addMaterialPlacement(layout.id, newPlacementData);
console.log("요소 추가 응답:", response);
if (response.success) {
const newPlacement = response.data as YardPlacement;
setPlacements((prev) => [...prev, newPlacement]);
setSelectedPlacement(newPlacement);
setShowConfigPanel(true); // 자동으로 설정 패널 표시
} else {
console.error("요소 추가 실패 (응답):", response);
setError(response.message || "요소 추가에 실패했습니다.");
}
} catch (error) {
console.error("요소 추가 실패 (예외):", error);
setError(`요소 추가에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`);
}
setPlacements((prev) => [...prev, newPlacement]);
setSelectedPlacement(newPlacement);
setShowConfigPanel(true);
setHasUnsavedChanges(true);
setNextPlacementId((prev) => prev - 1); // 다음 임시 ID
};
// 요소 선택 (3D 캔버스 또는 목록에서)
@ -101,28 +104,21 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
setShowConfigPanel(true);
};
// 요소 삭제
const handleDeletePlacement = async (placementId: number) => {
// 요소 삭제 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
const handleDeletePlacement = (placementId: number) => {
if (!confirm("이 요소를 삭제하시겠습니까?")) {
return;
}
try {
const response = await yardLayoutApi.removePlacement(placementId);
if (response.success) {
setPlacements((prev) => prev.filter((p) => p.id !== placementId));
if (selectedPlacement?.id === placementId) {
setSelectedPlacement(null);
setShowConfigPanel(false);
}
}
} catch (error) {
console.error("요소 삭제 실패:", error);
setError("요소 삭제에 실패했습니다.");
setPlacements((prev) => prev.filter((p) => p.id !== placementId));
if (selectedPlacement?.id === placementId) {
setSelectedPlacement(null);
setShowConfigPanel(false);
}
setHasUnsavedChanges(true);
};
// 자재 드래그 (3D 캔버스에서)
// 자재 드래그 (3D 캔버스에서, 로컬 상태에만 반영)
const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => {
const updatedPosition = {
position_x: Math.round(position.x * 2) / 2,
@ -135,27 +131,83 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
if (selectedPlacement?.id === id) {
setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedPosition } : null));
}
setHasUnsavedChanges(true);
};
// 저장
// 전체 저장 (신규/수정/삭제 모두 처리)
const handleSave = async () => {
setIsSaving(true);
try {
const response = await yardLayoutApi.batchUpdatePlacements(
layout.id,
placements.map((p) => ({
id: p.id,
position_x: p.position_x,
position_y: p.position_y,
position_z: p.position_z,
size_x: p.size_x,
size_y: p.size_y,
size_z: p.size_z,
color: p.color,
})),
);
// 1. 삭제된 요소 처리 (원본에는 있지만 현재 state에 없는 경우)
const deletedIds = originalPlacements
.filter((orig) => !placements.find((p) => p.id === orig.id))
.map((p) => p.id);
for (const id of deletedIds) {
await yardLayoutApi.removePlacement(id);
}
// 2. 신규 추가 요소 처리 (ID가 음수인 경우)
const newPlacements = placements.filter((p) => p.id < 0);
const addedPlacements: YardPlacement[] = [];
for (const newPlacement of newPlacements) {
const response = await yardLayoutApi.addMaterialPlacement(layout.id, {
material_code: newPlacement.material_code,
material_name: newPlacement.material_name,
quantity: newPlacement.quantity,
unit: newPlacement.unit,
position_x: newPlacement.position_x,
position_y: newPlacement.position_y,
position_z: newPlacement.position_z,
size_x: newPlacement.size_x,
size_y: newPlacement.size_y,
size_z: newPlacement.size_z,
color: newPlacement.color,
data_source_type: newPlacement.data_source_type,
data_source_config: newPlacement.data_source_config,
data_binding: newPlacement.data_binding,
memo: newPlacement.memo,
});
if (response.success) {
addedPlacements.push(response.data as YardPlacement);
}
}
// 3. 기존 요소 수정 처리 (ID가 양수인 경우)
const existingPlacements = placements.filter((p) => p.id > 0);
for (const placement of existingPlacements) {
await yardLayoutApi.updatePlacement(placement.id, {
material_code: placement.material_code,
material_name: placement.material_name,
quantity: placement.quantity,
unit: placement.unit,
position_x: placement.position_x,
position_y: placement.position_y,
position_z: placement.position_z,
size_x: placement.size_x,
size_y: placement.size_y,
size_z: placement.size_z,
color: placement.color,
data_source_type: placement.data_source_type,
data_source_config: placement.data_source_config,
data_binding: placement.data_binding,
memo: placement.memo,
});
}
// 4. 저장 성공 후 데이터 다시 로드
const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id);
if (response.success) {
const loadedData = response.data as YardPlacement[];
setPlacements(loadedData);
setOriginalPlacements(JSON.parse(JSON.stringify(loadedData)));
setHasUnsavedChanges(false);
setSelectedPlacement(null);
setShowConfigPanel(false);
alert("저장되었습니다");
}
} catch (error) {
@ -166,45 +218,24 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
}
};
// 설정 패널에서 저장
const handleSaveConfig = async (updatedData: Partial<YardPlacement>) => {
// 설정 패널에서 데이터 업데이트 (로컬 상태에만 반영, 서버 저장 안함)
const handleSaveConfig = (updatedData: Partial<YardPlacement>) => {
if (!selectedPlacement) return;
try {
const response = await yardLayoutApi.updatePlacement(selectedPlacement.id, updatedData);
if (response.success) {
const updated = response.data as YardPlacement;
// 로컬 상태만 업데이트
setPlacements((prev) =>
prev.map((p) => {
if (p.id === selectedPlacement.id) {
return { ...p, ...updatedData };
}
return p;
}),
);
// 현재 위치 정보를 유지하면서 업데이트
setPlacements((prev) =>
prev.map((p) => {
if (p.id === updated.id) {
// 현재 프론트엔드 상태의 위치를 유지
return {
...updated,
position_x: p.position_x,
position_y: p.position_y,
position_z: p.position_z,
};
}
return p;
}),
);
// 선택된 요소도 동일하게 업데이트
setSelectedPlacement({
...updated,
position_x: selectedPlacement.position_x,
position_y: selectedPlacement.position_y,
position_z: selectedPlacement.position_z,
});
setShowConfigPanel(false);
}
} catch (error) {
console.error("설정 저장 실패:", error);
setError("설정 저장에 실패했습니다.");
}
// 선택된 요소도 업데이트
setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedData } : null));
setShowConfigPanel(false);
setHasUnsavedChanges(true);
};
// 요소가 설정되었는지 확인
@ -227,19 +258,22 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
</div>
</div>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
<div className="flex items-center gap-2">
{hasUnsavedChanges && <span className="text-sm font-medium text-orange-600"> </span>}
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
{/* 에러 메시지 */}

View File

@ -18,7 +18,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface YardElementConfigPanelProps {
placement: YardPlacement;
onSave: (updatedData: Partial<YardPlacement>) => Promise<void>;
onSave: (updatedData: Partial<YardPlacement>) => void; // Promise 제거 (즉시 로컬 상태 업데이트)
onCancel: () => void;
}
@ -52,9 +52,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
const [sizeY, setSizeY] = useState(placement.size_y);
const [sizeZ, setSizeZ] = useState(placement.size_z);
// 에러 및 로딩
// 에러
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// 외부 DB 커넥션 목록 로드
useEffect(() => {
@ -180,8 +179,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
}
};
// 저장
const handleSave = async () => {
// 적용 (로컬 상태만 업데이트, 서버 저장은 나중에 일괄 처리)
const handleApply = () => {
// 검증
if (!queryResult) {
setError("먼저 데이터를 조회해주세요.");
@ -203,49 +202,40 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
return;
}
setIsSaving(true);
const selectedRow = queryResult.rows[selectedRowIndex];
const materialName = selectedRow[materialNameField];
const quantity = selectedRow[quantityField];
try {
const selectedRow = queryResult.rows[selectedRowIndex];
const materialName = selectedRow[materialNameField];
const quantity = selectedRow[quantityField];
const dataSourceConfig: YardDataSourceConfig = {
type: dataSourceType,
query: dataSourceType !== "rest_api" ? query : undefined,
connectionId: dataSourceType === "external_db" ? parseInt(externalConnectionId) : undefined,
url: dataSourceType === "rest_api" ? apiUrl : undefined,
method: dataSourceType === "rest_api" ? apiMethod : undefined,
dataPath: dataSourceType === "rest_api" && apiDataPath ? apiDataPath : undefined,
};
const dataSourceConfig: YardDataSourceConfig = {
type: dataSourceType,
query: dataSourceType !== "rest_api" ? query : undefined,
connectionId: dataSourceType === "external_db" ? parseInt(externalConnectionId) : undefined,
url: dataSourceType === "rest_api" ? apiUrl : undefined,
method: dataSourceType === "rest_api" ? apiMethod : undefined,
dataPath: dataSourceType === "rest_api" && apiDataPath ? apiDataPath : undefined,
};
const dataBinding: YardDataBinding = {
selectedRowIndex,
materialNameField,
quantityField,
unit: unit.trim(),
};
const dataBinding: YardDataBinding = {
selectedRowIndex,
materialNameField,
quantityField,
unit: unit.trim(),
};
const updatedData: Partial<YardPlacement> = {
material_name: String(materialName),
quantity: Number(quantity),
unit: unit.trim(),
color,
size_x: sizeX,
size_y: sizeY,
size_z: sizeZ,
data_source_type: dataSourceType,
data_source_config: dataSourceConfig,
data_binding: dataBinding,
};
const updatedData: Partial<YardPlacement> = {
material_name: String(materialName),
quantity: Number(quantity),
unit: unit.trim(),
color,
size_x: sizeX,
size_y: sizeY,
size_z: sizeZ,
data_source_type: dataSourceType,
data_source_config: dataSourceConfig,
data_binding: dataBinding,
};
await onSave(updatedData);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "저장 중 오류가 발생했습니다.";
setError(errorMessage);
} finally {
setIsSaving(false);
}
onSave(updatedData); // 동기적으로 즉시 로컬 상태 업데이트
};
return (
@ -537,15 +527,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
<Button variant="outline" onClick={onCancel} className="flex-1">
</Button>
<Button onClick={handleSave} disabled={isSaving || !queryResult} className="flex-1">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"저장"
)}
<Button onClick={handleApply} disabled={!queryResult} className="flex-1">
</Button>
</div>
</div>