From 0ae4a87f1727a6e559b8dbd614d651e0ccd991b9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:14:46 +0900 Subject: [PATCH 01/55] =?UTF-8?q?chore(plan):=2010-5=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=9A=94=EA=B5=AC=20=EC=9B=90=EB=AC=B8=20=EB=B3=B4=EA=B4=80?= =?UTF-8?q?(B05=20=C2=B7=20M01=20=C2=B7=20M02=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=C2=B7=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=C2=B7=20CAD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/raw/사용자요구/2026-10-05_수정요구.md diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md new file mode 100644 index 000000000..eec77747b --- /dev/null +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -0,0 +1,31 @@ +# 수정 요구 — B05 · M01 · M02 · 공통 · 대시보드 · CAD (2026-10-05 · 원문 그대로) + +1. B05 종단설계 + - 계획노선 편집에서 거리 측정에서 예상노선/계획노선이 서로 가려져 포인트 찍기가 어려움. 첫번째 포인트에 따라 두번째 포인트의 지점을 찍을때 노선 선정 자동으로 반영 + - 계획노선 편집에서 횡단내용 하단부의 좌우 표고 및 원지반, 기본계획 횡단의 글자 사이즈를 현재의 절반수준으로 변경 + - 계획노선 편집에서 횡단 우측 측점 표기의 글자를 제목과 동일한 폰트 사이즈로 변경 + +2. M01 마스터 데이터 + - 단가산출 로직 항목의 상세 페이지에서 우측 패널(다이어그램, 상세내용)에 토글버튼으로 접었다 폈다 할수 있게. 템플릿 확인 후 반영 + - 단가산출 호직 항목의 상세페이지의 좌측 항목의 순서는 기본정보 > 시험계산 > 단가산출 상세 > 텍스트 수식 순으로 표기 + - 우측패널의 하나더 추가하여 기존에 있던 다이어그램, 상세내용 패널 하나와 추가 패널하나를 넣어주는데 두 패널은 트레이드 오프 관계로 하나가 보이면 하나는 닫힘. 둘다 닫을 수도 있음. 이때 추가된 패널에는 단가산출 로직의 원문과 연결하여 표현 + - 체인톱 가격이 수기입력으로 받고 있지만 이걸 자재품목에서 '동력체인톱'항목으로 연결되어야함.(기본값은 이름 순서상 제일 위에 값을 사용) + - 체인톱의 오일은 자재품목에서 '작동유'를 의미함. + +3. M02 마스터 템플릿 + - 일위대가 미리보기와 상세보기 내부의 셀 연결 후 표기가 셀 주소 = 수량 으로 표기됨. 이걸 수량(셀주소)형태로 변경 + - 단가산출 로직의 항목명 하단에 선택된 옵션 값을 명기해 줬는데 이게 내용이 많은 경우 줄바꿈에 의해 전체 행이 커지고 있음. 이값을 이름, 수량산출, 결과 단위, 누무비,, 재료비, 경비 , 합계에 걸쳐 입력 정보 표기 하기 + - 단가산출 로직의 항목들의 총합계 행에서 전체 합계 항목을 테두리와 색상으로 표현 + - 일위대가 상세에서 상단의 단위는 사용자가 드롭다운 메뉴에서 선택해서 변경 + - 일위대사 상세 페이지의 단가산출 로직 테이블에서 이름 열외에 추가로 사용자가 지정한 이름을 사용할 수 있게 별칭을 넣을 수 있게 열 추가 + +4. 페이지 공통양식 + - B05 페이지의 좌측 패널에서 좌측 패널의 구조물리스트와 초기화/저장/횡단(종단) 등의 버튼류는 상단 구분선을 기준으로 패널 하단에 고정하였는데 풀렸음. 다시 고정 + +5. 대시보드 + - 일위대가가 구조물 조합으로 편입되면서 시스템 설정 컨테이너의 일위대가 항목은 없어도됨. + +6. 내부 특수 앱 + - CAD 프로그램 : 한글입력 안됨. + +계획서를 작성하고 코드작업을 진행해줘. 난 이제 자리를 비워야하니까 참고해. From 191b7ece271b9cc0cd221b3cbfce91d85bef6f2e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:15:15 +0900 Subject: [PATCH 02/55] auto: 2026-10-05 11:15 (ESD_LAPTOP) --- docs/raw/사용자요구/2026-10-05_수정요구.md | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/raw/사용자요구/2026-10-05_수정요구.md diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md new file mode 100644 index 000000000..eec77747b --- /dev/null +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -0,0 +1,31 @@ +# 수정 요구 — B05 · M01 · M02 · 공통 · 대시보드 · CAD (2026-10-05 · 원문 그대로) + +1. B05 종단설계 + - 계획노선 편집에서 거리 측정에서 예상노선/계획노선이 서로 가려져 포인트 찍기가 어려움. 첫번째 포인트에 따라 두번째 포인트의 지점을 찍을때 노선 선정 자동으로 반영 + - 계획노선 편집에서 횡단내용 하단부의 좌우 표고 및 원지반, 기본계획 횡단의 글자 사이즈를 현재의 절반수준으로 변경 + - 계획노선 편집에서 횡단 우측 측점 표기의 글자를 제목과 동일한 폰트 사이즈로 변경 + +2. M01 마스터 데이터 + - 단가산출 로직 항목의 상세 페이지에서 우측 패널(다이어그램, 상세내용)에 토글버튼으로 접었다 폈다 할수 있게. 템플릿 확인 후 반영 + - 단가산출 호직 항목의 상세페이지의 좌측 항목의 순서는 기본정보 > 시험계산 > 단가산출 상세 > 텍스트 수식 순으로 표기 + - 우측패널의 하나더 추가하여 기존에 있던 다이어그램, 상세내용 패널 하나와 추가 패널하나를 넣어주는데 두 패널은 트레이드 오프 관계로 하나가 보이면 하나는 닫힘. 둘다 닫을 수도 있음. 이때 추가된 패널에는 단가산출 로직의 원문과 연결하여 표현 + - 체인톱 가격이 수기입력으로 받고 있지만 이걸 자재품목에서 '동력체인톱'항목으로 연결되어야함.(기본값은 이름 순서상 제일 위에 값을 사용) + - 체인톱의 오일은 자재품목에서 '작동유'를 의미함. + +3. M02 마스터 템플릿 + - 일위대가 미리보기와 상세보기 내부의 셀 연결 후 표기가 셀 주소 = 수량 으로 표기됨. 이걸 수량(셀주소)형태로 변경 + - 단가산출 로직의 항목명 하단에 선택된 옵션 값을 명기해 줬는데 이게 내용이 많은 경우 줄바꿈에 의해 전체 행이 커지고 있음. 이값을 이름, 수량산출, 결과 단위, 누무비,, 재료비, 경비 , 합계에 걸쳐 입력 정보 표기 하기 + - 단가산출 로직의 항목들의 총합계 행에서 전체 합계 항목을 테두리와 색상으로 표현 + - 일위대가 상세에서 상단의 단위는 사용자가 드롭다운 메뉴에서 선택해서 변경 + - 일위대사 상세 페이지의 단가산출 로직 테이블에서 이름 열외에 추가로 사용자가 지정한 이름을 사용할 수 있게 별칭을 넣을 수 있게 열 추가 + +4. 페이지 공통양식 + - B05 페이지의 좌측 패널에서 좌측 패널의 구조물리스트와 초기화/저장/횡단(종단) 등의 버튼류는 상단 구분선을 기준으로 패널 하단에 고정하였는데 풀렸음. 다시 고정 + +5. 대시보드 + - 일위대가가 구조물 조합으로 편입되면서 시스템 설정 컨테이너의 일위대가 항목은 없어도됨. + +6. 내부 특수 앱 + - CAD 프로그램 : 한글입력 안됨. + +계획서를 작성하고 코드작업을 진행해줘. 난 이제 자리를 비워야하니까 참고해. From 0b3315438a5e3b925a0211a889aeddc76c1f00c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:15:17 +0900 Subject: [PATCH 03/55] auto: 2026-10-05 11:15 (ESD_LAPTOP) --- docs/raw/사용자요구/2026-10-05_수정요구.md | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/raw/사용자요구/2026-10-05_수정요구.md diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md new file mode 100644 index 000000000..eec77747b --- /dev/null +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -0,0 +1,31 @@ +# 수정 요구 — B05 · M01 · M02 · 공통 · 대시보드 · CAD (2026-10-05 · 원문 그대로) + +1. B05 종단설계 + - 계획노선 편집에서 거리 측정에서 예상노선/계획노선이 서로 가려져 포인트 찍기가 어려움. 첫번째 포인트에 따라 두번째 포인트의 지점을 찍을때 노선 선정 자동으로 반영 + - 계획노선 편집에서 횡단내용 하단부의 좌우 표고 및 원지반, 기본계획 횡단의 글자 사이즈를 현재의 절반수준으로 변경 + - 계획노선 편집에서 횡단 우측 측점 표기의 글자를 제목과 동일한 폰트 사이즈로 변경 + +2. M01 마스터 데이터 + - 단가산출 로직 항목의 상세 페이지에서 우측 패널(다이어그램, 상세내용)에 토글버튼으로 접었다 폈다 할수 있게. 템플릿 확인 후 반영 + - 단가산출 호직 항목의 상세페이지의 좌측 항목의 순서는 기본정보 > 시험계산 > 단가산출 상세 > 텍스트 수식 순으로 표기 + - 우측패널의 하나더 추가하여 기존에 있던 다이어그램, 상세내용 패널 하나와 추가 패널하나를 넣어주는데 두 패널은 트레이드 오프 관계로 하나가 보이면 하나는 닫힘. 둘다 닫을 수도 있음. 이때 추가된 패널에는 단가산출 로직의 원문과 연결하여 표현 + - 체인톱 가격이 수기입력으로 받고 있지만 이걸 자재품목에서 '동력체인톱'항목으로 연결되어야함.(기본값은 이름 순서상 제일 위에 값을 사용) + - 체인톱의 오일은 자재품목에서 '작동유'를 의미함. + +3. M02 마스터 템플릿 + - 일위대가 미리보기와 상세보기 내부의 셀 연결 후 표기가 셀 주소 = 수량 으로 표기됨. 이걸 수량(셀주소)형태로 변경 + - 단가산출 로직의 항목명 하단에 선택된 옵션 값을 명기해 줬는데 이게 내용이 많은 경우 줄바꿈에 의해 전체 행이 커지고 있음. 이값을 이름, 수량산출, 결과 단위, 누무비,, 재료비, 경비 , 합계에 걸쳐 입력 정보 표기 하기 + - 단가산출 로직의 항목들의 총합계 행에서 전체 합계 항목을 테두리와 색상으로 표현 + - 일위대가 상세에서 상단의 단위는 사용자가 드롭다운 메뉴에서 선택해서 변경 + - 일위대사 상세 페이지의 단가산출 로직 테이블에서 이름 열외에 추가로 사용자가 지정한 이름을 사용할 수 있게 별칭을 넣을 수 있게 열 추가 + +4. 페이지 공통양식 + - B05 페이지의 좌측 패널에서 좌측 패널의 구조물리스트와 초기화/저장/횡단(종단) 등의 버튼류는 상단 구분선을 기준으로 패널 하단에 고정하였는데 풀렸음. 다시 고정 + +5. 대시보드 + - 일위대가가 구조물 조합으로 편입되면서 시스템 설정 컨테이너의 일위대가 항목은 없어도됨. + +6. 내부 특수 앱 + - CAD 프로그램 : 한글입력 안됨. + +계획서를 작성하고 코드작업을 진행해줘. 난 이제 자리를 비워야하니까 참고해. From f535c233a6d94de6ef8dd6bfbc21a2527c7e2948 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:15:19 +0900 Subject: [PATCH 04/55] auto: 2026-10-05 11:15 (ESD_LAPTOP) --- docs/raw/사용자요구/2026-10-05_수정요구.md | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/raw/사용자요구/2026-10-05_수정요구.md diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md new file mode 100644 index 000000000..eec77747b --- /dev/null +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -0,0 +1,31 @@ +# 수정 요구 — B05 · M01 · M02 · 공통 · 대시보드 · CAD (2026-10-05 · 원문 그대로) + +1. B05 종단설계 + - 계획노선 편집에서 거리 측정에서 예상노선/계획노선이 서로 가려져 포인트 찍기가 어려움. 첫번째 포인트에 따라 두번째 포인트의 지점을 찍을때 노선 선정 자동으로 반영 + - 계획노선 편집에서 횡단내용 하단부의 좌우 표고 및 원지반, 기본계획 횡단의 글자 사이즈를 현재의 절반수준으로 변경 + - 계획노선 편집에서 횡단 우측 측점 표기의 글자를 제목과 동일한 폰트 사이즈로 변경 + +2. M01 마스터 데이터 + - 단가산출 로직 항목의 상세 페이지에서 우측 패널(다이어그램, 상세내용)에 토글버튼으로 접었다 폈다 할수 있게. 템플릿 확인 후 반영 + - 단가산출 호직 항목의 상세페이지의 좌측 항목의 순서는 기본정보 > 시험계산 > 단가산출 상세 > 텍스트 수식 순으로 표기 + - 우측패널의 하나더 추가하여 기존에 있던 다이어그램, 상세내용 패널 하나와 추가 패널하나를 넣어주는데 두 패널은 트레이드 오프 관계로 하나가 보이면 하나는 닫힘. 둘다 닫을 수도 있음. 이때 추가된 패널에는 단가산출 로직의 원문과 연결하여 표현 + - 체인톱 가격이 수기입력으로 받고 있지만 이걸 자재품목에서 '동력체인톱'항목으로 연결되어야함.(기본값은 이름 순서상 제일 위에 값을 사용) + - 체인톱의 오일은 자재품목에서 '작동유'를 의미함. + +3. M02 마스터 템플릿 + - 일위대가 미리보기와 상세보기 내부의 셀 연결 후 표기가 셀 주소 = 수량 으로 표기됨. 이걸 수량(셀주소)형태로 변경 + - 단가산출 로직의 항목명 하단에 선택된 옵션 값을 명기해 줬는데 이게 내용이 많은 경우 줄바꿈에 의해 전체 행이 커지고 있음. 이값을 이름, 수량산출, 결과 단위, 누무비,, 재료비, 경비 , 합계에 걸쳐 입력 정보 표기 하기 + - 단가산출 로직의 항목들의 총합계 행에서 전체 합계 항목을 테두리와 색상으로 표현 + - 일위대가 상세에서 상단의 단위는 사용자가 드롭다운 메뉴에서 선택해서 변경 + - 일위대사 상세 페이지의 단가산출 로직 테이블에서 이름 열외에 추가로 사용자가 지정한 이름을 사용할 수 있게 별칭을 넣을 수 있게 열 추가 + +4. 페이지 공통양식 + - B05 페이지의 좌측 패널에서 좌측 패널의 구조물리스트와 초기화/저장/횡단(종단) 등의 버튼류는 상단 구분선을 기준으로 패널 하단에 고정하였는데 풀렸음. 다시 고정 + +5. 대시보드 + - 일위대가가 구조물 조합으로 편입되면서 시스템 설정 컨테이너의 일위대가 항목은 없어도됨. + +6. 내부 특수 앱 + - CAD 프로그램 : 한글입력 안됨. + +계획서를 작성하고 코드작업을 진행해줘. 난 이제 자리를 비워야하니까 참고해. From 4041e61871127a5cae33e779719162194e337c36 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:15:21 +0900 Subject: [PATCH 05/55] auto: 2026-10-05 11:15 (ESD_LAPTOP) --- docs/raw/사용자요구/2026-10-05_수정요구.md | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/raw/사용자요구/2026-10-05_수정요구.md diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md new file mode 100644 index 000000000..eec77747b --- /dev/null +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -0,0 +1,31 @@ +# 수정 요구 — B05 · M01 · M02 · 공통 · 대시보드 · CAD (2026-10-05 · 원문 그대로) + +1. B05 종단설계 + - 계획노선 편집에서 거리 측정에서 예상노선/계획노선이 서로 가려져 포인트 찍기가 어려움. 첫번째 포인트에 따라 두번째 포인트의 지점을 찍을때 노선 선정 자동으로 반영 + - 계획노선 편집에서 횡단내용 하단부의 좌우 표고 및 원지반, 기본계획 횡단의 글자 사이즈를 현재의 절반수준으로 변경 + - 계획노선 편집에서 횡단 우측 측점 표기의 글자를 제목과 동일한 폰트 사이즈로 변경 + +2. M01 마스터 데이터 + - 단가산출 로직 항목의 상세 페이지에서 우측 패널(다이어그램, 상세내용)에 토글버튼으로 접었다 폈다 할수 있게. 템플릿 확인 후 반영 + - 단가산출 호직 항목의 상세페이지의 좌측 항목의 순서는 기본정보 > 시험계산 > 단가산출 상세 > 텍스트 수식 순으로 표기 + - 우측패널의 하나더 추가하여 기존에 있던 다이어그램, 상세내용 패널 하나와 추가 패널하나를 넣어주는데 두 패널은 트레이드 오프 관계로 하나가 보이면 하나는 닫힘. 둘다 닫을 수도 있음. 이때 추가된 패널에는 단가산출 로직의 원문과 연결하여 표현 + - 체인톱 가격이 수기입력으로 받고 있지만 이걸 자재품목에서 '동력체인톱'항목으로 연결되어야함.(기본값은 이름 순서상 제일 위에 값을 사용) + - 체인톱의 오일은 자재품목에서 '작동유'를 의미함. + +3. M02 마스터 템플릿 + - 일위대가 미리보기와 상세보기 내부의 셀 연결 후 표기가 셀 주소 = 수량 으로 표기됨. 이걸 수량(셀주소)형태로 변경 + - 단가산출 로직의 항목명 하단에 선택된 옵션 값을 명기해 줬는데 이게 내용이 많은 경우 줄바꿈에 의해 전체 행이 커지고 있음. 이값을 이름, 수량산출, 결과 단위, 누무비,, 재료비, 경비 , 합계에 걸쳐 입력 정보 표기 하기 + - 단가산출 로직의 항목들의 총합계 행에서 전체 합계 항목을 테두리와 색상으로 표현 + - 일위대가 상세에서 상단의 단위는 사용자가 드롭다운 메뉴에서 선택해서 변경 + - 일위대사 상세 페이지의 단가산출 로직 테이블에서 이름 열외에 추가로 사용자가 지정한 이름을 사용할 수 있게 별칭을 넣을 수 있게 열 추가 + +4. 페이지 공통양식 + - B05 페이지의 좌측 패널에서 좌측 패널의 구조물리스트와 초기화/저장/횡단(종단) 등의 버튼류는 상단 구분선을 기준으로 패널 하단에 고정하였는데 풀렸음. 다시 고정 + +5. 대시보드 + - 일위대가가 구조물 조합으로 편입되면서 시스템 설정 컨테이너의 일위대가 항목은 없어도됨. + +6. 내부 특수 앱 + - CAD 프로그램 : 한글입력 안됨. + +계획서를 작성하고 코드작업을 진행해줘. 난 이제 자리를 비워야하니까 참고해. From 10bc3686e2348cca2b58303c50926bf6d2fc3bd5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:22:06 +0900 Subject: [PATCH 06/55] =?UTF-8?q?style(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=ED=9A=A1=EB=8B=A8=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=95=20(44-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원지반 · 기본 계획 횡단 · 좌우 표고 글자 11px → 5.5px(절반) - 측점 표기 글자 = 제목 「횡단」과 같은 크기(inherit) - 화면 시나리오 resources/tester/ui_checks/44-2_횡단_글자.js 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B05_Profile_UI_Style_RouteEdit.css | 4 +- resources/tester/ui_checks/44-2_횡단_글자.js | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 resources/tester/ui_checks/44-2_횡단_글자.js diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index caa6c66c3..a7ebab158 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -187,7 +187,7 @@ .b05-routeedit__cross-station { color: var(--color-text-secondary); - font-size: var(--text-caption); + font-size: inherit; /* 44-2 — 제목(횡단)과 같은 크기 */ } /* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③). @@ -235,7 +235,7 @@ .b05-routeedit__cross-key { fill: var(--color-text-secondary, #94a3b8); - font-size: 11px; + font-size: 5.5px; /* 44-2 — 지금(11px)의 절반 */ } .b05-routeedit__cross-key.is-design { diff --git a/resources/tester/ui_checks/44-2_횡단_글자.js b/resources/tester/ui_checks/44-2_횡단_글자.js new file mode 100644 index 000000000..d5d0f35fc --- /dev/null +++ b/resources/tester/ui_checks/44-2_횡단_글자.js @@ -0,0 +1,72 @@ +/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기 + * 시작 화면: B05 종단설계(#/b05-profile) · 환경 검증 프로젝트(OWNERS.md) · 로그인됨 · 계획노선 편집 닫힘 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: [계획노선 편집] 열기 → 첫 측점 라벨 누름(앱 모듈의 라벨 상자 자리) → 횡단 판 글자 잼 → [취소]로 닫음 + * 기대 수치: + * - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 5.5px(옛 11px 의 절반) + * - station.fontSize = title.fontSize(측점 표기 = 제목 「횡단」 크기) + * - 바꾸는 값 없음(취소로 닫음 · 노선 안 고침) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + const v = fn(); + if (v) return v; + await w(200); + } + return null; + }; + const btn = (text) => + [...document.querySelectorAll("button")].find( + (b) => b.offsetParent && b.textContent.trim() === text, + ); + btn("계획노선 편집")?.click(); + const canvas = await until(() => + [...document.querySelectorAll("canvas")].find( + (c) => c.offsetParent && c.closest("[class*='routeedit']"), + ), + ); + if (!canvas) return JSON.stringify({ ok: false, why: "편집 캔버스 없음" }); + // 앱이 실제로 부른 그 모듈 주소(@fs/<폴더>/…) — 같은 주소라야 라벨 상자 WeakMap 을 같이 씀 + const url = performance + .getEntriesByType("resource") + .map((e) => e.name) + .find((n) => n.includes("B05_Profile_UI_RouteEdit_Input.ts")); + const input = await import(url); + const boxes = await until(() => { + const b = input.stationLabelBoxes(canvas); + return b.length ? b : null; + }); + if (!boxes) return JSON.stringify({ ok: false, why: "측점 라벨 상자 없음" }); + const rect = canvas.getBoundingClientRect(); + const box = boxes[Math.floor(boxes.length / 2)]; + const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true }; + canvas.dispatchEvent(new PointerEvent("pointerdown", at)); + canvas.dispatchEvent(new PointerEvent("pointerup", at)); + const pane = await until(() => + [...document.querySelectorAll(".b05-routeedit__cross")].find((p) => + p.querySelector(".b05-routeedit__cross-key"), + ), + ); + if (!pane) return JSON.stringify({ ok: false, why: "횡단 판 안 뜸", chainage: box.chainage }); + const fs = (el) => el && getComputedStyle(el).fontSize; + const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({ + text: t.textContent.slice(0, 20), + fontSize: fs(t), + h: +t.getBoundingClientRect().height.toFixed(1), + })); + const title = pane.querySelector(".b05-routeedit__cross-title"); + const station = pane.querySelector(".b05-routeedit__cross-station"); + const out = { + keys, + title: { text: title.textContent, fontSize: fs(title) }, + station: { text: station.textContent, fontSize: fs(station) }, + }; + out.ok = + keys.length === 3 && + keys.every((k) => k.fontSize === "5.5px") && + out.title.fontSize === out.station.fontSize; + btn("취소")?.click(); + return JSON.stringify(out); +})(); From 411ad7de7dc74f521c2cd219744dbb49ab06492a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:23:02 +0900 Subject: [PATCH 07/55] =?UTF-8?q?=EF=BB=BFfeat(M02):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=91=9C=2044-6=20=E2=80=94=20=EC=88=98?= =?UTF-8?q?=EB=9F=89(=EC=B9=B8)=20=ED=91=9C=EA=B8=B0=20=C2=B7=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EA=B0=92=20=EA=B1=B8=EC=B9=9C=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EC=A0=84=EC=B2=B4=20=ED=95=A9=EA=B3=84=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=20=C2=B7=20=EB=B3=84=EC=B9=AD=20=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칸 연결 표기 「B6 = 12.5」 → 「12.5(B6)」(수량산출 칸 · 수량 모달 지금 수량 · 자재 줄) - 옵션 값 = 줄 아래 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글 title) - 총합계 행 전체 합계 칸 테두리 · 색 - 별칭 열(이름 옆 · 칸 안 글상자 · 담은로직 줄 별칭 저장 · 서버 검사 40자 · 복사 · 가져오기 같이 감) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Labor_Doc.py | 11 ++- .../M02_MasterTemplete_Labor.css | 64 ++++++++++++ .../M02_MasterTemplete_Labor_Api.ts | 3 + .../M02_MasterTemplete_Labor_Detail.ts | 15 ++- .../M02_MasterTemplete_Labor_Rows.ts | 97 ++++++++++++++++--- .../M02_MasterTemplete_Labor_Text.ts | 2 + resources/tester/test_m02_labor_copy.py | 4 +- resources/tester/test_m02_labor_doc.py | 11 ++- resources/tester/test_m02_labor_values.py | 10 +- 9 files changed, 190 insertions(+), 27 deletions(-) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 1dd338ff5..51189cd41 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,6 +1,6 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). -문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}], +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). @@ -26,6 +26,7 @@ mm = mf.mp.mm COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 MATERIAL_HEAD = "자재|" _MAT_COLS = ("코드", "이름", "규격", "단위") +ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음 def _bad(text: str) -> store.StoreError: @@ -83,10 +84,16 @@ def clean(doc: Any) -> dict[str, Any]: given = one.get("입력값") or {} if not isinstance(given, dict): raise _bad(f"{where} · 입력값이 묶음 아님") - memo = one.get("메모") + memo, alias = one.get("메모"), one.get("별칭") + if alias is not None and not isinstance(alias, str): + raise _bad(f"{where} · 별칭은 글") + alias = (alias or "").strip() + if len(alias) > ALIAS_MAX: + raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지") lines.append( { "로직": code, + "별칭": alias or None, "메모": str(memo) if memo else None, "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, "수량": _qty(one.get("수량"), where), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 273b16e1f..3a42026b5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -78,6 +78,59 @@ white-space: nowrap; } +/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */ +.m02-labrow__opt { + max-width: 0; + overflow: hidden; + padding-top: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; + text-overflow: ellipsis; +} + +.m02-labrow__row:has(+ .m02-labrow__optrow) td { + border-bottom: 0; +} + +.m02-labrow__optrow.is-open { + cursor: pointer; +} + +.m02-labrow__optrow.is-open:hover, +.m02-labrow__row.is-open:hover + .m02-labrow__optrow { + background: var(--color-mist-violet); +} + +/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */ +.m02-labrow__alias { + min-width: 7rem; +} + +.m02-labrow__aliasin { + width: 100%; + min-width: 6rem; + padding: 2px var(--spacing-4); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm, 4px); + background: transparent; + color: inherit; + font: inherit; +} + +.m02-labrow__aliasin:hover, +.m02-labrow__aliasin:focus { + border-color: var(--color-primary); + border-style: solid; +} + +/* 총합계 줄 전체 합계 칸(44-6) — 테두리 · 색 */ +.m02-labrow__sum .m02-labrow__total { + border: 2px solid var(--color-primary); + background: var(--color-mist-violet); + color: var(--color-primary); +} + /* 입력 모달 */ .m02-labin { width: min(40rem, calc(100vw - 32px)); @@ -234,6 +287,17 @@ order: 3; text-align: right; } + + /* 옵션 값 줄 — 한 줄 통째 */ + .m02-labrow__optrow .m02-labrow__no, + .m02-labrow__sum .m02-labrow__alias { + display: none; + } + + .m02-labrow .m02-labrow__opt { + grid-column: 1 / -1; + max-width: none; + } } /* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 7c4de288a..5e7b50172 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -121,6 +121,8 @@ export type LaborQty = { 손: number } | null; /** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */ export interface LaborRow { 로직: string; + /** 사용자가 지은 이름(44-6 · 빈 것 = null) */ + 별칭?: string | null; 메모: string | null; 입력값: Record; 수량: LaborQty; @@ -153,6 +155,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 단위: d.단위 ?? null, 담은로직: (d.담은로직 ?? []).map((r) => ({ 로직: r.로직, + 별칭: r.별칭 ?? null, 메모: r.메모 ?? null, 입력값: r.입력값 ?? {}, 수량: r.수량 ?? null, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 8697f287f..ca0adfab4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -135,7 +135,13 @@ export function mountLaborDetail( { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) edit - ? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow } + ? { + open: openRow, + qty: openQty, + ...(opt.card ? {} : { move }), + remove: removeRow, + alias: setAlias, + } : { open: openRow }, ), ); @@ -262,6 +268,11 @@ export function mountLaborDetail( cur.담은로직.splice(i, 1); changed(); } + /** 별칭(44-6) — 화면 캐시만(저장은 [저장]) · 금액과 무관이라 다시 셈 · 다시 그림 없음 */ + function setAlias(i: number, value: string | null): void { + cur.담은로직[i].별칭 = value; + opt.onChanged(dirty()); + } function removeMat(i: number): void { cur.자재.splice(i, 1); changed(); @@ -311,7 +322,7 @@ export function mountLaborDetail( openLogicPicker((x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); - cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); + cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); void loadLogics().then(() => changed()); }), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 0b9f927fe..f2e413ce7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -1,8 +1,9 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Rows.ts * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. - * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · - * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. + * 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) · + * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · + * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조). * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). * 금액은 calc 답(없으면 「…」). * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). @@ -31,11 +32,14 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { 계: "Prev_Sum", }; +/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ +const SPAN = 4 + COSTS.length; + const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · - * 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */ +/** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) · + * 이은 칸이 비었으면 「수량 빈 칸(A1)」(수량 없음 · 서버와 같음) · 아니면 빈 글 */ export function qtyText( line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, @@ -44,12 +48,12 @@ export function qtyText( if (!line) return ""; if (line.수량 === null || line.수량 === undefined) { const at = line.알약 ? cells.get(line.알약) : undefined; - return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : ""; + return at ? `${tc("Labor_Qty")} ${tc("In_QtyEmpty")}(${at})` : ""; } const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); - return `${tc("Labor_Qty")} ${cell} = ${v}`; + return `${tc("Labor_Qty")} ${v}(${cell})`; } /** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */ @@ -60,7 +64,7 @@ export function baseQtyText( return `${num(line.배 || 1)} ${line.분모}`; } -/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */ +/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */ export function qtyShort( line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, @@ -68,13 +72,13 @@ export function qtyShort( ): string { const at = line?.알약 ? cells.get(line.알약) : undefined; if (!line || line.수량 === null || line.수량 === undefined) - return at ? `${at} = ${tc("In_QtyEmpty")}` : "—"; + return at ? `${tc("In_QtyEmpty")}(${at})` : "—"; if (line.수량출처 === "손") return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`; - return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`; + return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`; } -/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) · +/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) · * `open` 이 있으면 단추(줄 누름과 따로) */ function qtyCell( line: LaborCalcLine | undefined, @@ -155,6 +159,42 @@ export interface RowHandlers { qty?: (i: number) => void; move?: (i: number, d: number) => void; remove?: (i: number) => void; + /** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */ + alias?: (i: number, value: string | null) => void; +} + +/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */ +function aliasCell( + value: string | null | undefined, + set: ((v: string | null) => void) | null, +): HTMLElement { + if (!set) + return el("td", { + className: "m02-labrow__alias", + text: value ?? "", + attrs: { "data-label": tc("Col_Alias") }, + }); + const input = el("input", { + className: "m02-labrow__aliasin", + attrs: { + type: "text", + maxlength: "40", // 서버 `ALIAS_MAX` 와 같음 + title: tc("Alias_Edit"), + "aria-label": tc("Col_Alias"), + "data-labor-alias": "", + }, + }); + input.value = value ?? ""; + input.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(입력 모달)과 따로 + input.addEventListener("input", () => set(input.value.trim() || null)); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") input.blur(); + }); + return el("td", { + className: "m02-labrow__alias", + attrs: { "data-label": tc("Col_Alias") }, + children: [input], + }); } export interface LogicTableData { @@ -175,7 +215,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const tools = !!(move || remove); const lineOf = (key: string): LaborCalcLine | undefined => data.calc?.줄.find((l) => l.로직 === key); - const trs = data.rows.map((row, i) => { + const trs = data.rows.flatMap((row, i) => { const logic = data.logics.get(row.로직); const line = lineOf(row.로직); const unit = logic?.결과단위 ?? ""; @@ -201,7 +241,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ el("span", { className: "m02-labrow__key", text: row.로직 }), el("strong", { text: logic?.이름 ?? row.로직 }), - ...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []), ...(values.empty ? [ el("small", { @@ -221,6 +260,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []), ], }), + aliasCell(row.별칭, on?.alias ? (v) => on.alias?.(i, v) : null), qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: unit }), ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))), @@ -240,7 +280,23 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ], }); if (on) tr.addEventListener("click", () => on.open(i)); - return tr; + if (!note) return [tr]; + // 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 + const opt = el("tr", { + className: on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow", + attrs: { "data-labor-opt": row.로직 }, + children: [ + el("td", { className: "m02-labrow__no", text: "" }), + el("td", { + className: "m02-labrow__opt", + text: note, + attrs: { colspan: String(SPAN), title: note }, + }), + ...(tools ? [el("td", { text: "" })] : []), + ], + }); + if (on) opt.addEventListener("click", () => on.open(i)); + return [tr, opt]; }); const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => part?.[c] ?? null; @@ -251,11 +307,19 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { className: "m02-labrow__alias", text: "" }), el("td", { className: "m02-labrow__qty", text: "" }), el("td", { className: "m02-labrow__unit", text: "" }), - ...COSTS.map((c) => - moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])), - ), + ...COSTS.map((c) => { + const td = moneyCell( + sum(data.calc?.기준합, c), + sum(data.calc?.반영합, c), + wait, + tc(HEAD[c]), + ); + if (c === "계") td.classList.add("m02-labrow__total"); // 전체 합계 강조(44-6) + return td; + }), ...(tools ? [el("td", { text: "" })] : []), ], }), @@ -264,6 +328,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const heads = [ tc("Col_Order"), tc("Col_Name"), + tc("Col_Alias"), tc("Labor_QtyCol"), tc("Col_Unit"), ...COSTS.map((c) => tc(HEAD[c])), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index deae683f6..13fe5f3a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -27,6 +27,8 @@ const TEXT = { Logics_Title: ["단가산출 로직", "Unit-price logics"], Col_Order: ["차례", "No."], Col_Name: ["이름", "Name"], + Col_Alias: ["별칭", "Alias"], + Alias_Edit: ["눌러서 별칭 고침", "Click to edit alias"], Col_Unit: ["결과 단위", "Result unit"], Col_Move: ["순서", "Move"], Up: ["올리기", "Up"], diff --git a/resources/tester/test_m02_labor_copy.py b/resources/tester/test_m02_labor_copy.py index 56ca1b727..b2cc4269f 100644 --- a/resources/tester/test_m02_labor_copy.py +++ b/resources/tester/test_m02_labor_copy.py @@ -39,7 +39,7 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: for cid in ("rv", "c1"): store.create_structure(cid) got = store.read("labor", "rv") - logic = [{"로직": TEN, "입력값": {}, "수량": {"손": 2}}] + logic = [{"로직": TEN, "별칭": " 돌붙임 ", "입력값": {}, "수량": {"손": 2}}] store.write("labor", "rv", got["판"], {**got["문서"], "단위": "개소", "담은로직": logic}) personal = layers.personal_dir(7, 42) items_store.ensure(personal) @@ -71,6 +71,7 @@ def test_같은_층_일위대가_하나_복사_번호_열은_받는_쪽(world): c1 = _labor(system, "c1") assert (c1["열"], c1["일위대가번"]) == ("c1", before["일위대가번"]) assert (c1["단위"], c1["담은로직"][0]["로직"]) == ("개소", TEN) + assert c1["담은로직"][0]["별칭"] == "돌붙임" # 별칭도 같이(44-6 · 앞뒤 빈칸 뗌) assert c1["계산값"]["반영합"]["계"] > 0 # 서버가 다시 셈 @@ -80,6 +81,7 @@ def test_다른_층에서_내_개인으로_저장_뒤에도_덮어씀(world): for _ in range(2): # 저장된 데이터도 다시 바꿈 assert client.post(url).status_code == 200 assert _labor(personal, "a")["담은로직"][0]["로직"] == TEN + assert _labor(personal, "a")["담은로직"][0]["별칭"] == "돌붙임" area = URL.format("personal", "area", "a") + "?from=rv&from_layer=system" assert client.post(area).status_code == 200 diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index adee730ee..a2a629fd0 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -34,7 +34,7 @@ def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것(): ) ) assert got["담은로직"] == [ - {"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None} + {"로직": STONE, "별칭": None, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None} ] assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2} assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None) @@ -47,6 +47,8 @@ def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것(): _doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직 _doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님 _doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림 + _doc(담은로직=[{"로직": STONE, "별칭": 3}]), # 별칭 글 아님(44-6) + _doc(담은로직=[{"로직": STONE, "별칭": "가" * 41}]), # 별칭 40자 넘음 _doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님 _doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번 "문서 아님", @@ -58,6 +60,13 @@ def test_틀린_문서는_400(bad): assert e.value.status == 400 +def test_별칭은_앞뒤_빈칸_떼고_빈_글은_null(): + got = labor.clean( + _doc(담은로직=[{"로직": STONE, "별칭": " 돌붙임 "}, {"로직": TEN, "별칭": " "}]) + ) + assert [r["별칭"] for r in got["담은로직"]] == ["돌붙임", None] + + def test_빈_일위대가도_됨(): got = labor.server(_doc()) assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0 diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index be396caab..f69a66248 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -144,7 +144,7 @@ console.log(JSON.stringify([ def test_수량_글은_이은_칸_주소를_보임() -> None: - """`Labor_View.setQty(수량, 칸)` 의 칸 주소로 「수량 B6 = 20 ㎡」 — 없으면 「면적산출 칸」.""" + """`Labor_View.setQty(수량, 칸)` 의 칸 주소로 「수량 20 ㎡(B6)」 — 없으면 「면적산출 칸」.""" node = shutil.which("node") if not node: pytest.skip("node 없음") @@ -154,11 +154,11 @@ def test_수량_글은_이은_칸_주소를_보임() -> None: assert out.returncode == 0, out.stderr got = json.loads(out.stdout) # 수량산출 열(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 짧은 글 - assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "B6 = 12.5", "B6 = 빈 칸", "—"] + assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "12.5(B6)", "빈 칸(B6)", "—"] assert got[7:] == [ - "수량 B6 = 20 ㎡", - "수량 면적산출 칸 = 20 ㎡", + "수량 20 ㎡(B6)", # 44-6 — 수량(칸 주소) + "수량 20 ㎡(면적산출 칸)", "수량 손 입력 3 ㎡", - "수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음) + "수량 빈 칸(B6)", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음) "", ] From a22f03426fd9a80c584ccc24fc363ad2617ea020 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:23:22 +0900 Subject: [PATCH 08/55] =?UTF-8?q?feat(m02,b01):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=83=81=EC=84=B8=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=C2=B7=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=B9=B4=EB=93=9C=20=EC=97=86=EC=95=B0(44-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 상세 머리 「단위」 = 표준 단위 고르기(UnitPick) · 지금 값이 목록에 없어도 선택지 · 저장은 상세 [저장] - 대시보드 시스템 설정의 「일위대가」 카드 · 진입 해시 open=일위대가 길 · 안 쓰는 글 키 없앰 - 시험 고침 · ui_checks/44-7 화면 판정 스크립트 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- B01_Dashboard/B01_Dashboard_UI_Page.ts | 6 +- .../M02_MasterTemplete_Labor_Detail.ts | 31 +++++--- .../M02_MasterTemplete_Layer.ts | 6 +- .../M02_MasterTemplete_UI_Page.ts | 2 +- .../M02_MasterTemplete_UI_Side.ts | 11 +-- resources/tester/test_b01_settings_cards.py | 9 +-- resources/tester/test_m02_card_shell.py | 10 +++ resources/tester/test_m02_labor_tree.py | 4 +- resources/tester/test_m02_layer_screen.py | 2 +- .../tester/ui_checks/44-7_단위_드롭다운.js | 78 +++++++++++++++++++ ui_template/ui_template_locale_m2.ts | 1 - 11 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 resources/tester/ui_checks/44-7_단위_드롭다운.js diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 0c677c35d..4443b06a8 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -178,7 +178,6 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () => navigateTo(ROUTES.M01_MASTER_DATA), ), - m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", "master", desc), m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", "master", desc), m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), @@ -193,10 +192,7 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), L("B01_Dashboard_PermPersonalWrite"), ].join(" · "); - wrap.append( - m02(L("B01_Dashboard_CardUnitCombo"), "일위대가", root, desc), - m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc), - ); + wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", root, desc)); return wrap; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 8697f287f..e4e63a645 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -43,6 +43,7 @@ import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows" import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; +import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; @@ -330,15 +331,27 @@ export function mountLaborDetail( }), }); - const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); - unit.input.value = cur.단위 ?? ""; - unit.input.readOnly = !edit; - unit.input.maxLength = 20; - unit.input.setAttribute("data-labor-field", "단위"); - unit.input.addEventListener("input", () => { - cur.단위 = unit.input.value.trim() || null; - opt.onChanged(dirty()); - }); + // 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장] + const unit = { + root: el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }), + }; + const unit0 = cur.단위; + const drawUnit = (): void => + unit.root.replaceChildren( + el("label", { className: "ui-field__label", text: tc("Labor_Unit") }), + createUnitPick({ + value: cur.단위 || NO_UNIT, + extra: [unit0, cur.단위].filter((u): u is string => !!u), + readOnly: !edit, + ariaLabel: tc("Labor_Unit"), + onPick: (u) => { + cur.단위 = u === NO_UNIT ? null : u; + opt.onChanged(dirty()); + drawUnit(); + }, + }), + ); + drawUnit(); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); origin.input.readOnly = true; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index c1860e584..47fd72a35 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Layer.ts * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 - * `#/m02-master-template?open=구조물조합|일위대가|옵션|도면&root=master|company|personal&owner=…&company=…` + * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…` * (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · * 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대. * ========================================================================== */ @@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A export type ManageLayer = "system" | "company" | "personal"; /** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */ export type Root = "master" | "company" | "personal"; -export type OpenTarget = "구조물조합" | "일위대가" | "옵션" | "도면"; +export type OpenTarget = "구조물조합" | "옵션" | "도면"; export interface Entry { root: Root; @@ -27,7 +27,7 @@ export interface Entry { company: string | null; } -const OPENS: OpenTarget[] = ["구조물조합", "일위대가", "옵션", "도면"]; +const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면"]; export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index cfa2c9eeb..21b36e7fe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -226,7 +226,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ - reveal: (target: "옵션" | "도면" | "일위대가") => void; + reveal: (target: "옵션" | "도면") => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -414,14 +414,7 @@ export function buildSide(opt: SideOptions): SideHandle { } } - const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - // 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」 - if (target === "일위대가") { - columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove( - "is-collapsed", - ); - return void selectAll("labor"); - } + const reveal = (target: "옵션" | "도면"): void => { extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 97a09c3a2..f5872516f 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -70,31 +70,28 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite") @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_시스템_관리자_카드_다섯_고른_회사_해시(): +def test_시스템_관리자_카드_넷_고른_회사_해시(): m = "B01_Dashboard_PermMasterWrite" assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [ ["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"], - ["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master&company=3"], ["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"], ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"], ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"], ] # 회사가 없으면 해시에 안 실음 assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == ( - f"{M02}?open=일위대가&root=master" + f"{M02}?open=구조물조합&root=master" ) @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_회사_관리자_개인_카드_둘(): +def test_회사_관리자_개인_카드_하나(): company = [ - ["B01_Dashboard_CardUnitCombo", WRITE_CO, f"{M02}?open=일위대가&root=company"], ["B01_Dashboard_CardStructCombo", WRITE_CO, f"{M02}?open=구조물조합&root=company"], ] assert _cards({"role": "ADMIN", "is_master": False}, 3) == company # 회사 인자 안 실음 assert _cards({"role": "USER", "is_master": True}) == company # 회사 마스터 = 회사 수정 assert _cards({"role": "USER", "is_master": False}) == [ - ["B01_Dashboard_CardUnitCombo", READ_CO, f"{M02}?open=일위대가&root=personal"], ["B01_Dashboard_CardStructCombo", READ_CO, f"{M02}?open=구조물조합&root=personal"], ] diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 1b7c067a6..aada156a2 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -80,3 +80,13 @@ def test_일위대가_카드는_상세_카드_갈래(): rows = _text("M02_MasterTemplete_Labor_Rows.ts") assert "const tools = !!(move || remove);" in rows and "compactTable" not in rows assert "compact" not in _text("M02_MasterTemplete_Labor_View.ts").split("*/", 1)[1] + + +def test_일위대가_상세_단위는_표준_단위_고르기(): + """44-7 — 상세 머리 「단위」 = 표준 단위 목록(UnitPick) · 지금 값이 목록에 없어도 선택지 · + 고르면 캐시만(저장은 상세 [저장]) · 「-」 = 없음.""" + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert 'import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";' in detail + assert "extra: [unit0, cur.단위].filter((u): u is string => !!u)," in detail + assert "cur.단위 = u === NO_UNIT ? null : u;" in detail + assert "readOnly: !edit," in detail and "maxLength = 20" not in detail diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 50f51fcad..02df52984 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -41,10 +41,10 @@ def test_하위_차례_전체_항목_이름(): assert '| "labor";' in api and "일위대가번: string;" in api assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side - assert 'return void selectAll("labor");' in side # 해시 open=일위대가 + assert 'selectAll("labor")' not in side # 해시 open=일위대가 길 없앰(44-7) locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale - assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale + assert "B01_Dashboard_CardUnitCombo" not in locale # 대시보드 일위대가 카드 없앰(44-7) css = _text("M02_MasterTemplete_UI_Style.css") assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 9e3fb2227..280859a46 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -87,7 +87,7 @@ def test_주소_인자를_라우트에서_뗌(): # 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로 assert 'query.get("root") ?? query.get("layer")' in layer assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer - assert '["구조물조합", "일위대가", "옵션", "도면"]' in layer + assert '["구조물조합", "옵션", "도면"]' in layer # 일위대가 = 구조물 조합 안(44-7) picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts") assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker diff --git a/resources/tester/ui_checks/44-7_단위_드롭다운.js b/resources/tester/ui_checks/44-7_단위_드롭다운.js new file mode 100644 index 000000000..450efdcfe --- /dev/null +++ b/resources/tester/ui_checks/44-7_단위_드롭다운.js @@ -0,0 +1,78 @@ +/* 44-7 — ① M02 일위대가 상세 머리 「단위」 = 표준 단위 드롭다운 ② 대시보드 「시스템 설정」 일위대가 카드 없음 + * 시작 화면: 아무 화면 · 시스템 관리자 로그인 · M02 마스터 층에 구조물 열 하나 이상 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안) + * 기대 수치: + * - cards = ["마스터 데이터", "구조물 조합", "구조물 옵션", "도면 양식"] · hasLabor = false + * - pick = 1(단추) · textInput = 0(손글 칸 없음) · options > 10 · hasNow = true(지금 값이 선택지에 있음) + * - 다른 단위 고름 → after = 고른 값 · dirty = true(다른 열로 가려면 버림 물음 · 「취소」) + * - 처음 값 다시 고름 → back = 처음 값 · dirtyBack = false(물음 없이 옮겨 감 · 저장 안 함 · 바꾼 값 되돌림) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const out = {}; + // ② 대시보드 시스템 설정 카드 + location.hash = "#/b01-account"; + await until(() => document.querySelector(".b01-dashboard__setting-title")); + out.cards = [...document.querySelectorAll(".b01-dashboard__setting-title")].map( + (e) => e.textContent, + ); + out.hasLabor = out.cards.includes("일위대가"); + // ① M02 일위대가 상세(좌측 일위대가 하위 첫 줄) + location.hash = "#/m02-master-template?open=구조물조합&root=master"; + // 하위 줄은 다시 그려짐 — 누를 때마다 새로 찾음 + const row = () => + document + .querySelector('[data-all="labor"]') + ?.parentElement?.querySelector(".m02-side__item:not([data-all])"); + if (!(await until(row))) return JSON.stringify({ ...out, error: "일위대가 하위 없음" }); + await w(500); + row().click(); + const field = await until(() => document.querySelector('[data-labor-field="단위"]')); + if (!field) return JSON.stringify({ ...out, error: "단위 칸 없음" }); + // 고침 표시 = 다른 열(하위 둘째 줄)로 갈 때 버림 물음이 뜨는지 — 뜨면 「취소」 + const asks = async () => { + document + .querySelector('[data-all="labor"]') + .parentElement.querySelectorAll(".m02-side__item:not([data-all])")[1] + .click(); + const ask = await until(() => document.querySelector(".ui-confirm"), 1500); + [...(ask?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "취소")?.click(); + await w(200); + return !!ask; + }; + const btn = () => document.querySelector('[data-labor-field="단위"] .m02-df-unitpick > button'); + const items = async () => { + btn().click(); + return until(() => { + const li = document.querySelectorAll('[data-labor-field="단위"] .m02-df-unitpick__item'); + return li.length ? [...li] : null; + }); + }; + out.pick = document.querySelectorAll('[data-labor-field="단위"] .m02-df-unitpick').length; + out.textInput = field.querySelectorAll("input[type=text]:not(.m02-df-unitpick__filter)").length; + const u0 = btn().textContent; + out.before = u0; + const list = await items(); + out.options = list.length; + out.hasNow = list.some((b) => b.classList.contains("is-now")); + const other = list.find((b) => !b.classList.contains("is-now") && !b.textContent.startsWith("-")); + other.click(); + await w(200); + out.after = btn().textContent; + out.dirty = await asks(); + // 되돌림 — 처음 값(없음이면 「-」) + const again = await items(); + again.find((b) => (u0 === "-" ? b.textContent.startsWith("-") : b.textContent === u0)).click(); + await w(200); + out.back = btn().textContent; + out.dirtyBack = await asks(); + return JSON.stringify(out); +})(); diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e6817a238..ee9999c55 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -4,7 +4,6 @@ * ========================================================================== */ export const ui_locales_m2 = { - B01_Dashboard_CardUnitCombo: ["일위대가", "Unit costs"], B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"], B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"], B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"], From 543d7bb8a585f09a256da5cba065363df11337e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:27:09 +0900 Subject: [PATCH 09/55] =?UTF-8?q?fix(B05=C2=B7B06):=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EC=95=84=EB=9E=98=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=EB=AC=B6=EC=9D=8C=20=EB=B0=94=EB=8B=A5=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=EB=B3=B5=EA=B5=AC(PLAN=2044-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭 = 473d959d6 이 좌측 자리를 빈 채 틀에 넘겨 공용 쪼개기 건너뜀 · 보기 전환이 패널 클래스를 통째 덮어 ui-sidebar-fill 지움 - 공용 쪼개기 내보냄 · 두 번 불러도 한 번만 · 패널을 붙인 뒤 다시 부름 - 늦게 서는 칸은 스크롤 칸 안으로 · 보기 클래스는 toggle - 시험 test_design_panel_dock_fixed · 화면 시나리오 ui_checks/44-3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/design/design_panel.ts | 15 +++- A00_Common/design/design_shared.ts | 4 ++ .../tester/test_design_panel_dock_fixed.py | 38 ++++++++++ .../ui_checks/44-3_좌측패널_하단고정.js | 69 +++++++++++++++++++ ui_template/ui_template_overlay.ts | 4 +- 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_design_panel_dock_fixed.py create mode 100644 resources/tester/ui_checks/44-3_좌측패널_하단고정.js diff --git a/A00_Common/design/design_panel.ts b/A00_Common/design/design_panel.ts index 76094010f..08333ed9b 100644 --- a/A00_Common/design/design_panel.ts +++ b/A00_Common/design/design_panel.ts @@ -112,6 +112,13 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De const dockParts: HTMLElement[] = []; const dock = createDesignDock(view, dockParts, ctx.dock); + // 칸은 공용 스크롤 칸(쪼갠 뒤) 안에 둔다 — 쪼개기 전이면 패널 루트의 단추 묶음 앞. + const place = (element: HTMLElement): void => { + const scroll = root.querySelector(":scope > .ui-sidebar-scroll"); + if (scroll) scroll.append(element); + else root.insertBefore(element, dock.root); + }; + const show = (target: DesignView): void => { const wanted = new Set(panelSectionsFor(target).map((def) => def.id)); for (const def of panelSectionsFor(target)) { @@ -120,12 +127,12 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De const sectionRoot = "root" in made ? made.root : made; if ("root" in made && made.dock) dock.root.prepend(made.dock); built.set(def.id, sectionRoot); - root.insertBefore(sectionRoot, dock.root); + place(sectionRoot); } // 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게. for (const def of panelSectionsFor(target)) { const element = built.get(def.id); - if (element) root.insertBefore(element, dock.root); + if (element) place(element); } built.forEach((element, id) => { // 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다. @@ -144,7 +151,9 @@ export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): De dock, setView: (next) => { dock.setView(next); - root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form"; + // 클래스 통째 덮기 금지 — 공용 쪼개기가 붙인 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다. + root.classList.toggle("b05-route__panel", next === "profile"); + root.classList.toggle("b06-profile__form", next !== "profile"); show(next); }, }; diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 18fe1a8ba..622ceb954 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -12,6 +12,7 @@ import { ROUTES } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; +import { splitSidebarActions } from "@ui/ui_template_overlay"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav"; import { navigateTo } from "../router"; @@ -229,6 +230,9 @@ export function sharedPanel(shared: DesignPageShared): DesignPanel { shared.view(), ); shared.left.append(panel.root); + // 틀이 먼저 섰으면 좌측 자리가 빈 채로 지나갔다 — 붙인 지금 [스크롤 칸][아래 고정 단추]로 쪼갠다. + const body = shared.left.closest(".ui-workflow-overlay__body"); + if (body) splitSidebarActions(body); shared.panel = panel; return panel; } diff --git a/resources/tester/test_design_panel_dock_fixed.py b/resources/tester/test_design_panel_dock_fixed.py new file mode 100644 index 000000000..d507560c9 --- /dev/null +++ b/resources/tester/test_design_panel_dock_fixed.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""B05 · B06 좌측 패널 아래 고정(계획서 44-3) — 단추 묶음이 패널 바닥에 붙고 위 칸만 스크롤. + +공용 쪼개기(`splitSidebarActions`)가 [스크롤 칸][아래 고정 단추] 로 나누는데, 한 페이지(design_page)는 +좌측 자리를 빈 채 틀에 넘겨 쪼개기가 지나가고, 보기 전환이 패널 클래스를 통째 덮어 `ui-sidebar-fill` 을 +지웠다. 세 자리가 다시 어긋나면 빨강. 화면 수치는 `ui_checks/44-3_좌측패널_하단고정.js`. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OVERLAY = ROOT / "ui_template" / "ui_template_overlay.ts" +PANEL = ROOT / "A00_Common" / "design" / "design_panel.ts" +SHARED = ROOT / "A00_Common" / "design" / "design_shared.ts" + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_split_is_exported_and_idempotent(): + src = read(OVERLAY) + assert "export function splitSidebarActions(" in src + assert 'if (body.classList.contains("ui-sidebar-split")) return;' in src + + +def test_shared_panel_splits_after_attach(): + src = read(SHARED) + attach = src.index("shared.left.append(panel.root);") + assert "splitSidebarActions(body)" in src[attach : attach + 400] + + +def test_panel_keeps_fill_class_and_scroll_host(): + src = read(PANEL) + # 보기 전환이 클래스를 통째 덮으면 ui-sidebar-fill 이 지워져 단추 묶음이 바닥에서 뜬다. + assert "root.className = next" not in src + # 늦게 서는 칸은 스크롤 칸 안으로 — 패널 루트에 바로 끼우면 스크롤 밖에 남는다. + assert ":scope > .ui-sidebar-scroll" in src diff --git a/resources/tester/ui_checks/44-3_좌측패널_하단고정.js b/resources/tester/ui_checks/44-3_좌측패널_하단고정.js new file mode 100644 index 000000000..67dc1ff1b --- /dev/null +++ b/resources/tester/ui_checks/44-3_좌측패널_하단고정.js @@ -0,0 +1,69 @@ +/* 44-3 — B05 · B06 좌측 패널 아래 고정(구조물 목록 · 초기화 · 저장 · 보기 전환 단추 묶음) + * 시작 화면: B05 종단설계(#/b05-profile) · 좌측 패널 열림 · 프로젝트 = 환경 배정 storage 프로젝트 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치(B05 → B06 → B05 · 각 보기에서 보통 · 긴 패널 둘): + * - split = true · 패널 루트 자식 = [ui-sidebar-scroll, 단추 묶음] · 칸은 모두 스크롤 칸 안(loose = 0) + * - dockGap = 패널 몸통 바닥 − 단추 묶음 바닥 = 0(±1px) + * - 긴 패널(스크롤 칸에 2000px 임시 칸): overflow = true · dockGap 그대로 0 · 단추 묶음 top 안 바뀜 + * - 끝나면 임시 칸 지우고 처음 주소로 되돌림 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const start = location.hash; + const measure = async (tag) => { + const panel = document.querySelector(".b05-route__panel, .b06-profile__form"); + if (!panel) return { tag, error: "패널 없음" }; + const body = panel.closest(".ui-workflow-overlay__body"); + const scroll = panel.querySelector(":scope > .ui-sidebar-scroll"); + const dock = panel.querySelector(":scope > .ui-sidebar-actions"); + const kids = [...panel.children].map((c) => c.classList[0]); + const gap = () => + Math.round(body.getBoundingClientRect().bottom - dock.getBoundingClientRect().bottom); + const res = { + tag, + view: panel.classList.contains("b06-profile__form") ? "B06" : "B05", + split: body.classList.contains("ui-sidebar-split"), + kids, + loose: kids.filter((k) => k !== "ui-sidebar-scroll" && !dock?.classList.contains(k)).length, + dockGap: dock ? gap() : null, + }; + if (!scroll || !dock) return res; + const top0 = Math.round(dock.getBoundingClientRect().top); + const tall = document.createElement("div"); + tall.style.cssText = "height: 2000px; flex: none"; + scroll.append(tall); + await w(100); + res.long = { + overflow: scroll.scrollHeight > scroll.clientHeight, + dockGap: gap(), + dockMoved: Math.round(dock.getBoundingClientRect().top) - top0, + }; + tall.remove(); + res.ok = + res.split && + res.loose === 0 && + Math.abs(res.dockGap) <= 1 && + res.long.overflow && + Math.abs(res.long.dockGap) <= 1 && + res.long.dockMoved === 0; + return res; + }; + const go = async (hash) => { + location.hash = hash; + for (let i = 0; i < 40; i++) { + await w(250); + const want = hash.includes("b06") ? ".b06-profile__form" : ".b05-route__panel"; + if (document.querySelector(want + " > .ui-sidebar-actions")) break; + } + await w(500); + }; + const out = []; + await go("#/b05-profile"); + out.push(await measure("B05")); + await go("#/b06-section"); + out.push(await measure("B06")); + await go("#/b05-profile"); + out.push(await measure("B05 다시")); + if (location.hash !== start) await go(start); + return JSON.stringify({ ok: out.every((r) => r.ok), out }); +})(); diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 53cf25053..e25fb4236 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -112,7 +112,9 @@ function readOpenState(key: string, collapseWhenNarrow = false): boolean { * 아예 빼고, 나머지 그룹만 마진·패딩 없는 래퍼(`.ui-sidebar-scroll`)에 담아 그쪽만 * 스크롤시킨다. 페이지는 지금처럼 본문 마지막에 액션 줄을 붙이기만 하면 된다. */ -function splitSidebarActions(body: HTMLElement): void { +export function splitSidebarActions(body: HTMLElement): void { + // 이미 쪼갠 body 는 그대로 — 패널을 늦게 붙이는 화면(B05·B06 한 페이지)이 붙인 뒤 다시 부른다. + if (body.classList.contains("ui-sidebar-split")) return; // 액션 줄은 보통 페이지 패널 루트(b05-route__panel 등) **안**에 있다 — body 직계가 // 아니므로 깊이 무관하게 찾고, 액션 줄의 실제 부모 기준으로 쪼갠다. const actions = body.querySelector(".ui-sidebar-actions"); From 32192e819ab01030ef6ae502bac943a972279ffd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:28:05 +0900 Subject: [PATCH 10/55] =?UTF-8?q?test(M02):=2044-6=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=ED=91=9C=20=ED=99=94=EB=A9=B4=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=C2=B7=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EA=B0=92=20=EC=A4=84=20=ED=95=9C=20=EC=A4=84=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옵션 값 줄 칸 위 여백 없앰 · 작은 글(줄 높이 42 → 22) - resources/tester/ui_checks/44-6_일위대가_표.js 시나리오 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor.css | 9 +- .../tester/ui_checks/44-6_일위대가_표.js | 100 ++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 resources/tester/ui_checks/44-6_일위대가_표.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 3a42026b5..e4f13d0f6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -79,12 +79,17 @@ } /* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */ +.m02-labrow .m02-labrow__optrow td { + padding-top: 0; + padding-bottom: var(--spacing-4); + font-size: var(--text-caption); + line-height: 1.5; +} + .m02-labrow__opt { max-width: 0; overflow: hidden; - padding-top: 0; color: var(--color-text-secondary); - font-size: var(--text-caption); white-space: nowrap; text-overflow: ellipsis; } diff --git a/resources/tester/ui_checks/44-6_일위대가_표.js b/resources/tester/ui_checks/44-6_일위대가_표.js new file mode 100644 index 000000000..e64d366e3 --- /dev/null +++ b/resources/tester/ui_checks/44-6_일위대가_표.js @@ -0,0 +1,100 @@ +/* 44-6 일위대가 단가산출 로직 표 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌. + * + * 시작 화면: M02 마스터 템플릿(시스템 관리자) · 좌측 일위대가 줄 「SU-000003」 이 보이는 상태 · + * 첫 로직 줄(GF000219 돌쌓기)에 고르기 입력이 있음 · 저장은 안 함(끝에 [다시 열기] → 확인 → 버림). + * 기대 수치: + * heads[2] = "별칭" · alias.inputs = 줄 수 · foot.totalBorder ≥ 1.5(2px · 화면 배율) · foot.totalBg ≠ 다른 금액 칸 배경 + * opt.row = true · opt.colspan = 8(이름 ~ 합계) · opt.nowrap = "nowrap" · opt.ellipsis = "ellipsis" · + * opt.height < 30(한 줄) · opt.mainGrow ≤ 2(옵션을 넣어도 위 줄 높이 그대로) · opt.title = 옵션 글 전체 + * alias.noModal = true(칸 누름이 입력 모달을 안 엶) · alias.dirty = true · alias.kept = "시험별칭"(다시 그려도 남음) + * discarded = true(버린 뒤 별칭 빈 칸 · 옵션 줄 처음과 같음) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const btn = (root, text) => $$("button", root).find((b) => b.innerText.trim() === text); + const out = {}; + const side = $$(".m02-side__item").find((b) => b.innerText.startsWith("SU-000003")); + if (!side) return JSON.stringify({ error: "SU-000003 줄 없음" }); + side.click(); + await W(5000); + const table = () => $(".m02-labrow"); + const first = () => $("tbody tr[data-labor-logic]", table()); + const key = first()?.getAttribute("data-labor-logic"); + if (!key) return JSON.stringify({ error: "로직 줄 없음" }); + const optRow = () => $(`tr[data-labor-opt="${key}"]`, table()); + out.heads = $$("thead th", table()).map((t) => t.innerText.trim()); + // ③ 전체 합계 칸 + const total = $("tfoot .m02-labrow__total", table()); + const other = $$("tfoot .m02-labrow__money", table())[0]; + const cs = total && getComputedStyle(total); + out.foot = { + total: !!total, + totalBorder: cs ? parseFloat(cs.borderTopWidth) : 0, + totalBg: cs?.backgroundColor, + otherBg: getComputedStyle(other).backgroundColor, + }; + out.foot.colored = out.foot.totalBg !== out.foot.otherBg; + // ② 옵션 값 줄 — 입력 모달에서 고르기 칸을 모두 끝 값으로 + const optBefore = optRow()?.innerText ?? null; + const mainBefore = first().getBoundingClientRect().height; + $(".m02-labrow__name", first()).click(); + await W(500); + const im = $(".m02-labin"); + const picked = []; + for (const s of $$("select[data-labor-input]", im)) { + const opts = [...s.options].filter((o) => o.value); + if (!opts.length) continue; + s.value = opts[opts.length - 1].value; + s.dispatchEvent(new Event("change", { bubbles: true })); + picked.push(s.getAttribute("data-labor-input")); + } + btn(im, "확인")?.click(); + await W(3500); + const o = optRow(); + const td = o && $(".m02-labrow__opt", o); + const tcs = td && getComputedStyle(td); + out.opt = { + picked, + row: !!o, + colspan: td ? Number(td.getAttribute("colspan")) : null, + nowrap: tcs?.whiteSpace, + ellipsis: tcs?.textOverflow, + height: o ? Math.round(o.getBoundingClientRect().height) : null, + mainGrow: Math.round(first().getBoundingClientRect().height - mainBefore), + title: td?.getAttribute("title"), + text: td?.innerText, + inName: !!$(".m02-labrow__name .m02-labrow__note", first())?.innerText.includes( + picked[0] ?? "\u0000", + ), + }; + // ④ 별칭 — 칸 누름 · 입력 · 다시 그려도 남음 + out.alias = { inputs: $$("[data-labor-alias]", table()).length }; + const ai = $("[data-labor-alias]", first()); + ai.click(); + await W(300); + out.alias.noModal = !$(".m02-labin"); + ai.value = "시험별칭"; + ai.dispatchEvent(new Event("input", { bubbles: true })); + await W(200); + out.alias.dirty = + !!btn($(".m02-main__head"), "저장") && !btn($(".m02-main__head"), "저장").disabled; + // 다시 그림(입력 모달 확인 → 다시 셈) 뒤에도 남는지 + $(".m02-labrow__name", first()).click(); + await W(500); + btn($(".m02-labin"), "확인")?.click(); + await W(3500); + out.alias.kept = $("[data-labor-alias]", first())?.value; + // 버림 + btn($(".m02-main__head"), "다시 열기").click(); + await W(600); + btn($(".ui-confirm"), "확인")?.click(); + await W(4500); + out.after = { + alias: $("[data-labor-alias]", first())?.value, + opt: optRow()?.innerText ?? null, + }; + out.discarded = out.after.alias === "" && out.after.opt === optBefore; + return JSON.stringify(out); +})(); From 58a9b3e8b780888ac78434d724f6d3c1de75100b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:28:10 +0900 Subject: [PATCH 11/55] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EA=B5=AC=EA=B0=84=20=EC=9E=AC?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EC=B2=AB=20=EC=A0=90=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=B8=B0=EC=96=B5=20=C2=B7=20=EB=91=98=EC=A7=B8=20?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20=EC=BB=A4=EC=84=9C=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EB=8A=94=20=EA=B7=B8=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=EC=97=90=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 점을 찍은 노선(계획 · 예상)만 둘째 점 후보 — 겹친 다른 노선은 무시 · 첫 점이 노선 밖이면 전과 같음 - 첫 점 뒤 커서 미리보기(둘째 점 · 구간 덧그림) 추가 — 첫 점 노선 위에만 - 길이 = 노선 따라 잰 누가거리 차(전과 같음) - 검증 시나리오 ui_checks/44-1 · 44-1b Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../B05_Profile_UI_RouteEdit_Measure.ts | 37 ++++++-- .../B05_Profile_UI_RouteEdit_Pointer.ts | 8 +- .../ui_checks/44-1_구간재기_노선기억.js | 82 +++++++++++++++++ .../tester/ui_checks/44-1b_구간재기_화면.js | 88 +++++++++++++++++++ 4 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 resources/tester/ui_checks/44-1_구간재기_노선기억.js create mode 100644 resources/tester/ui_checks/44-1b_구간재기_화면.js diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index b7cffb06d..33b9467c9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -4,7 +4,8 @@ * * Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든 * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선 - * (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다. + * (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만 + * 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차. * * 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는 * 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를 @@ -56,6 +57,8 @@ export interface MeasureTool { hint: () => string; /** 재고 있나 — 작은 창을 띄울지 정하는 값. */ active: () => boolean; + /** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */ + preview: (px: number, py: number) => boolean; /** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */ pick: (px: number, py: number) => Promise; /** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */ @@ -109,6 +112,8 @@ export function bindMeasureControls(params: MeasureControlsParams): { export function createMeasureTool(params: MeasureToolParams): MeasureTool { /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ let picked: MeasurePoint[] = []; + /** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */ + let hover: MeasureMark | null = null; const hint = (): string => { if (picked.length === 0) return ""; @@ -133,10 +138,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { ); }; - /** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */ + /** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */ const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => { const lines = params.lines(); - const hits = (["planned", "expected"] as const) + const candidates: MeasureLine[] = + picked.length === 1 ? [picked[0].line] : ["planned", "expected"]; + const hits = candidates .map((line) => { const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX); if (!hit) return null; @@ -145,30 +152,42 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { }) .filter((hit): hit is NonNullable => hit !== null); if (!hits.length) return null; - const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined; - const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a)); + const best = hits.reduce((a, b) => (b.distance < a.distance ? b : a)); return { point: best.point, chainageM: best.chainageM, line: best.line }; }; return { - marks: () => - picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })), + marks: () => [ + ...picked.map((entry) => ({ + point: entry.point, + chainageM: entry.chainageM, + line: entry.line, + })), + ...(picked.length === 1 && hover ? [hover] : []), + ], hint, active: () => picked.length > 0, + preview(px, py) { + const next = picked.length === 1 ? hitAt(px, py) : null; + if (!next && !hover) return false; + hover = next; + return true; + }, clear() { + hover = null; if (picked.length === 0) return; picked = []; params.onChange(); }, async pick(px, py) { const hit = hitAt(px, py); + hover = null; if (!hit) { picked = []; // 노선을 빗나가면 재던 것을 접는다. params.onChange(); return; } - const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line); - picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; + picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; params.onChange(); if (picked.length < 2) return; const asked = picked; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index a3eb75eb0..ac87885c4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -71,7 +71,11 @@ export interface RouteEditPointerParams { /** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */ unrotate: (x: number, y: number) => [number, number]; contourStepM: () => number; - measure: { pick: (px: number, py: number) => unknown; clear: () => void }; + measure: { + pick: (px: number, py: number) => unknown; + preview: (px: number, py: number) => boolean; + clear: () => void; + }; /** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */ measureMode: () => boolean; crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown }; @@ -287,6 +291,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP params.draw(); return; } + // 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다. + if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw(); const overNode = nodeAt(px, py); // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. canvas.style.cursor = diff --git a/resources/tester/ui_checks/44-1_구간재기_노선기억.js b/resources/tester/ui_checks/44-1_구간재기_노선기억.js new file mode 100644 index 000000000..a0a9f779b --- /dev/null +++ b/resources/tester/ui_checks/44-1_구간재기_노선기억.js @@ -0,0 +1,82 @@ +/* 44-1 · B05 계획노선 편집 구간 재기 — 첫 점 노선 기억(둘째 점 · 커서 미리보기는 그 노선에만) + * 시작 화면: 아무 화면(localhost vite 포트) — 실제 `_RouteEdit_Measure.ts` 를 vite 로 불러 가짜 두 노선에 돌림 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 가짜 노선(화면 = 미터): 계획 = (0,0)→(100,0)→(100,100) 꺾은선 · 예상 = (0,6)→(200,6) 곧은선(계획 위 6px 에 겹침) + * 기대 수치: + * - overlap.second = "planned" · overlap.len = "100.0"(계획 첫 점 (10,0) → 꺾임 지나 (100,10) · 노선 따라 잰 값) + * - otherOnly.marks = 0(계획 첫 점 뒤 예상노선만 닿는 자리 (150,6) = 빗나감 · 예상으로 새로 시작 안 함) + * - previewOther.marks = 1(예상만 닿는 자리 미리보기 없음) · previewOwn = [2, "planned", 50](계획 위 미리보기) + * - expectedFirst.second = "expected" · len = "60.0"(첫 점이 예상이면 계획이 더 가까워도 예상에 붙음) + * - firstMiss.marks = 0(첫 점이 노선 밖이면 지금처럼 아무것도 안 찍힘) + */ +(async () => { + // vite root = A00_Common — 저장소 뿌리는 main.ts 가 옮겨 적은 /@fs/ 주소에서 얻음 + const repo = (await (await fetch("/main.ts")).text()).match( + /\/@fs\/[^"']*?\/(?=config\/config_frontend)/, + )[0]; + const { createMeasureTool } = await import( + `${repo}B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts` + ); + const lines = { + planned: [ + [0, 0], + [100, 0], + [100, 100], + ], + expected: [ + [0, 6], + [200, 6], + ], + }; + const make = () => + createMeasureTool({ + projectId: "ui-check-none", + stationIntervalM: 20, + lines: () => lines, + toScreen: (v) => [v[0], v[1]], + isClosed: () => true, // 지반고 응답은 버림 — 길이만 본다 + onChange: () => {}, + }); + const out = {}; + const lenOf = (tool) => (tool.hint().match(/길이 ([\d.]+)m/) || [])[1] ?? null; + + let tool = make(); + await tool.pick(10, 1); // 계획 (1px) · 예상 (5px) 둘 다 닿음 → 가까운 계획 + await tool.pick(100, 10); // 계획 꺾인 다리(0px) 위 · 예상도 4px + out.overlap = { second: tool.marks()[1]?.line, len: lenOf(tool) }; + + tool = make(); + await tool.pick(10, 1); + await tool.pick(150, 6); // 예상만 닿음 + out.otherOnly = { marks: tool.marks().length }; + + tool = make(); + await tool.pick(10, 1); + tool.preview(150, 6); + out.previewOther = { marks: tool.marks().length }; + tool.preview(50, 4); // 예상(2px)이 더 가까워도 계획(4px)에 붙어야 함 + const m = tool.marks(); + out.previewOwn = [m.length, m[1]?.line, m[1]?.point[0]]; + + tool = make(); + await tool.pick(10, 7); // 예상(1px)이 가까움 → 예상 + await tool.pick(70, 1); // 계획(1px)이 가까워도 예상(5px) + out.expectedFirst = { second: tool.marks()[1]?.line, len: lenOf(tool) }; + + tool = make(); + await tool.pick(50, 60); // 노선 밖 + out.firstMiss = { marks: tool.marks().length }; + + out.pass = + out.overlap.second === "planned" && + out.overlap.len === "100.0" && + out.otherOnly.marks === 0 && + out.previewOther.marks === 1 && + out.previewOwn[0] === 2 && + out.previewOwn[1] === "planned" && + out.previewOwn[2] === 50 && + out.expectedFirst.second === "expected" && + out.expectedFirst.len === "60.0" && + out.firstMiss.marks === 0; + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/44-1b_구간재기_화면.js b/resources/tester/ui_checks/44-1b_구간재기_화면.js new file mode 100644 index 000000000..095380fc1 --- /dev/null +++ b/resources/tester/ui_checks/44-1b_구간재기_화면.js @@ -0,0 +1,88 @@ +/* 44-1b · B05 계획노선 편집 구간 재기 — 실제 화면 배선(찍기 · 커서 미리보기) + * 시작 화면: B05 종단설계 · [계획노선 편집] 창이 떠 있음(노선이 있는 프로젝트 · 저장 안 함) + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: 격자(20px)로 찍어 계획 · 예상 노선 위 자리를 모은 뒤 + * 계획 a 를 찍고 → 먼 계획 자리로 커서 이동(미리보기 초록 픽셀 늘어남) + * → 계획에서 먼 예상 자리로 커서 이동(미리보기 사라짐 = a 만 남은 초록 픽셀) + * → 그 예상 자리를 찍음(빗나감 또는 계획에 붙음 · 예상노선으로 안 바뀜) → 계획 a · b 를 찍음(「구간 … 길이」) + * 기대 수치: previewOn > previewBase · previewOff = previewBase · afterOther.text 가 「예상노선」으로 시작 안 함 + * · final 에 「길이」 있고 「예상노선」 없음 · 끝나면 잰 것 지우고 재기 단추 끔 + */ +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const o = document.querySelector(".b05-routeedit"); + const c = o.querySelector("canvas"); + const box = o.querySelector(".b05-routeedit__measure"); + const text = o.querySelector(".b05-routeedit__measure-text"); + const btn = o.querySelector('[data-act="measure"]'); + const close = o.querySelector(".b05-routeedit__measure-close"); + const r = c.getBoundingClientRect(); + // pointerType 이 비면 지도 끌기(pan)가 잡힘 — mouse 로 · 누르면 바로 뗌 + const fire = (type, x, y) => + c.dispatchEvent( + new PointerEvent(type, { + button: 0, + clientX: x, + clientY: y, + pointerId: 1, + pointerType: "mouse", + bubbles: true, + }), + ); + const ev = (type, x, y) => { + fire(type, x, y); + if (type === "pointerdown") fire("pointerup", x, y); + }; + const green = () => { + const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) + if (d[i] === 34 && d[i + 1] === 197 && d[i + 2] === 94) n++; + return n; + }; + if (!btn.classList.contains("is-active")) btn.click(); + const hits = { planned: [], expected: [] }; + for (let y = r.top + 20; y < r.bottom - 20; y += 20) + for (let x = r.left + 20; x < r.right - 20; x += 20) { + ev("pointerdown", x, y); + if (/시작/.test(text.textContent)) + hits[/^예상노선/.test(text.textContent) ? "expected" : "planned"].push([x, y]); + if (!box.hidden) close.click(); // 닫기 = 지움 · 재기 모드도 꺼짐 + if (!btn.classList.contains("is-active")) btn.click(); + } + const far = (p, list, d) => list.every((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) > d); + const a = hits.planned[0]; + const b = hits.planned.find((p) => far(p, [a], 150)); + // 계획 자리에서 가장 먼 예상 자리 — 계획노선이 14px 밖이어야 「다른 노선만 닿는 자리」 + const gap = (p) => Math.min(...hits.planned.map((q) => Math.hypot(p[0] - q[0], p[1] - q[1]))); + const other = hits.expected.reduce((best, p) => (!best || gap(p) > gap(best) ? p : best), null); + const out = { found: { planned: hits.planned.length, expected: hits.expected.length } }; + out.otherGapPx = other && Math.round(gap(other)); + if (!a || !b || !other) return JSON.stringify({ ...out, error: "자리 못 찾음" }); + ev("pointerdown", ...a); + await wait(100); + out.previewBase = green(); + ev("pointermove", ...b); + await wait(100); + out.previewOn = green(); + ev("pointermove", ...other); + await wait(100); + out.previewOff = green(); + ev("pointerdown", ...other); + await wait(50); + out.afterOther = { hidden: box.hidden, text: text.textContent }; + ev("pointerdown", ...a); + out.finalA = text.textContent; + ev("pointerdown", ...b); + await wait(50); + out.final = text.textContent; + if (!box.hidden) close.click(); + if (btn.classList.contains("is-active")) btn.click(); + out.pass = + out.previewOn > out.previewBase && + out.previewOff === out.previewBase && + !/^예상노선/.test(out.afterOther.text) && + /길이/.test(out.final) && + !/예상노선/.test(out.final); + return JSON.stringify(out); +})(); From 7d39f90a0eff7a5e1adbe62fd5bea2a366cbd3f5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 11:31:09 +0900 Subject: [PATCH 12/55] =?UTF-8?q?feat(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=84=B8=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=C2=B7=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=EB=8F=84/=EC=9B=90=EB=AC=B8=20=EB=91=90=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 차례 = 기본정보 › 시험 계산 › 단가산출 상세 › 텍스트 수식 - 오른쪽 패널 둘(흐름도 · 원문) — 하나만 열림 · 둘 다 접기 · 머리 토글 단추 · 열린 패널 계정 취향(m01-logic-panel) - 흐름도 패널 접힘을 부르는 쪽이 정하는 길(folded · onFold · fold) - 원문 패널 = 출처 글로 품셈 본문 절을 찾아 보임(GET /api/m01/logic/source) · 못 찾으면 「원문 연결 없음」 + 출처 - 시험 test_m01_logic_source.py · 화면 시나리오 ui_checks/44-4_로직상세_패널.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- A00_Common/b_page_state.ts | 2 + M01_MasterData/M01_MasterData_Router.py | 6 + M01_MasterData/M01_MasterData_Store_Make.py | 73 +++++++ M01_MasterData/M01_MasterData_UI_LogicLab.ts | 16 +- .../M01_MasterData_UI_LogicLab_Detail.ts | 8 +- .../M01_MasterData_UI_LogicLab_Flow.ts | 8 + .../M01_MasterData_UI_LogicLab_Side.css | 54 +++++ .../M01_MasterData_UI_LogicLab_Side.ts | 191 ++++++++++++++++++ M02_MasterTemplete/DataFlow/M02_DF_Panel.ts | 15 +- resources/tester/test_m01_logic_source.py | 55 +++++ .../tester/ui_checks/44-4_로직상세_패널.js | 103 ++++++++++ 11 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Side.css create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts create mode 100644 resources/tester/test_m01_logic_source.py create mode 100644 resources/tester/ui_checks/44-4_로직상세_패널.js diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 2c411ffe8..37d5c757f 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -64,6 +64,8 @@ export const STATE_REGISTRY = { "m02-combo-panel-width": { bucket: "pref", scope: "global" }, /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ "m02-card-pills": { bucket: "pref", scope: "global" }, + /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ + "m01-logic-panel": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index f487a6d78..5b0aaa349 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -190,6 +190,12 @@ def get_logic(key: str) -> dict: return _call(store.logic, key) +@router.get("/logic/source") +def get_logic_source(src: str) -> dict: + """로직 원문 — 출처 글로 찾은 품셈 본문 절(읽기만).""" + return _call(make.source, src) + + @router.get("/logic/auto") def get_logic_auto(key: str) -> dict: """견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값.""" diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 76dc319d9..c5ab2df45 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -103,6 +103,79 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]} +# ── 로직 원문(출처 글 → 품셈 본문 절) ────────────────────────────────── +_NUMBER = re.compile(r"(? list[dict]: + """md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌.""" + out: list[dict] = [] + for line in lines: + text = line.strip() + if not text or text.startswith("