diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js
index 7810851d..5486c54b 100644
--- a/src/components/fabric/QPolygon.js
+++ b/src/components/fabric/QPolygon.js
@@ -7,6 +7,7 @@ import {
drawGableRoof,
drawRoofByAttribute,
drawShedRoof,
+ equalizeParallelEaveLabels,
equalizeSymmetricHips,
snapNearAxisEdges,
toGeoJSON,
@@ -16,6 +17,7 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js'
import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
+import { debugCapture } from '@/util/debugCapture'
// ========================================================================
// [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local')
@@ -100,6 +102,137 @@ function __attachDebugLabels(canvas, parentId) {
canvas.renderAll()
}
+// ========================================================================
+// [ROOF-FACE-DIAG 2026-06-04] 지붕면 할당 디버그 — 로컬 전용.
+// 할당된 각 지붕면(name === POLYGON_TYPE.ROOF)에
+// - 캔버스: 면 식별자 + 평면면적 라벨(F-n) (사람이 "F-3" 지목용)
+// - 로거: 면적(평면/실제)·구배·방향·꼭짓점 절대좌표 전체 (대화/디버그 공유용 — 본체)
+// 캔버스 라벨만으론 Claude 가 못 읽으므로 logger 덤프가 핵심.
+// ========================================================================
+const ROOF_FACE_LABEL_NAME = '__roofFaceDebugLabel'
+
+// fabric Polygon 의 points 는 pathOffset 기준 상대좌표 → 변환행렬로 절대(canvas)좌표 복원.
+function __roofFaceAbsPoints(roof) {
+ const m = roof.calcTransformMatrix()
+ const ox = roof.pathOffset?.x ?? 0
+ const oy = roof.pathOffset?.y ?? 0
+ return (roof.points || []).map((p) => fabric.util.transformPoint({ x: p.x - ox, y: p.y - oy }, m))
+}
+
+function __shoelaceAreaPx(pts) {
+ let a = 0
+ for (let i = 0; i < pts.length; i++) {
+ const cur = pts[i]
+ const nxt = pts[(i + 1) % pts.length]
+ a += cur.x * nxt.y - nxt.x * cur.y
+ }
+ return Math.abs(a) / 2
+}
+
+export function reattachRoofFaceDebugLabels(canvas) {
+ if (!__isDebugLabelsEnabled()) return
+ if (!canvas) return
+
+ // 재할당/재진입 시 기존 면 라벨 제거 → 카운트 reset 후 새로 부여.
+ canvas
+ .getObjects()
+ .filter((o) => o.name === ROOF_FACE_LABEL_NAME)
+ .forEach((o) => canvas.remove(o))
+
+ const roofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF)
+ logger.log(`[ROOF-FACE-DIAG] 할당 지붕면 ${roofs.length}개`)
+
+ // [ROOF-FACE-DIAG 2026-06-04] logger.log 는 브라우저 콘솔뿐이라 Claude 가 못 읽음.
+ // 좌표/면적을 debugCapture 로 debug/debug.log 에 영속화 → log-check 로 읽는 본체.
+ const records = []
+
+ roofs.forEach((roof, i) => {
+ const label = `F-${i + 1}`
+ const pts = __roofFaceAbsPoints(roof)
+ if (pts.length < 3) {
+ logger.log(`[ROOF-FACE-DIAG] ${label} 좌표 부족(pts=${pts.length}) skip`)
+ records.push({ face: label, skip: `pts=${pts.length}` })
+ return
+ }
+ // 좌표 단위 1px = 10mm (planeSize = hypot(px)*10) → 평면면적 ㎡ = pxArea * 100 / 1e6 = pxArea / 1e4
+ const planeM2 = __shoelaceAreaPx(pts) / 10000
+ // 구배(寸) → 경사각. 실제 지붕면적 = 평면 / cos(angle). 구배 없으면 실제 = 평면.
+ const pitch = Number(roof.pitch ?? roof.roofMaterial?.pitch ?? 0) || 0
+ const angle = Math.atan(pitch / 10)
+ const actualM2 = angle ? planeM2 / Math.cos(angle) : planeM2
+
+ // 캔버스 라벨: 면 중심에 식별자 + 평면면적만 (좌표는 로그에만).
+ const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
+ const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
+ const text = new fabric.Text(`${label} ${planeM2.toFixed(2)}㎡`, {
+ left: cx,
+ top: cy,
+ originX: 'center',
+ originY: 'center',
+ fontSize: 12,
+ fill: '#000',
+ fontFamily: 'monospace',
+ fontWeight: 'bold',
+ backgroundColor: 'rgba(0,255,255,0.85)',
+ selectable: false,
+ evented: false,
+ hasControls: false,
+ hasBorders: false,
+ name: ROOF_FACE_LABEL_NAME,
+ parentId: roof.id,
+ excludeFromExport: true,
+ })
+ canvas.add(text)
+ text.bringToFront()
+
+ // 꼭짓점별 좌표 라벨 (화면 표시 — local 전용). 같은 name 으로 cleanup 에 함께 제거됨.
+ pts.forEach((p) => {
+ const coord = new fabric.Text(`${Math.round(p.x)},${Math.round(p.y)}`, {
+ left: p.x,
+ top: p.y,
+ originX: 'center',
+ originY: 'bottom',
+ fontSize: 9,
+ fill: '#0a0',
+ fontFamily: 'monospace',
+ backgroundColor: 'rgba(255,255,255,0.7)',
+ selectable: false,
+ evented: false,
+ hasControls: false,
+ hasBorders: false,
+ name: ROOF_FACE_LABEL_NAME,
+ parentId: roof.id,
+ excludeFromExport: true,
+ })
+ canvas.add(coord)
+ coord.bringToFront()
+ })
+
+ // 로거 덤프(본체): 평면/실제 면적 + 구배 + 방향 + 변 개수 + 꼭짓점 절대좌표 전체.
+ const ptsStr = pts.map((p) => `(${p.x.toFixed(1)},${p.y.toFixed(1)})`).join(' ')
+ logger.log(
+ `[ROOF-FACE-DIAG] ${label} id=${roof.id?.slice?.(0, 8) ?? 'none'} ` +
+ `plane=${planeM2.toFixed(2)}㎡ actual=${actualM2.toFixed(2)}㎡ pitch=${pitch} ` +
+ `dir=${roof.direction ?? 'none'} lines=${roof.lines?.length ?? 0} pts=${pts.length} ${ptsStr}`,
+ )
+ records.push({
+ face: label,
+ id: roof.id?.slice?.(0, 8) ?? null,
+ planeM2: Number(planeM2.toFixed(2)),
+ actualM2: Number(actualM2.toFixed(2)),
+ pitch,
+ dir: roof.direction ?? null,
+ lines: roof.lines?.length ?? 0,
+ points: pts.map((p) => ({ x: Number(p.x.toFixed(1)), y: Number(p.y.toFixed(1)) })),
+ })
+ })
+
+ // Claude 가 읽는 본체 — debug/debug.log 에 [ROOF-FACE-DIAG] 라벨로 영속화.
+ debugCapture.log('ROOF-FACE-DIAG', { count: roofs.length, faces: records })
+
+ canvas.renderAll()
+}
+
export const QPolygon = fabric.util.createClass(fabric.Polygon, {
type: 'QPolygon',
// lines: [],
@@ -710,6 +843,18 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
if (this.innerLines && this.innerLines.length > 0) {
const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP)
equalizeSymmetricHips(hips, this.canvas)
+
+ // 평행 처마 쌍(고정 outerLine ↔ 이동 eaveHelpLine) 라벨 통일.
+ // OVER_EPS 클램프로 이동 변이 1mm 짧아져 평행인데 라벨이 1 차이 나는 케이스 보정.
+ // eaveHelpLine 은 이 지붕 소속(parentId)만, outerLine 은 좌표가 겹치는 것만 그룹핑되므로 cross-roof 오머지 없음.
+ const eaveCandidates = (this.canvas?.getObjects() || []).filter(
+ (o) =>
+ (o.name === 'outerLine' || (o.name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE && o.parentId === this.id)) &&
+ typeof o.x1 === 'number' &&
+ typeof o.y1 === 'number',
+ )
+ equalizeParallelEaveLabels(eaveCandidates, this.canvas)
+
this.canvas?.renderAll()
}
diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
index ec5a90ee..33ee5cca 100644
--- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
+++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx
@@ -263,15 +263,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
if (addedRoofs.length > 0) {
const raftCodeList = findCommonCode('203800')
setRaftCodes(raftCodeList)
+ // [2026-06-02] addedRoofs[0]에서 받은 roofSizeSet을 그대로 사용 (basicSetting으로 덮어씌우지 않기)
setCurrentRoof({
...addedRoofs[0],
planNo: currentCanvasPlan?.planNo || planNo,
- roofSizeSet: String(basicSetting.roofSizeSet),
- roofAngleSet: basicSetting.roofAngleSet,
+ roofSizeSet: String(addedRoofs[0].roofSizeSet),
+ roofAngleSet: addedRoofs[0].roofAngleSet,
})
+ // 입력모드도 함께 동기화
+ setInputMode(addedRoofs[0].roofSizeSet)
} else {
/** 데이터 설정 확인 후 데이터가 없으면 기본 데이터 설정 */
setCurrentRoof({ ...DEFAULT_ROOF_SETTINGS })
+ setInputMode(DEFAULT_ROOF_SETTINGS.roofSizeSet)
}
}, [addedRoofs])
diff --git a/src/components/management/StuffDetail.jsx b/src/components/management/StuffDetail.jsx
index 874aa8fd..30370d73 100644
--- a/src/components/management/StuffDetail.jsx
+++ b/src/components/management/StuffDetail.jsx
@@ -1082,39 +1082,49 @@ export default function StuffDetail() {
return
}
- // T01 / 1차 user + 2차 ID: firstAgent 검증 후에만 적용 (실패 시 무반영)
- get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => {
- logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId })
- if (!res?.firstAgentId) {
- swalFire({
- title: getMessage('stuff.detail.planReq.message.notMatch'),
- type: 'alert',
- icon: 'warning',
- })
- return
- }
+ // [PLANREQ-FORCE-SELECT 2026-06-05] T01 / 1차 user + 2차 ID
+ // 매핑 실패해도 모든 필드 적용 + 2차점을 옵션에 강제 추가 + selected
+ // 1차점 정보(firstAgent) 가 있으면 1차점도 함께 반영
+ // 'No data' 응답(4xx + message)도 매핑 실패와 동일하게 처리, 진짜 네트워크 에러만 알림
+ const applyOtherSaleStore = () => {
applyFields()
+ setOtherSaleStoreList((prev) => {
+ const exists = prev.some((o) => o.saleStoreId === info.saleStoreId)
+ return exists ? prev : [...prev, { saleStoreId: info.saleStoreId, saleStoreName: info.saleStoreName }]
+ })
setOtherSelOptions(info.saleStoreId)
form.setValue('otherSaleStoreId', info.saleStoreId)
form.setValue('otherSaleStoreName', info.saleStoreName)
form.setValue('otherSaleStoreLevel', info.saleStoreLevel)
- const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName }
- setSaleStoreList((prev) => {
- const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
- return exists ? prev : [...prev, firstAgent]
- })
- setShowSaleStoreList((prev) => {
- const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
- return exists ? prev : [...prev, firstAgent]
- })
- setSelOptions(res.firstAgentId)
- form.setValue('saleStoreId', res.firstAgentId)
- form.setValue('saleStoreName', res.firstAgentName)
- form.setValue('saleStoreLevel', '1')
- }).catch(() => {
- // 매핑 실패 — 아무것도 적용 안 함 + 사용자에게 알림
+ }
+ get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => {
+ logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId })
+ applyOtherSaleStore()
+ if (res?.firstAgentId) {
+ const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName }
+ setSaleStoreList((prev) => {
+ const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
+ return exists ? prev : [...prev, firstAgent]
+ })
+ setShowSaleStoreList((prev) => {
+ const exists = prev.some((s) => s.saleStoreId === res.firstAgentId)
+ return exists ? prev : [...prev, firstAgent]
+ })
+ setSelOptions(res.firstAgentId)
+ form.setValue('saleStoreId', res.firstAgentId)
+ form.setValue('saleStoreName', res.firstAgentName)
+ form.setValue('saleStoreLevel', '1')
+ }
+ }).catch((error) => {
+ // 'No data' 는 1차점 매핑 정보가 없을 뿐 — 2차점만 옵션 추가+select
+ const message = error?.response?.data?.message
+ if (message === 'No data') {
+ applyOtherSaleStore()
+ return
+ }
+ // 진짜 네트워크/서버 에러
swalFire({
- title: getMessage('stuff.detail.planReq.message.notMatch'),
+ title: getMessage('stuff.detail.planReq.message.networkError'),
type: 'alert',
icon: 'warning',
})
@@ -1878,16 +1888,18 @@ export default function StuffDetail() {
)) ||
null}
-
+ {session?.storeId === 'T01' && (
+
+ )}
@@ -2468,7 +2480,7 @@ export default function StuffDetail() {
>
) : null}
- {managementState?.tempFlg === '1' ? (
+ {managementState?.tempFlg === '1' && session?.storeId === 'T01' ? (
<>