dev #799

Merged
ysCha merged 20 commits from dev into prd-deploy 2026-04-28 18:07:08 +09:00
16 changed files with 1469 additions and 256 deletions

View File

@ -2,6 +2,8 @@
import { useEffect, useState, useContext, useRef } from 'react'
import { useMessage } from '@/hooks/useMessage'
import { useAxios } from '@/hooks/useAxios'
import { useRecoilValue } from 'recoil'
import { globalLocaleStore } from '@/store/localeAtom'
import Select from 'react-select'
import { SessionContext } from '@/app/SessionProvider'
import { isEmptyArray, isObjectNotEmpty } from '@/util/common-utils'
@ -10,24 +12,35 @@ import { useEstimateController } from '@/hooks/floorPlan/estimate/useEstimateCon
export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
const { getMessage } = useMessage()
const { get } = useAxios()
const globalLocale = useRecoilValue(globalLocaleStore)
const { handleEstimateCopy, estimateContextState } = useEstimateController(planNo, true)
const { session } = useContext(SessionContext)
// storeLvl
const lvl = session?.storeLvl || '1'
const nextLvl = Number(lvl) + 1
const saleStoreLabel = globalLocale === 'ko'
? `${lvl}차 판매점명 / ID`
: `${lvl}次販売店名/ID`
const otherSaleStoreLabel = globalLocale === 'ko'
? `${nextLvl}차+하위 판매점명 / ID`
: `${nextLvl}次下位販売店名/ID`
const [saleStoreList, setSaleStoreList] = useState([]) //
const [favoriteStoreList, setFavoriteStoreList] = useState([]) //
const [showSaleStoreList, setShowSaleStoreList] = useState([]) //
const [otherSaleStoreList, setOtherSaleStoreList] = useState([])
const [originOtherSaleStoreList, setOriginOtherSaleStoreList] = useState([])
const [saleStoreId, setSaleStoreId] = useState('') // 1
const [otherSaleStoreId, setOtherSaleStoreId] = useState('') // 1
const [saleStoreId, setSaleStoreId] = useState('') //
const [saleStoreName, setSaleStoreName] = useState('') //
const [otherSaleStoreId, setOtherSaleStoreId] = useState('') //
const [sendPlanNo, setSendPlanNo] = useState('1')
const [copyReceiveUser, setCopyReceiveUser] = useState('')
const ref = useRef() //2
const ref = useRef() //
useEffect(() => {
let url
@ -40,8 +53,9 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
if (session.storeLvl === '1') {
url = `/api/object/saleStore/${session?.storeId}/list?firstFlg=1&userId=${session?.userId}`
} else {
//T01 or 1
// url = `/api/object/saleStore/${session?.storeId}/list?firstFlg=1&userId=${session?.userId}`
// 2+ : ,
setSaleStoreId(session?.storeId)
url = `/api/object/saleStore/${session?.storeId}/childList?storeLvl=${session?.storeLvl}&userId=${session?.userId}`
}
}
@ -91,7 +105,14 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
setOtherSaleStoreList(otherList)
} else {
//T01 or 1
// 2+ : ,
const myself = res.find((row) => row.saleStoreId === session?.storeId)
if (myself) {
setSaleStoreName(myself.saleStoreName)
}
otherList = res.filter((row) => row.saleStoreId !== session?.storeId)
setOtherSaleStoreList(otherList)
setOriginOtherSaleStoreList(otherList)
}
}
}
@ -119,7 +140,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
}
}
// 1
//
const onSelectionChange = (key) => {
if (isObjectNotEmpty(key)) {
if (key.saleStoreId === saleStoreId) {
@ -154,7 +175,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
}
}
// 2
//
const onSelectionChange2 = (key) => {
if (isObjectNotEmpty(key)) {
if (key.saleStoreId === otherSaleStoreId) {
@ -169,7 +190,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
}
}
//2
//
const handleClear = () => {
if (ref.current) {
ref.current.clearValue()
@ -198,7 +219,7 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
<div className="estimate-copy-info-wrap">
<div className="estimate-copy-info-item">
<div className="estimate-copy-info-tit">
{getMessage('estimate.detail.estimateCopyPopup.label.saleStoreId')} <span className="red">*</span>
{saleStoreLabel} <span className="red">*</span>
</div>
{session.storeId === 'T01' && (
<div className="estimate-copy-info-box">
@ -247,9 +268,17 @@ export default function EstimateCopyPop({ planNo, setEstimateCopyPopupOpen }) {
<div className="estimate-copy-id">{saleStoreId}</div>
</div>
)}
{session.storeId !== 'T01' && session.storeLvl !== '1' && (
<div className="estimate-copy-info-box">
<div className="estimate-copy-sel">
<input type="text" className="input-light" value={saleStoreName || ''} disabled />
</div>
<div className="estimate-copy-id">{saleStoreId}</div>
</div>
)}
</div>
<div className="estimate-copy-info-item">
<div className="estimate-copy-info-tit">{getMessage('estimate.detail.estimateCopyPopup.label.otherSaleStoreId')}</div>
<div className="estimate-copy-info-tit">{otherSaleStoreLabel}</div>
<div className="estimate-copy-info-box">
<div className="estimate-copy-sel">
<Select

View File

@ -6,7 +6,88 @@ import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeo
import * as turf from '@turf/turf'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js'
import { drawSkeletonRidgeRoof } from '@/util/skeleton-utils'
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
// ========================================================================
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
// ----------------------------------------------------------------------
// 별도 util 파일을 두지 않고 QPolygon.js 안에 인라인.
// dev/prd 에선 가드가 즉시 false → 코드 미실행. 외부 의존성 없음.
// 라인 자체의 stroke/fill 은 절대 손대지 않고 fabric.Text 만 추가.
// ========================================================================
const DEBUG_LABEL_NAME = '__debugLabel'
function __isDebugLabelsEnabled() {
return process.env.NEXT_PUBLIC_RUN_MODE === 'local'
}
function __classifyLineForLabel(obj) {
const name = obj.name
const lineName = obj.lineName
const type = obj.attributes?.type
if (name === 'baseLine') return 'B'
if (name === 'outerLine' || name === 'eaves' || lineName === 'roofLine') return 'R'
if (name === LINE_TYPE.SUBLINE.HIP || lineName === LINE_TYPE.SUBLINE.HIP) return 'H'
if (name === LINE_TYPE.SUBLINE.RIDGE || lineName === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
if (name === LINE_TYPE.SUBLINE.VALLEY || lineName === LINE_TYPE.SUBLINE.VALLEY) return 'V'
if (name === LINE_TYPE.SUBLINE.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G'
if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG'
if (
type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE
) return 'E'
if (
typeof obj.x1 === 'number' &&
typeof obj.y1 === 'number' &&
typeof obj.x2 === 'number' &&
typeof obj.y2 === 'number'
) return 'SK'
return null
}
function __attachDebugLabels(canvas, parentId) {
if (!__isDebugLabelsEnabled()) return
if (!canvas || !parentId) return
const counters = {}
const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME)
objects.forEach((obj) => {
const prefix = __classifyLineForLabel(obj)
if (!prefix) return
counters[prefix] = (counters[prefix] || 0) + 1
const label = `${prefix}-${counters[prefix]}`
const mx = (obj.x1 + obj.x2) / 2
const my = (obj.y1 + obj.y2) / 2
const text = new fabric.Text(label, {
left: mx,
top: my,
originX: 'center',
originY: 'center',
fontSize: 12,
fill: '#000',
fontFamily: 'monospace',
fontWeight: 'bold',
backgroundColor: 'rgba(255,255,0,0.85)',
selectable: false,
evented: false,
hasControls: false,
hasBorders: false,
name: DEBUG_LABEL_NAME,
parentId: parentId,
excludeFromExport: true,
})
canvas.add(text)
text.bringToFront()
})
canvas.renderAll()
}
export const QPolygon = fabric.util.createClass(fabric.Polygon, {
type: 'QPolygon',
@ -380,8 +461,26 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
/**
* 보조선 그리기
* @param settingModalFirstOptions
* @param forceRedraw - true verifyMoveBoundary 'crossed' 가드를 우회하고 강제로 재빌드.
* (: 오버 이동 상태에서 wall.baseLines/roof.lines 그대로 두고 SK 재생성하고 싶을 )
*/
drawHelpLine(settingModalFirstOptions) {
drawHelpLine(settingModalFirstOptions, forceRedraw = false) {
// [정책] 오버 이동(verifyMoveBoundary='crossed') 처리:
// - roofLine 은 그대로, wall.baseLines 는 이미 오버된 좌표로 mutate 된 상태.
// - 보조선 싹 지우고 '오버된 wall.baseLines 좌표 그대로' SK 재빌드.
// - 일반 경로(drawSkeletonRidgeRoof) 는 45도 대각 확장/roof 경계 클램핑이 들어가
// 오버 polygon 이 왜곡 → 용마루 지붕 케이스는 전용 함수(drawSkeletonRidgeRoofFromBaseLines)로 분기.
// - forceRedraw 인자는 명시적 수동 호출 의도 표현 용도로만 유지.
let __verdict = 'unknown'
try {
__verdict = verifyMoveBoundary(this.id, this.canvas)
if (__verdict === 'crossed') {
console.warn(`[drawHelpLine] 경계 넘음(crossed) → 오버 전용 경로(drawSkeletonRidgeRoofFromBaseLines) 사용 (forceRedraw=${forceRedraw})`)
}
} catch (e) {
console.warn('[drawHelpLine] verifyMoveBoundary 예외 → 기존 흐름 진행:', e)
}
/* innerLines 초기화 */
this.canvas
.getObjects()
@ -479,8 +578,12 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
if (types.every((type) => type === LINE_TYPE.WALLLINE.EAVES)) {
// 용마루 -- straight-skeleton
// console.log('용마루 지붕')
drawSkeletonRidgeRoof(this.id, this.canvas, textMode)
// 오버(crossed) 이동 시 wall.baseLines 좌표 그대로 사용하는 전용 경로로 분기.
if (__verdict === 'crossed') {
drawSkeletonRidgeRoofFromBaseLines(this.id, this.canvas, textMode)
} else {
drawSkeletonRidgeRoof(this.id, this.canvas, textMode)
}
} else if (isGableRoof(types)) {
// A형, B형 박공 지붕
// console.log('패턴 지붕')
@ -492,6 +595,22 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
// console.log('변별로 설정')
drawRoofByAttribute(this.id, this.canvas, textMode)
}
// [DEBUG] 로컬(NEXT_PUBLIC_RUN_MODE==='local') 에서만 라벨 부착. 그 외 즉시 return.
__attachDebugLabels(this.canvas, this.id)
},
/**
* 오버 이동(crossed) 상태여도 wall.baseLines / roof.lines / outerLine / lengthText 그대로 두고
* 보조선(innerLines: eaveHelpLine, HIP, extensionLine, SK ridge ) 제거 SK 재빌드.
*
* drawHelpLine 진입부 verifyMoveBoundary 가드만 우회하는 wrapper. 이후 흐름은 동일.
* - 초기화 필터(line 405-411) 이미 wall/roof/baseLine/outerLine/lengthText 제외하고 있어
* 해당 객체들은 보존됨.
* - 사용자가 오버된 상태에서 "SK 만 다시 그려보기" 의도로 명시적으로 호출할 사용.
*/
redrawHelpLineForced(settingModalFirstOptions) {
return this.drawHelpLine(settingModalFirstOptions, true)
},
addLengthText() {

View File

@ -455,6 +455,7 @@ export default function CanvasMenu(props) {
if (createUser && tempFlg && lockFlg) {
if (createUser === 'T01' && sessionState.storeId !== 'T01') {
setAllButtonStyles('none')
setCopyButtonStyle('')
} else {
handleButtonStyles(tempFlg, lockFlg, docNo)
}
@ -516,6 +517,7 @@ export default function CanvasMenu(props) {
if (createUser && tempFlg && lockFlg) {
if (createUser === 'T01' && sessionState.storeId !== 'T01') {
setAllButtonStyles('none')
setCopyButtonStyle('')
} else {
setEstimateContextState({
tempFlg: estimateRecoilState.tempFlg,
@ -693,7 +695,7 @@ export default function CanvasMenu(props) {
<span className="name">{getMessage('plan.menu.estimate.reset')}</span>
</button>
{estimateRecoilState?.docNo !== null && (sessionState.storeId === 'T01' || sessionState.storeLvl === '1') && (
{estimateRecoilState?.docNo !== null && (sessionState.storeId === 'T01' || sessionState.storeLvl) && (
<button
type="button"
style={{ display: copyButtonStyle }}

View File

@ -58,7 +58,6 @@ export default function CircuitTrestleSetting({ id }) {
const { setModuleStatisticsData } = useCircuitTrestle()
const { handleCanvasToPng } = useImgLoader()
const moduleSelectionData = useRecoilValue(moduleSelectionDataState)
const passivityCircuitAllocationRef = useRef()
const { setIsGlobalLoading } = useContext(QcastContext)
const originCanvasViewPortTransform = useRef([])
@ -687,7 +686,18 @@ export default function CircuitTrestleSetting({ id }) {
.map((obj) => {
obj.pcses = getStepUpListData()
})
await capture(1)
// ( )
try {
await capture(1)
} catch (e) {
console.log('🚀 ~ capture(1) error:', e)
setIsGlobalLoading(false)
swalFire({
text: getMessage('common.message.send.error'),
icon: 'error',
})
return
}
// result=null
@ -821,9 +831,23 @@ export default function CircuitTrestleSetting({ id }) {
//
const getStepUpListData = () => {
// PCS (Cross Mix DC3 )
const pcsModuleTpCds = {}
canvas
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.circuit && obj.pcsItemId)
.forEach((module) => {
const pcsInstanceId = module.circuit?.circuitInfo?.id
const tpCd = module.moduleInfo?.moduleTpCd
if (!pcsInstanceId || !tpCd) return
if (!pcsModuleTpCds[pcsInstanceId]) pcsModuleTpCds[pcsInstanceId] = new Set()
pcsModuleTpCds[pcsInstanceId].add(tpCd)
})
const pcs = []
console.log(stepUpListData)
stepUpListData[0].pcsItemList.map((item, index) => {
const targetTpCds = pcsModuleTpCds[selectedModels[index]?.id]
return item.serQtyList
.filter((serQty) => serQty.selected && serQty.paralQty > 0)
.forEach((serQty) => {
@ -833,9 +857,19 @@ export default function CircuitTrestleSetting({ id }) {
pcsItemId: item.itemId,
pscOptCd: getPcsOptCd(index),
paralQty: serQty.paralQty,
// : itemId 1,
// : PCS itemId dedupe
// (Cross Mix conn / · Mix )
connections: item.connList?.length
? [...new Map(item.connList.map((conn) => [conn.itemId, { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt }])).values()]
? [
...new Map(
item.connList
.filter((conn) => !targetTpCds || !conn.moduleTpCd || targetTpCds.has(conn.moduleTpCd))
.map((conn) => [
conn.itemId,
{ connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt, moduleTpCd: conn.moduleTpCd },
]),
).values(),
]
: [],
})
// return {
@ -1030,7 +1064,7 @@ export default function CircuitTrestleSetting({ id }) {
</div>
{tabNum === 1 && allocationType === ALLOCATION_TYPE.AUTO && <PowerConditionalSelect {...powerConditionalSelectProps} />}
{tabNum === 1 && allocationType === ALLOCATION_TYPE.PASSIVITY && (
<PassivityCircuitAllocation {...passivityProps} ref={passivityCircuitAllocationRef} isFold={isFold} />
<PassivityCircuitAllocation {...passivityProps} isFold={isFold} />
)}
{tabNum === 2 && <StepUp {...stepUpProps} onInitialize={handleStepUpInitialize} />}
</div>

View File

@ -134,9 +134,7 @@ export default function StepUp(props) {
const moduleIds = targetSurface.modules.map((module) => module.id)
/** 기존 모듈 텍스트 삭제 */
canvas
.getObjects()
.filter((obj) => moduleIds.includes(obj.parentId))
;[...canvas.getObjects().filter((obj) => moduleIds.includes(obj.parentId))]
.forEach((text) => canvas.remove(text))
/** 새로운 모듈 회로 정보 추가 */
@ -347,6 +345,7 @@ export default function StepUp(props) {
itemId: conn.itemId ?? '',
itemNm: conn.itemNm ?? '',
vstuParalCnt: conn.vstuParalCnt ?? 0,
moduleTpCd: conn.moduleTpCd,
}))
}
@ -410,61 +409,100 @@ export default function StepUp(props) {
* 주어진 stepUpListData 기준으로 캔버스의 모든 module circuit 텍스트를 다시 그린다.
* 모든 pcsItem selected serQty 순회해 적용한다.
*/
const applyCircuitsToCanvas = (stepUpListSrc) => {
/**
* 모듈에 circuit 텍스트를 추가하는 공통 함수
*/
const addCircuitTextToModule = (module) => {
const targetModule = canvas.getObjects().find((obj) => obj.id === module.uniqueId)
if (targetModule && module.circuit !== '' && module.circuit !== null) {
const moduleCircuitText = new fabric.Text(module.circuit, {
left: targetModule.left + targetModule.width / 2,
top: targetModule.top + targetModule.height / 2,
fontFamily: circuitNumberText.fontFamily.value,
fontWeight: circuitNumberText.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
fontStyle: circuitNumberText.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
fontSize: circuitNumberText.fontSize.value,
fill: circuitNumberText.fontColor.value,
width: targetModule.width,
height: targetModule.height,
textAlign: 'center',
originX: 'center',
originY: 'center',
name: 'circuitNumber',
parentId: targetModule.id,
circuitInfo: module.pcsItemId,
visible: isDisplayCircuitNumber,
})
targetModule.circuit = moduleCircuitText
targetModule.pcsItemId = module.pcsItemId
targetModule.circuitNumber = module.circuit
canvas.add(moduleCircuitText)
}
}
const applyCircuitsToCanvas = (stepUpListSrc, mainIdx, subIdx) => {
if (!stepUpListSrc?.[0]?.pcsItemList) return
/** 모든 모듈 circuit 데이터 초기화 */
canvas
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE)
.forEach((module) => {
if (mainIdx >= 1) {
/**
* 우측 PCS (mainIdx >= 1) 클릭 :
* 해당 pcsItem selected serQty 찾아서 moduleList circuit 갱신한다.
* 좌측 PCS circuit 그대로 유지.
*/
const pcsItem = stepUpListSrc[0].pcsItemList[mainIdx]
const sel = pcsItem?.serQtyList?.find((sq) => sq.selected)
if (!sel) return
// pcsItem moduleList circuit
const moduleUniqueIds = new Set()
sel.roofSurfaceList.forEach((rs) => {
rs.moduleList.forEach((m) => moduleUniqueIds.add(m.uniqueId))
})
// circuit +
const targetModuleObjs = canvas.getObjects().filter(
(obj) => obj.name === POLYGON_TYPE.MODULE && moduleUniqueIds.has(obj.id),
)
targetModuleObjs.forEach((module) => {
module.circuit = null
module.circuitNumber = null
module.pcsItemId = null
})
/** 기존 circuit 텍스트 객체 모두 제거 */
canvas
.getObjects()
.filter((obj) => obj.name === 'circuitNumber')
.forEach((text) => canvas.remove(text))
const textsToRemove = [...canvas.getObjects().filter(
(obj) => obj.name === 'circuitNumber' && moduleUniqueIds.has(obj.parentId),
)]
textsToRemove.forEach((text) => canvas.remove(text))
stepUpListSrc[0].pcsItemList.forEach((pcsItem) => {
const sel = pcsItem.serQtyList?.find((sq) => sq.selected)
if (!sel) return
// circuit
sel.roofSurfaceList.forEach((roofSurface) => {
const targetSurface = canvas.getObjects().filter((obj) => obj.id === roofSurface.roofSurfaceId)[0]
if (!targetSurface) return
roofSurface.moduleList.forEach((module) => addCircuitTextToModule(module))
})
} else {
/**
* 좌측 PCS (mainIdx === 0) 클릭 :
* 전체 모듈 circuit 초기화 모든 pcsItem selected 적용
*/
canvas
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE)
.forEach((module) => {
module.circuit = null
module.circuitNumber = null
module.pcsItemId = null
})
roofSurface.moduleList.forEach((module) => {
const targetModule = canvas.getObjects().find((obj) => obj.id === module.uniqueId)
if (targetModule && module.circuit !== '' && module.circuit !== null) {
const moduleCircuitText = new fabric.Text(module.circuit, {
left: targetModule.left + targetModule.width / 2,
top: targetModule.top + targetModule.height / 2,
fontFamily: circuitNumberText.fontFamily.value,
fontWeight: circuitNumberText.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
fontStyle: circuitNumberText.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
fontSize: circuitNumberText.fontSize.value,
fill: circuitNumberText.fontColor.value,
width: targetModule.width,
height: targetModule.height,
textAlign: 'center',
originX: 'center',
originY: 'center',
name: 'circuitNumber',
parentId: targetModule.id,
circuitInfo: module.pcsItemId,
visible: isDisplayCircuitNumber,
})
targetModule.circuit = moduleCircuitText
targetModule.pcsItemId = module.pcsItemId
targetModule.circuitNumber = module.circuit
canvas.add(moduleCircuitText)
}
const circuitTexts = [...canvas.getObjects().filter((obj) => obj.name === 'circuitNumber')]
circuitTexts.forEach((text) => canvas.remove(text))
stepUpListSrc[0].pcsItemList.forEach((pcsItem) => {
const sel = pcsItem.serQtyList?.find((sq) => sq.selected)
if (!sel) return
sel.roofSurfaceList.forEach((roofSurface) => {
roofSurface.moduleList.forEach((module) => addCircuitTextToModule(module))
})
})
})
}
canvas.renderAll()
setModuleStatisticsData()
@ -483,8 +521,7 @@ export default function StepUp(props) {
setStepUpListData(tempStepUpListData)
/** PCS 2개 이상 또는 첫번째 PCS 선택 시에만 실행 */
const needsRefetch =
tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0
const needsRefetch = tempStepUpListData[0].pcsItemList.length > 1 && mainIdx === 0
if (needsRefetch) {
/** 파워컨디셔너 옵션 조회 요청 파라미터 */
@ -522,10 +559,10 @@ export default function StepUp(props) {
const dataArray = Array.isArray(res.data) ? res.data : [res.data]
const newStepUpListData = formatStepUpListData(dataArray)
setStepUpListData(newStepUpListData)
applyCircuitsToCanvas(newStepUpListData)
applyCircuitsToCanvas(newStepUpListData, mainIdx, subIdx)
} else {
swalFire({ text: getMessage('common.message.send.error') })
applyCircuitsToCanvas(tempStepUpListData)
applyCircuitsToCanvas(tempStepUpListData, mainIdx, subIdx)
}
})
return
@ -533,14 +570,28 @@ export default function StepUp(props) {
}
/** API 재조회가 없는 경로: 현재 tempStepUpListData 로 즉시 캔버스 갱신 */
applyCircuitsToCanvas(tempStepUpListData)
applyCircuitsToCanvas(tempStepUpListData, mainIdx, subIdx)
}
/**
* 현재 선택된 값들을 가져오는 함수 추가
*/
const getCurrentSelections = () => {
const selectedValues = stepUpListData[0].pcsItemList.forEach((item) => {
// PCS (Cross Mix DC3 )
const pcsModuleTpCds = {}
canvas
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.circuit && obj.pcsItemId)
.forEach((module) => {
const pcsInstanceId = module.circuit?.circuitInfo?.id
const tpCd = module.moduleInfo?.moduleTpCd
if (!pcsInstanceId || !tpCd) return
if (!pcsModuleTpCds[pcsInstanceId]) pcsModuleTpCds[pcsInstanceId] = new Set()
pcsModuleTpCds[pcsInstanceId].add(tpCd)
})
const selectedValues = stepUpListData[0].pcsItemList.forEach((item, index) => {
const targetTpCds = pcsModuleTpCds[selectedModels[index]?.id]
item.serQtyList.filter((serQty) => serQty.selected)
return item.serQtyList.map((serQty) => {
return {
@ -549,9 +600,23 @@ export default function StepUp(props) {
pcsItemId: serQty.itemId,
pcsOptCd: seletedOption,
paralQty: serQty.paralQty,
// : itemId 1,
// : PCS itemId dedupe
// (Cross Mix conn / · Mix )
connections: item.connList?.length
? [...new Map(item.connList.map((conn) => [conn.itemId, { connItemId: conn.itemId, connMaxParalCnt: conn.connMaxParalCnt }])).values()]
? [
...new Map(
item.connList
.filter((conn) => !targetTpCds || !conn.moduleTpCd || targetTpCds.has(conn.moduleTpCd))
.map((conn) => [
conn.itemId,
{
connItemId: conn.itemId,
connMaxParalCnt: conn.connMaxParalCnt,
moduleTpCd: conn.moduleTpCd,
},
]),
).values(),
]
: [],
}
})

View File

@ -10,6 +10,7 @@ import Angle from '@/components/floor-plan/modal/lineTypes/Angle'
import DoublePitch from '@/components/floor-plan/modal/lineTypes/DoublePitch'
import Diagonal from '@/components/floor-plan/modal/lineTypes/Diagonal'
import { usePopup } from '@/hooks/usePopup'
import { useSwal } from '@/hooks/useSwal'
import { useState } from 'react'
import Image from 'next/image'
import { v4 as uuidv4 } from 'uuid'
@ -18,6 +19,7 @@ export default function WallLineSetting(props) {
const { id } = props
const { addPopup, closePopup } = usePopup()
const { getMessage } = useMessage()
const { swalFire } = useSwal()
const [propertiesId, setPropertiesId] = useState(uuidv4())
const [useCalcPad, setUseCalcPad] = useState(false)
const {
@ -182,10 +184,11 @@ export default function WallLineSetting(props) {
<button
className="btn-frame modal act"
onClick={() => {
handleFix()
// closePopup(id)
// setShowPropertiesSettingModal(true)
swalFire({
type: 'confirm',
text: getMessage('modal.cover.outline.fix.confirm'),
confirmFn: handleFix,
})
}}
>
{getMessage('modal.cover.outline.fix')}

View File

@ -9,11 +9,13 @@ import { OUTER_LINE_TYPE } from '@/store/outerLineAtom'
import OuterLineWall from '@/components/floor-plan/modal/lineTypes/OuterLineWall'
import { usePlacementShapeDrawing } from '@/hooks/surface/usePlacementShapeDrawing'
import { usePopup } from '@/hooks/usePopup'
import { useSwal } from '@/hooks/useSwal'
import Image from 'next/image'
export default function PlacementShapeDrawing({ id, pos = { x: 50, y: 230 } }) {
const { getMessage } = useMessage()
const { closePopup } = usePopup()
const { swalFire } = useSwal()
const [buttonAct, setButtonAct] = useState(1)
const [useCalcPad, setUseCalcPad] = useState(false)
const types = [
@ -155,7 +157,16 @@ export default function PlacementShapeDrawing({ id, pos = { x: 50, y: 230 } }) {
<button className="btn-frame modal mr5" onClick={handleRollback}>
{getMessage('modal.cover.outline.rollback')}
</button>
<button className="btn-frame modal act" onClick={handleFix}>
<button
className="btn-frame modal act"
onClick={() => {
swalFire({
type: 'confirm',
text: getMessage('modal.cover.outline.fix.confirm'),
confirmFn: handleFix,
})
}}
>
{getMessage('modal.placement.surface.drawing.fix')}
</button>
</div>

View File

@ -264,6 +264,8 @@ export function useMasterController() {
*/
const getPcsAutoRecommendList = async (params = null) => {
return await post({ url: '/api/v1/master/getPcsAutoRecommendList', data: params }).then((res) => {
console.log('[moduleTpCd] getPcsAutoRecommendList req:', params)
console.log('[moduleTpCd] getPcsAutoRecommendList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) })))
return res
})
}
@ -286,6 +288,8 @@ export function useMasterController() {
*/
const getPcsVoltageChk = async (params = null) => {
return await post({ url: '/api/v1/master/getPcsVoltageChk', data: params }).then((res) => {
console.log('[moduleTpCd] getPcsVoltageChk req:', params)
console.log('[moduleTpCd] getPcsVoltageChk res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) })))
return res
})
}
@ -322,11 +326,15 @@ export function useMasterController() {
}
return await post({ url: '/api/v1/master/getPcsVoltageStepUpList', data: params }).then((res) => {
console.log('[moduleTpCd] getPcsVoltageStepUpList req:', params)
console.log('[moduleTpCd] getPcsVoltageStepUpList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) })))
return res
})
}
const getQuotationItem = async (params) => {
console.log('[moduleTpCd] getQuotationItem req pcses.connections:', params?.pcses?.map((p) => ({ pcsItemId: p.pcsItemId, connections: p.connections })))
console.log('[moduleTpCd] getQuotationItem full req:', params)
return await post({ url: '/api/v1/master/getQuotationItem', data: params })
.then((res) => {
return res
@ -355,6 +363,8 @@ export function useMasterController() {
*/
const getPcsConnOptionItemList = async (params = null) => {
return await post({ url: '/api/v1/master/getPcsConnOptionItemList', data: params }).then((res) => {
console.log('[moduleTpCd] getPcsConnOptionItemList req:', params)
console.log('[moduleTpCd] getPcsConnOptionItemList res pcsItemList:', res?.data?.pcsItemList?.map((p) => ({ itemId: p.itemId, pcsTpCd: p.pcsTpCd, connList: p.connList?.map((c) => ({ itemId: c.itemId, moduleTpCd: c.moduleTpCd })) })))
return res
})
}

View File

@ -106,6 +106,18 @@ export function useImgLoader() {
canvas.renderAll()
// 렌더링 완료 대기 (최대 3초, 초과 시 에러)
const renderReady = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(false), 3000)
requestAnimationFrame(() => {
clearTimeout(timeout)
resolve(true)
})
})
if (!renderReady) {
throw new Error('RENDER_TIMEOUT')
}
const formData = new FormData()
// 3. 도면 오브젝트의 바운딩 박스 계산
@ -164,8 +176,9 @@ export function useImgLoader() {
} catch (e) {
canvas.backgroundColor = originalBg
canvas.renderAll()
setIsGlobalLoading(false)
toggleLineEtc(true)
console.log('🚀 ~ handleCanvasToPng ~ e:', e)
throw e
}
}

View File

@ -2601,8 +2601,16 @@ export const useTrestle = () => {
const { constTp } = moduleSelection.construction
const { addRoof } = moduleSelection
// 서브모듈 코드 판별: 서로 다른 계열 혼합(예: DC3=D+C3)일 때만 서브모듈 코드 세팅
// 단일(C3) 또는 같은 계열 혼합(C1C2C3)은 빈 문자열
const subCodes = module.itemTp?.match(/[A-Z]\d*/g) || []
const uniquePrefixes = new Set(subCodes.map((code) => code.charAt(0)))
const isDifferentSeriesMix = subCodes.length > 1 && uniquePrefixes.size > 1
const subModuleTpCd = isDifferentSeriesMix ? surface.modules?.[0]?.moduleInfo?.moduleTpCd || '' : ''
return {
moduleTpCd: module.itemTp,
subModuleTpCd,
roofMatlCd: parent.roofMaterial.roofMatlCd,
mixMatlNo: module.mixMatlNo,
raftBaseCd: addRoof.raft ?? addRoof.raftBaseCd,

View File

@ -11,6 +11,14 @@ import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { getSelectLinePosition } from '@/util/skeleton-utils'
import { useMouse } from '@/hooks/useMouse'
// [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법.
// 'clamp' : (기본/2026-04-24 최종) 평행 위치-OVER_EPS 까지 클램핑. wall.baseLines 는 항상 정상 polygon, SK 확장 정상.
// 'reject' : (2026-04-22 정책) silent skip — 오버면 해당 target 의 mutation 자체를 건너뜀. 토스트 없음.
// 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험)
// 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응.
const OVER_MOVE_POLICY = 'clamp'
const OVER_EPS = 0.5
//동선이동 형 올림 내림
export function useMovementSetting(id) {
const TYPE = {
@ -746,6 +754,57 @@ export function useMovementSetting(id) {
deltaX = value.toNumber()
}
// [OVER_GUARD] 오버 이동(인접 라인 반전) 처리. 정책은 파일 상단 OVER_MOVE_POLICY 로 전환.
// 한계 계산 (공통):
// nLimit = (nextLine 의 free 끝점 좌표) - (currentLine 의 해당 끝점 좌표)
// pLimit = (prevLine 의 free 끝점 좌표) - (currentLine 의 해당 시작점 좌표)
// delta 와 같은 부호인 한계만 후보(반대 부호 한계는 인접 라인이 길어지는 방향).
// 정책별 동작:
// 'passthrough' → 가드 전체 스킵
// 'reject' → |delta| > |한계-EPS| 이면 해당 target 은 mutation 없이 건너뜀 (silent skip)
// 'clamp' → |delta| 를 |한계-EPS| 로 잘라 평행 직전까지 이동. roof.moveUpDown 도 동일 비율 동기화.
if (OVER_MOVE_POLICY !== 'passthrough') {
const __isHoriz = currentLine.y1 === currentLine.y2
const __raw = __isHoriz ? deltaY : deltaX
const __sgn = Math.sign(__raw)
if (__sgn !== 0) {
const __nLimit = __isHoriz ? (nextLine.y2 - currentLine.y2) : (nextLine.x2 - currentLine.x2)
const __pLimit = __isHoriz ? (prevLine.y1 - currentLine.y1) : (prevLine.x1 - currentLine.x1)
let __absLimit = Infinity
if (Math.sign(__nLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__nLimit))
if (Math.sign(__pLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__pLimit))
// [EPS] 평행 정확히 도달 시 인접 baseLine zero-length → polygon 중복 꼭짓점 → SkeletonBuilder 헛선 발생.
// OVER_EPS 여유 두어 인접 baseLine 길이 OVER_EPS(=0.5) 로 남기고 SHOULDER_ABSORBED(planeSize<1) 가 자연 흡수.
const __safeAbsLimit = Math.max(0, __absLimit - OVER_EPS)
const __isOver = Math.abs(__raw) > __safeAbsLimit + 0.001
if (__isOver && OVER_MOVE_POLICY === 'reject') {
// silent skip: mutation 자체 건너뜀. movingLineFromSkeleton 의 roof.points 확장도 막으려면 moveUpDown=0.
console.warn(
`[handleSave] OVER 거부(reject): ${__isHoriz ? 'deltaY' : 'deltaX'}=${__raw.toFixed(1)} > limit=${__safeAbsLimit.toFixed(1)} → target skip`
)
roof.moveUpDown = 0
return
}
if (OVER_MOVE_POLICY === 'clamp') {
const __limited = __sgn * Math.min(Math.abs(__raw), __safeAbsLimit)
if (Math.abs(__limited - __raw) > 0.001) {
console.warn(
`[handleSave] OVER 클램핑: ${__isHoriz ? 'deltaY' : 'deltaX'} ${__raw.toFixed(1)}${__limited.toFixed(1)} (nLimit=${__nLimit.toFixed(1)} pLimit=${__pLimit.toFixed(1)}, EPS=${OVER_EPS})`
)
if (__isHoriz) deltaY = __limited
else deltaX = __limited
// [SYNC roof.moveUpDown] movingLineFromSkeleton 은 roof.moveUpDown(mm) 로 roof.points 확장.
// delta 만 줄이고 moveUpDown 방치 시 wall 은 평행까지, roof.points 는 원본 양만큼 이동 →
// polygon 자기교차 → removeNonOrthogonalPoints 가 꼭짓점 축소 → "SK 가 wall 안에서 멈춤".
// __limited(cm) × 10 = mm.
roof.moveUpDown = Math.round(Math.abs(__limited) * 10)
}
}
}
}
currentLine.set({
x1: currentLine.x1 + deltaX,
y1: currentLine.y1 + deltaY,

View File

@ -30,6 +30,8 @@ import { calculateAngle } from '@/util/qpolygon-utils'
import { fabric } from 'fabric'
import { outlineDisplaySelector } from '@/store/settingAtom'
import { usePopup } from '@/hooks/usePopup'
import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage'
import Big from 'big.js'
import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
import { useObject } from '@/hooks/useObject'
@ -84,6 +86,8 @@ export function useOuterLineWall(id, propertiesId) {
const arrow1Ref = useRef(arrow1)
const arrow2Ref = useRef(arrow2)
const { addPopup, closePopup } = usePopup()
const { swalFire } = useSwal()
const { getMessage } = useMessage()
const setOuterLineFix = useSetRecoilState(outerLineFixState)
@ -929,7 +933,11 @@ export function useOuterLineWall(id, propertiesId) {
const enterCheck = (e) => {
if (e.key === 'Enter') {
handleFix()
swalFire({
type: 'confirm',
text: getMessage('modal.cover.outline.fix.confirm'),
confirmFn: handleFix,
})
}
}

View File

@ -22,6 +22,8 @@ import {
import { usePolygon } from '@/hooks/usePolygon'
import { POLYGON_TYPE } from '@/common/common'
import { usePopup } from '@/hooks/usePopup'
import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage'
import { useSurfaceShapeBatch } from './useSurfaceShapeBatch'
import { roofDisplaySelector } from '@/store/settingAtom'
@ -48,6 +50,8 @@ export function usePlacementShapeDrawing(id) {
const { setSurfaceShapePattern } = useRoofFn()
const { changeSurfaceLineType } = useSurfaceShapeBatch({})
const { handleSelectableObjects } = useObject()
const { swalFire } = useSwal()
const { getMessage } = useMessage()
const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState)
const adsorptionRange = useRecoilValue(adsorptionRangeState)
@ -1089,7 +1093,11 @@ export function usePlacementShapeDrawing(id) {
const enterCheck = (e) => {
if (e.key === 'Enter') {
handleFix()
swalFire({
type: 'confirm',
text: getMessage('modal.cover.outline.fix.confirm'),
confirmFn: handleFix,
})
}
}

View File

@ -70,6 +70,7 @@
"modal.cover.outline.length": "長さ(mm)",
"modal.cover.outline.arrow": "方向(矢印)",
"modal.cover.outline.fix": "外壁線確定",
"modal.cover.outline.fix.confirm": "本当に確定しますか?確定後は修正できません。",
"modal.cover.outline.rollback": "前に戻る",
"modal.cover.outline.finish": "設定完了",
"common.setting.finish": "設定完了",

View File

@ -70,6 +70,7 @@
"modal.cover.outline.length": "길이(mm)",
"modal.cover.outline.arrow": "방향(화살표)",
"modal.cover.outline.fix": "외벽선 확정",
"modal.cover.outline.fix.confirm": "정말로 확정하시겠습니까? 확정 후에는 수정할 수 없습니다.",
"modal.cover.outline.rollback": "일변전으로 돌아가기",
"modal.cover.outline.finish": "설정완료",
"common.setting.finish": "설정완료",

File diff suppressed because it is too large Load Diff