import { useEffect, useRef, useState } from 'react' import Image from 'next/image' import { useMessage } from '@/hooks/useMessage' import { usePopup } from '@/hooks/usePopup' import SizeGuide from '@/components/floor-plan/modal/placementShape/SizeGuide' import MaterialGuide from '@/components/floor-plan/modal/placementShape/MaterialGuide' import WithDraggable from '@/components/common/draggable/WithDraggable' import { useCanvasSetting } from '@/hooks/option/useCanvasSetting' import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { addedRoofsState, roofDisplaySelector, roofMaterialsAtom } from '@/store/settingAtom' import { useCommonCode } from '@/hooks/common/useCommonCode' import QSelectBox from '@/components/common/select/QSelectBox' import { globalLocaleStore } from '@/store/localeAtom' import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' import { usePolygon } from '@/hooks/usePolygon' import { canvasState, canvasSettingState, currentCanvasPlanState, currentMenuState } from '@/store/canvasAtom' import { useCanvasMenu } from '@/hooks/common/useCanvasMenu' import { MENU, POLYGON_TYPE } from '@/common/common' import { useRoofFn } from '@/hooks/common/useRoofFn' import { usePlan } from '@/hooks/usePlan' import { normalizeDecimal } from '@/util/input-utils' import { CalculatorInput } from '@/components/common/input/CalcInput' import { usePdfImport } from '@/hooks/pdf-import/usePdfImport' import { logger } from '@/util/logger' // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert import { useSwal } from '@/hooks/useSwal' import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning' // [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 import + 호출 함께 제거 import { debugCapture } from '@/util/debugCapture' const INPUT_MODE = { SIZE_ROOF: '1', SIZE_ACTUAL: '2', PDF: 'pdf', } const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024 const PDF_PAGE_MODE = { ALL: 'all', SPECIFY: 'specify', } /** * 지붕 레이아웃 */ export const ROOF_MATERIAL_LAYOUT = { PARALLEL: 'P', STAIRS: 'S', } export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, planNo, openPoint }) { const [showSizeGuideModal, setShowSizeGuidModal] = useState(false) const [showMaterialGuideModal, setShowMaterialGuidModal] = useState(false) const [useCalcPad, setUseCalcPad] = useState(false) const disableKeypad = !useCalcPad const { getMessage } = useMessage() const roofMaterials = useRecoilValue(roofMaterialsAtom) const globalLocale = useRecoilValue(globalLocaleStore) const { basicSetting, setBasicSettings, fetchBasicSettings, basicSettingSave } = useCanvasSetting() const [addedRoofs, setAddedRoofs] = useRecoilState(addedRoofsState) const { findCommonCode } = useCommonCode() const [raftCodes, setRaftCodes] = useState([]) /** 서까래 정보 */ const [currentRoof, setCurrentRoof] = useState(null) /** 현재 선택된 지붕재 정보 */ const { closePopup } = usePopup() /** usePopup에서 closePopup 함수 가져오기 */ const { drawDirectionArrow } = usePolygon() const { setSurfaceShapePattern } = useRoofFn() const canvas = useRecoilValue(canvasState) const [canvasSetting, setCanvasSetting] = useRecoilState(canvasSettingState) const currentCanvasPlan = useRecoilValue(currentCanvasPlanState) const roofDisplay = useRecoilValue(roofDisplaySelector) const { setPolygonLinesActualSize } = usePolygon() const { setSelectedMenu } = useCanvasMenu() const setCurrentMenu = useSetRecoilState(currentMenuState) const roofRef = { roofCd: useRef(null), width: useRef(null), length: useRef(null), rafter: useRef(null), hajebichi: useRef(null), } const { saveCanvas } = usePlan() // [LOW-PITCH-WARN 2026-05-06] const { swalFire } = useSwal() const { applyOuterline } = usePdfImport() // PDF 업로드 관련 상태 const [inputMode, setInputMode] = useState(INPUT_MODE.SIZE_ROOF) const [pdfFile, setPdfFile] = useState(null) const [pdfPageMode, setPdfPageMode] = useState(PDF_PAGE_MODE.ALL) const [pdfFacadePages, setPdfFacadePages] = useState('') const [pdfFloorPages, setPdfFloorPages] = useState('') const [pdfAnalyzing, setPdfAnalyzing] = useState(false) const pdfInputRef = useRef(null) /** * 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드) */ const roofSizeSetArray = [ { id: 'ra01', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ROOF, message: 'modal.placement.initial.setting.size.roof' }, { id: 'ra02', name: 'roofSizeSet', value: INPUT_MODE.SIZE_ACTUAL, message: 'modal.placement.initial.setting.size.actual' }, // { id: 'ra-pdf', name: 'roofSizeSet', value: INPUT_MODE.PDF, message: 'modal.placement.initial.setting.size.pdf' }, ] const handleInputModeChange = (value) => { setInputMode(value) if (value === INPUT_MODE.PDF) { // PDF 모드는 내부적으로 복시도(1) 로 저장된다. setCurrentRoof((prev) => ({ ...prev, roofSizeSet: INPUT_MODE.SIZE_ROOF })) } else { setCurrentRoof((prev) => ({ ...prev, roofSizeSet: value })) } } const resetPdfFile = () => { setPdfFile(null) if (pdfInputRef.current) pdfInputRef.current.value = '' } const handlePdfFileChange = (e) => { const next = e.target.files?.[0] if (!next) return if (next.type !== 'application/pdf') { swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.mime'), type: 'alert' }) e.target.value = '' return } if (next.size > PDF_MAX_FILE_BYTES) { swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.too.large'), type: 'alert' }) e.target.value = '' return } setPdfFile(next) } const analyzePdfAndApply = async () => { if (!pdfFile) { swalFire({ text: getMessage('modal.placement.initial.setting.size.pdf.error.no.file'), type: 'alert' }) return false } const formData = new FormData() formData.append('file', pdfFile) formData.append('unitHint', 'mm') formData.append('pageMode', pdfPageMode) if (pdfPageMode === PDF_PAGE_MODE.SPECIFY) { formData.append('facadePages', pdfFacadePages.trim()) formData.append('floorPages', pdfFloorPages.trim()) } setPdfAnalyzing(true) let response try { response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData }) } catch (e) { logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e) setPdfAnalyzing(false) swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' }) return false } let payload try { payload = await response.json() } catch (e) { logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e) setPdfAnalyzing(false) swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' }) return false } setPdfAnalyzing(false) if (!response.ok) { const msg = payload?.error?.message || getMessage('pdf.import.error.analyze') swalFire({ text: msg, type: 'alert' }) return false } if (payload.empty || !Array.isArray(payload.outerline) || payload.outerline.length === 0) { swalFire({ text: payload.notes || getMessage('pdf.import.error.no.floorplan'), type: 'alert' }) return false } const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' }) if (!applied) { swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' }) return false } return true } /** * 지붕각도 설정(경사/각도) */ const roofAngleSetArray = [ { id: 'ra04', name: 'roofAngleSet', value: 'slope', message: 'modal.placement.initial.setting.roof.pitch' }, { id: 'ra05', name: 'roofAngleSet', value: 'flat', message: 'modal.placement.initial.setting.roof.angle' }, ] /** * 지붕재 초기값 */ const DEFAULT_ROOF_SETTINGS = { roofSizeSet: '1', roofAngleSet: 'slope', angle: 21.8, hajebichi: null, id: 'ROOF_ID_WA_53A', index: 0, layout: ROOF_MATERIAL_LAYOUT.PARALLEL, lenAuth: 'R', lenBase: '235.000', length: 235, name: '일본기와 A', nameJp: '和瓦A', pitch: 4, planNo: planNo, raft: '', raftAuth: 'C', raftBaseCd: 'HEI_455', roofCd: '', roofMatlCd: 'ROOF_ID_WA_53A', roofMatlNm: '일본기와 A', roofMatlNmJp: '和瓦A', roofPchAuth: null, roofPchBase: null, selected: true, widAuth: 'R', widBase: '265.000', width: 265, } useEffect(() => { /** * 메뉴에서 배치면초기설정 선택 시 조회 후 화면 오픈 */ if (openPoint && openPoint === 'canvasMenus') fetchBasicSettings(planNo, openPoint) }, []) /** * 현재 활성 플랜이 변경될 때 currentRoof.planNo 업데이트 */ useEffect(() => { if (currentCanvasPlan?.planNo && currentRoof) { setCurrentRoof((prev) => ({ ...prev, planNo: currentCanvasPlan.planNo })) } }, [currentCanvasPlan?.planNo]) /** * 배치면초기설정 데이터 조회 후 화면 오픈 */ useEffect(() => { if (addedRoofs.length > 0) { const raftCodeList = findCommonCode('203800') setRaftCodes(raftCodeList) setCurrentRoof({ ...addedRoofs[0], planNo: currentCanvasPlan?.planNo || planNo, roofSizeSet: String(basicSetting.roofSizeSet), roofAngleSet: basicSetting.roofAngleSet, }) } else { /** 데이터 설정 확인 후 데이터가 없으면 기본 데이터 설정 */ setCurrentRoof({ ...DEFAULT_ROOF_SETTINGS }) } }, [addedRoofs]) /** * 배치면초기설정 정보 변경 시 basicSettings 설정 */ useEffect(() => { if (!currentRoof) return setBasicSettings({ ...basicSetting, planNo: Number(currentRoof.planNo), roofSizeSet: String(currentRoof.roofSizeSet), roofAngleSet: currentRoof.roofAngleSet, roofsData: { planNo: Number(currentRoof.planNo), roofApply: true, roofSeq: 0, roofMatlCd: currentRoof.roofMatlCd, roofWidth: currentRoof.width, roofHeight: currentRoof.length, roofHajebichi: currentRoof.hajebichi, roofGap: currentRoof.raft, roofLayout: currentRoof.layout, roofPitch: currentRoof.pitch, roofAngle: currentRoof.angle, }, }) }, [currentRoof]) const handleRoofAngleSetChange = (value) => { setCurrentRoof({ ...currentRoof, roofAngleSet: value }) } /** * 지붕재 변경 시 현재 지붕 정보 변경 */ const handleRoofTypeChange = (value) => { const selectedRoofMaterial = roofMaterials.find((roof) => roof.roofMatlCd === value) setCurrentRoof({ ...selectedRoofMaterial, // pitch: currentRoof?.pitch, // angle: currentRoof?.angle, index: 0, planNo: currentRoof.planNo, roofSizeSet: String(currentRoof.roofSizeSet), roofAngleSet: currentRoof.roofAngleSet, }) } /** * 입력 값 변경 시 현재 지붕 정보 변경 */ const changeInput = (value, e) => { const { name } = e.target setCurrentRoof({ ...currentRoof, [name]: Number(normalizeDecimal(value)) }) } /** * 서까래 변경 시 현재 지붕 정보 변경 */ const handleRafterChange = (value) => { setCurrentRoof({ ...currentRoof, raft: value }) } /** * 지붕 레이아웃 변경 시 현재 지붕 정보 변경 */ const handleRoofLayoutChange = (value) => { if (+currentRoof.roofSizeSet === 3) { setCurrentRoof({ ...currentRoof, layout: ROOF_MATERIAL_LAYOUT.PARALLEL }) return } setCurrentRoof({ ...currentRoof, layout: value }) } /** * 배치면초기설정 저장 버튼 클릭 */ const handleSaveBtn = async () => { if (pdfAnalyzing) return if (inputMode === INPUT_MODE.PDF) { const ok = await analyzePdfAndApply() if (!ok) return } const roofInfo = { ...currentRoof, planNo: currentCanvasPlan?.planNo || basicSetting.planNo, roofCd: roofRef.roofCd.current?.value, width: roofRef.width.current?.value, length: roofRef.length.current?.value, hajebichi: roofRef.hajebichi.current?.value, //raft: roofRef.rafter.current?.value, selected: true, layout: currentRoof.layout, index: 0, } // [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 제거 debugCapture.log('LOW-PITCH-DIAG handleSaveBtn', { roofMatlCd: roofInfo.roofMatlCd, pitch: roofInfo.pitch, angle: roofInfo.angle, idMatched: LOW_PITCH_RESTRICTED_ROOF_IDS.has(roofInfo.roofMatlCd), restricted: isLowPitchRestricted(roofInfo), }) // [LOW-PITCH-WARN 2026-05-06] 6종 기와 + (2 ≤ 寸 < 2.5 OR 11.31 ≤ 度 < 14.04) 일 때 안내 alert. OK 누르면 저장 진행 await notifyLowPitchRestrictionForRoofs([roofInfo], swalFire, getMessage) const newAddedRoofs = [...addedRoofs] newAddedRoofs[0] = { ...roofInfo } setAddedRoofs(newAddedRoofs) // currentRoof의 roofSizeSet을 canvasSetting에 반영 setCanvasSetting({ ...canvasSetting, roofSizeSet: currentRoof?.roofSizeSet }) /** * 배치면초기설정 저장 (메뉴 변경/useEffect 트리거 없이) */ basicSettingSave( { ...basicSetting, planNo: currentCanvasPlan?.planNo || basicSetting.planNo, /** * 선택된 지붕재 정보 */ selectedRoofMaterial: { ...newAddedRoofs[0], }, }, { skipSideEffects: true }, ) const roofs = canvas.getObjects().filter((obj) => obj.roofMaterial?.index === 0) roofs.forEach((roof) => { /** 모양 패턴 설정 */ setSurfaceShapePattern(roof, roofDisplay.column, false, { ...roofInfo }) roof.roofMaterial = { ...roofInfo } drawDirectionArrow(roof) setPolygonLinesActualSize(roof, true) }) canvas.renderAll() /** 지붕면 존재 여부에 따라 메뉴 설정 */ const hasRoofs = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.ROOF) // roofSizeSet에 따라 메뉴 설정 if (currentRoof?.roofSizeSet === '2') { setSelectedMenu('surface') setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING) } else if (currentRoof?.roofSizeSet === '1') { setSelectedMenu('outline') setCurrentMenu(MENU.ROOF_COVERING.EXTERIOR_WALL_LINE) } else if (hasRoofs) { setSelectedMenu('surface') setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING) } else { setSelectedMenu('outline') setCurrentMenu(MENU.ROOF_COVERING.EXTERIOR_WALL_LINE) } /* 저장 후 화면 닫기 */ closePopup(id) await saveCanvas(false) } return (
{inputMode === INPUT_MODE.PDF && ( <> )}
{getMessage('modal.placement.initial.setting.plan.drawing')} {getMessage('modal.placement.initial.setting.plan.drawing.size.stuff')}         {getMessage('modal.placement.initial.setting.plan.drawing.only.number')}
{getMessage('modal.placement.initial.setting.size')}
{currentRoof && roofSizeSetArray.map((item) => (
handleInputModeChange(e.target.value)} />
))}
{inputMode === INPUT_MODE.PDF && (
{getMessage('modal.placement.initial.setting.size.pdf.info')}
)}
{getMessage('modal.placement.initial.setting.size.pdf.file.label')}
{pdfFile && !pdfAnalyzing && }
{getMessage('modal.placement.initial.setting.size.pdf.file.info')}
{getMessage('modal.placement.initial.setting.size.pdf.page.label')}
setPdfPageMode(e.target.value)} />
setPdfPageMode(e.target.value)} />
{pdfPageMode === PDF_PAGE_MODE.SPECIFY && ( <> {getMessage('modal.placement.initial.setting.size.pdf.page.facade.label')} setPdfFacadePages(e.target.value)} /> {getMessage('modal.placement.initial.setting.size.pdf.page.floor.label')} setPdfFloorPages(e.target.value)} /> )}
{pdfPageMode === PDF_PAGE_MODE.SPECIFY ? getMessage('modal.placement.initial.setting.size.pdf.page.specify.info') : getMessage('modal.placement.initial.setting.size.pdf.page.all.info')}
{getMessage('modal.placement.initial.setting.roof.material')}
{ return { ...roof, name: globalLocale === 'ko' ? roof.roofMatlNm : roof.roofMatlNmJp } })} value={currentRoof?.roofSizeSet === '3' ? null : currentRoof?.roofMatlCd} onChange={(e) => handleRoofTypeChange(e.roofMatlCd)} sourceKey="id" targetKey="id" showKey="name" disabled={currentRoof?.roofSizeSet === '3'} />
{currentRoof && ['R', 'C'].includes(currentRoof.widAuth) && (
W
{/* changeInput(normalizeDigits(e.target.value), e)}*/} {/* readOnly={currentRoof?.widAuth === 'R'}*/} {/* disabled={currentRoof?.roofSizeSet === '3'}*/} {/*/>*/} { setCurrentRoof({ ...currentRoof, value }) }} readOnly={currentRoof?.widAuth === 'R'} disabled={currentRoof?.roofSizeSet === '3'} disableKeypad={disableKeypad} options={{ allowNegative: false, allowDecimal: false, //(index !== 0), }} />
)} {currentRoof && ['R', 'C'].includes(currentRoof.lenAuth) && (
L
{/* changeInput(normalizeDigits(e.target.value), e)}*/} {/* readOnly={currentRoof?.lenAuth === 'R'}*/} {/* disabled={currentRoof?.roofSizeSet === '3'}*/} {/*/>*/} { setCurrentRoof({ ...currentRoof, value }) }} readOnly={currentRoof?.lenAuth === 'R'} disabled={currentRoof?.roofSizeSet === '3'} disableKeypad={disableKeypad} options={{ allowNegative: false, allowDecimal: false, //(index !== 0), }} />
)} {currentRoof && ['C', 'R'].includes(currentRoof.raftAuth) && (
{getMessage('modal.placement.initial.setting.rafter')} {raftCodes?.length > 0 && (
r.clCode === (currentRoof.raft ?? currentRoof?.raftBaseCd))?.clCodeNm} value={currentRoof?.raft ?? currentRoof?.raftBaseCd} onChange={(e) => handleRafterChange(e.clCode)} sourceKey="clCode" targetKey={currentRoof?.raft ? 'raft' : 'raftBaseCd'} showKey="clCodeNm" disabled={currentRoof?.roofSizeSet === '3'} />
)}
)} {currentRoof && ['C', 'R'].includes(currentRoof?.roofPchAuth) && (
{getMessage('hajebichi')}
{/* changeInput(normalizeDigits(e.target.value), e)}*/} {/* readOnly={currentRoof?.roofPchAuth === 'R'}*/} {/* disabled={currentRoof?.roofSizeSet === '3'}*/} {/*/>*/} { const hajebichi = value === '' ? '' : Number(value) setCurrentRoof((prev) => ({ ...prev, hajebichi, })) }} readOnly={currentRoof?.roofPchAuth === 'R'} disabled={currentRoof?.roofSizeSet === '3'} disableKeypad={disableKeypad} options={{ allowNegative: false, allowDecimal: false, //(index !== 0), }} />
)}
{/* {currentRoof && (
)} */}
{getMessage('modal.placement.initial.setting.roof.angle.setting')}
{currentRoof && roofAngleSetArray.map((item, index) => (
setCurrentRoof({ ...currentRoof, roofAngleSet: e.target.value })} />
{/* { const v = normalizeDecimal(e.target.value) e.target.value = v if (index === 0) { const num = v === '' ? '' : Number(v) setCurrentRoof({ ...currentRoof, pitch: num === '' ? '' : num, angle: num === '' ? '' : getDegreeByChon(num) }) } else { const num = v === '' ? '' : Number(v) setCurrentRoof({ ...currentRoof, pitch: num === '' ? '' : getChonByDegree(num), angle: num === '' ? '' : num }) } }} /> */} { if (index === 0) { const pitch = value === '' ? '' : Number(value) const angle = pitch === '' ? '' : getDegreeByChon(pitch) setCurrentRoof((prev) => ({ ...prev, pitch, angle, })) } else { const angle = value === '' ? '' : Number(value) const pitch = angle === '' ? '' : getChonByDegree(angle) setCurrentRoof((prev) => ({ ...prev, pitch, angle, })) } }} disableKeypad={disableKeypad} options={{ allowNegative: false, allowDecimal: true, }} />
{index === 0 ? '寸' : '度'}
))}
{showSizeGuideModal && } {showMaterialGuideModal && }
) }