909 Commits
Author SHA1 Message Date
eomsangdon a7b10ab741 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-05 21:08:00 +09:00
eomsangdon 1d2ccba49c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-05 21:08:00 +09:00
eomsangdon baee8d8dd4 Merge commit '4f09b082e' into sub_laptop_4 2026-10-05 21:07:41 +09:00
eomsangdonandClaude Opus 5.5 a7a8faa9b3 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 21:07:41 +09:00
eomsangdon f8ae4b59c0 Merge commit '4f09b082e' into sub_laptop_3 2026-10-05 21:07:37 +09:00
eomsangdonandClaude Opus 5.5 0a9432167e chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 21:07:37 +09:00
eomsangdon 222b08b4f4 Merge commit '4f09b082e' into sub_laptop_1 2026-10-05 21:07:31 +09:00
eomsangdonandClaude Opus 5.5 31bef903cf chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 21:07:30 +09:00
eomsangdon 4f09b082ee Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 21:06:36 +09:00
eomsangdonandClaude Opus 5.5 b7b827a831 chore(sync): 공유 docs 파일 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 21:05:43 +09:00
eomsangdonandClaude Opus 5.5 47cbbd17bd feat(M02): 51-14 일위대가 순서 칸 2행 — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 단가승격 [옵션] · 일위조합 [수정] 서고 원본 모달
- 순서 칸 = 단추 묶음 두 줄(공용 ui-btn-group 간격) · 카드 갈래는 아래 줄만
- 단가승격 줄 [옵션] = 승격 문서 넣을 값 모달 · [저장] = 서고 저장 길 · 머리 한 줄 알림(쓰는 모든 구조물에 따라감)
- 일위조합 줄 [수정] = 사전조합 상세 모달(서고 모드 표 · 옵션 · 수량 · 순서 · 삭제 · 추가) · [저장] = 서고 원본 · 취소 = 버림 물음
- 자기 층 서고만 고침(위 층 = 읽기만) · 닫으면 구조물 일위대가 다시 셈
- 확인 시나리오 resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 21:05:31 +09:00
eomsangdonandClaude Opus 5.5 524c89d85c chore(plan): 10-5 순서 칸 2행 · 승격 옵션 · 조합 수정 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:58:39 +09:00
eomsangdonandClaude Opus 5.5 a522c1979b chore(M02): 사용자 편집 — 마스터 서고 사전조합 SC-000001(구분 고름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:58:11 +09:00
eomsangdon b0c80d63f5 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
충돌(Labor_Api 주석 · Labor_Pick 서고 줄 보조 글) = sub3 51-12 고르기 판 취함(같은 걸러 보기 · 빈 값 거름)
2026-10-05 20:57:21 +09:00
eomsangdon 25791197df Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-05 20:56:14 +09:00
eomsangdonandClaude Opus 5.5 a3368f206d chore(plan): 10-5 서고 구분 · 일위조합 하위 행 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:56:05 +09:00
eomsangdon a8045bbacc Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 20:55:56 +09:00
eomsangdonandClaude Opus 5.5 f6bcfa52ce chore(sync): 공유 docs 파일 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 20:55:15 +09:00
eomsangdonandClaude Opus 5.5 86d4f3e882 fix(M02): 51-13 일위조합 줄 하위 행 — 세부 항목 한 층(참조 줄 · 로직 줄) 노무비 · 재료비 · 경비 = 안 줄 기준 × 안 줄 수량
- 하위 행 이름이 참조 키만 읽어 로직 안 줄(51-11)에서 표가 깨짐 → 참조 키 없으면 로직 키
- calc 답 「안」 타입에 로직 줄 자리 추가
- 확인 시나리오 resources/tester/ui_checks/51-13_일위조합_하위행.js (calc 답 바꿔 끼움 · 서고 저장 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 20:55:11 +09:00
eomsangdon a92f768952 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-10-05 20:53:51 +09:00
eomsangdon 354eb577bd Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-05 20:53:40 +09:00
eomsangdonandClaude Opus 5.5 74af9f8d99 chore(sync): 공유 docs 파일 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 20:52:54 +09:00
eomsangdonandClaude Opus 5.5 ce211f779b fix(M02): 사전조합 구분 빈 값 = 「—」 선택지 · 연결 = {로직, 출처}(51-12 서버 이음)
- 옛 사전조합(구분 빈 글)이 첫 구분으로 보이던 어긋남 없앰
- 화면 시나리오 51-12 [만들기] 단추 이름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 20:52:53 +09:00
eomsangdon ce9238755e Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1 2026-10-05 20:50:05 +09:00
eomsangdonandClaude Opus 5.5 e66c92f562 chore(sync): 공유 docs 파일 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 20:50:05 +09:00
eomsangdon 25dda4d851 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_3 2026-10-05 20:49:53 +09:00
eomsangdonandClaude Opus 5.5 0793543803 fix(M02): 고르기 창 서고 줄 보조 글 — 구분 없으면 끝 「·」 안 붙임 (51-12 화면)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 20:49:53 +09:00
eomsangdonandClaude Opus 5.5 dda5f6233f feat(M02): 서고 구분 · 상세구분 · 연결 화면(51-12)
- 서고 목록표에 구분 · 상세구분 칸(키 뒤)
- 단가승격 상세 정보 칸 = 구분 · 상세구분 · 연결(원 로직 키 · 출처) 읽기만
- 사전조합 상세 정보 칸 = 구분 · 상세구분 고르기(로직 구분 목록 logic-lib/subs · 구분 바꾸면 상세구분 = 그 첫 줄)
- 새 사전조합 처음 값 = 로직 구분 목록 첫 줄
- 로직 고르기 창 구분 · 상세구분 거름에 승격 · 사전조합 줄도 걸림
- 화면 시나리오 51-12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 20:49:43 +09:00
eomsangdonandClaude Opus 5.5 0b11333942 feat(M02): 단가승격 · 사전조합 문서 구분 · 상세구분 · 연결 칸 — 51-12 서버
- 단가승격 = 저장 때 원 로직의 구분 · 상세구분 · 연결 {로직 키, 출처 글} 적음(화면 값 안 받음)
- 사전조합 = 화면이 고른 구분 · 상세구분 저장(빈 글 허용 · 처음 값은 화면 몫)
- 서고 목록 길 · 고르기 길(층 양식 목록) 줄에 구분 · 상세구분 · 연결 실음 · 양식 읽기 길도
- 옛 문서(칸 없음) = 읽을 때 채움 — 승격은 원 로직 것 · 사전조합은 빈 글
- 시험 test_m02_labor_ref 23 통과 · 층 읽기 시험 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 20:48:54 +09:00
eomsangdonandClaude Opus 5.5 28e535f490 feat(M02): 로직 고르기 창 구분 · 상세구분 거름에 단가승격 · 사전조합 줄 포함 (51-12 화면)
- 구분 · 상세구분 고르기 칸 = 로직 구분 + 서고 줄(승격 · 조합) 구분 · 같은 값은 한 칸
- 구분을 고르면 서고 줄도 같은 값으로 걸러짐(이전 = 서고 줄 숨김)
- 서고 줄 보조 글에 구분 · 상세구분 · 사전조합 상세 [추가] 창도 같은 창
- 확인 시나리오 resources/tester/ui_checks/51-12_고르기_구분.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 20:47:49 +09:00
eomsangdon c7a84a0ed9 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-10-05 20:35:20 +09:00
eomsangdon bc85368852 Merge commit '063ae7482' into sub_laptop_4 2026-10-05 20:34:51 +09:00
eomsangdonandClaude Opus 5.5 845f868212 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:34:51 +09:00
eomsangdon 0bec4fb06a Merge commit '063ae7482' into sub_laptop_3 2026-10-05 20:34:46 +09:00
eomsangdonandClaude Opus 5.5 97902e2be3 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:34:46 +09:00
eomsangdon 69a420c27f Merge commit '063ae7482' into sub_laptop_2 2026-10-05 20:34:41 +09:00
eomsangdonandClaude Opus 5.5 2a599a7146 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:34:41 +09:00
eomsangdon 534d9ea41c Merge commit '063ae7482' into sub_laptop_1 2026-10-05 20:34:35 +09:00
eomsangdonandClaude Opus 5.5 f6dadbf104 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:34:35 +09:00
eomsangdon 063ae74828 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-05 20:33:46 +09:00
eomsangdonandClaude Opus 5.5 b3cf5559a2 chore(plan): 10-5 사전조합 고르기 정정 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:33:40 +09:00
eomsangdon d495f78759 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1 2026-10-05 20:31:15 +09:00
eomsangdonandClaude Opus 5.5 bec4258bd3 feat(M02): 사전조합 [로직 추가] = 일위대가 상세와 같은 고르기 창 · 같은 줄 동작(51-11 화면)
- 고르기 창 서고 줄만 거름(only) 없앰 — 단가산출 · 단가승격 · 일위조합 모두 · 구분 · 상세구분 거름
- 사전조합 단가산출 로직 줄 = [옵션] · 수량(손 · 기본 1) · 위로 · 아래로 · 삭제 · 하위 행 일위대가 상세와 같음
- 시험 기대 글 고침(card_shell) · 화면 시나리오 51-11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 20:31:11 +09:00
eomsangdonandClaude Opus 5.5 31874ea3a6 feat(m02): 사전조합 담은로직에 단가산출 로직 줄 허용 — 51-11 서버
- 줄 = {참조, 수량, 메모} | {로직, 입력값, 메모, 수량} · 수량 기본 {손: 1}
- 검사 = 일위대가 labor.clean 같은 길(모르는 로직 · 같은 로직 · 같은 참조 두 번 · 입력값 꼴)
- 펼침 계산 = 로직 줄 run_line 기준 ÷ 배 × 수량 · 안 줄에 로직 줄
- 로직 지우기 검사에 사전조합 포함 · 굳힘 로직 목록에 사전조합 로직 포함
- 시험 test_m02_labor_ref 22 통과(섞인 사전조합 합 = 손셈 · 지우기 409)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 20:29:39 +09:00
eomsangdon 9e2ce4ed2c Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-10-05 20:10:04 +09:00
eomsangdon 73f6e46564 Merge commit 'da1fe4020' into sub_laptop_4 2026-10-05 20:09:26 +09:00
eomsangdonandClaude Opus 5.5 b9be232dc0 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:09:26 +09:00
eomsangdon ec0a430421 Merge commit 'da1fe4020' into sub_laptop_3 2026-10-05 20:09:21 +09:00
eomsangdonandClaude Opus 5.5 98122d46a4 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:09:21 +09:00
eomsangdon 673e634d69 Merge commit 'da1fe4020' into sub_laptop_2 2026-10-05 20:09:15 +09:00
eomsangdonandClaude Opus 5.5 82d3f144c3 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:09:15 +09:00
eomsangdon eb3e938fd1 Merge commit 'da1fe4020' into sub_laptop_1 2026-10-05 20:09:09 +09:00
eomsangdonandClaude Opus 5.5 563c117774 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:09:08 +09:00
eomsangdonandClaude Opus 5.5 da1fe40202 test(M02): 구조물 패널 시험 Kind 글 — 50-3 서고 종류(promote · precombo) 더한 꼴에 맞춤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:08:39 +09:00
eomsangdon 68de87645c Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_6 2026-10-05 20:07:17 +09:00
eomsangdonandClaude Opus 5.5 9443a0371f feat(M02): 서고 목록표 M01 표 양식 · 사전조합 [로직 추가] 같은 고르기 창(51-10)
- 서고 목록표(단가산출 승격 · 일위대가 사전조합) = M01 단가산출 로직 목록 양식 — 찾기 줄(buildHeadFilters) · 쪽(buildPager · 30줄) · 표 m01-master__grid · 줄 m01-master__table-row 를 M01 에서 import
- 머리 단추 꼴 = M01 [신규 로직] · [로직 복사/생성] · [로직 지우기] 와 같음(filled · ghost · danger 빨강) · 단추 틈 8px(m01-head 와 같음)
- 사전조합 상세 [추가] 따로 단추 없앰 — 일위대가 상세와 같은 [로직 추가] · 같은 고르기 창(Labor_Pick · only = 서고 줄만)
- 시험 기대 글 고침(card_shell 늘 참이던 assert) · 화면 시나리오 51-10 · 51-6 단추 이름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 20:05:32 +09:00
eomsangdonandClaude Opus 5.5 1ea098a119 chore(M02): 사용자 편집 — 마스터 일위대가 item3 · 10-5 요구 원문 덧
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 20:03:22 +09:00
eomsangdon 7243da042f Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-05 20:02:23 +09:00
eomsangdon 9a0d68bc0b Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-05 20:02:22 +09:00
eomsangdonandClaude Opus 5.5 ae04b403d6 fix(ui): 51-8 오른쪽 패널 높이 — 칸 크기가 바뀌면 다시 잼(M01 로직 상세 240 고정 해소)
- 원인: 패널 높이를 붙은 직후 한 번만 잼 · M01 은 내용이 늦게 차 그때 칸이 짧아 최소 240 · 스크롤·창 크기 바뀜 전까지 그대로
- 고침: 패널 · 부모 칸 크기 지켜봄(ResizeObserver) — 다시 붙음 · 내용 늦게 참에도 다시 잼
- 51-1 확인: 스크롤 알림 없는 「그대로」 줄 추가(알림이 흠을 가림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 20:01:14 +09:00
eomsangdonandClaude Opus 5.5 2a914e11eb feat(ui): 공용 찾기 목록 양식 · 로직 고르기 창 적용 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 (51-9)
- ui_template_find_list(.ts · .css) 새로 둠 — 거름 칸 2열 격자 · 찾기 칸 · 알림 줄 · 한 줄 결과 목록(꼬리표 · 이름 · 흐린 보조 글 · 단위) · 고름 하이라이트 · ↑ ↓ Enter
- M02 로직 고르기 창(Labor_Pick)이 이 양식을 씀 — 구분 칸 · 층 단추 · data-pick 표지 그대로
- 확인 시나리오 resources/tester/ui_checks/51-9_고르기_찾기양식.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 19:59:05 +09:00
eomsangdonandClaude Opus 5.5 2cb820a2e8 fix(M02): 51-7 덧 일위대가 하위 행 — 이름 글자 키움 · 줄 촘촘
- 하위 행 이름 12 → 14px · 줄 높이 61 → 40px · 칸 여백 12/16 → 4/8px (위 줄 16px · 85px 그대로)
- ui_checks 51-2 시나리오 — 하위 행 글자 · 높이 px 전후 적음 · font · h · topH 잼

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 19:52:57 +09:00
eomsangdonandClaude Opus 5.5 74c20420c4 fix(M02): 51-7 일위대가 표 줄 이름 앞 ▸/▾ 화살표 없앰 — 줄 누름 펼침 · 같은 줄 다시 누름 접힘 그대로
- 카드 · 상세 같은 표(logicTable) — 화살표 span · .m02-labrow__fold 스타일 걷어냄
- ui_checks 51-2 시나리오 — 열림 = 고른 줄 수 · 화살표 = 0 기대

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 19:50:28 +09:00
eomsangdon 440e063c13 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-05 19:47:31 +09:00
eomsangdon eae1b57607 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-05 19:47:31 +09:00
eomsangdon a63cf8f3f4 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-05 19:47:30 +09:00
eomsangdon 75c00c1322 Merge commit '7f3506e65' into sub_laptop_4 2026-10-05 19:47:23 +09:00
eomsangdonandClaude Opus 5.5 5077a8567a chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:47:23 +09:00
eomsangdon 025e666934 Merge commit '7f3506e65' into sub_laptop_3 2026-10-05 19:47:20 +09:00
eomsangdon f23ca9054c Merge commit '7f3506e65' into sub_laptop_2 2026-10-05 19:47:15 +09:00
eomsangdonandClaude Opus 5.5 705b176eb1 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:47:15 +09:00
eomsangdonandClaude Opus 5.5 7f3506e65e test(M02): 카드 시험 기대 글 — 51-6 사전조합 줄 고르기(addRef) 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:46:13 +09:00
eomsangdon 9e589cf7e3 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
충돌 = Labor_Text 글 키 둘 다 남김(50-4 상세 글 · 51-3 사전조합 글) · docs 수정요구 = 메인 판
2026-10-05 19:45:06 +09:00
eomsangdon d68899994f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-05 19:44:26 +09:00
eomsangdonandClaude Opus 5.5 e4547e4548 chore(M02): 사용자 편집 — 마스터 서고 단가승격 · 사전조합 · 구조물 집계표(화면에서 만듦 · 고침)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:44:15 +09:00
eomsangdonandClaude Opus 5.5 c8cb0dab58 chore(plan): 10-5 서고 · 고르기 창 · 단위 단추 · 사전조합 줄 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:44:15 +09:00
eomsangdon ded40b34b3 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_3 2026-10-05 19:43:53 +09:00
eomsangdon 690d939294 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-10-05 19:43:52 +09:00
eomsangdon 4464bfeb72 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_6 2026-10-05 19:43:51 +09:00
eomsangdon e51bb99c4c Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_1 2026-10-05 19:43:46 +09:00
eomsangdon 018de55f07 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-05 19:43:32 +09:00
eomsangdon 1eb9029285 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 19:43:27 +09:00
eomsangdonandClaude Opus 5.5 a1673aeb10 feat(M02): 서고 사전조합 상세 줄 넣기 — [추가] · 수량 손 입력 · 자기 참조 422(51-6)
- 사전조합 상세 머리 [추가] = 고르기 창 서고 줄만(단가승격 · 사전조합 · 위 층만 · 구분 거름 없음)
- 새 줄 수량 기본 손 1 · 수량 창은 손 입력만(면적산출 시트 없음) · 위로 · 아래로 · 삭제 · [저장]
- 서버: 사전조합이 자기 자신을 담으면 422 「순환 — 자기 자신」 · 단가승격 상세는 한 줄 고정 그대로
- 시험 한 개 · 화면 시나리오 51-6(가짜 응답 · 서고 쓰기 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 19:42:39 +09:00
eomsangdon 2818b5ff99 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1 2026-10-05 19:39:42 +09:00
eomsangdonandClaude Opus 5.5 16bacad5c4 chore(sync): 공유 docs 사용자 요구 파일 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 19:39:13 +09:00
eomsangdonandClaude Opus 5.5 9d9955631f feat(M02): 일위대가 조합 서고 — 좌측 항목 둘 · 상세 목록표(51-4)
- 좌측 컨테이너 = 층 단추 + 「단가산출 승격」 · 「일위대가 사전조합」 항목만 · 새로 · 본떠 · 지우기 단추 없앰
- 항목 누름 = 상세 칸 목록표(고름 · 키 · 이름 · 단위 · 기준 금액 · 고친 날) · 줄 누름 = 그 문서 상세
- 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 까닭 알림)
- 층 목록 서고 줄에 1 단위 기준 계(`기준금액`) 실음 · 시험 한 개 · 화면 시나리오 51-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 19:39:07 +09:00
eomsangdonandClaude Sonnet 5.5 9aeefd44dc feat(M02): 로직 고르기 창 한 목록 — 찾기 단추 · 탭 없앰 · 구분 칸 · 치는 대로 걸러짐 (51-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 19:35:01 +09:00
eomsangdonandClaude Sonnet 5.5 4a4cdc8aa9 fix(ui): 51-5 단위 고르기 단추 = 공용 기본 크기([저장]과 같은 높이 · 글자)
- 공용 UnitPick 한 곳 — 작은 크기 지정 제거
- 시험: ui_checks/51-5_단위단추_크기.js

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 19:33:10 +09:00
eomsangdon c45bf3460e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3
# Conflicts:
#	docs/raw/사용자요구/2026-10-05_수정요구.md
2026-10-05 19:32:29 +09:00
eomsangdon 36a9461f57 chore(sync): 공유 docs 파일 담음 2026-10-05 19:32:14 +09:00
eomsangdon 466dd61517 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
# Conflicts:
#	docs/raw/사용자요구/2026-10-05_수정요구.md
2026-10-05 19:32:03 +09:00
eomsangdonandClaude Opus 5.5 96f2313baf chore(sync): 공유 docs 사용자 요구 파일 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 19:31:55 +09:00
eomsangdonandClaude Opus 5.5 4a5c2bb248 chore(sync): 공유 docs 사용자 요구 원문 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 19:31:09 +09:00
eomsangdonandClaude Opus 5.5 ab599ce334 fix(M02): 51-2 일위대가 줄 상세 — 표 아래 따로 상자 대신 고른 줄 바로 아래 하위 행(접고 폄)
- 줄 누름 = 그 줄 아래 하위 행(같은 표 · 같은 칸 · 이름 앞 ▸/▾ · 한 줄만 · 다시 누름 = 닫힘)
- 단가산출 · 승격 = 그 로직 재료 · 노무 · 경비 줄 · 사전조합 = 안 줄 · 금액 칸 = 기준 / 반영 두 줄 꼴
- 서버 승격 줄 = 1 단위로 나눔(하위 합 = 위 줄 기준)
- 50-4 M01 시험계산 상자 · 안 쓰는 글 걷어냄
- 시험: 승격 줄 합 = 기준 · ui_checks 51-2 시나리오

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 19:31:01 +09:00
eomsangdonandClaude Sonnet 5.5 a82275b38e feat(ui): 51-1 오른쪽 패널 높이 = 보이는 창 − 머리띠 · 스크롤 때 화면 안에 붙음
- 여백 8px 제거 · 칸 바닥 넘지 않음 · 칸 위가 화면 아래면 칸 위부터
- 시험: ui_checks/51-1_오른쪽패널_높이.js

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 19:30:10 +09:00
eomsangdonandClaude Opus 5.5 ac9e24f34b Merge origin/main_laptop_1 into sub_laptop_2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 19:26:22 +09:00
eomsangdonandClaude Opus 5.5 7da1b2e94b chore(sync): 공유 docs 사용자 요구 원문 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 19:26:11 +09:00
eomsangdonandClaude Opus 5.5 ff4e35724a chore(plan): 10-5 오른쪽 패널 높이 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:21:02 +09:00
eomsangdon 1375b13e53 Merge origin/main_laptop_1 into sub_laptop_4 2026-10-05 19:11:36 +09:00
eomsangdonandClaude Sonnet 5.5 3dc13899af chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-05 19:11:35 +09:00
eomsangdonandClaude Opus 5.5 37b54f3c93 chore(M02): 사용자 편집 — 구조물 옵션 정의 저장(판 올림 · 이력)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:10:41 +09:00
eomsangdonandClaude Opus 5.5 f2b5c3b44d fix(M02): 합친 뒤 LaborRow 참조 칸 두 번 — 하나로(50-3 ③ · ④ 이음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 19:10:40 +09:00
eomsangdon b2e4aad477 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-05 19:09:27 +09:00
eomsangdon 5317802b33 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
충돌(Labor_Detail.ts 한 줄) = 같은 식 · 주석 붙은 쪽(sub1) 취함
2026-10-05 19:09:26 +09:00
eomsangdon 222231996c Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_1 2026-10-05 19:08:38 +09:00
eomsangdon 408d2721f9 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1 2026-10-05 19:08:37 +09:00
eomsangdonandClaude Opus 5.5 6430b65036 test(M02): 50-3③ 서고 화면 시나리오 — 좌측 컨테이너 차례 · 층 단추 · 새로 창 · 상세 서고 모드 수치
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 19:07:38 +09:00
eomsangdon 29ccf4a94e Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1 2026-10-05 19:04:05 +09:00
eomsangdon 3feb56b190 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 19:00:33 +09:00
eomsangdonandClaude Opus 5.5 90d27128e1 feat(M02): 50-3 ④ 로직 고르기 창 서고 탭 · 참조 줄 표 · 줄 상세
- [로직 추가] 창 탭 단가산출 · 단가승격 · 사전조합 — 서고 탭은 층 단추(위 층만 · 기본 = 가장 가까운 층) + 그 층 목록 · 고르면 참조 줄 더함(같은 참조 두 번 막음)
- 담은 로직 표 참조 줄 — 줄 가름 lineId · 키 · 이름 · 단위 = 가리킨 문서 것 · 구분 = 참조 키 머리 · [옵션] 없음(넣을 값은 승격 때 고정)
- 줄 상세 — 단가승격 = 원 로직 키 + 원 로직 표 + 기준(1 단위) · 사전조합 = 안 줄 목록 한 층(구분 · 이름 · 수량 · 금액 넷) + 기준 · 아래 반영 합
- 문서 정리 · 보기 패널 · 다이어그램이 참조 줄을 받음(다이어그램은 단가산출 줄만)
- 시험: 승격 줄 원 로직 · 표 · 사전조합 안 줄 · ui_checks 50-3-4 시나리오(목록 · calc 는 탭 안 가짜 · 공유 마스터 안 씀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 18:59:40 +09:00
eomsangdonandClaude Opus 5.5 7e24c5aaec Merge origin/sub_laptop_7 (50-3 ①②⑤ 서고 서버) into sub_laptop_2
- 충돌 M02_Labor_Doc.calc — sub7 run_line 을 취하고 50-4 「줄」 실음을 run_line 으로 옮김
- 단가승격 펼침 · 참조 줄 답에 원 로직 키 · 「줄」 실음(50-3 ④ 줄 상세용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 18:53:01 +09:00
eomsangdon 7ebd8e5610 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_7 2026-10-05 18:48:15 +09:00
eomsangdon 83be2aef12 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-10-05 18:48:15 +09:00
eomsangdon b0659f6c5b Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-10-05 18:48:14 +09:00
eomsangdonandClaude Opus 5.5 9ee6c9f3fd feat(M02): 50-3 ①②⑤ 일위대가 조합 서고 서버 — 단가승격 SP · 사전조합 SC · 참조 펼침 · 굳힘
- 층 양식 길 새 종류 promote · precombo(키 = 파일 이름 · 층마다 다음 번호) · 새로 · 복사 길 POST /layers/{층}/library/{종류}
- 참조 {층 · 주인 · 키} — 위 층만 · 같은 참조 두 번 400 · 순환 422 · 쓰는 곳 있으면 지우기 409(모든 층 일위대가 · 사전조합 훑음)
- calc 가 참조 줄 펼침(재귀 · 캐시 · 깊이 상한 8 · 까닭) · 알약 키 「참조|층|주인|키」 · 분모 = 가리킨 문서 단위 · 배 1 · settle TS 짝 lineId
- 층 사이 복사 = 못 읽는 참조 같이 복사(본뜬) · 프로젝트 가져오기 = 참조사본으로 굳힘 · 로직 지우기 검사에 단가승격 더함 · 새 프로젝트 씨앗은 서고 뺌
- 시험 test_m02_labor_ref.py(펼침 · 순환 · 층 규칙 · 409 · 복사 · 굳힘 · 거울)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 18:47:02 +09:00
eomsangdonandClaude Opus 5.5 027ad3da98 feat(M02): 50-3③ 서고 새로 · 복사를 서버 키 길로 — POST /layers/{층}/library/{종류} · 참조 줄 보존
- 새로 = 서버가 그 층 다음 SP · SC 키 · 복사 = 본뜬 원본 → 고른 이름으로 고쳐 씀
- 일위대가 문서 줄의 참조 {층, 주인, 키} 를 화면 캐시에서 안 버림 · 로직 입력 받기에서 참조 줄 건너뜀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 18:45:01 +09:00
eomsangdon 897083893c Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_6 2026-10-05 18:42:50 +09:00
eomsangdonandClaude Opus 5.5 b1a5f38a16 feat(M02): 50-3③ 일위대가 조합 서고 화면 틀 — 좌측 컨테이너 · 단가승격 · 사전조합 목록 · 상세 서고 모드
- 구조물 조합 아래 「일위대가 조합」 컨테이너 · 층 단추(진입 open=일위대가조합) · 새로 · 복사 · 지우기
- 단가승격 새로 = 로직 고르기 → SP- 키 저장 · 사전조합 새로 = 이름만 → SC- 키
- 상세 = 일위대가 상세 서고 모드(이름 칸 · 로직 표 · 원문만 · 자재 · 면적 · 다이어그램 없음)
- 낡은 글 찾기 시험 셋을 지금 코드에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 18:42:50 +09:00
eomsangdon 0cd94bd589 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-05 18:42:48 +09:00
eomsangdonandClaude Sonnet 5.5 2d954a14dd feat(M01): 50-2 원문 패널 — 마크다운 기호 벗김 · 출처 항 · 주 강조
- 서버 원문 조각에서 역슬래시 · 인용 머리 · 글 안 주석 벗김(원본 자료 손 안 댐)
- 출처 항 · 주 번호(3-4-2 1 · 9-7-1 주⑥)를 source() 답에 실음 — 그 조각 hit 표시
- 패널이 그 항을 옅은 바탕 + 막대로 강조 · 보이게 스크롤 · 머리에 「1항」 · 「주⑥」
- 시험 3 추가 · 화면 시나리오 50-2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 18:42:15 +09:00
eomsangdonandClaude Opus 5.5 7b28cb42d3 feat(M02): 50-4 일위대가 줄 누르면 표 아래 상세 · 구분 열
- 서버 calc 답 줄마다 그 로직 재료 · 노무 · 경비 「줄」 실음(버리던 것)
- 상세 · 카드 표 줄 누름 = 표 아래 M01 시험계산 표(answerView 다시 씀) + 반영 합 · 한 줄만 · 같은 줄 다시 누름 = 닫힘
- 이름 왼쪽 「구분」 열 — 키 머리에서 읽음(SP- 단가승격 · SC- 일위조합 · 그 밖 단가산출 · 저장 안 함)
- 시험: 줄 실림 · 합 = 기준 · ui_checks 50-4 시나리오

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 18:41:23 +09:00
eomsangdon 02ee538481 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-05 18:40:46 +09:00
eomsangdonandClaude Sonnet 5.5 28f399bc2d feat(M02): 면적산출 알약 · 보낸 범위 표시 — 도면 보낸 범위 점선 + 이름표 · 칸 고르면 알약 강조 · 남은 알약 ⚠ + ×
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 18:40:05 +09:00
eomsangdon 5dfffa980b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-05 18:32:58 +09:00
eomsangdonandClaude Opus 5.5 e313a3f604 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 18:32:57 +09:00
eomsangdonandClaude Opus 5.5 3e572ef210 chore(plan): 10-5 일위대가 조합 · 면적산출 알약 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 18:20:18 +09:00
eomsangdon 1b457a3cc1 Merge commit '20cf8000b' into sub_laptop_3 2026-10-05 16:43:30 +09:00
eomsangdonandClaude Opus 5.5 639098185e chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 16:43:29 +09:00
eomsangdon 20cf8000be Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 16:41:56 +09:00
eomsangdonandClaude Sonnet 5.5 0516bdc696 test(M02): 49-7 상세 머리 [?] 칸 반영 — 머리 children 글 시험 기대를 지금 코드에 맞춤 (돌아가기 동작은 그대로)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 16:41:16 +09:00
eomsangdon 6438a9858b Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 16:39:19 +09:00
eomsangdonandClaude Sonnet 5.5 72881c20e7 feat(M02): 49-8 구조물 조합 찾기 칸 없앰 — 분류 표 · 카드가 위로 붙음
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 16:38:40 +09:00
eomsangdon 8210cf16a3 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 16:34:16 +09:00
eomsangdonandClaude Sonnet 5.5 4ff81f43f1 feat(M02): 49-7 상세 셋(도면 · 일위대가 · 면적산출) 머리 제목 옆 [?] 도움말 — 카드와 같은 글 한 벌 · 상세 전용 줄(파일 불러오기 · 도면으로 보내기 · 돌아가기) 더함
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 16:33:40 +09:00
eomsangdon 54388f919f Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-05 16:32:18 +09:00
eomsangdon 0cde4b7804 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-05 16:32:15 +09:00
eomsangdonandClaude Sonnet 5.5 ff85704a05 feat(M02): 49-6 구조물 조합 일위대가 카드 자재목록 [자재 추가] 숨김 — 상세는 그대로
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 16:32:02 +09:00
eomsangdon f3e84b5635 Merge origin/main_laptop_1 into sub_laptop_2 2026-10-05 16:28:07 +09:00
eomsangdon 25cc82ef86 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용 2026-10-05 16:28:06 +09:00
eomsangdonandClaude Opus 5.5 fa84a83dee chore(git): 사용자 편집 — .gitignore 에 docs/ 더함 · graphify 줄 주석
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 16:25:24 +09:00
eomsangdon d2ea1e975d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-05 16:24:59 +09:00
eomsangdon c8f0761c8a chore(sync): 공유 docs 파일 담음 2026-10-05 16:24:59 +09:00
eomsangdon 8b06e7187f Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-10-05 16:23:09 +09:00
eomsangdon 4dbea00162 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-05 16:23:07 +09:00
eomsangdonandClaude Opus 5.5 1ed154f2a4 chore(sync): 공유 docs 파일 담음 — 사용자 원문 덧 요구(원문 보기 표 · 일원화)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 16:22:40 +09:00
eomsangdonandClaude Opus 5.5 f0745f714d test(M02): 48-2 정의 화면 시나리오 · 종류 줄 · 배치 줄 안 접힘 · 48-1 시나리오 새 화면 고름
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 16:22:25 +09:00
eomsangdonandClaude Opus 5.5 0a9979e107 test(B05): 48-3 · 48-4 화면 시나리오 — 판 1 전후 대조 · 정의 사본 따라감
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 16:22:18 +09:00
eomsangdon afc605597c Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-05 16:19:48 +09:00
eomsangdon 66cb85169b Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-05 16:19:07 +09:00
eomsangdonandClaude Opus 5.5 4db23f2648 chore(M02): 구조물 옵션 정의 틀 문서 · 10-5 원문 덧(원문 표 · 일원화)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 16:18:44 +09:00
eomsangdon 44fd311286 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_6 2026-10-05 16:17:59 +09:00
eomsangdon 86b857d917 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-05 16:17:57 +09:00
eomsangdonandClaude Opus 5.5 4b79e7f39c feat(M02): 48-2 지난 판 목록 · 한 벌 읽기 길 · 정의 화면 시험
- GET /api/m02/structure-definition/history · /history/{판}(로그인 누구나 · 없는 판 404)
- 화면이 만드는 고침 한 벌을 서버가 받음 · 판 +1 · 403 · 422 시험(사본 정의)
- 옛 옵션 보이기 · 그룹 시험의 화면 글 검사를 정의 화면 자리로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 16:17:00 +09:00
eomsangdonandClaude Opus 5.5 712d3f19d5 fix(ui): 49 오른쪽 접는 패널 = 좌측 패널 손잡이 양식 · 패널 몸만 따로 스크롤 · 원문 보기 한 벌
- 49-1 공용 ui_template_side_panels — 세로 글 띠 · 접기 ▸ 걷어내고 공용 createWorkflowPanelHandle(side · right · 이름표) 손잡이를 패널 왼쪽 가장자리에 붙임 · 패널마다 손잡이 하나 · 하나만 열림 · 폭 공유 · 모두 접힘(폭 0 · 손잡이만 남음) — M01 로직 상세 · M02 일위대가 상세 · 면적산출 상세 모두 따라감
- 49-3 패널 묶음을 보이는 칸 안에 붙임(followView · 위 칸 overflow 가 CSS sticky 를 막아 JS) — 높이 = 보이는 칸 · 머리띠 아래 멈춤 · 몸만 스크롤
- 49-2 원문 md 읽기 — 빈 줄 · 주석이 붙은 표 둘을 가름(한 표로 합쳐지던 깨짐) · 칸 안 <br> = 줄바꿈
- 49-4 원문 칸 80vh 제한 걷어냄 — 표 높이 = 내용 · 가로 넓은 표만 가로 스크롤
- 49-5 원문 꼴 한 벌 — M01 둘레 글자 크기(14) 안 따르고 본문 크기(M02 와 같음)
- 확인 시나리오 ui_checks/49_오른쪽패널_양식.js · 시험 test_m01_logic_source 표 가름 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 16:16:59 +09:00
eomsangdonandClaude Opus 5.5 fc54a385df feat(B05): 48-3 · 48-4 구조물 배치 칸 = 구조물 정의대로 그림
- 정의 덧칸 얹기 — GET /m02/structure-definition 의 구조물군 이름 · 기준측점 · 동작 · 배치 · 규칙 · 그룹 형상을 종류에 얹음(못 받으면 옛 화면 그대로)
- 레지스트리 칸 — 배치(줄 · 칸 · 너비) 격자 · 차례 · 안 씀(enabled:false) · 규칙(선택지 · 보이기 · 기본값) 즉시 적용 · 규칙이 가린 칸은 저장 안 건드림
- 손코딩 시설 폼(동작 계곡통과시설) — 안 씀 · 선택지 · 규칙을 칸 표식으로 붙임 · 배치 · 이름은 손코딩 그대로
- 지난 정의 값 — 지금 정의에 없는 키 값을 읽기만 상자로
- 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 켬끔 · 값은 옵션 {그룹 id}__{칸}
- 정의에서 뺀 선택지를 고른 옛 값은 그 값만 남겨 보임
- 시험 test_b05_structures_define 8 · 헬퍼 문맥 groupBoxes 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 16:15:37 +09:00
eomsangdon ef6faaaf20 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1
# Conflicts:
#	M02_MasterTemplete/StructOptions/M02_SO_Api.ts
#	M02_MasterTemplete/StructOptions/M02_SO_Options.ts
2026-10-05 16:15:27 +09:00
eomsangdonandClaude Opus 5.5 8ca2f1cd6e feat(M02): 48-2 구조물 옵션 정의 편집 화면 — 군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 판 이력
- 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만)
- 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 16:15:10 +09:00
eomsangdon 22268b3bfe Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-05 16:15:04 +09:00
eomsangdonandClaude Opus 5.5 c6d78f4069 test(M02): 48-1 구조물 정의 시험 · 화면 확인 시나리오
- 판 1 옮김 37종 · 옵션 235 · 검사 통과 · 읽기 누구나 · 쓰기 403 · 판 +1 · 이력 판_N · 낡은 판 409
- 검사 422 = 키 겹침 · 꼴 · 규칙 대상 · 배치 · 종류 덧칸 · 표시 · 그룹 형상 · 모르는 칸(안 씀)
- 프로젝트 저장 정의 판 · 없는 키 값 보존 · ui_checks 48-1_구조물정의_읽기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 16:14:12 +09:00
eomsangdon 70a73f79a1 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_5 2026-10-05 16:12:47 +09:00
eomsangdonandClaude Opus 5.5 bf1fa50817 feat(M02): 48-1 구조물 정의 한 벌 — 정의 틀 문서 · 판 1 옮김 · 읽기 길
- 정의 틀 = docs/raw/M02_구조물옵션정의/정의_틀.md (담는 것 · 키 규칙 · 꼴 · 배치 · 그룹 형상 · 규칙 · 동작 · 판 이력)
- resources/master_template/구조물정의.json 판 1 = 옛 B05 레지스트리(37종 · 옵션 235) + 옵션표시 + 옵션그룹 키 그대로 옮김
- GET /api/m02/structure-definition(로그인) · PUT(시스템 관리자만 · 판 +1 · 이력 판_N) · 검사(꼴 · 키 겹침 · 규칙 대상)
- GET /structure-types · B05 구조물 검사가 새 정의를 읽음(응답 꼴 그대로) · 옵션 보이기 · 그룹 저장도 정의 칸으로
- 프로젝트 structures.json 에 저장 때 정의 판 · 정의에 없는 키 값은 남김(오류 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 16:09:33 +09:00
eomsangdon 9ca9dff3cb Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 16:00:52 +09:00
eomsangdon 56545b16d5 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-10-05 16:00:51 +09:00
eomsangdon 44c6136b91 auto: 2026-10-05 16:00 (ESD_LAPTOP) 2026-10-05 16:00:45 +09:00
eomsangdonandClaude Opus 5.5 4600b9970c chore(M02): 사용자 편집 — 구조물 옵션 그룹(마스터 템플릿 화면에서 고침) · 49 원문 덧 요구
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 15:55:37 +09:00
eomsangdonandClaude Opus 5.5 9b1fe9cfa9 chore(plan): 10-5 구조물 옵션 정의 페이지 · 원문 보기 패널 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 15:55:16 +09:00
eomsangdon ee3741254d Merge remote-tracking branches 'origin/CODEX' and 'origin/안티그래비티' into main_laptop_1 2026-10-05 15:13:02 +09:00
eomsangdon 08e075c027 Merge remote-tracking branch 'origin/main_laptop_1' into 안티그래비티 2026-10-05 15:12:45 +09:00
eomsangdonandClaude Opus 5.5 31252b7772 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:12:45 +09:00
eomsangdon 9e7eecb6e0 Merge remote-tracking branch 'origin/main_laptop_1' into CODEX 2026-10-05 15:12:37 +09:00
eomsangdonandClaude Opus 5.5 dc94c232f0 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:12:37 +09:00
eomsangdon 590764e49f Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_desktop_1' into main_laptop_1 2026-10-05 15:11:52 +09:00
eomsangdonandClaude Opus 5.5 863988f73d fix(B05): 계획노선 편집 등고선 고르기 — 끊어진 같은 높이 줄을 함께 강조
- 고른 가닥과 높이가 같은 가닥을 모두 덧그림 · 높이를 모르면 고른 가닥만
- 검증 시나리오 ui_checks/48-3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-05 14:04:23 +09:00
eomsangdon 2ba21a6bdd auto: 2026-10-05 14:03 (EOMSANGDON-HOME) 2026-10-05 14:03:20 +09:00
eomsangdonandClaude Opus 5.5 322cd150cc fix(B05): 46-5 옵션 그룹 상자 — 숨은 칸은 안 옮김 · 시설 갈래가 바뀌면 다시 세움
- 화면 검증에서 찾음: 숨어 있던 독립 기슭막이 칸(같은 키)까지 상자로 옮겨 두 벌로 보임
- 숨은 조상이 있는 칸은 제자리 · 유입구 구조 등 시설 칸이 바뀌면 그룹 상자도 다시 세움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-05 14:03:19 +09:00
eomsangdonandClaude Opus 5.5 481001ca65 feat(B05): 노선 추천 모달 오른쪽 설명 칸 — 세로 구분선 · 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법
- 기존 칸 그대로 · 모달 폭 960 → 1320px · 좁은 폭(1000px 이하)은 아래로
- 글은 서버 엔진 실제 동작 기준(만드는 법 · 이럴 때 · 주의) — `_Recommend_Help.ts` 로 나눔
- 고른 갈래 설명 강조 · 보이는 자리로 굴림
- 검증 시나리오 ui_checks/48-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-05 14:01:15 +09:00
eomsangdonandClaude Opus 5.5 2faec5fb14 feat(B05): 계획노선 편집 구간 재기 — 첫 점 뒤 커서 자리 길이 · 지반고 · 종단기울기 실시간 표시
- 첫 점을 찍을 때 그 노선 땅 높이를 2m 간격(최대 3000점)으로 한 번에 받아 둠 — 커서 이동 중 서버 부르지 않음
- 둘째 점을 누르면 전처럼 두 점만 다시 물어 고정 · 묻는 동안 「지반고 읽는 중」
- 노선을 고치면 높이 표를 다시 받음
- 검증 시나리오 ui_checks/48-1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-05 13:57:30 +09:00
eomsangdon 92c10f5cc8 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_4 2026-10-05 13:54:09 +09:00
eomsangdon bad2e6cb6d Merge remote-tracking branch 'origin/sub_laptop_6' into sub_desktop_1 2026-10-05 13:50:18 +09:00
eomsangdon de7e441faf Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-10-05 13:50:09 +09:00
eomsangdon d4bfeeb217 Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1 2026-10-05 13:50:08 +09:00
eomsangdon b9ab962b9f Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-10-05 13:50:07 +09:00
eomsangdon 38f056e36a Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-10-05 13:50:04 +09:00
eomsangdonandClaude Opus 5.5 a0c292ba05 chore(docs): 2026-10-05 사용자 수정요구 원문 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-05 13:49:33 +09:00
eomsangdon 79ca220823 auto: 2026-10-05 13:49 (EOMSANGDON-HOME) 2026-10-05 13:49:14 +09:00
eomsangdon 5884c7f0bd Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
충돌(DrawingCard.ts) = 47-6 mountStructure 한 벌을 취하고 47-2 noImport 를 그 안에 옮김
2026-10-05 13:49:13 +09:00
eomsangdon 78f436b136 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
시험 충돌(test_m02_card_shell.py) = 47-1 · 47-2 시험과 47-5 시험 둘 다 남김
2026-10-05 13:48:45 +09:00
eomsangdon 26399f0dd8 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-10-05 13:47:53 +09:00
eomsangdon 03c13dacf7 Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_3 2026-10-05 13:47:52 +09:00
eomsangdonandClaude Opus 5.5 fff2845117 feat(M02): 47-6 면적산출 상세 오른쪽 = 공용 접는 패널 둘(일위대가 · 도면)
- 옛 접기 손잡이 · 폭 손잡이 · 사이드바 카드 틀을 공용 오른쪽 접는 패널(47-3)로 갈음 — 하나만 열림 · 폭 공유 · 모두 접힘
- 「도면」 패널 = 그 열 구조물 도면 보기만(확대 · 이동) · 처음 펼 때 읽고 그림
- DrawingCard 미리보기 한 벌(mountStructure)을 카드 · 도면 패널이 같이 씀
- 시험: test_m02_structure_panel 기대를 공용 패널로 · ui_checks/47-6 시나리오

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 13:47:07 +09:00
eomsangdon ecccaa2a33 Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_2 2026-10-05 13:46:47 +09:00
eomsangdonandClaude Opus 5.5 66cb7afbab feat(M02): 47-4 일위대가 상세 오른쪽 「원문」 접는 패널 — 고른 줄 로직의 품셈 원문
- 공용 오른쪽 접는 패널(ui_template_side_panels · 47-3 부품) · 취향 키 m02-labor-side · 카드 갈래엔 없음
- 고른 줄 없음 = 안내 한 줄 · 고르면 그 로직 출처 원문(GET /m01/logic/source)
- 원문 보기를 M01_MasterData_UI_LogicLab_Source 로 떼어 M01 로직 상세와 한 벌로 씀(M01 동작 그대로)
- 입력 목록 길(GET /m02/logic-lib/inputs)에 로직 출처 칸 추가
- 시험: test_m02_labor_values 출처 확인 · 화면 시나리오 ui_checks/47-4_일위대가_원문패널.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 13:45:53 +09:00
eomsangdon 675d62b588 Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_6 2026-10-05 13:42:35 +09:00
eomsangdon 994b577038 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-05 13:42:29 +09:00
eomsangdon 5f5da8119b Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_2 2026-10-05 13:42:17 +09:00
eomsangdonandClaude Opus 5.5 da510d3cbc fix(M01): 47-3 로직 상세 오른쪽 = 공용 접는 패널 둘(흐름도 · 원문) — 위아래 접기 카드 걷어냄
- 흐름도 · 원문 = 따로 패널 · 접힌 패널 = 세로 글 띠 · 하나만 열림 · 모두 접힘 됨 · 폭 공유(경계 끌기 · 화살표 키)
- 열린 패널 · 폭 = 취향 키 m01-logic-side · 흐름도 패널 자체 접기 단추 · 끌기 경계는 숨김
- 옛 두 칸 비율 막대(buildSplit) 제거 · 부품 칸 숨김([hidden]) 규칙 덧붙임
- 화면 시나리오 ui_checks/47-3_로직상세_오른쪽패널.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 13:41:48 +09:00
eomsangdonandClaude Opus 5.5 cd1ef69d2b feat(M02): 47-5 일위대가 줄 누름 = 고름 하이라이트 · [옵션] 단추로 넣을 값 모달 · 줄 단추 공용 묶음
- 단가산출 로직 표 줄 누름 = 고른 줄 하이라이트(로직 키 · 다시 그려도 유지) · 넣을 값 모달은 새 [옵션] 단추
- 줄 단추 이름 위로 · 아래로 · 옵션 · 삭제(빨강) · 카드 갈래 = 옵션 · 삭제
- 공용 단추 묶음 `ui-btn-group` · 간격 토큰 `--btn-group-gap`(4px) — 이 표에만 적용
- 시험: test_m02_card_shell 낡은 글 맞춤 + 47-5 확인 · 화면 시나리오 ui_checks/47-5_일위대가_줄_고름.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 13:41:13 +09:00
eomsangdonandClaude Opus 5.5 15e08b28cb feat(B05·B06·M02): 46-3·46-4 옵션 그룹 보조 컨테이너 — 구조물 하나 · 조합 여럿
- 구조물 기록 · 관 지점 기록에 group_items {그룹 id: summary_item} 칸(검사 parse_group_items)
- 폼: 종류의 그룹마다 상자(제목 = 그룹 이름) · 그 그룹 옵션 칸을 옮겨 담음 · 「집계 항목」 [고르기][해제] · 빈 그룹도 상자
- 정의가 사라진 그룹 연결 = 값 보존 · 경고 한 줄
- 복사본 = 그룹 연결마다 {구조물 id}__{그룹 id} 폴더(관 pipe@측점__그룹)
- 집계표 채움 = 정의된 그룹마다 한 건 더(그 그룹이 고른 열) · 미지정 그룹은 「미지정」 알림에 셈
- B06 관 초안 · B05 시설 보관소 · 병합도 그룹 칸을 이음
- 시험 test_structure_group_items.py · 코드 글자를 보던 시험 셋은 바뀐 자리로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-05 13:39:12 +09:00
eomsangdon 9499ff16cc Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_3 2026-10-05 13:39:08 +09:00
eomsangdonandClaude Opus 5.5 ff881aaeb0 feat(M02): 47-6 면적산출 도면 패널 몸 — 그 열 구조물 도면 보기만(카드 미리보기와 같은 길)
- DrawingCard.mountDrawingView 보기 갈래 추가 · AreaDrawing 처음 펼 때 읽고 그림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 13:39:08 +09:00
eomsangdon 21f404bba6 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_1 2026-10-05 13:39:01 +09:00
eomsangdon 13b80d8581 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 13:39:00 +09:00
eomsangdonandClaude Opus 5.5 605cc090ac feat(M02): 47-1 · 47-2 구조물 조합 카드 [?] 제목 옆 · 도움말 네 절 · 미리보기 파일 불러오기 없앰
- [?] = 카드 제목(도면 정보 · 일위대가 · 면적산출) 바로 옆 · 도움말 = 설명 · 단축키 · 기능 · 사용법
- 도움말 글 한 곳(M02_MasterTemplete_CardHelp.ts) · 일위대가 카드에도 [?]
- 도면 카드 미리보기 📂 없음(noImport) · 도면 상세는 그대로
- 시험 · 화면 시나리오(ui_checks/47-1_카드_도움말.js)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 13:38:13 +09:00
eomsangdonandClaude Opus 5.5 cbe0c17505 feat(ui): 47-3 공용 오른쪽 접는 패널 부품 — 패널 여럿 나란히 · 하나만 열림 · 폭 공유
- createSidePanels({ prefKey, panels: [{ id, title, body }] }) · 본문과 함께 .ui-side-panels-row 칸에 [본문, root]
- 접힌 패널 = 제목 세로 글 띠(누르면 폄) · 편 패널 = 머리(제목 · 접기 ▸) + 몸 · 모두 접힘 됨
- 왼쪽 경계 끌기 · 화살표 키로 폭 · 열린 패널 · 폭은 취향 키 aislo:pref:{prefKey}
- 폭 자르기 시험(test_ui_side_panels)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 13:36:25 +09:00
eomsangdon 59a3351ac4 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-05 13:32:09 +09:00
eomsangdonandClaude Opus 5.5 b79dd24066 chore(docs): 공유 docs 수정요구 추가 2 반영 — 받기 전 작업 트리 맞춤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 13:32:09 +09:00
eomsangdon c6f84a2c64 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-05 13:31:57 +09:00
eomsangdonandClaude Opus 5.5 5d61d1de58 chore(sync): 수정요구 추가 2 원문 — 공유 docs 로 들어온 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 13:31:57 +09:00
eomsangdon 935d6acbe4 Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1 2026-10-05 13:30:37 +09:00
eomsangdonandClaude Opus 5.5 66442a5efc chore(plan): 10-5 추가 2 원문(오른쪽 접는 패널 · 원문 보기 · 줄 단추 · 카드 ?)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 13:30:27 +09:00
eomsangdonandClaude Opus 5.5 37634e2c26 feat(M02): 46-2 구조물 옵션 그룹 — 종류마다 이름 붙은 묶음 편집 · 정본 · /structure-types groups
- 정본 `resources/master_template/구조물옵션그룹.json` = {type_id: [{id, 이름, 옵션[]}]} · 빈 그룹 됨
- 길 GET · PUT /api/m02/structure-option-groups(시스템 관리자) · 검사(없는 종류 · 키 · 겹침 · 옵션 두 그룹 422 · 낡은 판 409)
- /api/projects/structure-types 종류마다 groups(차례 · 이름 · 옵션) · 깨진 표 = 그룹 없음
- 옵션 보이기 화면 = 그룹 목록(이름 · 위로 · 아래로 · 지우기 · 추가) + 옵션 줄 「들어갈 자리」(본체 · 그룹)
- 좌측 「구조물 옵션 보이기」 = 시스템 관리자만
- 시험 test_m02_structure_option_groups.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-05 13:21:37 +09:00
eomsangdon c6fc11cf13 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-05 13:15:50 +09:00
eomsangdonandClaude Opus 5.5 b33ad8fc06 chore(sync): main.py 를 dev 와 같게 — 시놀로지가 옮긴 같은 내용(데스크탑 PLAN 46 구조물 옵션 길)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 13:15:49 +09:00
eomsangdon 9663a41b4a Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_2 2026-10-05 13:14:44 +09:00
eomsangdonandClaude Opus 5.5 bd3c48c525 refactor(M02): 46-1 구조물 옵션 보이기 코드를 M02_MasterTemplete/StructOptions/ 한 곳으로
- 서버 정본 `M02_SO_Options.py` · 길 `M02_SO_Router.py`(고쳐 씀 = 시스템 관리자 · 읽기 = 로그인)
- 화면 `M02_SO_Options.ts` · 글자 `M02_SO_Text.ts` · API `M02_SO_Api.ts` · 스타일 `M02_SO_Style.css`
- 길 주소 · 동작 그대로 · B05 · 시험은 경로만 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-05 13:14:09 +09:00
eomsangdonandClaude Opus 5.5 acf7570da4 fix(B05): 46-6 종단 패널 높이 끌기 상한 — 부모(display: contents · 높이 0) 대신 가운데 칸 높이 기준
- 상한 식 한 벌 `profilePanelMaxHeight` (Heights) — 메인 리사이저 max · 자동 확대 상한이 같이 씀
- 기준 = `.design-page__main` 높이 × 0.9 · 없으면 부모

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-05 13:14:08 +09:00
eomsangdonandClaude Opus 5.5 2334a2abcf fix(M02): 일위대가 표 이름 한 줄 말줄임 · 칸 선 이어짐(45-7)
- 이름 칸 flex 뺌(칸 선 끊김 원인) · 코드 · 이름 · 설명 글마다 한 줄 말줄임 + title
- 머리 칸 · 코드 · 규격 줄바꿈 없음 · 전체 합계 칸 테두리를 안쪽 그림자로(칸 선 굵기 그대로)
- 확인 시나리오 resources/tester/ui_checks/45-7_일위대가_표_줄.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 13:13:57 +09:00
eomsangdonandClaude Opus 5.5 aa2845eef7 style(M02): 구조물 조합 삭제 단추를 공용 위험(빨강) 단추로
- 카드 셋 머리 [삭제] · 일위대가 줄 [삭제] 두 곳 = ui-btn--danger(테마 토큰)
- 화면 확인 시나리오 45-6 추가(밝음 5.44 · 어두움 5.79 대비 · 옆 단추와 같은 키)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 13:04:28 +09:00
eomsangdon 9e2c034d9f Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1 2026-10-05 12:50:41 +09:00
eomsangdon d9217a54d5 Merge commit '629f7c10c' into sub_laptop_3 2026-10-05 12:50:28 +09:00
eomsangdonandClaude Opus 5.5 0d2b5251d4 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 12:50:27 +09:00
eomsangdon 190a857270 Merge commit '629f7c10c' into sub_laptop_1 2026-10-05 12:50:13 +09:00
eomsangdonandClaude Opus 5.5 d769278764 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 12:50:13 +09:00
eomsangdon 629f7c10c8 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-05 12:48:14 +09:00
eomsangdonandClaude Opus 5.5 4ee2a4b74b fix(M02): 45-5 구조물 조합 분류 표 · 카드 셋 사이 간격 107px → 12px · 표 아래 줄 · 카드 칸 위 줄 없앰
- 카드가 보일 때 표 자리 최소 높이 240px 풀어 표 높이만큼만
- 카드 칸 위 구분선 · 안쪽 여백 12px 없앰(메인 칸 간격 하나만)
- 조합 분류 표 틀 아래 줄 없앰(마지막 줄 칸 선이 표를 닫음)
- 확인 시나리오 resources/tester/ui_checks/45-5_분류표_카드_간격.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 12:47:31 +09:00
eomsangdon dc8ae0f60f Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-05 12:40:36 +09:00
eomsangdon 0c85cf6b04 auto: 2026-10-05 12:40 (ESD_LAPTOP) 2026-10-05 12:40:32 +09:00
eomsangdon ce457265b9 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 12:40:13 +09:00
eomsangdon f1a1c15053 auto: 2026-10-05 12:40 (ESD_LAPTOP) 2026-10-05 12:40:05 +09:00
eomsangdonandClaude Opus 5.5 6eb3c551be chore(plan): 10-5 추가 원문(구조물 조합 상세 공간 · 구분선)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 12:39:35 +09:00
eomsangdon f334dee816 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-05 12:39:04 +09:00
eomsangdon 0e59629d69 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-05 12:39:02 +09:00
eomsangdon 08e2b211a9 auto: 2026-10-05 12:38 (ESD_LAPTOP) 2026-10-05 12:38:58 +09:00
eomsangdonandClaude Opus 5.5 9fc5673b73 test(M02): 빈 칸 수량 표기 기대를 44-6 「수량 빈 칸(칸)」 꼴로 맞춤 — 빈 칸 = 수량 없음 뜻 그대로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 12:38:22 +09:00
eomsangdon 3f5c38c90d Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 12:34:56 +09:00
eomsangdon ed0059d544 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-05 12:34:54 +09:00
eomsangdonandClaude Opus 5.5 f5a972d0e7 fix(M02): 단추를 공용 기본 단추로 · 알약 꼴은 진짜 알약에만
- 공용 작은 단추(ui-btn--sm) 모서리 8px → 4px — 낮은 키에 8px 이면 알약 꼴
- 층 단추 · 고르기 창 출처 단추 · 나무 「+」 = createButton(켜짐 filled · 나머지 ghost) · 전용 단추 꼴 CSS 지움
- 데이터 흐름 입력 패널 갈래 · 방식 단추 pill → ghost
- 연결 알약 · 다이어그램 알약 · 칩 · 이름표는 그대로
- 시험: 층 단추 글 찾기 들여쓰기 맞춤 · 화면 시나리오 45-1 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 12:34:10 +09:00
eomsangdon cce4e0721c Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-05 12:33:29 +09:00
eomsangdonandClaude Opus 5.5 407481dc8d feat(M02): 좌측 구조물 조합 안 하위 컨테이너 셋 · 「전체 항목」 길 없앰(45-2)
- 좌측 구조물 조합 = 층 단추 · 나무만 · 구조물 도면 · 일위대가 · 면적산출 하위와 수평선 지움
- 「전체 항목」 목록 화면 길(onAll · buildAllItems) 지움 · 상세는 카드 [수정]으로
- 쓰는 곳 없는 structureLabel · 로케일 키 다섯 · 하위 CSS 지움
- 낡은 글 찾기 시험을 지금 코드에 맞춤 · 화면 시나리오 ui_checks/45-2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 12:32:50 +09:00
eomsangdon 2ab764ee6a Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-05 12:28:42 +09:00
eomsangdonandClaude Opus 5.5 966669bb34 feat(M02): 45-3 구조물 조합 분류 표 일위대가 · 도면 · 면적산출 키 줄 숨김
- 표 부품 combo 일 때 마스터 번호 줄 안 그림(보기만 · 장부 · 저장 문서 그대로)
- 번호 글 폭 맞춤(fitMeta) · 전용 CSS 없앰 · 방향키는 머리 줄만
- 시험 test_m02_combo_table 지금 코드에 맞춤 · 시나리오 ui_checks/45-3_분류표_키줄.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 12:28:04 +09:00
eomsangdon 83ad5b1507 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-05 12:27:23 +09:00
eomsangdon b94259f3b5 auto: 2026-10-05 12:26 (ESD_LAPTOP) 2026-10-05 12:26:41 +09:00
eomsangdonandClaude Opus 5.5 9b4b56edc6 fix(B05): 계획노선 편집 횡단 판 크기 바뀜 때 다시 지어 배율 1 · 글자 11px 복귀 (44-2 덧)
- 판이 접힌 · 좁은 때 지은 viewBox 가 펼쳐질 때 통째로 늘어나 글자가 커 보이던 길 없앰
- ResizeObserver 로 판 크기가 바뀌면 실제 크기로 다시 지음 · 확대 · 이동 상태 유지
- 최소치(240×150)는 판이 0 일 때만 · 범례 · 좌우 표고 글 11px
- 44-2 시나리오 — 접힘 · 좁음 · 넓음 · 줄임 네 갈래 배율 · 글자 높이 잼

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-05 12:26:29 +09:00
eomsangdon 7110299075 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-05 12:25:56 +09:00
eomsangdonandClaude Opus 5.5 1ca99ed3ae fix(M01): 로직 상세 오른쪽 두 패널 공용 접기 카드로 — 제목 행 누름 · 캐럿 · 하나만 열림
- 흐름도 · 원문 = ui-collapsible 카드(M02 면적산출 오른쪽 패널 꼴) · attachCollapsible 토글 위에 하나만 열림 규칙
- 따로 만든 원문 토글 단추 · 얇은 띠 제거 · 흐름도 패널 자체 접기 단추는 M01 에서 숨김
- 둘 다 접힘 = 제목 행 둘(칸 140px) · 취향 기억(m01-logic-panel) 그대로
- 44-4 화면 시험 카드 꼴로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 12:25:12 +09:00
eomsangdon a86a03932c Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-10-05 12:23:28 +09:00
eomsangdon d26da8e6db Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-10-05 12:23:26 +09:00
eomsangdon 94bd0d1b9f Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 12:23:25 +09:00
eomsangdon 40ff4a551c auto: 2026-10-05 12:23 (ESD_LAPTOP) 2026-10-05 12:23:19 +09:00
eomsangdonandClaude Opus 5.5 1fd2521e82 chore(plan): 10-5 추가작업 원문(M02 단추 양식 · 좌측 하위 컨테이너 · 분류 표 키 줄)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 12:22:39 +09:00
eomsangdon 8990729a6d Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-05 11:46:13 +09:00
eomsangdonandClaude Opus 5.5 72795f7497 test(B05): 44-1b 구간재기 판정 흐림 견딤 — previewOff 는 previewBase ±3px · previewOn 은 2배 초과
- 선 경계 흐림으로 previewOff 가 1px 어긋나 실패하던 판정 완화
- 동작 코드 그대로 · 5178 에서 통과(base 84 · on 720 · off 83)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 11:45:17 +09:00
eomsangdon b892257e21 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-05 11:33:20 +09:00
eomsangdon b30cd5c6eb Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-05 11:33:19 +09:00
eomsangdon b2c61d2e9a Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-05 11:32:46 +09:00
eomsangdon 912f4e7216 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-05 11:32:42 +09:00
eomsangdonandClaude Opus 5.5 ac1124b9f1 fix(B07-CAD): 한글 IME 조합 입력 — 문자 단계 숨은 받개 · 조합 중 단축키 건너뜀
- 문자를 기다리는 단계(TEXT · MTEXT · 표 칸 · 지시선)에서 숨은 input 에 초점 · compositionupdate/end 로 글자 받음
- 조합 중 키(isComposing · 229 · Process)는 단축키 처리에서 건너뜀 · 명령줄도 같음
- 명령 단계는 초점을 풀어 한글 자판이어도 영문 명령 그대로
- 입력 해석 패턴을 input-patterns.ts 로 나눔(700줄 상한)
- 검증 창구 entities 에 label · 시나리오 ui_checks/44-8_CAD_한글입력.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-05 11:32:37 +09:00
eomsangdonandClaude Opus 5.5 bc094969ac feat(M01): 44-5 체인톱 가격 · 오일 자재품목 연결 — 손 입력 칸 걷음
- GF000002 작업로 설치 · GF000056 어린나무 가꾸기 · GF000076 방제 벌목조재 — 입력 체인톱가격 · 체인오일단가 걷음
- 체인오일 = 호표 재료 줄(검색어 「작동유」 → 기어작동유 4,612원/ℓ) · 체인톱 손료 = 호표 재료 줄(검색어 「동력체인톱」 → 기본 400mm 530,000원 · 비목 경비 · 규격 고름 가능)
- 잡품 = 휘발유 줄 금액 × 40%(주⑤ · 오일이 재료비에 들어와 식 대상 좁힘)
- 같은 단가면 원가 전후 같음(GF000002 계 454,056) · 손 입력 빈칸 견본 대비 체인오일 · 손료가 채워짐
- 시험 — test_m01_auto 빈 원 입력 시험을 GF000051 예취기가격으로 옮김 · 체인톱 자재 시험 추가 · ui_checks/44-5_체인톱_자재.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 11:31:58 +09:00
eomsangdon 1855bf7ae2 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-05 11:31:55 +09:00
eomsangdonandClaude Opus 5.5 7d39f90a0e feat(M01): 단가산출 로직 상세 차례 · 오른쪽 흐름도/원문 두 패널
- 좌측 차례 = 기본정보 › 시험 계산 › 단가산출 상세 › 텍스트 수식
- 오른쪽 패널 둘(흐름도 · 원문) — 하나만 열림 · 둘 다 접기 · 머리 토글 단추 · 열린 패널 계정 취향(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 11:31:09 +09:00
eomsangdon 83b6238109 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-10-05 11:29:06 +09:00
eomsangdon fe8ace2777 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-10-05 11:29:04 +09:00
eomsangdon 17256bfac8 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 11:29:04 +09:00
eomsangdon 3fd094eef3 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-05 11:29:02 +09:00
eomsangdonandClaude Opus 5.5 58a9b3e8b7 feat(B05): 계획노선 편집 구간 재기 — 첫 점 노선 기억 · 둘째 점 · 커서 미리보기는 그 노선에만
- 첫 점을 찍은 노선(계획 · 예상)만 둘째 점 후보 — 겹친 다른 노선은 무시 · 첫 점이 노선 밖이면 전과 같음
- 첫 점 뒤 커서 미리보기(둘째 점 · 구간 덧그림) 추가 — 첫 점 노선 위에만
- 길이 = 노선 따라 잰 누가거리 차(전과 같음)
- 검증 시나리오 ui_checks/44-1 · 44-1b

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-05 11:28:10 +09:00
eomsangdonandClaude Opus 5.5 32192e819a test(M02): 44-6 일위대가 표 화면 시나리오 · 옵션 값 줄 한 줄 높이
- 옵션 값 줄 칸 위 여백 없앰 · 작은 글(줄 높이 42 → 22)
- resources/tester/ui_checks/44-6_일위대가_표.js 시나리오 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 11:28:05 +09:00
eomsangdon 3fba3857cf Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-05 11:27:57 +09:00
eomsangdonandClaude Opus 5.5 543d7bb8a5 fix(B05·B06): 좌측 패널 아래 단추 묶음 바닥 고정 복구(PLAN 44-3)
- 까닭 = 473d959d6 이 좌측 자리를 빈 채 틀에 넘겨 공용 쪼개기 건너뜀 · 보기 전환이 패널 클래스를 통째 덮어 ui-sidebar-fill 지움
- 공용 쪼개기 내보냄 · 두 번 불러도 한 번만 · 패널을 붙인 뒤 다시 부름
- 늦게 서는 칸은 스크롤 칸 안으로 · 보기 클래스는 toggle
- 시험 test_design_panel_dock_fixed · 화면 시나리오 ui_checks/44-3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 11:27:09 +09:00
eomsangdon dbf1e87285 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-05 11:24:05 +09:00
eomsangdon b464fe0a7f Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 11:24:03 +09:00
eomsangdonandClaude Opus 5.5 a22f03426f feat(m02,b01): 일위대가 상세 단위 드롭다운 · 대시보드 일위대가 카드 없앰(44-7)
- 일위대가 상세 머리 「단위」 = 표준 단위 고르기(UnitPick) · 지금 값이 목록에 없어도 선택지 · 저장은 상세 [저장]
- 대시보드 시스템 설정의 「일위대가」 카드 · 진입 해시 open=일위대가 길 · 안 쓰는 글 키 없앰
- 시험 고침 · ui_checks/44-7 화면 판정 스크립트

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 11:23:22 +09:00
eomsangdon 6b3b1e92a4 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-05 11:23:05 +09:00
eomsangdonandClaude Opus 5.5 411ad7de7d feat(M02): 일위대가 단가산출 로직 표 44-6 — 수량(칸) 표기 · 옵션 값 걸친 한 줄 · 전체 합계 강조 · 별칭 열
- 칸 연결 표기 「B6 = 12.5」 → 「12.5(B6)」(수량산출 칸 · 수량 모달 지금 수량 · 자재 줄)
- 옵션 값 = 줄 아래 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글 title)
- 총합계 행 전체 합계 칸 테두리 · 색
- 별칭 열(이름 옆 · 칸 안 글상자 · 담은로직 줄 별칭 저장 · 서버 검사 40자 · 복사 · 가져오기 같이 감)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 11:23:02 +09:00
eomsangdonandClaude Opus 5.5 10bc3686e2 style(B05): 계획노선 편집 횡단 글자 크기 조정 (44-2)
- 원지반 · 기본 계획 횡단 · 좌우 표고 글자 11px → 5.5px(절반)
- 측점 표기 글자 = 제목 「횡단」과 같은 크기(inherit)
- 화면 시나리오 resources/tester/ui_checks/44-2_횡단_글자.js 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-05 11:22:06 +09:00
eomsangdon 80667d8555 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-05 11:17:14 +09:00
eomsangdon bf40ca52a4 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_8 2026-10-05 11:15:38 +09:00
eomsangdon a7d17a6c25 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_7 2026-10-05 11:15:36 +09:00
eomsangdon 576b12eb7d Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_6 2026-10-05 11:15:33 +09:00
eomsangdon 0c5f9b6485 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_4 2026-10-05 11:15:33 +09:00
eomsangdon 3c1ba6333d Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_5 2026-10-05 11:15:30 +09:00
eomsangdon 3df8cb9b38 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-05 11:15:30 +09:00
eomsangdon b5cdd13fbe Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_2 2026-10-05 11:15:28 +09:00
eomsangdon a93670db03 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 11:15:26 +09:00
eomsangdon 7e0a7b5e15 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-05 11:15:24 +09:00
eomsangdon 4ddf0d6f42 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 11:15:23 +09:00
eomsangdon 4041e61871 auto: 2026-10-05 11:15 (ESD_LAPTOP) 2026-10-05 11:15:21 +09:00
eomsangdon f535c233a6 auto: 2026-10-05 11:15 (ESD_LAPTOP) 2026-10-05 11:15:19 +09:00
eomsangdon 0b3315438a auto: 2026-10-05 11:15 (ESD_LAPTOP) 2026-10-05 11:15:17 +09:00
eomsangdon 191b7ece27 auto: 2026-10-05 11:15 (ESD_LAPTOP) 2026-10-05 11:15:15 +09:00
eomsangdonandClaude Opus 5.5 0ae4a87f17 chore(plan): 10-5 수정 요구 원문 보관(B05 · M01 · M02 · 공통 · 대시보드 · CAD)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 11:14:46 +09:00
eomsangdon af8802e697 Merge remote-tracking branches 'origin/main_desktop_1', 'origin/sub_desktop_1', 'origin/CODEX' and 'origin/안티그래비티' into main_laptop_1 2026-10-05 10:14:02 +09:00
eomsangdon ebec54aa38 Merge remote-tracking branch 'origin/main_laptop_1' into 안티그래비티 2026-10-05 10:11:35 +09:00
eomsangdonandClaude Opus 5.5 0973c11218 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용(줄 끝만 다름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:11:35 +09:00
eomsangdon 8301955d6a Merge remote-tracking branch 'origin/main_laptop_1' into CODEX 2026-10-05 10:11:31 +09:00
eomsangdonandClaude Opus 5.5 2bd6963334 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용(줄 끝만 다름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:11:31 +09:00
eomsangdon 6578620948 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-10-05 10:11:25 +09:00
eomsangdonandClaude Opus 5.5 02d2c5a751 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용(줄 끝만 다름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:11:25 +09:00
eomsangdonandClaude Opus 5.5 ff044bfe7b chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용 17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:10:14 +09:00
eomsangdonandClaude Opus 5.5 b7cafc6221 chore(sync): 공유 docs 파일 담음 — 기준 커밋과 같은 내용 17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:10:08 +09:00
eomsangdon 822ef680e3 Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1 2026-10-05 10:10:05 +09:00
eomsangdonandClaude Opus 5.5 2fa1c7d29b chore(sync): main.py 를 기준 커밋과 같게 — 시놀로지가 옮긴 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:10:04 +09:00
eomsangdon 6d10f9603c Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-05 01:06:11 +09:00
eomsangdon da6a6e75c4 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-05 01:06:10 +09:00
eomsangdon 9176593d22 auto: 2026-10-05 01:05 (ESD_LAPTOP) 2026-10-05 01:05:18 +09:00
eomsangdon d8dc15ba12 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-10-05 01:01:46 +09:00
eomsangdon abed39f61b Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_5 2026-10-05 01:01:45 +09:00
eomsangdonandClaude Opus 5.5 1e512c2c0b fix(M02): 면적산출 상세 머리 [도면으로 보내기] = 공용 기본 단추(ghost · sm 없앰 · 18px → 32px · 43-5 덧)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 01:00:44 +09:00
eomsangdon 7741a4086c Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-05 00:53:41 +09:00
eomsangdonandClaude Opus 5.5 e1f7b5f62d feat(M02): 구조물 조합 카드 빈 상태 [새로 만들기] · [복사 생성] 없앰 · 일위대가 · 면적산출 가져오기 = 함께 덮어씀(43-9b)
- 빈 카드도 보통 카드 — 빈 몸 + 가져오기 · 수정 · 삭제(새 항목 칸 셋은 서버가 자동 생성)
- 일위대가 · 면적산출 카드 가져오기 · 집계표 줄 조회 후 선택 = copyLaborAreaFrom(알약 연결 포함) · 도면은 그 칸만
- 가져오기 창 안내 한 줄 「일위대가 · 면적산출 함께 가져옴」(pickFrom note)
- 화면 검증 시나리오 ui_checks/43-9b_빈카드_함께가져오기.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 00:51:27 +09:00
eomsangdon e909f8efc3 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
docs 충돌(사용자요구_1005.md) = 메인 판(추가 원문 포함)으로 풂
2026-10-05 00:38:13 +09:00
eomsangdon 8350e270ff Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
docs 충돌(사용자요구_1005.md) = 메인 판(추가 원문 포함)으로 풂
2026-10-05 00:37:59 +09:00
eomsangdon aaa0dff71b Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_6 2026-10-05 00:37:14 +09:00
eomsangdon 8cb06d0bea Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_6 2026-10-05 00:37:14 +09:00
eomsangdonandClaude Opus 5.5 9d42cb065b feat(M02): 일위대가 · 면적산출 함께 가져오기 길 (43-9a)
- copy 길 pair=true — 같은 원본 열의 면적산출(시트 · 알약 연결) + 일위대가(담은로직 · 입력값 · 수량 · 자재) 함께 덮어씀 · 도면 그대로
- 면적 먼저(일위대가 계산값이 새 칸 값 읽음) · 원본 면적 없음 · 실무문서 = 일위대가만
- 권한 · 남의 층 감사 기록은 기존 _root 그대로
- 화면 함수 copyLaborAreaFrom(열, 원본, from) — 카드 잇기는 43-9b
- 새 항목 칸 셋 자동 생성은 기존 집계표 저장 길 확인 · 시험 test_m02_pair_copy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-05 00:36:54 +09:00
eomsangdonandClaude Opus 5.5 4915f85227 test(M02): UI_Main 글 찾는 시험 기대 글을 41-3 코드에 맞춤 — onSelect 의 toDrawing.select · busy 의 toDrawing.waiting (43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 00:36:47 +09:00
eomsangdon 73ec041e10 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-05 00:36:23 +09:00
eomsangdon 46600a230b auto: 2026-10-05 00:35 (ESD_LAPTOP) 2026-10-05 00:35:07 +09:00
eomsangdonandClaude Opus 5.5 ef24de8b63 feat(M02): 일위대가 수량산출 모달 하나 — 지금 수량 · 손 입력 · 연결 풀기 + 면적산출 시트를 모달 안에 바로(칸 누름 = 이음) · 넣을 값 모달에서 수량 칸 뺌(43-1 · 43-2)
- 새 Labor_QtyModal.ts(로직 줄 수량산출 칸 · 자재 줄 같음 · [확인] = 손 · 고른 칸 · 풀기 · [닫기] = 그대로) · 두 단계 모달([엑셀에서 고르기]) 없앰
- AreaCellPick: 시트 부분을 mountAreaSheet 로 뗌(읽기만 · 실제 값 · 이은 칸 색 · 함께 잇기 · 바꾸기 물음 그대로) · 모달 pickAreaCell 지움
- Labor_Detail: openQty · openMat → openQtyFor · linkCell · Labor_Input: 수량 칸 · openQtyModal 지움(입력값 · 메모만)
- 화면 판정 시나리오 resources/tester/ui_checks/43-1_수량산출_모달.js · 시험 test_m02_area_panel 부품 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 00:35:03 +09:00
eomsangdon 6a5669d59c Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-05 00:34:54 +09:00
eomsangdon aaff1be817 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-10-05 00:34:07 +09:00
eomsangdon a21788db30 auto: 2026-10-05 00:34 (ESD_LAPTOP) 2026-10-05 00:34:01 +09:00
eomsangdon db2f4bf7bc Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-05 00:33:56 +09:00
eomsangdonandClaude Opus 5.5 ca4fcf0364 feat(M02): 구조물 조합 일위대가 카드 = 상세 그대로 — 단가산출 로직 2행 표 + 자재목록(Labor_Detail 카드 갈래 · 순서 · 로직 추가 · 다이어그램 · 정보 칸 없음 · 빼기 · 자재 추가 그대로) · 카드 [저장] · Ctrl+S
43-4 — 줄 누름 = 넣을 값 모달 · 수량산출 칸 = 수량 모달(상세와 같은 함수) · 저장 차례 = 면적 먼저 → 일위대가 · 표 감싸개 안 가로 스크롤 · 40-2 한 줄 표(compactTable · Labor_View compact · m02-labline css) 지움 · 빼기만 있는 줄도 끝 칸 · 화면 시나리오 ui_checks/43-4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 00:33:55 +09:00
eomsangdonandClaude Opus 5.5 bc1314e94c feat(M02): 일위대가 자재 줄 2행 · 가격 다섯(43-3) — M01 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급) · 기준단가 칸 강조
- 서버 calc 답 자재 줄에 가격 · 기준칸(마스터 · 회사 · 개인 층 calc · 프로젝트 복사본 calc 같은 함수 material_price) · 원본 자료는 읽기만
- 자재목록 표 = 코드 · 이름 · 규격 · 수량산출(위 1 단위 / 아래 지금 수량) · 단위 · 가격 다섯(빈 것 「—」) · 기준단가 / 반영 금액
- 화면 판정 시나리오 resources/tester/ui_checks/43-3_자재가격.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 00:33:28 +09:00
eomsangdonandClaude Opus 5.5 848f8bbc93 test(M02): 화면 시나리오 43-6 도면 표 선 색 — orca eval 한 번 · 놓기 → 저장 JSON 선 색 · 되돌림 (43-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-05 00:33:02 +09:00
eomsangdon fd77c511be Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-05 00:30:33 +09:00
eomsangdon bcedbbc118 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-05 00:29:00 +09:00
eomsangdonandClaude Opus 5.5 e2a938f2cf fix(M02): 상세 머리 [돌아가기] = 일반 단추 · 머리 단추 대비 시나리오(43-5 · E1)
- 도면 · 일위대가 · 면적산출 상세 [돌아가기] = 공용 기본(filled · 32px · 머리 단추와 같은 높이 · ghost sm 아님)
- E1 대비 1.11 = ORCA 탭에서 단추 color 전환이 멈춘 채 읽힌 착오 · 전환 끄고 재면 어두움 8.01 · 밝음 16.65 → 색 고침 없음
- 시나리오 resources/tester/ui_checks/43-5_돌아가기_대비.js · 시험 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 00:28:37 +09:00
eomsangdonandClaude Opus 5.5 5067ee4478 chore(M02): 3차 수정 사용자 요구 추가 원문(카드 새로 만들기 없앰 · 일위대가 · 면적산출 함께 가져오기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 00:27:47 +09:00
eomsangdon c7c004ef17 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_4 2026-10-05 00:27:28 +09:00
eomsangdon 46b52b28a6 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-05 00:27:26 +09:00
eomsangdon ba72671c58 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3 2026-10-05 00:27:19 +09:00
eomsangdon 41c1304761 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-05 00:27:16 +09:00
eomsangdonandClaude Opus 5.5 b1dbd9c3cb fix(B07 CAD): 서버가 만든 표 선 색 · 굵기 · id 비면 기본값 — 놓기 · 업데이트 · 다시 열기 한 곳 (43-6)
- TableEntity.fromJson: lineColor 비면 자동색 #fff · lineWidth 비면 1 · id 비면 새 id(면적산출 → 도면 표 변환 답에 세 칸이 없음 → 저장 JSON 선 색 빠지던 흠 E2)
- 시험 aislo-table-bridge.test.ts 에 선 색 없는 표 놓기 · 저장 JSON 선 색 · 다시 읽기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-05 00:26:53 +09:00
eomsangdon 0da4defcce auto: 2026-10-05 00:26 (ESD_LAPTOP) 2026-10-05 00:26:45 +09:00
eomsangdon 0fe3ffb1c3 auto: 2026-10-05 00:26 (ESD_LAPTOP) 2026-10-05 00:26:42 +09:00
eomsangdonandClaude Opus 5.5 31a753b7fd chore(M02): 3차 수정 사용자 요구 원문(10-5) 보관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-05 00:22:50 +09:00
eomsangdon 285aeec1dd Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-04 23:55:57 +09:00
eomsangdon f13fbf70ee Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-04 23:55:56 +09:00
eomsangdon caa9612cbd Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-04 23:55:55 +09:00
eomsangdon 6e2f17a55b Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-04 23:55:54 +09:00
eomsangdon 0b48e39026 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_8 2026-10-04 23:50:38 +09:00
eomsangdon 58d43c16f9 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_2 2026-10-04 23:50:28 +09:00
eomsangdon ad35aa777e Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-04 23:50:28 +09:00
eomsangdon c77cb1fa3d Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_4 2026-10-04 23:50:24 +09:00
eomsangdon 436fee7037 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_5 2026-10-04 23:50:18 +09:00
eomsangdon 8d1b8cd5f0 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into sub_laptop_3 2026-10-04 23:50:17 +09:00
eomsangdon 5b0d7fadaa Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-04 23:50:16 +09:00
eomsangdon c721fa852d Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-04 23:50:09 +09:00
eomsangdon 78b545da71 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-04 23:50:04 +09:00
eomsangdon 45f8c6cdd5 auto: 2026-10-04 23:49 (ESD_LAPTOP) 2026-10-04 23:49:58 +09:00
eomsangdon d7bef83a81 auto: 2026-10-04 23:49 (ESD_LAPTOP) 2026-10-04 23:49:55 +09:00
eomsangdon 2944f22b2a Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-04 23:49:55 +09:00
eomsangdon da49db8ce7 auto: 2026-10-04 23:49 (ESD_LAPTOP) 2026-10-04 23:49:54 +09:00
eomsangdon b7a0235c8b auto: 2026-10-04 23:49 (ESD_LAPTOP) 2026-10-04 23:49:48 +09:00
eomsangdonandClaude Opus 5.5 51cfb6ff44 chore(plan): 끝난 39~41장(M02 구조물 조합 개편 1 · 2차 · 면적산출 표 → 도면 표)을 docs/raw/plans 로 이관 — PLAN.md 는 1장 · 32장 · 42장(사용자 검증)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-04 23:46:21 +09:00
eomsangdon 795b861dae Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-04 23:34:56 +09:00
eomsangdon 1f337d20d9 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-04 23:34:54 +09:00
eomsangdonandClaude Opus 5.5 6a721484c9 fix(M02): 도면으로 보내기 흠 D3 · D4 — 빈 범위 막음 · 놓기 기다리는 표도 떠날 때 물음(41)
- D3 보이는 글이 없는 범위 = 크기 창 전에 「보낼 칸에 내용이 없음」 · 도면 안 엶
- D4 보낸 표 놓기 전 떠남(다시 열기 · 머리 메뉴 · 돌아가기) = 「저장 안 한 고침」 물음(취소 = 그대로 놓기 · 확인 = 버림) — busy 에 toDrawing.waiting · 확인 때 drop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 23:34:18 +09:00
eomsangdonandClaude Opus 5.5 18d097c745 fix(M02): 일위대가 한 줄 표 아주 좁은 카드(340px 이하) — 줄마다 두 줄(항목 · 수량산출 / 금액 넷) · 가로 넘침 0
- 머리 글 그대로 · 같은 꼴 두 줄 · 금액 11px · 카드 260 · 280 · 316 · 360 · 400 · 560 에서 scrollWidth = clientWidth · 잘린 칸 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 23:32:37 +09:00
eomsangdonandClaude Opus 5.5 2563272c69 fix(M02): 일위대가 카드 빈 채 남음 — 카드 「불러오는 중」 대상에서 일위대가 카드를 뺌(몸 Labor_View 를 덮었다 지우던 것)
40 · 41 끝 검증 D1 — 도면 · 면적 읽기가 0.3초 넘을 때 · 일위대가 몸은 제 「불러오는 중」.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 23:30:40 +09:00
eomsangdon bd5dd9cb21 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_5' into sub_laptop_8 2026-10-04 23:02:03 +09:00
eomsangdon aa818345b1 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-04 23:02:00 +09:00
eomsangdon be4d99cf62 Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_1 2026-10-04 23:01:55 +09:00
eomsangdonandClaude Opus 5.5 be2e677d27 test(M02): 구조물 도면 저장 꼴 시험 — 연결표 맞춤 갈고리(toDrawing.withLinks) 감쌈(41-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 23:00:27 +09:00
eomsangdonandClaude Opus 5.5 48a746d5b7 fix(M02): 도면으로 보내기 범위 — 면적 상세가 선 뒤에도 시트 첫 고름 유지 · 범위 없으면 알림(41-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 23:00:05 +09:00
eomsangdonandClaude Opus 5.5 b2aa2a987e fix(B07 CAD): 표 놓기 — 보낸 표 선 색 · 굵기 그대로 (41-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 22:56:28 +09:00
eomsangdonandClaude Opus 5.5 4db230bfff fix(M02): 일위대가 [엑셀에서 고르기] — 고르기 답의 바꾸기를 linkAreaCell 다섯째 인자로(기본 함께 · 바꾸기면 그 칸 다른 알약 뺌 · 40 이음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 22:53:25 +09:00
eomsangdon 7403aef9bc Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_2 2026-10-04 22:49:11 +09:00
eomsangdonandClaude Opus 5.5 cc63e49ab3 feat(B07): DXF 내보내기 표 = 선 + 글 — 칸 정렬 · 병합 자리 끊김 · B07 수량표도 나옴 (41-2)
- Frame_Export: Table 갈래 table_parts(경계선 · 글 자리) · 정렬 MIDDLE_LEFT · CENTER · RIGHT · 높이 = 칸 → 표 글자 크기 · 전에는 Table 이 조용히 빠짐
- 짝 = openwebcad table-geometry.ts(두 파일 머리) · 거울 시험 test_b07_frame_export_table.py + helper_b07_table_geometry.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 22:48:34 +09:00
eomsangdonandClaude Opus 5.5 3d6f59e494 feat(spreadsheet): 칸 하나에 알약 여럿 — 다른 알약 칸이면 [함께 잇기] · [바꾸기] · [취소] 물음(알약 누름 · 끌어 놓기 · 칸 지정 · 엑셀 고르기) · 칸 색 = 먼저 이은 알약 · 오른위 수 · 마우스 올리면 이름들 · linkAreaCell 함께 기본 (PLAN 40 마무리)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 22:48:12 +09:00
eomsangdonandClaude Opus 5.5 4a264d961e feat(M02): 도면으로 보내기 — 41-1 표 변환 답 · 41-2 놓기 손잡이 꼴에 맞춤(41-3)
- 답 {표, 시트, 범위, 값} · 연결표 줄 = {연결, 시트, 범위, 글자크기, 값(시트!A1: 글)} · 다름 = 값 견줌
- placeTable(표, 연결) · [업데이트] = updateTable 바꾼 표만 값 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 22:47:45 +09:00
eomsangdon ecc4ed372d Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_5 2026-10-04 22:45:48 +09:00
eomsangdon 33209b8677 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-04 22:45:45 +09:00
eomsangdonandClaude Opus 5.5 3ded39875f feat(M02): 면적산출 범위 → 도면 표 서버 — Node 번들 변환 · 표 길 · 면적 저장 때 도면 표 칸 글 맞춤 (41-1)
- common_util_spreadsheet_node.ts areaTable: 시트 · 범위 → CAD 표 객체(표시 글 = formatValue · cellStyleAt · 병합 → colSpan/rowSpan · 범위 밖 병합 잘라냄 · 숨긴 행열 뺌 · 가로 정렬 · 굵게 · 열 너비 · 행 높이 비율 · 칸 수 한도 · 칸 key 「시트id!A1」 · style.연결 빈 글)
- 파이썬은 부르기만(common_util_spreadsheet.area_tables · 새 M02_Area_Table.py)
- POST /api/m02/layers/{층}/area/{열}/table {시트, 범위, 글자크기} → {표, 시트, 범위, 값} · 저장 안 함 · 층 읽기 권한
- 면적 저장(Store.write · 비우기 · 복사 · 프로젝트 층 길) 뒤 그 열 도면 연결표마다 표 칸 글 다시 · 마지막 값 갱신 · 표 없으면 기록 둠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 22:45:09 +09:00
eomsangdonandClaude Opus 5.5 0bc5606638 feat(M02): CAD 표 놓기 · 칸 갈기 호스트 함수 — cad_host placeTable · updateTable · 도면 손잡이 (41-2)
- cad_host: placeTable(entity, link) · updateTable(link, cells) → 바꾼 표 수 · 도면이 열리기 전이면 열린 뒤 보냄(싣기가 덮지 않게) · 5 초 넘으면 실패
- M02_MasterTemplete_Drawing: DrawingTemplateHandle 에 같은 함수 둘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 22:44:36 +09:00
eomsangdonandClaude Opus 5.5 c7a8ed7cf9 fix(M02): 이름 맞춤 — 한 줄 표 머리 「항목 · 수량산출 · 단위 · …」(사용자 글) · 남은 화면 글 「담은 로직」 → 「단가산출 로직」(40-1 끝)
- 한 줄 표(compact) 머리만 · 상세 표 머리는 그대로
- 글 줄만: 넣을 값 빈 글 · 전체 항목 · 복사 생성 「단가산출 로직 수」 · 면적산출 알약 없음 · 실무문서 locale · B05 고르기 모달 일위대가 카드 · 면적 알약 없음 · 일위대가 문서 검사 글(「단가산출 로직 n째」)
- 시험 기대값: test_m02_item_list 머리 글

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 22:44:14 +09:00
eomsangdonandClaude Opus 5.5 c426fa2ad6 feat(B07 CAD): 표 놓기 도구 · 표 메시지 place-table · update-table (41-2)
- 새 tools/annotate/place-table-tool.ts — PLACETABLE: 부모가 보낸 표를 커서에 붙여 미리보기 · 누르면 그 자리(왼쪽 위) · Esc 그만 · 놓기 = 되돌리기 한 걸음
- 새 integration/aislo-table-bridge.ts — place-table{entity, link} = style.연결 단 표로 놓기 시작 · update-table{link, cells} = 연결 같은 표 칸만 갈기(자리 · 크기 그대로) → table-updated{link, count} · 확정 · 보기 전용이면 안 함
- TableEntity style.연결 · replaceCells · bridge 갈래 한 줄 · 명령 PLACETABLE
- 시험 aislo-table-bridge.test.ts(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 22:43:40 +09:00
eomsangdonandClaude Opus 5.5 408cd7b6c7 feat(M02): 면적산출 범위 → 도면 표 화면 틀(41-3) — [도면으로 보내기] · 같은 열 도면에 넘겨 놓기 · 연결표 기록 · 다름 알림 [업데이트]
- 새 M02_MasterTemplete_AreaToDrawing.ts — 범위 · 글자 크기(기본 2.5) 창 · 저장 안 한 고침 먼저 저장 · 표 변환 길(41-1) · placeTable · updateTable(41-2 손잡이) · 표 여럿 = T번호
- UI_Main 갈고리(select · onArea · onDrawing · withLinks) · StructureDoc 연결표 칸
- 시험 test_m02_area_to_drawing.py(Node)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 22:42:38 +09:00
eomsangdon ca88552f49 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-10-04 22:41:40 +09:00
eomsangdon c318fa7e0d Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-04 22:41:37 +09:00
eomsangdon b31468787a auto: 2026-10-04 22:39 (ESD_LAPTOP) 2026-10-04 22:39:53 +09:00
eomsangdonandClaude Opus 5.5 35ccc26afa feat(M02): 일위대가 수량산출 열 — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 / 엑셀에서 고르기(면적 칸 연결 · [저장] 때 면적 문서 함께) · 버튼 공용 양식(40-1 ④ ⑤)
- Labor_Rows 수량산출 칸(qtyCell) · 결과 단위 왼쪽 · 좁은 칸은 윗줄
- Labor_Input 수량 칸에 [엑셀에서 고르기](sub3 pickAreaCell) · 이으면 손 수량 지움 · 모달 닫음
- Labor_Detail: 면적 문서 · 판을 들고 linkAreaCell · recalcWorkbook · 저장 안 한 연결은 calc 에 칸 값으로 · saveArea
- UI_Main labor 갈래: [저장] = 면적 문서 먼저 → 일위대가 판 다시 읽음 → 일위대가(서버가 면적 저장 때 일위대가 계산값을 다시 적어 판이 바뀜)
- 로직 추가 · 자재 추가 · 올리기 · 내리기 · 빼기 = 기본 크기 버튼(sm 알약 꼴 아님)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 22:39:46 +09:00
eomsangdonandClaude Opus 5.5 e70fc184ae fix(M02): 넣을 값 고르기 칸 「자동값 xx」 빈 선택지 없앰 — 값이 없으면 자동값(첫 선택지)을 고른 채 흐리게 · 저장은 바꿀 때만(40-1 ③ · 브레인 정함)
- 자동값도 없는 고르기 칸만 「—」 빈 선택지 · 범위 · 글 칸은 그대로(빈 칸 = 자동값 자리표시)
- B05 고르기 모달 값 카드 확인 상자 받아들임 그대로(확인 false → true · 고르면 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 22:39:46 +09:00
eomsangdon 7ca49764ce Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-10-04 22:38:44 +09:00
eomsangdon d9276467b1 auto: 2026-10-04 22:37 (ESD_LAPTOP) 2026-10-04 22:37:28 +09:00
eomsangdonandClaude Opus 5.5 e3236558bd feat(M02): 구조물 조합 카드 [삭제] · 일위대가 카드 한 줄 표 — 그 항목 비우기(번호 · 열 그대로 · 물음 한 번 · 비운 뒤 빈 카드) · 좁은 카드 단추 줄 바꿈
40-2 ④ ⑤ — clearPart(40-4) 잇기 · 찬 카드 · 쓸 수 있을 때만 · 실패 = 카드 안 까닭 · Labor_View compact(40-1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 22:37:22 +09:00
eomsangdon a171ed3abc Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_8 2026-10-04 22:30:34 +09:00
eomsangdon ad90e72260 auto: 2026-10-04 22:29 (ESD_LAPTOP) 2026-10-04 22:29:27 +09:00
eomsangdonandClaude Opus 5.5 3ad007683c feat(spreadsheet): 알약 끌어 칸에 놓기 · 이은 알약 색 자동 — 키로 정해지는 색(24 색 · 두 테마 밝기) · 이은 칸 배경 같은 색 옅게 · 다른 알약 칸에 놓으면 바꿀지 물음 · 반짝임 그대로 (PLAN 40-3 ② ③)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 22:29:26 +09:00
eomsangdonandClaude Opus 5.5 5a9491bd69 chore(plan): 41장 면적산출 표 → 도면 표 — 사용자 요구 원문 더함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-04 22:28:55 +09:00
eomsangdon ffc76efe9c Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into sub_laptop_1 2026-10-04 22:27:25 +09:00
eomsangdon 2b7091d5a5 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-04 22:27:23 +09:00
eomsangdon 903eb8234e Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into sub_laptop_2 2026-10-04 22:27:04 +09:00
eomsangdonandClaude Opus 5.5 fd4830556e fix(M02): 일위대가 「담은 로직」 → 「단가산출 로직」 — 상세 · 보기 갈래(면적 패널) 제목 · 빈 글 · 같은 로직 알림 · 다이어그램(40-1 ②)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 22:26:05 +09:00
eomsangdonandClaude Opus 5.5 74a524202e feat(M02): 구조물 조합 카드 셋 3열 · 단추 「가져오기」 · 「수정」 · 「새 항목」 공용 버튼 크기
40-2 ①~③ — 상세 칸 960px 아래 1열(상세 칸 폭 container query) · 카드 [수정] 툴팁에 무엇을 고치는지 · 「새 항목」 = 저장과 같은 크기.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 22:26:00 +09:00
eomsangdonandClaude Opus 5.5 1c0868b788 feat(M02): 일위대가 보기 갈래 한 줄 표(compact · 카드용) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 반영 · 없으면 기준 흐리게 · 자재 줄 같은 표 아래(40-1 ①)
- createLaborView(onRow, { compact: true }) · Labor_Rows compactTable · baseQtyText(결과단위 기준 수량) · qtyShort(손 5 ㎥ · B6 = 12.5 · —)
- 400px 이하면 결과 단위 칸 접음 · 300 · 320 · 360 · 420 · 560 에서 가로 넘침 없음
- 시험: test_m02_labor_values 에 수량산출 글 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 22:24:35 +09:00
eomsangdonandClaude Opus 5.5 fda2527c4b feat(M02): 엑셀 칸 고르기 모달 부품 AreaCellPick — 그 열 면적산출 읽기만 시트 · 칸 누름 = 주소 · 연결 풀기 · 순수 linkAreaCell(면적 문서 · 알약 키 · 칸) (PLAN 40-3 ①)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 22:23:27 +09:00
eomsangdonandClaude Opus 5.5 00cb6db66f feat(M02): 항목 하나 비우기 길 — 도면 · 일위대가 · 면적산출을 새 열과 같은 빈 문서로 (40-4)
- POST /api/m02/layers/{층}/{structures|labor|area}/{열}/clear(owner) · 번호 · 열 그대로 · 받는 층 쓰기 권한(_root)
- Store.clear · 면적을 비우면 그 열 일위대가 계산값 다시 · 장부 맞춤
- 화면 함수 Api_Fetch clearPart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 22:21:23 +09:00
eomsangdon 572680d78a Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2 2026-10-04 22:20:34 +09:00
eomsangdon 74ab8d1d71 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-04 22:20:32 +09:00
eomsangdon 27fcbde040 auto: 2026-10-04 22:20 (ESD_LAPTOP) 2026-10-04 22:20:26 +09:00
eomsangdon 68817d7175 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1 2026-10-04 22:20:12 +09:00
eomsangdon 03602ea138 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-04 22:20:10 +09:00
eomsangdon 9b6c2e7057 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-10-04 22:20:09 +09:00
eomsangdon 1e992ca0e3 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-04 22:20:08 +09:00
eomsangdon 4347d25c99 auto: 2026-10-04 22:20 (ESD_LAPTOP) 2026-10-04 22:20:04 +09:00
eomsangdon 43b1c12765 auto: 2026-10-04 22:20 (ESD_LAPTOP) 2026-10-04 22:20:03 +09:00
eomsangdonandClaude Opus 5.5 7cdc7b08da chore(plan): 40장 M02 구조물 조합 개편 2차 — 사용자 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-04 22:18:59 +09:00
eomsangdon 11ef83985f Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-04 22:08:46 +09:00
eomsangdonandClaude Opus 5.5 6541b65834 fix(M02): 구조물 조합 표 「일위대가」 줄 = 그 열 일위대가번(SU) — 옛 조합 키(UA) 칸 안 읽음 · 번호 글 줄(도면 · 면적산출과 같음)
- 표 부품: 일위대가 줄도 번호 글 줄(손으로 못 침 · laborLabel) · 조회 후 선택이 열 칸을 안 고침
- 누르면 다른 줄과 같은 길(일위대가 복사해 가져오기) · 열의 옛 「일위대가」 값은 파일에 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 22:08:06 +09:00
eomsangdonandClaude Opus 5.5 d4458ec894 fix(M02): 일위대가 목록 합계 · 단위 · 새 항목 두 단추 — 수량 없으면 기준 합(작은 글 「기준」) · 담은 로직 0 「—」 · 단위 = 일위대가 문서 단위 · 누른 단추가 진하게
39-8 B2 · B3 · B4 — 서버 목록 칸 labor 에 기준 · 단위 · laborSum · Col note · buildTwoWay choose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 22:05:58 +09:00
eomsangdonandClaude Opus 5.5 26a24889e6 test(M02): 층 길 접두어 시험 — 시스템 층도 /layers/system/… 꼴 기대 (39-8 H2 뒤)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 21:49:28 +09:00
eomsangdon e6fe5232e5 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-04 21:46:45 +09:00
eomsangdon 308b893e17 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-04 21:46:44 +09:00
eomsangdon 67f090879f Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-04 21:46:43 +09:00
eomsangdon 7a4f1da235 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-04 21:46:42 +09:00
eomsangdon 4112da7b7d Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-04 21:46:41 +09:00
eomsangdonandClaude Opus 5.5 524dc8febc fix(M02): 마스터 층 읽기도 층 길 한 벌 — 회사 관리자 · 일반 「시스템 관리자 권한」 막힘 풂 (39-8)
- Api_Fetch: 시스템 층도 /layers/system/… (양식 목록 · 읽기 · 저장 · 지우기 · 번호 · 장부 · 새 도면 · 복사) — 옛 /api/m02/templates/* 부름 0
- 서버: GET /layers/system/structure-options(읽기 로그인 누구나 · 고쳐 씀은 옛 길 그대로) · 층 양식 목록에 쓰는곳(지우기 확인 전 막음)
- 「〇〇로 복사…」 폴더 창 칸 글 「복사할 폴더」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 21:37:24 +09:00
eomsangdonandClaude Opus 5.5 31d9a099be fix(M02): 가져오기 목록 — 그 부분 문서가 없는 줄은 흐리게 · 누르면 까닭 한 줄 · 확인 창 · 복사 안 감
39-8 H3 — 옮기기 전 층(일위대가 labor 없음 등)에서 고르면 「없는 양식」 으로 실패하던 것 · missingPart(도면 · 면적산출 · 일위대가 · 항목 통째는 막지 않음).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 21:30:36 +09:00
eomsangdonandClaude Opus 5.5 4e1b7cc5ef fix(M02): 나무가 바뀌면 좌측 하위 열 줄도 다시 받음(39-8 H1)
- 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 onChanged 한 길 — 집계표 다시 열기와 함께 side.refresh(열 줄 · 번호)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 21:29:58 +09:00
eomsangdonandClaude Opus 5.5 26f12e081f fix(M02): 입력 모달 「면적산출에서 잇기」 — 「저장 안 한 고침」 물음에서 취소면 입력 모달 · 넣던 값 그대로(옮겨 갔을 때만 닫음)
- goArea 가 옮겨 갔는지(참 · 거짓)를 돌려줌 · UI_Main labor 갈래 · 자재 수량 모달도 같은 길

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 21:25:35 +09:00
eomsangdonandClaude Opus 5.5 68f1c20e65 fix(M01): 자재 · 직종 고르기 찾기 — 낱말이 키(MT…)와 같으면 맞음(39-8 · 찾기 칸 글 「이름 · 규격 · 코드」 와 맞춤)
- 이름 · 규격 낱말 AND · 띄어쓰기는 그대로 · 원본 자료는 읽기만
- M02 일위대가 [자재 추가] · M01 자재 고르기가 같은 길(/api/m01/pick)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 21:22:09 +09:00
eomsangdonandClaude Opus 5.5 88ce573e63 fix(M02): 카드 [알약] 취향 계정 따름 — 계정 취향 받은 뒤에 올림(브라우저 옛 값이 덮지 않게) · 카드는 열 때마다 다시 읽음 (PLAN 39-8 ③)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 21:22:01 +09:00
eomsangdonandClaude Opus 5.5 c535de7b31 fix(M02): 면적 오른쪽 패널 빈 칸 = 수량 없음 — 서버 area_values 와 같은 규칙(식 칸 = 계산 수 · 그 밖 = 칸 값 · 빈 · 글 빠짐) · 「수량 A1 = 빈 칸」 · 반영 — (PLAN 39-8 ②)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 21:22:00 +09:00
eomsangdonandClaude Opus 5.5 46f8b7611d fix(M02): 일위대가 보기 갈래 좁은 칸 — 표를 줄마다 카드로(이름 한 줄 · 금액 넷 한 줄 · 칸 이름 위에 작게 · 합계 굵게) · 가로 스크롤 없음
- 컨테이너 쿼리(.m02-labview 640px 이하)만 · 넓은 상세 표는 그대로
- Labor_Rows: 금액 · 수 칸에 data-label · 차례 · 단위 · 코드 · 규격 칸 이름표
- 확인: 면적 패널 320 · 420 · 560 에서 scrollWidth = clientWidth(담은 로직 · 자재목록) · 밝음 · 어두움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 21:17:58 +09:00
eomsangdonandClaude Opus 5.5 b1d092efeb test(M02): 층 길 시험 — 지운 Combo_Api 대신 일위대가 문서 길(laborAt 층 받음)을 봄(39 마무리)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 21:06:29 +09:00
eomsangdonandClaude Opus 5.5 8087c73060 refactor(M02): 새 항목 일위대가 고르기 없앰 — 항목 추가 창 줄 · pickCombo · 서버가 받는 일위대가 키 · 열 칸 새로 쓰기 · /combos/all · fetchUsableCombos 지움 · 새 구조물 일위대가 = 빈 labor 문서(39-1d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 21:04:48 +09:00
eomsangdon 5066c6bb27 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-04 21:00:15 +09:00
eomsangdonandClaude Opus 5.5 022a8d854d refactor(M02): 일위대가 조합 남은 은퇴 — 갈라 내기 · 키 적기(use-combo) · 조합 만들기 · 지우기 · 알약 길 지움 · Branch.py 지움 · 시험은 저장소 함수 · 은퇴 길 404 · 405 로(39-1d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 20:58:51 +09:00
eomsangdon a507d37545 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-10-04 20:48:51 +09:00
eomsangdon 476fd6d8ee Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-04 20:48:49 +09:00
eomsangdon a6d7851e83 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_4 2026-10-04 20:48:45 +09:00
eomsangdonandClaude Opus 5.5 1cce06780e refactor(M02): 옛 일위대가 조합 상세 Labor_Edit.ts 지움 — 그것만 쓰던 조합 길 함수(newCombo · editCombo · deleteCombo · fetchComboInputs · fetchLibLogic · fetchLibAuto) · 죽은 글 17 · CSS(미리 보기 · 조합 목록 · flash)도
- 쓰는 곳 0 은 grep 으로 확인한 것만 · Labor_Pick · View · Detail · Rows · Input · Diagram 은 남김
- 시험: test_ui_loading 의 Labor_Edit 줄 → 실무문서 상세 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 20:47:42 +09:00
eomsangdonandClaude Opus 5.5 1e1fb9f811 refactor(M02): 옛 조합 패널 지움 — ComboPanel · Combo(조합 줄 · 조합 고치기 · 갈라 내기 창) · Combo_Api · 클라이언트 branchCombo · setColumnCombo · 쓰는 곳 0 (PLAN 39-4 D3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 20:46:17 +09:00
eomsangdon ae959d9ec9 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_1 2026-10-04 20:44:53 +09:00
eomsangdonandClaude Opus 5.5 e7fc09bf53 test(M01): 조합 고치기 시험 — 은퇴한 PUT 길 대신 저장소 함수 combo_edit 로(39-1d 뒤)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 20:44:41 +09:00
eomsangdon 71aef816f0 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-04 20:44:31 +09:00
eomsangdonandClaude Opus 5.5 95d5876495 feat(M02): 면적산출 오른쪽 패널 — 지금 시트 연결 칸 주소를 일위대가 보기에 넘김 · 저장 전 잇기도 「수량 C3 = 12.5」 (PLAN 39-4 D6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 20:44:09 +09:00
eomsangdon b0fdc84931 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-04 20:42:46 +09:00
eomsangdonandClaude Opus 5.5 0674fad4f2 merge(M02): origin/sub_laptop_1 받음 — ItemCards · Structure 충돌 풂(카드 labor · copyPartFrom 살림 · 재료산출 지움 반영)
- ItemCards 카드 글 = 도면 · 면적산출 · 일위대가(material 빠짐)
- Structure.ts = copyPartFrom 하나(옛 COPY 표 · copyArea · copyStructure 지움) · 일위대가 카드 열기 살림
- Api_Fetch copyPartFrom 종류에서 material 뺌(재료산출 지움 39-7a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 20:42:33 +09:00
eomsangdonandClaude Opus 5.5 8cf26e0b0c test(M02): 카드 실패 · 불러오는 중 시험 기대값을 지금 꼴로 — 고르기 모달 retry 하나 · 일위대가 나무(Labor.ts) 지움 뒤 실무문서 화면
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 20:41:41 +09:00
eomsangdonandClaude Opus 5.5 e96a0ef8ac feat(M02): 일위대가 보기 갈래 setQty(수량, 칸) — 저장 전 연결 칸 주소도 「수량 B6 = 20」 으로(면적산출 오른쪽 패널용)
- 칸 = {알약 키: 칸 주소} · 주면 그 뒤로 저장본 연결보다 앞섬 · 열을 다시 보이면 비움
- 시험: test_m02_labor_values 에 수량 글 칸 주소 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 20:41:41 +09:00
eomsangdonandClaude Opus 5.5 f49e31e40e merge(M02): origin/sub_laptop_6 받음 — UI_Main 충돌 풂(면적 일위대가 패널 살림 · 재료산출 · 조합 패널 길 지움)
- sub3 apanel · 고른 칸 · refresh 살림 · sub6 의 matPanel · matState 지움 · 면적만 남아 죽은 조합 패널(cpanel) · remount 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 20:39:09 +09:00
eomsangdon 42eddb7037 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2 2026-10-04 20:39:03 +09:00
eomsangdon 28310476a0 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-04 20:38:47 +09:00
eomsangdonandClaude Opus 5.5 6d46dda543 fix(M02): 저장 안 한 고침 물음 — 「새 항목」 두 단추 화면으로 갈 때 · 카드 가져오기 전(가져온 뒤 카드를 다시 읽음)
39-5 예외 — 고침이 말없이 버려지지 않게.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 20:19:31 +09:00
eomsangdonandClaude Opus 5.5 6f84708909 feat(M02): 일위대가 카드 · 다른 곳에서 가져오기 — 카드 차례 도면 → 일위대가 → 면적산출 · 일위대가 몸 = 보기 갈래 · 빈 일위대가 두 단추 · 가져오기 = 같은 층 · 다른 층 · 사람 · 실무문서(일위대가)
39-5 H5 — copyPartFrom(from_layer · from_owner · practice · 39-1b 길) · 집계표 일위대가 줄 「조회 후 선택」 = 일위대가 복사해 가져오기 · CardKind(카드만 labor · 프로젝트 고르기 모달 EditKind 그대로).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 20:18:36 +09:00
eomsangdonandClaude Opus 5.5 c704f18982 refactor(M02): 선택 입력 지움(39-7b G3) — 카드 · 자리 고르기 · inputs/ 연결 · 입력 목록 · 연결 · 알약 길 · 프로젝트 복사본 「입력연결」 · 시스템 층 inputs · 시험
- 넣을 값 카드(M02_Labor_Values)는 담은 로직 입력 전부를 보임(자리 「선택」 감싸기 없앰) · 이름 「넣을 값」
- 고르기 모달 면적산출 알약 = 복사본 일위대가 알약(가져온 때 그대로)
- 입력값.json · 고른자재.json · /calc 는 그대로 · storage 층 inputs/ 파일은 안 건드림(읽기만 끊음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 19:36:46 +09:00
eomsangdonandClaude Opus 5.5 79e4bba1bb merge(M02): origin/dev(39 통합) 받음 — G1 · G2 지움과 부딪힌 곳 풂
- 남의 새 줄(labor · 일위대가번 SU · 실무문서 · 「전체 항목」 · 수평선 · 알약 켬끔 · 복사해 가져오기)은 살리고 material · 자재조합 · 재료번 · matset 줄만 뺌
- dev 쪽 새 시험(test_m02_item_labor · test_m02_card_sheet_edit)의 재료산출 · 자재조합 자리 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 19:22:22 +09:00
eomsangdonandClaude Opus 5.5 611f5413f3 feat(M02): 면적산출 상세 오른쪽 = 일위대가 패널 AreaPanel — 담은 로직 · 자재목록(기준 · 반영) · 줄 누름 칸 지정 · 칸 값 바뀌면 바로 곱함 · 조합 줄 · [조합 고치기] 지움 (PLAN 39-4 D2 · D6)
- AreaLink 칸 값 글(수 · 글 · 참 · 오류) 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 19:06:21 +09:00
eomsangdon f0b8882a84 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_5 2026-10-04 18:41:56 +09:00
eomsangdonandClaude Opus 5.5 a97ad41c47 fix(M02): 39 통합 뒤 엇갈림 — 일위대가 합 칸 없을 수 있음(picker) · 새 열 = 빈 일위대가 · Structure.ts readTemplate 뺌 시험 맞춤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 18:40:55 +09:00
eomsangdonandClaude Opus 5.5 16fc214b17 merge(M02): origin/sub_laptop_3 받음 — Structure.ts import 충돌 풂(getActiveOwner 더함 · readTemplate 뺌)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 18:30:44 +09:00
eomsangdonandClaude Opus 5.5 f393abdeec merge(M01): origin/sub_laptop_7 받음 — 로직 지우기 검사 두 벌을 sub4 한 벌로(labor_errors · 실무문서 · 알림 글)
- sub7 의 LABOR_ROOTS · 겹친 _labor_files · labor_errors 지움 · sub7 시험은 층 자리(layers.SYSTEM_ROOT) · 알림 글만 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 18:30:14 +09:00
eomsangdon e700c7ca3d Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_2 2026-10-04 18:28:36 +09:00
eomsangdon 5d603ddb27 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-10-04 18:28:32 +09:00
eomsangdon 09ea0148e3 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-10-04 18:28:31 +09:00
eomsangdon 766282cab6 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_7 2026-10-04 18:28:04 +09:00
eomsangdon 1192f4c264 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-10-04 18:28:01 +09:00
eomsangdonandClaude Opus 5.5 1df0a7fb05 merge(M02): origin/sub_laptop_2 받음 — Api_Fetch Kind 설명 충돌 풂(labor 한 번)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 18:28:00 +09:00
eomsangdon 068909eb9a Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_7 2026-10-04 18:28:00 +09:00
eomsangdon b717724457 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-04 18:27:58 +09:00
eomsangdon d40d4312f5 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_1 2026-10-04 18:04:07 +09:00
eomsangdon 27f117b97b Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1 2026-10-04 18:04:04 +09:00
eomsangdon 5df5c3ef12 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_1 2026-10-04 18:04:04 +09:00
eomsangdon 66c77a7445 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-04 18:03:55 +09:00
eomsangdonandClaude Opus 5.5 5b4aed1b20 feat(M02): 일위대가 상세를 UI_Main 종류 labor 로 엶 · 보기 갈래 부품 · 수량만 바뀌면 짝 셈으로 바로 곱함(39-3 C7)
- UI_Main: 종류 labor 갈래 한 곳(mountLaborDetail · 「면적산출에서 잇기」 = 물은 뒤 그 열 면적산출) · 번호 칸 일위대가번
- Api_Fetch: Kind 에 labor
- 새 Labor_View.ts(createLaborView — 면적산출 오른쪽 패널 · 일위대가 카드용 · 담은 로직 + 자재목록 · show · setQty · onRow)
- Labor_Detail: 면적산출 연결 칸 → 「수량 F14 = …」 · 수량만 바뀌면 Labor_Calc.settle 로 서버 없이 · 손 입력 숫자는 수로
- Labor_Api: calc 답 모양은 Labor_Calc.ts 것을 씀 · normalizeLabor · areaLinks
- 자재 표 머리 「단위」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 18:03:54 +09:00
eomsangdonandClaude Opus 5.5 cbcfc18436 feat(M01): 로직 지우기 검사 = 모든 층 일위대가 문서 담은로직 + 실무문서 — 쓰는 곳 알림 · 조합 고치기 길(PUT) 은퇴(39-1d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 18:03:35 +09:00
eomsangdon 1a415793c9 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-10-04 18:03:25 +09:00
eomsangdonandClaude Opus 5.5 1baf761c4d feat(B05): 항목 고르기 모달 일위대가 카드 · 넣을 값 = 담은 로직 입력 전부 (39-1c)
- 카드 차례 도면 · 일위대가 · 면적산출 — 새 ui_template_item_picker_labor.ts(담은 로직 수 · 자재 수 · 기준 합 · 반영 합 · 빠진 입력 · 멈춘 로직)
- 자료 laborOf: 복사본이면 복사본 일위대가 + calc · 아니면 원본 층 labor calc(저장 안 함) · 일위대가 없으면 「일위대가 없음」
- 넣을 값 카드 · 편집 = M02_Labor_Values 바로 · 목록 = 담은 로직 입력 전부(logic-lib/inputs) · 값은 복사본 입력값
- ProjectCalc 에 기준합 · 반영합 계 · 시험 test_item_picker_detail 손봄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 18:01:32 +09:00
eomsangdonandClaude Opus 5.5 ecc4a651a4 feat(M01): 로직 지우기 검사 — 모든 층 구조물 일위대가 문서가 담은 로직까지 (39-1b B9)
- M01 저장 전 검사(check_saved)에 labor_errors — 시스템 층 · storage 회사 · 개인 층 labor/*.json 이 담은 로직이 없어지면 막음(프로젝트 층 빼고 · 실무문서는 조합 검사 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 17:57:07 +09:00
eomsangdonandClaude Opus 5.5 61d2ed5997 refactor(M02): 재료산출 시트 지움 — 종류 material · 재료번 SM · 좌측 줄 · 집계표 「재료산출」 줄 · 카드 · 전체 편집 · 복사 길 · 프로젝트 복사본 「재료」 · 고르기 모달 · 시스템 층 · fixture · 시험
- 새 프로젝트 씨앗(KINDS)에서도 빠짐 · 새 열은 구조물 도면 · 면적산출만 만듦
- storage 층(회사 · 개인 · 프로젝트) material/ 파일은 지우지 않음(읽기만 끊음)
- 장부 재료번 칸은 다음 집계표 [저장] 때 빠짐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 17:56:49 +09:00
eomsangdonandClaude Opus 5.5 e1ad3d3625 feat(M02): 프로젝트 복사본 일위대가 문서 — 스냅샷 · 입력값 첫 값 · 자재 단가 · 수량 반영 계산 (39-1c)
- Item_Source: 층 labor/<열>.json 이 있으면 일위대가.json = {줄: 문서, 로직, 빠진로직, 알약} · 없으면 옛 UA 조합 줄 · 단가 묶음에 자재 MT
- Item_Snapshot: 입력값.json 첫 값 = 문서 입력값 {로직: {입력: {값, 확인: false}}}
- Item_Prices: collect 가 자재 MT 도 · calc 가 줄마다 수량 · 반영 + 자재 · 기준합 · 반영합(M02_Labor_Doc.settle 한 벌 · 면적 알약 칸 값)
- Item_Copy · Project_Items_Router: 일위대가 종류 읽기만(쓰면 400) · 재적용은 새 첫 값 위에 프로젝트 값 · calc 에 면적
- 시험 test_m02_item_labor.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 17:55:58 +09:00
eomsangdonandClaude Opus 5.5 dcce47b6d9 test(M02): 구조물 조합 제목 줄 시험 — 새 머리 꼴(← 제목 앞) · 「새 항목」 = 두 단추 화면 · 고른 폴더 folder()
39-5 — 134a727c9 · 364d22ff6 뒤 기대값 맞춤.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:54:24 +09:00
eomsangdonandClaude Opus 5.5 6cf72bbf11 feat(M02): 카드 빈 칸 두 단추 · 「복사해 가져오기」 — 빈 카드 [새로 만들기] [복사 생성] · 찬 카드 가져오기 · 집계표 「조회 후 선택」 도 탭 + 목록 고르기
39-5 — 가져오기 = ItemPick 창(탭 · 받는 열 자신 뺌) → 덮어쓰기 확인 → 내용만 복사(번호 · 열 그대로) · 같은 곳은 지금 길 · 다른 층 · 사람은 서버 층 넘김 길(39-1b) 뒤 이음(지금은 막음 알림) · 일위대가 카드는 보기 부품(39-3) 뒤.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:52:56 +09:00
eomsangdonandClaude Opus 5.5 07ac61d4d6 merge(M02): origin/dev 받음 — 일위대가 조합 길 충돌 풂(39장)
- Labor_Api.ts · Router_Combos.py = 나무 길(sub5) · 미리 보기 길(sub2) 둘 다 지운 꼴 · 시스템 관리자 문 _door(sub4) 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 17:52:17 +09:00
eomsangdonandClaude Opus 5.5 d21f258da6 feat(M02): 면적산출 알약 = 일위대가 pills 길 — 로직 물량 「이름|단위」 + 자재 「자재|MT키」 · 다 고정 · 선택 입력 알약 빠짐 (PLAN 39-4 D7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 17:52:05 +09:00
eomsangdonandClaude Opus 5.5 36c437d9a1 fix(M02): 남의 층 쓰기 기록 = 성공한 쓰기만 — 길이 답한 뒤 남김 · 막힘 · 실패 부름 뺌(39-1d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 17:51:28 +09:00
eomsangdonandClaude Opus 5.5 5617f9e891 feat(M02): 항목 하나 복사 층 넘김 · 실무문서 조합 → 일위대가 · 구조물 복사 = 새 일위대가번 (39-1b B5 · B6)
- POST /layers/{층}/{structures|labor|area|material}/{열}/copy 에 from_layer · from_owner(원본 읽기 권한 · 받는 층 쓰기) · from_layer=practice = 실무문서 조합(UA) → 일위대가 문서(시스템 관리자만 · 일위대가만)
- copy_from 원본 층 · 원본 문서 받음 · 면적산출 복사 뒤 그 열 일위대가 계산값 다시
- 구조물 조합 복사(copy_item) · 분류 더하기 = 일위대가 문서를 새 SU 로 복사 · 열 일위대가 조합 키는 안 받음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 17:51:17 +09:00
eomsangdon ba1658de68 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_3 2026-10-04 17:49:33 +09:00
eomsangdon 3db416fc7b Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4 2026-10-04 17:49:33 +09:00
eomsangdon fcb9513aee Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_4 2026-10-04 17:49:29 +09:00
eomsangdon aaf9407c9a Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-04 17:49:12 +09:00
eomsangdonandClaude Opus 5.5 fdb07fa4ab style(M02): 면적산출 카드 [알약] 켬 단추 강조색 — 켬 · 끔이 단추에서 보임 (PLAN 39-4 D1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 17:48:58 +09:00
eomsangdonandClaude Opus 5.5 1cf32deaca feat(M02): 좌측 일위대가 하위 열 줄 — 「SU-… 이름」 · 누름 = onOpen({kind: "labor"})(39-2)
- 일위대가 하위 = 구조물 열마다 한 줄(번호 = /structures/numbers 일위대가번 · 없으면 「없음」)
- Api_Fetch Kind 에 labor · StructureNumbers 에 일위대가번(39-1a 서버 칸)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 17:48:04 +09:00
eomsangdonandClaude Opus 5.5 ba5c2e99d4 feat(M02): 옛 일위대가 조합 → 열마다 일위대가 문서 옮기기 — 시스템 층 돌림 (39-1b B7)
- M02_Labor_Migrate: 층마다 열마다 labor 문서 · 여러 열이 쓰던 조합은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 다시 돌려도 같음 · --storage(회사 · 개인 층) · --dry-run
- M02_Labor_Doc.from_combo: 옛 조합(실무문서 UA · UC · UP) → 일위대가 문서 몸(본뜬 = 층 · 주인 · 키)
- 시스템 층 결과: labor/item1(← UA000008) · item2(빈) · item3(← UA000001)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 17:46:30 +09:00
eomsangdon 8d7214ba55 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-04 17:45:52 +09:00
eomsangdon bbe3f0f1a7 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_5 2026-10-04 17:45:25 +09:00
eomsangdon 1aad41ec0e Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-10-04 17:45:21 +09:00
eomsangdon 7dc557037b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-04 17:45:21 +09:00
eomsangdon 9242cd15e5 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-10-04 17:45:16 +09:00
eomsangdonandClaude Opus 5.5 3ad7678e15 feat(M02): 일위대가 자재목록 [자재 추가] — M01 자재 하나씩(M02_Material_Pick) · 같은 자재 두 번 막음(39-3 C5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 17:44:57 +09:00
eomsangdonandClaude Opus 5.5 f8d024fc28 refactor(M02): 일위대가 조합 나무 서버 길 지움 — /tree · /tree-many · /paste(39-2)
- Combo_Store 나무 고침 함수(_apply · edit_tree(_many) · commit · _write_master_folders · _set_path) 지움 · 목록 폴더는 읽기만
- Combo_Copy.py(층 사이 붙여넣기) 지움 · Labor_Api 나무 · 붙여넣기 함수 지움
- 옛 나무 · 붙여넣기 시험을 「길 없음」 검사로 갈음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 17:44:31 +09:00
eomsangdonandClaude Opus 5.5 29922434d6 feat(M02): 일위대가 자료 틀 — 열마다 labor 문서 · SU 번호 · 서버 계산 · calc · pills 길 (39-1a)
- labor 종류 · 일위대가번 SU(도번 · 면적번과 같은 뒷자리) · 빈 문서 · 뼈대 검사
- M02_Labor_Doc: 정리 · 검사(같은 로직 두 번 · 없는 로직 · 자재 · 수량 꼴) · 기준(M01 엔진) · settle(수량 · 반영 · 합)
- [저장] · 복사 때 서버가 계산값 다시 적음 · 면적산출 저장 때 그 열 일위대가 계산값도
- 짝 M02_MasterTemplete_Labor_Calc.ts settle + 거울 시험
- 길 POST /layers/{층}/labor/{열}/calc · GET …/pills (main.py 등록)
- M01 _PER 「원/1,000본」 배 1000

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-04 17:43:43 +09:00
eomsangdon 24e03f78de Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-04 17:43:10 +09:00
eomsangdonandClaude Opus 5.5 d847f724cf feat(M02): 일위대가 상세 부품 — 담은 로직 표 · 입력 모달 · 자재목록 표 · 다이어그램 calc 값 · 미리 보기 지움(39-3 C3 · C4 · C6)
- 새 Labor_Detail.ts(mountLaborDetail · 차례 = 정보 → 담은 로직 → 자재목록 → 다이어그램 · 고칠 때마다 calc 를 뒤에서 · 최신 답만 · 같은 로직 · 자재 두 번 막음)
- 새 Labor_Input.ts(담은 로직 줄 입력 모달 · 빈 칸 = 자동값 · 메모 · 수량 = 이은 칸 / 손 입력 / 면적산출에서 잇기 · 사라진 입력값 알림 · 자재 수량 모달)
- 새 Labor_Rows.ts(작은 글 「지역 서울 · 수량 B6 = 12.5㎡」 · 빈 값 n 붉게 · 금액 칸 넷 두 줄(위 기준 · 아래 반영) · 합계 줄 · 보기 갈래는 손잡이 없이)
- Labor_Api.ts: labor 문서 · calc 답 모양 · calcLabor · fetchLogicInputs · previewCombo 지움
- Labor_Diagram.ts: buildDiagramData → buildLaborDiagram(줄 입력값 + calc · 수량 있으면 반영 · 없으면 기준 표기 · 자재목록 카드 하나 → 재료비 · 알약 = 입력 모달)
- 미리 보기 지움: Labor_Preview.ts · M02 preview 길 · Combo_Store.preview · 옛 조합 상세의 미리 보기 · 다이어그램 칸
- 시험: test_m02_labor_diagram 새 모양 · test_m01_combo 미리 보기 시험은 같은 계산(combo_preview)을 바로 부름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 17:42:59 +09:00
eomsangdon 91bb01c3f3 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_1 2026-10-04 17:41:29 +09:00
eomsangdonandClaude Opus 5.5 8f5a573ebc fix(M02): 가져올 곳 「다른 사람」 — 나는 뺌(개인 탭) · 빈 사람 「비어 있음」 · 처음 = 첫 빈 아닌 사람 · 빈 사람은 빈 목록
39-5 — 사람 목록 전부(39-6 E1~E4)에 맞춤.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:40:31 +09:00
eomsangdonandClaude Opus 5.5 a2d1471931 feat(M02): 화면 권한 맡은 회사 — accessFor 짝 · 개인 단추 = 회사 사람 나무(나 맨 위 · 비어 있음) · 「〇〇로 복사…」 볼 회사까지 · 대상 폴더 고르기 창(39-6 E5~E8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 17:39:38 +09:00
eomsangdonandClaude Opus 5.5 54ce67ceb8 refactor(M02): 자재 조합(UM) 지움 — 좌측 컨테이너 · 재료산출 옆 판 · 길(material-combos · 갈라 내기 · 키 적기 자재조합) · 저장소 · 틀 검사 · 복사 길의 UM 줄 · 시스템 층 표 · fixture · 시험
- 자재 고르기 창은 M02_Material_Pick.ts 로 옮겨 남김
- storage 층(회사 · 개인 · 프로젝트) 자재조합.json 은 지우지 않음(읽기만 끊음)
- check_master 틀 검사 · _틀.md 11장 · _화면_계약.md 자재 조합 절 정리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 17:39:16 +09:00
eomsangdon 789c78db2d Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1 2026-10-04 17:38:06 +09:00
eomsangdon fe8d0b8359 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-04 17:38:04 +09:00
eomsangdonandClaude Opus 5.5 364d22ff6f feat(M02): 새 구조물 조합 두 단추 — 「새 항목」 → 상세 칸 [새로 만들기] [복사 생성] · 복사 생성 = 탭 + 목록 → 지금 층 · 고른 폴더 · 새 키 · 새 번호
39-5 H4 — 나무 copyIn(붙여넣기 길 · 새 열 하나면 그 열 고름) · folder 손잡이 · ItemPick buildTwoWay(빈 카드도 씀).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:37:46 +09:00
eomsangdon 54ba657ff1 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-04 17:37:02 +09:00
eomsangdon c507fe237f Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-04 17:37:01 +09:00
eomsangdonandClaude Opus 5.5 0e28b5be4a feat(M02): 좌측 「실무문서」 — 옛 마스터 일위대가 조합 목록 · 상세 읽기만 · 통째 지우기(39-2)
- 새 M02_MasterTemplete_Practice.ts — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만
- [통째 지우기] = 물음 두 번 → DELETE /api/m02/practice(시스템 관리자 문 is_system_admin · 판 · 쓰는 곳 0 확인 · 줄을 비움)
- Combo_Store.drop_practice · 시험 test_m02_practice.py(임시 사본)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 17:36:51 +09:00
eomsangdonandClaude Opus 5.5 516719d50d feat(M02): 층 권한 「회사 맡음」 — 맡은 회사 · 그 회사 개인 층 고침 · 남의 층 첫 쓰기 소속 확인 뒤 뼈대 · 시스템 로그 · 일위대가 조합 길 시스템 관리자만 · 사람 목록 전부 · 가져오기 같은 규칙(39-6 E1~E4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 17:36:44 +09:00
eomsangdon 9469956b47 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-04 17:34:55 +09:00
eomsangdonandClaude Opus 5.5 af92f0553e feat(M02): 「전체 항목」 화면 — 구조물 도면 · 일위대가 · 면적산출 하위 목록을 상세 칸에 · 줄 누름 = 그 항목 상세
39-5 H2 — 좌측 onAll(39-2) 이음 · 제목 「하위 · 전체 항목」 · 목록 = ItemList(M01 꼴).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:34:10 +09:00
eomsangdon 4ff4855767 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_5' into sub_laptop_1 2026-10-04 17:31:41 +09:00
eomsangdonandClaude Opus 5.5 eb132f13d0 feat(M02): 가져올 곳 고르기 ItemPick — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록 · 칸 안 · 창 · 줄 누름 확인
39-5 H3 — 탭 목록 · 기본 탭 = readableSources(39-6 E5) · 다른 사람 = 사람 고르기 하나 · 실무문서 = 마스터 일위대가 조합(일위대가 고를 때).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:31:11 +09:00
eomsangdonandClaude Opus 5.5 80a635d3b9 feat(M02): 좌측 구조물 조합 수평선 · 하위 「일위대가」 · 「전체 항목」 줄 · onAll 손잡이(39-2)
- 층 단추 · 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출(재료산출 · 자재 조합은 sub6 몫으로 그대로)
- 하위마다 맨 위 「전체 항목」 — 누르면 SideOptions.onAll(kind)(화면은 39-5) · 양식 · 다른 보기가 열리면 고름 지움
- 이름 「일위대가」(하위 제목 · 대시보드 카드 글) · 해시 open=일위대가 = 일위대가 하위 펼침 + 전체 항목
- 일위대가 열 줄은 SU 번호(39-1a) 뒤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 17:30:41 +09:00
eomsangdonandClaude Opus 5.5 43a59a6893 refactor(M02): 일위대가 입력 목록 · 넣을 값 칸 부품 옮김(39-3 C2) — 선택 입력에서 M02_Labor_Inputs.py · M02_Labor_Values(_Model).ts 로 · 옛 자리는 다시 내보냄 · 입력 목록 길 logic-lib/inputs
- 서버: inputs · combo_logics · _num 몸을 M02_Labor_Inputs.py 로(logic_inputs = 로직 키들 입력 + 자동값) · M02_MasterTemplete_Inputs.py 는 그것을 부름
- 길: GET /api/m02/logic-lib/inputs?keys= (로그인 · 일위대가 입력 모달용)
- 화면: 값 카드(createInputsValueCard) · 값 함수 → M02_Labor_Values.ts · M02_Labor_Values_Model.ts · 값 칸 하나(createValueControl)를 떼어 값 카드와 입력 모달이 같이 씀 · CSS 도 옮김
- 옛 자리(M02_MasterTemplete_Inputs.ts · _Inputs_Model.ts)는 새 파일을 다시 내보냄 — B05 고르기 모달 그대로
- 시험: 새 test_m02_labor_values.py · 옮긴 자리 따라 test_item_picker_detail · test_m02_card_shell · helper_m02_inputs 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-04 17:27:30 +09:00
eomsangdon 6cb877925f Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_1 2026-10-04 17:27:19 +09:00
eomsangdonandClaude Opus 5.5 134a727c9c feat(M02): 상세 제목 M01 머리 꼴 — 제목 18px 왼쪽 · 「돌아가기」 는 제목 앞 · 단추 오른쪽
39-5 — 마스터 템플릿 상세 머리 전부(구조물 조합 · 도면 · 면적산출 · 일위대가 · 양식 밖 화면).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:27:02 +09:00
eomsangdonandClaude Opus 5.5 18a0639253 feat(M02): 구조물 항목 목록 부품 — M01 찾기 줄 · 쪽 · 표 꼴 · 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 서버 목록 칸 수정일 · labor(번호 · 담은 로직 수 · 합계)
39-5 H1 · H6 — 전체 항목 · 복사 생성 · 항목 하나 복사가 같이 쓸 목록 하나.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-04 17:24:00 +09:00
eomsangdonandClaude Opus 5.5 0b158636cc feat(M02): 면적산출 칸 지정 모달 AreaLink — 지금 고른 칸으로 · 주소로 잇기 · 풀기 · 정본은 면적 문서 연결(setLink) (PLAN 39-4 D5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 17:23:56 +09:00
eomsangdonandClaude Opus 5.5 8739148bba feat(spreadsheet): 손잡이 setLink — 알약 키를 칸에 잇기 · 풀기 · 알약 누름과 같은 규칙 · 되돌리기 됨 (PLAN 39-4 D4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 17:23:01 +09:00
eomsangdonandClaude Opus 5.5 ab84c763b7 refactor(M02): 좌측 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움(39-2)
- Labor.ts · Labor_Tree.ts 지움 · 페이지 · 좌측 이음 · 해시 표시(mark) 지움
- 나무 부품 일위대가 전용 칸(clipKind · maxDepth · item) 지움
- 옛 나무 시험을 새 짜임 검사로 갈음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-04 17:22:24 +09:00
eomsangdonandClaude Opus 5.5 eee8f9a459 feat(M02): 가져올 곳 탭 readableSources · 회사 맡음 manages — 복사 생성 · 복사해 가져오기 탭 목록(39-6 E5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-04 17:21:17 +09:00
eomsangdonandClaude Opus 5.5 8fde124df5 refactor(M02): 자재 고르기 창 따로 — M02_Material_Pick.ts(pickMaterial · MaterialItem) · 일위대가 자재목록이 다시 씀
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 17:21:12 +09:00
eomsangdonandClaude Opus 5.5 1871b6ffc2 feat(M02): 면적산출 카드 [알약] 켬끔 — 아래 알약 줄 기본 숨김 · 계정 취향 기억 · 상세는 늘 보임 (PLAN 39-4 D1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-04 17:20:47 +09:00
eomsangdon 3d79d2dab8 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2 2026-10-04 17:19:32 +09:00
eomsangdon c67171551f Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-04 17:19:29 +09:00
eomsangdon fe07dd8a45 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-04 17:19:28 +09:00
eomsangdon 628349e15b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-04 17:19:26 +09:00
eomsangdon 5cf7c1c5b1 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-10-04 17:19:22 +09:00
eomsangdon ef0a407fda Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_6 2026-10-04 17:19:21 +09:00
eomsangdon 18b1a085d8 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-04 17:19:21 +09:00
eomsangdon 6c28357fb7 auto: 2026-10-04 17:19 (ESD_LAPTOP) 2026-10-04 17:19:20 +09:00
eomsangdon 056ddd8822 auto: 2026-10-04 17:19 (ESD_LAPTOP) 2026-10-04 17:19:18 +09:00
eomsangdon 9e8a1c1f63 auto: 2026-10-04 17:19 (ESD_LAPTOP) 2026-10-04 17:19:16 +09:00
eomsangdon ddd73349f2 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_5 2026-10-04 17:19:13 +09:00
eomsangdon 22e53ec4a7 auto: 2026-10-04 17:19 (ESD_LAPTOP) 2026-10-04 17:19:08 +09:00
eomsangdon 74708c77e5 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_7 2026-10-04 17:19:01 +09:00
eomsangdonandClaude Opus 5.5 277311955b feat(common): 오른쪽 클릭 메뉴 공통 양식 — 공용 CSS 한 벌 · 「한 번에 하나」 장부 합침 · 조건 만들기 공용 메뉴로
- 새 ui_template_context_menu.css(.ui-ctx-menu) — 지도 · 표 메뉴와 나무 메뉴가 같은 클래스 · 자리 · z 는 :where 로 페이지가 덮어씀
- 페이지 CSS 4 벌(스프레드시트 · B04 지도 · B05 배수 · 종단)과 나무 메뉴 CSS 의 모양 줄 지움 — 자리 · z 만 남김
- 상태줄 통계 메뉴 흠(CSS 없음) — 공용 클래스 + .ss-statusbar 기준 자리
- 나무 메뉴가 지도 · 표 메뉴 장부에 등록(enterMenu · leaveMenu)
- 조건 만들기 — 가로채기 지움 · 칸 메뉴가 있으면 그 끝 한 줄(ctx.cellMenuExtra) · 없으면(미리보기 편집기) 같은 공용 메뉴에 이 줄만 (PLAN 39-9b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 17:17:01 +09:00
eomsangdonandClaude Opus 5.5 0c141396ec chore(plan): 39장 M02 구조물 조합 개편 설계 정함 — 일위대가 열마다 labor 문서 · 실무문서 · 권한 · 파일 주인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-04 17:16:59 +09:00
eomsangdonandClaude Opus 5.5 0f8efaa424 feat(common): B · M 페이지 브라우저 기본 오른쪽 클릭 메뉴 막기 — 공용 진입 창 캡처 한 곳 · CAD iframe 도 · 글 입력 칸은 살림
- 새 ui_template_native_menu.ts — 주소 /^[bm]\d/ · 글 입력 칸(input · textarea · contenteditable · 읽기 전용 포함) 제외 · preventDefault 만(우리 메뉴 그대로)
- main.ts 공용 진입 한 줄 · cad_host.ts iframe load 때 같은 함수
- 「브라우저 메뉴 그대로 둔다」 주석 셋 바로잡음(B04 지도 · B05 배수 · 3D)
- 시험 test_common_native_menu.py (PLAN 39-9a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-04 17:12:28 +09:00
eomsangdonandClaude Opus 5.5 2a18f5e9f5 chore(plan): 39장 M02 구조물 조합 개편 — 사용자 요구 원문 · 일위대가 답 1~4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-04 17:06:42 +09:00
eomsangdonandClaude Opus 5.5 c8269a969f chore(plan): 끝난 33~38장을 docs/raw/plans 로 이관 — 미체크 줄은 갈음 · 접음 표시 뒤 완료 처리 · PLAN.md 는 1장 · 32장만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
2026-10-04 16:00:53 +09:00
eomsangdon 19e6b807da auto: 2026-10-04 15:45 (ESD_LAPTOP) 2026-10-04 15:45:56 +09:00
eomsangdon 88d370b7ff auto: 2026-10-03 08:19 (ESD_LAPTOP) 2026-10-03 08:19:10 +09:00
eomsangdonandClaude Opus 5.5 29d5792d94 fix(M01): 저장 · 캐시 삭제 단추 — 저장 안 한 고친 것이 없으면 숨김(단가산출 로직 · 마스터 요소)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-02 10:34:54 +09:00
eomsangdonandClaude Opus 5.5 2f25468019 fix(M01): 「고친 것 버리기」 → 「캐시 삭제」 — 단가산출 로직 · 마스터 요소 화면 단추 · 묻는 글
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-02 10:14:25 +09:00
eomsangdon e0fc236608 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-02 09:57:39 +09:00
eomsangdonandClaude Opus 5.5 3336902c17 feat(M01): 흐름 로직 입력 약속 하나 — 저장 때 「입력」 은 흐름의 설계 때 정할 것으로 다시 씀 · 지우는 것은 계산 칸만 · 시험판 길 지움
- 흐름으로 저장 = 계산 칸(중간 · 호표 · 결과 · 덧줄)만 지우고 「입력」(이름 · 설명 · 단위 · 고르기 · 범위)은 흐름에서 같은 꼴로 · 조합 · 구조물 · 미리 보기 · 다시 적용은 「입력」 을 그대로 읽음(따로 갈래 없음)
- 엔진 갈래: 안 준 입력 = 멈춤 · 빈 값으로 준 입력 · 단가 없는 품목 · 표에 없는 조건 = 그 값만 비움(알림) · 결과 꼴 = 돈 출력 줄 · 값들 · 알림
- 프로젝트에서 고른 자재 = 흐름 재료 자리 이름으로(고름 · 다시 적용 때 살림)
- 시험판 길 지움: /list · /get · /save · /delete · /user-elements(서버) · cfList · cfGet · cfSave · cfDelete · cfUserElement*(Api) · 빈 _data 폴더
- 시험: 조합에 담은 로직을 흐름으로 바꿔도 입력 목록 · 로직별 결과 · 비목 합 · 단가 묶음 셈이 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-02 09:57:30 +09:00
eomsangdon cb48dc214a Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-02 09:45:23 +09:00
eomsangdon 5946ded7d0 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
# Conflicts:
#	M01_MasterData/M01_MasterData_UI_Logic_List.ts
2026-10-02 09:44:33 +09:00
eomsangdonandClaude Opus 5.5 7e2338a807 feat(M01): 왼쪽 패널 「유가 · 전력」 컨테이너 — 재료 다음 따로 · 재료 컨테이너에서는 뺌 · 서버 재료 그룹 그대로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-02 09:42:45 +09:00
eomsangdonandClaude Sonnet 5.5 65c2de2e9c feat(m01): 마스터 요소 상세 일곱 화면 머리를 단가산출 로직과 같은 공용 조각으로(제목 18px · 단추 줄 · 찾기 줄)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-02 09:42:34 +09:00
eomsangdonandClaude Opus 5.5 c6776d5488 fix(M01): 로직 목록 「막힘만」 지움 · 왼쪽 로직 「전체」 가 가운데를 로직 목록으로
- 「막힘만」 체크 지움(줄의 ⛔ 막힘 표시 · 까닭 말풍선은 그대로)
- 로직 컨테이너 「전체」 · 구분 거름이 가운데를 로직 화면으로 바꿈(다른 컨테이너를 보던 중에도) — 원인: 거름이 로직 목록만 거르고 화면 전환(onLogic)을 안 부름
- 「전체」 는 찾는 글까지 비움 · 로직 상세가 열린 채면 목록으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-02 09:41:51 +09:00
eomsangdonandClaude Sonnet 5.5 af201fbbd3 feat(M02-일위대가조합): 조합 다이어그램 - 로직별 넣을 값 → 로직 카드 → 비용 구분 칸 · 미리 보기와 값 한 벌 · 카드 누르면 단가산출 로직 화면
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-02 09:34:08 +09:00
eomsangdonandClaude Sonnet 5.5 7418571b63 fix(m02-df): 입력 판 글에서 「옛」 말 뺌 — 계산 방법을 흐름도로 읽음
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-02 09:18:00 +09:00
eomsangdon 78dd044998 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_4 2026-10-02 08:55:46 +09:00
eomsangdon fd2a777c91 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-02 08:55:45 +09:00
eomsangdonandClaude Opus 5.5 28da700d47 feat(M01): 흐름 로직 줄 = 옛 식 칸(입력 · 중간 · 호표 · 결과 · 덧줄) 지움 · 읽는 곳은 흐름의 설계 때 정할 것 · 출력에서(사용자 결정)
- master_formula.inputs_of · FLOW_INPUTS · OLD_FIELDS · mf.run 이 흐름 줄이면 스스로 서버 갈래를 깖(스크립트 · 시험도)
- check_master 틀 검사 · 시험입력 · 로직(…) 입력 검사 · master_auto 자동값 · 프로젝트 빈 입력 · 연결 · 다시 적용 값이 흐름 입력으로
- 프로젝트 단가 묶음(collect)이 흐름의 요소 · 표 · 다른 로직 · 기계 시간당 경비 로직을 모음
- 옛 엔진 갈래 결과 = 옛 꼴(돈 출력 줄 · 값들 · 알림) · 조합 · 프로젝트에서 안 준 입력은 빈 입력 알림(예시값 아님)
- /text 흐름 로직 = 출력 상자 줄 · GET /logic 은 보기용으로 설계 때 정할 것을 입력 꼴로 얹음(파일은 그대로)
- 찾을 값이 빈 조건 찾기는 그 값만 비움(옛 셈처럼)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-02 08:55:36 +09:00
eomsangdonandClaude Opus 5.5 869bfe7c5d chore(m02): CostFlow 시험판 폴더 지움(사용자 지시 · 본 프로그램 영향 없음)
- M02_MasterTemplete/CostFlow 21 파일 · main.py 등록 줄 · test_m02_cf_* 넷 · fixtures_df/costflow 견본 셋 지움
- 추적 안 된 찌꺼기(__pycache__)는 tmp/trash_1002 로 · DataFlow 는 그대로
- 서버 다시 읽기 뒤 /api/m02/cf 404 · test_m02_df_* 124 통과 · typecheck · DataFlow 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-02 08:12:19 +09:00
eomsangdon eda867a1dc Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-02 08:02:53 +09:00
eomsangdonandClaude Opus 5.5 d822a08a5e fix(m02-df): 흐름 셈 = 옛 셈 — 빈 단가 줄을 부르는 덧줄은 빈 값 · 전수 1,472 모두 맞음
- 빈 단가(blank) 요소 값 = 「#N/A」(서버 Compute · 화면 값 받기 Entry_Detail · Api CfAnswer.blank) → 그 값을 쓰는 식은 빈 값 · 결과가 오류일 때만 끊긴 곳 「단가 없는 품목을 씀」(Eval NoPrice · Compute 같음)
- 변환: 비목 합 · 덧줄의 비목 이름 = 빈 줄 0(SUM(IFERROR…)) · 호표 줄 = IF(수량=0,0,수량×단가) · 줄.이름은 빈 줄이면 빈 값(옛 env 줄)
- 오류 값 칸은 NA() 로(Compute _is_err · Eval isErrorValue)
- Compute 700줄 넘어 표 찾기 · 값 같음을 M02_DF_Lookup.py 로 나눔(Compute 가 다시 내보냄)
- 전수 대조 시험입력 · 기본 입력 1,472/1,472 · 시험 test_m02_df_model 37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 08:02:10 +09:00
eomsangdonandClaude Opus 5.5 54fc4f1b1f fix(M01): 흐름 로직의 시험 계산 입력 칸 = 흐름의 설계 때 정할 것(변수 · 고르기 · 범위 · 단위)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-02 08:01:58 +09:00
eomsangdonandClaude Opus 5.5 8fed757fc1 fix(M02): 표준 표 고친 값 = _standard/ 파일에 바로 · 로직 목록 시험이 자체 파일 차례에 안 기댐(사용자 결정)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-02 07:57:58 +09:00
eomsangdon 5273f61b87 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-02 07:57:38 +09:00
eomsangdonandClaude Sonnet 5.5 33700dc940 refactor(m02-df): 입력 판 사용자 요소 만들기 지움(시험판 내용은 임시) — 요소는 마스터 요소 화면에서
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-02 07:56:57 +09:00
eomsangdonandClaude Opus 5.5 8e35375c54 feat(M02): 흐름도 패널 머리에 「표준 표」 단추 — 단위 · 금액 표준 모달(renderStandards title false) · 보기만이면 칸 · 단추 막음
- 시험판 페이지를 지워 사라진 표준 표 여는 길을 패널로 옮김 · 고치기는 시스템 관리자만(readOnly 면 fieldset 으로 막음)
- typecheck · DataFlow 전부 검사 초록 · 시험 2 통과 · ORCA: 모달 「표준 표」 줄 80 · 고치기 열림 / 보기만 막힘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-02 07:56:33 +09:00
eomsangdon cc4b6a6674 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-02 07:53:32 +09:00
eomsangdon fd2a6851c7 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_3 2026-10-02 07:53:23 +09:00
eomsangdon 937661c573 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-02 07:53:21 +09:00
eomsangdon e5a1cdf841 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-02 07:53:21 +09:00
eomsangdon cb613507ae Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-02 07:53:21 +09:00
eomsangdonandClaude Opus 5.5 66d0fd58b9 chore(M01): 사용자 지시 — 자체 로직 GX000001~3 지움(단가산출에 사본 없음)
키 대장은 그대로(같은 키 다시 안 씀) · 옛 줄 사본은 tmp/backup_1002/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-02 07:52:31 +09:00
eomsangdonandClaude Opus 5.5 3dddb53997 refactor(M02): 흐름도 — 시험판 Entry 지움 · 패널 「흐름도」 단추 하나(접기 · 펴기) · 끌기 손잡이 늘 보임(사용자 지시)
- M02_DF_Entry.ts · .css 지움(아무도 안 부름) · Paper · 상세 내용 틀이 쓰던 꾸밈 18 규칙은 M02_DF_Paper.css 로 옮김
- Paper 의 시험 로직(DF-) 받기 지움 — 다른 로직은 부르는 쪽 logics 로만
- 패널: 머리 「접기」 · 띠 「흐름도 ▸」 → 「흐름도」 단추 하나(펼치면 머리 · 접으면 띠 · aria-expanded) · 접기 전 너비 기억(펴면 그 너비)
- 끌기 손잡이: 세로 막대 + 가운데 잡이 늘 보임 · 올리면 강조
- typecheck · DataFlow 전부 검사 초록 · 시험 10 통과 · ORCA: 단추 머리 ↔ 띠 · onResize 560 → 40 → 560

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-02 07:46:00 +09:00
eomsangdonandClaude Opus 5.5 45b614ca55 fix(M01): 흐름도 패널 글 · 왼쪽 고침 → 오른쪽 따라감 · 시험판 길 지움
- 패널 위 글 「이 로직의 계산 방법을 흐름도로 보여 줌」(「옛 식」 · 「정본」 말 지움)
- 왼쪽 편집기로 고친 뒤 잠시 쉬면 고친 줄을 서버 변환(POST /api/m01/logic/flow {file, key, row})으로 다시 받아 오른쪽에 넣음 · 마지막 물음만 받음 · 길이 아직 없으면 아무 일 없음
- 다른 로직 받기에서 흐름도 시험판(DF-) cfGet 길 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-02 07:43:09 +09:00
eomsangdonandClaude Sonnet 5.5 eb30b3f16a refactor(m02-df): 다른 로직 입력에서 내 시험 흐름도(DF-) 무리 · cfList · cfGet 지움 — 고를 대상은 마스터 로직만
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-02 07:42:59 +09:00
eomsangdonandClaude Opus 5.5 c7cd7d4ea0 feat(M01): POST /api/m01/logic/flow — 저장 안 한 고친 줄(옛 편집기)을 흐름으로 변환해 돌려줌(쓰기 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-02 07:42:47 +09:00
eomsangdonandClaude Opus 5.5 d202a3a6d8 fix(M01): 기본 로직 안내 글 — 관리자는 흐름도에서 고침 · 아니면 보기만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-02 01:48:15 +09:00
eomsangdonandClaude Sonnet 5.5 9269e9da97 fix(M02-흐름도): 함수 판 시트 · 식 보기가 선 규칙을 지나 들어온 값(arrived)을 받은 칸에 넣어 보임 - Paper · Entry 가 값 · 다른 로직을 넘김 · 오류 글은 값 없음
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-02 01:44:34 +09:00
eomsangdon 453ac49dd8 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-02 01:38:55 +09:00
eomsangdonandClaude Opus 5.5 f30bc9e82f fix(M01): 시험 입력 한 벌 — 왼쪽 시험 계산 입력 ↔ 흐름 변수 예시값
- 로직을 열 때 왼쪽 시험 계산 입력(자동값 포함)을 이름이 같은 흐름 변수 예시값으로
- 왼쪽 입력을 바꾸면 흐름 예시값도 · 흐름 검증 칸 예시값을 바꾸면 왼쪽 입력도 바꾸고 다시 셈
- 예시값만 바뀐 것은 고친 것(drafts)으로 안 셈(sameShape)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-02 01:38:24 +09:00
eomsangdonandClaude Opus 5.5 d1df48c45f fix(m02-df): 변환 변수 예시값 = 시험입력 · 없으면 왼쪽 시험 계산 기본값(master_auto)
- 고르기 첫 값 · 범위 아래 끝 · 표에서 가장 낮은 값 — /logic/auto 와 같은 규칙(GF000004 횡단경사 55) · 글 수 → 수

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 01:36:27 +09:00
eomsangdonandClaude Opus 5.5 1ee1668368 fix(m02-df): 36-2 마지막 대조 99.8% — 표 찾기 글은 대소문자까지 같아야(옛 find)
- Rule lookup 같음 = 글끼리 LEN + FIND(대소문자 가림) · Compute _lookup = _exact(글은 그대로 비교) — 「t_2」 「T_2」 · 「e」 「E」 줄이 둘 다 맞던 것
- 시험 test_m02_df_model 36
- 전수(sub4 범위규칙 칸 · 검색어 찾기 · 재료 입력 받은 뒤): 시험입력 1,469/1,472(99.8%) · 기본 입력 1,466(99.6%) · 남은 = 자료 빈 단가 · 없는 품목에서 옛 셈은 식째 비움 · 흐름은 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 01:34:53 +09:00
eomsangdon 2b3ba84fe5 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-02 01:04:13 +09:00
eomsangdon 5969278df3 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-02 01:04:11 +09:00
eomsangdonandClaude Opus 5.5 3f71305ddb fix(M02): 흐름 셈 = 옛 셈 — 표 「·범위규칙」 줄 경계 말 · 검색어 조건 찾기 · 재료가 로직 입력 읽음 · 빈 단가 알림 · 고르기 글 값 그대로(PLAN 36-2 대조 49 → 8)
- main(sub7 a774b4e97 Model 덧) 받아 합침 · Compute 재료 조건 찾기는 sub7 의 틀 채우기 + 빈 단가 알림
- 남은 8: 표에 여럿 맞음 5(옛 find 의 값 칸 있는 줄만 거름 — lookup) · 빈 값이 든 식 3(옛 = 그 줄 비움 · 흐름 = 빈 칸 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-02 01:04:03 +09:00
eomsangdonandClaude Opus 5.5 85e11265b5 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7
충돌 넷 풂 — 합계 끝수 정본 = CfLogic.totalRound(totalRoundOf(lg, fallback) 하나 · 옮긴 로직 = 자신 끝수 · null = 없음 · 안 정함 = 기본 1원 버림) · sub5 줄 · 소계 꼴 살림 · sub1 fillTemplate(InputPanel_Build) 살리고 Entry_Detail 의 같은 함수 뺌 · flowview 시험을 정본 뜻으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 01:02:21 +09:00
eomsangdonandClaude Opus 5.5 d2ab8ee8cc fix(m02-df): 변환 흐름 화면 흠 넷 — 변수 예시값 · 표 찾기 실패 같은 뜻 · 다른 로직 서버 값 · 합계 끝수
- 변환: 설계 값 변수 예시값 = 줄 시험입력 · 없으면 check_master.시험입력 기본(글 수 → 수) · totalRound = null(옛 계는 끝수 없음)
- Model CfLogic.totalRound(없음 = 기본 계금 1원 미만 버림 · null = 끝수 없음) · Round totalRoundFor · roundBy/roundText 가 null 받음 · Summary · M01 흐름 보기 합계가 그것을 씀
- Compute: 함수 결과 오류는 오류 값(「#N/A」 등)으로 넘김(화면 Eval 과 같음 · 받는 칸으로 번짐 · 옛 엔진은 그 줄을 비움) — 서버가 빈 칸 0 으로 계속 세던 차이 없앰
- 화면 값 물음(Entry_Detail queriesOf): 인력 · 기계 · 유가 조건 찾기도 자리 물음 · 재료 검색어 틀은 채운 글 · 다른 로직 입력은 서버 /logic-value 로 받은 설계 값째 셈(그 안 조건 찾기 · 단가까지) · Eval 은 서버 출력이 있으면 그것
- Values.one_value: 인력 · 기계 · 유가 자리 물음 = group_find
- 전수 대조 시험입력 1,414/1,472(96.1%) · 기본 입력 1,429(97.1%) — 남은 49 는 sub4 범위규칙 칸 · 검색어 찾기 · 재료 값 차이

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 00:56:28 +09:00
eomsangdonandClaude Opus 5.5 fcd45afe9f fix(m02-df): 재료 조건 찾기 「검색어」 면 틀을 채운 글로 값 물음(fillTemplate)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-02 00:40:53 +09:00
eomsangdon 85432ae68c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-02 00:39:53 +09:00
eomsangdonandClaude Sonnet 5.5 df2368a48f feat(m02-df): 재료 조건 찾기 검색어 틀 — 틀 칸 · 자리별 알약 · 지금 잡히는 재료 미리 보기
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-02 00:38:35 +09:00
eomsangdonandClaude Opus 5.5 9872ad66f5 fix(M02): 흐름 셈 옛 셈과 같은 뜻 — 재료 값이 로직 입력(자재지역 · 계약종별) 읽음 · 빈 단가는 알림 · 표 범위 경계 칸 · 검색어 조건 찾기(PLAN 36-2 대조)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-02 00:35:21 +09:00
eomsangdon d6c84a78c5 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-02 00:35:01 +09:00
eomsangdonandClaude Sonnet 5.5 a5edf43d6e fix(m01): 흐름 합계 끝수 = 비용 출력 상자에 실린 로직 자신의 끝수 · 새 로직만 기본 1원 미만 버림
왼쪽 흐름 보기 소계 · 계와 오른쪽 원가 합계가 같은 끝수를 씀(Round.totalRoundOf)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-02 00:34:17 +09:00
eomsangdon 84dd9ce663 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-10-02 00:33:59 +09:00
eomsangdonandClaude Sonnet 5.5 cca0e09cd2 test(M02-흐름도): 식 보기 값 줄은 선 규칙을 지나 들어온 값으로 그림
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-02 00:33:38 +09:00
eomsangdonandClaude Opus 5.5 a774b4e97b feat(m02-df): 36-2 덧 — 재료 조건 찾기 「검색어」 틀 · 표 범위 경계 규칙 칸
- Model CfMaterialRule field 「검색어」 + template(「레미콘 {a}-{b}-{c}」) + slots(자리마다 알약) · materialSources · depends · 흐름 따라가기에 자리 알약도
- Compute 재료 조건 찾기 검색어 = 틀에 자리 알약 값을 끼운 글(옛 fill)을 dv.slot_price 값으로 넘김(서버 찾기는 sub4)
- 표 찾기: 표 블록 「·범위규칙」 칸(줄마다 초과·이하 · 이상·이하 · 이상·미만 · 이하)을 Rule lookup · Compute _lookup 이 따름 · 없으면 초과·이하
- 변환: 재료 검색어 자리 = 검색어 틀 · scope 구분 · 상세구분 · 조건 찾기 요소로 들어가는 선은 안 만듦(depends 로 이음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 00:32:15 +09:00
eomsangdon fa33abb5f0 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-02 00:25:50 +09:00
eomsangdonandClaude Opus 5.5 8193f29ae9 fix(m02-df): 36-2 변환 대조 96.7% — 표 찾기 위 끝 최소 · 값 칸 범위 갈림 · 로직.계 · 조건 찾기 원문번호
- convert(row, ctx={key, file, master}) — sub4 /api/m01/logic/flow 부르는 꼴 · master 는 ctx 것
- {입력} 요소 찾는 값 = 원문번호(「MO:경유:{지역}」 → "경유:"&지역 글 잇는 함수) · 호표 줄 그룹 = 키 앞 두 글자
- 로직(…).계 = 다른 로직 비목 셋 합
- 표 찾기: 여럿이면 범위 위 끝이 가장 작은 줄(옛 find) · 값 칸이 「·아래」 「·위」 로 갈리면 아래 = 위일 때 그 값(Rule · Compute 같음)
- 표 찾기 실패로 넣은 NA() 칸은 칸 계산 오류로 알리지 않음(Eval · Compute)
- 전수 대조는 서버 길 그대로(sub4 조건 찾기 · 표 식 글) · 입력은 옛 엔진처럼 수로 · 까닭 분류
- 시험입력 1,423/1,472(96.7%) · 기본 입력 1,434/1,472(97.4%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-02 00:24:22 +09:00
eomsangdon 218d981f8e Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-02 00:19:34 +09:00
eomsangdonandClaude Sonnet 5.5 9ec240cb46 fix(M02-흐름도): 조건 줄 새 줄 기본 값은 목록 첫 값(속 글 안 보임) · 조건 만들기 목록에 나중에 놓은 알약 · 변수도 뜸
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-02 00:17:57 +09:00
eomsangdon 509e469d56 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-01 23:58:11 +09:00
eomsangdonandClaude Opus 5.5 52a085eb0c feat(m02-df): 36-2 옛 로직 → 흐름도 변환 · 표 찾기 옛 find 와 같은 뜻
- M02_DF_Convert.convert(row, ctx={key, file, master}) → {flow, notes} — 옛 파서 나무 → 함수 상자 시트(칸 주소 · 결과 하나) · 표 찾기 lookup · 로직 줄 비목 셋 · 덧줄 비목 합 · 줄.이름 · 결과 = 값 출력 · 자동 배치
- {입력} 낀 요소(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 재료 검색어) = 조건 찾기 field 「키」(Model CfMaterialRule · Api slot 타입) · 자리 여럿은 글 잇는 함수
- 표 찾기: 빈 조건 칸 = 무엇이든 · 꺼낼 칸 빈 줄 건너뜀(Rule · Compute) · 함수 칸으로 가는 실패는 #N/A 를 넣어 넘김 · 결과가 오류일 때만 끊긴 곳(Eval · Compute 같음)
- 전수 대조 test_m02_df_convert(사본 폴더 1,472 로직 · 시험입력 92.9% · 기본 입력 94.3%) → tmp/convert_report_36.json

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 23:57:58 +09:00
eomsangdonandClaude Opus 5.5 f0ecaa122d feat(M01): 흐름 셈 단가 = mf.run 을 부른 쪽 자리(프로젝트 사본이면 그 단가 · PLAN 36-4 브레인 답)
- M02_DF_Values.SOURCE — 단가 · 값 · 표를 넘겨진 Master 에서(없으면 마스터) · 견본 로직은 그 Master 에 잠깐 얹어 셈
- Store_Flow 옛 엔진 갈래가 master 를 넘김 · 다른 로직(흐름 · 옛)도 같은 자리
- 찾기 목록(재료 조건 찾기 · 그룹 찾기 · 기계 짝)은 마스터 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 23:57:25 +09:00
eomsangdon 6c4f852ecc Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-01 23:49:27 +09:00
eomsangdonandClaude Opus 5.5 466fa56540 feat(M01): 흐름 로직 옛 엔진 한 곳 갈래 · 인력/기계/유가 조건 찾기 · 표 수 식 글 · 재료 키 찾기(PLAN 36-4 덧)
- master_formula.run 에 FLOW_RUN 자리 — Store_Flow 가 꽂음 · 조합 · 프로젝트 단가 · 읽는 식 · 옛 식의 로직(…) 도 흐름 로직이면 흐름 셈(단가는 마스터 · 프로젝트 사본 단가는 아직)
- 흐름 → 옛 → 흐름 돌고 도는 부름 막음 · /logic/auto 흐름 로직 = 설계 예시값
- 조건 찾기 요소 인력 · 기계 · 유가전력(group_find · 이름 · 키 · 원문번호 같음/포함 · 기본 키 대체)
- 표 값 칸 수 식 글(「(0.05)」 「1/1.30」) = 옛 엔진 파서로 수 · 재료 조건 찾기 field 키(자재 키 · 품번)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 23:49:16 +09:00
eomsangdon f4fc6f9e5f Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1 2026-10-01 23:47:27 +09:00
eomsangdonandClaude Opus 5.5 0d45014e38 feat(M01): 저장 뒤 새 줄은 서버가 준 키(added)로 엶
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 23:46:37 +09:00
eomsangdonandClaude Sonnet 5.5 27c5eba521 feat(m02-df): 다른 로직 입력 — 변환 흐름이 비면 옛 줄 입력 · 자동값으로 받는 알약 · 진짜 서버 길로 값 확인
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 23:46:31 +09:00
eomsangdon 8a42a7fe75 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 23:43:13 +09:00
eomsangdonandClaude Opus 5.5 abbf61971a feat(M01): 로직 상세 오른쪽 흐름도 패널 잇기 · 신규 로직
- 로직을 열면 오른쪽에 흐름도 패널(mountFlowPanel) — 줄의 흐름 · 없으면 흐름 받기(/api/m01/logic/flow) · 없으면 빈 흐름 · 변환 알림 한 줄
- 패널 고침 → 줄의 흐름으로 drafts(캐시) · [저장] 한 번 · [고친 것 버리기] 함께 · 흐름 로직이면 왼쪽 보기(buildFlowView) 다시
- 패널 너비 = 패널이 알린 px(두 칸 끌기 막대는 숨김)
- 「신규 로직」 — 기본정보 칸(이름 · 원문번호 · 결과 단위 · 출처) + 빈 흐름 → 로직_자체 새 줄 · 저장 뒤 새 키 열기
- 보기만 = 시스템 관리자가 아니면

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 23:43:11 +09:00
eomsangdon e8662131e0 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 23:42:51 +09:00
eomsangdonandClaude Sonnet 5.5 bad349783b feat(m02-df): 입력 판 인력 · 기계 · 유가전력 조건 찾기(이름 또는 키 같음) — 방식 고르기 · 미리 보기
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 23:42:50 +09:00
eomsangdon 2abd3d46ae Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_4 2026-10-01 23:41:45 +09:00
eomsangdon 3b1dd532aa Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-01 23:41:42 +09:00
eomsangdonandClaude Opus 5.5 7b90d28e22 feat(M01): 로직 「흐름」 칸 — 저장 검사 · 서버 다시 셈 · 흐름 받기 · 계산 갈래 · 신규 GX · 다른 로직 마스터 키(PLAN 36-4)
- /save 줄의 흐름 = 꼴 검사(없는 알약 · 겹친 선 · 돌고 도는 선 422 · 빈 받는 자리 경고) → M02_DF_Compute 로 다시 셈 → 받는 칸 값
- 키 없는 새 흐름 줄 = 로직_자체.json 에 GX 다음 키 · 구분 자체 · 옛 식 칸은 빈 것 · 응답 {files, added, warnings}
- GET /api/m01/logic/flow = 흐름 그대로 · 없으면 M02_DF_Convert.convert · 아직 없으면 빈 흐름 + 변환 준비 중
- /calc = 흐름 있으면 흐름 셈(입력 = 설계 때 정할 것 · sums 노무비 · 재료비 · 경비 · 계 · outputs) · 없으면 옛 셈
- DataFlow 다른 로직 입력 · /logic-value · /design-inputs 가 마스터 키도(흐름 = 흐름 셈 · 옛 로직 = 옛 셈) · 서로 부르면 막음
- Compute 의 Router _num 은 모듈로 부름(서로 부름 풀기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 23:41:32 +09:00
eomsangdonandClaude Sonnet 5.5 3ea0c0fdf2 refactor(m01): 흐름 왼쪽 보기 — 식 보기를 sub2 formulaLines 직접 import 로
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 23:40:53 +09:00
eomsangdonandClaude Opus 5.5 deabd978c0 fix(M02): 흐름도 패널 처음 배율 = 읽히는 최소 0.6(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체(0.25) · 크게 보기는 그대로 0.9(PLAN 36-3 덧)
- 다이어그램 opts.fitAllMin(단추) · fitMin(저절로) 나눔 · Paper · Panel 이 넘김
- 시험 diagram_parts 8 + panel 2 통과 · DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 23:39:21 +09:00
eomsangdon d584cdeb9e Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-01 23:38:56 +09:00
eomsangdon e14c36204f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-01 23:38:54 +09:00
eomsangdon 31cdd84737 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 23:37:55 +09:00
eomsangdon 2a93b92050 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_3 2026-10-01 23:37:30 +09:00
eomsangdon b34a028794 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 23:37:14 +09:00
eomsangdonandClaude Sonnet 5.5 dcb023f4e6 feat(M02-흐름도): 함수 판 식 보기 · 칸 연결 표 · 칸 색 · 조건 만들기 연결 + 선 조건 여러 줄(그리고/또는 · 값 목록 고르기 · 범위 검사)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 23:37:13 +09:00
eomsangdon 981e0f26f1 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 23:35:48 +09:00
eomsangdonandClaude Opus 5.5 14547d4f20 fix(M02): 흐름도 패널 — 크게 보기를 닫으면 판을 처음 차례로 되돌려 끼움 · 만들 때 변수 차례 · 좁은 패널 맞춤 바닥 0.25(PLAN 36-3)
- ORCA(5183): 540px 칸 상자 11 모두 판 안(0.288) · 머리 끌기 → onChange 새 객체 1 번 · 크게 보기 0.9 · 닫으면 패널 0.288 · 고친 자리 그대로
  · 실행 취소 → onChange 되돌린 객체 · 접기 띠 「흐름도 ▸」 onResize 40 · 펴기 · 가장자리 끌기 +120 → onResize 764 · localStorage
  · 보기만: 더하기 0 · 끌기 onChange 0 · 고르기 됨 · 판 칸 막힘 · 여러 조건 이름표 · 밝음 · 어두움
- resources/master_data 안 바뀜(git status)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 23:35:09 +09:00
eomsangdonandClaude Opus 5.5 96326eaa35 chore(m02-df): 36-7 정리 — 흐름도 시작 화면 견본 목록 지움 · 시험 견본을 resources/tester/fixtures_df 로
- DataFlow/_sample 다섯 · CostFlow/_sample 셋 → fixtures_df(costflow) · 시험 길 고침(df_sample · df_model · cf_sample)
- M02_DF_Sample.ts 지움(시험 밖에서 시작 화면만 씀) · Entry 견본 칸 · 글 · 부름 지움 · 시작 화면 한 칸
- CostFlow 폴더 나머지 · 시험판 단추 · 메뉴는 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 23:35:01 +09:00
eomsangdon d787f3faa5 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-01 23:34:21 +09:00
eomsangdonandClaude Sonnet 5.5 69e68a761b fix(m02-df): 다른 로직 흐름 못 읽을 때 쉬운 말
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 23:33:56 +09:00
eomsangdon 72df53ad54 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-01 23:30:26 +09:00
eomsangdon ff127b22cc Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-01 23:30:25 +09:00
eomsangdon fd976dcde3 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 23:30:25 +09:00
eomsangdon 5006b3de21 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-01 23:30:25 +09:00
eomsangdonandClaude Sonnet 5.5 2911dccc55 chore(M01): 흐름도 적용 조사 문서 같은 내용 먼저 담음(합치기 충돌 방지)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 23:30:24 +09:00
eomsangdon 7fb9ef8c55 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-01 23:30:24 +09:00
eomsangdon 37e9924082 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-01 23:30:20 +09:00
eomsangdon cb7fc50c69 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-01 23:30:19 +09:00
eomsangdonandClaude Opus 5.5 3c78322350 feat(M02): 오른쪽 흐름도 패널 M02_DF_Panel.ts — mountFlowPanel(host, opts) → set · destroy · expand(PLAN 36-3)
- 세로: 머리(제목 · 크게 보기 · 접기) · 실행 취소 · 다이어그램 · 상세 내용 · 원가 · 검증(접기) · 저장 단추 없음
- 왼쪽 가장자리 끌기 · 화살표 키 → onResize(px) · 마지막 너비 localStorage · 접기 = 얇은 띠 「흐름도 ▸」
- 크게 보기 = 같은 판을 화면 전체로 옮겨 끼움 · 닫으면 패널로(고친 것 그대로) · Esc · Ctrl+Z
- 좁은 패널은 저절로 맞춤 바닥 0.5(한눈에) · 크게 보기는 0.9 — 다이어그램 opts.fitMin
- 시험 test_m02_df_panel.py 2 통과 · typecheck · DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 23:30:18 +09:00
eomsangdonandClaude Sonnet 5.5 e799fbf345 feat(M02-흐름도): 식 보기 FormulaView · 엑셀 조건 만들기 spreadsheet_condition(우클릭 · conditions 줄 때만) + 시험
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 23:30:11 +09:00
eomsangdon 336b9df326 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 23:30:03 +09:00
eomsangdon 1438216801 auto: 2026-10-01 23:29 (ESD_LAPTOP) 2026-10-01 23:29:59 +09:00
eomsangdonandClaude Sonnet 5.5 fbb6902576 feat(m02-df): 다른 로직 입력 = 마스터 로직 고르기 — 원문 · 장 · 찾기 · 쪽 · 순환 막음 · 값은 서버 길
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 23:29:48 +09:00
eomsangdonandClaude Opus 5.5 672dbbd1b7 chore(M01): 흐름도 적용 조사 — 공유 docs 사본 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-01 23:29:20 +09:00
eomsangdon f5d3e89890 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 23:28:49 +09:00
eomsangdon 2d2c62defa auto: 2026-10-01 23:28 (ESD_LAPTOP) 2026-10-01 23:28:48 +09:00
eomsangdonandClaude Sonnet 5.5 5d7291b89a feat(m01): 흐름 로직 왼쪽 「지금 내용」 — 단가산출 상세 · 텍스트 수식(buildFlowView) · Detail 흐름 칸
- FlowRows(순수): 출력 상자를 비용 종류별 줄 · 소계 · 계(합계 끝수 1원 미만 버림) · 함수 상자 결과
- FlowView: 화면 · sub2 식 보기가 있으면 식 두 줄
- Detail: DetailContext.flowHost 가 있으면 호표 · 읽는 식 대신 이 칸
- 시험 test_m01_logic_flowview.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 23:28:34 +09:00
eomsangdon e75a99f7db Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-01 23:28:15 +09:00
eomsangdon 50b5efd8f6 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 23:27:56 +09:00
eomsangdon f1419cda96 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-10-01 23:27:54 +09:00
eomsangdon 0fafd3fbc8 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into sub_laptop_4 2026-10-01 23:27:49 +09:00
eomsangdon 9516116af5 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-01 23:27:47 +09:00
eomsangdonandClaude Opus 5.5 97f306a7af chore(m01): 36-1 흐름도 적용 옛 식 조사 — 파서 자리 · 1,472 식 말 전수 · 옮기기 어려운 꼴 · 계산 길 부르는 곳 · 시험입력
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 23:27:33 +09:00
eomsangdonandClaude Opus 5.5 accf8ecc4f feat(M01): 단가산출 로직 단추 정리 · 로직 상세 두 칸
- 「식으로 새로 만들기」 단추 · 화면 길 지움(LogicLab_New 다섯 파일 — 쓰는 곳 없음)
- 「시험판 ▾」 메뉴 · 시험판 단추 둘(CostFlow · DataFlow 입구) · 가져오기 줄 지움
- 「본떠 만들기」 → 「로직 복사/생성」 · 단추 차례 = ← 로직 목록 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장
- 로직 상세 두 칸(LogicLab_Flow) — 왼쪽 지금 내용 · 오른쪽 흐름도 자리 · 끌기 막대 · 너비 비율 기억(localStorage · 25~75%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 23:27:29 +09:00
eomsangdon a9a0219329 Merge remote-tracking branches 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_3 2026-10-01 23:27:15 +09:00
eomsangdon afb7da9824 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-01 23:27:14 +09:00
eomsangdonandClaude Sonnet 5.5 49fa7e5fbe wip(m02-df): 마스터 로직 고르기 순수 부분
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 23:27:12 +09:00
eomsangdonandClaude Opus 5.5 2e99091673 feat(m02-df): 36-0b 선 조건 여러 줄(그리고 · 또는) · 값 목록
- CfRule gate conds[{from · op · value}] · join 그리고/또는 · 옛 한 줄 꼴(from · op · value)도 읽음 — 옛 칸은 첫 조건과 같게 둠(makeGate)
- gateConds · makeGate · valueChoices(변수 고르기 · 재료 규격 · 표 칸 값 · 수 · 함수 결과는 null)
- 규칙 → 받는 칸 엑셀 식 IF(AND/OR(...),A1,NA()) · 선 이름표 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 겹침 · 안 맞음은 조건 알약 묶음끼리
- 조건 값 열쇠 「상자|알약」 먼저(이름 겹침 막음) · M02_DF_Compute.py gate 같은 뜻(서버 = 화면 시험)
- 시험 test_m02_df_model 30개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 23:27:04 +09:00
eomsangdonandClaude Opus 5.5 20720ea6f8 feat(M02): 흐름도 고치는 핵심 M02_DF_Paper.ts(패널 · 크게 보기 공용) · 다이어그램 보기만 · 다시 맞추기(PLAN 36-3)
- Entry 시험판 고치는 화면을 옮겨 적음(Entry 는 안 고침) — 다이어그램 · 상세 내용 · 원가 · 검증 · 실행 취소 · onChange 객체 그대로
- 다이어그램 opts.readOnly(끌기 · 잇기 · 더하기 · 지우기 막음 · 고르기 · 흐름 따라가기는 됨) · opts.fitKey(바뀌면 다시 맞춤)
- 알약 정보 글 · SVG 도우미를 Parts 로(Diagram 681줄)
- DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 23:26:58 +09:00
eomsangdon 34b9acfb4f Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-10-01 23:26:20 +09:00
eomsangdon 3137182e5d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 23:25:41 +09:00
eomsangdon 4697db29d3 auto: 2026-10-01 23:25 (ESD_LAPTOP) 2026-10-01 23:25:37 +09:00
eomsangdonandClaude Opus 5.5 99a47bec03 feat(M01): 마스터 사본 폴더 길 — AISLO_MASTER_DIR 가 있으면 그 폴더 · 없으면 정본(흐름도 적용 정함 13 · PLAN 36-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 23:25:20 +09:00
eomsangdon 5ec3c395b2 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-01 23:24:36 +09:00
eomsangdonandClaude Opus 5.5 1ce609e0d2 chore(docs): 흐름도 적용 정함 공유 파일 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 23:24:26 +09:00
eomsangdon 4a54497795 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-01 23:24:20 +09:00
eomsangdon 83b5040c8e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 23:24:19 +09:00
eomsangdonandClaude Sonnet 5.5 7ad2f2c133 chore(M01): 흐름도 적용 정함 문서 같은 내용 먼저 담음(합치기 충돌 방지)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 23:24:18 +09:00
eomsangdon 17ae4f85a6 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-01 23:22:28 +09:00
eomsangdon 9de99218de auto: 2026-10-01 23:22 (ESD_LAPTOP) 2026-10-01 23:22:23 +09:00
eomsangdonandClaude Opus 5.5 533826d613 chore(M02): 사용자 마스터 틀 고침 — 화면 저장 22:44~22:54 (구조물집계표 · 항목 3 · 입력)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-01 23:21:47 +09:00
eomsangdonandClaude Opus 5.5 69a138af00 chore(M01): 흐름도 본 프로그램 적용 정함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-01 23:21:20 +09:00
eomsangdonandClaude Opus 5.5 2c134b440f fix(M02): 흐름도 restParts — 쉼이 그 상자에 걸렸나는 link 와 무관 · 빈 값 판정은 내보내는 알약 값만(PLAN 35-9 브레인 재검증)
- 지반 견본(토사) 꼴 시험: 흐림 = 브레이커로 깨기 하나 · 1㎥ 경비 · 경비는 안 흐림
- 시험 test_m02_df_diagram_parts.py 7 통과 · DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 21:21:30 +09:00
eomsangdon 932ec987dd Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 21:18:36 +09:00
eomsangdonandClaude Opus 5.5 7d209c56f6 fix(m02-df): 상세 내용 설정 줄은 상자를 고른 때만 — 선 · 알약이면 줄째 숨김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 21:17:51 +09:00
eomsangdonandClaude Opus 5.5 321e63f9c1 fix(M02): 흐름도 쉼 흐림 — 상자는 들어오는 선이 모두 쉼(또는 쉼이 걸리고 내보내는 값이 모두 빈 값)일 때만 · 일부면 그 선과 받는 알약만(PLAN 35-9 브레인 재검증)
- restParts(Parts) · 받는 알약 쉼 모양(점선 · 흐린 글)
- 시험 test_m02_df_diagram_parts.py 6 통과 · DataFlow 전부 검사 초록 · Diagram 699줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 21:16:41 +09:00
eomsangdon 438c3660ca Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 21:06:15 +09:00
eomsangdonandClaude Opus 5.5 9052cb7411 fix(M02): 흐름도 기댐 선 끝을 알약 · 이름표에 붙임 — 시작 = 조건 알약 오른쪽 · 끝 = 규칙 선 이름표 가장자리 또는 재료 알약 오른쪽(PLAN 35-9 브레인 재검증)
- 시작에 작은 점 · 끝에 화살 · 끝 계산 depEnd · depPath(Parts)
- 시험 test_m02_df_diagram_parts.py 5 통과 · DataFlow 전부 검사 초록 · Diagram 697줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 21:02:21 +09:00
eomsangdonandClaude Sonnet 5.5 c2f7694e1d fix(M02-흐름도): 선 규칙 표에서 찾기 - 범위 이름은 이름 (범위) 한 줄만 · 꺼낼 칸은 표 머리 칸만
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 21:01:27 +09:00
eomsangdonandClaude Opus 5.5 1dfe6bfefb fix(m02-df): 만나는 상자는 살아 있는 선이 있으면 셈 — 서버 = 화면
- 받는 상자는 들어오는 선이 모두 쉬는 갈래일 때만 쉼 · 하나라도 살아 있으면 셈(쉬는 갈래 값은 빈 칸) · 쉬는 선은 쉼 줄만
- 조건 선 바로 뒤 상자는 그대로 막음 · M02_DF_Compute.py 도 같은 뜻(resting · arrive)
- 시험: 지반 견본 토사 → 1㎥ 경비 2,000 · 암 → 브레이커 갈래 값 · 쉼 줄 · 문제 0 · 서버 = 화면(26개)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 21:00:14 +09:00
eomsangdon 3eb18bf74b Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 20:49:09 +09:00
eomsangdonandClaude Sonnet 5.5 aaa36625e8 feat(m02-df): 끊긴 곳은 문제만 · 쉬는 갈래는 따로 회색 줄(level)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 20:39:31 +09:00
eomsangdonandClaude Opus 5.5 8a9f23c6e3 refactor(M02): 흐름도 다이어그램 쉼 가르기를 Eval level 그대로(가정 걷음 · PLAN 35-9)
- 시험 test_m02_df_diagram_parts.py 4 통과 · DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 20:39:25 +09:00
eomsangdon eb063c50bc Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 20:38:48 +09:00
eomsangdonandClaude Sonnet 5.5 e2b047ab1a fix(M02-흐름도): 선 규칙 표에서 찾기 - 이름·아래/이름·위 짝을 한 줄 「이름 (범위)」 로 묶어 고름
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 20:38:36 +09:00
eomsangdonandClaude Opus 5.5 018177408b feat(m02-df): 끊긴 곳 무게 — 쉼(쉬는 갈래) · 문제
- CfBreak.level: 통과 안 됨 · 그 아래로 번진 빈 값 = 쉼 · 빈 받는 자리 · 표에 없는 조건 · 맞는 재료 없음 · 조건 겹침 · 안 맞음 · 오류 = 문제
- 쉬는 갈래에서 온 빈 값은 받는 상자도 쉼(셈 안 함 · 출력 빈 값 · 문제로 안 셈)
- M02_DF_Compute.py breaks 에 같은 뜻 level 한 칸
- 시험 25개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 20:38:21 +09:00
eomsangdon 03e00fe15d Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-01 20:37:40 +09:00
eomsangdon 55c787d5af Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 20:36:42 +09:00
eomsangdonandClaude Opus 5.5 28469de625 fix(m02-df): 표준 표 모달 제목 한 번 — renderStandards title:false
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 20:36:24 +09:00
eomsangdon d76d40f614 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 20:35:40 +09:00
eomsangdonandClaude Opus 5.5 047f3a2798 fix(m02-df): 재료 자리 값 물음 · 상자 이름 칸 · 작은 칸 · 시작 단추
- 재료 선택 자리 · 조건 찾기를 서버 /values 에 slot(방식 · 기본 품목 · 범위 · 찾기 규칙) · value(이어진 알약 지금 값)로 물음 — 물음 서명에 들어가 값이 바뀌면 다시 받음(관경 600 → 400 = 116,600)
- 상세 내용 설정에 「상자 이름」(입력 · 함수 상자 · 빈 이름 · 같은 이름 안 받음 · 출력은 출력 판 이름 칸)
- 머리 로직 이름 · 결과 단위 칸 공용 작은 크기(compact)
- 시작 화면 「새로 만들기」 단추가 칸 폭으로 늘어나지 않게

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 20:35:39 +09:00
eomsangdonandClaude Opus 5.5 078e168f7d feat(M02): 흐름도 다이어그램 — 기댐 선(규칙의 조건 알약 → 규칙 자리) · 빈 판 안내 세 줄 · 쉼 갈래(회색 점선 · 받는 상자 흐리게 · 「!」 는 문제에만)(PLAN 35-9 브레인 검증)
- Model ruleSources · elementPort 로 기댐 선(표 조건 · 조건 · 재료 「규격에 포함」) · 흐름 길에도 칠함 · 누르면 그 규칙 자리(선 · 재료 상자) 고름
- breaks level(쉼 · 문제) · 없으면 「통과 안 됨」 = 쉼
- 끌기 손잡이를 Parts 로(Diagram 681줄 · Parts 320줄)
- 시험 test_m02_df_diagram_parts.py 4 통과 · DataFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 20:35:24 +09:00
eomsangdon 8473968f95 Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 20:34:03 +09:00
eomsangdonandClaude Sonnet 5.5 5bae4c9587 fix(m02-df): 출력 상자별 값은 그 출력 끝수 자리대로 · 검증 칸 글 명사형
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 20:33:35 +09:00
eomsangdonandClaude Opus 5.5 b5462afa94 fix(m02-df): 표준 표 판 제목을 고를 수 있게 — renderStandards opts.title(기본 true · 모달은 false)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 20:33:28 +09:00
eomsangdonandClaude Sonnet 5.5 5ef189ca45 fix(M02-흐름도): 알약 판 숫자에 천 단위 쉼표 · 온 길 줄에 선 규칙 글(보낸 알약 → (규칙) → 받는 알약)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 20:33:13 +09:00
eomsangdon 3abbe74f58 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-01 20:16:35 +09:00
eomsangdonandClaude Opus 5.5 609dd98fe2 fix(m02-df): 자재 찾기 띄어쓰기 지우고 비교 · 선 규칙 화면(Rule.ts) = 서버 시험
- /materials — 품목 글(이름 · 규격 · 키 · 원문번호) · 찾는 낱말 모두 띄어쓰기를 지우고 포함(AND) — 「흄관 300」 3 → 8 · 「흄관」 45 → 117
- 시험 — 이름 속 띄어쓰기 · Rule.ts 를 Node 로 그대로 돌려 서버 apply_rule 과 25가지 맞댐(표 찾기 범위 · 숫자 하나 = 같음 · 여럿 · 없음 · 조건 · 바꾸기 · 끝수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 20:15:34 +09:00
eomsangdon 12b063af26 Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-10-01 20:13:47 +09:00
eomsangdon 226e6c305d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-01 20:13:47 +09:00
eomsangdon 96b0e4c8fb Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 20:03:32 +09:00
eomsangdonandClaude Sonnet 5.5 67e631ac8c refactor(m02-df): 입력 판 자재 찾기를 서버 길(cfMaterials)로 — 대신 길 지움
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 20:03:12 +09:00
eomsangdonandClaude Opus 5.5 6c645585f4 fix(m02-df): 표준 표 판을 서버 Api(cfStandards · Save · Reset)로 · 대신 길 지움 · 서버 답 = 처음 값 시험
- 견본 시험의 /calc 대조를 Store.calc 로(옛 LogicBuilder 폴더 지움 뒤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 20:02:55 +09:00
eomsangdon 108ecc3c83 Merge remote-tracking branches 'origin/sub_laptop_7' and 'origin/sub_laptop_5' into main_laptop_1 2026-10-01 20:01:59 +09:00
eomsangdonandClaude Sonnet 5.5 0158c940cb fix(M02-흐름도): UnitPick 표준 표를 서버 /standards 로 읽음 - 번들 대신 길 지움
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 20:01:40 +09:00
eomsangdon b353daecfc Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-01 20:01:33 +09:00
eomsangdonandClaude Opus 5.5 39c0d163ff fix(m02-df): 표 범위 칸에 숫자 하나(아래 = 위)면 「같음」 — 브레인 정함
- 아래 ≠ 위는 지금대로(아래 < 값 ≤ 위 · 빈 끝 열림) · 견치돌 「높이 4」 같은 줄이 맞음
- 시험 — 저장 다시 셈 4 → 0.7 줄 · apply_rule 4 / 3.5 / 3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 20:01:27 +09:00
eomsangdonandClaude Sonnet 5.5 1fc2885f76 refactor(m02-df): 재료 찾기 · 단가 · 금액 표준을 서버 길(cfMaterials · cfStandards)로 바꾸고 대신 길을 지움
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 20:01:23 +09:00
eomsangdonandClaude Opus 5.5 2d459f7b13 feat(m02-df): 표 범위 칸 숫자 하나(아래 = 위)는 같음으로 맞춤
- 표에서 찾기 범위 칸: 아래 = 위면 값 == 그 수 · 다르면 아래 < 값 <= 위(빈 끝 열림) — 견치돌 높이 4 같은 줄
- 시험 24개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 20:01:08 +09:00
eomsangdonandClaude Opus 5.5 ce65d1df3b feat(m02-df): 흐름도 잇기 — 고름 · 상세 내용 판 · 흐름 길 · 끊긴 곳 · 다른 로직 · 표준 표
- 고름 = 상자 · 알약{box,port,dir} · 선 — 다이어그램 · 검증 칸 줄 · 알약 판 상자 이름 누름 모두 같은 고름으로
- 상세 내용 본문 = 입력 · 함수 · 출력 판 · 알약 = renderPillPanel · 선 = renderLinkPanel · 경고 = 끊긴 곳 가운데 고른 것 몫
- 알약 · 선 고름 = Model traceBack/traceForward 로 Diagram opts.trace · Eval breaks 로 Diagram opts.breaks
- 다른 로직 입력이 부르는 로직(저장 DF- · 견본)을 겹쳐 읽어 Eval opts.logics 로(셈 · 검사 · 판 모두)
- 「표준 표」 단추 = renderStandards 판(공용 모달)
- 입력 값 물음 · 결과 단위 후보는 Entry_Detail 로 옮김(700 줄 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 20:00:56 +09:00
eomsangdon 3e5dd641f1 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-10-01 19:59:53 +09:00
eomsangdon ba650fe63f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-01 19:59:51 +09:00
eomsangdonandClaude Sonnet 5.5 8c200dfc77 feat(m02-df): 입력 판 단위 칸을 UnitPick 으로 — 변수 · 새 요소 · DF 길로 확인
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 19:58:50 +09:00
eomsangdonandClaude Opus 5.5 868dc8e37f feat(m02-df): 흐름도 서버 — 재료 자리 셋 · 자재 찾기 · 다른 로직 값 · 설계 때 정할 것 · 표준 표 · Eval 과 같은 차례로 다시 셈
- M02_DF_Compute.py — 화면 Eval(evalLogic)과 같은 차례(Model depends) · 선 규칙은 Rule.ts applyRule 과 같은 뜻(값 기준 · 막히면 받는 상자 멈춤) · 끝수 = ROUND 계열 · 함수 시트는 같은 엔진 Node(서로 안 기대는 것은 한 번에) · 다른 로직은 받은 설계 값으로 셈 · 서로 부르면 막음 · 받는 칸에 값만 되돌림
- M02_DF_Values.py(Router 에서 나눔) — 재료 고정 · 선택(고른 품목 · 기본) · 조건 찾기(범위 + 규격/이름 포함/같음 · 0 · 여럿 후보) · 자재 찾기(띄어쓰기 AND · 이름 · 규격 · 키 · 원문번호 · 쪽) · 표준 표
- Router — GET /materials · /design-inputs · POST /logic-value · GET/PUT/DELETE /standards · main.py 등록
- Api — cfMaterials · cfLogicValue · cfDesignInputs · cfStandards(Save · Reset) · slot 물음
- 시험 — 재료 셋 · AND · 다른 로직 · 순환 · 선 규칙 넷 · 끝수 · 설계 때 정할 것 · 표준 표 · 권한

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 19:58:10 +09:00
eomsangdonandClaude Sonnet 5.5 df735e99a8 feat(m02-df): 검증 칸에서 다른 로직 받는 알약 예시값(logic.examples)을 고침
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 19:55:42 +09:00
eomsangdon 1eb636353c Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_3 2026-10-01 19:54:58 +09:00
eomsangdon 46f9c57fa0 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-01 19:54:08 +09:00
eomsangdon 314783a027 Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 19:53:29 +09:00
eomsangdonandClaude Opus 5.5 dad45e997b feat(m02-df): 다른 로직 입력 받는 알약 예시값 자리
- CfLogicRef.examples(받는 알약 이름 → 값) · Eval 값 차례 = 이어진 선 > 넘긴 값 > 상자 예시값 > 그 로직 자기 예시값
- CfDesign.example — 변수 = variable.example · 다른 로직 = examples(없으면 null)
- 시험 23개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 19:52:55 +09:00
eomsangdonandClaude Sonnet 5.5 646799e902 fix(m02-df): 다른 로직 입력을 빼면 받는 알약으로 들어오던 선도 지움
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 19:52:52 +09:00
eomsangdon 15226e8563 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-01 19:52:26 +09:00
eomsangdonandClaude Sonnet 5.5 296255e357 feat(m02-df): 검증 칸을 Model designInputs · Eval breaks 로 이음 · 출력 단위 고르기(UnitPick) · 단위 표준 소수자리 제안
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 19:52:25 +09:00
eomsangdonandClaude Sonnet 5.5 083f320ac4 fix(M02-흐름도): 상세 내용 판을 모델 몸통에 이음 - 빈 함수 대신 traceBack·breaks·arrived 를 씀
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 19:52:01 +09:00
eomsangdon 9848bd01b9 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_7 2026-10-01 19:51:47 +09:00
eomsangdon 25021f9371 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 19:51:27 +09:00
eomsangdonandClaude Opus 5.5 745d164177 refactor(M02): 흐름도 다이어그램 — 선 규칙 글을 Rule.ruleLabel 그대로 · 가정한 대신 글 걷음(PLAN 35-9)
- Model · Eval 몸통(03736cba1) 받은 뒤 DataFlow 전부 검사 초록
- ORCA: 「표에서 찾기 (뒷길이 = 뒷길이) → 석공」 · 「× 1.3」 · 「지반 = 암 일 때만」 · 「소수 2자리 반올림」 · 규칙 없는 선 이름표 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 19:51:23 +09:00
eomsangdon 3b4ce57d1e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-01 19:50:42 +09:00
eomsangdonandClaude Opus 5.5 4a62f05caf wip(m02-df): 서버 계산 M02_DF_Compute.py(재료 셋 · 다른 로직 · 선 규칙 · 끝수) · 설계 때 정할 것 · 다른 로직 값 길 · 시험 더함(규칙 하나 맞추는 중)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 19:50:41 +09:00
eomsangdon 126c251256 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 19:50:37 +09:00
eomsangdon 7753f8c2c1 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-01 19:50:37 +09:00
eomsangdonandClaude Sonnet 5.5 b76420dc3c feat(m02-df): 입력 판 조건 찾기 단위 · 재료 요소 검사 보정
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 19:50:36 +09:00
eomsangdonandClaude Opus 5.5 b67dcc804c feat(M02): 흐름도 다이어그램 — 넓힌 타입에 맞춤 · 선 규칙 이름표(CfLink.rule) · 흐름 길(CfStep) · 끊긴 곳(CfBreak) · 고른 선 · 상자는 흐리게 안 함(PLAN 35-9)
- DataFlow 전부 검사 초록 · Diagram 639줄 + Parts 248줄
- ORCA 견본(상자 6 · 선 5 · 규칙 넷): 이름표 「표: 석공 칸」「× 1.3」「지반 = 암일 때」「소수 2 반올림」 선 가운데 0.1 안
  · 온 길 상자 3 · 선 2 · 알약 5 / 가는 길 상자 2 · 선 2 · 알약 3 / 나머지 흐림(색 + 굵기 3.5 + 점선)
  · 끊긴 곳 상자 · 선 · 알약 표시 · 누르면 까닭 · 알약 고름 on.select(f1, 알약) · 선 고름 진하게
  · 가운데 단추 끌기 판 +60,+30(상자 안 움직임) · 왼쪽 빈 곳 끌기 판 그대로 · Space+끌기 판 −50 · 상자 머리 끌기 on.move
  · user-select none · 밝음 · 어두움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 19:49:24 +09:00
eomsangdon f759216242 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 19:49:21 +09:00
eomsangdon a35aef7086 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-01 19:49:19 +09:00
eomsangdon a2981c6337 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 19:48:25 +09:00
eomsangdonandClaude Sonnet 5.5 022c7e086b feat(M02-흐름도): 상세 내용 판 - 선 규칙 LinkPanel · 알약 PillPanel · 함수 판 끝수는 모델 round 로
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 19:48:24 +09:00
eomsangdonandClaude Opus 5.5 03736cba18 feat(m02-df): 흐름도 Model · Eval 넓힘 — 선 규칙 · 재료 방식 · 다른 로직 · 끝수 · 흐름 따라가기 · 끊긴 곳
- 선 규칙(lookup · gate · scale · round)을 엑셀 수식으로 바꿔 엔진 한 벌로 셈(M02_DF_Rule) · 선 위 규칙 글 · 막힌 갈래는 빈 값
- 재료 요소 고정 · 선택 자리 · 조건 찾기(알약 이름 = 자리) · 다른 로직 입력(받는 알약 = 설계 때 정할 것 · 그 로직으로 셈)
- 끝수 func.output.round · output.round · 기댐(선 · 규칙 조건 · 재료 조건)으로 차례 · 순환 막음 · setRule
- traceBack · traceForward · designInputs · breaks(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 · 조건 겹침 · 조건 안 맞음 · 오류)
- 시험 test_m02_df_model.py 22개(견본 셋 · 규칙 수식 · 흐름 · 끊긴 곳 · 설계 때 정할 것)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 19:47:53 +09:00
eomsangdon 2a76ee1816 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 19:47:52 +09:00
eomsangdon dd2499364b Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-01 19:47:51 +09:00
eomsangdon 295dbf622f Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-01 19:47:50 +09:00
eomsangdon 3cf1a02935 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into sub_laptop_5 2026-10-01 19:47:46 +09:00
eomsangdon 34e58a5fda Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-01 19:47:45 +09:00
eomsangdonandClaude Sonnet 5.5 d24851d1a4 feat(m02-df): 흐름도 검증 칸(끊긴 곳 · 재료 자리 · 조건 자리) · 출력 끝수 기본 · 합계 끝수
- 새 Verify: 끊긴 곳 목록(on.select) · 변수 예시값 · 재료 선택 자리(찾기 · 쪽 · 고르기) · 재료 조건 자리(잡힌 품목 · 0 · 여럿) · 이어지지 않은 다른 로직 값
- 새 Round · Material: 금액 단위 표준에서 끝수 기본(금액란 0.1원 · 계금 1원 미만 버림) · 재료 찾기 · 단가
- OutputPanel 끝수 고르기 · Summary 합계 끝수

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 19:47:43 +09:00
eomsangdonandClaude Opus 5.5 41cdf22d82 feat(m02-df): 단가산출 흐름도(시험) 들어가는 길 · 페이지 · 상세 내용 머리
- 시험판 ▾ 목록 「단가산출 흐름도(시험)」 · main.py DataFlow 서버 등록 줄
- 페이지 제목 「단가산출 흐름도」 · 단추 공용 작은 크기(size "sm") · 큰 단추 css 덮기 없앰
- 상세 내용(옛 「고른 상자」) — 머리 왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 알약 이름 · 설정 · 본문 · 경고 차례
- 고름 = 상자 · 선 · 알약 — 종류마다 판을 바꿔 끼움(선 = 보내는 곳 · 받는 곳 · 선 규칙 글 · 알약 = 온 길 · 가는 길)
- 경고 = 검사 · 계산 글 가운데 고른 것 몫만

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 19:46:47 +09:00
eomsangdonandClaude Opus 5.5 e2abd5a3c2 feat(m02-df): 흐름도 견본 넷 — 지반 가르기(조건 통과 선 둘) · 흄관 조건 찾기(재료 자리) · 다른 로직 잇기 · 메쌓기
- _sample/ground.json · hume.json · linked.json 새로 · rate.json 뺌(견본 넷에 없음) · value.json 은 linked 가 부르는 작업량 로직
- M02_DF_Sample.ts 목록 차례 · 쉬운 말 한 줄
- 시험 test_m02_df_sample.py — 모양 · 선 규칙 · 재료 방식 · 다른 로직 입력 · 출처 사슬 · 값(내부 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 19:46:46 +09:00
eomsangdon 7818dbf4ef Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 19:45:31 +09:00
eomsangdonandClaude Sonnet 5.5 eb2661ab18 feat(m02-df): 입력 판 재료 방식 셋 · 자재 찾기 쪽 넘기기 · 다른 로직 입력 초안
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 19:45:30 +09:00
eomsangdon 6db065e2e1 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 19:43:14 +09:00
eomsangdon 77d1bfbdc5 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-01 19:42:55 +09:00
eomsangdonandClaude Opus 5.5 e6fda191a2 wip(m02-df): 서버 입력 값을 M02_DF_Values.py 로 나눔 · 자재 찾기 · 재료 자리 · 표준 표 길 · main 등록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 19:42:54 +09:00
eomsangdon 768ae40d4f Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 19:42:50 +09:00
eomsangdon 92ffd41bc5 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-10-01 19:42:46 +09:00
eomsangdon a0d0b0354e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-01 19:42:35 +09:00
eomsangdonandClaude Opus 5.5 d562f09935 feat(M02): 흐름도 다이어그램 — 선 규칙 이름표 · 흐름 길 칠하기 · 끊긴 곳 · 알약 고름 · 가운데 단추 판 옮기기 · 글자 선택 막음(PLAN 35-9)
- 순수 부분을 M02_DF_Diagram_Parts.ts 로 나눔(700줄 안)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 19:42:34 +09:00
eomsangdon a3730d227e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 19:42:28 +09:00
eomsangdonandClaude Sonnet 5.5 348003cf5a feat(M02-흐름도): 공용 단위 고르기 UnitPick · 끝수 줄 · 함수 판 단위 고르기로 바꿈
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 19:42:27 +09:00
eomsangdonandClaude Opus 5.5 67ced35ade chore(M01): 옛 시험판 셋 지움 — 쉬운 단가산출 · 로직 만들기 · 시트 로직 폴더 · 시험 · 막대 목록 항목 · 서버 등록 줄
- 남김: 단가산출 다이어그램(CostFlow) · 로직_자체.json
- CostFlow 견본 시험의 /calc 대조를 M01 store 셈으로 바꿈(LogicBuilder 안 기댐)
- 추적 안 된 찌꺼기(__pycache__ · _data)는 tmp/trash_1001/ 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 19:42:16 +09:00
eomsangdonandClaude Opus 5.5 65e3c919ca feat(m02-df): 표준 표 판 · standardsList — 두 표 보기 · 칸 고치기 · 저장 · 처음 값으로
- M02_DF_Standards.ts — renderStandards(host, on) · standardsList()(단위 고르기 · 끝수 제안 · 동기) · loadStandards · 서버 GET/PUT/DELETE /api/m02/df/standards
- 시험 test_m02_df_standards.py 에 standardsList 목록(단위 반올림 · 금액 지위 → 자리 · 버림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 19:42:11 +09:00
eomsangdon 5f993e4075 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 19:41:02 +09:00
eomsangdon e48ed90093 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-01 19:39:54 +09:00
eomsangdonandClaude Opus 5.5 6008a1e7ae feat(m02-df): 흐름도 Model 넓힌 타입 · 함수 머리 올림
- 재료 요소 mode(fixed · choice · rule) · scope · rule{field · op · from} · default · slot
- 입력 갈래 logic + CfLogicRef{key · name · outputs · designs}
- 선 규칙 CfLink.rule — lookup · gate · scale · round · CfRound(func.output.round · output.round)
- 흐름 CfStep · CfDesign · traceBack · traceForward · designInputs · Eval CfBreak · breaks
- M02_DF_Rule.ts — ruleLabel · applyRule · applyRound (속은 준비 중)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 19:39:48 +09:00
eomsangdon acfacb3a38 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-10-01 19:39:22 +09:00
eomsangdon 4cd2930ff3 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 19:39:10 +09:00
eomsangdon 8ed369b209 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 19:38:51 +09:00
eomsangdon 14b222dcb5 auto: 2026-10-01 19:38 (ESD_LAPTOP) 2026-10-01 19:38:49 +09:00
eomsangdon 13f37b4f56 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-01 19:38:34 +09:00
eomsangdon 92546d48db Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 19:38:33 +09:00
eomsangdonandClaude Sonnet 5.5 9731b133fe chore(M01): 흐름도 정함 문서 같은 내용 먼저 담음(합치기 충돌 방지)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 19:38:33 +09:00
eomsangdon 10d6b30e6d Merge remote-tracking branches 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 19:38:31 +09:00
eomsangdonandClaude Opus 5.5 d660f2a12e feat(m02-df): 단위 · 금액 표준 표 처음 값 — 산림품셈 1-2-2 → DataFlow/_standard
- 단위표준.json 73 줄(병합 칸 10 줄 폄 · 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 줄 · 주 ①~③) · 원문 흠 그대로
- 금액단위표준.json 다섯 줄(지위 · 미만버림)
- 시험 test_m02_df_standards.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 19:37:53 +09:00
eomsangdonandClaude Opus 5.5 fc7affb019 feat(m02-df): 단가산출 흐름도 0걸음 — CostFlow 를 DataFlow 로 옮겨 이름 바꿈
- 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로
- _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함
- 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로)
- 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 19:37:51 +09:00
eomsangdon 8099a90ced Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-01 19:37:15 +09:00
eomsangdon 5fb3e089b2 auto: 2026-10-01 19:37 (ESD_LAPTOP) 2026-10-01 19:37:11 +09:00
eomsangdon da9eead7e5 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-01 19:36:15 +09:00
eomsangdonandClaude Opus 5.5 1930b80aa4 chore(M01): 흐름도 정함 — 공유 docs 사본 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 19:36:12 +09:00
eomsangdonandClaude Opus 5.5 12a60baeeb chore(M01): 단가산출 흐름도 시험판 정함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-01 19:35:01 +09:00
eomsangdon db1f31a326 Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 16:51:52 +09:00
eomsangdonandClaude Sonnet 5.5 833e8e5772 fix(m02-cf): 원가 합계 「고칠 것」 검사에 받은 입력 값을 넘김
renderSummary 다섯째 인자 opts.values 를 checkLogic 에 전달.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 16:51:27 +09:00
eomsangdonandClaude Opus 5.5 d297d3384e fix(m02-cf): 견본 인력 입력 상자 단위 원/일 · 요소 단위가 같으면 상자 단위로(시험)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 16:50:30 +09:00
eomsangdon 266c0a9eec Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 16:49:27 +09:00
eomsangdonandClaude Sonnet 5.5 aacd21dd7c fix(m02-cf): 입력 상자 단위를 요소 · 변수 단위에 맞춤 — 열 때 바로잡음(견본 인력 상자 원/일)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 16:49:20 +09:00
eomsangdonandClaude Opus 5.5 19fed5b90f feat(M01): 단가산출 막대 시험판 넷을 「시험판 ▾」 목록 하나로 묶음 · CostFlow 원가 칸에 입력 값도 넘김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 16:49:02 +09:00
eomsangdonandClaude Opus 5.5 7322c5af12 fix(M02): 단가산출 다이어그램 — 알약 값 쉼표 · 표 「· ○줄」 · 저절로 맞춤 90% 아래로 안 내림(PLAN 35-7 브레인 검증 ② ③ ⑥)
- 수는 천 단위 쉼표 · 소수 셋째 자리까지 · 끝 0 지움 · 글은 그대로
- 저절로 맞춤이 넘치면 왼쪽 위부터 보임 · 축소 단추로는 더 줄임
- 알약 대신 길(Model 준비 전) 걷음 · 682줄 · CostFlow 전부 검사 초록
- ORCA 견본 메쌓기: 97,882.512 · 268,908 · 0.364 · 표 · 40줄 · 처음 확대 0.9 · 한눈에 0.9 · 축소 0.75 · 알약 글 10.8px

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 16:48:49 +09:00
eomsangdonandClaude Opus 5.5 2be767d186 fix(m02-cf): 함수 상자 출력 기본 이름을 로직 안에서 겹치지 않게
- addBox(function) 출력 이름 = 「결과 1」 「결과 2」 … (안 쓰인 가장 작은 번호) · A1 머리도 같은 이름
- 이름이 겹치면 link 가 받는 알약을 「이름 (2)」 로 만듦(이미 됨 · 시험 더함)
- 시험 14개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 16:32:32 +09:00
eomsangdon 306a251712 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 16:24:17 +09:00
eomsangdonandClaude Opus 5.5 b571d7cecc feat(m02-cf): 고른 상자 판 머리에 상자 색 바꾸기(정함 점검 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 16:24:16 +09:00
eomsangdon 2f5cec2d61 Merge remote-tracking branches 'origin/sub_laptop_7', 'origin/sub_laptop_8' and 'origin/sub_laptop_2' into main_laptop_1 2026-10-01 16:23:07 +09:00
eomsangdonandClaude Opus 5.5 1004768037 feat(m02-cf): 함수 상자에 놓으면 받는 알약을 새로 만들어 이음
- link: 받는 쪽이 함수 상자이고 그 이름 받는 알약이 없으면 새로 만듦(빈 이름이면 보낸 알약 이름 · 같으면 「이름 (2)」) · func.inputs 에 칸 없이 더함
- unlink · removeBox: 받는 알약이 칸에 안 놓였고 다른 선도 없으면 같이 지움
- 검사: 칸에 놓지 않은 받는 알약 알림
- 시험 13개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 16:22:20 +09:00
eomsangdonandClaude Opus 5.5 71c20453e4 fix(M02): 단가산출 다이어그램 — 함수 상자 몸통 · 「+」 받는 자리에 놓으면 보낸 알약 이름으로 on.link(PLAN 35-7)
- 받는 알약 0 개인 새 함수 상자도 이을 수 있음 · 받는 알약은 Model 이 새로 만듦
- 함수 상자 왼쪽 흐린 「+」 받는 자리 · 잇기 중이면 밝게
- ORCA: 빈 함수 상자 몸통 ← 면적 · 「+」 ← 높이 → on.link 두 번(이름 그대로) · CostFlow 전부 검사 초록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 16:22:01 +09:00
eomsangdonandClaude Sonnet 5.5 2ea4883273 fix(M02-CostFlow): FuncPanel 칸 없는 받는 알약을 아직 안 놓은 알약으로 보임 - 선 없이도 보이고 칸에 놓으면 잠금
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 16:21:55 +09:00
eomsangdon 42b94ed737 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 16:21:38 +09:00
eomsangdonandClaude Opus 5.5 dfd4810bf1 fix(m02-cf): 견본 메쌓기 높이 증가율표 입력 그룹을 소요량으로 · 시험이 마스터 그룹과 대조
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 16:21:27 +09:00
eomsangdon 4451bbe6a5 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-10-01 16:21:11 +09:00
eomsangdonandClaude Opus 5.5 7df1df5702 feat(m02-cf): 입력 상자 판(renderInputPanel) 이음 · 기계 요소 「키#칸」 은 키와 칸으로 나눠 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 16:20:36 +09:00
eomsangdonandClaude Opus 5.5 f609dd2767 feat(m02-cf): 기계 입력 값 — 입력 판 ref 「EQ키#칸」 · 칸 「시간당 경비」(기계경비 로직)
- /cf/values · 저장 다시 넣기가 기계 ref 「EQ키#칸」 과 {ref: 키, field: 칸} 둘 다 받음
- 칸 「시간당 경비」 = 기종(분류번호)을 받는 기계경비 로직 가운데 짝 차례 첫째(옛 EasyCalc pickMachine 규칙을 옮김) · 빈 입력은 첫 고르기(지역 = 전국평균) · inputs 로 바꿈
- 시험 — 키#칸 = {ref, field} = 기계 표 값 · 시간당 경비 = 그 로직 /calc 계 · 저장 때 같은 값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 16:16:38 +09:00
eomsangdon cd8837d9c3 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 16:14:53 +09:00
eomsangdon 76c123d4e6 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 16:14:17 +09:00
eomsangdon 6714c363b7 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-01 16:14:16 +09:00
eomsangdonandClaude Opus 5.5 712aac8e39 feat(m02-cf): 단가산출 다이어그램 잇기 — 열기 · 견본 복사 · 입력 값 · 다이어그램 · 함수/출력 판 · 원가 · 저장(서버 대조) · 지우기 · 실행 취소
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 16:14:11 +09:00
eomsangdon 0c7569e5bb Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-01 16:14:04 +09:00
eomsangdonandClaude Sonnet 5.5 e2dfbe687b feat(m02-cf): InputPanel 완성 — 서버 Api 최종 꼴(ref · Model 사용자 요소)에 맞춤 · 환율 유가전력 알약 이름
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 16:13:08 +09:00
eomsangdon 9b11fccbef Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 16:11:27 +09:00
eomsangdon 2cecd38f35 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 16:11:00 +09:00
eomsangdonandClaude Opus 5.5 ae549f913c feat(m02-cf): 단가산출 다이어그램 서버 · Api — Model CfLogic 꼴로 저장 · 서버 다시 셈 · 입력 값 · 사용자 요소
- M02_CF_Router.py — /api/m02/cf/list(내 것 + _sample 견본) · get · save · delete · values · user-elements(만들기 · 지우기 · _data) · 키 CF-0001 · UE-0001
- 저장: 입력 상자 값(요소 · 표 · 변수)을 마스터에서 다시 받고 → 함수 상자 시트를 한 통합문서로 묶어(받는 칸 값 · 함수→함수 시트 사이 참조) Node 로 셈 → 받는 칸에 값만 되돌려 넣음 · 출력 상자 비용 종류별 원가
- 알약 이름: 입력 = 요소 name · 「표」 · 변수 name / 함수 받는 = inputs[].port · 내보내는 = output.name / 출력 받는 = 「값」
- 입력 값: 인력 · 재료(키 · 검색어 묶음) · 유가전력 · 환율 · 요율(rate_percent) · 기계(값 칸 · 시간당 경비 로직) · 소요량 / 계수 표(블록 · 한 줄) · 사용자 요소
- M02_CF_Api.ts — Model 타입(CfLogic · CfGroup · CfUserElement)을 씀 · CfQuery · CfAnswer · CfComputed · 쉬운 말
- main.py 등록 한 문장 · 시험 test_m02_cf_router.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 16:10:36 +09:00
eomsangdonandClaude Opus 5.5 2c4078e187 fix(m02-cf): 견본 색을 Model 기본 색(BOX_COLOR)과 같게 · 시험이 Model 값과 대조
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 16:08:28 +09:00
eomsangdon 608de92696 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 16:08:06 +09:00
eomsangdon 3b2b830786 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 16:07:44 +09:00
eomsangdonandClaude Sonnet 5.5 ad1d104776 feat(m02-cf): 입력 상자 판 InputPanel 초안 — 그룹 요소 · 표 · 변수 · 사용자 요소
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 16:07:44 +09:00
eomsangdon d415923d16 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 16:07:25 +09:00
eomsangdonandClaude Opus 5.5 97b7111c06 fix(M02): 단가산출 다이어그램 — 확대 단추 글 · 어두운 화면 종류 글자 밝게(PLAN 35-7)
- CostFlow 전부 검사 초록 · 660줄
- ORCA 견본(상자 8 · 선 9 · 직렬 · 병렬): 처음 한눈에 맞춤 · 선 끝 알약에서 0.05 안 · 상자 끌기 → on.move(f3, 400, 350)(+100, +50)
  · 확대 1.212→1.454 · 휠 1.322 · 한눈에 1.161 모두 판 안 · 끌어 잇기 → Model 잇기 통과 · 막힘 까닭 한 줄
  · 함수 고르면 이어진 선 넷만 진하게 · 선 끊기 · 줄 맞추기 · 화살표 · 더하기 · 지우기 물음 · 밝음 · 어두움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 16:07:08 +09:00
eomsangdon 8ba872384d Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-01 16:06:40 +09:00
eomsangdonandClaude Opus 5.5 91c743382b feat(m02-cf): 단가산출 다이어그램 견본 셋 · M02_CF_Sample(목록 · 복사해서 열기)
- _sample/masonry.json — 돌쌓기 메쌓기(인력): 변수 · 품표(QF000421) · 증가율표(QF000422) · 인력 → 증가율 찾기 → 품(둘) → 노무비(둘) → 합 → 출력 노무비
- _sample/rate.json — 공구손료(비율): 노무비 × 율 → 출력 재료비
- _sample/value.json — 덤프트럭 시간당 작업량: q · f · E · Cm → 출력 값
- 시험 test_m02_cf_sample.py — 모양 · 알약 이름 규칙 · 견본 ① 노무비 148,470.504 = /calc GF000219 · ③ = GF000133

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 16:06:32 +09:00
eomsangdon f552441529 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-01 16:05:55 +09:00
eomsangdon b0c86116e6 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 16:05:42 +09:00
eomsangdonandClaude Sonnet 5.5 47eba22e54 feat(M02-CostFlow): 함수 상자 판 FuncPanel 추가 - 시트 편집기 · 받은 알약 · 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 16:05:41 +09:00
eomsangdonandClaude Sonnet 5.5 11d377d9e0 feat(m02-cf): 단가산출 다이어그램 출력 상자 판(OutputPanel) · 원가 합계 칸(Summary)
출력 상자의 받은 값 · 비용 종류 · 단위 · 이름 판, 원가 합계(노무비 · 재료비 · 경비 · 계) · 출력 상자별 줄 · 값 출력 · 빈 받는 자리 · 오류 · 예시값.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 16:05:15 +09:00
eomsangdon d761e2d543 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 16:04:46 +09:00
eomsangdon 70a8e15303 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-01 16:04:46 +09:00
eomsangdonandClaude Opus 5.5 cf73a51fdb fix(M02): 단가산출 다이어그램 700줄 안으로 · 알약 이름 규칙(표 · 값) 맞춤(PLAN 35-7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 16:04:46 +09:00
eomsangdon f0a8f48ac6 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-01 16:04:32 +09:00
eomsangdonandClaude Opus 5.5 4f02421acc wip(m02-cf): 서버 · 시험 첫 꼴(workbook · fills) — 다음 커밋에서 Model CfLogic 꼴로 바꿈
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 16:04:32 +09:00
eomsangdon 316ffa0b14 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 16:04:13 +09:00
eomsangdon 8135ac3ca2 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-01 16:04:11 +09:00
eomsangdonandClaude Opus 5.5 6ba9d76605 feat(m02-cf): 단가산출 다이어그램 Model · Eval 속 채움 · 시험
- 상자 더하기(종류별 기본 색 · 단위 - · 함수 = 빈 시트 하나 · 출력 = 노무비) · 지우기(선도) · 자리 · 색 · 단위(함수 · 출력 안쪽 단위도) · updateBox
- 알약 이름 규칙(브레인 정함) — 입력 = element.name · 표 · variable.name / 함수 = inputs[].port · output.name / 출력 = 값
- 선 규칙 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 · 입력으로 들어감 · 같은 상자 · 순환 · 같은 받는 자리 두 번 · 없는 알약은 막고 까닭
- 계산 — 함수 상자를 차례대로(직렬 · 병렬) 받는 칸 · 변수 칸에 값을 넣고 우리 스프레드시트 엔진으로 셈 · 출력 상자 비용 종류별 원가 합(노무비 · 재료비 · 경비 · 값들 · 계)
- 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류 칸
- 시험 resources/tester/test_m02_cf_model.py 11개(엔진 그대로 Node)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 16:04:05 +09:00
eomsangdonandClaude Opus 5.5 e837e5965e feat(m02-cf): 시작 화면 내 로직 · 견본 목록을 cfList 로(속이 오기 전엔 빈 목록)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 16:03:23 +09:00
eomsangdon 93ac4ab22e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-01 16:03:03 +09:00
eomsangdonandClaude Opus 5.5 2fe9ca154a feat(M02): 단가산출 다이어그램 자유 배치 초안 — 상자 끌어 놓기 · 판 끌기 · 확대 · 한눈에 · 줄 맞추기 · 굽은 선(PLAN 35-7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 16:03:03 +09:00
eomsangdon ac5a0eb685 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_5 2026-10-01 16:02:49 +09:00
eomsangdon f013343c1e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 16:02:21 +09:00
eomsangdon a684820394 auto: 2026-10-01 16:02 (ESD_LAPTOP) 2026-10-01 16:02:18 +09:00
eomsangdon 87ec8628a8 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-01 16:01:52 +09:00
eomsangdon 227fd8245b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 16:01:52 +09:00
eomsangdonandClaude Sonnet 5.5 24068f65e4 chore(M01): 원가 다이어그램 문서 같은 내용 먼저 담음(합치기 충돌 방지)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 16:01:51 +09:00
eomsangdon 87dcc4b288 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 16:01:49 +09:00
eomsangdonandClaude Opus 5.5 b0d908c68b feat(m02-cf): 단가산출 다이어그램 단추 · 시험판 페이지 · 시작 화면 · 고치는 화면 틀(1걸음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 16:01:49 +09:00
eomsangdon 802e632b70 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-01 16:01:16 +09:00
eomsangdon 716af60df7 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_6 2026-10-01 16:01:13 +09:00
eomsangdonandClaude Opus 5.5 73fa2e612a feat(m02-cf): 단가산출 다이어그램 Api 타입 · 함수 머리 · _data 를 git 밖으로
- CfGroup · CfCost · CfQuery · CfValue · CfFill · CfLogic · CfListItem · CfSaved · CfUserElement
- cfList · cfGet · cfSave · cfDelete · cfValues · cfUserElements · cfUserElementAdd · cfUserElementDelete · cfPlain 머리
- CostFlow/.gitignore — _data/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 16:00:41 +09:00
eomsangdon 5701d671d8 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1 2026-10-01 16:00:16 +09:00
eomsangdon 043a3b4516 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-01 16:00:14 +09:00
eomsangdonandClaude Opus 5.5 0e200cf74a chore(M01): 원가다이어그램 정함 — 공유 docs 사본 담음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 16:00:12 +09:00
eomsangdonandClaude Opus 5.5 2e66b0a6ce feat(m02-cf): 단가산출 다이어그램 Model · Eval 타입 · 함수 머리 올림
CfLogic · CfBox · CfInput · CfVariable · CfFunc · CfOutput · CfPort · CfLink · CfElementRef · CfUserElement · CfPill · CfKind · CfGroup · CfCost · CfValue · CfValues · CfEvalResult
newLogic · addBox · removeBox · moveBox · setColor · setUnit · portsOut · portsIn · link · unlink · evalLogic · checkLogic (속은 준비 중)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 15:59:53 +09:00
eomsangdon 66f77cceeb Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-01 15:59:40 +09:00
eomsangdon e0dfc5cbaa auto: 2026-10-01 15:59 (ESD_LAPTOP) 2026-10-01 15:59:36 +09:00
eomsangdonandClaude Opus 5.5 84c2b968ec chore(M01): 단가산출 다이어그램 시험판 정함 — 사용자 기준 점검
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-01 15:58:33 +09:00
eomsangdon cf823bb6f6 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-01 14:06:01 +09:00
eomsangdonandClaude Opus 5.5 bb89baa557 chore(M01): 공유 docs 사본 담음(다른 창이 쓴 쉬운단가 문서)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 14:05:12 +09:00
eomsangdonandClaude Opus 5.5 58cee6404e chore(M01): 시험판 단추 공용 양식 점검
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic
2026-10-01 14:03:38 +09:00
eomsangdon 3a89267520 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 14:03:07 +09:00
eomsangdonandClaude Opus 5.5 0e72719a5d feat(m01-sl): 다른 로직 상자 손잡이 잇기(renderLogicTools) · 넘길 값을 실은 바깥 값 물음을 짐작한 물음보다 먼저
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 14:02:45 +09:00
eomsangdonandClaude Opus 5.5 8b07250aa0 fix(m01-sl): 가져오기 이음매 — 항목 상자 = Item 틀(applyItemLayout) · 다른 로직 = LogicTools 틀 · 표 줄 수 따라감 · 자재 묶음 · 상자 이름
- 항목 상자: applyItemLayout 로 B1 수량 · B2~B4 단가 · B6 금액 · B7~B9 비목 · node.ref = ItemRef · 덧줄 「비목 × 율 / 100」 = 비율 틀 · refreshBases
- 결과 상자: Model 결과 틀(B2~B4 노무비 · 재료비 · 경비 입력 알약 · 선이 여럿이면 + · B5 계)
- 다른 로직 상자: LogicTools 와 같은 틀(ref = 로직 번호 · 넘길 값 칸 · 돈 로직은 노무비 · 재료비 · 경비 · 계 · 단계 1)
- 표 상자: 「표」 알약 E1 만 · 맞음 칸 = 표 줄 수 이상 · 최소 20 줄 + 「표 줄 수」 칸
- 자재 검색어 묶음: 바깥 값 key = 묶음 JSON 글 · 상자 이름 = 직종 · 로직 · 표 이름(names)
- 시험: 6개 /calc 같은 값(끝수 원 미만) · 1,469 중 1,370 다 바뀜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 14:01:08 +09:00
eomsangdon 52b86b4620 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 13:58:25 +09:00
eomsangdonandClaude Opus 5.5 12ec81be08 feat(m01-sl): 상자와 선 · 시트 · 결과 세 칸을 내용 높이만큼 — 안쪽 세로 밀기 없앰(창만 밈) · 시트 판 높이 = 쓰는 줄 + 두 줄
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:58:06 +09:00
eomsangdon c7d814d7be Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-01 13:55:54 +09:00
eomsangdon 43600bbdaa Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-01 13:55:52 +09:00
eomsangdonandClaude Sonnet 5.5 e5955b705d fix(m01-sl): 비율 바탕 이음은 아는 상자만 · 손선 보존, 결과 칸 값/단위, 다른 로직 손잡이
- refreshBases 를 Model connect/disconnect 로 바꿔 모르는 항목 상자와 손으로 이은 선을 안 건드림, applyItemLayout 내보냄
- 결과 칸: 돈 아닌 결과는 값과 단위, 「100㎡ 당」 표기
- 새 LogicTools: 다른 로직 찾기 · 고르기 · 넘길 값 · 결과 알약

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 13:55:04 +09:00
eomsangdon b188ba3dd5 Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_3' into main_laptop_1 2026-10-01 13:54:36 +09:00
eomsangdon 9be09eed9d Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into sub_laptop_7 2026-10-01 13:54:05 +09:00
eomsangdonandClaude Sonnet 5.5 2c848702ea fix(m01-sl): 표 상자에 표 번호 안 보이게 — 시트 이름 · 찾기 판 머리를 표 이름으로 · 표 줄 수 따라가기
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 13:53:46 +09:00
eomsangdonandClaude Opus 5.5 ab514e8d10 fix(m01-sl): 자재 바깥 값 — 검색어 묶음으로도 단가를 찾음(기존 엔진 재료 줄과 같은 길)
- {kind: "자재", key} 의 key 가 「{」 로 시작하는 JSON 글(또는 묶음)이면 {구분, 상세구분, 검색어, 규격?} 로 풀어 /calc 재료 줄처럼(검색어 · 자동값 차례 · 단위 환산) · 아니면 키
- unit(그 줄 단위)을 주면 기존 로직 재료 줄과 같은 단가 · 못 찾으면 쉬운 말(맞는 자재 없음 · 단가가 자료에 비어 있음)
- Api SlQuery 에 unit? · 시험 — 검색어 묶음 단가 = /calc 그 줄 단가(글 · 묶음) · 저장 때도 같은 값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:53:20 +09:00
eomsangdonandClaude Opus 5.5 1fe49ad241 feat(m01-sl): 다른 로직 상자 손잡이 자리(renderLogicTools 가 올라오면 부름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:53:15 +09:00
eomsangdon a02ea70d2e Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_1' into main_laptop_1 2026-10-01 13:47:34 +09:00
eomsangdonandClaude Opus 5.5 2d64a76fc3 fix(m01-sl): 상자를 안 고르면 시트 판을 숨기기만 — 갈아끼우면 표 상자 편집기가 다시 안 뜨던 흠
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:46:16 +09:00
eomsangdon 45192daf30 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 13:42:50 +09:00
eomsangdonandClaude Opus 5.5 90f4e6ab20 fix(m01-sl): 바깥 값 — 옛 답은 버리고 다시 받음 · 아직 고르지 않은 상자는 묻지 않음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:42:50 +09:00
eomsangdonandClaude Opus 5.5 5c1a5e14bc test(m01-sl): 표 블록 열이 서버 · Table_Build · Import_Parse 셋 다 같은지
- 견본 표(범위 조건 · 값 칸 범위 80~100 · 범위 칸에 수 하나)로 서버 /outside 표 답 = sub3 blockCells = sub6 tableRows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:39:24 +09:00
eomsangdon 7018453a88 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4 2026-10-01 13:37:52 +09:00
eomsangdon 60a287419d Merge remote-tracking branches 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 13:37:12 +09:00
eomsangdonandClaude Opus 5.5 fdc06bbc26 fix(m01-sl): 선 잇기 · 끊기 · 상자 더하기/지우기 뒤 다이어그램이 옛 그림으로 덮던 흠 — 다음 틱에 다시 그림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:35:50 +09:00
eomsangdonandClaude Opus 5.5 46c7f8e41c fix(m01-sl): 가져온 표 블록 칸 차례를 정함대로 — 값 칸 범위도 아래 · 위 두 칸 · 숫자 하나면 두 칸에 같은 수
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 13:29:16 +09:00
eomsangdonandClaude Sonnet 5.5 679a759e84 feat(m01-sl): 표 상자 열 차례를 서버 표 블록에 맞춤 — 값 칸 범위도 아래 · 위 두 칸 · 숫자 하나는 같은 수
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 13:28:59 +09:00
eomsangdonandClaude Opus 5.5 892e156efe fix(m01-sl): 표 블록 칸 차례를 정함대로 — 범위가 있는 칸은 조건 · 값 모두 두 칸
- 브레인 정함: 서버가 칸 차례 정본 · 조건 칸 → 값 칸 · 범위 조건 · 범위 값(「20~45」)은 「이름·아래」 「이름·위」
- 그 칸에 수 하나면 아래 · 위에 같은 수 · 빈 칸은 빈 칸 · 블록 왼위 = 「표」 바깥 알약 칸
- f8d777628(값 칸 한 칸 · 「80,100」 글)을 되돌림 · 시험 기대값 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:28:25 +09:00
eomsangdon 62d63c0d17 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-01 13:27:48 +09:00
eomsangdonandClaude Opus 5.5 d9a5733865 fix(m01-sl): 가져온 로직 장비 단가가 0 이던 흠 — 넘길 입력값을 계산값으로 · 바뀌면 바깥 값 다시 받음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:27:48 +09:00
eomsangdonandClaude Opus 5.5 74d2ba25f8 chore(m01-sl): Model 머리 주석에 표 블록 · 항목 단가 약속 두 줄
① 표 블록 칸 차례 = 서버 _table 하나(범위는 조건 · 값 모두 이름·아래/위 · 숫자 하나면 같은 수 · 왼위 = 표 알약 칸)
② 항목 단가 바깥 값 주인 = Item itemOutsides · 같은 칸이면 그쪽이 이김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 13:27:35 +09:00
eomsangdon 184c66e14e Merge remote-tracking branches 'origin/sub_laptop_4', 'origin/sub_laptop_7' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-01 13:26:59 +09:00
eomsangdonandClaude Opus 5.5 499e52ca73 feat(m01-sl): 시트 로직 잇기 — 열기 · 바깥 값 · 다이어그램 · 시트 판 · 표/항목 손잡이 · 결과 · 저장(서버 값 대조) · 지우기 · 실행 취소 · 가져오기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:21:15 +09:00
eomsangdonandClaude Opus 5.5 520c7dc406 fix(m01-sl): 가져온 표 상자 「표」 바깥 값 출력 알약(E1) — Table 상자와 같은 꼴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 13:17:36 +09:00
eomsangdonandClaude Opus 5.5 ebf51d8b35 feat(m01-sl): 표 상자 이음매 — 블록은 표 알약 칸부터 · 줄면 옛 줄 지움 · 머리 줄 바뀜 알림
- 표 블록 왼위 = 그 표 상자의 「표」 바깥 값 알약 칸(A1 · E1 무엇이든) · applyOutside 가 그 칸부터 머리 줄 포함 채움
- 표가 줄었으면 블록 열 아래 남은 옛 값 칸을 지움(찾기 판 수식은 블록 열 밖이라 그대로)
- answersToValues(cells, 답, sl) — 시트 머리 줄과 받은 머리 줄이 다르면 쉬운 말
- addNode(table) = 빈 시트(Table 이 블록 · 찾기 판 · 알약을 채움 — 틀 칸이 블록과 겹치지 않게)
- 시험 15개(Table_Build 와 잇는 시험 더함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 13:17:13 +09:00
eomsangdonandClaude Opus 5.5 34165ec66a test(m01-sl): 품셈 표 블록 왼위 = 「표」 바깥 알약 칸(E1) 시험
- 서버 저장 다시 넣기는 이미 알약 칸에서 펼침 — 고정 A1 아님을 시험으로 남김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:17:10 +09:00
eomsangdonandClaude Opus 5.5 f8d7776284 fix(m01-sl): 품셈 표 바깥 값 열 차례를 Table 과 같게 — 범위 조건만 두 칸 · 값 칸은 한 칸
- 조건 칸(범위 조건은 「·아래」 「·위」) → 값 칸 · 머리 줄 포함 블록을 A1 부터(M01_SL_Table_Build layoutOf · blockCells 와 같음)
- 범위 칸에 목록이 아니면 빈 칸 · 값 칸 범위 값은 「80,100」 글(JS String 과 같음)
- 시험 견본에 값 칸 범위 · 범위 아닌 칸 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:16:21 +09:00
eomsangdon 124c3976d8 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 13:15:58 +09:00
eomsangdon 7b9e91f7e2 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-01 13:15:56 +09:00
eomsangdonandClaude Opus 5.5 dd24ebc5c9 feat(m01-sl): 시트 로직 기존 로직 가져오기 · 6개 /calc 같은 값 시험
- M01_SL_Import.ts — 로직 줄 → 시트 로직(상자 = 시트 · 알약 · 선) · 만약/최소/최대/올림/버림/반올림 → IF/MIN/MAX/ROUNDUP/ROUNDDOWN/ROUND · 찾기 → 표 상자(맞음 칸 + INDEX/MATCH) · 로직 → 다른 로직 상자 · 바깥 값 자리 outsideOf
- M01_SL_Import_Parse.ts — 엔진 문법 식 읽기 · 품셈 표 칸 놓기
- 시험 test_m01_sl_import.py — 견본 6개 시트 셈 = /calc · 마스터 1,469 중 1,386 다 바뀜 · 바뀐 통합문서 #NAME? 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-10-01 13:14:59 +09:00
eomsangdonandClaude Opus 5.5 5f9240c9e3 fix(m01-sl): 시트 로직 서버 · Api 를 sub7 Model 모양으로 — SheetLogic · SlSource · SlOutsideAnswer
- 저장 몸 = Model SheetLogic(workbook · nodes · edges) · 바깥 값 칸 = Eval outsideCells 규칙(알약 outside · 상자 source · 표 · 로직 ref 짐작 · id 「상자!칸」)
- 몸의 outside: SlOutsideAt[](Item 항목 단가 — 칸마다 비목) 도 받음 · 같은 칸은 그쪽이 이김 · 값은 늘 마스터에서
- /outside 물음 = {id, ...SlSource} · 답 = Eval SlOutsideAnswer · 표 범위 칸 「·아래」 「·위」(Model RANGE_LOW · RANGE_HIGH)
- Api 겹친 타입(SlLogic · SlField · SlOutsideValue) 지우고 Model · Eval 것을 씀 · Item 이 쓰는 SlOutside · SlOutsideAt 는 SlQuery 별칭으로 남김
- 시험 test_m01_sl_router.py 를 Model 모양으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:14:02 +09:00
eomsangdon 8e2d8d6bb3 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-01 13:14:00 +09:00
eomsangdon e00676aa1b Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-01 13:13:47 +09:00
eomsangdonandClaude Sonnet 5.5 eb9082dc30 feat(m01-sl): 표 상자 Table 완성 — 내 표 붙여넣기 · 범위 조건 · 빈칸 무관 · 선 수식 보존 · 시험
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 13:13:03 +09:00
eomsangdonandClaude Opus 5.5 817826c1af feat(m01-sl): 항목 상자 틀을 Item 노무 틀과 한 벌로
- addNode(item) = M01_SL_Item moneyLayout(노무)과 같은 칸 — B1 수량 · B2~B4 노무/재료/경비 단가(노무 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 노무비/재료비/경비 · B10 사용 수량 · 머리 줄 없음
- 출력 알약 단가 · 금액 · 노무비 · 재료비 · 경비 · 사용 수량 · node.ref = {"what":"노무"}
- 시험 14개(틀 칸 대조 더함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 13:10:49 +09:00
eomsangdon ab2bdc78df Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 13:09:13 +09:00
eomsangdon 66925bdfad Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-01 13:08:46 +09:00
eomsangdon 22430f20b5 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-01 13:08:30 +09:00
eomsangdonandClaude Sonnet 5.5 7bd6e8d7d1 feat(m01-sl): 시트 로직 표 상자 손잡이 Table 초안 — 품셈 표 가져오기 · 내 표 · 찾기 수식 짜기
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
2026-10-01 13:08:02 +09:00
eomsangdonandClaude Opus 5.5 ef0b4ae385 feat(m01-sl): 시트 로직 상자 이름 바꾸기 · 바깥 값 약속
- renameNode — 엔진 시트 이름 명령으로 시트 이름 · 그 시트를 가리키던 선 수식이 따라감 · 1~31 글자 · 못 쓰는 글자 · 같은 이름은 막고 까닭
- 상자 더하기 이름도 시트 이름 규칙대로 고쳐 만듦(못 쓰는 글자 → · · 31 글자)
- SlSource(서버 바깥 값 물음 한 줄) · SlField · SlOutside.id(상자 id!칸) · source · SlNode.source
- 표 범위 칸 약속 RANGE_LOW · RANGE_HIGH · rangeHead(이름) = 「이름·아래」 「이름·위」
- answersToValues — 서버 답(id · ok · value · rows) → 넣을 값 · 못 받은 것은 쉬운 말
- 시험 13개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 13:07:56 +09:00
eomsangdonandClaude Sonnet 5.5 7f98edac2c feat(m01-sl): 시트 로직 항목 상자 손잡이(Item) · 결과 칸(Result)
항목 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 틀 · 단가 바깥 값 자리 · 바탕 이음, 큰 합계 · 일위대가표 · 예시값 띠 · 빈 입력 · 오류.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 13:07:36 +09:00
eomsangdon bd7e6552cd Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-01 13:07:18 +09:00
eomsangdonandClaude Opus 5.5 35207e211d perf(m01-sl): Entry 가 SheetLogic 파일을 import() 로 늦게 불러옴 — 엔진이 M01 첫 묶음에 안 들어가게
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-01 13:06:29 +09:00
eomsangdon 29f7cafbda Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-01 13:06:19 +09:00
eomsangdon b1043526ab Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-01 13:06:17 +09:00
eomsangdon ae9388e96c Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-01 13:05:55 +09:00
eomsangdonandClaude Sonnet 5.5 50ecb5abed feat(M01-시트로직): 아래 시트 판 Panel 추가 - 시트 편집기 · 입력/출력 알약 · 받는 칸 잠금 · 틀 단추 · 한 줄 오류
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-10-01 13:05:55 +09:00
eomsangdonandClaude Opus 5.5 920783a9b0 fix(M01): 시트 로직 다이어그램 — 선을 바로 그림(크기 바뀌면 다시) · 끈 뒤 클릭 삼킴이 남지 않게 · 고르면 까닭 줄 지움(PLAN 35-5)
- SheetLogic 전부 검사 초록
- ORCA 견본(상자 7 · 선 8): 단계마다 x 16 · 299 · 582 · 866 · 1149 · 선 끝 알약에서 0.05px 안 · 띠 321 / 화면 1076
  · 끌어 잇기 → on.connect · 상자 몸통에 놓으면 같은 이름 자리 · 막히면 한 줄 까닭 · 선 고름 → Delete → on.disconnect
  · 상자 고르면 이어진 선 넷만 진하게 · 잇기 단추 길 · + 상자 → on.add · Delete → 물음 → on.remove · 밝음 · 어두움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 13:05:41 +09:00
eomsangdon 783b6c5a28 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-01 13:05:12 +09:00
eomsangdon e242183fed Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-01 13:04:58 +09:00
eomsangdonandClaude Opus 5.5 67e4c27658 feat(m01-sl): 시트 로직 Model · Eval 속 채움 · 시험
- 상자 더하기(종류별 틀 시트 · 칸 A 이름 · B 값 · C 설명) · 지우기 · 같은 이름은 「 (2)」
- 선 잇기 = 받는 입력 알약 칸에 「'시트'!칸」 참조 수식(여러 선은 +) · 끊기 = 그 참조 뺌 · 순환 · 같은 상자 · 두 번 · 없는 알약은 막고 까닭
- 알약 정하기 · 빼기(이름 · 칸이 바뀌면 선이 따라감)
- 바깥 값 칸 목록 · 넣기(표는 칸 덩어리) · 화면 계산 = 우리 스프레드시트 엔진 recalcWorkbook · 결과 노무비 · 재료비 · 경비 · 계
- 검사: 빈 입력 알약 · 안 받은 바깥 값 · 결과 상자 없음 / 선 없음 · 오류 칸 · 순환(#CYCLE!)
- 시험 resources/tester/test_m01_sl_model.py 10개(엔진 그대로 Node)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 13:04:15 +09:00
eomsangdon bf6976fab6 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-01 13:04:12 +09:00
eomsangdonandClaude Opus 5.5 687484bdc3 feat(m01-sl): 시트 로직 서버 길 — 목록 · 열기 · 저장(서버 다시 셈) · 지우기 · 바깥 값
- M01_SL_Router.py — /api/m01/sl/list · get · save · delete · outside · 자리 SheetLogic/_data(git 밖) · 키 SL-0001 꼴
- 저장 때 바깥 값(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과)을 마스터에서 다시 넣고 common_util_spreadsheet(Node)로 다시 셈 · 브라우저 계산값 버림
- 단가 · 장비 · 기존 로직은 /calc(Store.calc)와 같은 셈 · 표는 머리 줄 + 줄(범위는 「이름·아래」 「이름·위」)
- main.py 등록 한 문장(시스템 관리자 · 폴더째 지우면 이 줄도 지움)
- M01_SL_Api.ts 몸 — slList · slGet · slSave · slDelete · slOutside · slPlain(EasyCalc plainError)
- 시험 test_m01_sl_router.py — 저장 · 열기 · 목록 · 지우기 · 서버 다시 셈 · 바깥 값 · 403 · 마스터 사본 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-10-01 13:03:32 +09:00
eomsangdon 88d0a4ecd4 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-01 13:01:31 +09:00
eomsangdonandClaude Opus 5.5 7c1266bcb4 feat(M01): 시트 로직 위 가로 다이어그램 초안 — 상자 · 알약 · 선 · 끌어 잇기(PLAN 35-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
2026-10-01 13:01:29 +09:00
eomsangdon 9d98f1f2ad Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-10-01 13:00:52 +09:00
eomsangdon 62fa53e927 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-01 13:00:25 +09:00
564 changed files with 66727 additions and 25968 deletions
+4 -3
View File
@@ -73,10 +73,11 @@ config/spreadsheet_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
# 다만 아래 둘은 PC 마다 다른 값이라 나르면 안 됨 — 나르면 상대 PC 에서 어긋남.
docs/wiki/graphify-out/.graphify_python
docs/wiki/graphify-out/.graphify_incremental.json
# docs/wiki/graphify-out/.graphify_python
# docs/wiki/graphify-out/.graphify_incremental.json
# 캐시는 **PC 마다 다시 만들어지는 파생물**이라 git 으로 나르지 않는다(2026-09-10 사용자 확정).
# 까닭 — 코드 창에서 graphify 가 돌 때마다 이 폴더가 바뀌어, 위키 브랜치를 받을 때
# 「덮으면 네 것이 날아간다」로 **병합이 막혔다**. 4.3MB·443파일도 함께 가벼워진다.
docs/wiki/graphify-out/cache/
#docs/wiki/graphify-out/cache/
docs/
~$*
+24 -9
View File
@@ -59,9 +59,13 @@ export const STATE_REGISTRY = {
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"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 에 안 둠 */
@@ -297,6 +301,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs";
const PREFS_PUSH_DELAY_MS = 1500;
let applyingServerPrefs = false;
let prefsPushTimer: ReturnType<typeof setTimeout> | null = null;
/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */
let prefsPull: Promise<void> | null = null;
function localPrefs(): Record<string, string> {
const out: Record<string, string> = {};
@@ -318,17 +324,26 @@ function pushUiPrefs(): void {
if (prefsPushTimer) clearTimeout(prefsPushTimer);
prefsPushTimer = setTimeout(() => {
prefsPushTimer = null;
void fetch(PREFS_ENDPOINT, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prefs: localPrefs() }),
}).catch(() => undefined);
void (prefsPull ?? Promise.resolve())
.then(() =>
fetch(PREFS_ENDPOINT, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prefs: localPrefs() }),
}),
)
.catch(() => undefined);
}, PREFS_PUSH_DELAY_MS);
}
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */
export async function syncUiPrefs(): Promise<void> {
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */
export function syncUiPrefs(): Promise<void> {
prefsPull = pullUiPrefs();
return prefsPull;
}
async function pullUiPrefs(): Promise<void> {
let stored: Record<string, unknown> = {};
try {
const response = await fetch(PREFS_ENDPOINT, { credentials: "include" });
+12 -3
View File
@@ -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<HTMLElement>(":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);
},
};
+4
View File
@@ -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<HTMLElement>(".ui-workflow-overlay__body");
if (body) splitSidebarActions(body);
shared.panel = panel;
return panel;
}
+4
View File
@@ -11,6 +11,7 @@
import "./random_uuid_polyfill";
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import {
@@ -51,6 +52,9 @@ function bootstrap(): void {
// 2. 테마/언어 복원
initThemeAndLang();
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = "";
let shownHash = "";
+24 -2
View File
@@ -22,7 +22,9 @@ import { createCalcEngine } from "./spreadsheet_graph";
import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import type { ConditionOptions } from "./spreadsheet_condition";
import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
@@ -47,6 +49,7 @@ import type {
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
export type { LinkInfo } from "./spreadsheet_link_set";
export interface SpreadsheetOptions {
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
@@ -62,6 +65,8 @@ export interface SpreadsheetOptions {
/** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 ·
* 알약 막대만. 다른 화면 모양은 그대로 */
preview?: boolean;
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
conditions?: ConditionOptions;
}
export interface SpreadsheetHandle {
@@ -71,8 +76,11 @@ export interface SpreadsheetHandle {
setDoc(book: Workbook): void;
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
recalc(): CalcValues;
/** 알약 키 → 연결 칸 값(빈 칸 0) */
/** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */
getLinkedValues(): Record<string, CalcValue>;
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
* 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */
setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;
destroy(): void;
}
@@ -165,6 +173,10 @@ export function createSpreadsheet(
grid.render();
select(ctx.selection);
loadLinks();
if (opts.conditions && !readOnly)
void import("./spreadsheet_condition").then((m) => {
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
});
function notify(message: string): void {
toast.textContent = message;
@@ -277,10 +289,20 @@ export function createSpreadsheet(
for (const s of ctx.book.시트)
for (const l of s.연결 ?? []) {
const p = parseA1(l.칸);
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
const cell = s.칸[l.칸];
const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");
if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
}
return out;
},
setLink(key, cell, info, replace) {
if (readOnly) return false;
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace);
if (command === false) return false;
if (command) dispatch(command);
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
return true;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
@@ -0,0 +1,51 @@
.ss-cond {
min-width: min(46rem, 96vw);
}
.ss-cond-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) 0;
}
.ss-cond-label {
min-width: 5em;
font-weight: var(--font-weight-bold);
}
.ss-cond-pick {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
.ss-cond-cell,
.ss-cond-value {
width: 7em;
}
.ss-cond .ui-input.is-bad {
border-color: var(--color-danger);
border-style: dashed;
}
.ss-cond-note {
margin: 0 0 var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-warning);
background: var(--color-surface-raised);
}
.ss-cond-formula {
flex: 1 1 20em;
overflow-wrap: anywhere;
color: var(--color-text-secondary);
}
.ss-cond-foot {
margin-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
@@ -0,0 +1,362 @@
/* =============================================================================
* spreadsheet_condition.ts
* 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF)
* 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내
* 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로
* 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import { toA1 } from "./spreadsheet_address";
import {
COND_OPS,
CELL,
draftToFormula,
formulaToDraft,
type CondBranch,
type CondDraft,
type CondOp,
type CondOperand,
} from "./spreadsheet_condition_text";
import "./spreadsheet_condition.css";
/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */
export interface ConditionOptions {
valuesFor(a1: string): (string | number)[] | null;
pills?(): { name: string; cell: string }[];
}
export { COND_OPS, draftToFormula, formulaToDraft };
export type { CondBranch, CondDraft, CondOp, CondOperand };
/* ── 글 ─────────────────────────────────────────────────────────────────── */
const TEXT = {
Menu: ["조건 만들기", "Make a condition"],
Title: ["조건 만들기", "Make a condition"],
If: ["만약", "If"],
ElseIf: ["아니면 만약", "Else if"],
Then: ["→", "→"],
Otherwise: ["아니면 →", "Otherwise →"],
AddElseIf: ["+ 아니면 만약", "+ Else if"],
Remove: ["지움", "Remove"],
Make: ["만들기", "Make"],
Cancel: ["취소", "Cancel"],
Close: ["닫기", "Close"],
CellPick: ["칸 직접", "A cell"],
ValuePick: ["값", "A value"],
Cell: ["칸 주소", "Cell address"],
Value: ["값", "Value"],
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
Target: ["식이 들어갈 칸", "Formula goes into"],
AsIs: [
"이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침",
"This formula can't be shown as lines — edit the formula as it is",
],
} as const;
const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/* ── 줄 판 ───────────────────────────────────────────────────────────────── */
const OWN = "\u0001cell";
const OWN_VALUE = "\u0001value";
function select(
options: { value: string; text: string }[],
value: string,
onPick: (v: string) => void,
aria: string,
): HTMLSelectElement {
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
s.value = value;
s.addEventListener("change", () => onPick(s.value));
return s;
}
/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */
function cellPick(
opts: ConditionOptions,
value: string,
onChange: (cell: string) => void,
aria: string,
): HTMLElement {
const pills = opts.pills?.() ?? [];
const hit = pills.find((p) => p.cell === value);
const options = [
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
{ value: OWN, text: tc("CellPick") },
];
const box = el("span", { className: "ss-cond-pick" });
const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria);
box.append(sel);
if (!hit) {
const input = el("input", {
className: "ui-input ui-input--sm ss-cond-cell",
attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" },
});
input.value = value;
input.addEventListener("change", () => {
const m = CELL.exec(input.value.trim().toUpperCase());
input.classList.toggle("is-bad", !m);
input.title = m ? "" : tc("BadCell");
if (m) onChange(`${m[1]}${m[2]}`);
});
box.append(input);
}
return box;
}
/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */
function operandPick(
opts: ConditionOptions,
o: CondOperand,
hint: string | null,
onChange: (o: CondOperand) => void,
aria: string,
): HTMLElement {
const box = el("span", { className: "ss-cond-pick" });
const pills = opts.pills?.() ?? [];
const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell);
const options = [
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
{ value: OWN, text: tc("CellPick") },
{ value: OWN_VALUE, text: tc("ValuePick") },
];
const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN;
box.append(
select(
options,
mode,
(v) => {
if (v === OWN_VALUE) onChange({ kind: "value", value: 0 });
else if (v === OWN) onChange({ kind: "cell", cell: "B2" });
else onChange({ kind: "cell", cell: v });
},
aria,
),
);
if (o.kind === "cell" && !isPill) {
const input = el("input", {
className: "ui-input ui-input--sm ss-cond-cell",
attrs: { type: "text", "aria-label": tc("Cell") },
});
input.value = o.cell;
input.addEventListener("change", () => {
const m = CELL.exec(input.value.trim().toUpperCase());
input.classList.toggle("is-bad", !m);
if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` });
});
box.append(input);
}
if (o.kind === "value") {
const list = hint ? opts.valuesFor(hint) : null;
if (list && list.length) {
const key = (v: string | number): string => `${typeof v}:${v}`;
box.append(
select(
list.map((v) => ({ value: key(v), text: String(v) })),
key(o.value),
(k) => {
const hitv = list.find((v) => key(v) === k);
if (hitv !== undefined) onChange({ kind: "value", value: hitv });
},
tc("Value"),
),
);
} else {
const input = el("input", {
className: "ui-input ui-input--sm ss-cond-value",
attrs: { type: "text", "aria-label": tc("Value") },
});
input.value = String(o.value);
input.addEventListener("change", () => {
const t = input.value.trim();
onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t });
});
box.append(input);
}
}
return box;
}
function openJob(
ctx: SpreadsheetContext,
opts: ConditionOptions,
a1: string,
draft: CondDraft | null,
existing: string | null,
): void {
openModal({
title: `${tc("Title")} — ${a1}`,
closeLabel: tc("Close"),
dialogClass: "ss-cond",
mount: (body, close) => {
let d: CondDraft = draft ?? {
branches: [
{
left: "B1",
op: "=",
right: { kind: "value", value: "" },
then: { kind: "value", value: 0 },
},
],
otherwise: { kind: "value", value: 0 },
};
if (existing !== null && !draft) {
body.append(
el("p", { className: "ss-cond-note", text: tc("AsIs") }),
el("code", { className: "ss-cond-formula", text: `=${existing}` }),
);
return;
}
const draw = (): void => {
const set = (next: CondDraft): void => ((d = next), draw());
const rows = d.branches.map((b, i) => {
const edit = (patch: Partial<CondBranch>): void =>
set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
return el("div", {
className: "ss-cond-row",
children: [
el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }),
cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")),
select(
COND_OPS.map((o) => ({ value: o, text: o })),
b.op,
(v) => edit({ op: v as CondOp }),
"op",
),
operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")),
tc("Then"),
operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")),
d.branches.length > 1
? createButton({
label: tc("Remove"),
variant: "ghost",
size: "sm",
onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }),
})
: "",
],
});
});
const last = d.branches[d.branches.length - 1];
body.replaceChildren(
...rows,
el("div", {
className: "ss-cond-row",
children: [
createButton({
label: tc("AddElseIf"),
variant: "ghost",
size: "sm",
onClick: () =>
set({
...d,
branches: [
...d.branches,
{
left: last.left,
op: "=",
right: { kind: "value", value: "" },
then: { kind: "value", value: 0 },
},
],
}),
}),
],
}),
el("div", {
className: "ss-cond-row",
children: [
el("span", { className: "ss-cond-label", text: tc("Otherwise") }),
operandPick(
opts,
d.otherwise,
null,
(otherwise) => set({ ...d, otherwise }),
tc("Otherwise"),
),
],
}),
el("div", {
className: "ss-cond-row ss-cond-foot",
children: [
el("code", {
className: "ss-cond-formula",
text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`,
}),
createButton({
label: tc("Make"),
variant: "filled",
size: "sm",
onClick: () => {
const sheet = ctx.sheet();
const { 값: _v, ...keep } = sheet.칸[a1] ?? {};
ctx.dispatch({
종류: "칸",
시트: sheet.id,
칸: { [a1]: { ...keep, 식: draftToFormula(d) } },
});
close();
},
}),
createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }),
],
}),
);
};
draw();
},
});
}
/** 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음(계획서 39-9b).
* 칸 메뉴(`spreadsheet_menu.ts`)가 있으면 그 끝 한 줄 · 없으면(미리보기 편집기) 같은 공용 메뉴에 이 줄만 */
export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle {
const items = (r: number, c: number): Array<[string, () => void]> => {
if (ctx.readOnly) return [];
const a1 = toA1(r, c);
return [
[
tc("Menu"),
() => {
const existing = ctx.sheet().칸[a1]?.식 ?? null;
const draft = existing !== null ? formulaToDraft(existing) : null;
openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null);
},
],
];
};
if (ctx.cellMenuExtra) {
ctx.cellMenuExtra = items;
return { root: null, refresh() {}, destroy: () => (ctx.cellMenuExtra = () => []) };
}
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
const onContext = (e: MouseEvent): void => {
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit || hit.kind !== "cell") return;
const list = items(hit.r, hit.c);
if (!list.length) return;
e.preventDefault();
const box = ctx.root.getBoundingClientRect();
menu.open(e.clientX - box.left, e.clientY - box.top, list);
};
ctx.root.addEventListener("contextmenu", onContext);
return {
root: menu.element,
refresh() {},
destroy() {
ctx.root.removeEventListener("contextmenu", onContext);
menu.close();
menu.element.remove();
},
};
}
@@ -0,0 +1,121 @@
/* =============================================================================
* spreadsheet_condition_text.ts
* 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림
* ========================================================================== */
export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<";
export type CondOperand =
{ kind: "cell"; cell: string } | { kind: "value"; value: number | string };
export interface CondBranch {
/** 비교하는 칸 */
left: string;
op: CondOp;
right: CondOperand;
then: CondOperand;
}
export interface CondDraft {
branches: CondBranch[];
otherwise: CondOperand;
}
export const XL_OP: Record<CondOp, string> = {
"=": "=",
"≠": "<>",
"≥": ">=",
"≤": "<=",
">": ">",
"<": "<",
};
export const FROM_XL: Record<string, CondOp> = {
"=": "=",
"<>": "≠",
">=": "≥",
"<=": "≤",
">": ">",
"<": "<",
};
export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"];
export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/;
/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */
export const operandText = (o: CondOperand): string =>
o.kind === "cell"
? o.cell
: typeof o.value === "number"
? String(o.value)
: `"${o.value.replace(/"/g, '""')}"`;
/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */
export function draftToFormula(d: CondDraft): string {
return d.branches.reduceRight(
(rest, b) =>
`IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`,
operandText(d.otherwise),
);
}
/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */
export function splitArgs(s: string): string[] {
const out: string[] = [];
let depth = 0;
let quote = false;
let from = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"') quote = !quote;
else if (quote) continue;
else if (ch === "(") depth++;
else if (ch === ")") depth--;
else if (ch === "," && depth === 0) {
out.push(s.slice(from, i));
from = i + 1;
}
}
out.push(s.slice(from));
return out;
}
export function parseOperand(raw: string): CondOperand | null {
const t = raw.trim();
const cell = CELL.exec(t);
if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` };
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) };
const q = /^"((?:[^"]|"")*)"$/.exec(t);
return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null;
}
/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */
export function ifBody(f: string): string | null {
const t = f.trim();
if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null;
let depth = 0;
let quote = false;
for (let i = 2; i < t.length; i++) {
if (t[i] === '"') quote = !quote;
else if (quote) continue;
else if (t[i] === "(") depth++;
else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null;
}
return t.slice(3, -1);
}
/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */
export function formulaToDraft(formula: string): CondDraft | null {
const body = ifBody(formula);
if (body === null) return null;
const args = splitArgs(body);
if (args.length !== 3) return null;
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]);
if (!m) return null;
const left = parseOperand(m[1]);
const right = parseOperand(m[3]);
const then = parseOperand(args[1]);
if (!left || left.kind !== "cell" || !right || !then) return null;
const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then };
const plain = parseOperand(args[2]);
if (plain) return { branches: [branch], otherwise: plain };
const rest = formulaToDraft(args[2]);
return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null;
}
@@ -0,0 +1,65 @@
/* =============================================================================
* spreadsheet_link_ask.ts (주인 D)
* 같은 칸에 이미 다른 알약 — [함께 잇기](기본 · Enter) · [바꾸기] · [취소](Esc) 물음(40 마무리).
* 알약 누름 · 끌어 놓기(`spreadsheet_links`) · M02 칸 지정 모달 · 엑셀 칸 고르기가 같이 씀 ·
* 모양은 공용 확인창(`ui-confirm`) 그대로.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
const TEXT = {
ask: "{cell} 칸에 이미 {names} — 이 알약도 함께 이을까?",
together: "함께 잇기",
replace: "바꾸기",
cancel: "취소",
};
/** 「함께」 = 그 칸 다른 알약 그대로 · 「바꾸기」 = 다른 알약을 뺌 · null = 그만둠 */
export type ShareChoice = "함께" | "바꾸기" | null;
export function askShare(cell: string, names: string[]): Promise<ShareChoice> {
return new Promise((resolve) => {
const overlay = el("div", { className: "ui-confirm" });
const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리
const done = (choice: ShareChoice): void => {
overlay.remove();
if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true });
resolve(choice);
};
const together = createButton({
label: TEXT.together,
variant: "filled",
onClick: () => done("함께"),
});
overlay.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape") return;
ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음
done(null);
});
const message = TEXT.ask
.replace("{cell}", cell)
.replace("{names}", names.map((n) => `「${n}」`).join(" · "));
overlay.append(
el("div", {
className: "ui-confirm__panel",
children: [
el("p", { className: "ui-confirm__message", text: message }),
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }),
createButton({
label: TEXT.replace,
variant: "ghost",
onClick: () => done("바꾸기"),
}),
together,
],
}),
],
}),
);
document.body.append(overlay);
together.focus(); // Enter = 함께 잇기(기본)
});
}
@@ -0,0 +1,38 @@
/* =============================================================================
* spreadsheet_link_color.ts (주인 D)
* 이은 알약 색 — 알약 키로 정해지는 색상(hue). 알약 · 이은 칸 배경 · 칸 세모가 같은 색(PLAN 40-3).
* 칸 24 개(30° 바퀴 12 + 15° 어긋난 바퀴 12) — 키 해시로 첫 자리 · 겹치면 다음 빈 자리(같은 문서 안에서 서로 다르게).
* 밝기 · 채도는 테마마다 CSS(`spreadsheet_links.css` `--ss-link-l`) · 여기는 색상만 · DOM 모름(시험이 Node 로 부름).
* ========================================================================== */
const SLOTS = 24;
/** FNV-1a 32비트 — 키 글자로 늘 같은 수 */
function hash(text: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
/** 자리 → 색상(°) — 0~11 = 30° 간격 · 12~23 = 그 사이(15° 어긋남) */
const hueOf = (slot: number): number => (slot % 12) * 30 + (slot >= 12 ? 15 : 0);
/** 이은 알약 키들 → 키마다 색상(°) — 키 차례와 무관(정렬해 자리 배정) · 24 개 넘으면 다시 돎 */
export function linkHues(keys: Iterable<string>): Map<string, number> {
const out = new Map<string, number>();
const used = new Set<number>();
for (const key of [...new Set(keys)].sort()) {
if (used.size >= SLOTS) used.clear();
const start = hash(key) % 12;
// 첫 바퀴(30°)를 다 쓴 뒤에야 둘째 바퀴 — 적을 때 서로 30° 넘게 벌어짐
let slot = start;
for (let i = 1; i < SLOTS && used.has(slot); i += 1)
slot = (i < 12 ? 0 : 12) + ((start + i) % 12);
used.add(slot);
out.set(key, hueOf(slot));
}
return out;
}
@@ -0,0 +1,91 @@
/* =============================================================================
* spreadsheet_link_set.ts (주인 D)
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나에 알약 여럿(같은 수량을 여러
* 로직이 씀 · 40 마무리) — `replace` 면 그 칸의 다른 알약을 뺌 · 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
* ========================================================================== */
import { parseA1, toA1 } from "./spreadsheet_address";
import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types";
/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */
export interface LinkInfo {
이름?: string;
단위?: string;
자재?: LinkMaterial;
}
/** 칸 주소 맞춤(「 b6 」 → 「B6」) — 틀리면 null */
export function normA1(cell: string): string | null {
const at = parseA1(cell.trim().toUpperCase());
return at ? toA1(at.r, at.c) : null;
}
/** 그 시트 그 칸에 이은 다른 알약(`key` 빼고 · 이은 차례) — 물음 「함께 잇기 · 바꾸기」 에 씀 */
export function linksAt(book: Workbook, sheetId: string, cell: string, key: string): CellLink[] {
const a1 = normA1(cell);
const sheet = book.시트.find((s) => s.id === sheetId);
return (sheet?.연결 ?? []).filter((l) => l.칸 === a1 && l.키 !== key);
}
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false ·
* `replace` = 그 칸의 다른 알약 연결을 뺌(아니면 함께 둠) */
export function linkCommand(
book: Workbook,
sheetId: string,
key: string,
cell: string | null,
info: LinkInfo = {},
replace = false,
): Command | null | false {
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
if (cell !== null && !at) return false;
const a1 = at ? toA1(at.r, at.c) : null;
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
const home = book.시트.find((s) => s.id === sheetId);
if (a1 && !home) return false;
const others = a1 && replace ? linksAt(book, sheetId, a1, key).length : 0;
if (a1 && !others && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
if (one) {
const 이름 = info.이름 ?? old?.이름;
const 단위 = info.단위 ?? old?.단위;
const 자재 = info.자재 ?? old?.자재;
if (이름) one.이름 = 이름;
if (단위) one.단위 = 단위;
if (자재) one.자재 = 자재;
}
const commands: Command[] = [];
for (const s of book.시트) {
const was = s.연결 ?? [];
const rest = was.filter((l) => l.키 !== key && !(replace && s === home && a1 && l.칸 === a1));
const next = s === home && one ? [...rest, one] : rest;
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
const copy = structuredClone(s);
if (next.length) copy.연결 = next;
else delete copy.연결;
commands.push({ 종류: "시트통째", 시트: copy });
}
if (!commands.length) return null;
return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands };
}
/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 ·
* 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */
export function linkAreaCell<T extends Workbook>(
book: T,
key: string,
cell: string | null,
info: LinkInfo = {},
replace = false,
): T | null {
const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0];
if (!sheet) return null;
const command = linkCommand(book, sheet.id, key, cell, info, replace);
if (command === false) return null;
const next = structuredClone(book);
const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command];
for (const c of list)
if (c.종류 === "시트통째") next.시트 = next.시트.map((s) => (s.id === c.시트.id ? c.시트 : s));
return next;
}
+135 -7
View File
@@ -33,14 +33,55 @@
color: var(--color-accent);
}
/* 연결됨 — 흐림(연결 현황) */
.ss-link.is-linked {
opacity: 0.5;
border-style: dashed;
/* 이은 알약 색(40-3) — 색상 = 알약 키(`--ss-link-h` · spreadsheet_link_color) · 밝기 · 옅기 = 테마 */
.ss {
--ss-link-s: 70%;
--ss-link-l: 31%;
--ss-link-fill: 0.16;
}
.ss-link.is-linked:hover {
opacity: 0.8;
:root[data-theme="dark"] .ss {
--ss-link-l: 66%;
--ss-link-fill: 0.26;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .ss {
--ss-link-l: 66%;
--ss-link-fill: 0.26;
}
}
/* 연결됨 — 알약 색 테두리 · 왼쪽 띠 · 옅은 배경(글은 본문 색 그대로 · 읽힘) */
.ss-link.is-linked {
border-color: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill));
box-shadow: inset 4px 0 0 hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
}
.ss-link.is-linked code {
color: inherit;
}
.ss-link[draggable="true"] {
cursor: grab;
}
/* 이은 칸 — 알약 색 옅게(칸 글 위 · 누름은 칸으로 지나감) */
.ss-link-cell {
position: absolute;
z-index: 2;
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill));
pointer-events: none;
}
/* 알약을 끌 때 놓일 칸 */
.ss-link-drop {
position: absolute;
z-index: 4;
box-sizing: border-box;
border: 2px dashed var(--color-accent);
pointer-events: none;
}
.ss-link--add {
@@ -55,7 +96,7 @@
width: 0;
height: 0;
border-top: 6px solid transparent;
border-left: 6px solid var(--color-accent);
border-left: 6px solid hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
pointer-events: none;
}
@@ -136,3 +177,90 @@
opacity: 0;
}
}
/* 칸 하나에 알약 여럿 — 오른위 작은 수(먼저 이은 알약 색) */
.ss-link-count {
position: absolute;
z-index: 3;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
color: var(--color-canvas);
font-size: 10px;
font-weight: 700;
line-height: 14px;
text-align: center;
transform: translate(-100%, 0);
pointer-events: none;
}
/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */
.ss-link.is-here {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
font-weight: 600;
}
.ss-link.is-pop {
animation: ss-link-pop 1.2s ease-out 1 both;
}
@keyframes ss-link-pop {
0%,
40%,
80% {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent);
}
20%,
60%,
100% {
box-shadow: 0 0 0 0 transparent;
}
}
/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */
.ss-link.is-orphan {
border: 1px dashed var(--color-danger);
color: var(--color-danger);
}
.ss-link__x {
margin-left: 2px;
padding: 0 4px;
border-radius: 50%;
font-weight: 700;
cursor: pointer;
}
.ss-link__x:hover {
background: color-mix(in srgb, var(--color-danger) 20%, transparent);
}
.ss-link-cell.is-orphan {
outline: 1px dashed var(--color-danger);
outline-offset: -2px;
}
/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */
.ss-link-range {
position: absolute;
z-index: 3;
box-sizing: border-box;
border: 2px dashed var(--color-accent);
pointer-events: none;
}
.ss-link-range__tag {
position: absolute;
left: -2px;
bottom: 100%;
padding: 0 4px;
border-radius: 3px 3px 0 0;
background: var(--color-accent);
color: var(--color-canvas);
font-size: 10px;
line-height: 14px;
white-space: nowrap;
}
+188 -26
View File
@@ -1,8 +1,11 @@
/* =============================================================================
* spreadsheet_links.ts (주인 D)
* 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 —
* 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는
* 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 ·
* 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] ·
* 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
@@ -12,8 +15,11 @@
import "./spreadsheet_links.css";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address";
import { applyCommand } from "./spreadsheet_commands";
import { askShare } from "./spreadsheet_link_ask";
import { linkHues } from "./spreadsheet_link_color";
import { linkCommand, linksAt } from "./spreadsheet_link_set";
import type { slotOf } from "./spreadsheet_editor";
import { shiftSpan } from "./spreadsheet_refshift";
import { selectCell } from "./spreadsheet_selection";
@@ -47,6 +53,11 @@ export interface LinkOptions {
onItemsChange?: (항목: LinkItem[]) => void;
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
/** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만
* 목록에 없는 연결 알약에 ⚠ + [×] */
확정?: boolean;
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */
표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[];
}
export interface Links extends PartHandle {
@@ -55,8 +66,8 @@ export interface Links extends PartHandle {
}
const TEXT = {
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
free: "칸을 고른 채 누르면 이 칸에 연결",
bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기",
free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결",
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
unlink: "연결 풀기",
remove: "목록에서 빼기",
@@ -68,6 +79,8 @@ const TEXT = {
fail: "자재 목록을 못 받음",
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
dup: "이미 목록에 있는 자재",
orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음",
orphanX: "연결 풀기",
};
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
@@ -136,7 +149,12 @@ export function mountLinks(
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
const items: LinkItem[] = opts ? [...opts.항목] : [];
let dots: HTMLElement[] = [];
/** 끄는 중인 알약 · 놓을 칸 표시 */
let dragging: LinkItem | null = null;
const dropMark = el("div", { className: "ss-link-drop" });
let pick: HTMLElement | null = null;
/** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */
let lastAt = "";
/** 키 → 연결(시트 · 줄) */
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
@@ -153,15 +171,22 @@ export function mountLinks(
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
function link(item: LinkItem): void {
/** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */
async function link(item: LinkItem, at?: string): Promise<void> {
if (ctx.readOnly) return;
const sheet = ctx.sheet();
const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c);
const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키);
const one: CellLink = { 키: item.키, 이름: item.이름, 칸 };
if (item.단위) one.단위 = item.단위;
if (item.자재) one.자재 = item.자재;
put(sheet, [...rest, one]);
const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c);
const others = linksAt(ctx.book, sheet.id, 칸, item.키);
const choice = others.length
? await askShare(
칸,
others.map((l) => l.이름 ?? l.키),
)
: "함께";
if (!choice) return back();
const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 };
const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기");
if (command) ctx.dispatch(command);
back();
}
@@ -184,30 +209,63 @@ export function mountLinks(
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) {
function pill(
item: LinkItem,
hit: { sheet: Sheet; link: CellLink } | undefined,
hue: number | undefined,
orphan: boolean,
here: string,
) {
const where = hit
? hit.sheet.id === ctx.selection.시트
? hit.link.칸
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
: "";
const b = el("button", {
className: hit ? "ss-link is-linked" : "ss-link",
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${
here ? ` is-here${here === "new" ? " is-pop" : ""}` : ""
}`,
attrs: {
type: "button",
title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free,
"data-key": item.키,
},
children: [
el("span", { text: item.이름 }),
el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }),
...(item.단위 ? [el("small", { text: item.단위 })] : []),
...(where ? [el("code", { text: where })] : []),
],
});
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
const unlink = (): void => {
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
if (orphan && !ctx.readOnly) {
const x = el("span", {
className: "ss-link__x",
text: "×",
attrs: { role: "button", title: TEXT.orphanX },
});
x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back()));
b.append(x);
}
b.draggable = !ctx.readOnly;
b.addEventListener("dragstart", (e) => {
dragging = item;
e.dataTransfer?.setData("text/plain", item.키);
if (e.dataTransfer) e.dataTransfer.effectAllowed = "link";
});
b.addEventListener("dragend", () => {
dragging = null;
dropMark.remove();
});
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
function unlink(): void {
if (hit)
put(
hit.sheet,
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
);
};
}
b.addEventListener("contextmenu", (e) => {
e.preventDefault();
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
@@ -274,7 +332,18 @@ export function mountLinks(
? ofMaterial(link.자재)
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
);
const pills = shown.map((item) => pill(item, map.get(item.키)));
const hues = linkHues(map.keys());
const listed = new Set(items.map((i) => i.키));
const sure = opts.모드 === "입력" && opts.확정 === true;
// 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new")
const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`;
const fresh = at !== lastAt;
lastAt = at;
const pills = shown.map((item) => {
const hit = map.get(item.키);
const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : "";
return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here);
});
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
const more = el("button", {
className: "ss-link ss-link--add",
@@ -285,25 +354,108 @@ export function mountLinks(
pills.push(more);
}
bar.replaceChildren(...pills);
if (fresh)
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}
/** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
function placeDots(): void {
for (const d of dots) d.remove();
dots = [];
for (const l of ctx.sheet().연결 ?? []) {
const p = parseA1(l.칸);
const hues = linkHues(found().keys());
const listed = new Set(items.map((i) => i.키));
const sure = opts?.모드 === "입력" && opts.확정 === true;
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
const byCell = new Map<string, CellLink[]>();
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
for (const [a1, list] of byCell) {
const p = parseA1(a1);
if (!p) continue;
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) continue; // 숨긴 행열
const first = list[0];
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
const dot = el("div", { className: "ss-link-dot" });
dot.style.left = `${box.x}px`;
dot.style.top = `${box.y + box.h - 7}px`;
layer.append(dot);
dots.push(dot);
const marks = [tint, dot];
if (sure && !listed.has(first.키)) tint.classList.add("is-orphan");
if (list.length > 1) {
const count = el("div", { className: "ss-link-count", text: String(list.length) });
count.style.left = `${box.x + box.w}px`;
count.style.top = `${box.y}px`;
marks.push(count);
}
for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0));
layer.append(...marks);
dots.push(...marks);
}
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
for (const mark of opts?.표시범위 ?? []) {
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
if (!g) continue;
const { layer, box } = slot(ctx, g.r0, g.c0);
const end = slot(ctx, g.r1, g.c1).box;
if (!box.w || !box.h) continue;
const frame = el("div", {
className: "ss-link-range",
attrs: { "data-range": mark.범위 },
children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })],
});
frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`;
layer.append(frame);
dots.push(frame);
}
}
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
let tipped = false;
const onHover = (e: MouseEvent): void => {
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null;
const names = a1
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
: [];
if (hit && hit.r >= 0 && hit.c >= 0)
for (const m of opts?.표시범위 ?? []) {
const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null;
if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름);
}
if (names.length) ctx.grid.root.title = names.join(" · ");
else if (tipped) ctx.grid.root.removeAttribute("title");
tipped = names.length > 0;
};
// ── 알약 끌어 칸에 놓기(40-3) ──────────────────────────────────────────
/** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */
function cellAt(e: DragEvent): { r: number; c: number } | null {
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
return hit && hit.r >= 0 && hit.c >= 0 ? { r: hit.r, c: hit.c } : null;
}
const onDragOver = (e: DragEvent): void => {
const at = dragging && !ctx.readOnly ? cellAt(e) : null;
if (!at) return void dropMark.remove();
e.preventDefault(); // 놓을 수 있음
if (e.dataTransfer) e.dataTransfer.dropEffect = "link";
const { layer, box } = slot(ctx, at.r, at.c);
dropMark.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
if (dropMark.parentElement !== layer) layer.append(dropMark);
};
const onDragLeave = (e: DragEvent): void => {
if (!ctx.grid.root.contains(e.relatedTarget as Node | null)) dropMark.remove();
};
const onDrop = async (e: DragEvent): Promise<void> => {
const item = dragging;
const at = item && !ctx.readOnly ? cellAt(e) : null;
dropMark.remove();
dragging = null;
if (!item || !at) return;
e.preventDefault();
ctx.select(selectCell(ctx.sheet(), at.r, at.c), true);
await link(item, toA1(at.r, at.c));
};
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
function closePick(): void {
pick?.remove();
@@ -384,6 +536,11 @@ export function mountLinks(
const onZoom = (): void => placeDots();
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
const onDropEvent = (e: DragEvent): void => void onDrop(e);
ctx.grid.root.addEventListener("dragover", onDragOver);
ctx.grid.root.addEventListener("dragleave", onDragLeave);
ctx.grid.root.addEventListener("drop", onDropEvent);
ctx.grid.root.addEventListener("mousemove", onHover);
return {
root: opts ? bar : null,
@@ -394,6 +551,11 @@ export function mountLinks(
},
destroy() {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
ctx.grid.root.removeEventListener("dragover", onDragOver);
ctx.grid.root.removeEventListener("dragleave", onDragLeave);
ctx.grid.root.removeEventListener("drop", onDropEvent);
ctx.grid.root.removeEventListener("mousemove", onHover);
dropMark.remove();
closePick();
for (const d of dots) d.remove();
menu.element.remove();
@@ -32,6 +32,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
ctx.cellMenuExtra ??= () => []; // 칸 메뉴 있음 — 조건 만들기가 이 끝에 붙음
function isMerged(range: CellRange): boolean {
return (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
@@ -106,6 +107,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
...(ctx.cellMenuExtra?.(r, c) ?? []),
];
}
@@ -93,6 +93,7 @@
}
.ss-statusbar {
position: relative; /* 통계 고르기 메뉴(공용 `.ui-ctx-menu` · absolute)의 기준 */
display: flex;
align-items: center;
gap: var(--spacing-8);
+1 -33
View File
@@ -241,39 +241,7 @@
cursor: pointer;
}
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */
.ss__context-menu {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 160px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss__context-menu[hidden] {
display: none;
}
.ss__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-body);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.ss__context-menu-item:hover {
background: var(--color-surface-sunken, var(--color-mist-violet));
}
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) — 모양 · 자리는 공용 `.ui-ctx-menu` ── */
.ss__context-menu-item.is-danger {
color: var(--color-danger);
@@ -109,6 +109,9 @@ export interface SpreadsheetContext {
showSheet(sheetId: string): void;
grid: GridHandle;
editor: EditorHandle;
/** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) ·
* 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */
cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>;
}
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
+1 -5
View File
@@ -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;
}
+3 -1
View File
@@ -17,7 +17,7 @@ import {
API_SURFACE_BUILD_TIMEOUT_MS,
API_TIMEOUT_MS,
} from "@config/config_frontend";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
export interface SurfaceAnalyzeRequest {
@@ -494,6 +494,8 @@ export interface DetailPipePoint {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
group_items?: GroupItems | null;
}
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
@@ -23,6 +23,7 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_pipes import (
@@ -30,6 +31,7 @@ from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_USER,
PipePoint,
carry_facility_attributes,
carry_indices,
clear_pipe_points,
load_pipe_points,
parse_pipe_points,
@@ -64,14 +66,40 @@ def _build(
if points:
_retag(detail.pipes, points)
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
fresh = [
pick is None
for pick in carry_indices(
[point.chainage_m for point in rebuilt], [point.chainage_m for point in points or []]
)
]
# 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다
# (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교).
carried = carry_facility_attributes(rebuilt, points or [])
# 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다.
_apply_recommendations(detail, carried)
_apply_new_defaults([point for point, new in zip(carried, fresh) if new])
return detail, carried
def _apply_new_defaults(points: list[PipePoint]) -> None:
"""새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김).
시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로
(옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05).
"""
types = structure_type_map()
for point in points:
definition = types.get(point.facility)
if definition is None:
continue
options = dict(point.options or {})
for field in definition.options:
if field.key not in options and field.default not in (None, ""):
options[field.key] = field.default
point.options = options or None
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
@@ -585,7 +585,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
event.clientX - rect.left,
event.clientY - rect.top,
);
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다.
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음).
if (opened) event.preventDefault();
});
+1 -28
View File
@@ -87,37 +87,10 @@
box-shadow: inset 0 0 0 1px var(--b04-layer-color);
}
/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */
/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치 · 모양은 공용 `.ui-ctx-menu`. */
.b04-map__context-menu {
position: absolute;
z-index: 6;
display: flex;
flex-direction: column;
min-width: 140px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
}
.b04-map__context-menu[hidden] {
display: none;
}
.b04-map__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
border: 0;
border-radius: var(--radius-cards);
background: transparent;
color: var(--color-text-body);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.b04-map__context-menu-item:hover {
background: var(--color-surface-sunken);
}
.b04-map__viewport {
+37 -3
View File
@@ -12,8 +12,15 @@
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import {
type DefinedRule,
type DefinedShape,
type DefinedSpot,
type StructureDefinition,
withDefinition,
} from "./B05_Profile_UI_Structures_Define";
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
export type StructurePlacement = "point" | "interval" | "site";
@@ -27,7 +34,7 @@ export interface StructureOptionField {
default: string | number | null;
/** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */
required?: boolean;
/** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */
/** 거짓 = 「안 씀」(48 정의 틀 3장) — 폼에 안 그림 · 옛 값은 그대로 남음. */
enabled?: boolean;
/** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다
* (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */
@@ -77,6 +84,27 @@ export interface StructureType {
design_owner: string | null;
reference_only: boolean;
enabled: boolean;
/** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */
groups?: StructureOptionGroup[];
/** 구조물군 이름(48 정의 `구조물군`) — 없으면 화면 기본 이름. */
group_name?: string;
/** 기준 측점 칸 보임(48 정의 `종류.기준측점`) — false 일 때만 가림. */
show_anchor?: boolean;
/** 손코딩 특수 동작 이름(48 정의 `종류.동작`) — 그 동작이 붙은 칸 · 폼을 그대로 살림. */
behaviors?: string[];
/** 옵션 키 → 배치 칸(48 정의 `배치`). */
spots?: Record<string, DefinedSpot> | null;
/** 상위 → 하위 규칙(48 정의 `규칙` 중 이 종류 것). */
rules?: DefinedRule[] | null;
}
/** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */
export interface StructureOptionGroup {
id: string;
name: string;
options: string[];
/** 그룹이 받을 형상 칸(48 정의 그룹 `형상`) — 길이 · 높이 · 전 · 후 받기 켬끔 + 뜻. */
shape?: DefinedShape;
}
export interface StructureInstance {
@@ -94,6 +122,8 @@ export interface StructureInstance {
geometry: Record<string, unknown> | null;
/** 이 구조물이 뜻하는 구조물 집계표 항목(21-1) — 없으면 null(미지정 · 집계표에 안 들어감). */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
group_items?: GroupItems | null;
}
interface StructureTypesResponse {
@@ -170,10 +200,14 @@ let typesCache: Promise<StructureType[]> | null = null;
*/
export function fetchStructureTypes(includeDisabled = false): Promise<StructureType[]> {
if (!typesCache) {
// 구조물 정의(48) 덧칸 — 못 받으면 옛 화면 그대로(정의 없음 = 지금 차례 · 2열)
const definition = requestJson<StructureDefinition>("/m02/structure-definition", {
method: "GET",
}).catch(() => null);
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
method: "GET",
})
.then((payload) => payload.types)
.then(async (payload) => withDefinition(payload.types, await definition))
.catch((error) => {
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
throw error;
@@ -12,9 +12,13 @@ import os
import uuid
from typing import Any, Iterable
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map
from B05_Profile.B05_Profile_Structures_Schema import (
StructureInstance,
definition_version,
structure_type_map,
)
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete.M02_MasterTemplete_Options import hidden_keys
from M02_MasterTemplete.StructOptions.M02_SO_Options import hidden_keys
STRUCTURES_FILE_NAME = "structures.json"
_STAGE_DIR = os.path.join("B05_Profile", "route")
@@ -125,6 +129,8 @@ def save_structures(
"revision": revision,
"structures": [item.model_dump(mode="json") for item in items],
"migrated_legacy": sorted(history),
# 저장 때 구조물 정의 판(PLAN 48-1) — 뒤에 정의가 바뀌어도 이 값이 어느 정의로 적혔는지 앎
"definition_version": definition_version(),
}
atomic_write_json(structures_file_path(project_root), payload)
return revision
@@ -192,12 +198,15 @@ def _validate_options(item: StructureInstance, definition, hidden: set[str] = fr
for key, value in item.options.items():
option = allowed.get(key)
if option is None:
raise ValueError(f"{definition.name}에 정의되지 않은 옵션입니다: {key}")
# 지금 정의에 없는 키 = 지난 정의 값 — 버리지 않고 그대로 남김(PLAN 48-0 ② · 오류 없음)
continue
if option.input == "number":
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{definition.name}의 {option.label}은(는) 숫자여야 합니다.")
if not (value == value and abs(value) != float("inf")) or value < 0:
raise ValueError(f"{definition.name}의 {option.label}은(는) 0 이상이어야 합니다.")
elif option.input == "bool" and not isinstance(value, bool):
raise ValueError(f"{definition.name}의 {option.label}은(는) 예 · 아니오여야 합니다.")
elif option.input == "select" and option.choices and str(value) not in option.choices:
raise ValueError(f"{definition.name}의 {option.label} 값이 선택지에 없습니다: {value}")
for option in definition.options:
+1 -1
View File
@@ -42,7 +42,7 @@ from common_util.common_util_drainage_pipes import (
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
logger = logging.getLogger(__name__)
+48 -13
View File
@@ -1,13 +1,15 @@
"""B05 구조물 타입 레지스트리·인스턴스 검증 모델.
구조물 타입 정의는 코드가 아니라 데이터(`B05_Profile_Structure_Types.json`)다. 리스트가
바뀌어도 코드를 고치지 않도록 분리했고, 프론트는 `GET /structure-types`로 같은 정본을 받는다.
구조물 타입 정의는 코드가 아니라 데이터(M02 마스터 `resources/master_template/구조물정의.json`
`types` · PLAN 48-1)다. 리스트가 바뀌어도 코드를 고치지 않도록 분리했고, 프론트는
`GET /structure-types`로 같은 정본을 받는다. 정의 틀 = `docs/raw/M02_구조물옵션정의/정의_틀.md`.
인스턴스 정본은 `B05_Profile/route/structures.json` **하나뿐**이다(2026-08-16 사용자 확정).
DB에는 참조 메타만 남긴다 — 두 곳에 같은 값을 두면 어긋난 쪽이 화면에 뜬다(관 매설 지점의
"복원 유령" 전례).
"""
import copy
import json
import os
from functools import lru_cache
@@ -15,14 +17,18 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from common_util.common_util_summary_item import parse_summary_item
from common_util.common_util_summary_item import parse_group_items, parse_summary_item
# 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적).
STRUCTURE_PLACEMENTS = ("point", "interval", "site")
STRUCTURE_SIDES = ("left", "right", "center", "cross")
_REGISTRY_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "B05_Profile_Structure_Types.json"
# 시험은 사본으로 바꿈 — 읽기 캐시는 (경로 · 고친 시각 · 크기)로 갈려 저장 즉시 새로 읽음
DEFINITION_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"resources",
"master_template",
"구조물정의.json",
)
Placement = Literal["point", "interval", "site"]
@@ -35,7 +41,7 @@ class StructureOptionField(BaseModel):
key: str
label: str
input: Literal["select", "number", "text"]
input: Literal["select", "number", "text", "bool"]
choices: list[str] = Field(default_factory=list)
unit: str | None = None
default: Any = None
@@ -99,14 +105,37 @@ class StructureType(BaseModel):
enabled: bool = True
@lru_cache(maxsize=1)
def load_structure_types() -> tuple[StructureType, ...]:
"""레지스트리 정본을 읽어 타입 목록을 돌려준다(프로세스 수명 동안 1회 로드)."""
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
payload = json.load(handle)
def _stamp() -> tuple[str, int, int]:
stat = os.stat(DEFINITION_PATH)
return DEFINITION_PATH, stat.st_mtime_ns, stat.st_size
@lru_cache(maxsize=4)
def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
@lru_cache(maxsize=4)
def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]:
payload = _read_definition(path, mtime, size)
return tuple(StructureType.model_validate(item) for item in payload["types"])
def load_definition() -> dict[str, Any]:
"""구조물 정의 한 벌(사본) — `{판, types, 표시, 그룹, 종류, 배치, 규칙, 동작, …}`."""
return copy.deepcopy(_read_definition(*_stamp()))
def definition_version() -> int:
return int(_read_definition(*_stamp()).get("판", 1))
def load_structure_types() -> tuple[StructureType, ...]:
"""정의 정본을 읽어 타입 목록을 돌려준다(파일이 바뀔 때만 다시 읽음)."""
return _read_types(*_stamp())
def structure_type_map() -> dict[str, StructureType]:
return {item.type_id: item for item in load_structure_types()}
@@ -132,8 +161,7 @@ def station_planting_labels() -> set[str]:
def registry_schema_version() -> int:
with open(_REGISTRY_PATH, encoding="utf-8") as handle:
return int(json.load(handle).get("schema_version", 1))
return int(_read_definition(*_stamp()).get("schema_version", 1))
class StructureInstance(BaseModel):
@@ -160,12 +188,19 @@ class StructureInstance(BaseModel):
geometry: dict[str, Any] | None = None
# 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷
summary_item: dict[str, Any] | None = None
# 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item`
group_items: dict[str, Any] | None = None
@field_validator("summary_item")
@classmethod
def validate_summary_item(cls, value: Any) -> dict[str, Any] | None:
return parse_summary_item(value)
@field_validator("group_items")
@classmethod
def validate_group_items(cls, value: Any) -> dict[str, Any] | None:
return parse_group_items(value)
@model_validator(mode="after")
def validate_placement_fields(self) -> "StructureInstance":
if self.placement == "interval":
+33 -55
View File
@@ -53,13 +53,17 @@ import {
optionalSelect,
OUTLET_REVET_KEYS,
putNumber,
REVET_COMMON_DEFAULTS,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import {
hasLinks,
linkFields,
type GroupItems,
type SummaryItem,
} from "../common_util/common_util_summary_item";
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden";
@@ -74,11 +78,13 @@ export interface FacilityAttributes {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */
group_items?: GroupItems | null;
}
/** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */
function hasExtras(attributes: FacilityAttributes): boolean {
if (attributes.summary_item) return true; // 집계 항목만 지정한 맨 배관도 보관한다
if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다
if (attributes.facility !== "pipe") return true;
if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true;
return !!attributes.options && Object.keys(attributes.options).length > 0;
@@ -122,13 +128,13 @@ export function createFacilityStore() {
end_m?: number;
options?: Record<string, string | number>;
summary_item?: SummaryItem | null;
group_items?: GroupItems | null;
}>,
): void {
entries = points
.map((point) => {
// 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔
// 값을 이어받았는데, 횡단 보기에서 [해제]한 초안이 응답으로 돌아올 때 옛 지정이 되살아났다.
const summary = point.summary_item ?? null;
return {
chainage: point.chainage_m,
attributes: {
@@ -136,7 +142,7 @@ export function createFacilityStore() {
start_m: point.start_m,
end_m: point.end_m,
options: point.options,
...(summary ? { summary_item: summary } : {}),
...linkFields(point),
} as FacilityAttributes,
};
})
@@ -176,9 +182,9 @@ export function createFacilityStore() {
return points.map((point, index) => {
const pick = picks[index];
if (pick === null) return { ...point };
const { summary_item: summary, ...rest } = entries[pick].attributes;
const { summary_item: _s, group_items: _g, ...rest } = entries[pick].attributes;
// 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐.
return { ...point, ...rest, ...(summary ? { summary_item: summary } : {}) };
return { ...point, ...rest, ...linkFields(entries[pick].attributes) };
});
},
};
@@ -239,11 +245,11 @@ export function createFacilityOptionsForm(
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
// 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7).
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
new Option("— 안 정함 —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
@@ -257,10 +263,10 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
// 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음.
const inletType = document.createElement("select");
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
new Option("— 안 정함 —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
@@ -278,7 +284,7 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material");
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1", "0", "제안 2");
const basinLength = numberInput("0.1", "0");
// 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a).
const basinSplit = createBasinSplitFields(basinLength);
basinSplit.onChange(() => emit());
@@ -291,11 +297,7 @@ export function createFacilityOptionsForm(
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
height,
});
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height });
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4).
@@ -316,12 +318,8 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
height,
});
const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height });
outletGroup.body.append(
grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField),
...outletRevet.rows,
@@ -342,10 +340,10 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetSide = optionalSelect("— 안 정함 —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide, "side"));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS);
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS);
/** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */
let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = {
inlet: "좌",
@@ -380,12 +378,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
const boxSize = optionalSelect("— 안 정함 —", [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxWidth = numberInput("0.1", "0");
const boxHeight = numberInput("0.1", "0");
const boxCustomRow = grid(
labeled("본체 폭 (m)", boxWidth, "body_width_m"),
labeled("본체 높이 (m)", boxHeight, "body_height_m"),
@@ -408,33 +406,14 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로.
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`).
// 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7).
const ford = createFordFields(() => emit());
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
@@ -444,7 +423,6 @@ export function createFacilityOptionsForm(
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
@@ -586,7 +564,7 @@ export function createFacilityOptionsForm(
}
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
@@ -609,7 +587,7 @@ export function createFacilityOptionsForm(
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
// 값이 없으면 「안 정함」.
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -212,27 +212,13 @@ export interface RevetmentKeys {
after?: string;
}
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
* (함수로 받아 볼 때마다 셈).
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
/** 높이 칸 안내 — 관 벽은 비우면 관경 기준 최소 높이(엔진 `pipe_wall_min_height_m` · 함수로 받아
* 볼 때마다 셈) · 빈 글이면 「설계자 입력」. 형태 · 길이 · 전후 기본값은 등록부가 시설이 생길 때
* 칸에 실제 값으로 넣음(PLAN 46-7 · 제안값 단추 없음). */
export interface RevetmentDefaults {
form: string;
length: string;
height: string | (() => string);
before: string;
after: string;
}
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
before: "5",
after: "5",
} as const;
export const INLET_REVET_KEYS: RevetmentKeys = {
form: "inlet_revet_form",
length: "inlet_revet_length_m",
@@ -273,16 +259,12 @@ function sabangBasisLines(): string[] {
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
const fields = createRevetmentFields(keys, { height: "" });
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
@@ -326,24 +308,24 @@ export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
const suggestHeight = (): string =>
const wallFloor = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
const form = document.createElement("select");
form.replaceChildren(
new Option(`제안 ${defaults.form}`, ""),
new Option("— 안 정함 —", ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const length = keys.length ? numberInput("0.1", "0") : null;
// 관 벽은 비우면 관경 기준 최소 높이로 섬(엔진) · 독립 기슭막이 높이는 「설계자 입력」.
const heightHint = (): string =>
wallFloor() ? `비우면 관경 기준 ${wallFloor()}` : "설계자 입력";
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
// 관 벽 기준 높이는 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
const before = keys.before && length ? numberInput("0.1", "0") : null;
const after = keys.after && length ? numberInput("0.1", "0") : null;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
@@ -367,25 +349,10 @@ export function createRevetmentFields(
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5), keys.before) : null;
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5), keys.after) : null;
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
const fill = document.createElement("button");
fill.type = "button";
fill.className = "b05-structure__suggest";
fill.textContent = "제안값 넣기";
fill.addEventListener("click", () => {
if (!form.value) form.value = defaults.form;
if (length && !length.value) length.value = defaults.length;
if (!height.value) height.value = suggestHeight();
if (before && !before.value) before.value = defaults.before;
if (after && !after.value) after.value = defaults.after;
form.dispatchEvent(new Event("change"));
});
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
grid(fill),
];
return {
@@ -393,7 +360,7 @@ export function createRevetmentFields(
rows,
lengthInput: length,
write(options) {
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
// 받은 값만 칸에 — 없으면 비워 둠(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form);
@@ -447,8 +414,8 @@ export function createBasinSplitFields(length: HTMLInputElement): {
read: (target: Record<string, string | number>) => void;
onChange: (handler: () => void) => void;
} {
const before = numberInput("0.1", "0", "제안 1");
const after = numberInput("0.1", "0", "제안 1");
const before = numberInput("0.1", "0");
const after = numberInput("0.1", "0");
let last: HTMLInputElement = before;
const sync = (): void => {
const total = Number.parseFloat(length.value);
@@ -528,10 +495,6 @@ export const WING_OUT_KEYS: WingKeys = {
angle: "wing_out_angle_deg",
};
/** 날개벽 기본 제원 — 설치 있음·짧은쪽 높이 1m·길이 2m·각도 45°
* (2026-08-17 사용자 확정). 유입·유출이 같은 값을 쓴다. */
const WING_DEFAULTS = { install: "있음", height: "1", length: "2", angle: "45" } as const;
interface WingFields {
root: HTMLFieldSetElement;
/** 횡단도에서 그 측 날개벽·측벽을 고를 때 칸을 세우는 자리(B06 전용, 2026-08-30).
@@ -541,18 +504,16 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
* 기본 제원(있음·1·2·45, 사용자 확정)은 등록부 기본값 — 시설이 생길 때 칸에 실제 값(PLAN 46-7). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
const install = optionalSelect("— 안 정함 —", ["있음", "없음"]);
const height = numberInput("0.1", "0");
const length = numberInput("0.1", "0");
const angle = numberInput("1", "0");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1), keys.height);
@@ -594,12 +555,5 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -3,10 +3,9 @@
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
*
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
* 월류 폭 기본값(세월교 10m · 물넘이 5m · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴
* (PLAN 46-7 · 제안값 단추 없음). 빈 칸 안내 = 엔진이 비운 칸을 읽는 값(폭 기본 · 필요 수심).
* 적은 높이가 필요 수심보다 낮으면 되돌림(계산값 미만은 안 받음).
* ========================================================================== */
import {
@@ -35,7 +34,6 @@ export interface FordFields {
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
@@ -66,14 +64,15 @@ export function createFordFields(emit: () => void): FordFields {
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
/** 폭을 비우면 엔진이 쓰는 폭(`B06_Section_Engine_Culvert.FORD_*_DEFAULT_WIDTH_M` 짝). */
const emptyWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const widthM = Number.parseFloat(width.value || width.dataset.empty || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
height.placeholder = minDepthM !== null ? `필요 수심 ${minDepthM.toFixed(2)} 이상` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
@@ -121,8 +120,8 @@ export function createFordFields(emit: () => void): FordFields {
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
width.dataset.empty = String(emptyWidth(kind));
width.placeholder = `비우면 ${emptyWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
@@ -142,10 +141,5 @@ export function createFordFields(emit: () => void): FordFields {
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
@@ -84,6 +84,8 @@ interface MergeableAttributes {
options?: Record<string, string | number>;
/** 집계 항목(options 밖) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
summary_item?: unknown;
/** 옵션 그룹 집계 항목(PLAN 46-3) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
group_items?: unknown;
}
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
@@ -99,5 +101,14 @@ export function mergeFacilityOptions<T extends MergeableAttributes>(
next.summary_item === undefined && previous.summary_item !== undefined
? { summary_item: previous.summary_item }
: {};
return { ...next, ...summary, options: Object.keys(options).length ? options : undefined };
const groups =
next.group_items === undefined && previous.group_items !== undefined
? { group_items: previous.group_items }
: {};
return {
...next,
...summary,
...groups,
options: Object.keys(options).length ? options : undefined,
};
}
@@ -7,7 +7,7 @@
* 그대로 다시 내보내므로 기존 import 경로는 유효하다.
* ========================================================================== */
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -67,6 +67,8 @@ export interface DrainagePipeReport {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
group_items?: GroupItems | null;
}
export interface DrainagePanelCallbacks {
+1 -1
View File
@@ -212,7 +212,7 @@ export function bindPipeContextMenu(
]);
return;
}
// 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다.
// 계획선에서 먼 자리는 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음).
if (!editor.canAddAt(view, x, y)) return;
event.preventDefault();
// 빈 자리 — 종단그래프와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 일원화).
@@ -34,6 +34,7 @@ export function buildPipeReports(
source: (pipe.reason || "user") as PipeSource,
options: attributes?.options,
summary_item: attributes?.summary_item,
group_items: attributes?.group_items,
};
});
}
+3 -3
View File
@@ -9,6 +9,7 @@
import { createPanelResizer } from "@ui/ui_template_resizer";
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
@@ -19,8 +20,6 @@ const MIN_CHART_HEIGHT = 100;
const MASSHAUL_HANDLE_GUTTER_PX = 13;
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
const MIN_PANEL_HEIGHT = 180;
/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */
const HEIGHT_KEY = stateKey("profile-height") ?? "";
@@ -147,7 +146,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
);
},
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
max: () => profilePanelMaxHeight(root),
storageKey: HEIGHT_KEY,
});
@@ -19,6 +19,13 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 패널 높이 상한(px) — 메인 리사이저 max 와 자동 확대가 같이 쓴다. 기준 = 한 페이지 가운데 칸
* `.design-page__main`(바로 위 `.design-page__profile` 은 display: contents 라 높이 0). */
export function profilePanelMaxHeight(root: HTMLElement): number {
const basis = root.closest<HTMLElement>(".design-page__main") ?? root.parentElement;
return (basis?.clientHeight || window.innerHeight) * MAX_PANEL_HEIGHT_RATIO;
}
export interface HeightCascadeDeps {
/** 패널 루트 — 자리가 모자라면 이 높이를 키운다. */
root: HTMLElement;
@@ -155,9 +162,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
const growCap = Math.round(
(deps.root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
);
const growCap = Math.round(profilePanelMaxHeight(deps.root));
const grown = Math.min(deps.root.offsetHeight + deficit, growCap);
if (grown > deps.root.offsetHeight + 1) {
deps.root.style.setProperty("--b05-profile-height", `${grown}px`);
@@ -6,7 +6,7 @@
* 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다.
* ========================================================================== */
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
@@ -27,6 +27,8 @@ export interface RouteProfilePanelCallbacks {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
group_items?: GroupItems | null;
}>,
) => void;
/** 테이블에서 구조물 라인을 끌어 옮김. */
+1
View File
@@ -166,6 +166,7 @@ export async function openRouteEditModal(
measureControls.sync();
draw();
},
onHint: () => measureControls.sync(),
});
/** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */
const measureControls = bindMeasureControls({
+40 -21
View File
@@ -16,7 +16,7 @@
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* ========================================================================== */
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import {
buildCrossSvg,
@@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane {
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
/** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */
let drawn: CrossPreviewResponse | null = null;
let size = { width: 0, height: 0 };
let zoom: ZoomPanState | undefined;
function draw(preview: CrossPreviewResponse): void {
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고
// 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고,
// 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다.
const rect = box.getBoundingClientRect();
size = { width: Math.round(rect.width), height: Math.round(rect.height) };
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
box.replaceChildren(plot.svg);
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
initial: zoom,
onChange: (state) => (zoom = state),
});
}
const observer = new ResizeObserver(() => {
if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다.
const rect = box.getBoundingClientRect();
const width = Math.round(rect.width);
const height = Math.round(rect.height);
if (!drawn || width === 0 || (width === size.width && height === size.height)) return;
draw(drawn);
});
return {
root,
show(preview, intervalM) {
box.replaceChildren();
drawn = preview;
zoom = undefined;
if (!preview) {
station.textContent = "";
foot.textContent = empty;
@@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane {
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
station.textContent = formatStation(preview.chainage_m, intervalM);
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.max(Math.round(rect.width), 240),
Math.max(Math.round(rect.height), 150),
);
box.append(plot.svg);
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
draw(preview);
foot.textContent = summarizeCross(preview);
},
wait(text) {
box.replaceChildren();
drawn = null;
foot.textContent = text;
},
};
+185 -26
View File
@@ -4,11 +4,16 @@
*
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
* (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만
* 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차.
*
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
* 끄는 동안에는 한 번도 부르지 않는다.
*
* 첫 점을 찍으면 그 노선 땅 높이를 일정 간격으로 **한 번에** 받아 둔다(계획서 48-1). 커서를
* 움직이는 동안 길이 · 지반고 · 기울기는 이 표에서 보간해 바로 보이고 서버는 안 부른다.
* 둘째 점을 누르면 전처럼 두 점만 다시 물어 그 값으로 고정한다.
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
@@ -22,6 +27,10 @@ export type MeasureLine = "planned" | "expected";
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
/** 커서 미리보기용 땅 높이 표 간격(m). */
const PROFILE_STEP_M = 2;
/** 한 번에 묻는 점 수 상한 — 긴 노선은 간격을 넓힌다. */
const PROFILE_MAX_POINTS = 3000;
interface MeasurePoint extends RoutePointHit {
line: MeasureLine;
@@ -40,6 +49,69 @@ export interface MeasureToolParams {
isClosed: () => boolean;
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
onChange: () => void;
/** 커서 미리보기 값이 바뀌었다 — 작은 창 글만 다시 적는다(그리기는 포인터 몫). */
onHint?: () => void;
}
/** 첫 점 노선의 땅 높이 표 — 누가거리 `chainages[i]` 자리의 지반고 `z[i]`. */
interface LineProfile {
/** 표를 만든 노선 — 편집으로 노선이 바뀌면 배열이 새로 생겨 낡은 표를 알아챈다. */
vertices: Vertex[];
stepM: number;
chainages: number[];
/** 받는 중이면 null. */
z: Array<number | null> | null;
failed: boolean;
}
/** 노선 전체 길이(m). */
function lineLength(vertices: Vertex[]): number {
let total = 0;
for (let index = 0; index < vertices.length - 1; index += 1) {
total += Math.hypot(
vertices[index + 1][0] - vertices[index][0],
vertices[index + 1][1] - vertices[index][1],
);
}
return total;
}
/** 노선을 따라 `stepM` 마다 한 점(끝점 포함) — 점과 그 누가거리. */
function sampleAlong(vertices: Vertex[], stepM: number): { points: Vertex[]; chainages: number[] } {
const points: Vertex[] = [];
const chainages: number[] = [];
let travelled = 0;
let next = 0;
for (let index = 0; index < vertices.length - 1; index += 1) {
const [ax, ay] = vertices[index];
const [bx, by] = vertices[index + 1];
const segmentM = Math.hypot(bx - ax, by - ay);
while (segmentM > 0 && next <= travelled + segmentM) {
const t = (next - travelled) / segmentM;
points.push([ax + (bx - ax) * t, ay + (by - ay) * t]);
chainages.push(next);
next += stepM;
}
travelled += segmentM;
}
if (points.length && travelled - chainages[chainages.length - 1] > 1e-6) {
points.push(vertices[vertices.length - 1]);
chainages.push(travelled);
}
return { points, chainages };
}
/** 표에서 누가거리 자리의 지반고를 선형 보간 — 양옆 중 하나라도 모르면 null. */
function profileZ(profile: LineProfile, chainageM: number): number | null {
const { chainages, z } = profile;
if (!z || chainages.length === 0) return null;
if (chainages.length === 1) return z[0];
const index = Math.max(0, Math.min(chainages.length - 2, Math.floor(chainageM / profile.stepM)));
const [c0, c1] = [chainages[index], chainages[index + 1]];
const [z0, z1] = [z[index], z[index + 1]];
if (z0 === null || z1 === null) return null;
const t = c1 > c0 ? Math.max(0, Math.min(1, (chainageM - c0) / (c1 - c0))) : 0;
return z0 + (z1 - z0) * t;
}
export interface MeasureMark {
@@ -56,6 +128,8 @@ export interface MeasureTool {
hint: () => string;
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */
preview: (px: number, py: number) => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
@@ -109,34 +183,97 @@ export function bindMeasureControls(params: MeasureControlsParams): {
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
let hover: MeasureMark | null = null;
/** 첫 점 노선의 땅 높이 표 — 커서 미리보기 값의 바탕. */
let profile: LineProfile | null = null;
/** 두 점을 찍고 그 자리 지반고를 묻는 중 — 답이 오기 전에 「못 읽음」으로 보이지 않게. */
let fixing = false;
const hint = (): string => {
if (picked.length === 0) return "";
const first = picked[0];
const name = LINE_NAME[first.line];
if (picked.length === 1) {
return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
/** 첫 점 노선 땅 높이를 한 번에 받아 둔다. 같은 노선 표가 살아 있으면 다시 안 묻는다. */
const loadProfile = (line: MeasureLine): void => {
const vertices = params.lines()[line];
if (profile && profile.vertices === vertices) return;
const stepM = Math.max(PROFILE_STEP_M, lineLength(vertices) / PROFILE_MAX_POINTS);
const { points, chainages } = sampleAlong(vertices, stepM);
const asked: LineProfile = { vertices, stepM, chainages, z: null, failed: !points.length };
profile = asked;
if (!points.length) return;
fetchRouteElevations(params.projectId, points)
.then((heights) => {
asked.z = chainages.map((_, index) => heights[index] ?? null);
})
.catch(() => {
asked.failed = true;
})
.finally(() => {
if (params.isClosed() || profile !== asked) return; // 그 사이 다른 노선을 짚었으면 버린다.
params.onHint?.();
});
};
/** 구간 한 줄 — 두 점(누가거리 · 지반고)에서 길이 · 지반고 · 종단기울기. */
const spanText = (
name: string,
a: { chainageM: number; z: number | null },
b: { chainageM: number; z: number | null },
missing: string,
): string => {
const span = Math.abs(b.chainageM - a.chainageM);
const head =
`${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
}
`${name}구간 ${formatStation(a.chainageM, params.stationIntervalM)} → ` +
`${formatStation(b.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (a.z === null || b.z === null || span <= 1e-6) return `${head} · ${missing}`;
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = second.z - first.z;
const rise = b.z - a.z;
return (
`${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` +
`${head} · 지반고 ${a.z.toFixed(1)} → ${b.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
);
};
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
/** 커서 미리보기 한 줄 — 받아 둔 높이 표에서 보간한 값(누르면 두 점만 다시 물어 고정). */
const previewText = (first: MeasurePoint, at: MeasureMark): string => {
const table =
profile !== null && profile.vertices === params.lines()[first.line] ? profile : null;
const zOf = (chainageM: number): number | null => (table ? profileZ(table, chainageM) : null);
const missing = !table
? "지반고 읽는 중…"
: table.failed
? "지반고를 못 읽어 기울기는 못 냅니다."
: table.z
? "지표면 밖이라 기울기는 못 냅니다."
: "지반고 읽는 중…";
const text = spanText(
LINE_NAME[first.line],
{ chainageM: first.chainageM, z: zOf(first.chainageM) },
{ chainageM: at.chainageM, z: zOf(at.chainageM) },
missing,
);
return `${text} · 누르면 고정`;
};
const hint = (): string => {
if (picked.length === 0) return "";
const first = picked[0];
if (picked.length === 1) {
if (hover) return previewText(first, hover);
return `${LINE_NAME[first.line]}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
return spanText(
LINE_NAME[first.line],
first,
picked[1],
fixing ? "지반고 읽는 중…" : "지반고를 못 읽어 기울기는 못 냅니다.",
);
};
/** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */
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,32 +282,52 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
})
.filter((hit): hit is NonNullable<typeof hit> => 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;
// 노선을 고친 뒤면 표가 낡았다 — 다시 받는다.
if (next) loadProfile(picked[0].line);
params.onHint?.();
return true;
},
clear() {
hover = null;
profile = 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 }];
fixing = picked.length === 2;
params.onChange();
if (picked.length < 2) return;
if (picked.length < 2) {
loadProfile(hit.line);
return;
}
const asked = picked;
try {
const heights = await fetchRouteElevations(
@@ -183,7 +340,9 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
});
} catch {
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
if (picked !== asked) return;
}
fixing = false;
params.onChange();
},
};
@@ -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 =
@@ -43,6 +43,7 @@ import {
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help";
import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
@@ -225,7 +226,8 @@ export function openRecommendModal(params: RecommendParams): void {
<span class="b05-routerec__state" data-state></span>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
</div>`;
</div>
${recommendHelpHtml()}`;
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
@@ -342,6 +344,7 @@ export function openRecommendModal(params: RecommendParams): void {
? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline")
: "";
line("criteria").hidden = !preview;
syncRecommendHelp(body, logic);
drawMap();
}
@@ -0,0 +1,188 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Help.ts
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
*
* 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을
* 바꾸면 여기 글도 같이 고친다. 고른 갈래는 강조하고 보이는 자리로 굴린다.
* ========================================================================== */
import type { RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
interface HelpEntry {
/** 한 줄 요약. */
summary: string;
/** 노선을 만드는 법. */
how: string[];
/** 이럴 때 고름. */
when: string;
/** 주의할 점. */
caution: string;
}
const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
follow: {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴",
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
},
gradeline: {
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
how: [
"구간마다 기울기가 「목표 기울기」에 가장 가깝게 붙도록 길을 찾음",
"목표 칸을 비우면 종단기울기 상한이 목표 · 상한보다 크게 넣으면 상한으로 잘림",
"후보 중 목표와의 차(측점 20m 기울기 평균)가 가장 작은 것을 고름",
],
when: "일정한 기울기로 오르내리는 노선이 필요할 때(지형도 위 영선 잡기와 같은 일)",
caution:
"오르막 · 내리막을 가리지 않음 · 목표를 비우면 상한에 붙은 가파른 노선이 되기 쉬움 — 목표를 상한보다 조금 낮게 넣기를 권함",
},
earthwork: {
summary: "깎고 쌓는 흙(절토 + 성토)이 가장 적은 노선",
how: [
"옆경사가 급한 곳 · 지반이 상한보다 가파른 곳을 비싸게 보고 길을 찾음",
"후보 중 절토 + 성토 어림 양이 가장 적은 것을 고름",
"흙 양 = 계획고를 상한 안으로 맞춘 선과 옆경사 단면(노폭 4m)으로 어림",
],
when: "토공량 · 사토 처리를 줄여야 할 때",
caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것",
},
gentle: {
summary: "종단기울기가 가장 순한 노선",
how: [
"기울기가 상한에 가까울수록 크게 벌점을 매겨 길을 찾음",
"후보 중 가장 가파른 구간의 기울기가 가장 낮은 것을 고름(같으면 평균 기울기)",
],
when: "차량 주행 · 노면 침식이 걱정되는 노선",
caution: "거리 비용이 작아 돌아가는 긴 노선이 나올 수 있음 — 연장을 확인",
},
safe: {
summary: "급한 옆경사 · 계류 가까운 곳을 피하는 노선",
how: [
"옆경사 35°(약 70%) 이상 · 계류 이격 거리 안을 위험 구간으로 보고 피함",
"후보 중 위험 구간을 지나는 길이가 가장 짧은 것을 고름(같으면 평균 옆경사)",
"35° 기준 = 「35° 이상 전폭 절토」 기술 기준",
],
when: "붕괴 · 유실 위험이 큰 급경사지 · 계곡 가까운 지형",
caution:
"계곡을 따라가는 지형에서는 위험 구간이 어디서나 길어 다른 갈래와 차이가 작음 — 계류 이격 값을 줄여 다시 셈해 볼 것",
},
crossings: {
summary: "계류를 건너는 횟수가 가장 적은 노선",
how: [
"계류 위를 지나는 칸에 벌점을 매겨 길을 찾음",
"후보 중 계류 횡단 수가 가장 적은 것을 고름(같으면 계류에서 더 먼 쪽)",
"10m 안에서 여러 번 건너는 것은 한 번으로 셈",
],
when: "배수관 · 세월교 같은 횡단 구조물을 줄이고 싶을 때",
caution: "소하천 · 지방하천 · 국가하천만 셈 — 작은 세류는 빠짐",
},
cost: {
summary: "토공 · 연장 · 계류 횡단을 한 숫자로 묶은 공사비 어림이 가장 낮은 노선",
how: [
"공사비 어림 = 흙 1㎥ × 1 + 연장 1m × 2 + 계류 횡단 1곳 × 400(흙 ㎥ 상당)",
"같은 비율을 1m 당 값으로 바꿔 길을 찾고, 후보 중 어림값이 가장 낮은 것을 고름",
],
when: "토공 · 연장 · 구조물 수를 한꺼번에 비교하고 싶을 때",
caution: "실제 원가가 아닌 상대값(임시 비율) — 갈래끼리 비교용으로만 볼 것",
},
weighted: {
summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선",
how: [
"예상 = 예상노선에서 떨어지지 않기 · 토공 = 흙 양 줄이기 · 기울기 = 순한 기울기",
"비중대로 섞은 비용 하나로 한 번 길을 찾음 — 한쪽이 100 이면 그 갈래와 같은 노선",
"자물쇠로 비중 하나를 고정하면 나머지 둘만 움직임 · 기본 50 / 25 / 25",
],
when: "예상노선을 어느 정도 살리면서 토공 · 기울기도 챙기고 싶을 때",
caution: "제약은 벌점일 뿐이라 넘는 구간이 남을 수 있음 — 못 지킨 구간을 확인",
},
};
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단기울기 상한(%)",
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
},
{
name: "최소 반지름(m)",
text: "꺾는 곳마다 이 반지름의 곡선이 들어갈 만큼 곧게 간 뒤에만 다시 꺾음. 비우면 설계속도 · 지형 법정 값(20km/h 15m · 12m)",
},
{
name: "계류 이격(m)",
text: "계류에서 이 거리 안으로는 따라 걷지 않고 건너거나 빠져나가는 방향으로만 움직임. 비우면 100m(타당성평가 「산지계류 100m 이내」 기준)",
},
{
name: "지반 고름 폭(m)",
text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m",
},
];
const LINES_HELP: Array<{ name: string; text: string }> = [
{
name: "비교값",
text: "연장 · 기울기 최대 · 평균 · 옆경사 · 절토 · 성토 · 계류 횡단 · 공사비 어림 — 갈래를 바꿔 가며 견줌",
},
{
name: "못 지킨 구간",
text: "제약을 넘은 측점(5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
},
{
name: "상한을 풂",
text: "지형상 상한 안으로는 시점 · 종점을 이을 수 없어 꼭 필요한 만큼만 상한을 올린 경우",
},
{
name: "거의 같은 노선",
text: "제약 안에서 갈 길이 좁은 띠 하나뿐이라 갈래가 달라도 노선이 겹치는 경우 — 제약 값을 바꾸면 벌어질 수 있음",
},
];
const LOGIC_ORDER: RouteInitialLogic[] = [
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
];
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
export function recommendHelpHtml(): string {
const logics = LOGIC_ORDER.map((key) => {
const entry = LOGIC_HELP[key];
return `<article class="b05-routerec__help-item" data-help-logic="${key}">
<h4>${LOGIC_LABEL[key]}</h4>
<p class="b05-routerec__help-summary">${entry.summary}</p>
<ul>${entry.how.map((line) => `<li>${line}</li>`).join("")}</ul>
<p><b>이럴 때</b> ${entry.when}</p>
<p class="b05-routerec__help-caution"><b>주의</b> ${entry.caution}</p>
</article>`;
}).join("");
const list = (items: Array<{ name: string; text: string }>): string =>
`<dl>${items.map((item) => `<dt>${item.name}</dt><dd>${item.text}</dd>`).join("")}</dl>`;
return `<aside class="b05-routerec__help" aria-label="노선 추천 설명">
<h3>추천 갈래</h3>
<p class="b05-routerec__help-lead">제약을 지키는 후보 노선 여러 벌을 찾은 뒤, 갈래마다 자기 기준이 가장 좋은 것을 고름.</p>
${logics}
<h3>공통 제약</h3>
<p class="b05-routerec__help-lead">예상노선 추적 · 비중 계산을 뺀 갈래는 넘지 않음 · 칸을 비우면 기본값.</p>
${list(CRITERIA_HELP)}
<h3>결과 줄 읽는 법</h3>
${list(LINES_HELP)}
</aside>`;
}
/** 고른 갈래 설명을 강조하고 보이는 자리로 굴린다. */
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
body.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
const on = item.dataset.helpLogic === logic;
if (on && !item.classList.contains("is-on")) item.scrollIntoView({ block: "nearest" });
item.classList.toggle("is-on", on);
});
}
@@ -184,10 +184,17 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.lineWidth = 0.8;
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
if (scene.pickedContour >= 0) {
const { layer } = scene.contours;
const level = layer.features[scene.pickedContour]?.labelValue ?? null;
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
layer.features.forEach((feature, index) => {
if (index === scene.pickedContour || (level !== null && feature.labelValue === level)) {
drawPreparedFeature(context, layer, index, view);
}
});
}
// 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
}
@@ -0,0 +1,216 @@
/* =============================================================================
* B05_Profile_UI_Structures_Define.ts
* 구조물 정의 → 「구조물 배치」 칸 모양 (PLAN 48-3 · 48-4) — 화면 요소 없는 순수 규칙.
*
* 정의(`resources/master_template/구조물정의.json` · GET /structure-types 가 실어 줌)를 읽어
* 칸 차례 · 격자 · 안 씀 · 규칙(상위 값 → 하위 선택지 · 보이기 · 기본값)을 낸다. 틀 =
* `docs/raw/M02_구조물옵션정의/정의_틀.md`. 선택지를 줄이면(숨김) 옛 값은 그 값만 남겨 보임.
* 그리기는 `_Structures_Panel_Options`(레지스트리 칸) · `_Structures_Define_Form`(시설 폼 칸).
* 값에 따른 계산 · 연동 제어는 여기 없음(48-5).
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
/** 정의가 옵션 하나에 실어 주는 몫 — 칸 그리기가 쓰는 것만. */
export interface DefinedOption {
key: string;
label: string;
input: string;
choices: string[];
default: string | number | null;
visible?: boolean;
/** 거짓 = 「안 씀」(정의 틀 3장) — 칸을 안 그림 · 옛 값은 그대로 남음. */
enabled?: boolean;
}
/** 상위 → 하위 규칙 한 줄(정의 `규칙`) — 상위 칸 값이 `만약.값들` 중 하나면 하위 칸에 `그러면` 을 씀. */
export interface DefinedRule {
종류?: string;
만약: { 옵션: string; 값들: Array<string | number> };
그러면: {
옵션: string;
/** 하위 칸 선택지를 이 목록으로 좁힘. */
선택지?: string[];
/** 하위 칸을 보이거나 가림. */
보이기?: boolean;
/** 상위 값이 바뀌어 이 줄이 걸리면 하위 칸 값을 이 값으로. */
기본값?: string | number | null;
};
}
/** 배치 격자 한 칸 — 정의 `배치` 의 `{줄, 칸, 너비}`(1부터). */
export interface DefinedSpot {
줄?: number;
칸?: number;
너비?: number;
}
export interface DefinedType {
options: DefinedOption[];
/** 종류 덧칸 — 기준 측점 칸 보임(기본 보임). */
show_anchor?: boolean;
/** 옵션 키 → 배치 칸. 비면 정의 옵션 차례대로 2열. */
spots?: Record<string, DefinedSpot> | null;
rules?: DefinedRule[] | null;
}
/** 칸 하나의 지금 모양 — 규칙을 건 뒤. */
export interface OptionState {
visible: boolean;
choices: string[];
}
const whole = (n: unknown): number | null =>
typeof n === "number" && Number.isInteger(n) && n >= 1 ? n : null;
/** 그릴 옵션 차례 — 배치가 있으면 (줄, 칸) 차례 · 없는 칸은 뒤에 정의 차례로. 안 씀 · 숨김은 뺌. */
export function orderedOptions<T extends DefinedOption>(type: {
options: T[];
spots?: Record<string, DefinedSpot> | null;
}): T[] {
const spots = type.spots ?? {};
const rank = (key: string): [number, number] => {
const spot = spots[key];
return [whole(spot?.줄) ?? Infinity, whole(spot?.칸) ?? Infinity];
};
return type.options
.map((option, index) => ({ option, index, at: rank(option.key) }))
.filter(({ option }) => option.enabled !== false && option.visible !== false)
.sort((a, b) => a.at[0] - b.at[0] || a.at[1] - b.at[1] || a.index - b.index)
.map(({ option }) => option);
}
/** 한 줄 칸 수 — 배치가 쓰는 가장 오른쪽 칸(1 ~ 6) · 배치 없으면 2. */
export function columnsOf(type: { spots?: Record<string, DefinedSpot> | null }): number {
let widest = 0;
for (const spot of Object.values(type.spots ?? {})) {
const col = whole(spot.칸) ?? 1;
widest = Math.max(widest, col + (whole(spot.너비) ?? 1) - 1);
}
return widest ? Math.min(Math.max(widest, 2), 6) : 2;
}
/** 칸 하나의 CSS 격자 자리 — 배치 없으면 null(흐름대로). */
export function gridPlace(
spot: DefinedSpot | undefined,
columns: number,
): { row: string; column: string } | null {
if (!spot) return null;
const row = whole(spot.줄);
const col = whole(spot.칸);
const span = Math.min(whole(spot.너비) ?? 1, columns);
return {
row: row ? String(row) : "",
column: col ? `${Math.min(col, columns)} / span ${span}` : `span ${span}`,
};
}
/** 기준 측점 칸을 보일지 — 정의가 끈 때만 가림. */
export function showsAnchor(type: { show_anchor?: boolean }): boolean {
return type.show_anchor !== false;
}
const same = (a: unknown, b: unknown): boolean => String(a ?? "") === String(b ?? "");
/** 규칙을 지금 값에 건 칸 모양 — 걸린 줄의 선택지 · 보이기가 정의 기본을 덮음(뒤 줄이 이김). */
export function ruleStates(
type: DefinedType,
values: Record<string, string | number | undefined>,
): Map<string, OptionState> {
const states = new Map<string, OptionState>();
for (const option of type.options)
states.set(option.key, { visible: true, choices: [...option.choices] });
for (const rule of type.rules ?? []) {
const state = states.get(rule.그러면.옵션);
// 가려진 상위 칸에 매인 줄은 안 걸림 — 가림이 사슬로 이어짐(정의 차례대로 적용)
if (!state || states.get(rule.만약.옵션)?.visible === false) continue;
if (!hits(rule, values)) continue;
const pick = rule.그러면.선택지;
if (pick) state.choices = pick.filter((c) => state.choices.includes(c));
if (typeof rule.그러면.보이기 === "boolean") state.visible = rule.그러면.보이기;
}
return states;
}
const hits = (rule: DefinedRule, values: Record<string, unknown>): boolean =>
rule.만약.값들.some((value) => same(value, values[rule.만약.옵션]));
/** 상위 값이 바뀌었을 때 하위 칸에 넣을 기본값 `{하위 키: 값}` — 걸린 줄 중 기본값이 있는 것만. */
export function ruleDefaults(
type: DefinedType,
parent: string,
values: Record<string, string | number | undefined>,
): Record<string, string | number> {
const out: Record<string, string | number> = {};
for (const rule of type.rules ?? []) {
const value = rule.그러면.기본값;
if (rule.만약.옵션 !== parent || value === undefined || value === null) continue;
if (hits(rule, values)) out[rule.그러면.옵션] = value;
}
return out;
}
/** 지금 정의에 없는 키의 값 — 「지난 정의 값」 읽기만 칸으로 보임. `known` = 폼이 아는 키 전부. */
export function staleValues(
values: Record<string, string | number> | undefined,
known: Iterable<string>,
): Array<[string, string | number]> {
const keys = new Set(known);
return Object.entries(values ?? {}).filter(([key]) => !keys.has(key));
}
/** 그룹 형상 칸(정의 틀 4장) — 길이 · 높이 · 기준 측점 전 · 후 마다 받기 켬끔 + 뜻. */
export type ShapeName = "길이" | "높이" | "전" | "후";
export const SHAPE_FIELDS: ReadonlyArray<{ name: ShapeName; suffix: string; label: string }> = [
{ name: "길이", suffix: "length_m", label: "길이 (m)" },
{ name: "높이", suffix: "height_m", label: "높이 (m)" },
{ name: "전", suffix: "before_m", label: "기준측점 전 (m)" },
{ name: "후", suffix: "after_m", label: "기준측점 후 (m)" },
];
export type DefinedShape = Partial<Record<ShapeName, { 받기?: boolean; 뜻?: string }>>;
/** 그룹 형상 값 저장 키 — 구조물 옵션에 `{그룹 id}__{칸}` 으로 둠. */
export const shapeKey = (groupId: string, suffix: string): string => `${groupId}__${suffix}`;
/** 그룹이 받는 형상 칸 — 받기 켠 것만 정해진 차례로. */
export function shapeFields(
shape: DefinedShape | undefined,
): Array<{ suffix: string; label: string; meaning: string }> {
return SHAPE_FIELDS.filter((f) => shape?.[f.name]?.받기 === true).map((f) => ({
suffix: f.suffix,
label: f.label,
meaning: shape?.[f.name]?.뜻 ?? "",
}));
}
/** 정의 한 벌(GET /m02/structure-definition) 중 화면이 쓰는 칸. */
export interface StructureDefinition {
판?: number;
구조물군?: Record<string, string>;
종류?: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
그룹?: Record<string, Array<{ id: string; 이름?: string; 형상?: DefinedShape }>>;
배치?: Record<string, Record<string, DefinedSpot>>;
규칙?: DefinedRule[];
}
/** 종류 목록에 정의 덧칸을 얹음 — 구조물군 이름 · 기준 측점 · 동작 · 배치 · 규칙 · 그룹 형상.
* 정의를 못 받았으면(null) 그대로 — 지금 화면과 같음. */
export function withDefinition<
T extends { type_id: string; group: string; groups?: Array<{ id: string }> },
>(types: T[], definition: StructureDefinition | null): T[] {
if (!definition) return types;
return types.map((type) => {
const kind = definition.종류?.[type.type_id] ?? {};
const shapes = new Map((definition.그룹?.[type.type_id] ?? []).map((g) => [g.id, g.형상]));
return {
...type,
group_name: definition.구조물군?.[type.group],
show_anchor: kind.기준측점,
behaviors: kind.동작 ?? [],
spots: definition.배치?.[type.type_id] ?? null,
rules: (definition.규칙 ?? []).filter((rule) => rule.종류 === type.type_id),
...(type.groups
? { groups: type.groups.map((g) => ({ ...g, shape: shapes.get(g.id) })) }
: {}),
};
});
}
@@ -0,0 +1,85 @@
/* =============================================================================
* B05_Profile_UI_Structures_Define_Form.ts
* 손코딩 시설 폼(동작 「계곡통과시설」)에 정의를 붙임(PLAN 48-3 · 48-4) — 숨긴 선택지 · 규칙.
*
* · 칸은 `labeled(..., 옵션키)` 가 붙인 `data-option-keys` 표식으로 찾음(키 하나인 칸만) · 찾는 자리 =
* 폼 본문 전체(그룹 상자로 옮겨 간 칸까지).
* · 칸 배치 · 이름은 손코딩 그대로(동작이 그림) — 안 씀은 끈 옵션과 같은 길(`_Drainage_Facility_Hidden`).
* · 선택지 = 정의 · 규칙 선택지 — 새 선택지는 보기를 더하고 밖 보기는 `hidden` 으로만 가림(옛 값은 그대로).
* 여러 키를 함께 적는 칸(유입구 구조 · 본체 규격)은 키 하나 표식이 아니라 안 건드림.
* · 규칙이 가린 칸 = `is-rule-hidden`(끈 옵션 표식 `is-option-hidden` 과 따로).
* ========================================================================== */
import type { StructureType } from "./B05_Profile_Api_Structures";
import { ruleDefaults, ruleStates } from "./B05_Profile_UI_Structures_Define";
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
/** 시설 폼이 가릴 옵션 키 — 「구조물 옵션 보이기」에서 끈 것 + 정의 「안 씀」. */
export function hiddenOptionKeys(type: StructureType | null): string[] {
return (type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key);
}
export interface FacilityDefinition {
/** 지금 종류 정의로 칸 모양을 맞춤 — 시설 폼을 채운 뒤마다 부름. */
apply: (type: StructureType | null) => void;
}
export function createFacilityDefinition(root: HTMLElement): FacilityDefinition {
let type: StructureType | null = null;
const wrapOf = (key: string): HTMLElement | null =>
[...root.querySelectorAll<HTMLElement>("[data-option-keys]")].find(
(node) => node.dataset.optionKeys === key,
) ?? null;
const controlOf = (key: string): HTMLInputElement | HTMLSelectElement | null =>
wrapOf(key)?.querySelector<HTMLInputElement | HTMLSelectElement>("select, input") ?? null;
const values = (): Record<string, string> =>
Object.fromEntries((type?.options ?? []).map((o) => [o.key, controlOf(o.key)?.value ?? ""]));
const paint = (): void => {
if (!type) return;
const states = ruleStates(type, values());
for (const option of type.options) {
const wrap = wrapOf(option.key);
const state = states.get(option.key);
if (!wrap || !state) continue;
wrap.classList.toggle("is-rule-hidden", !state.visible);
const control = controlOf(option.key);
if (!(control instanceof HTMLSelectElement)) continue;
// 정의에 새로 든 선택지는 보기를 더하고, 정의 · 규칙 밖 보기는 가림(빈 보기 · 지금 값은 그대로)
const have = new Set([...control.options].map((item) => item.value));
for (const choice of state.choices)
if (!have.has(choice)) control.add(new Option(choice, choice));
for (const item of control.options) {
item.hidden =
!!item.value && !state.choices.includes(item.value) && item.value !== control.value;
}
}
};
// 상위 칸이 바뀌면 걸린 규칙의 기본값을 하위 칸에 넣고, 그 칸의 change 로 폼 반영(시설 폼 emit)을 잇는다.
root.addEventListener("change", (event) => {
if (!type) return;
const key = (event.target as HTMLElement).closest<HTMLElement>("[data-option-keys]")?.dataset
.optionKeys;
if (key) {
for (const [child, value] of Object.entries(ruleDefaults(type, key, values()))) {
const control = controlOf(child);
if (!control || control.value === String(value)) continue;
control.value = String(value);
control.dispatchEvent(new Event("change", { bubbles: true }));
}
}
paint();
});
return {
apply(next) {
type = next;
root
.querySelectorAll(".is-rule-hidden")
.forEach((node) => node.classList.remove("is-rule-hidden"));
root.querySelectorAll<HTMLOptionElement>("option[hidden]").forEach((o) => (o.hidden = false));
paint();
},
};
}
+19 -29
View File
@@ -61,9 +61,8 @@ interface OptionShape {
}
/**
* 등록부 옵션 입력 한 칸 — **저장된 값만 칸에 넣고, 등록부 기본값은 제안으로만** 보임
* (숫자·글 칸은 회색 글씨, 고르기 칸은 빈 보기 이름). 2026-09-14 브레인 판정 「기본값을 몰래
* 확정으로 바꾸지 않는다」 — 칸에 채워 두면 저장이 그 값을 적어 사용자가 한 일 없이 확정이 됨.
* 등록부 옵션 입력 한 칸 — **받은 값만 칸에 넣음**. 새 구조물은 부르는 쪽이 등록부 기본값을 값으로
* 실어 줌(PLAN 46-7 · 제안값 단추 없음) · 옛 기록의 빈 칸은 빈 칸 그대로(미확정).
* 고르기 칸은 늘 빈 보기를 둠 — 첫 보기를 미리 고르지 않음.
*/
export function optionControl(
@@ -71,16 +70,16 @@ export function optionControl(
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = suggested
? `— 안 정함 (제안 ${suggested}) —`
: option.required
? "— 선택 —"
: "— 안 정함 —";
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
const blank = option.required ? "— 선택 —" : "— 안 정함 —";
// 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음)
const kept = value && !option.choices.includes(value) ? [value] : [];
const element = select([
["", blank],
...[...option.choices, ...kept].map((c) => [c, c] as [string, string]),
]);
element.value = value;
if (tip) element.title = tip;
return element;
@@ -89,7 +88,7 @@ export function optionControl(
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
element.placeholder = option.required ? "필수 입력" : "";
if (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
@@ -105,24 +104,15 @@ export function optionControl(
return element;
}
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
export function suggestButton(
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-structure__suggest";
button.textContent = "제안값 넣기";
button.addEventListener("click", () => {
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
filled.forEach((entry) => {
entry.input.value = entry.suggested;
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
});
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
});
return button;
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
export function optionDefaults(
options: ReadonlyArray<{ key: string; default: string | number | null }>,
): Record<string, string | number> {
return Object.fromEntries(
options
.filter((option) => option.default !== null && option.default !== "")
.map((option) => [option.key, option.default as string | number]),
);
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
@@ -23,6 +23,11 @@ import {
} from "./B05_Profile_UI_Structures_Fields";
import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
import {
createFacilityDefinition,
type FacilityDefinition,
} from "./B05_Profile_UI_Structures_Define_Form";
export interface StructuresFormElements {
root: HTMLElement;
@@ -45,6 +50,10 @@ export interface StructuresFormElements {
/** 버튼 묶음 — 패널이 그 앞에 메모 칸을 끼워 넣는다. */
actions: HTMLElement;
facilityOptions: ReturnType<typeof createFacilityOptionsForm>;
/** 「지난 정의 값」 읽기만 상자(48-3) — 지금 정의에 없는 키의 값. */
staleBox: HTMLElement;
/** 시설 폼(손코딩)에 정의 붙이기(48-3 · 48-4) — 숨긴 선택지 · 규칙. */
facilityDefinition: FacilityDefinition;
primary: HTMLButtonElement;
removeButton: HTMLButtonElement;
resetButton: HTMLButtonElement;
@@ -52,6 +61,8 @@ export interface StructuresFormElements {
list: HTMLElement;
/** 옵션 · 시설 서브폼 아래 「집계 항목」 줄(PLAN 21-4) — 이름 · [고르기] · [해제]. */
summary: SummaryRow;
/** 본체 집계 항목 아래 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 · 집계 항목. */
groupBoxes: GroupBoxes;
}
/** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */
@@ -144,6 +155,10 @@ export function buildStructuresForm(options: {
// 집계 항목 줄 — 종류를 고른 뒤에만 보인다(패널이 `syncPlacementFields` 에서 켠다).
const summary = buildSummaryRow();
summary.root.hidden = true;
const groupBoxes = createGroupBoxes();
const staleBox = document.createElement("fieldset");
staleBox.className = "b05-structure__group b05-structure__stale";
staleBox.hidden = true;
body.append(
typeRow,
@@ -152,7 +167,9 @@ export function buildStructuresForm(options: {
positionDivider,
optionRow,
facilityOptions.root,
staleBox,
summary.root,
groupBoxes.root,
actions,
);
@@ -173,10 +190,13 @@ export function buildStructuresForm(options: {
optionRow,
actions,
facilityOptions,
staleBox,
facilityDefinition: createFacilityDefinition(body),
primary,
removeButton,
resetButton,
list,
summary,
groupBoxes,
};
}
@@ -0,0 +1,290 @@
/* =============================================================================
* B05_Profile_UI_Structures_Groups.ts
* 구조물 폼의 **옵션 그룹 보조 컨테이너**(PLAN 46-3) — M02 「구조물 옵션 보이기」에서 종류마다 만든
* 그룹(`StructureType.groups`)마다 상자 하나(제목 = 그룹 이름) · 그 그룹 옵션 칸 · 「집계 항목」 줄.
*
* · 칸은 새로 그리지 않고 **옮긴다** — 레지스트리 옵션 칸 · 계곡 통과 시설 칸이 붙인
* `data-option-keys` 표식으로 찾아 상자 안으로 옮기고, 제자리에는 표지(주석 노드)를 둔다. 값 읽기 ·
* 이벤트는 칸 참조 그대로라 안 바뀜. 다시 세울 때(`render`)마다 먼저 제자리로 되돌림.
* · 키가 여럿인 칸(본체 규격 · 유입구 구조)은 **키가 다 그 그룹일 때만** 옮김(숨김 규칙과 같음 ·
* 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘).
* · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄).
* · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠.
* · 그룹 형상 칸(48 정의 그룹 `형상` · 길이 · 높이 · 전 · 후 받기 켬끔) — 값은 구조물 옵션
* `{그룹 id}__{칸}` 으로 저장(패널이 옵션에 얹음) · 계산은 안 함(48-5).
* ========================================================================== */
import { openItemPicker } from "@ui/ui_template_item_picker";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import type { StructureType } from "./B05_Profile_Api_Structures";
import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
import { field, numberInput } from "./B05_Profile_UI_Structures_Fields";
import {
GROUP_JOIN,
type GroupItems,
type SummaryItem,
} from "../common_util/common_util_summary_item";
export interface GroupBoxes {
/** 상자를 담는 자리 — 폼이 본체 「집계 항목」 줄 아래에 붙임. */
root: HTMLElement;
/** 종류 · 칸에 맞춰 상자를 다시 세움 — `scopes` 안 표식 칸을 그룹 상자로 옮김. */
render: (type: StructureType | null, scopes: HTMLElement[], facility: string | null) => void;
get: () => GroupItems;
/** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */
set: (items: GroupItems) => void;
/** 한 그룹 값 — null = 해제. */
setOne: (groupId: string, item: SummaryItem | null) => void;
/** [고르기] · [해제] · 형상 칸 고침 배선 — 패널이 한 번 부른다. */
bind: (handlers: GroupHandlers) => void;
/** 형상 칸 값 `{그룹 id}__{칸}: 수` — 빈 칸은 뺌. 옵션에 얹어 저장. */
shapes: () => Record<string, number>;
/** 지금 종류 그룹의 형상 키 전부(받기 끈 칸 포함) — 저장 병합 · 지난 정의 값 판정. */
shapeKeys: (type?: StructureType | null) => string[];
/** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */
setShapes: (options: Record<string, string | number> | undefined) => void;
}
interface GroupHandlers {
pick: (groupId: string) => void;
clear: (groupId: string) => void;
change?: () => void;
}
export function createGroupBoxes(): GroupBoxes {
const root = document.createElement("div");
root.className = "b05-structure__optgroups";
root.dataset.b05Groups = "";
root.hidden = true;
let items: GroupItems = {};
let handlers: GroupHandlers | null = null;
let shapes: Record<string, string> = {};
let current: StructureType | null = null;
const keysOf = (type: StructureType | null | undefined): string[] =>
(type?.groups ?? []).flatMap((g) => SHAPE_FIELDS.map((f) => shapeKey(g.id, f.suffix)));
const shapeInputs = new Map<string, HTMLInputElement>();
const rows = new Map<string, SummaryRow>();
/** 옮긴 칸 · 제자리 표지 */
const moved: Array<{ node: HTMLElement; mark: Comment }> = [];
/** 칸이 다 빠져 가린 줄 · 소그룹 상자 */
const emptied: HTMLElement[] = [];
/** 그룹 상자 몸통 — 옮겨 온 칸이 원래 자리처럼 잠기게(측점 확정 전) */
let bodies: HTMLElement[] = [];
let lockSource: HTMLElement | null = null;
const syncLock = (): void => {
const locked = !!lockSource?.inert;
bodies.forEach((body) => {
body.inert = locked;
body.classList.toggle("is-locked", locked);
});
};
const lockWatch = typeof MutationObserver !== "undefined" ? new MutationObserver(syncLock) : null;
const restore = (): void => {
for (const { node, mark } of moved.splice(0)) {
if (mark.isConnected) mark.replaceWith(node);
else node.remove(); // 원래 자리가 다시 그려졌음(레지스트리 칸) — 옛 칸은 버림
}
emptied.splice(0).forEach((node) => node.classList.remove("is-group-emptied"));
};
const paintStale = (known: Set<string>): HTMLElement | null => {
const stale = Object.keys(items).filter((id) => !known.has(id));
if (!stale.length) return null;
const note = document.createElement("p");
note.className = "b05-structure__owner-note";
note.textContent = `정의가 사라진 그룹 연결 ${stale.length}개 — 값만 남음(집계표에는 안 들어감)`;
return note;
};
return {
root,
render(type, scopes, facility) {
restore();
rows.clear();
shapeInputs.clear();
current = type;
const groups = type?.groups ?? [];
const boxes: HTMLElement[] = [];
bodies = [];
// 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식)
const off = hiddenFormKeys(
facility,
(type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key),
);
for (const group of groups) {
const keys = hiddenFormKeys(facility, group.options);
const box = document.createElement("fieldset");
box.className = "b05-structure__group b05-structure__optgroup";
box.dataset.b05Group = group.id;
const legend = document.createElement("legend");
legend.textContent = group.name;
const body = document.createElement("div");
body.className = "b05-structure__grid";
for (const scope of scopes) {
const marked = [...scope.querySelectorAll<HTMLElement>("[data-option-keys]")];
// 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게
const shown = (node: HTMLElement): boolean => {
for (let at: HTMLElement | null = node; at && at !== scope; at = at.parentElement)
if (at.hidden) return false;
return true;
};
const fits = marked.filter(
(node) =>
shown(node) &&
(node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)),
);
// 바깥 칸만 — 옮길 칸 안의 칸은 따라 옴
for (const node of fits.filter((n) => !fits.some((o) => o !== n && o.contains(n)))) {
const parent = node.parentElement;
const mark = document.createComment(`옵션 그룹 ${group.id}`);
node.before(mark);
body.append(node);
moved.push({ node, mark });
const own = (node.dataset.optionKeys ?? "").split(" ");
node.classList.toggle(
"is-option-hidden",
own.every((key) => off.has(key)),
);
// 칸이 다 빠진 줄 · 소그룹 상자는 가림(빈 줄 · 빈 상자 제목이 안 남게)
for (let at = parent; at && at !== scope; at = at.parentElement) {
if (at.querySelector("input, select, textarea")) break;
at.classList.add("is-group-emptied");
emptied.push(at);
}
}
}
for (const shape of shapeFields(group.shape)) {
const key = shapeKey(group.id, shape.suffix);
const input = numberInput("0.1", "0");
input.value = shapes[key] ?? "";
input.dataset.shapeKey = key;
input.title = shape.meaning;
input.addEventListener("change", () => {
shapes = { ...shapes, [key]: input.value.trim() };
handlers?.change?.();
});
shapeInputs.set(key, input);
body.append(field(shape.label, input));
}
if (!body.childElementCount) {
const none = document.createElement("p");
none.className = "b05-structure__owner-note";
none.textContent = "옵션 없음 — 집계 항목만 지정";
body.append(none);
}
const row = buildSummaryRow();
row.set(items[group.id] ?? null);
row.bind({
pick: () => handlers?.pick(group.id),
clear: () => handlers?.clear(group.id),
});
rows.set(group.id, row);
box.append(legend, body, row.root);
boxes.push(box);
bodies.push(body);
}
const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null;
root.replaceChildren(...boxes, ...(stale ? [stale] : []));
root.hidden = !type || (!boxes.length && !stale);
// 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로
const facilityRoot = scopes.find((scope) =>
scope.classList.contains("b05-drainage__facility"),
);
if (facilityRoot && facilityRoot !== lockSource) {
lockSource = facilityRoot;
lockWatch?.observe(facilityRoot, { attributes: true, attributeFilter: ["inert"] });
}
syncLock();
},
get: () => ({ ...items }),
set(next) {
items = { ...next };
for (const [id, row] of rows) row.set(items[id] ?? null);
},
setOne(groupId, item) {
if (item) items = { ...items, [groupId]: item };
else {
const { [groupId]: _gone, ...rest } = items;
items = rest;
}
rows.get(groupId)?.set(item);
},
bind(next) {
handlers = next;
},
shapes() {
const out: Record<string, number> = {};
for (const key of shapeInputs.keys()) {
const text = (shapes[key] ?? "").trim();
if (text !== "" && Number.isFinite(Number(text))) out[key] = Number(text);
}
return out;
},
shapeKeys: (type) => keysOf(type === undefined ? current : type),
setShapes(options) {
shapes = Object.fromEntries(
Object.entries(options ?? {})
.filter(([key]) => key.includes(GROUP_JOIN))
.map(([key, value]) => [key, String(value)]),
);
for (const [key, input] of shapeInputs) input.value = shapes[key] ?? "";
},
};
}
/** 본체 · 그룹 「집계 항목」 [고르기] · [해제] 배선 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고
* 고치는 중이면 곧바로 반영(새 항목은 종전대로 [추가]가 확정) · 안 고른 채 닫으면 그대로.
* 복사본은 구조물마다(고치는 구조물 id · 관 지점 = `pipe@측점`) · 그룹은 `{id}__{그룹 id}` ·
* 추가 전이면 null(원본 보기만). */
export function bindSummaryPickers(opts: {
summary: SummaryRow;
groups: GroupBoxes;
projectId: string | null;
editingId: () => string | null;
selectedPipeChainage: () => number | null;
commitSummary: () => void;
/** 형상 칸 고침 반영 — 고치는 중이면 곧바로(새 항목은 [추가]가 확정). */
liveCommit: () => void;
}): void {
const base = (): string | null => {
const pipe = opts.selectedPipeChainage();
return opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null);
};
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
const pick = (current: SummaryItem | null, structureId: string | null) =>
openItemPicker({ current, canCopy: true, projectId: opts.projectId, structureId });
opts.summary.bind({
pick: () =>
void pick(opts.summary.get(), base()).then((result) => {
if (result.action === "cancel") return;
opts.summary.set(result.action === "apply" ? result.item : null);
opts.commitSummary();
}),
clear: () => {
opts.summary.set(null);
opts.commitSummary();
},
});
opts.groups.bind({
change: opts.liveCommit,
pick: (groupId) => {
const id = base();
void pick(
opts.groups.get()[groupId] ?? null,
id ? `${id}${GROUP_JOIN}${groupId}` : null,
).then((result) => {
if (result.action === "cancel") return;
opts.groups.setOne(groupId, result.action === "apply" ? result.item : null);
opts.commitSummary();
});
},
clear: (groupId) => {
opts.groups.setOne(groupId, null);
opts.commitSummary();
},
});
}
+54 -44
View File
@@ -22,10 +22,12 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field } from "./B05_Profile_UI_Structures_Fields";
import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options";
import { openItemPicker } from "@ui/ui_template_item_picker";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import {
paintStale,
renderOptionFields as renderOptionFieldsInto,
} from "./B05_Profile_UI_Structures_Panel_Options";
import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -39,7 +41,8 @@ import {
loadPipeForm as loadPipeFormInto,
} from "./B05_Profile_UI_Structures_Panel_Commit";
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
import { showsAnchor } from "./B05_Profile_UI_Structures_Define";
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
@@ -67,7 +70,8 @@ export function createStructuresSection(
// 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만.
const form = buildStructuresForm({
getInterval: () => callbacks.getInterval(),
onFacilityChange: () => liveCommit(),
// 갈래(유입구 구조 등)가 바뀌면 보이는 칸이 달라짐 — 그룹 상자도 다시 세움(PLAN 46-3)
onFacilityChange: () => (regroup(), liveCommit()),
});
const {
root,
@@ -86,12 +90,22 @@ export function createStructuresSection(
optionRow,
actions,
facilityOptions,
staleBox,
facilityDefinition,
primary,
removeButton,
resetButton,
list,
summary,
groupBoxes,
} = form;
/** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */
const regroup = (): void =>
groupBoxes.render(
currentType(),
[optionRow, facilityOptions.root],
facilityFormKind(currentType()),
);
/** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신
* 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션
@@ -224,6 +238,8 @@ export function createStructuresSection(
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
: "";
ownerNote.hidden = !type?.design_owner;
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
@@ -232,16 +248,17 @@ export function createStructuresSection(
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
function syncFacilityForm(
options: Record<string, string | number> = {},
options?: Record<string, string | number>,
designFlow: number | null = null,
): void {
const kind = facilityFormKind(currentType());
facilityOptions.setFacility(kind, options, designFlow);
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow);
if (kind)
paintStale(staleBox, currentType(), options, kind, groupBoxes.shapeKeys(currentType()));
// 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장)
facilityOptions.setHiddenOptions(
kind,
(currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key),
);
facilityOptions.setHiddenOptions(kind, hiddenOptionKeys(currentType()));
regroup();
facilityDefinition.apply(kind ? currentType() : null); // 숨긴 선택지 · 규칙(48-3 · 48-4)
syncOptionLock();
}
@@ -310,8 +327,16 @@ export function createStructuresSection(
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
function renderOptionFields(values: Record<string, string | number> = {}): void {
renderOptionFieldsInto(optionContext, values);
function renderOptionFields(values?: Record<string, string | number>): void {
renderOptionFieldsInto(optionContext, values ?? newDefaults());
if (!facilityFormKind(currentType()))
paintStale(staleBox, currentType(), values, null, groupBoxes.shapeKeys(currentType()));
regroup();
}
/** 값 없이 그리면 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 옛 기록은 받은 값만). */
function newDefaults(): Record<string, string | number> {
return optionDefaults(currentType()?.options ?? []);
}
/** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도
@@ -491,31 +516,18 @@ export function createStructuresSection(
},
summary: () => summary.get(),
setSummary: (value) => summary.set(value),
groupBoxes,
};
bindStructuresEvents(panelContext);
// 집계 항목 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 고치는 중이면 곧바로 반영한다
// (새 항목은 종전대로 [추가]가 확정). 안 고른 채 닫으면 그대로.
summary.bind({
pick: () => {
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
void openItemPicker({
current: summary.get(),
canCopy: true,
projectId: sectionOptions.projectId ?? null,
// 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만)
structureId:
editingId ??
(selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null),
}).then((result) => {
if (result.action === "cancel") return;
summary.set(result.action === "apply" ? result.item : null);
commitSummary();
});
},
clear: () => {
summary.set(null);
commitSummary();
},
// 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups`
bindSummaryPickers({
summary,
groups: groupBoxes,
projectId: sectionOptions.projectId ?? null,
editingId: () => editingId,
selectedPipeChainage: () => selectedPipeChainage,
commitSummary,
liveCommit,
});
body.insertBefore(field("메모", memoField), actions);
syncButtons();
@@ -528,11 +540,11 @@ export function createStructuresSection(
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
setTypes(next) {
types = next;
const groups = [...new Set(next.map((t) => t.group))];
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다.
const groups = new Map(next.map((t) => [t.group, t.group_name] as const));
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저.
groupSelect.replaceChildren(
new Option("— 선택 —", ""),
...groups.map((group) => new Option(GROUP_LABELS[group] ?? group, group)),
...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)),
);
groupSelect.value = "";
syncTypeOptions();
@@ -643,11 +655,9 @@ export function createStructuresSection(
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
// 새 구조물 — 등록부 기본값을 칸에 실제 값으로(PLAN 46-7 · 사용자가 고치면 됨).
renderOptionFields();
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
syncFacilityForm();
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();
@@ -18,7 +18,13 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D
import type { StationFields } from "./B05_Profile_UI_Structures_Fields";
import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement";
import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary";
import { readSummaryItem, type SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
import {
readGroupItems,
readSummaryItem,
type GroupItems,
type SummaryItem,
} from "../common_util/common_util_summary_item";
import type {
PipeFacilityItem,
StructuresCallbacks,
@@ -69,6 +75,15 @@ export interface StructuresCommitContext {
/** 폼의 「집계 항목」 값(PLAN 21-4) — 저장은 옵션 밖 `summary_item` 칸으로 나간다. */
summary: () => SummaryItem | null;
setSummary: (value: SummaryItem | null) => void;
/** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */
/** 옵션 그룹 상자 — 집계 항목(46-3) · 형상 칸 값(48-3). */
groupBoxes: GroupBoxes;
}
/** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */
function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } {
const items = ctx.groupBoxes.get();
return Object.keys(items).length ? { group_items: items } : {};
}
export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void {
@@ -91,6 +106,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
ctx.memoField.value = target.memo ?? "";
ctx.renderOptionFields(target.options);
ctx.setSummary(readSummaryItem(target.summary_item));
ctx.groupBoxes.set(readGroupItems(target.group_items));
ctx.groupBoxes.setShapes(target.options);
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) {
@@ -119,6 +136,8 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
ctx.memoField.value = "";
ctx.renderOptionFields();
ctx.setSummary(null);
ctx.groupBoxes.set({});
ctx.groupBoxes.setShapes({});
ctx.syncFacilityForm();
}
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
@@ -166,6 +185,8 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
ctx.memoField.value = "";
ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null);
ctx.setSummary(readSummaryItem(pipe.summary_item));
ctx.groupBoxes.set(readGroupItems(pipe.group_items));
ctx.groupBoxes.setShapes(pipe.options);
[ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid());
ctx.syncPlacementFields();
ctx.syncButtons();
@@ -174,9 +195,11 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte
}
export function readOptions(ctx: StructuresCommitContext): Record<string, string | number> {
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다.
if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions();
const values: Record<string, string | number> = {};
// 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. 그룹 형상 칸(48-3)은 둘 다 얹음.
const shapes = ctx.groupBoxes.shapes();
if (ctx.facilityFormKind(ctx.currentType()))
return { ...ctx.facilityOptions.readOptions(), ...shapes };
const values: Record<string, string | number> = { ...shapes };
ctx.optionInputs.forEach((input) => {
// 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다.
if (input.isEmpty()) return;
@@ -201,6 +224,9 @@ export function commitSummaryOnly(ctx: StructuresCommitContext): boolean {
const summary = ctx.summary();
if (summary) next.summary_item = summary;
else delete next.summary_item;
const groups = groupField(ctx);
if (groups.group_items) next.group_items = groups.group_items;
else delete next.group_items;
ctx.structures[index] = next;
emit(ctx);
return true;
@@ -221,10 +247,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
}
// 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2).
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
const options = ctx.facilityOptions.readOptions();
const options = readOptions(ctx);
if (Object.keys(options).length) attributes.options = options;
// 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다.
attributes.summary_item = ctx.summary();
attributes.group_items = groupField(ctx).group_items ?? null; // 빈 것도 실어 옛 지정을 풂
if (ctx.tempPipeChainage() !== null) {
// 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영.
const from = ctx.tempPipeChainage() as number;
@@ -306,6 +333,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
end_m: end,
options: readOptions(ctx),
...(summary ? { summary_item: summary } : {}),
...groupField(ctx),
memo: ctx.memoField.value.trim(),
placement_source: "manual" as const,
status: "draft" as const,
@@ -341,12 +369,13 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
options: mergeStructureOptions(
previous,
type.type_id,
ctx.optionInputs.map((entry) => entry.key),
[...ctx.optionInputs.map((entry) => entry.key), ...ctx.groupBoxes.shapeKeys()],
records[0].options,
),
};
// 해제 = 칸을 지움(옛 지정이 `...previous` 로 살아 오지 않게)
if (!summary) delete next.summary_item;
if (!groupField(ctx).group_items) delete next.group_items;
ctx.structures[index] = next;
}
records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null }));
@@ -10,8 +10,16 @@
import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
import { field, optionControl } from "./B05_Profile_UI_Structures_Fields";
import {
columnsOf,
gridPlace,
orderedOptions,
ruleDefaults,
ruleStates,
staleValues,
} from "./B05_Profile_UI_Structures_Define";
import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
export interface OptionRenderContext {
@@ -46,40 +54,48 @@ export function renderOptionFields(
optionInputs.length = 0;
optionRow.replaceChildren();
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
// 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`).
const shown =
type && !ctx.facilityFormKind(type)
? type.options.filter((o) => (ctx.includeDetail || isB05Option(o)) && isOptionVisible(o))
? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o))
: [];
if (!shown.length) {
optionRow.style.gridTemplateColumns = "";
if (!type || !shown.length) {
optionRow.hidden = true;
ctx.syncOptionLock();
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> = [];
const columns = columnsOf(type);
if (columns !== 2) optionRow.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`;
// 규칙(48-4)이 가린 칸은 `optionInputs` 에서 뺌 — 필수 검사 · 저장이 안 봄(옛 값은 병합이 지킴)
const all: Array<{ entry: (typeof optionInputs)[number]; wrap: HTMLElement }> = [];
shown.forEach((option) => {
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
// 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7).
// 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘.
const input = optionControl(option, values[option.key]);
suggestions.push({ input, suggested: String(option.default ?? "") });
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
const locked = option.enabled === false;
if (locked && !input.value) input.value = String(option.default ?? "");
if (locked) {
input.disabled = true;
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input, option.default_basis));
input.addEventListener("change", () => ctx.liveCommit());
optionInputs.push({
key: option.key,
required: !locked && !!option.required,
input,
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
isEmpty: () => input.value.trim() === "",
const wrap = field(label, input, option.default_basis);
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
// 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
const place = gridPlace(type.spots?.[option.key], columns);
wrap.style.gridRow = place?.row ?? "";
wrap.style.gridColumn = place?.column ?? "";
optionRow.append(wrap);
// 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4)
input.addEventListener("change", () => {
applyRules(option.key);
ctx.liveCommit();
});
all.push({
wrap,
entry: {
key: option.key,
required: !!option.required,
input,
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
isEmpty: () => input.value.trim() === "",
},
});
// 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을
// 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다.
@@ -111,9 +127,74 @@ export function renderOptionFields(
input.addEventListener("change", () => refresh(true));
}
});
if (suggestions.some((entry) => entry.suggested)) {
optionRow.append(suggestButton(suggestions));
/** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */
function applyRules(parent: string | null): void {
const now = (): Record<string, string> =>
Object.fromEntries(all.map(({ entry }) => [entry.key, entry.input.value]));
if (parent) {
for (const [key, value] of Object.entries(ruleDefaults(type!, parent, now()))) {
const hit = all.find(({ entry }) => entry.key === key);
if (hit) hit.entry.input.value = String(value);
}
}
const next = ruleStates(type!, now());
optionInputs.length = 0;
for (const { entry, wrap } of all) {
const state = next.get(entry.key);
const off = state?.visible === false;
wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식
if (!off) optionInputs.push(entry);
if (state && entry.input.tagName.toLowerCase() === "select")
syncChoices(entry.input as HTMLSelectElement, state.choices);
}
}
applyRules(null);
ctx.syncOptionLock();
ctx.syncRangeDisplay();
}
/** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */
function syncChoices(element: HTMLSelectElement, choices: string[]): void {
const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value);
const keep = element.value && !choices.includes(element.value) ? [element.value] : [];
const want = [...choices, ...keep];
if (now.length === want.length && now.every((v, i) => v === want[i])) return;
const value = element.value;
const blank = element.children[0];
element.replaceChildren(blank, ...want.map((c) => new Option(c, c)));
element.value = value;
}
/** 「지난 정의 값」 상자(48-3) — 지금 정의에 없는 키의 값을 읽기만 칸으로 보임(오류 없음 · 저장은
* 병합이 옛 값 그대로 지킴). 아는 키 = 정의 옵션 전부(안 씀 포함) + 시설 폼이 적는 칸. */
export function paintStale(
box: HTMLElement,
type: StructureType | null,
values: Record<string, string | number> | undefined,
facility: PipeFacility | null,
/** 그룹 형상 키(`{그룹 id}__{칸}`) — 정의된 그룹 몫이라 지난 값이 아님. */
shapeKeys: string[] = [],
): void {
const known = [
...shapeKeys,
...(type?.options ?? []).map((o) => o.key),
...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []),
];
const stale = type ? staleValues(values, known) : [];
box.replaceChildren();
box.hidden = !stale.length;
if (!stale.length) return;
const legend = document.createElement("legend");
legend.textContent = "지난 정의 값";
const body = document.createElement("div");
body.className = "b05-structure__grid";
for (const [key, value] of stale) {
const input = document.createElement("input");
input.type = "text";
input.readOnly = true;
input.value = String(value);
input.title = "지금 정의에 없는 칸 — 읽기만 · 계산에 안 쓰임";
body.append(field(key, input));
}
box.append(legend, body);
}
@@ -19,7 +19,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
import type { SummaryItem } from "../common_util/common_util_summary_item";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -51,6 +51,8 @@ export interface PipeFacilityItem {
options?: Record<string, string | number>;
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
summary_item?: SummaryItem | null;
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
group_items?: GroupItems | null;
}
/** 관 지점 정본 한 건 → 목록·폼 항목. 집계 항목(21-1)도 옮긴다 — 안 옮기면 목록 색 · 폼이 잃는다. */
@@ -69,6 +71,7 @@ export function pipeToFacilityItem(
design_flow_m3s: pipe.design_flow_m3s,
options: pipe.options,
summary_item: pipe.summary_item,
group_items: pipe.group_items,
};
}
@@ -8,9 +8,9 @@
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 일 때만 숨김. 없으면 보임. */
export function isOptionVisible(option: { visible?: boolean }): boolean {
return option.visible !== false;
/** 옵션 보이기 — 서버가 채운 `visible`(21-8)이 false 이거나 정의가 「안 씀」(`enabled:false` · 48)일 때 숨김. */
export function isOptionVisible(option: { visible?: boolean; enabled?: boolean }): boolean {
return option.visible !== false && option.enabled !== false;
}
/** 저장할 옵션 — 같은 종류를 고치는 중이면 폼이 안 그린 옛 칸을 이어 붙임.
+1 -28
View File
@@ -111,37 +111,10 @@
opacity: 1;
}
/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */
/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작) · 모양은 공용 `.ui-ctx-menu`. */
.b05-drainage__context-menu {
position: absolute;
z-index: 6;
display: flex;
flex-direction: column;
min-width: 130px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
}
.b05-drainage__context-menu[hidden] {
display: none;
}
.b05-drainage__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
border: 0;
border-radius: var(--radius-cards);
background: transparent;
color: var(--color-text-body);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.b05-drainage__context-menu-item:hover {
background: var(--color-surface-sunken);
}
.b05-drainage__viewport {
@@ -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 사용자 지시 ③).
@@ -14,8 +14,9 @@
z-index: calc(var(--z-modal, 1000) + 2);
}
/* 오른쪽 설명 칸(계획서 48-2)만큼 넓힘 — 왼쪽 기존 칸은 예전 폭 그대로. */
.ui-modal.b05-routerec {
width: min(960px, 94vw);
width: min(1320px, 96vw);
--modal-max: 92vh;
font-size: var(--text-body-sm);
}
@@ -25,6 +26,119 @@
font-size: var(--text-body-sm);
}
/* 왼쪽 = 기존 칸 넷(알약 · 지도 · 결과 줄 · 바닥) · 오른쪽 = 세로 구분선 + 설명 칸. */
.ui-modal.b05-routerec .b05-routerec__body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
align-content: start;
column-gap: var(--spacing-16, 16px);
}
.b05-routerec__body > :not(.b05-routerec__help) {
grid-column: 1;
}
/* 설명 칸 — 왼쪽 높이를 따라가고(제 글 길이로 창을 키우지 않음) 넘치면 제 안에서 굴림. */
.b05-routerec__help {
grid-column: 2;
grid-row: 1 / span 4;
contain: size;
overflow-y: auto;
padding-left: var(--spacing-16, 16px);
border-left: 1px solid var(--color-border);
color: var(--color-text-body);
font-size: var(--text-body-sm);
line-height: 1.55;
}
.b05-routerec__help h3 {
margin: var(--spacing-12) 0 var(--spacing-4, 4px);
color: var(--color-text-heading, var(--color-text-body));
font-size: var(--text-body);
font-weight: 700;
}
.b05-routerec__help h3:first-child {
margin-top: 0;
}
.b05-routerec__help-lead {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
}
.b05-routerec__help-item {
margin-bottom: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-8, 6px);
}
/* 고른 갈래 — 알약과 같은 강조색 테두리. */
.b05-routerec__help-item.is-on {
border-color: var(--color-primary);
background: var(--color-surface);
}
.b05-routerec__help-item h4 {
margin: 0 0 2px;
font-size: var(--text-body-sm);
font-weight: 700;
}
.b05-routerec__help-item.is-on h4 {
color: var(--color-primary);
}
.b05-routerec__help-item p,
.b05-routerec__help dd {
margin: 2px 0;
}
.b05-routerec__help-item ul {
margin: 2px 0;
padding-left: 1.1em;
}
.b05-routerec__help-summary {
font-weight: 600;
}
.b05-routerec__help-caution {
color: var(--color-text-secondary);
}
.b05-routerec__help dl {
margin: 0;
}
.b05-routerec__help dt {
margin-top: var(--spacing-8);
font-weight: 600;
}
.b05-routerec__help dd {
color: var(--color-text-secondary);
}
/* 좁은 폭 — 설명 칸은 아래로(가로 구분선 · 제 높이 한도 안에서 굴림). */
@media (max-width: 1000px) {
.ui-modal.b05-routerec .b05-routerec__body {
grid-template-columns: minmax(0, 1fr);
}
.b05-routerec__help {
grid-column: 1;
grid-row: auto;
contain: none;
max-height: 280px;
padding-top: var(--spacing-12);
padding-left: 0;
border-top: 1px solid var(--color-border);
border-left: none;
}
}
/* 알약 — 하나만 고른 상태(라디오 묶음). */
/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */
.b05-routerec__pills {
@@ -221,11 +221,6 @@
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
@@ -489,3 +484,44 @@
.b05-drainage__facility .is-option-hidden {
display: none;
}
/* 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 하나 · 옮겨 온 칸 · 「집계 항목」 줄 */
.b05-structure__optgroups {
display: flex;
flex-direction: column;
gap: 6px;
}
.b05-structure__optgroups[hidden],
.is-group-emptied {
display: none;
}
.b05-structure__optgroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.b05-structure__optgroup > legend {
font-weight: 600;
opacity: 1;
}
.b05-structure__optgroup .is-option-hidden {
display: none;
}
.b05-structure__optgroup > .b05-structure__grid.is-locked {
opacity: 0.5;
}
/* 구조물 정의 규칙(PLAN 48-4)이 가린 칸 — 그룹 상자로 옮겨 가도 같은 표식 */
.b05-structure-section .is-rule-hidden {
display: none;
}
/* 「지난 정의 값」 읽기만 상자(PLAN 48-3) — 지금 정의에 없는 키의 값 */
.b05-structure__stale input[readonly] {
opacity: 0.7;
}
+2 -47
View File
@@ -194,62 +194,17 @@
/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ──────────────
세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */
/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */
/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) · 모양은 공용 `.ui-ctx-menu` ── */
.b05-profile-chart__context-menu {
position: absolute;
z-index: 7;
display: flex;
flex-direction: column;
min-width: 120px;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
}
.b05-profile-chart__context-menu[hidden] {
display: none;
}
.b05-profile-chart__context-menu-item {
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-radius: var(--radius-cards);
background: transparent;
color: var(--color-text-body);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.b05-profile-chart__context-menu-item:hover {
background: var(--color-surface-sunken);
}
/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기.
종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */
/* 구조물군 하위 메뉴(아코디언) — 종류가 많은 군이 펼쳐지면 최대 높이에서 안쪽 스크롤. */
.b05-profile-chart__context-menu,
.b05-drainage__context-menu {
max-height: 320px;
overflow-y: auto;
}
.b05-profile-chart__context-menu-item.is-group,
.b05-drainage__context-menu-item.is-group {
font-weight: var(--font-weight-medium);
}
.b05-profile-chart__context-menu-sub,
.b05-drainage__context-menu-sub {
display: flex;
flex-direction: column;
}
.b05-profile-chart__context-menu-item.is-sub,
.b05-drainage__context-menu-item.is-sub {
padding-left: var(--spacing-24);
}
.b05-profile-table__irregular-col {
+1 -1
View File
@@ -75,7 +75,7 @@ export function mountViewerStructureMenu(deps: ViewerMenuDeps): void {
const model = deps.modelPointAt(event.clientX, event.clientY);
if (!model) return;
const hit = projectToRoute(deps.routePoints(), model.x, model.y);
// 노선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다 — 배치 대상이 아니다.
// 노선에서 먼 자리는 아무 메뉴도 안 뜸 — 배치 대상이 아님(브라우저 메뉴는 공용 진입이 막음).
if (!hit || hit.distance > ROUTE_PICK_DISTANCE_M) return;
event.preventDefault();
const chainage = Number(hit.chainage.toFixed(2));
@@ -31,7 +31,11 @@ import {
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { readSummaryItem } from "../common_util/common_util_summary_item";
import {
linkFields,
readGroupItems,
readSummaryItem,
} from "../common_util/common_util_summary_item";
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
NO_SELECTION,
@@ -180,6 +184,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
source: "user",
options: attributes.options ?? {},
summary_item: attributes.summary_item ?? null,
group_items: attributes.group_items ?? null,
});
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
currentChainageM = at;
@@ -197,13 +202,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
movePipeTo(fromChainageM, toChainageM);
if (attributes.summary_item !== undefined) {
if (attributes.summary_item !== undefined || attributes.group_items !== undefined) {
// 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다.
const mine = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M,
);
if (mine && (mine.summary_item ?? null)?.key !== attributes.summary_item?.key) {
const before = mine ? JSON.stringify(linkFields(mine)) : "";
if (mine && attributes.summary_item !== undefined)
mine.summary_item = attributes.summary_item;
if (mine && attributes.group_items !== undefined)
mine.group_items = attributes.group_items;
if (mine && JSON.stringify(linkFields(mine)) !== before) {
persistPipes();
section.setPipeFacilities(pipeFacilities);
deps.onStructuresChanged?.();
@@ -294,6 +303,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
end_m: pipe.end_m,
source: pipe.source,
summary_item: readSummaryItem(pipe.summary_item),
group_items: readGroupItems(pipe.group_items),
// 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다.
options: withSpecDefaults(
pipe.options,
@@ -374,7 +384,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
...(pipe.options ? { options: pipe.options } : {}),
...(pipe.summary_item ? { summary_item: pipe.summary_item } : {}),
...linkFields(pipe),
})),
);
deps.onPipesWritten?.();
@@ -6,6 +6,10 @@ DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불
그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를
건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다.
표(Table · B07 수량표 · M02 41장 면적산출 표)는 선 + 글로 풀어 쓴다. 짝(CLAUDE.md 5장 ①):
`table_parts` ↔ openwebcad `src/helpers/table-geometry.ts`(`tableBorders` · `cellRect` ·
`cellTextAnchor`) — 거울 시험 `resources/tester/test_b07_frame_export_table.py`.
"""
from __future__ import annotations
@@ -31,6 +35,14 @@ logger = logging.getLogger(__name__)
_DXF_VERSION = "R2018"
_DWG_SOURCE_VERSION = "R2004"
_DEFAULT_TEXT_MM = 3.0
#: 표 기본 글자 크기 · 여백 — openwebcad `DEFAULT_TABLE_STYLE` 과 같게
_TABLE_FONT = 2.2
_TABLE_PADDING = 1.0
_ALIGN = {
"left": TextEntityAlignment.MIDDLE_LEFT,
"right": TextEntityAlignment.MIDDLE_RIGHT,
"center": TextEntityAlignment.MIDDLE_CENTER,
}
def _xy(point: Any) -> tuple[float, float] | None:
@@ -47,6 +59,96 @@ def _layer_name(raw: Any) -> str:
return name[:255] or "0"
def _span(value: Any) -> int:
"""병합 칸 수 — TS `Math.max(1, Math.round(v ?? 1))` 와 같게(반올림은 .5 올림)."""
number = float(value) if isinstance(value, (int, float)) else 1.0
return max(1, math.floor(number + 0.5))
def _owners(cells: list, rows: int, columns: int) -> list[list[str | None]]:
owners: list[list[str | None]] = [[None] * columns for _ in range(rows)]
for row in range(rows):
for column in range(columns):
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
if not isinstance(cell, dict):
continue
for r in range(row, min(rows, row + _span(cell.get("rowSpan")))):
for c in range(column, min(columns, column + _span(cell.get("colSpan")))):
owners[r][c] = f"{row}:{column}"
return owners
def table_parts(shape: dict[str, Any]) -> tuple[list[tuple], list[dict[str, Any]]]:
"""표 → (경계선 [(x1, y1, x2, y2)], 글 [{text, x, y, align, height}]) — 원점 = 왼쪽 위 · 아래로.
바깥 테두리는 늘 · 안쪽 선은 양옆(위아래)이 같은 병합 칸이면 끊음 · 글은 칸 가운데 높이 ·
정렬에 따라 여백만큼 안쪽."""
origin = _xy(shape.get("origin")) or (0.0, 0.0)
widths = [float(w) for w in shape.get("columnWidths") or []]
heights = [float(h) for h in shape.get("rowHeights") or []]
cells = shape.get("cells") or []
style = shape.get("style") or {}
rows, columns = len(heights), len(widths)
if not rows or not columns:
return [], []
xs, ys = [origin[0]], [origin[1]]
for width in widths:
xs.append(xs[-1] + width)
for height in heights:
ys.append(ys[-1] - height)
owners = _owners(cells, rows, columns)
lines = [
(xs[0], ys[0], xs[columns], ys[0]),
(xs[0], ys[rows], xs[columns], ys[rows]),
(xs[0], ys[0], xs[0], ys[rows]),
(xs[columns], ys[0], xs[columns], ys[rows]),
]
for column in range(1, columns):
for row in range(rows):
left, right = owners[row][column - 1], owners[row][column]
if left is None or left != right:
lines.append((xs[column], ys[row], xs[column], ys[row + 1]))
for row in range(1, rows):
for column in range(columns):
above, below = owners[row - 1][column], owners[row][column]
if above is None or above != below:
lines.append((xs[column], ys[row], xs[column + 1], ys[row]))
raw = style.get("padding")
padding = float(raw) if isinstance(raw, (int, float)) else _TABLE_PADDING
texts = []
for row in range(rows):
for column in range(columns):
cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None
if not isinstance(cell, dict) or not cell.get("text"):
continue
right = xs[min(columns, column + _span(cell.get("colSpan")))]
bottom = ys[min(rows, row + _span(cell.get("rowSpan")))]
align = cell.get("align") if cell.get("align") in _ALIGN else "center"
x = {"left": xs[column] + padding, "right": right - padding}.get(
align, (xs[column] + right) / 2
)
height = cell.get("fontSize") or style.get("fontSize") or _TABLE_FONT
texts.append(
{
"text": str(cell["text"]),
"x": x,
"y": (ys[row] + bottom) / 2,
"align": align,
"height": float(height),
}
)
return lines, texts
def _add_table(space: Any, shape: dict[str, Any], attribs: dict[str, Any]) -> None:
lines, texts = table_parts(shape)
for x1, y1, x2, y2 in lines:
space.add_line((x1, y1), (x2, y2), dxfattribs=attribs)
for one in texts:
text = space.add_text(one["text"], dxfattribs={**attribs, "height": one["height"]})
text.set_placement((one["x"], one["y"]), align=_ALIGN[one["align"]])
def _add_entity(
space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int]
) -> None:
@@ -65,6 +167,9 @@ def _add_entity(
# 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다.
skipped["Image"] = skipped.get("Image", 0) + 1
return
if kind == "Table": # 표 = 선 + 글(칸 정렬 반영)
_add_table(space, shape, attribs)
return
start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint"))
if start and end:
@@ -29,6 +29,7 @@ import {
revCloudToolStateMachine,
updateDimensions,
} from '../tools/annotate/leader-table-tools';
import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool';
import {
tableColumnToolStateMachine,
tableEditToolStateMachine,
@@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [
tool: Tool.TABLEUNMERGE,
machine: tableUnmergeToolStateMachine,
},
{
id: 'PLACETABLE',
label: '표 놓기',
glyph: '▦',
hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다',
tool: Tool.PLACETABLE,
machine: placeTableToolStateMachine,
},
];
export const MARKUP_COMMANDS: CadCommand[] = [
@@ -53,6 +53,7 @@ export const CommandLine: FC = () => {
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return; // 한글 조합 중 키는 IME 몫
if (event.key === 'Escape') {
event.preventDefault();
getInputController().handleEscapeKey();
@@ -34,6 +34,8 @@ export interface TableStyleOptions {
textColor: string;
/** 문자를 칸 좌우 끝에서 띄우는 거리 */
padding: number;
/** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */
연결?: string;
}
export const DEFAULT_TABLE_STYLE: TableStyleOptions = {
@@ -97,6 +99,11 @@ export class TableEntity implements Entity {
}
// ── 쓰기 (표 편집 명령이 쓴다)
/** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */
public replaceCells(cells: TableCells): void {
this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length);
}
public setCell(row: number, column: number, patch: Partial<TableCell>): void {
const current = this.cells[row]?.[column];
if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다
@@ -344,9 +351,11 @@ export class TableEntity implements Entity {
data.cells,
data.style
);
table.id = jsonEntity.id;
table.lineColor = jsonEntity.lineColor;
table.lineWidth = jsonEntity.lineWidth;
// 서버가 만든 표(면적산출 → 도면 · M02 41장)는 id · 선 색 · 굵기가 없을 수 있다 — 비면 새 표의
// 기본값(선 색 = 자동색 `#fff` · 바탕 따라 흰/검정)으로 채운다. 놓기 · [업데이트] · 다시 열기가 모두 여기를 지난다.
table.id = jsonEntity.id || table.id;
table.lineColor = jsonEntity.lineColor || table.lineColor;
table.lineWidth = jsonEntity.lineWidth ?? table.lineWidth;
table.lineDash = jsonEntity.lineDash;
return table;
}
@@ -31,6 +31,8 @@ export function registerCadDebugHook(): void {
opacity: entity.opacity ?? null,
hidden: isEntityHidden(entity),
color: entity.lineColor,
// 글자 객체의 내용 — 한글 입력(IME) 검증이 읽는다
label: (entity as { getLabel?: () => string }).getLabel?.() ?? null,
box: [
Math.round(box.xmin),
Math.round(box.ymin),
@@ -4,6 +4,9 @@
*
* 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은
* 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다.
*
* 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts`
* (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`.
*/
export interface TableCell {
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import { ImeSink, isImeKey } from './ime-sink.ts';
import { InputController } from './input-controller.ts';
describe('한글 IME 입력', () => {
it('조합 사건으로 「가나다」 가 들어간다', () => {
let text = '';
const sink = new ImeSink((data) => {
text += data;
});
for (const [steps, done] of [
[['ㄱ', '가'], '가'],
[['ㄴ', '나'], '나'],
[['ㄷ', '다'], '다'],
] as const) {
for (const step of steps) sink.handleCompositionUpdate(step);
expect(sink.composing).toBe(steps[steps.length - 1]);
sink.handleCompositionEnd(done);
}
expect(text).toBe('가나다');
expect(sink.composing).toBe('');
// 조합 중 input 은 compositionend 몫 — 겹쳐 넣지 않음 · 조합 뒤 띄어쓰기는 받음
sink.handleInput({ isComposing: true, data: '다' });
sink.handleInput({ isComposing: false, data: ' ' });
expect(text).toBe('가나다 ');
});
it('조합 중 키는 단축키로 먹지 않는다', () => {
expect(isImeKey({ isComposing: true, keyCode: 0, key: 'r' } as KeyboardEvent)).toBe(true);
expect(isImeKey({ isComposing: false, keyCode: 229, key: 'r' } as KeyboardEvent)).toBe(true);
expect(isImeKey({ isComposing: false, keyCode: 0, key: 'Process' } as KeyboardEvent)).toBe(
true
);
expect(isImeKey({ isComposing: false, keyCode: 76, key: 'l' } as KeyboardEvent)).toBe(false);
const controller = new InputController();
const preventDefault = vi.fn();
controller.handleKeyStroke({
key: 'Process',
keyCode: 229,
isComposing: true,
target: null,
preventDefault,
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent);
expect(preventDefault).not.toHaveBeenCalled();
controller.ime.handleCompositionEnd('가나다');
expect((controller as unknown as { text: string }).text).toBe('가나다');
});
});
@@ -0,0 +1,68 @@
/**
* 한글 IME 받개 — 캔버스에는 글 칸이 없어 조합(compositionstart/update/end)이 생기지 않는다.
* 문자를 기다리는 단계(TEXT · MTEXT · 표 칸 · 지시선 …)에서만 숨은 input 에 초점을 줘 조합을 받고,
* 끝난 글자를 넘긴다. 명령 단계에서는 초점을 풀어 한글 자판이어도 L · REC 같은 영문 명령이 들어간다.
*/
import { getActiveToolActor } from '../state.ts';
/** 조합 중 키 — 단축키·글자 처리가 먹지 않고 IME 에 넘긴다 */
export const isImeKey = (evt: KeyboardEvent): boolean =>
evt.isComposing || evt.keyCode === 229 || evt.key === 'Process';
/** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */
export function activeToolAcceptsText(): boolean {
const snapshot = getActiveToolActor()?.getSnapshot();
return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT;
}
export class ImeSink {
/** 조합 중인 글자 — 커서 옆 입력 칸에 미리 보인다 */
public composing = '';
public element: HTMLInputElement | null = null;
constructor(private readonly onCommit: (text: string) => void) {}
public attach() {
const el = document.createElement('input');
el.className = 'cad-ime-sink';
el.tabIndex = -1;
el.autocomplete = 'off';
el.setAttribute('aria-hidden', 'true');
el.style.cssText =
'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;border:0;padding:0;pointer-events:none';
el.addEventListener('compositionupdate', (evt) => this.handleCompositionUpdate(evt.data));
el.addEventListener('compositionend', (evt) => this.handleCompositionEnd(evt.data));
el.addEventListener('input', (evt) => this.handleInput(evt as InputEvent));
document.body.appendChild(el);
this.element = el;
}
public handleCompositionUpdate(data: string) {
this.composing = data ?? '';
}
public handleCompositionEnd(data: string) {
this.composing = '';
if (this.element) this.element.value = '';
if (data) this.onCommit(data);
}
/** 조합 직후 IME 가 바로 넣는 글자(띄어쓰기 · 숫자) — 조합 중 input 은 compositionend 가 맡는다 */
public handleInput(evt: Pick<InputEvent, 'isComposing' | 'data'>) {
if (evt.isComposing || !evt.data) return;
if (this.element) this.element.value = '';
this.onCommit(evt.data);
}
/** 문자를 기다리면 받개에 초점 · 아니면 놓음. 다른 입력 칸(명령줄 · 대화상자)의 초점은 뺏지 않는다 */
public sync(wantsText: boolean) {
const el = this.element;
if (!el) return;
const active = document.activeElement;
if (wantsText && !active?.closest('input, textarea, select, [contenteditable]')) {
el.focus({ preventScroll: true });
} else if (!wantsText && active === el && !this.composing) {
el.blur();
}
}
}
@@ -65,21 +65,15 @@ import {
type RelativePointInputEvent,
type TextInputEvent,
} from '../tools/tool.types.ts';
const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
function polarToPoint(distance: number, degrees: number): Point {
const radians = (degrees * Math.PI) / 180;
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
}
import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts';
import {
ABSOLUTE_POINT_REGEXP,
ABSOLUTE_POLAR_REGEXP,
NUMBER_REGEXP,
polarToPoint,
RELATIVE_POINT_REGEXP,
RELATIVE_POLAR_REGEXP,
} from './input-patterns.ts';
/** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */
const DRAG_SELECT_MIN_PX = 4;
@@ -88,11 +82,15 @@ export class InputController {
private text = '';
/** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */
private leftPressLocation: Point | null = null;
public readonly ime = new ImeSink((data) => {
this.text += data;
});
constructor() {
if (typeof process === 'object' && process?.env?.NODE_ENV === 'test') {
return; // used during unit testing
}
this.ime.attach();
// Listen for keystrokes
document.addEventListener('keydown', (evt) => {
this.handleKeyStroke(evt);
@@ -141,10 +139,11 @@ export class InputController {
CANVAS_INPUT_FIELD_HEIGHT,
CANVAS_INPUT_FIELD_BACKGROUND_COLOR
);
// Draw text in input field
if (this.text) {
this.ime.sync(activeToolAcceptsText());
// Draw text in input field (조합 중인 한글도 함께)
if (this.text || this.ime.composing) {
drawController.drawTextScreen(
this.text,
this.text + this.ime.composing,
new Point(
screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2,
screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2
@@ -217,6 +216,7 @@ export class InputController {
return;
}
this.sendMouseClick(evt);
this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로
}
}
@@ -392,9 +392,11 @@ export class InputController {
}
public handleKeyStroke(evt: KeyboardEvent) {
if ((evt.target as HTMLElement | null)?.closest('input, textarea, select')) {
const target = evt.target as HTMLElement | null;
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
return;
}
if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김
console.log(`key pressed: ${evt.key}`);
if (evt.key === 'F12') {
// F12 => open developer tools
@@ -472,6 +474,7 @@ export class InputController {
} else if (evt.key === 'Enter') {
// User wants to submit the input or submit the action
this.handleEnterKey();
this.ime.sync(activeToolAcceptsText());
} else if (evt.key === 'ArrowDown') {
// Move the screen down
getScreenCanvasDrawController().setScreenOffset(this.getScreenPanStep('down', evt.shiftKey));
@@ -517,11 +520,7 @@ export class InputController {
public handleEnterKey() {
// submit the text as input to the active tool and clear the input field
const activeTool = getActiveToolActor();
const activeToolSnapshot = activeTool?.getSnapshot();
const activeToolState = activeToolSnapshot?.value;
const activeToolCanHandleTextInput =
!!activeToolSnapshot?.machine?.states?.[activeToolState]?.config?.on?.TEXT_INPUT;
const activeToolCanHandleTextInput = activeToolAcceptsText();
if (this.text === '') {
console.log('ENTER: ', {
@@ -0,0 +1,17 @@
/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */
import { Point } from '@flatten-js/core';
export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/;
/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */
const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)';
export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`);
export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`);
/** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */
export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`);
export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`);
/** 거리·각도(도)를 x·y 변위로 바꾼다 */
export function polarToPoint(distance: number, degrees: number): Point {
const radians = (degrees * Math.PI) / 180;
return new Point(distance * Math.cos(radians), distance * Math.sin(radians));
}
@@ -33,6 +33,7 @@ import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import { setRecoveryScope } from '../helpers/autosave.ts';
import { registerBoxResizeDrag } from './box-resize-drag.ts';
import { handleTableMessage } from './aislo-table-bridge.ts';
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
@@ -190,6 +191,7 @@ function applyFramePreview(): void {
export function registerAisloDrawingBridge() {
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장)
const message = event.data as Partial<DrawingSaveRequestMessage>;
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
try {
@@ -0,0 +1,141 @@
import { Point } from '@flatten-js/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TableEntity } from '../entities/TableEntity.ts';
import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts';
import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts';
import {
AISLO_PLACE_TABLE_MESSAGE,
AISLO_TABLE_UPDATED_MESSAGE,
AISLO_UPDATE_TABLE_MESSAGE,
handleTableMessage,
updateLinkedTables,
} from './aislo-table-bridge.ts';
const run = vi.hoisted(() => vi.fn());
vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run }));
const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' };
function table(link?: string): TableEntity {
return new TableEntity(
layer.id,
new Point(10, 20),
[30, 15],
[5, 5],
[
[{ text: '항목', colSpan: 2, key: 'S1!A1' }, null],
[
{ text: '면적', key: 'S1!A2' },
{ text: '12.5', align: 'right', key: 'S1!B2' },
],
],
{ fontSize: 2.5, ...(link ? { 연결: link } : {}) }
);
}
beforeEach(() => {
setLayers([layer]);
setEntities([]);
setPendingTable(null);
run.mockClear();
});
describe('표 놓기(PLACETABLE)', () => {
it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => {
const placed = tableAt(table('T1'), new Point(100, 200), layer.id);
expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]);
expect(placed.getColumnWidths()).toEqual([30, 15]);
expect(placed.getCell(1, 1)?.text).toBe('12.5');
expect(placed.getStyle().연결).toBe('T1');
});
it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => {
const notify = vi.fn();
const handled = await handleTableMessage(
{
type: AISLO_PLACE_TABLE_MESSAGE,
entity: { ...(await table().toJson()), lineColor: '#123456' },
link: 'T7',
},
notify
);
expect(handled).toBe(true);
expect(run).toHaveBeenCalledWith('PLACETABLE');
expect(getPendingTable()?.getStyle().연결).toBe('T7');
expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2);
expect(getPendingTable()?.lineColor).toBe('#123456');
expect(notify).not.toHaveBeenCalled();
});
it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => {
const linked = table('T1');
expect(linked.clone().getStyle().연결).toBe('T1');
const json = await linked.toJson();
if (!json) throw new Error('표 JSON 없음');
const back = await TableEntity.fromJson(json);
expect(back.getStyle().연결).toBe('T1');
});
});
describe('선 색 없는 표(서버 표 변환 답 · 43-6)', () => {
it('id · 선 색 · 굵기가 없으면 기본값(자동색 #fff · 1 · 새 id)으로 채워 놓고 저장 JSON 에도 남음', async () => {
const json = await table('T8').toJson();
if (!json) throw new Error('표 JSON 없음');
const { id: _id, lineColor: _color, lineWidth: _width, layerId: _layer, ...bare } = json;
await handleTableMessage(
{ type: AISLO_PLACE_TABLE_MESSAGE, entity: bare as typeof json, link: 'T8' },
vi.fn()
);
const pending = getPendingTable();
expect(pending?.lineColor).toBe('#fff');
expect(pending?.lineWidth).toBe(1);
const placed = tableAt(pending as TableEntity, new Point(0, 0), layer.id);
placed.lineColor = pending?.lineColor ?? '';
const saved = await placed.toJson();
expect(saved?.lineColor).toBe('#fff');
const back = await TableEntity.fromJson(bare as typeof json);
expect(back.id).toBeTruthy();
expect(back.lineColor).toBe('#fff');
});
});
describe('표 칸 갈기(update-table)', () => {
it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => {
const linked = table('T1');
const other = table('T2');
setEntities([linked, other]);
const count = updateLinkedTables('T1', [
[{ text: '항목(새)', colSpan: 2 }, null],
[{ text: '면적' }, { text: '99.0', align: 'right' }],
]);
expect(count).toBe(1);
expect(linked.getCell(1, 1)?.text).toBe('99.0');
expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로
expect(other.getCell(1, 1)?.text).toBe('12.5');
expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]);
expect(isDrawingDirty()).toBe(true);
});
it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => {
const linked = table('T1');
setEntities([linked]);
updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]);
expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([
['a', 'b'],
['', ''],
]);
});
it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => {
setEntities([table('T1')]);
const before = getEntities();
const notify = vi.fn();
await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify);
expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 });
expect(getEntities()).toBe(before);
});
it('표 메시지가 아니면 false', async () => {
expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false);
});
});
@@ -0,0 +1,80 @@
/**
* 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`.
* place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작
* update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음)
* → 부모에 table-updated{link, count}
* 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함.
*/
import { toast } from 'react-toastify';
import { runCommandInput } from '../commands/run-command.ts';
import type { JsonEntity } from '../entities/Entity.ts';
import { TableEntity } from '../entities/TableEntity.ts';
import type { TableCells } from '../helpers/table-geometry.ts';
import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts';
import { setPendingTable } from '../tools/annotate/place-table-tool.ts';
export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table';
export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table';
export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated';
/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */
interface TableMessage {
type?: string;
entity?: JsonEntity;
link?: string;
cells?: TableCells;
}
/** 이 표가 그 연결의 것인가 */
export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity =>
entity instanceof TableEntity && entity.getStyle().연결 === link;
/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */
export function updateLinkedTables(link: string, cells: TableCells): number {
const tables = getEntities().filter((entity) => isLinkedTable(entity, link));
for (const table of tables) table.replaceCells(cells);
if (tables.length) setEntities([...getEntities()], true);
return tables.length;
}
/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */
export async function startPlaceTable(entity: JsonEntity, link: string): Promise<void> {
const table = await TableEntity.fromJson(entity as Parameters<typeof TableEntity.fromJson>[0]);
const linked = new TableEntity(
table.layerId,
table.getOrigin(),
table.getColumnWidths(),
table.getRowHeights(),
table.getCells(),
{ ...table.getStyle(), 연결: link }
);
linked.lineColor = table.lineColor; // 보낸 선 색 · 굵기 그대로
linked.lineWidth = table.lineWidth;
setPendingTable(linked);
runCommandInput('PLACETABLE');
}
const blocked = (): boolean => {
if (!isDrawingReadOnly() && !isViewOnly()) return false;
toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.');
return true;
};
/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */
export async function handleTableMessage(
data: unknown,
notify: (type: string, payload: Record<string, unknown>) => void
): Promise<boolean> {
const message = data as TableMessage | null;
if (!message || typeof message !== 'object') return false;
if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) {
if (!blocked()) await startPlaceTable(message.entity, message.link);
return true;
}
if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) {
const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells);
notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count });
return true;
}
return false;
}
+1
View File
@@ -113,6 +113,7 @@ export enum Tool {
MLEADERSTYLE = 'MLEADERSTYLE',
MLEADERALIGN = 'MLEADERALIGN',
TABLE = 'TABLE',
PLACETABLE = 'PLACETABLE',
TABLESTYLE = 'TABLESTYLE',
TABLEEDIT = 'TABLEEDIT',
TABLEROW = 'TABLEROW',
@@ -0,0 +1,49 @@
/**
* 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게
* 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음.
* 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다.
*/
import type { Point } from '@flatten-js/core';
import { TableEntity } from '../../entities/TableEntity';
import { addEntities, getActiveLayerId } from '../../state';
import { Tool } from '../../tools';
import { createSequenceTool } from '../factories/sequence-tool';
let pending: TableEntity | null = null;
/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */
export function setPendingTable(table: TableEntity | null): void {
pending = table;
}
export function getPendingTable(): TableEntity | null {
return pending;
}
/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */
export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity {
const placed = new TableEntity(
layerId,
at.clone(),
table.getColumnWidths(),
table.getRowHeights(),
table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))),
table.getStyle()
);
placed.lineColor = table.lineColor;
placed.lineWidth = table.lineWidth;
return placed;
}
export const placeTableToolStateMachine = createSequenceTool({
tool: Tool.PLACETABLE,
steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }],
helpers: true,
restart: false,
preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []),
commit: (input) => {
if (!pending) return;
addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true);
pending = null;
},
});
-571
View File
@@ -1,571 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Api.ts
* 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 ·
* 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀)
* 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
ApiError,
copyLogic,
fetchAuto,
fetchLogic,
runCalc,
type CalcAnswer,
type LogicInput,
type LogicRow,
} from "../M01_MasterData_UI_Logic_Api";
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
/** 목록 한 줄 */
export interface EasyListItem {
key: string;
name: string;
/** 품셈 기본(읽기만) · false = 내가 만든 것 */
isBase: boolean;
/** 화면 글 「품셈 기본」 · 「내가 만든 것」 */
owner: string;
/** 결과단위 「원/㎡」 */
resultUnit: string;
/** 쉬운 길로 열 수 있나 — 돈 아닌 로직(작업량 등)은 false */
canOpen: boolean;
/** 못 여는 까닭 쉬운 말 · 열 수 있으면 null */
whyNot: string | null;
/** 어디 것인지 — 「산림품셈 13장 구조물」 · 내가 만든 것은 장만 */
where: string;
}
/** 열기 답 — `trial` = 설계 때 정하는 값의 견본(자동값) */
export interface EasyOpened {
row: LogicRow;
version: string;
isBase: boolean;
trial: Record<string, unknown>;
}
/** 새로 · 본뜨기 · 고치기 답 */
export interface EasySaved {
key: string;
version: string;
row: LogicRow;
}
/** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직
* `where` = 회사 · 개인 층 조합의 자리 글(「회사 · 산림조합」) · 마스터 층 · 로직은 없음
* 층을 못 읽었으면 `key` "" · `name` = 「일부를 못 읽음」 한 줄(지우기 전에 확인하게) */
export interface EasyUse {
kind: "combo" | "logic";
where?: string;
key: string;
name: string;
}
/** 기계 고르기 목록 한 줄 */
export interface EasyMachine {
분류번호: string;
이름: string;
규격: string;
/** 줄 단위(「시간」) */
단위: string;
}
/** 기종 → 호표 「요소」(기계경비 로직 부름 글) · 그 로직이 받는 설계 때 정하는 값 */
export interface EasyMachineElement {
element: string;
needInputs: LogicInput[];
}
/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */
export interface MachineLogic {
key: string;
name: string;
resultUnit: string;
inputs: LogicInput[];
}
/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */
const TEXT = {
Base: ["품셈 기본", "Standard"],
Mine: ["내가 만든 것", "Mine"],
NotMoney: [
"금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄",
"Gives a non-money value (output etc.) — open it in the full screen",
],
E403: [
"품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침",
"Standard items are read-only — use “Edit a copy”",
],
E404: [
"찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
"Not found — it may be deleted; reopen the list",
],
E409: [
"다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요",
"Changed elsewhere first — reopen and edit again",
],
Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"],
Generic: [
"저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요",
"Did not pass the check — review your changes",
],
More: ["그 밖 {n} 건", "{n} more"],
UnknownName: [
"「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요",
"「{name}」 is not defined — add it as a design value",
],
NeedCost: [
"넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요",
"Pick where it goes (labor · material · equipment)",
],
NoLines: [
"줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요",
"No lines — add at least one labor, material or machine line",
],
Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"],
CallNeeds: [
"부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요",
"The called item needs 「{name}」 — add it as a design value",
],
CallExtra: [
"부르는 다른 일위대가는 「{name}」 값을 받지 않음",
"The called item does not take 「{name}」",
],
Kind: [
"고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요",
"The picked item does not match the line type — pick again",
],
Loop: [
"서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요",
"Items call each other — remove one side",
],
Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"],
Gone: [
"이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음",
"A linked item is missing — items still in use cannot be deleted",
],
NoName: ["이름을 넣어 주세요", "Enter a name"],
Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"],
Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"],
Material: [
"재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요",
"No material matches — pick again",
],
Stop: [
"이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요",
"Cannot be calculated with these values — check the design values",
],
NoMachine: [
"이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요",
"No hourly cost data for this machine — pick another",
],
AtCompany: ["회사 · {name}", "Company · {name}"],
AtPerson: ["개인 · {name}", "Personal · {name}"],
MyCompany: ["내 회사", "My company"],
Me: ["나", "Me"],
Unseen: [
"회사 · 개인 조합 일부를 못 읽음 — 지우기 전에 확인 필요",
"Some company or personal combos could not be read — check before deleting",
],
} as const satisfies Record<string, readonly [string, string]>;
type SayKey = keyof typeof TEXT;
function say(key: SayKey, fill: Record<string, string | number> = {}): string {
const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
/** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */
function plainCheck(line: string): string {
const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null;
const unknown = name(/모르는 이름 「([^」]+)」/);
if (unknown) return say("UnknownName", { name: unknown });
const needs = name(/입력 「([^」]+)」 안 줌/);
if (needs) return say("CallNeeds", { name: needs });
const extra = name(/에 입력 「([^」]+)」 없음/);
if (extra) return say("CallExtra", { name: extra });
if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines");
if (/비목 「/.test(line)) return say("NeedCost");
if (/출처 모양/.test(line)) return say("Source");
if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind");
if (/돌고 도는 참조/.test(line)) return say("Loop");
if (/식 못 읽음/.test(line)) return say("Unread");
if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone");
if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName");
if (/끝수/.test(line)) return say("Cut");
if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material");
if (/칸 「[^」]+」 없음/.test(line)) return say("Broken");
return say("Generic");
}
/** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */
function plainOf(status: number, detail: unknown): string {
if (status === 0) return say("Net");
if (status === 403) return say("E403");
if (status === 404) return say("E404");
if (status === 409) return say("E409");
const body = detail as { errors?: unknown } | null;
const lines =
status === 422 && body && Array.isArray(body.errors)
? body.errors.map((x) => String(x))
: typeof detail === "string"
? [detail]
: [];
if (!lines.length) return say("Generic");
const plain = [...new Set(lines.map(plainCheck))];
const shown = plain.slice(0, 3);
if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 }));
return shown.join("\n");
}
/** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */
function plainStop(text: string): string {
return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text;
}
const MACHINE = "기계";
/** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */
const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]);
/** 그 로직이 받는 기종(분류번호) 목록 */
function machineCodes(m: MachineLogic): string[] {
const spec = m.inputs.find((i) => i.이름 === MACHINE);
return (spec?.고르기 ?? []).map(String);
}
/** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터.
* 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */
function machineRank(m: MachineLogic): number[] {
const names = m.inputs.map((i) => i.이름);
const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0;
const lossOnly = names.includes("지역") ? 0 : 1;
const boost = lossOnly && names.includes("보정작업") ? 1 : 0;
return [attach, lossOnly, boost, machineCodes(m).length];
}
/** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */
export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null {
const ranked = family
.filter((m) => machineCodes(m).includes(code))
.map((m) => ({ m, rank: machineRank(m) }))
.sort((a, b) => {
for (let i = 0; i < a.rank.length; i += 1) {
if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i];
}
return a.m.key < b.m.key ? -1 : 1;
});
return ranked[0]?.m ?? null;
}
/** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */
export function machineCall(m: MachineLogic, code: string): EasyMachineElement {
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE);
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs };
}
/** 층 조합 줄 통째(`담은로직` 포함) */
export interface LayerCombo {
키: string;
이름?: string;
담은로직?: { 로직?: string }[];
}
/** 이 로직을 담은 층 조합 — `layer` = company · personal · `name` = 층 주인 이름 */
export function holding(
combos: LayerCombo[],
key: string,
layer: "company" | "personal",
name: string,
): EasyUse[] {
const where = say(layer === "company" ? "AtCompany" : "AtPerson", { name });
return combos
.filter((c) => (c.담은로직 ?? []).some((one) => one?.로직 === key))
.map((c): EasyUse => ({ kind: "combo", where, key: c.키, name: c.이름 ?? "" }));
}
/* ── 순수 끝 ── */
const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`)
const OWNER = "현장";
const CALC_WAIT_MS = 300;
const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」)
/** `/api/m01` 길 · M02 층 조합 읽기는 `m02` */
const request = <T>(path: string, body?: unknown): Promise<T> =>
send<T>(`${API_BASE_URL}/m01${path}`, body);
const m02 = <T>(path: string): Promise<T> => send<T>(`${API_BASE_URL}/m02${path}`);
async function send<T>(url: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(url, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new ApiError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
interface Summary {
file: string;
키: string;
구분: string;
상세구분: string;
이름: string;
결과단위: string;
}
const isMoney = (unit: string): boolean => unit.startsWith("원");
/** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */
export async function listEasy(q: string): Promise<EasyListItem[]> {
const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`);
const items = got.logics.map((s): EasyListItem => {
const isBase = s.file !== OWN;
const canOpen = isMoney(s.결과단위);
return {
key: s.키,
name: s.이름,
isBase,
owner: say(isBase ? "Base" : "Mine"),
resultUnit: s.결과단위,
canOpen,
whyNot: canOpen ? null : say("NotMoney"),
where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분,
};
});
return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)];
}
/** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */
export async function openEasy(key: string): Promise<EasyOpened> {
const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]);
return {
row: one.logic,
version: one.version,
isBase: one.file !== OWN,
trial: trialFor(one.logic, auto),
};
}
/** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */
function trialFor(row: LogicRow, trial: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
(row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]),
);
}
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | undefined;
/** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
export function calcLater(
row: LogicRow,
trial: Record<string, unknown>,
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
): void {
const mine = ++calcSeq;
clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row })
.then((ans) => {
if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason)));
})
.catch((e: unknown) => {
if (mine === calcSeq) onDone(null, plainError(e));
});
}, CALC_WAIT_MS);
}
/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */
export async function newEasy(row: LogicRow): Promise<EasySaved> {
const logic = { ...row, 출처: row.출처 || "자체" };
const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", {
logic,
owner: OWNER,
});
return { key: got.key, version: got.version, row: got.logic };
}
/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */
export async function copyEasy(key: string): Promise<EasySaved> {
const got = await copyLogic(key);
return { key: got.key, version: got.version, row: got.logic };
}
/** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */
export async function editEasy(key: string, version: string, row: LogicRow): Promise<EasySaved> {
const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", {
key,
version,
logic: row,
});
return { key, version: got.version, row: got.logic };
}
/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */
export async function deleteEasy(key: string, version: string): Promise<{ version: string }> {
const got = await request<{ version: string }>("/logic/delete", { key, version });
return { version: got.version };
}
/** 쓰는 곳 — 담은 일위대가 조합(마스터 · 회사 · 개인 층) + 이 로직을 부르는 내가 만든 것.
* 지우기 누를 때 부름(무거움) · 서버 지우기 검사는 마스터 층 조합만 막음.
* ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) —
* 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */
export async function usesOf(key: string): Promise<EasyUse[]> {
const [combos, mine, layered] = await Promise.all([
request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`),
request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`),
layerUses(key),
]);
const call = `로직(${key}`;
const others = mine.logics.filter((s) => s.키 !== key);
const rows = await Promise.all(others.map((s) => fetchLogic(s.키)));
const callers = rows
.filter((one) => JSON.stringify(one.logic).includes(call))
.map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 }));
return [
...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })),
...layered,
...callers,
];
}
interface LayerTarget {
layer: "company" | "personal";
owner: string;
name: string;
rows?: { 키: string }[];
}
/** 회사 · 개인 층 조합 가운데 이 로직을 담은 것 — M02 층 길 읽기만(시스템 관리자는 모든 층).
* 내 층 = `/combos/all` · 남의 회사 = `/layers/company/owners` · 회사마다 사람 = `/layers/personal/owners`.
* 못 읽은 층이 있으면 「일부를 못 읽음」 한 줄을 더함.
* ponytail: 층마다 조합 하나씩 받아 훑음 — 조합이 수천이면 서버에 「이 로직을 담은 층 조합」 길을 더할 것 */
async function layerUses(key: string): Promise<EasyUse[]> {
let missed = false;
const soft = <T>(p: Promise<T>, empty: T): Promise<T> =>
p.catch(() => {
missed = true;
return empty;
});
type Own = { layer: string; owner: string | null; 줄: { 키: string }[] };
type Owner = { owner: string; name: string };
const own = await soft(m02<Own[]>("/combos/all"), []);
const targets: LayerTarget[] = [];
let myCompany = "";
for (const one of own) {
if (one.layer === "system" || !one.owner) continue;
const company = one.layer === "company";
if (company) myCompany = one.owner;
targets.push({
layer: company ? "company" : "personal",
owner: one.owner,
name: say(company ? "MyCompany" : "Me"),
rows: one.줄,
});
}
const companies = await soft(m02<Owner[]>("/layers/company/owners"), []);
for (const c of companies) targets.push({ layer: "company", ...c });
const people = await Promise.all(
[myCompany, ...companies.map((c) => c.owner)]
.filter(Boolean)
.map((c) => soft(m02<Owner[]>(`/layers/personal/owners?${query({ company: c })}`), [])),
);
for (const p of people.flat()) targets.push({ layer: "personal", ...p });
const found = await Promise.all(
targets.map(async (t) => {
const at = `/layers/${t.layer}/combos`;
const own = `?${query({ owner: t.owner })}`;
const rows = t.rows ?? (await soft(m02<{ 줄: { 키: string }[] }>(at + own), { 줄: [] })).줄;
const combos = await Promise.all(
rows.map((r) =>
soft(m02<{ combo: LayerCombo }>(`${at}/${encodeURIComponent(r.키)}${own}`), null),
),
);
const full = combos.flatMap((x) => (x ? [x.combo] : []));
return holding(full, key, t.layer, t.name);
}),
);
const out = found.flat();
if (missed) out.push({ kind: "combo", key: "", name: say("Unseen") });
return out;
}
let family: Promise<MachineLogic[]> | null = null;
/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */
function machineFamily(): Promise<MachineLogic[]> {
family ??= request<{ logics: Summary[] }>("/logics")
.then((got) =>
Promise.all(
got.logics
.filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위))
.map((s) => fetchLogic(s.키)),
),
)
.then((all) =>
all
.map((one) => ({
key: one.logic.키,
name: one.logic.이름,
resultUnit: one.logic.결과단위,
inputs: one.logic.입력 ?? [],
}))
.filter((m) => machineCodes(m).length > 0),
)
.catch((e: unknown) => {
family = null;
throw e;
});
return family;
}
interface MachineRow {
원문번호?: string;
이름?: string;
규격?: string;
}
/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */
export async function listMachines(q: string): Promise<EasyMachine[]> {
const [all, got] = await Promise.all([
machineFamily(),
request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`),
]);
return got.items.flatMap((row): EasyMachine[] => {
const code = String(row.원문번호 ?? "");
const m = pickMachine(all, code);
if (!m) return [];
const unit = m.resultUnit.split("/")[1] ?? "";
return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }];
});
}
/** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */
export async function machineElement(기종: string): Promise<EasyMachineElement> {
const m = pickMachine(await machineFamily(), 기종);
if (!m) throw new Error(say("NoMachine"));
return machineCall(m, 기종);
}
/** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */
export function plainError(e: unknown): string {
if (e instanceof ApiError) return plainOf(e.status, e.detail);
if (e instanceof Error && e.message === say("NoMachine")) return e.message;
return say("Net");
}
@@ -1,192 +0,0 @@
.m01-easycards {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m01-easycards-group {
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.m01-easycards-group__head,
.m01-easycards-group--empty {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
min-height: 44px;
padding: var(--spacing-8) var(--spacing-16);
}
.m01-easycards-group__head {
cursor: pointer;
}
.m01-easycards-group--empty {
border-style: dashed;
color: var(--color-text-muted);
}
.m01-easycards-group__title {
font-weight: var(--font-weight-bold);
}
.m01-easycards-group__sum {
font-weight: var(--font-weight-medium);
}
.m01-easycards-list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
.m01-easycards-card {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.m01-easycards-card--locked {
border-left: 4px dashed var(--color-border);
background: var(--color-surface-raised);
}
.m01-easycards-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
}
.m01-easycards-sentence {
display: flex;
flex: 1 1 24em;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
min-width: 0;
}
.m01-easycards-tag {
font-weight: var(--font-weight-bold);
}
.m01-easycards-name {
font-weight: var(--font-weight-medium);
}
.m01-easycards-name--pick {
min-height: 44px;
padding: 0 var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: inherit;
font: inherit;
cursor: pointer;
}
.m01-easycards-name--pick:focus-visible {
outline: 2px solid var(--color-focus-ring);
}
.m01-easycards .ui-input,
.m01-easycards .ui-select {
min-height: 44px;
font-size: var(--text-body);
}
.m01-easycards-qty {
flex: none;
width: 7em;
}
.m01-easycards-text {
flex: none;
width: 10em;
}
.m01-easycards-select {
flex: none;
width: auto;
}
.m01-easycards-auto,
.m01-easycards-why {
color: var(--color-text-secondary);
}
.m01-easycards-amount {
margin-left: auto;
font-weight: var(--font-weight-bold);
white-space: nowrap;
}
.m01-easycards-amount--wait {
opacity: 0.5;
font-weight: var(--font-weight-regular);
}
.m01-easycards-tools {
display: flex;
gap: var(--spacing-4);
}
.m01-easycards-tools .ui-btn,
.m01-easycards-locknote .ui-btn {
min-height: 44px;
font-size: var(--text-body);
}
.m01-easycards-warn {
margin: 0;
padding: var(--spacing-4) var(--spacing-8);
border-left: 4px solid var(--color-warning);
font-weight: var(--font-weight-medium);
}
.m01-easycards-locknote {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m01-easycards-lock {
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-bold);
}
.m01-easycards-add {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-easycards-add .ui-btn {
min-height: 44px;
font-size: var(--text-body);
}
@media (max-width: 640px) {
.m01-easycards-amount {
margin-left: 0;
}
.m01-easycards-tools {
width: 100%;
}
}
@@ -1,542 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Cards.ts
* 쉬운 단가산출 카드 그리기 — 묶음 + 소계 · 문장 칸 · 잠금 카드 · 지움 · ▲▼ · 카드 안 경고 · 더하기 단추
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 바뀐 doc 는 on.change 로만 알림(그리기는 부른 쪽이 다시)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api";
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import {
addCard,
addPercent,
checkCard,
removeCard,
updateCard,
withInputs,
type CostKind,
type EasyCard,
type EasyDoc,
type PickResult,
} from "./M01_EasyCalc_Model";
import { openEasyPick } from "./M01_EasyCalc_Pick";
import { openEasyCond, readCond } from "./M01_EasyCalc_Cond";
import { openEasyTable, readFinds } from "./M01_EasyCalc_Table";
import "./M01_EasyCalc_Cards.css";
const TEXT = {
Group_person: ["사람", "Workers"],
Group_material: ["재료", "Materials"],
Group_machine: ["기계·경비", "Machines"],
Group_logic: ["다른 일위대가", "Other unit prices"],
Group_percent: ["덧붙임", "Add-ons"],
Subtotal: ["소계", "Subtotal"],
Won: ["원", "KRW"],
Empty: ["아직 없음", "none yet"],
Pending: ["계산 중", "calculating"],
Tag_person: ["사람", "Worker"],
Tag_material: ["재료", "Material"],
Tag_machine: ["기계", "Machine"],
Tag_logic: ["다른 일위대가", "Other unit price"],
Tag_percent: ["덧붙임", "Add-on"],
Pre_person: [" 이 ", " works "],
Post_person: [" 일함", ""],
Pre_pick: [" 를 ", " uses "],
Post_material: [" 씀", ""],
Post_machine: [" 씀", ""],
Post_logic: ["", ""],
Pre_percent: [" 의 ", " of "],
Mid_percent: [" % 를 ", " % as "],
Post_percent: [" 로 더함", ""],
Slot: ["넣을 자리", "put into"],
Pick_Empty: ["고르기", "choose"],
Repick: ["다른 것으로 다시 고르기", "Choose another"],
Eg_qty: ["예: 0.072", "e.g. 0.072"],
Eg_percent: ["예: 2", "e.g. 2"],
Eg_name: ["예: 공구손료", "e.g. tool wear"],
Qty: ["수량", "Amount"],
Name: ["이름", "Name"],
Auto: ["자동", "auto"],
Locked: ["잠김", "Locked"],
ToNumber: ["숫자로 바꾸기", "Make it a number"],
ByTable: ["표 보고 고르기", "Choose from table"],
ByCond: ["조건 보고 고치기", "Edit conditions"],
ToNumber_Wait: ["계산이 끝난 뒤 가능", "Available after calculation"],
ToNumber_Ask: [
"자동 값을 그대로 숫자로 바꿈 · 이후 자동으로 달라지지 않음",
"Freeze the auto value as a number",
],
ToNumber_Ok: ["바꾸기", "Convert"],
Remove: ["지움", "Remove"],
Up: ["위로", "Move up"],
Down: ["아래로", "Move down"],
Add_person: ["+ 사람", "+ Worker"],
Add_material: ["+ 재료", "+ Material"],
Add_machine: ["+ 기계", "+ Machine"],
Add_logic: ["+ 다른 일위대가", "+ Other unit price"],
Add_percent: ["+ 덧붙임", "+ Add-on"],
Base_노무비: ["사람 품값", "labor cost"],
Base_재료비: ["재료값", "material cost"],
Base_경비: ["기계·경비", "machine & expenses"],
Base_사람줄합: ["사람 줄의 합", "sum of worker lines"],
} as const;
const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
type Group = "person" | "material" | "machine" | "logic" | "percent";
const GROUPS: Group[] = ["person", "material", "machine", "logic", "percent"];
type PercentBase = NonNullable<EasyCard["percentBase"]>;
const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"];
const COSTS: CostKind[] = ["노무비", "재료비", "경비"];
interface Ctx {
doc: EasyDoc;
lines: Map<string, CalcLine>;
readOnly: boolean;
on: { change(doc: EasyDoc): void; trial?(): Record<string, unknown> };
/** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */
badText: Map<string, string>;
}
const badTexts = new WeakMap<HTMLElement, Map<string, string>>();
const closedGroups = new WeakMap<HTMLElement, Set<Group>>();
let picking = false;
const groupOf = (card: EasyCard): Group => {
if (card.kind !== "locked") return card.kind;
if (card.source?.part === "덧줄") return "percent";
const kind = (card.raw as { 종류?: string } | null)?.종류;
return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic";
};
/** Model 의 toRow 와 같은 나눔 — 줄 차례가 어긋나지 않게 */
const partOf = (card: EasyCard): "호표" | "덧줄" =>
card.kind === "percent"
? "덧줄"
: card.kind === "locked"
? (card.source?.part ?? "덧줄")
: "호표";
/** 식 수량이거나 종류를 못 알아본 줄 — 숫자로 바꾸기 전까지 잠김 */
const isLocked = (card: EasyCard): boolean =>
card.qtyFormula !== null || (card.kind === "locked" && card.lockedWhy !== null);
/** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */
function lineMap(doc: EasyDoc, ans: CalcAnswer | null): Map<string, CalcLine> {
const out = new Map<string, CalcLine>();
if (!ans || !ans.ok || !ans.lines) return out;
const ordered = [
...doc.cards.filter((c) => partOf(c) === "호표"),
...doc.cards.filter((c) => partOf(c) === "덧줄"),
];
if (ordered.length !== ans.lines.length) return out;
ordered.forEach((card, i) => out.set(card.id, ans.lines![i]));
return out;
}
const parseQty = (text: string): number | null => {
const t = text.replace(/[,\s]/g, "");
if (t === "") return null;
return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : Number.NaN;
};
/** 검사가 막혀도 그리기는 계속 */
function warnOf(ctx: Ctx, card: EasyCard): string | null {
if (ctx.doc.isBase) return null;
try {
return checkCard(card, ctx.doc);
} catch {
return null;
}
}
function patchCard(ctx: Ctx, card: EasyCard, patch: Partial<EasyCard>): void {
ctx.on.change(updateCard(ctx.doc, card.id, patch));
}
/** 화면 묶음 안 이웃과 자리 바꿈 — 잠금 카드도 같은 묶음이라 Model.moveCard 의 묶음과 달라 여기서 바꿈 */
function swapInGroup(doc: EasyDoc, card: EasyCard, step: -1 | 1): EasyDoc {
const same = doc.cards.filter((c) => groupOf(c) === groupOf(card));
const neighbor = same[same.indexOf(card) + step];
if (!neighbor) return doc;
const cards = doc.cards.slice();
const a = cards.indexOf(card);
const b = cards.indexOf(neighbor);
[cards[a], cards[b]] = [cards[b], cards[a]];
return { ...doc, cards };
}
async function repick(ctx: Ctx, card: EasyCard): Promise<void> {
if (picking || card.kind === "locked" || card.kind === "percent") return;
picking = true;
try {
const pick = await openEasyPick(card.kind, { qty: card.qty });
if (!pick) return;
const next = updateCard(ctx.doc, card.id, {
name: pick.name,
spec: pick.spec,
unit: pick.unit,
element: pick.element,
qty: pick.qty,
});
ctx.on.change(withInputs(next, pick.needInputs ?? []));
} finally {
picking = false;
}
}
async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise<void> {
if (picking) return;
picking = true;
try {
const pick = await openEasyPick(kind);
if (pick) ctx.on.change(addCard(ctx.doc, pick));
} finally {
picking = false;
}
}
function addBlankPercent(ctx: Ctx): void {
ctx.on.change(addPercent(ctx.doc, "", "노무비", null, "경비"));
}
async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise<void> {
const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`;
if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) {
patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null });
}
}
/** 표 · 조건 창 — 답(새 식)이 있으면 그 카드 식만 바꿈 */
async function editFormula(
ctx: Ctx,
card: EasyCard,
open: typeof openEasyTable | typeof openEasyCond,
): Promise<void> {
if (picking) return;
picking = true;
try {
const formula = await open(card, ctx.doc, ctx.on.trial?.());
if (formula !== null) patchCard(ctx, card, { qtyFormula: formula });
} finally {
picking = false;
}
}
/* ------------------------------ 칸 만들기 ------------------------------ */
const text = (cls: string, value: string): HTMLSpanElement =>
el("span", { className: cls, text: value });
function qtyInput(ctx: Ctx, card: EasyCard): HTMLInputElement {
const bad = ctx.badText.get(card.id);
const shown = card.qty === null ? "" : Number.isNaN(card.qty) ? (bad ?? "") : String(card.qty);
const input = el("input", {
className: "ui-input m01-easycards-qty",
attrs: {
type: "text",
inputmode: "decimal",
placeholder: tc(card.kind === "percent" ? "Eg_percent" : "Eg_qty"),
"aria-label": tc("Qty"),
},
});
input.value = shown;
if (ctx.readOnly) input.readOnly = true;
input.addEventListener("change", () => {
const qty = parseQty(input.value);
if (Number.isNaN(qty)) ctx.badText.set(card.id, input.value);
else ctx.badText.delete(card.id);
if (Object.is(qty, card.qty)) return;
patchCard(ctx, card, { qty });
});
return input;
}
function nameWidget(ctx: Ctx, card: EasyCard): HTMLElement {
if (card.kind === "percent") {
const input = el("input", {
className: "ui-input m01-easycards-text",
attrs: { type: "text", placeholder: tc("Eg_name"), "aria-label": tc("Name") },
});
input.value = card.name;
if (ctx.readOnly) input.readOnly = true;
input.addEventListener("change", () => {
if (input.value !== card.name) patchCard(ctx, card, { name: input.value.trim() });
});
return input;
}
const label = [card.name, card.spec].filter(Boolean).join(" ") || tc("Pick_Empty");
if (ctx.readOnly || card.kind === "locked") return text("m01-easycards-name", label);
const button = el("button", {
className: "m01-easycards-name m01-easycards-name--pick",
text: `${label} ▾`,
attrs: { type: "button", "aria-label": `${label} — ${tc("Repick")}` },
});
button.addEventListener("click", () => void repick(ctx, card));
return button;
}
function selectBox<T extends string>(
ctx: Ctx,
values: T[],
current: T | null,
label: (v: T) => string,
onPick: (v: T) => void,
aria: string,
): HTMLElement {
if (ctx.readOnly) return text("m01-easycards-name", current ? label(current) : "");
const select = el("select", {
className: "ui-select m01-easycards-select",
attrs: { "aria-label": aria },
});
if (current === null)
select.append(el("option", { attrs: { value: "" }, text: tc("Pick_Empty") }));
for (const v of values) select.append(el("option", { attrs: { value: v }, text: label(v) }));
select.value = current ?? "";
select.addEventListener("change", () => {
if (select.value) onPick(select.value as T);
});
return select;
}
function amountText(ctx: Ctx, card: EasyCard): HTMLElement {
const line = ctx.lines.get(card.id);
if (!line) return text("m01-easycards-amount m01-easycards-amount--wait", `= ${tc("Pending")}`);
return text("m01-easycards-amount", `= ${formatMoney(line.금액)} ${tc("Won")}`);
}
function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement | null {
if (ctx.readOnly) return null;
const at = group.indexOf(card);
const move = (label: string, glyph: string, step: -1 | 1, off: boolean) => {
const b = createButton({
label: glyph,
variant: "ghost",
size: "sm",
disabled: off,
onClick: () => ctx.on.change(swapInGroup(ctx.doc, card, step)),
});
b.setAttribute("aria-label", label);
return b;
};
return el("div", {
className: "m01-easycards-tools",
children: [
move(tc("Up"), "▲", -1, at <= 0),
move(tc("Down"), "▼", 1, at >= group.length - 1),
createButton({
label: tc("Remove"),
variant: "ghost",
size: "sm",
onClick: () => ctx.on.change(removeCard(ctx.doc, card.id)),
}),
],
});
}
/** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */
function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement {
const g = groupOf(card);
const locked = isLocked(card);
const percent = card.kind === "percent";
const plain = card.kind === "locked";
const name = nameWidget(ctx, card);
const value = locked ? lockedValue(ctx, card) : qtyInput(ctx, card);
const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)];
if (percent) {
parts.push(
selectBox(
ctx,
BASES,
card.percentBase,
(v) => tc(`Base_${v}`),
(v) => patchCard(ctx, card, { percentBase: v }),
tc("Slot"),
),
tc("Pre_percent"),
value,
tc("Mid_percent"),
name,
tc("Post_percent"),
);
} else {
parts.push(
name,
plain ? " " : g === "person" ? tc("Pre_person") : tc("Pre_pick"),
value,
text("m01-easycards-unit", ` ${card.unit}`),
plain ? "" : tc(`Post_${g}` as "Post_person"),
);
}
const slot = percent
? el("span", {
className: "m01-easycards-slot",
children: [
` · ${tc("Slot")} `,
selectBox(
ctx,
COSTS,
card.cost,
(v) => tc(`Base_${v}`),
(v) => patchCard(ctx, card, { cost: v }),
tc("Slot"),
),
],
})
: null;
const head = el("div", {
className: "m01-easycards-line",
children: [
el("div", {
className: "m01-easycards-sentence",
children: slot ? [...parts, slot] : parts,
}),
amountText(ctx, card),
toolButtons(ctx, card, group) ?? "",
],
});
const rows: HTMLElement[] = [head];
const warn = warnOf(ctx, card);
if (warn) rows.push(el("p", { className: "m01-easycards-warn", text: `⚠ ${warn}` }));
if (locked) rows.push(lockedNote(ctx, card));
return el("li", {
className: `m01-easycards-card${locked ? " m01-easycards-card--locked" : ""}`,
children: rows,
});
}
/** 잠금 카드 값 자리 — 「자동 0.50」 */
function lockedValue(ctx: Ctx, card: EasyCard): HTMLElement {
const line = ctx.lines.get(card.id);
const value = line ? formatNumber(line.수량) : tc("Pending");
return text("m01-easycards-auto", `(${tc("Auto")} ${value})`);
}
function lockedNote(ctx: Ctx, card: EasyCard): HTMLElement {
const line = ctx.lines.get(card.id);
const note: (HTMLElement | string)[] = [
text("m01-easycards-lock", `🔒 ${tc("Locked")}`),
text("m01-easycards-why", card.lockedWhy ?? ""),
];
if (!ctx.readOnly) {
const button = createButton({
label: tc("ToNumber"),
variant: "ghost",
size: "sm",
disabled: !line,
onClick: () => line && void lockToNumber(ctx, card, line.수량),
});
if (!line) button.title = tc("ToNumber_Wait");
note.push(button);
const formula = card.qtyFormula;
const tools: [boolean, keyof typeof TEXT, typeof openEasyTable | typeof openEasyCond][] = [
[formula !== null && !!readFinds(formula)?.length, "ByTable", openEasyTable],
[formula !== null && !!readCond(formula, ctx.doc.row), "ByCond", openEasyCond],
];
for (const [has, label, open] of tools) {
if (has)
note.push(
createButton({
label: tc(label),
variant: "ghost",
size: "sm",
onClick: () => void editFormula(ctx, card, open),
}),
);
}
}
return el("div", { className: "m01-easycards-locknote", children: note });
}
/* ------------------------------ 묶음 · 전체 ------------------------------ */
function groupView(ctx: Ctx, host: HTMLElement, group: Group, cards: EasyCard[]): HTMLElement {
const title = tc(`Group_${group}`);
if (!cards.length) {
return el("section", {
className: "m01-easycards-group m01-easycards-group--empty",
children: [
text("m01-easycards-group__title", title),
text("m01-easycards-group__none", tc("Empty")),
],
});
}
const found = cards.map((c) => ctx.lines.get(c.id)).filter((l): l is CalcLine => !!l);
const sum =
found.length === cards.length
? `${tc("Subtotal")} ${formatMoney(found.reduce((s, l) => s + l.금액, 0))} ${tc("Won")}`
: `${tc("Subtotal")} ${tc("Pending")}`;
const details = el("details", {
className: "m01-easycards-group",
children: [
el("summary", {
className: "m01-easycards-group__head",
children: [
text("m01-easycards-group__title", title),
text(
`m01-easycards-group__sum${found.length === cards.length ? "" : " m01-easycards-amount--wait"}`,
sum,
),
],
}),
el("ul", {
className: "m01-easycards-list",
children: cards.map((c) => cardView(ctx, c, cards)),
}),
],
});
const closed = closedGroups.get(host) ?? new Set<Group>();
closedGroups.set(host, closed);
details.open = !closed.has(group);
details.addEventListener("toggle", () => {
if (details.open) closed.delete(group);
else closed.add(group);
});
return details;
}
function addBar(ctx: Ctx): HTMLElement {
const add = (key: keyof typeof TEXT, run: () => void) =>
createButton({ label: tc(key), variant: "ghost", onClick: run });
return el("div", {
className: "m01-easycards-add",
children: [
add("Add_person", () => void addByPick(ctx, "person")),
add("Add_material", () => void addByPick(ctx, "material")),
add("Add_machine", () => void addByPick(ctx, "machine")),
add("Add_logic", () => void addByPick(ctx, "logic")),
add("Add_percent", () => addBlankPercent(ctx)),
],
});
}
/** 카드 묶음 그리기 — ans 는 줄 금액 · null 이면 「계산 중」 */
export function renderCards(
host: HTMLElement,
doc: EasyDoc,
ans: CalcAnswer | null,
on: { change(doc: EasyDoc): void; trial?(): Record<string, unknown> },
): void {
const badText = badTexts.get(host) ?? new Map<string, string>();
badTexts.set(host, badText);
const ctx: Ctx = { doc, lines: lineMap(doc, ans), readOnly: doc.isBase, on, badText };
host.classList.add("m01-easycards");
host.replaceChildren(
...GROUPS.map((g) =>
groupView(
ctx,
host,
g,
doc.cards.filter((c) => groupOf(c) === g),
),
),
...(ctx.readOnly ? [] : [addBar(ctx)]),
);
}
@@ -1,97 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Cond.css — 조건 보고 고치기 창
* ========================================================================== */
.m01-easycond {
width: min(760px, calc(100vw - 32px));
}
.m01-easycond__lines {
display: flex;
flex-direction: column;
gap: 12px;
}
.m01-easycond__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 16px;
line-height: 1.6;
}
.m01-easycond__slot {
display: inline-flex;
align-items: center;
gap: 4px;
}
.m01-easycond__select,
.m01-easycond__num {
min-height: 36px;
padding: 4px 8px;
border: 1px solid var(--color-border, #c9ced6);
border-radius: 8px;
background: var(--color-surface, #fff);
color: inherit;
font: inherit;
}
.m01-easycond__num {
width: 7em;
text-align: right;
}
.m01-easycond__num.is-bad {
border-color: var(--color-danger, #d64545);
outline: 2px solid var(--color-danger, #d64545);
}
.m01-easycond__word {
padding: 4px 8px;
border-radius: 8px;
background: var(--color-surface-raised, #eef1f5);
font-weight: 600;
}
.m01-easycond__chip {
min-height: 36px;
padding: 4px 12px;
border: 1px dashed var(--color-accent, #3a6fd8);
border-radius: 999px;
background: transparent;
color: var(--color-accent, #3a6fd8);
font: inherit;
cursor: pointer;
}
.m01-easycond__chip:disabled {
border-color: var(--color-border, #c9ced6);
color: inherit;
cursor: default;
}
.m01-easycond__money {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
background: var(--color-surface-raised, #eef1f5);
font-size: 16px;
font-weight: 600;
}
.m01-easycond__money.is-waiting {
opacity: 0.5;
}
.m01-easycond__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.m01-easycond__cannot {
font-size: 16px;
}
@@ -1,620 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Cond.ts
* 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고
* 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null
*
* 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음
* 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리)
* 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로
* 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로)
* 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄
* 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
runCalc,
type CalcAnswer,
type LogicInput,
type LogicRow,
} from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { calcLater } from "./M01_EasyCalc_Api";
import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Cond.css";
const TEXT = {
Title: ["조건 보고 고치기", "Change by conditions"],
Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"],
Part: ["조건", "Condition"],
If: ["만약", "If"],
ElseIf: ["아니고 만약", "Else if"],
Then: ["→", "→"],
Else: ["아니면 →", "Otherwise →"],
Subject: ["가", "is"],
Big: ["가운데 큰 값", "whichever is larger"],
Small: ["가운데 작은 값", "whichever is smaller"],
And: ["와", "and"],
RoundA: ["을 소수", "to"],
RoundB: ["자리까지", "decimal places,"],
Up: ["올림", "round up"],
Down: ["버림", "round down"],
Half: ["반올림", "round off"],
OpEq: ["이면", "equal to"],
OpNe: ["이 아니면", "not equal to"],
OpGt: ["보다 크면", "greater than"],
OpGe: ["이상이면", "at least"],
OpLt: ["보다 작으면", "less than"],
OpLe: ["이하이면", "at most"],
ChipTable: ["표에서 찾은 값", "Value from table"],
ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"],
ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"],
ChipCalc: ["계산한 값", "Calculated value"],
ToNumber: ["숫자로", "Use a number"],
Line: ["이 줄", "This line"],
Total: ["합계", "Total"],
Was: ["전", "before"],
Waiting: ["계산 중 …", "Calculating …"],
Use: ["이걸로", "Use this"],
Cancel: ["취소", "Cancel"],
NotNumber: ["숫자만 넣음", "Numbers only"],
} as const;
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */
/** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */
type CNode = { src: string; p: [number, number] } & (
| { t: "num" | "str" | "name" | "ref"; v: string }
| { t: "call"; f: string; items: CNode[] }
| { t: "bin"; op: string; a: CNode; b: CNode }
| { t: "neg" | "paren"; a: CNode }
| { t: "field"; a: CNode; f: string }
);
const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"];
const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"];
const NAME_RE = /[\p{L}\p{N}_]+/uy;
const NUM_RE = /\d+(?:\.\d+)?/y;
/** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */
function parseCond(s: string): CNode | null {
let i = 0;
const fail = (): never => {
throw new Error("parse");
};
const ws = (): void => {
while (i < s.length && /\s/.test(s[i])) i++;
};
const take = (w: string): boolean => {
ws();
if (!s.startsWith(w, i)) return false;
i += w.length;
return true;
};
const need = (w: string): void => void (take(w) || fail());
const mk = <T extends object>(start: number, body: T): CNode =>
({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode;
const sticky = (re: RegExp): string | null => {
re.lastIndex = i;
const m = re.exec(s);
if (m) i = re.lastIndex;
return m ? m[0] : null;
};
const word = (): string => (ws(), sticky(NAME_RE) ?? fail());
const quoted = (): string => {
const end = s.indexOf("'", i);
if (end < 0) fail();
const v = s.slice(i, end);
i = end + 1;
return v;
};
/** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */
const skipGroup = (): void => {
let depth = 0;
let q = false;
for (; i < s.length; i++) {
if (s[i] === "'") q = !q;
else if (!q && s[i] === "(") depth++;
else if (!q && s[i] === ")" && --depth === 0) return void i++;
}
fail();
};
/** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */
const skipRef = (): void => {
for (let depth = 0; i < s.length; i++) {
const ch = s[i];
if ("({".includes(ch)) depth++;
else if (")}".includes(ch) && depth-- === 0) return;
else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return;
else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return;
}
};
const fields = (start: number, node: CNode): CNode => {
while (take(".")) {
const f = take("'") ? quoted() : word();
node = mk(start, { t: "field", a: node, f });
}
return node;
};
const primary = (): CNode => {
ws();
const start = i;
if (take("(")) {
const a = expr();
need(")");
return fields(start, mk(start, { t: "paren", a }));
}
if (take("'")) return mk(start, { t: "str", v: quoted() });
const num = sticky(NUM_RE);
if (num) return mk(start, { t: "num", v: num });
const w = word();
if (w === "찾기" || w === "로직") {
ws();
if (s[i] !== "(") fail();
skipGroup();
return fields(start, mk(start, { t: "ref", v: w }));
}
if (COND_FUNCS.includes(w)) {
need("(");
const items = [expr()];
while (take(",")) items.push(expr());
need(")");
return fields(start, mk(start, { t: "call", f: w, items }));
}
if (s[i] === ":") {
i++;
skipRef();
return fields(start, mk(start, { t: "ref", v: w }));
}
return fields(start, mk(start, { t: "name", v: w }));
};
const unary = (): CNode => {
ws();
const start = i;
return take("-") ? mk(start, { t: "neg", a: unary() }) : primary();
};
const chain = (next: () => CNode, ops: string[], once = false): CNode => {
ws();
const start = i;
let node = next();
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
node = mk(start, { t: "bin", op, a: node, b: next() });
return node;
};
const mul = (): CNode => chain(unary, ["*", "/"]);
const add = (): CNode => chain(mul, ["+", "-"]);
const compare = (): CNode => chain(add, COMPARE_OPS, true);
const both = (): CNode => chain(compare, ["그리고"]);
const expr = (): CNode => chain(both, ["또는"]);
try {
const node = expr();
ws();
return i === s.length ? node : null;
} catch {
return null;
}
}
/** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */
export type CondSlot =
| { kind: "num"; value: number }
| { kind: "text"; value: string }
| { kind: "input"; name: string }
| { kind: "table" | "calc"; src: string };
export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<=";
export interface CondTest {
left: CondSlot;
op: CondOp;
right: CondSlot;
}
export type CondShape =
| { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot }
| { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] }
| {
type: "round";
fn: "올림" | "버림" | "반올림";
value: CondSlot;
places: number;
hadPlaces: boolean;
};
/** 덩어리 하나 — p = 읽은 글(text) 안 자리 */
export interface CondBlock {
shape: CondShape;
p: [number, number];
}
/** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */
export interface CondRead {
text: string;
via: string | null;
blocks: CondBlock[];
}
const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n);
function slotOf(node: CNode, inputs: LogicInput[]): CondSlot {
const n = unparen(node);
if (n.t === "num") return { kind: "num", value: Number(n.v) };
if (n.t === "neg" && unparen(n.a).t === "num")
return { kind: "num", value: -Number(unparen(n.a).src) };
if (n.t === "str") return { kind: "text", value: n.v };
if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v };
if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기"))
return { kind: "table", src: n.src };
return { kind: "calc", src: node.src.trim() };
}
function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null {
if (n.t !== "call") return null;
const slot = (x: CNode): CondSlot => slotOf(x, inputs);
if (n.f === "만약") {
const t = unparen(n.items[0]);
if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null;
const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) };
const branch = { test, value: slot(n.items[1]) };
const rest = shapeOf(unparen(n.items[2]), inputs);
return rest?.type === "if"
? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise }
: { type: "if", branches: [branch], otherwise: slot(n.items[2]) };
}
if (n.f === "최소" || n.f === "최대")
return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null;
const places = n.items[1] ? unparen(n.items[1]) : null;
if (n.items.length > 2 || (places && places.t !== "num")) return null;
const fn = n.f as "올림" | "버림" | "반올림";
const value = slot(n.items[0]);
return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places };
}
const condNum = (n: number): string => {
const t = String(Number(n.toPrecision(12)));
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
};
function slotText(s: CondSlot): string {
if (s.kind === "num") return condNum(s.value);
if (s.kind === "text") return `'${s.value}'`;
return s.kind === "input" ? s.name : s.src;
}
/** 덩어리 → 식 글 */
export function writeCond(shape: CondShape): string {
if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`;
if (shape.type === "round") {
const v = slotText(shape.value);
return shape.places === 0 && !shape.hadPlaces
? `${shape.fn}(${v})`
: `${shape.fn}(${v}, ${condNum(shape.places)})`;
}
return shape.branches.reduceRight((acc, b) => {
const t = b.test;
return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`;
}, slotText(shape.otherwise));
}
const flat = (s: string): string => s.replace(/[\s()]+/g, "");
/** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */
function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] {
if (n.t === "call") {
const shape = shapeOf(n, inputs);
if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }];
return n.items.flatMap((x) => blocksOf(x, inputs));
}
if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)];
if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs);
return [];
}
/** 식 글 → 덩어리들 · 하나도 못 읽으면 null */
export function readCond(formula: string, row: Pick<LogicRow, "입력" | "중간">): CondRead | null {
let text = formula.trim();
let via: string | null = null;
for (let hop = 0; hop < 5; hop++) {
const mid = (row.중간 ?? []).find((m) => m.이름 === text);
if (!mid) break;
via = mid.이름;
text = mid.식.trim();
}
const node = parseCond(text);
const blocks = node ? blocksOf(node, row.입력 ?? []) : [];
return blocks.length ? { text, via, blocks } : null;
}
/** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */
export function writeRead(read: CondRead): string {
let out = read.text;
for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0]))
out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]);
return out;
}
/* ── 화면 ───────────────────────────────────────────────────────────────── */
const OPS: [CondOp, keyof typeof TEXT][] = [
["==", "OpEq"],
["!=", "OpNe"],
[">", "OpGt"],
[">=", "OpGe"],
["<", "OpLt"],
["<=", "OpLe"],
];
/** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */
const TABLE_MODULE = import.meta.glob<{
openEasyTable: (
card: EasyCard,
doc: EasyDoc,
trial?: Record<string, unknown>,
) => Promise<string | null>;
}>("./M01_EasyCalc_Table.ts");
/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */
const put = (slot: CondSlot, next: CondSlot): void => {
for (const k of Object.keys(slot)) delete (slot as Record<string, unknown>)[k];
Object.assign(slot, next);
};
function select(
options: { value: string; text: string }[],
value: string,
onChange: (v: string) => void,
): HTMLSelectElement {
const sel = el("select", { className: "m01-easycond__select" });
const all = options.some((o) => o.value === value)
? options
: [...options, { value, text: value }];
for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } }));
sel.value = value;
sel.addEventListener("change", () => onChange(sel.value));
return sel;
}
function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement {
const box = el("input", {
className: "m01-easycond__num",
attrs: { type: "text", inputmode: "decimal", value: condNum(value) },
});
box.addEventListener("input", () => {
const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value);
box.classList.toggle("is-bad", !ok);
box.title = ok ? "" : te("NotNumber");
if (ok) onChange(Number(box.value));
});
return box;
}
function chipLabel(slot: CondSlot, inputs: LogicInput[]): string {
if (slot.kind === "table") return te("ChipTable");
if (slot.kind !== "calc") return "";
if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc");
const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? [];
return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc");
}
/** 문장 짓기 — 고칠 때마다 changed() */
class Sentence {
constructor(
private card: EasyCard,
private doc: EasyDoc,
private changed: () => void,
private trial: Record<string, unknown>,
) {}
/** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */
slot(s: CondSlot): HTMLElement {
const host = el("span", { className: "m01-easycond__slot" });
const draw = (): void => {
host.replaceChildren();
if (s.kind === "num") {
host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed())));
return;
}
if (s.kind === "text" || s.kind === "input") {
const text = s.kind === "text" ? s.value : s.name;
host.append(el("span", { className: "m01-easycond__word", text }));
return;
}
const chip = el("button", {
className: "m01-easycond__chip",
text: chipLabel(s, this.doc.inputs),
attrs: { type: "button" },
});
const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"];
if (load && s.kind === "table")
chip.addEventListener("click", async () => {
const got = await (
await load()
).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial);
if (got) (put(s, { kind: "table", src: got }), draw(), this.changed());
});
else chip.disabled = true;
const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed());
host.append(
chip,
createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }),
);
};
draw();
return host;
}
/** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */
test(t: CondTest): HTMLElement[] {
const inputs = this.doc.inputs;
const right = el("span", { className: "m01-easycond__slot" });
const drawRight = (): void => {
const name = t.left.kind === "input" ? t.left.name : null;
const inp = inputs.find((x) => x.이름 === name);
const choices = inp?.고르기 ?? [];
const r = t.right;
if (!choices.length || (r.kind !== "text" && r.kind !== "num"))
return right.replaceChildren(this.slot(r));
const pickOne = (v: string): void => {
const asNum = choices.some((c) => typeof c === "number" && String(c) === v);
put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v });
this.changed();
};
const opts = choices.map((c) => ({ value: String(c), text: String(c) }));
right.replaceChildren(select(opts, String(r.value), pickOne));
};
drawRight();
const l = t.left;
const left =
l.kind === "input"
? select(
inputs.map((x) => ({ value: x.이름, text: x.이름 })),
l.name,
(v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()),
)
: this.slot(l);
const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) }));
const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed()));
return [left, el("span", { text: te("Subject") }), right, op];
}
/** 덩어리 하나 → 줄들 */
block(shape: CondShape): HTMLElement[] {
if (shape.type === "if")
return [
...shape.branches.map((b, i) =>
line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)),
),
line(te("Else"), this.slot(shape.otherwise)),
];
if (shape.type === "pick") {
const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)]));
const opts = [
{ value: "최대", text: te("Big") },
{ value: "최소", text: te("Small") },
];
const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed()));
return [line(...kids, fn)];
}
const ways = [
{ value: "올림", text: te("Up") },
{ value: "버림", text: te("Down") },
{ value: "반올림", text: te("Half") },
];
const setPlaces = (v: number): void => (
(shape.places = Math.max(0, Math.round(v))),
this.changed()
);
const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed()));
const places = numberBox(shape.places, setPlaces);
return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)];
}
}
const line = (...kids: (HTMLElement | string)[]): HTMLElement =>
el("div", {
className: "m01-easycond__line",
children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)),
});
/** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */
function moneyView(card: EasyCard, doc: EasyDoc, trial: Record<string, unknown>) {
const box = el("div", { className: "m01-easycond__money", text: te("Waiting") });
const ho = doc.cards.filter(
(c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"),
);
const at = ho.findIndex((c) => c.id === card.id);
let first: [number | null, number | null] | null = null;
let seq = 0;
let timer = 0;
const show = (ans: CalcAnswer | null, plain: string | null): void => {
box.classList.remove("is-waiting");
if (!ans || !ans.ok)
return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : ""));
const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null;
const sums = ans.sums ?? {};
const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0);
first ??= [lineSum, total];
const part = (label: string, v: number | null, was: number | null): string =>
v === null
? ""
: `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`;
const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])];
box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t })));
};
const update = (text: string): void => {
box.classList.add("is-waiting");
const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null }));
const mine = ++seq;
const done = (ans: CalcAnswer | null, plain: string | null = null): void =>
void (mine === seq && show(ans, plain));
try {
calcLater(row, trial, done);
} catch {
// Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만)
window.clearTimeout(timer);
timer = window.setTimeout(() => {
runCalc({ key: "", inputs: trial, row }).then(
(a) => done(a),
() => done(null),
);
}, 300);
}
};
return { box, update };
}
/** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */
export function openEasyCond(
card: EasyCard,
doc: EasyDoc,
trial: Record<string, unknown> = {},
): Promise<string | null> {
const read = readCond(card.qtyFormula ?? "", doc.row);
return new Promise((resolve) => {
let done = false;
const finish = (v: string | null): void => {
if (!done) ((done = true), resolve(v));
};
openModal({
title: te("Title"),
closeLabel: te("Cancel"),
dialogClass: "m01-easycond",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
if (!read)
return void body.append(
el("p", { className: "m01-easycond__cannot", text: te("Cannot") }),
);
const base = flat(writeRead(read));
const money = moneyView(card, doc, trial);
const use = createButton({
label: te("Use"),
variant: "filled",
disabled: true,
onClick: () => (finish(writeRead(read)), close()),
});
const changed = (): void => {
const text = writeRead(read);
use.disabled = flat(text) === base;
money.update(text);
};
const say = new Sentence(card, doc, changed, trial);
const lines = el("div", { className: "m01-easycond__lines" });
read.blocks.forEach((b, i) => {
if (read.blocks.length > 1)
lines.append(
el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }),
);
lines.append(...say.block(b.shape));
});
const cancel = createButton({
label: te("Cancel"),
variant: "ghost",
onClick: () => (finish(null), close()),
});
const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] });
body.append(lines, money.box, foot);
money.update(writeRead(read));
},
});
});
}
@@ -1,567 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Entry.ts
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
* 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다
* EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본)
* · 지우기(usesOf 먼저 · 물음 한 번) · Ctrl+Z · Ctrl+S
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import {
calcLater,
copyEasy,
deleteEasy,
editEasy,
listEasy,
newEasy,
openEasy,
plainError,
usesOf,
type EasyListItem,
} from "./M01_EasyCalc_Api";
import { renderCards } from "./M01_EasyCalc_Cards";
import {
checkName,
EasyHistory,
newDoc,
setEasyModelLanguage,
toDoc,
toRow,
type EasyCard,
type EasyDoc,
} from "./M01_EasyCalc_Model";
import { renderSheet, renderTrialStrip } from "./M01_EasyCalc_Sheet";
import "./M01_EasyCalc_Style.css";
const TEXT = {
Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"],
Title: ["쉬운 단가산출", "Easy unit-price"],
CloseWindow: ["창 닫기", "Close window"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"],
Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"],
Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"],
Start_Mine: ["내가 만든 것", "Made by me"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Found: ["찾은 것", "Found"],
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
Start_None: ["찾은 것 없음", "Nothing found"],
Per: ["{u} 당", "per {u}"],
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
Name: ["이름", "Name"],
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
Spec: ["크기", "Size"],
Unit: ["단위", "Unit"],
Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
Undo: ["되돌리기", "Undo"],
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함", "Not saved"],
BaseNote: [
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
"Standard items are read-only — use the button below to make your own copy",
],
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
Copying: ["내 것으로 만드는 중", "Making your copy"],
Copied: ["내 것으로 만들었음 — 이제 고칠 수 있음", "Your copy is ready — you can edit it now"],
Cards: ["무엇이 들어가나", "What goes in"],
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
BadLines: [
"⚠ 표시 줄을 먼저 고쳐 주세요 — 빈 이름 · 숫자 아님 · 0 · 0 보다 작은 수는 저장 안 됨",
"Fix the ⚠ lines first — empty names, non-numbers, 0 or negative amounts can't be saved",
],
Uses_Wait: ["쓰는 곳을 찾는 중 — 10초쯤 걸림", "Looking for places that use it — about 10 s"],
Uses_Block: ["쓰는 곳이 있어 못 지움 — {v}", "Cannot delete — it is used by {v}"],
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Delete_Ok: ["지우기", "Delete"],
Deleted: ["지웠음", "Deleted"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
type TextKey = keyof typeof TEXT;
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
/** 목록에 한 번에 보이는 줄 수 */
const SHOWN = 50;
/** 「원/㎡」 → 「㎡」 */
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */
const amountOf = (resultUnit: string): string => {
const unit = unitOf(resultUnit);
return /^\d/.test(unit) ? unit : `1${unit}`;
};
/** 저장을 막는 줄 — 이름 빔 · 수량이 숫자 아님 · 0 · 음수(서버가 0 · 음수를 받아 줌) · 같은 줄 · 너무 큼은 경고만 */
const blocksSave = (c: EasyCard): boolean =>
!c.name.trim() ||
(c.qtyFormula === null && !(c.qty !== null && Number.isFinite(c.qty) && c.qty > 0));
/** 카드를 넣어 새로 생긴 설계 값의 시험값 — 서버 자동값(`master_auto.values`)과 같은 규칙:
* 고르기 = 첫째 · 범위 = 낮은 끝 · 그 밖 숫자 0 · 값이 「원」 인 가격 값은 비워 둠(0 원처럼 안 보이게) */
function fillNew(
inputs: LogicInput[],
trial: Record<string, unknown>,
known: Set<string>,
): Record<string, unknown> {
const out = { ...trial };
for (const spec of inputs) {
if (known.has(spec.이름)) continue;
known.add(spec.이름);
if (out[spec.이름] !== undefined || String(spec.단위 ?? "").includes("원")) continue;
out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0);
}
return out;
}
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
export function createEasyCalcButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
}
function openEasyCalc(): void {
setEasyModelLanguage(currentLanguageIndex);
let body: HTMLElement;
/** 고치는 화면이 열려 있을 때만 */
let paper: { history: EasyHistory; save: () => Promise<void>; undo: () => void } | null = null;
let logics: EasyListItem[] | null = null;
let query = "";
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
const mayLeave = async (): Promise<boolean> =>
!paper?.history.dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(고르기 창 등 다른 창은 제 몫) */
const onKey = (ev: KeyboardEvent): void => {
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
};
/* ---------- 시작 화면 ---------- */
const listRow = (item: EasyListItem): HTMLElement => {
const row = el("button", {
className: "m01-easycalc__item",
attrs: { type: "button", title: item.where },
children: [
el("span", { className: "m01-easycalc__item-name", text: item.name }),
createTag(item.owner, item.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-easycalc__item-unit",
text: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
}),
...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]),
],
});
if (item.whyNot) row.title = item.whyNot;
row.disabled = !item.canOpen;
row.addEventListener("click", () => void openPaper(item.key));
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") });
const mineHost = el("div", { className: "m01-easycalc__list" });
const foundHost = el("div", { className: "m01-easycalc__list" });
const search = createInputField({
type: "search",
placeholder: te("Start_Search"),
value: query,
onInput: (v) => {
query = v;
drawFound();
},
});
search.root.classList.add("m01-easycalc__search");
const drawFound = (): void => {
if (!logics) return;
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const hits = words.length
? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w)))
: logics;
status.textContent = !hits.length
? te("Start_None")
: hits.length > SHOWN
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
: "";
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
};
body.replaceChildren(
el("div", {
className: "m01-easycalc__start",
children: [
el("div", {
className: "m01-easycalc__start-col",
children: [
el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }),
search.root,
el("h4", { text: te("Start_Found") }),
status,
foundHost,
],
}),
el("div", {
className: "m01-easycalc__start-col m01-easycalc__start-side",
children: [
createBigButton(te("Start_Blank"), () => openBlank()),
el("h4", { text: te("Start_Mine") }),
mineHost,
],
}),
],
}),
);
search.input.focus();
try {
logics ??= await listEasy("");
} catch (error) {
status.textContent = te("LoadFailed", { v: plainError(error) });
return;
}
const mine = logics.filter((x) => !x.isBase);
mineHost.replaceChildren(
...(mine.length
? mine.map(listRow)
: [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]),
);
drawFound();
};
/** 단위 고르기 후보 — 목록에 다섯 번 넘게 쓰인 「원/…」 차례 · 설명 붙은 드문 단위는 뺌(연 로직 것은 따로 더함) */
const unitChoices = (): string[] => {
const count = new Map<string, number>();
for (const x of logics ?? []) {
if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
}
return [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
};
/* ---------- 고치는 화면 ---------- */
const openBlank = (): void => showPaper(newDoc("", unitOf(unitChoices()[0] ?? "원/㎡")), {});
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
try {
const opened = await openEasy(key);
showPaper(
toDoc(opened.row, { key, version: opened.version, isBase: opened.isBase }),
opened.trial,
);
} catch (error) {
showToast(plainError(error), "error");
void showStart();
}
};
const showPaper = (first: EasyDoc, firstTrial: Record<string, unknown>): void => {
let doc = first;
let trial = firstTrial;
let ans: CalcAnswer | null = null;
let history = new EasyHistory();
history.push(doc);
let busy = false;
const unsaved = createTag(te("Unsaved"), "warning");
const total = el("div", { className: "m01-easycalc__total" });
const note = el("p", { className: "m01-easycalc__note", attrs: { role: "status" } });
const trialHost = el("div", { className: "m01-easycalc__part m01-easycalc__trial" });
const cardsHost = el("div", { className: "m01-easycalc__cards" });
const sheetHost = el("div");
let trialShape = "";
/** 연 때 있던 설계 값 — 서버 자동값을 그대로 둠 · 이 밖에 새로 생긴 값만 채움 */
const known = new Set(doc.inputs.map((i) => i.이름));
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
onInput: (v) => edit({ ...doc, name: v }),
});
name.root.classList.add("m01-easycalc__name");
const spec = createInputField({ label: te("Spec"), onInput: (v) => edit({ ...doc, spec: v }) });
const unit = createSelectField({
label: te("Unit"),
options: [],
onChange: (v) => edit({ ...doc, resultUnit: v }),
});
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: () => doUndo() });
const saveButton = createButton({ label: te("Save"), onClick: () => void doSave() });
const deleteButton = createButton({
label: te("Delete"),
variant: "danger",
onClick: () => void doDelete(),
});
const close = createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) void showStart();
})(),
});
const actions = el("div", { className: "m01-easycalc__actions" });
const baseBar = el("div", {
className: "m01-easycalc__base",
children: [
el("p", { text: te("BaseNote") }),
createBigButton(te("BaseCopy"), () => void doCopy()),
],
});
/** 위 띠 칸을 doc 로 — 열 때 · 되돌릴 때 · 저장 뒤 */
const fillStrip = (): void => {
const units = unitChoices();
if (!units.includes(doc.resultUnit)) units.unshift(doc.resultUnit);
unit.setOptions(
units.map((u) => ({ value: u, text: unitOf(u) })),
doc.resultUnit,
);
name.input.value = doc.name;
spec.input.value = doc.spec;
for (const box of [name.input, spec.input, unit.select]) box.disabled = doc.isBase;
actions.replaceChildren(
...(doc.isBase ? [] : [undo, saveButton]),
...(doc.key && !doc.isBase ? [deleteButton] : []),
close,
);
baseBar.hidden = !doc.isBase;
};
/** 금액 · 카드 · 표 — 바뀔 때마다(pending) · 계산 답이 올 때마다(answer) */
const draw = (pending: boolean, answer = false): void => {
const value = ans?.ok ? formatMoney(ans.result ?? ans.sums?.["계"] ?? 0) : "—";
total.textContent = te("Total", { u: amountOf(doc.resultUnit), v: value });
total.classList.toggle("is-pending", pending);
sheetHost.classList.toggle("is-pending", pending);
unsaved.hidden = !history.dirty;
undo.disabled = !history.canUndo;
const shape = JSON.stringify(doc.inputs);
if (shape !== trialShape) {
trialShape = shape;
trial = fillNew(doc.inputs, trial, known);
renderTrialStrip(trialHost, doc.inputs, trial, (next) => {
trial = next;
calc();
});
}
drawCards(answer);
renderSheet(sheetHost, { name: doc.name, resultUnit: doc.resultUnit }, ans);
};
/** 계산 답만 왔을 때 카드 칸에 글을 넣는 중이면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게).
* doc 가 바뀌면 늘 바로 그림 — 카드가 옛 doc 를 쥐고 있으면 그다음 조작이 옛 판본 위에 쌓임 */
let waiting = false;
const drawCards = (answer = false): void => {
const active = document.activeElement;
if (answer && active instanceof HTMLInputElement && cardsHost.contains(active)) {
if (!waiting) {
waiting = true;
cardsHost.addEventListener(
"focusout",
() =>
setTimeout(() => {
waiting = false;
drawCards(true);
}),
{ once: true },
);
}
return;
}
renderCards(cardsHost, doc, ans, { change: edit, trial: () => trial });
};
const calc = (): void => {
draw(true);
calcLater(toRow(doc), trial, (next, plain) => {
ans = next;
note.textContent = plain ?? "";
draw(false, true);
});
};
const edit = (next: EasyDoc): void => {
doc = next;
history.push(doc);
calc();
};
const doUndo = (): void => {
const back = history.undo();
if (!back) return;
doc = back;
fillStrip();
calc();
};
/** 저장 뒤 · 본뜬 뒤 — 서버가 준 줄로 다시 엶 · 되돌리기 기록은 여기서 새로 */
const reopen = (next: EasyDoc): void => {
doc = next;
history = new EasyHistory();
history.push(doc);
logics = null;
fillStrip();
calc();
};
const doSave = async (): Promise<void> => {
if (doc.isBase || busy) return;
const mine = (logics ?? []).filter((x) => !x.isBase && x.key !== doc.key).map((x) => x.name);
const bad = checkName(doc.name, mine);
name.setError(bad ?? undefined);
if (bad) return void name.input.focus();
if (doc.cards.some(blocksSave)) return void (note.textContent = te("BadLines"));
busy = true;
saveButton.disabled = true;
note.textContent = te("Saving");
try {
const row = toRow(doc);
const saved =
doc.key && doc.version ? await editEasy(doc.key, doc.version, row) : await newEasy(row);
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
note.textContent = "";
showToast(te("Saved"), "success");
} catch (error) {
note.textContent = `${te("SaveFailed")} — ${plainError(error)}`;
} finally {
busy = false;
saveButton.disabled = false;
}
};
const doCopy = async (): Promise<void> => {
if (!doc.key || busy) return;
busy = true;
note.textContent = te("Copying");
try {
const saved = await copyEasy(doc.key);
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
showToast(te("Copied"), "success");
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
}
};
const doDelete = async (): Promise<void> => {
if (!doc.key || !doc.version || doc.isBase || busy) return;
busy = true;
deleteButton.disabled = true;
note.textContent = te("Uses_Wait");
try {
const uses = await usesOf(doc.key);
const blocking = uses.filter((u) => u.key);
if (blocking.length) {
note.textContent = te("Uses_Block", {
v: blocking.map((u) => (u.where ? `${u.where} ${u.name}` : u.name)).join(", "),
});
return;
}
note.textContent = uses.map((u) => u.name).join(" · ");
if (!(await showConfirmDialog(te("Delete_Ask", { v: doc.name }), te("Delete_Ok")))) {
return;
}
await deleteEasy(doc.key, doc.version);
logics = null;
paper = null;
showToast(te("Deleted"), "success");
void showStart();
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
deleteButton.disabled = false;
}
};
paper = {
get history() {
return history;
},
save: doSave,
undo: doUndo,
};
body.replaceChildren(
el("div", {
className: "m01-easycalc__strip",
children: [name.root, spec.root, unit.root, total, unsaved, actions],
}),
note,
baseBar,
trialHost,
el("section", {
className: "m01-easycalc__part",
children: [el("h4", { text: te("Cards") }), cardsHost],
}),
el("details", {
className: "m01-easycalc__part m01-easycalc__sheet",
children: [el("summary", { text: te("Sheet") }), sheetHost],
}),
);
fillStrip();
calc();
if (!doc.isBase) name.input.focus();
};
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
openModal({
title: te("Title"),
closeLabel: te("CloseWindow"),
dialogClass: "m01-easycalc",
beforeClose: mayLeave,
mount: (host) => {
body = host;
(host.parentElement ?? host).addEventListener("keydown", onKey);
void showStart();
},
});
}
function createBigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m01-easycalc__big");
return button;
}
@@ -1,587 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Model.ts
* 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기
* DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로
*
* 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic
* 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말)
* 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked
* 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림
* 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤
* ========================================================================== */
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api";
export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
export type CostKind = "노무비" | "재료비" | "경비";
export interface EasyCard {
/** 화면 번호 · 저장 안 함 */
id: string;
kind: CardKind;
/** 보이는 이름(호표 · 덧줄 이름) */
name: string;
/** 크기(규격) · 없으면 "" */
spec: string;
/** 인 · ㎥ · 시간 · % … */
unit: string;
/** 숫자 수량 · 식이면 null */
qty: number | null;
/** 잠금 식(원문 그대로) · 숫자면 null */
qtyFormula: string | null;
/** 넣을 자리 · 다른 일위대가 · 기계는 null(엔진이 나눔) */
cost: CostKind | null;
/** 덧붙임 바탕 · 덧붙임 아니면 null */
percentBase: CostKind | "사람줄합" | null;
/** 호표 「요소」 칸 값 그대로(인력 LB 키 · 재료 검색어 묶음 · 로직 부름 글) */
element: unknown;
/** 잠금 까닭 쉬운 말 · 아니면 null */
lockedWhy: string | null;
/** 원래 자리 · 새 카드 null */
source: { part: "호표" | "덧줄"; index: number } | null;
/** 원래 줄 통째 — 카드가 안 다룬 칸을 저장 때 그대로 살림 */
raw: unknown;
}
export interface EasyDoc {
/** 새 로직 null */
key: string | null;
/** 판본(409 대조) */
version: string | null;
/** 품셈 기본 = 읽기만 */
isBase: boolean;
name: string;
spec: string;
/** 결과단위 「원/㎡」 */
resultUnit: string;
/** 설계 때 정하는 값 */
inputs: LogicInput[];
cards: EasyCard[];
/** 원래 로직 줄(새 로직은 최소 칸 줄) */
row: LogicRow;
}
/** 물음 셋 고르기 결과 — Pick 이 가져다 씀 */
export interface PickResult {
kind: "person" | "material" | "machine" | "logic";
name: string;
spec: string;
unit: string;
element: unknown;
qty: number | null;
needInputs?: LogicInput[];
}
/* ── 글 ─────────────────────────────────────────────────────────────────── */
const TEXT = {
WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"],
WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"],
WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"],
WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"],
WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"],
NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"],
Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"],
Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"],
NoName: ["이름이 비어 있음", "The name is empty"],
SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"],
SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"],
/** {per} {qty} {unit} 을 채움 */
TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"],
} as const;
let lang = 0;
/** 화면 말 고르기 — 0 한국어 · 1 영어 */
export function setEasyModelLanguage(index: number): void {
lang = index === 1 ? 1 : 0;
}
const tx = (key: keyof typeof TEXT): string => TEXT[key][lang];
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
const COSTS: readonly string[] = ["노무비", "재료비", "경비"];
const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v);
/** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */
const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/;
const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/;
const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/;
let seq = 0;
const newId = (): string => `c${++seq}`;
const clone = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T));
const isObj = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const norm = (s: string): string => s.replace(/\s+/g, "");
/** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */
function numText(n: number): string {
const s = String(Number(n.toPrecision(12)));
return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s;
}
/** 바깥을 통째 감싼 괄호 하나 벗김 */
function unwrap(s: string): string {
const t = s.trim();
if (!t.startsWith("(") || !t.endsWith(")")) return t;
let depth = 0;
for (let i = 0; i < t.length; i++) {
if (t[i] === "(") depth++;
else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t;
}
return t.slice(1, -1).trim();
}
/** 괄호 밖의 + 로 나눔 */
function splitPlus(s: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = "";
for (const ch of s) {
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "+" && depth === 0) {
out.push(cur.trim());
cur = "";
} else cur += ch;
}
out.push(cur.trim());
return out;
}
const hasName = (formula: string, name: string): boolean => {
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula);
};
/** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */
function lockedWhy(formula: string, row: LogicRow): string {
const seen = new Set<string>();
const flags = { table: false, cond: false, input: false };
const walk = (f: string, depth: number): void => {
if (/찾기\s*\(/.test(f)) flags.table = true;
if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true;
for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true;
if (depth > 6) return;
for (const m of row.중간 ?? []) {
if (seen.has(m.이름) || !hasName(f, m.이름)) continue;
seen.add(m.이름);
walk(m.식, depth + 1);
}
};
walk(formula, 0);
if (flags.table) return tx("WhyTable");
if (flags.cond) return tx("WhyCond");
if (flags.input) return tx("WhyInput");
return tx("WhyCalc");
}
/* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */
/** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */
function personTerm(qtyText: string, element: unknown): string {
const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`;
return `${q} * ${String(element)}`;
}
/** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */
function parsePercent(formula: string): { base: string; rate: number } | null {
const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim());
if (!m || !m[1].trim()) return null;
return { base: unwrap(m[1]), rate: Number(m[2]) };
}
/** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */
function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean {
if (persons.length === 0) return false;
const terms = splitPlus(base);
if (terms.length !== persons.length) return false;
return terms.every((t, i) => {
const p = persons[i];
const want = personTerm(p.qtyText, p.element);
return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`);
});
}
function baseOf(
base: string,
persons: { qtyText: string; element: unknown }[],
): EasyCard["percentBase"] {
if (isCost(base)) return base;
return isPersonSum(base, persons) ? "사람줄합" : null;
}
/* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */
const qtyTextOf = (line: HoLine): string => String(line.수량 ?? "");
function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard {
const kind: CardKind =
line.종류 === "인력"
? "person"
: line.종류 === "재료"
? "material"
: line.종류 === "로직"
? MACHINE_CALL.test(String(line.요소))
? "machine"
: "logic"
: "locked";
const qtyText = qtyTextOf(line);
const numeric = NUMBER_TEXT.test(qtyText);
return {
id: newId(),
kind,
name: line.이름 ?? "",
spec: line.규격 ?? "",
unit: line.단위 ?? "",
qty: numeric ? Number(qtyText) : null,
qtyFormula: numeric ? null : qtyText,
cost: isCost(line.비목) ? line.비목 : null,
percentBase: null,
element: clone(line.요소),
lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row),
source: { part: "호표", index },
raw: clone(line),
};
}
function addCardOf(
line: NamedFormula,
index: number,
row: LogicRow,
persons: { qtyText: string; element: unknown }[],
): EasyCard {
const p = parsePercent(line.식);
const base = p ? baseOf(p.base, persons) : null;
const percent = p !== null && base !== null;
return {
id: newId(),
kind: percent ? "percent" : "locked",
name: line.이름 ?? "",
spec: "",
unit: percent ? "%" : "",
qty: percent ? p.rate : null,
qtyFormula: percent ? null : line.식,
cost: isCost(line.비목) ? line.비목 : null,
percentBase: percent ? base : null,
element: null,
lockedWhy: percent ? null : lockedWhy(line.식, row),
source: { part: "덧줄", index },
raw: clone(line),
};
}
export function toDoc(
row: LogicRow,
meta: { key: string | null; version: string | null; isBase: boolean },
): EasyDoc {
const ho = row.호표 ?? [];
const persons = ho
.filter((h) => h.종류 === "인력")
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
return {
key: meta.key,
version: meta.version,
isBase: meta.isBase,
name: row.이름 ?? "",
spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "",
resultUnit: row.결과단위 ?? "",
inputs: clone(row.입력 ?? []),
cards: [
...ho.map((h, i) => hoCard(h, i, row)),
...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)),
],
row: clone(row),
};
}
/** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
export function emptyRow(name = "", per = "㎡"): LogicRow {
return {
키: "",
원문번호: "",
이름: name,
결과단위: `원/${per}`,
출처: "자체",
입력: [],
중간: [],
호표: [],
덧줄: [],
끝수: null,
};
}
/** 빈 종이로 새로 */
export function newDoc(name = "", per = "㎡"): EasyDoc {
return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false });
}
/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */
const partOf = (c: EasyCard): "호표" | "덧줄" =>
c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표";
const TYPE_OF: Record<string, string> = {
person: "인력",
material: "재료",
machine: "로직",
logic: "로직",
};
/** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */
function qtyTextFor(c: EasyCard, rawText: unknown): string {
if (c.qtyFormula !== null) return c.qtyFormula;
if (c.qty === null || !Number.isFinite(c.qty)) return "";
const keep =
typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty;
return keep ? (rawText as string) : numText(c.qty);
}
/** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */
function setField(o: Record<string, unknown>, k: string, v: string): void {
if (v !== "" || k in o) o[k] = v;
}
function hoLineOf(c: EasyCard): HoLine {
const raw = isObj(c.raw) ? clone(c.raw) : {};
const line: Record<string, unknown> = raw;
if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind];
line.요소 = clone(c.element);
setField(line, "이름", c.name);
setField(line, "규격", c.spec);
setField(line, "단위", c.unit);
line.수량 = qtyTextFor(c, raw.수량);
if (c.cost !== null) line.비목 = c.cost;
else if (isCost(raw.비목)) delete line.비목;
return line as unknown as HoLine;
}
function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string {
const rate = numText(c.qty ?? 0);
if (c.percentBase === "사람줄합") {
const terms = persons.map((p) => personTerm(p.qtyText, p.element));
return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`;
}
return `${c.percentBase ?? "노무비"} * ${rate} / 100`;
}
function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula {
const raw = isObj(c.raw) ? clone(c.raw) : {};
const line: Record<string, unknown> = raw;
line.이름 = c.name;
const rawText = typeof raw.식 === "string" ? raw.식 : "";
if (c.kind === "percent") {
const p = parsePercent(rawText);
const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase;
line.식 = same ? rawText : percentText(c, persons);
} else {
line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText);
}
if (c.cost !== null) line.비목 = c.cost;
else if (isCost(raw.비목)) delete line.비목;
return line as unknown as NamedFormula;
}
export function toRow(doc: EasyDoc): LogicRow {
const row = clone(doc.row) as LogicRow & Record<string, unknown>;
row.이름 = doc.name;
setField(row, "규격", doc.spec);
row.결과단위 = doc.resultUnit;
row.입력 = clone(doc.inputs);
const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf);
const persons = ho
.filter((h) => h.종류 === "인력")
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons));
if (ho.length || "호표" in row) row.호표 = ho;
if (add.length || "덧줄" in row) row.덧줄 = add;
return row;
}
/* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */
const COST_OF: Record<PickResult["kind"], CostKind | null> = {
person: "노무비",
material: "재료비",
machine: null,
logic: null,
};
/** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */
const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind);
/** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */
function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] {
const last = (hit: (c: EasyCard) => boolean): number => {
let at = -1;
cards.forEach((c, i) => {
if (hit(c)) at = i;
});
return at;
};
let at = last((c) => groupOf(c) === groupOf(card));
if (at < 0) at = last((c) => partOf(c) === partOf(card));
if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1;
const out = cards.slice();
out.splice(at + 1, 0, card);
return out;
}
export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc {
const card: EasyCard = {
id: newId(),
kind: pick.kind,
name: pick.name,
spec: pick.spec,
unit: pick.unit,
qty: pick.qty,
qtyFormula: null,
cost: COST_OF[pick.kind],
percentBase: null,
element: clone(pick.element),
lockedWhy: null,
source: null,
raw: null,
};
return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []);
}
/** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */
export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc {
const inputs = doc.inputs.slice();
for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need));
return inputs.length === doc.inputs.length ? doc : { ...doc, inputs };
}
/** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */
export function addPercent(
doc: EasyDoc,
name: string,
base: CostKind | "사람줄합",
rate: number | null,
cost: CostKind | null,
): EasyDoc {
const card: EasyCard = {
id: newId(),
kind: "percent",
name,
spec: "",
unit: "%",
qty: rate,
qtyFormula: null,
cost,
percentBase: base,
element: null,
lockedWhy: null,
source: null,
raw: null,
};
return { ...doc, cards: insertAt(doc.cards, card) };
}
/** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */
export function updateCard(doc: EasyDoc, id: string, patch: Partial<EasyCard>): EasyDoc {
return {
...doc,
cards: doc.cards.map((c) => {
if (c.id !== id) return c;
const next: EasyCard = { ...c, ...patch, id: c.id };
if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null;
return next;
}),
};
}
export function removeCard(doc: EasyDoc, id: string): EasyDoc {
return { ...doc, cards: doc.cards.filter((c) => c.id !== id) };
}
/** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */
export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc {
const from = doc.cards.findIndex((c) => c.id === id);
if (from < 0) return doc;
const g = groupOf(doc.cards[from]);
let to = from + step;
while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step;
if (to < 0 || to >= doc.cards.length) return doc;
const cards = doc.cards.slice();
[cards[from], cards[to]] = [cards[to], cards[from]];
return { ...doc, cards };
}
/* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */
/** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */
const TOO_BIG: Record<string, number> = { 인: 10, 일: 10, 시간: 24, "%": 100 };
const TOO_BIG_ELSE = 10000;
export function checkCard(card: EasyCard, doc: EasyDoc): string | null {
if (!card.name.trim()) return tx("NoName");
if (card.qtyFormula !== null) return null;
if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber");
if (card.qty === 0) return tx("Zero");
if (card.qty < 0) return tx("Negative");
const same = doc.cards.some(
(c) =>
c.id !== card.id &&
c.kind === card.kind &&
c.name === card.name &&
c.spec === card.spec &&
JSON.stringify(c.element) === JSON.stringify(card.element) &&
(card.kind !== "percent" || c.percentBase === card.percentBase),
);
if (same) return tx("SameLine");
if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) {
const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`;
return tx("TooBig")
.replace("{per}", per)
.replace("{qty}", numText(card.qty))
.replace("{unit}", card.unit);
}
return null;
}
/** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */
export function checkName(name: string, mine: string[]): string | null {
if (!name.trim()) return tx("NoName");
return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null;
}
/* ── 되돌리기 ───────────────────────────────────────────────────────────── */
const HISTORY_MAX = 200;
/** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */
export class EasyHistory {
private stack: string[] = [];
private saved: string | null = null;
push(doc: EasyDoc): void {
const s = JSON.stringify(doc);
if (this.stack[this.stack.length - 1] === s) return;
this.stack.push(s);
if (this.saved === null) this.saved = s;
if (this.stack.length > HISTORY_MAX) this.stack.shift();
}
undo(): EasyDoc | null {
if (this.stack.length < 2) return null;
this.stack.pop();
return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc;
}
get canUndo(): boolean {
return this.stack.length > 1;
}
/** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */
get dirty(): boolean {
return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved;
}
markSaved(): void {
this.saved = this.stack[this.stack.length - 1] ?? null;
}
}
@@ -1,100 +0,0 @@
.m01-easypick {
--modal-max: 86vh;
width: min(760px, 94vw);
font-size: var(--text-body-lg, 1.125rem);
}
.m01-easypick__ask {
margin: 0;
font-size: 1.35em;
color: var(--color-text);
}
.m01-easypick__muted {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-easypick__kinds {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--spacing-12);
}
.m01-easypick__kind,
.m01-easypick__row {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
border: 2px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-easypick__kind {
flex-direction: column;
min-height: 96px;
}
.m01-easypick__kind strong {
font-size: 1.3em;
}
.m01-easypick__kind span {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-easypick__kind:hover,
.m01-easypick__row:hover,
.m01-easypick__kind:focus-visible,
.m01-easypick__row:focus-visible {
border-color: var(--color-accent);
outline: 3px solid var(--color-accent);
outline-offset: 1px;
}
.m01-easypick__bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m01-easypick__bar > :last-child {
flex: 1 1 200px;
}
.m01-easypick__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
max-height: 46vh;
overflow: auto;
}
.m01-easypick__row {
align-items: center;
justify-content: space-between;
min-height: 52px;
}
.m01-easypick__name {
flex: 1 1 auto;
min-width: 0;
}
.m01-easypick__unit,
.m01-easypick__price {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-easypick__tag {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m01-easypick__chosen {
margin: 0;
font-size: 1.15em;
}
.m01-easypick__rough {
min-height: 1.4em;
margin: 0;
color: var(--color-accent);
font-weight: 600;
}
@@ -1,495 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Pick.ts
* 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음.
* 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절.
* 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch";
import { fetchLogic, fetchMaterials, type ElementBrief } from "../M01_MasterData_UI_Logic_Api";
import { listEasy, listMachines, machineElement, plainError } from "./M01_EasyCalc_Api";
import type { PickResult } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Pick.css";
type Kind = PickResult["kind"];
/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */
type Brief = ElementBrief & { 구분?: string; 상세구분?: string };
const TEXT = {
Title: ["넣을 것 고르기", "Pick what to add"],
Close: ["닫기", "Close"],
Back: ["← 뒤로", "← Back"],
AskKind: ["무엇을 넣을까요?", "What do you want to add?"],
AskWhich: ["어떤 것인가요?", "Which one?"],
AskQty: ["얼마나 쓰나요?", "How much?"],
Person: ["사람", "People"],
Material: ["재료", "Material"],
Machine: ["기계", "Machine"],
Logic: ["다른 일위대가", "Another unit-price job"],
PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"],
MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"],
MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"],
LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"],
Search: ["이름을 쳐서 찾기", "Type a name to search"],
AllSub: ["전체", "All"],
SubLabel: ["구분", "Group"],
DetailLabel: ["상세구분", "Detail"],
Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"],
Loading: ["찾는 중…", "Searching…"],
None: ["찾는 것 없음", "Nothing found"],
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
NoPrice: ["단가 없음", "No price"],
PerDay: ["하루 {p} 원", "{p} won per day"],
PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"],
Picked: ["고른 것", "Chosen"],
QtyPlace: ["예: 0.072", "e.g. 0.072"],
QtyLabel: ["{u} (숫자)", "{u} (number)"],
Rough: ["금액 어림 약 {m} 원", "About {m} won"],
Add: ["넣기", "Add"],
QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"],
Failed: ["불러오지 못함", "Could not load"],
} as const;
const te = (key: keyof typeof TEXT, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
TEXT[key][currentLanguageIndex] as string,
);
const won = (n: number): string => Math.round(n).toLocaleString("ko-KR");
/** 목록에 그릴 줄 수 — 더 찾으려면 이름을 쳐서 좁힘 */
const SHOW = 100;
const MAT_FILE = "재료_자재품목.json";
/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */
interface Candidate {
name: string;
spec: string;
unit: string;
/** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */
price: number | null;
/** 줄 오른쪽 글(단가 표시) */
priceText: string;
tag?: string;
make: () => Promise<Omit<PickResult, "qty">>;
}
interface Filter {
q: string;
sub: string;
detail: string;
}
interface Source {
title: string;
desc: string;
/** 구분 · 상세구분 칸을 보일지(재료만) */
grouped?: boolean;
/** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */
load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>;
}
/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 ·
* 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */
const byName = <T extends { 이름?: string }>(items: T[], q: string): T[] => {
const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase();
const w = squash(q);
const rank = (it: T): number => {
const n = squash(it.이름 ?? "");
if (n === w) return 0;
if (!n.startsWith(w)) return 3;
return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1;
};
return items
.map((it, i) => ({ it, i, r: rank(it) }))
.sort((a, b) => a.r - b.r || a.i - b.i)
.map((x) => x.it);
};
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const SOURCES: Partial<Record<Kind, Source>> = {
person: {
title: te("Person"),
desc: te("PersonDesc"),
load: async (f) => {
const { total, items } = await fetchPick("job", f.q);
return {
total,
items: items.map((it) => {
const price = num(it.값);
return {
name: it.이름,
spec: it.규격,
unit: "인",
price,
priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }),
make: async () => ({
kind: "person",
name: it.이름,
spec: it.규격,
unit: "인",
element: it.ref,
}),
};
}),
};
},
},
material: {
title: te("Material"),
desc: te("MaterialDesc"),
grouped: true,
load: async (f) => {
if (!f.q && !f.sub) return null;
const found = f.q
? await fetchPick("price", f.q).then((r) => ({
total: r.total,
items: byName(r.items, f.q)
.filter((it) => !f.sub || (it as Brief).구분 === f.sub)
.map((it) => ({ ...it, 구분: (it as Brief).구분 })),
}))
: await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then(
(r) => ({
total: r.total,
items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? f.sub })),
}),
);
return {
total: found.total,
items: found.items.map((it) => {
const name = it.이름 ?? "";
const spec = it.규격 ?? "";
const unit = typeof it.단위 === "string" ? it.단위 : "";
const price = num(it.값);
const element: Record<string, string> = { 구분: it.구분 ?? f.sub };
const detail = (it as Brief).상세구분;
if (detail) element["상세구분"] = detail;
element["검색어"] = `${name} ${spec}`.trim();
return {
name,
spec,
unit,
price,
priceText:
price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }),
tag: element["구분"],
make: async () => ({ kind: "material", name, spec, unit, element }),
};
}),
};
},
},
machine: {
title: te("Machine"),
desc: te("MachineDesc"),
load: async (f) => {
const items = await listMachines(f.q);
return {
total: items.length,
items: items.map((m) => ({
name: m.이름,
spec: m.규격,
unit: m.단위,
price: null,
priceText: "",
make: async () => {
const { element, needInputs } = await machineElement(m.분류번호);
return {
kind: "machine",
name: m.이름,
spec: m.규격,
unit: m.단위,
element,
needInputs,
};
},
})),
};
},
},
logic: {
title: te("Logic"),
desc: te("LogicDesc"),
load: async (f) => {
// 돈으로 나오는 일위대가만 — 「원/㎡」 꼴
const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/"));
return {
total: items.length,
items: items.map((e) => {
const unit = e.resultUnit.slice(2);
return {
name: e.name,
spec: "",
unit,
price: null,
priceText: "",
tag: e.owner,
make: async () => {
// 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴)
const needInputs = (await fetchLogic(e.key)).logic.입력;
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
return {
kind: "logic",
name: e.name,
spec: "",
unit,
element: `로직(${e.key}${rest})`,
needInputs,
};
},
};
}),
};
},
},
};
/** 물음 셋 — 고른 것 · 취소는 null */
export function openEasyPick(
kind?: Kind,
opts: { qty?: number | null } = {},
): Promise<PickResult | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (r: PickResult | null): void => {
if (!settled) resolve(r);
settled = true;
};
openModal({
title: te("Title"),
closeLabel: te("Close"),
dialogClass: "m01-easypick",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
const done = (r: PickResult): void => (finish(r), close());
const stepKinds = (): void => {
body.replaceChildren(
el("h4", { className: "m01-easypick__ask", text: te("AskKind") }),
kindButtons(),
);
body.querySelector<HTMLElement>("button")?.focus();
};
const kindButtons = (): HTMLElement => {
const order: Kind[] = ["person", "material", "machine", "logic"];
const grid = el("div", { className: "m01-easypick__kinds" });
order.forEach((k, i) => {
const src = SOURCES[k];
const b = el("button", {
className: "m01-easypick__kind",
attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) },
children: [
el("strong", { text: src?.title ?? labelOf(k) }),
el("span", { text: src?.desc ?? descOf(k) }),
],
});
b.addEventListener("click", () => stepList(k, true));
grid.append(b);
});
grid.addEventListener("keydown", (ev) => {
const k = order[Number(ev.key) - 1];
if (k) stepList(k, true);
});
return grid;
};
const stepList = (k: Kind, canBack: boolean): void => {
const src = SOURCES[k];
if (!src) return;
const back = canBack ? stepKinds : null;
const search = createInputField({ type: "search", placeholder: te("Search") });
const filter: Filter = { q: "", sub: "", detail: "" };
const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } });
const count = el("span", { className: "m01-easypick__muted" });
const bar: HTMLElement[] = [search.root];
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
count.textContent = "";
list.replaceChildren(
el("p", { className: "m01-easypick__muted", text: te("Loading") }),
);
try {
const found = await src.load(filter);
if (mine !== seq) return; // 최신 응답만
if (!found)
return void list.replaceChildren(
el("p", { className: "m01-easypick__muted", text: te("Hint") }),
);
const shown = found.items.slice(0, SHOW);
count.textContent = te("Count", { n: found.total, m: shown.length });
list.replaceChildren(
...(shown.length
? shown.map((c) =>
rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)),
)
: [el("p", { className: "m01-easypick__muted", text: te("None") })]),
);
} catch (e) {
if (mine === seq) showToast(plainError(e), "error");
}
};
if (src.grouped) bar.unshift(...subFilters(filter, load));
let timer = 0;
search.input.addEventListener("input", () => {
filter.q = search.input.value.trim();
window.clearTimeout(timer);
timer = window.setTimeout(load, 250);
});
body.replaceChildren(
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }),
el("div", { className: "m01-easypick__bar", children: bar }),
count,
list,
);
body.onkeydown = (ev) => {
if (back && ev.altKey && ev.key === "ArrowLeft") back();
else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev);
};
search.input.focus();
void load();
};
const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => {
const qty = createInputField({
type: "text",
label: te("QtyLabel", { u: c.unit || "—" }),
placeholder: te("QtyPlace"),
});
qty.input.inputMode = "decimal";
if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움
const rough = el("p", { className: "m01-easypick__rough" });
const parse = (): number | null => {
const n = Number(qty.input.value.trim().replace(/,/g, ""));
return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null;
};
const refresh = (): void => {
const n = parse();
rough.textContent =
n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : "";
qty.setError(undefined);
};
const submit = async (): Promise<void> => {
const n = parse();
if (n === null) return qty.setError(te("QtyBad"));
try {
done({ ...(await c.make()), qty: n });
} catch (e) {
qty.setError(plainError(e));
}
};
qty.input.addEventListener("input", refresh);
qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit());
body.replaceChildren(
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
el("h4", { className: "m01-easypick__ask", text: te("AskQty") }),
el("p", {
className: "m01-easypick__chosen",
children: [
el("span", { className: "m01-easypick__muted", text: te("Picked") }),
el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }),
],
}),
qty.root,
rough,
createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }),
);
body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.();
qty.input.focus();
void k;
};
if (kind) stepList(kind, false);
else stepKinds();
},
});
});
}
const labelOf = (k: Kind): string =>
te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]);
const descOf = (k: Kind): string =>
te(
(
{
person: "PersonDesc",
material: "MaterialDesc",
machine: "MachineDesc",
logic: "LogicDesc",
} as const
)[k],
);
/** 목록 줄 — 이름 · 크기 · 단위 · 단가 */
function rowOf(c: Candidate, onPick: () => void): HTMLElement {
const b = el("button", {
className: "m01-easypick__row",
attrs: { type: "button", role: "option" },
children: [
el("span", {
className: "m01-easypick__name",
children: [
el("strong", { text: c.name }),
...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []),
...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []),
],
}),
el("span", { className: "m01-easypick__unit", text: c.unit }),
el("span", { className: "m01-easypick__price", text: c.priceText }),
],
});
b.addEventListener("click", onPick);
return b;
}
/** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */
function moveFocus(body: HTMLElement, ev: KeyboardEvent): void {
const items = [...body.querySelectorAll<HTMLElement>("input[type=search], .m01-easypick__row")];
const at = items.indexOf(document.activeElement as HTMLElement);
if (at < 0) return;
const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)];
if (!next) return;
ev.preventDefault();
next.focus();
}
/** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */
function subFilters(filter: Filter, load: () => Promise<void>): HTMLElement[] {
let subs: SubInfo[] = [];
const all = { value: "", text: te("AllSub") };
const subBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: te("SubLabel"),
onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()),
});
const detailBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: te("DetailLabel"),
onChange: (v) => ((filter.detail = v), void load()),
});
const fillDetail = (): void => {
const details = subs.find((s) => s.name === filter.sub)?.details ?? [];
detailBox.root.hidden = !details.length;
detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], "");
};
fillDetail();
void fetchSubs({ file: MAT_FILE })
.then((r) => {
subs = r;
subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub);
})
.catch(() => showToast(te("Failed"), "error"));
return [subBox.root, detailBox.root];
}
@@ -1,246 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Sheet.ts
* 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨)
* 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import {
searchElements,
type CalcAnswer,
type CalcLine,
type LogicInput,
} from "../M01_MasterData_UI_Logic_Api";
import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money";
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
import "./M01_EasyCalc_Sheet.css";
const TEXT = {
Busy: ["계산 중", "Calculating"],
Stopped: ["계산하지 못했어요", "Could not calculate"],
Name: ["명칭", "Name"],
Spec: ["규격", "Size"],
Qty: ["수량", "Qty"],
Unit: ["단위", "Unit"],
Total: ["합계", "Total"],
Labor: ["노무비", "Labor"],
Material: ["재료비", "Material"],
Expense: ["경비", "Expense"],
Note: ["비고", "Note"],
Price: ["단가", "Price"],
Amount: ["금액", "Amount"],
Strip: ["설계 때 정하는 값", "Values set at design time"],
Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"],
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
Pick: ["고르세요", "Choose"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
OutOfRange: ["범위 밖이에요", "Out of range"],
} as const;
const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
const COSTS = ["노무비", "재료비", "경비"] as const;
const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const cell = (text: string, className = ""): HTMLElement => el("td", { className, text });
const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num");
/** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */
const note = (line: CalcLine): string =>
[line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · ");
function lineRow(line: CalcLine): HTMLElement {
// 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임
const per = (k: string): number =>
line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0;
return el("tr", {
children: [
cell(line.이름),
cell(line.품목?.규격 ?? ""),
money(qty(line.수량)),
cell(line.단위),
money(formatPrice(line.단가)),
money(formatMoney(line.금액)),
...COSTS.flatMap((k) => [
money(formatPrice(per(k))),
money(formatMoney(line.비목?.[k] ?? 0)),
]),
cell(note(line), "m01-easysheet__note"),
],
});
}
/** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */
export function renderSheet(
host: HTMLElement,
head: { name: string; resultUnit: string },
ans: CalcAnswer | null,
): void {
const title = el("p", {
className: "m01-easysheet__title",
text: [head.name, head.resultUnit].filter(Boolean).join(" · "),
});
if (!ans || !ans.ok) {
host.replaceChildren(
title,
el("p", {
className: "m01-easysheet__status",
text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"),
}),
);
return;
}
const lines = ans.lines ?? [];
const sums = ans.sums ?? {};
const group = (label: string): HTMLElement =>
el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } });
const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } });
const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h }));
const table = el("table", {
className: "m01-easysheet__table",
children: [
el("thead", {
children: [
el("tr", {
children: [
one(ts("Name")),
one(ts("Spec")),
one(ts("Qty")),
one(ts("Unit")),
group(ts("Total")),
...COSTS.map((k) => group(ts(COST_HEAD[k]))),
one(ts("Note")),
],
}),
el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }),
],
}),
el("tbody", { children: lines.map(lineRow) }),
el("tfoot", {
children: [
el("tr", {
children: [
el("th", { text: ts("Total"), attrs: { colspan: "4" } }),
cell(""),
money(formatMoney(sums["계"] ?? 0)),
...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]),
cell(""),
],
}),
],
}),
],
});
host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] }));
}
/** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */
const KEY_RE = /^[A-Z]{2}\d{6}$/;
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
const nameCache = new Map<string, string | null>();
async function keyName(key: string): Promise<string | null> {
if (nameCache.has(key)) return nameCache.get(key)!;
const group = KEY_GROUP[key.slice(0, 2)];
let name: string | null = null;
if (group) {
try {
const hit = (await searchElements(group, key)).items.find((it) => it.ref === key);
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
} catch {
name = null;
}
}
nameCache.set(key, name);
return name;
}
/** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */
type Trial = Record<string, unknown>;
function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
const [lo, hi] = spec.범위 ?? [];
const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : "";
const box = el("input", {
className: "m01-easysheet__input",
attrs: { type: "text", inputmode: "decimal", placeholder: hint },
});
const warn = el("span", { className: "m01-easysheet__warn" });
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
box.addEventListener("change", () => {
const raw = box.value.trim();
const num = Number(raw);
const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : "";
const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : "";
warn.textContent = bad || out;
box.toggleAttribute("aria-invalid", !!(bad || out));
if (bad) return;
if (raw === "") delete state[spec.이름];
else state[spec.이름] = num;
emit();
});
return el("span", { className: "m01-easysheet__ctl", children: [box, warn] });
}
function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
const picks = spec.고르기 ?? [];
const optionText = (o: string | number): string =>
(typeof o === "string" && nameCache.get(o)) || String(o);
const options = (): { value: string; text: string }[] => [
{ value: "", text: ts("Pick") },
...picks.map((o) => ({ value: String(o), text: optionText(o) })),
];
const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
const field = createSelectField({
options: options(),
value: cur,
compact: true,
ariaLabel: spec.이름,
onChange: (v) => {
const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄
if (hit === undefined) delete state[spec.이름];
else state[spec.이름] = hit;
emit();
},
});
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
if (keys.length) {
void Promise.all(keys.map(keyName)).then(() => field.setOptions(options(), cur));
}
return field.root;
}
/** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */
export function renderTrialStrip(
host: HTMLElement,
inputs: LogicInput[],
trial: Record<string, unknown>,
onChange: (trial: Record<string, unknown>) => void,
): void {
if (!inputs.length) {
host.replaceChildren();
host.hidden = true;
return;
}
host.hidden = false;
const state = { ...trial };
const emit = (): void => onChange({ ...state });
host.replaceChildren(
el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }),
el("div", {
className: "m01-easysheet__fields",
children: inputs.map((spec) =>
el("label", {
className: "m01-easysheet__field",
children: [
el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }),
spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit),
],
}),
),
}),
el("p", { className: "m01-easysheet__muted", text: ts("Trial") }),
);
}
@@ -1,175 +0,0 @@
/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */
.ui-modal.m01-easycalc {
--modal-max: 90vh;
width: min(1280px, 96vw);
height: var(--modal-max);
font-size: var(--text-body);
}
.m01-easycalc .ui-modal__body {
flex: 1;
min-height: 0;
gap: var(--spacing-16);
}
.m01-easycalc__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-easycalc__big {
min-height: 56px;
padding: 0 var(--spacing-24);
font-size: var(--text-body);
}
/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */
.m01-easycalc__start {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--spacing-24);
min-height: 0;
}
.m01-easycalc__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01-easycalc__start-col h4 {
margin: var(--spacing-8) 0 0;
}
.m01-easycalc__ask {
margin: 0;
font-size: var(--text-subheading);
}
.m01-easycalc__search .ui-input {
font-size: var(--text-body);
}
.m01-easycalc__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-easycalc__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-easycalc__item:hover:not(:disabled) {
border-color: var(--color-accent);
}
.m01-easycalc__item:disabled {
cursor: default;
opacity: 0.6;
}
.m01-easycalc__item-name {
flex: 1;
min-width: 0;
}
.m01-easycalc__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
/* 고치는 화면 — 위 띠 */
.m01-easycalc__strip {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
padding-bottom: var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m01-easycalc__strip .ui-field {
min-width: 120px;
}
.m01-easycalc__name {
flex: 1 1 240px;
}
.m01-easycalc__total {
font-size: var(--text-subheading);
font-weight: 700;
white-space: nowrap;
}
.m01-easycalc__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
.m01-easycalc__base {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-16);
}
.m01-easycalc__base p {
margin: 0;
}
.m01-easycalc__part {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01-easycalc__part h4 {
margin: 0;
}
.m01-easycalc__cards {
flex: 1;
}
.m01-easycalc__sheet summary {
cursor: pointer;
font-weight: 600;
}
/* 고친 뒤 금액을 다시 셈하는 동안 옛 금액을 흐리게 */
.m01-easycalc .is-pending {
opacity: 0.45;
}
.m01-easycalc__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m01-easycalc__note:empty {
display: none;
}
@@ -1,161 +0,0 @@
/* 쉬운 단가산출 2단계 — 표 보고 고르기 창 */
.ui-modal.m01-easytable {
width: min(1100px, 96vw);
}
.m01-easytable .ui-modal__body {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.m01-easytable__note {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-easytable__which,
.m01-easytable__actions,
.m01-easytable__seg {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-easytable__which:empty {
display: none;
}
.m01-easytable__actions {
justify-content: flex-end;
}
.m01-easytable__head {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-easytable__conds {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01-easytable__conds h4 {
margin: 0;
font-size: var(--text-body);
}
.m01-easytable__cond {
display: grid;
grid-template-columns: minmax(6rem, 10rem) minmax(5rem, 1fr) auto;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m01-easytable__cond-value {
font-weight: 600;
}
.m01-easytable__grid {
max-height: 46vh;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01-easytable__grid table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m01-easytable__grid th,
.m01-easytable__grid td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: left;
white-space: nowrap;
}
.m01-easytable__grid thead th {
position: sticky;
top: 0;
background: var(--color-surface);
color: var(--color-text-secondary);
}
.m01-easytable__grid td.is-value {
text-align: right;
}
.m01-easytable__grid .is-col {
font-weight: 700;
color: var(--color-text);
}
.m01-easytable__grid tbody tr {
cursor: pointer;
}
.m01-easytable__grid tbody tr:hover {
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
}
.m01-easytable__grid tr.is-hit {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-easytable__grid tr.is-picked td:first-child {
box-shadow: inset 3px 0 0 var(--color-accent);
}
.m01-easytable__money {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-16);
}
.m01-easytable__money.is-busy {
opacity: 0.5;
}
.m01-easytable__hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
font-size: var(--text-body-sm);
}
.m01-easytable__hint:empty {
display: none;
}
.m01-easytable__warn {
margin: 0;
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m01-easytable__warn:empty {
display: none;
}
@media (max-width: 600px) {
.m01-easytable__cond {
grid-template-columns: 1fr 1fr;
}
.m01-easytable__seg {
grid-column: 1 / -1;
}
}
@@ -1,696 +0,0 @@
/* =============================================================================
* M01_EasyCalc_Table.ts
* 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`)
* 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고,
* 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null).
*
* 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 —
* 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`).
* 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchTable } from "../M01_MasterData_Api_Fetch";
import {
fetchAuto,
runCalc,
runText,
searchElements,
type HoLine,
type LogicRow,
type NamedFormula,
} from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Table.css";
const TEXT = {
Title: ["표 보고 고르기", "Pick from the table"],
Close: ["닫기", "Close"],
Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"],
NoTable: ["표를 불러오지 못함", "Couldn't load the table"],
Loading: ["불러오는 중", "Loading"],
Hint: [
"줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸",
"Tap a row to use it · yellow = row in use · bold = column the quantity comes from",
],
Conds: ["어느 줄을 쓸까", "Which row"],
Design: ["설계 때 정함", "Set at design"],
Fix: ["이 값으로 고정", "Fix this value"],
AsIs: ["지금대로", "As is"],
NotYet: ["(아직 없음)", "(none yet)"],
Amount: ["이 줄 금액", "This line"],
Total: ["합계", "Total"],
Wait: ["계산 중", "Calculating"],
Stopped: ["계산 멈춤", "Calculation stopped"],
DesignHint: [
"이 값은 설계 때 정함 — 늘 이 줄로 하려면",
"This value is set at design — to always use this row,",
],
Mismatch: [
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
"The picked row isn't matched by these conditions",
],
Use: ["이걸로", "Use this"],
Cancel: ["취소", "Cancel"],
Which: ["표 고르기", "Choose table"],
} as const;
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1];
/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */
export interface FindArg {
/** 표 조건 칸 이름 */
key: string;
/** `=` 오른쪽 식 글 그대로 */
rhs: string;
}
export interface FindCall {
/** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */
start: number;
end: number;
table: string;
args: FindArg[];
col: string;
}
const NAME = /[\p{L}\p{N}_]/u;
const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch);
class Reader {
i = 0;
constructor(readonly s: string) {}
ws(): void {
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
}
take(ch: string): boolean {
this.ws();
if (this.s[this.i] !== ch) return false;
this.i++;
return true;
}
name(): string | null {
this.ws();
const start = this.i;
while (isName(this.s[this.i])) this.i++;
return this.i > start ? this.s.slice(start, this.i) : null;
}
quoted(): string | null {
const end = this.s.indexOf("'", this.i);
if (end < 0) return null;
const text = this.s.slice(this.i, end);
this.i = end + 1;
return text;
}
/** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */
until(head: boolean): string | null {
const start = this.i;
let depth = 0;
while (this.i < this.s.length) {
const ch = this.s[this.i];
if (ch === "'" && !head) {
const end = this.s.indexOf("'", this.i + 1);
if (end < 0) return null;
this.i = end + 1;
continue;
}
if (ch === "(" || ch === "{") depth++;
else if (ch === ")" || ch === "}") {
if (depth === 0) break;
depth--;
} else if (ch === "," && depth === 0) break;
this.i++;
}
return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null;
}
}
/** 한 자리의 찾기 — 못 읽으면 null */
function readOne(s: string, at: number): FindCall | null {
const r = new Reader(s);
r.i = at + 2;
if (!r.take("(")) return null;
r.ws();
const table = r.until(true);
if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸
const args: FindArg[] = [];
while (r.take(",")) {
r.ws();
const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
if (!key || !r.take("=")) return null;
r.ws();
const rhs = r.until(false);
if (!rhs) return null;
args.push({ key, rhs });
}
if (!r.take(")") || !r.take(".")) return null;
r.ws();
const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
if (!col) return null;
return { start: at, end: r.i, table, args, col };
}
/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */
export function readFinds(formula: string): FindCall[] | null {
const out: FindCall[] = [];
let i = 0;
while (i < formula.length) {
const ch = formula[i];
if (ch === "'") {
const end = formula.indexOf("'", i + 1);
if (end < 0) return null;
i = end + 1;
continue;
}
if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) {
const call = readOne(formula, i);
if (!call) return null;
out.push(call);
i = call.end;
continue;
}
i++;
}
return out;
}
const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`);
/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */
export function writeFind(table: string, args: FindArg[], col: string): string {
const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)];
return `찾기(${parts.join(", ")}).${plain(col)}`;
}
/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */
export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string {
let out = formula;
for (let k = calls.length - 1; k >= 0; k--) {
const text = swaps[k];
if (text !== null && text !== undefined)
out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end);
}
return out;
}
/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */
export function literal(value: unknown, rule = ""): string | null {
if (typeof value === "number") return String(value);
if (typeof value === "string") return value.includes("'") ? null : `'${value}'`;
if (Array.isArray(value)) {
const n = rangeNumber(value as [number | null, number | null], rule);
return n === null ? null : String(n);
}
return null;
}
/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */
export function rangeNumber(cell: [number | null, number | null], rule: string): number | null {
const [low, high] = cell;
const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0];
const round = (n: number): number => Number(n.toFixed(6));
if (low !== null && high !== null) return round((low + high) / 2);
if (high !== null) {
if (kind !== "이상·미만") return high;
return round(high > 1 ? high - 1 : high / 2);
}
if (low !== null) return kind.startsWith("이상") ? low : low + 1;
return null;
}
/** 식 글이 따옴표 글 · 수면 그 값 */
export function literalValue(rhs: string): string | number | null {
const q = rhs.match(/^'([^']*)'$/);
if (q) return q[1];
return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null;
}
/* ── 표 · 서버 ──────────────────────────────────────────────────────── */
type Cell = string | number | [number | null, number | null] | null | undefined;
interface EasyTable {
이름?: string;
기준?: string;
범위규칙?: string;
조건: Record<string, string>;
값칸: Record<string, string>;
줄: Record<string, Cell>[];
}
const tables = new Map<string, Promise<EasyTable | null>>();
function loadTable(key: string): Promise<EasyTable | null> {
const hit = tables.get(key);
if (hit) return hit;
const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"];
const job = (async (): Promise<EasyTable | null> => {
for (const group of groups) {
const found = await searchElements(group, key).catch(() => null);
const file = found?.items.find((x) => x.ref === key)?.file;
if (!file) continue;
const got = await fetchTable(file, key).catch(() => null);
if (got?.table) return got.table as unknown as EasyTable;
}
return null;
})();
tables.set(key, job);
return job;
}
/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */
function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula {
const raw = (card.raw ?? {}) as Record<string, unknown>;
if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula;
return {
종류: "인력",
요소: String(card.element ?? ""),
이름: card.name,
단위: card.unit,
...raw,
수량: formula,
} as HoLine;
}
/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */
function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
const line = cardLine(card, formula);
if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] };
return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] };
}
/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */
function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
try {
const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c));
return { ...toRow({ ...doc, cards }), 키: "" };
} catch {
const row: LogicRow = { ...doc.row, 키: "" };
const at = card.source?.index ?? -1;
if (card.source?.part === "덧줄" && row.덧줄?.[at])
row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x));
else if (row.호표?.[at])
row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x));
return row;
}
}
interface Preview {
ok: boolean;
/** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */
hits: (number | null)[];
line: number | null;
total: number | null;
}
async function preview(
card: EasyCard,
doc: EasyDoc,
formula: string,
calls: FindCall[],
inputs: Record<string, unknown>,
): Promise<Preview> {
const [text, calc] = await Promise.all([
runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null),
runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null),
]);
const lines = text?.ok
? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name)
: [];
const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표");
const seen = new Map<string, number>();
const hits = calls.map((c) => {
const nth = seen.get(c.table) ?? 0;
seen.set(c.table, nth + 1);
const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth];
return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null;
});
const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0);
return {
ok: !!text?.ok && !!calc?.ok,
hits,
line: lines.length ? sum : null,
total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null,
};
}
/* ── 창 ─────────────────────────────────────────────────────────────── */
type Mode = "design" | "fix" | "asis";
interface CondState {
key: string;
/** 원래 식 글 · 새로 더한 조건은 null */
orig: string | null;
mode: Mode;
/** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */
input: string | null;
fix: Cell;
}
interface CallState {
conds: CondState[];
col: string;
picked: number | null;
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
tryValues: Record<string, unknown>;
/** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */
designHint: string[];
}
const showCell = (v: Cell): string => {
if (v === null || v === undefined) return "";
if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
return String(v);
};
function startState(call: FindCall, doc: EasyDoc): CallState {
const names = new Set(doc.inputs.map((x) => x.이름));
const conds = call.args.map((a): CondState => {
const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null;
const lit = literalValue(a.rhs);
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
});
return { conds, col: call.col, picked: null, tryValues: {}, designHint: [] };
}
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
const out: FindArg[] = [];
for (const c of st.conds) {
if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input });
else if (c.mode === "fix") {
const rhs = literal(c.fix, table?.범위규칙);
if (rhs === null) return null;
out.push({ key: c.key, rhs });
} else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig });
}
return out;
}
/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */
function compose(
formula: string,
calls: FindCall[],
states: CallState[],
tbl: (EasyTable | null)[],
) {
const swaps = calls.map((call, k) => {
const args = stateArgs(states[k], tbl[k]);
if (!args) return null;
const same =
states[k].col === call.col &&
args.length === call.args.length &&
args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs);
return same ? null : writeFind(call.table, args, states[k].col);
});
return replaceFinds(formula, calls, swaps);
}
/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */
export function openEasyTable(
card: EasyCard,
doc: EasyDoc,
trial?: Record<string, unknown>,
): Promise<string | null> {
const formula = card.qtyFormula ?? "";
const calls = readFinds(formula);
return new Promise((resolve) => {
let done = false;
const finish = (value: string | null): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title: tt("Title"),
closeLabel: tt("Close"),
dialogClass: "m01-easytable",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
if (!calls || !calls.length) {
body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") }));
return;
}
void mountTable(body, { card, doc, formula, calls, trial }, (v) => {
finish(v);
close();
});
},
});
});
}
async function mountTable(
body: HTMLElement,
ctx: {
card: EasyCard;
doc: EasyDoc;
formula: string;
calls: FindCall[];
trial?: Record<string, unknown>;
},
finish: (value: string | null) => void,
): Promise<void> {
const { card, doc, formula, calls } = ctx;
body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") }));
const [base, tbl] = await Promise.all([
ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})),
Promise.all(calls.map((c) => loadTable(c.table))),
]);
const states = calls.map((c) => startState(c, doc));
let now = 0;
let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null };
let seq = 0;
let busy = false;
const which = el("div", { className: "m01-easytable__which" });
const head = el("div", { className: "m01-easytable__head" });
const condBox = el("div", { className: "m01-easytable__conds" });
const grid = el("div", { className: "m01-easytable__grid" });
const money = el("div", { className: "m01-easytable__money" });
const warn = el("p", { className: "m01-easytable__warn" });
const hint = el("p", { className: "m01-easytable__hint" });
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
const cancel = createButton({
label: tt("Cancel"),
variant: "ghost",
onClick: () => finish(null),
});
const newFormula = (): string => compose(formula, calls, states, tbl);
const inputs = (): Record<string, unknown> => ({ ...base, ...states[now].tryValues });
const refresh = async (): Promise<void> => {
const mine = ++seq;
busy = true;
paint();
const got = await preview(card, doc, newFormula(), calls, inputs());
if (mine !== seq) return;
last = got;
busy = false;
paint();
};
const pickRow = (idx: number, col: string | null): void => {
const table = tbl[now];
const st = states[now];
const row = table?.줄[idx];
if (!table || !row) return;
st.designHint = [];
for (const key of Object.keys(table.조건)) {
if (row[key] === undefined) continue;
let c = st.conds.find((x) => x.key === key);
if (!c) {
const input = doc.inputs.some((x) => x.이름 === key) ? key : null;
c = { key, orig: null, mode: "fix", input, fix: null };
st.conds.push(c);
}
if (c.mode === "design" && c.input) {
const n = Array.isArray(row[key])
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
: row[key];
st.tryValues[c.input] = n;
st.designHint.push(key);
} else {
c.mode = "fix";
c.fix = row[key];
}
}
if (col) st.col = col;
st.picked = idx;
void refresh();
};
const setMode = (c: CondState, mode: Mode): void => {
if (mode === "fix" && (c.fix === null || c.fix === undefined)) {
const hit = last.hits[now];
const row = hit === null ? null : tbl[now]?.줄[hit];
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
}
c.mode = mode;
states[now].designHint = states[now].designHint.filter((k) => k !== c.key);
void refresh();
};
/** 누른 줄의 「설계 때 정함」 조건을 한 번에 그 줄 값으로 고정 */
const fixPicked = (): void => {
const st = states[now];
const row = st.picked === null ? null : tbl[now]?.줄[st.picked];
if (!row) return;
for (const c of st.conds)
if (st.designHint.includes(c.key)) {
c.mode = "fix";
c.fix = row[c.key];
}
st.designHint = [];
void refresh();
};
const paintHint = (): void => {
const st = states[now];
hint.replaceChildren(
...(st.picked !== null && st.designHint.length
? [
el("span", { text: `${tt("DesignHint")} ` }),
createButton({ label: tt("Fix"), size: "sm", onClick: fixPicked }),
]
: []),
);
};
const paintConds = (): void => {
const st = states[now];
condBox.replaceChildren(
el("h4", { text: tt("Conds") }),
...st.conds.map((c) => {
const opts: [Mode, string][] = [];
if (c.input) opts.push(["design", tt("Design")]);
if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null)
opts.push(["asis", tt("AsIs")]);
opts.push(["fix", tt("Fix")]);
const hit = last.hits[now];
const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]);
const value =
c.mode === "fix"
? showCell(c.fix)
: c.mode === "design" && c.input
? showCell(inputs()[c.input] as Cell) || used
: used;
return el("div", {
className: "m01-easytable__cond",
children: [
el("span", { className: "m01-easytable__cond-name", text: c.key }),
el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }),
el("span", {
className: "m01-easytable__seg",
children: opts.map(([mode, label]) => {
const b = createButton({
label,
size: "sm",
variant: c.mode === mode ? "filled" : "ghost",
onClick: () => setMode(c, mode),
});
b.setAttribute("aria-pressed", String(c.mode === mode));
return b;
}),
}),
],
});
}),
);
};
const paintGrid = (): void => {
const table = tbl[now];
if (!table) {
grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") }));
return;
}
const st = states[now];
const conds = Object.keys(table.조건);
const vals = Object.keys(table.값칸);
const hit = last.hits[now];
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
const rows = table.줄.map((row, idx) => {
const tr = el("tr", {
className: `${idx === hit && !busy ? "is-hit" : ""} ${idx === st.picked ? "is-picked" : ""}`,
children: [
...conds.map((k) => el("td", { text: showCell(row[k]) })),
...vals.map((k) =>
el("td", {
className: `is-value${k === st.col ? " is-col" : ""}`,
text: showCell(row[k]),
attrs: { "data-col": k },
}),
),
],
});
tr.addEventListener("click", (ev) => {
const cell = (ev.target as HTMLElement).closest("td");
pickRow(idx, cell?.dataset.col ?? null);
});
return tr;
});
const unit = (k: string): string => (table.값칸[k] ? `${k} (${table.값칸[k]})` : k);
const top = [
...conds.map((k) => th(k)),
...vals.map((k) => th(unit(k), k === st.col ? "is-col" : "")),
];
const thead = el("thead", { children: [el("tr", { children: top })] });
grid.replaceChildren(el("table", { children: [thead, el("tbody", { children: rows })] }));
};
const paint = (): void => {
const table = tbl[now];
which.replaceChildren(
...(calls.length > 1
? calls.map((_c, k) =>
createButton({
label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`,
size: "sm",
variant: k === now ? "filled" : "ghost",
onClick: () => {
now = k;
void refresh();
},
}),
)
: []),
);
head.replaceChildren(
el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }),
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
);
paintConds();
paintHint();
paintGrid();
const picked = states[now].picked;
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
warn.textContent = mismatch ? tt("Mismatch") : "";
const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`);
money.replaceChildren(
el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }),
el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }),
el("span", {
className: "m01-easytable__note",
text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"),
}),
);
money.classList.toggle("is-busy", busy);
use.disabled = busy || mismatch || !last.ok;
};
body.replaceChildren(
which,
head,
condBox,
hint,
grid,
money,
warn,
el("div", { className: "m01-easytable__actions", children: [cancel, use] }),
);
await refresh();
}
-121
View File
@@ -1,121 +0,0 @@
/* =============================================================================
* M01_LB_Api.ts
* 로직 만들기 판 Api — 고친 표로 예시값 계산(0.3초 · 최신만) · 표를 쓰는 로직(사용처) · 표 읽기
* 서버 = `M01_LB_Calc.py`(`/api/m01/lb/*`) · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` 「맞출 모양」
* 시험: `resources/tester/test_m01_lb_calc.py`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchTable, fetchTables } from "../M01_MasterData_Api_Fetch";
import { ApiError, type CalcAnswer, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
/** 표 키 → 표 사본(고친 값 포함 · 표 통째) — 서버가 메모리에서만 바꿔 끼움 */
export type LbTables = Record<string, unknown>;
/** 표를 쓰는 로직 한 줄 — `via` = 표 「용도」 에 적힘 · 로직 글에 표 키가 있음 · 둘 다 */
export interface LbTableUser {
key: string;
이름: string;
via: "용도" | "글" | "둘";
}
/** 표 한 벌 — `file` · `version` 은 표가 든 파일 */
export interface LbTable {
file: string;
version: string;
table: Record<string, unknown> & { 줄: Record<string, unknown>[] };
}
const CALC_WAIT_MS = 300;
const TABLE_GROUPS = ["소요량", "계수"]; // 표가 드는 그룹(계수 표가 소요량 파일에 든 것도 있음)
async function send<T>(path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01/lb${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new ApiError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
/** 로직이 받는 입력값만 — 모르는 이름을 보내면 서버가 멈춤 */
function inputsFor(row: LogicRow, inputs: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
(row.입력 ?? []).filter((i) => i.이름 in inputs).map((i) => [i.이름, inputs[i.이름]]),
);
}
/** 계산 멈춤 까닭 — 표 키(`QF000421`)가 섞인 글은 뭉뚱그림 */
function plainStop(reason: string): string {
const plain = safeReason(reason);
return /[A-Z]{2}\d{6}/.test(plain) ? safeReason("{}") : plain;
}
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | undefined;
/** 0.3초 뒤 고친 표로 서버 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
export function lbCalcLater(
row: LogicRow,
inputs: Record<string, unknown>,
tables: LbTables,
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
): void {
const mine = ++calcSeq;
clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
send<CalcAnswer>("/calc", { row, inputs: inputsFor(row, inputs), tables })
.then((ans) => {
if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(ans.reason));
})
.catch((e: unknown) => {
if (mine === calcSeq) onDone(null, plainError(e));
});
}, CALC_WAIT_MS);
}
/** 기다림 없이 바로 셈(기대값 시험표처럼 여러 줄을 차례로) — `lbCalcLater` 의 최신만과 안 걸림.
* 서버에 못 닿으면 null · 멈춤(ok false)은 답 그대로 · 둘 다 `onPlain` 에 쉬운 말 */
export async function lbCalcNow(
row: LogicRow,
inputs: Record<string, unknown>,
tables: LbTables,
onPlain?: (plain: string) => void,
): Promise<CalcAnswer | null> {
try {
const ans = await send<CalcAnswer>("/calc", { row, inputs: inputsFor(row, inputs), tables });
if (!ans.ok) onPlain?.(plainStop(ans.reason));
return ans;
} catch (e: unknown) {
onPlain?.(plainError(e));
return null;
}
}
/** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */
export async function tableUsers(key: string): Promise<LbTableUser[]> {
const got = await send<{ users: LbTableUser[] }>(
`/table-users?${new URLSearchParams({ key }).toString()}`,
);
return got.users;
}
/** 표 키 하나로 표 통째 — 든 파일을 찾아(소요량 · 계수) Api_Fetch 의 표 읽기로 */
export async function fetchLbTable(key: string): Promise<LbTable> {
for (const group of TABLE_GROUPS) {
const page = await fetchTables(group, "", "", key, 1, 5);
const head = page.tables.find((t) => t.키 === key);
if (head) return { file: head.file, ...(await fetchTable(head.file, key)) };
}
throw new ApiError(404, `없는 표 「${key}」`);
}
@@ -1,665 +0,0 @@
/* =============================================================================
* M01_LB_Blocks.ts
* 로직 만들기 판 — 로직 줄 ↔ 블록(① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값) 읽기 · 쓰기
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/로직판_A_설계초안.md` 7-1 · `로직판_A_정함.md` 그대로
*
* 정본 상태 = `lb.doc`(EasyCalc Model 의 EasyDoc) — 쓰기는 `toRow(lb.doc)` 하나
* 읽기 = 식 자리(중간 · 수량 · 요소 · 비율 · 결과)마다 엔진 문법(`master_formula._Parser`)으로 읽어
* ② 표 조회는 Table `readFinds` · ③ 조건 덩어리는 Cond `readCond` · 나머지 사칙 사슬은 여기서
* 고치기 = 그 자리 글만 갈아끼운 줄을 다시 읽음(안 고친 자리는 글자까지 그대로)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { readCond, writeRead, type CondRead } from "../EasyCalc/M01_EasyCalc_Cond";
import { emptyRow, toDoc, toRow, type EasyDoc } from "../EasyCalc/M01_EasyCalc_Model";
import { readFinds, writeFind, type FindArg } from "../EasyCalc/M01_EasyCalc_Table";
/** 값이 오는 곳 */
export type LbRef =
| { k: "input"; name: string } // ① 입력값
| { k: "fixed"; value: number | string } // 고정값
| { k: "lookup"; id: string } // ② 표 조회 결과
| { k: "calc"; name: string } // ③ 이름 붙은 계산값(중간)
| { k: "logic"; key: string; field: string | null } // 다른 로직 결과값(.노무비 등)
| { k: "cost"; name: "노무비" | "재료비" | "경비" | "사람줄합" };
/** 블록으로 못 나눈 조각 — 원래 글 그대로 */
export interface LbExpr {
k: "expr";
text: string;
}
/** 식 자리 — 중간 · 호표 수량 · 덧줄 · 결과 · 장비/일위대가 요소 */
export type LbPart = "중간" | "수량" | "비율" | "결과" | "요소";
export interface LbLookup {
/** 화면 번호 · 저장 안 함 */
id: string;
/** QF000421 · 현장 사본이면 QX… */
table: string;
conds: { col: string; from: LbRef | LbExpr }[];
/** 결과 칸 */
col: string;
at: { part: LbPart; index: number; start: number; end: number }[];
}
export type LbCalcKind =
"값" | "곱나눔" | "할증" | "더하기" | "조건목록" | "최소최대" | "올림" | "단계";
export interface LbCalc {
/** 중간 이름 · 수량 자리면 null */
name: string | null;
kind: LbCalcKind;
/** 곱나눔 · 더하기 · 할증(바탕 · 율) */
parts: (LbRef | LbExpr)[];
ops?: ("*" | "/" | "+" | "-")[];
/** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 · 다른 틀이면 글 속 조건 덩어리 */
cond?: CondRead;
at: { part: Exclude<LbPart, "요소">; index: number };
/** 원래 글(안 고치면 그대로 씀) */
text: string;
}
export interface LbLogic {
/** ④ 항목 · ⑤ 금액 — Model 것 그대로 */
doc: EasyDoc;
/** ① */
inputs: LogicInput[];
/** ② */
lookups: LbLookup[];
/** ③ */
calcs: LbCalc[];
/** ⑤ */
result: { kind: "금액"; unit: string } | { kind: "값"; unit: string; calc: LbCalc };
/** 못 읽은 까닭(쉬운 말) — 비면 다 읽힘 */
blocked: string[];
}
/** 원문 표 칸 고치기(메모리 사본) */
export interface TableEdit {
table: string;
row: number;
col: string;
value: number | string | [number | null, number | null];
}
const TEXT = {
BadText: ["읽을 수 없는 글이 있음", "Has text that can't be read"],
CondInLookup: ["표 조회 조건에 계산이 들어 있음", "A table lookup condition is calculated"],
AndOr: ["조건에 「그리고 · 또는」이 섞임", "A condition mixes and/or"],
PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"],
Unknown: ["모르는 이름이 있음", "Has an unknown name"],
Result: ["결과값", "Result value"],
Qty: ["수량", "quantity"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/* ── 식 읽기(엔진 문법) ─────────────────────────────────────────────────── */
type Node = {
t:
"num" | "str" | "name" | "eref" | "paren" | "neg" | "field" | "call" | "find" | "logic" | "bin";
v?: string;
f?: string;
op?: string;
a?: Node;
b?: Node;
items?: Node[];
head?: string;
args?: [string, Node][];
src: string;
};
const CMPS = ["<=", ">=", "==", "!=", "<", ">"];
const ROUND = ["올림", "버림", "반올림"];
function parse(s: string): Node | null {
let i = 0;
const fail = (): never => {
throw new Error("parse");
};
const ws = (): void => {
while (i < s.length && /\s/.test(s[i])) i++;
};
const take = (w: string): boolean => {
ws();
if (!s.startsWith(w, i)) return false;
i += w.length;
return true;
};
const need = (w: string): void => void (take(w) || fail());
const stick = (re: RegExp): string | null => {
re.lastIndex = i;
const m = re.exec(s);
if (m) i = re.lastIndex;
return m ? m[0] : null;
};
const word = (): string => (ws(), stick(/[\p{L}\p{N}_]+/uy) ?? fail());
const quoted = (): string => {
const e = s.indexOf("'", i);
if (e < 0) fail();
const v = s.slice(i, e);
i = e + 1;
return v;
};
/** 키 · 원문번호 글 — head 면 , ) 에서만 멈춤 */
const ref = (head: boolean): string => {
const st = i;
for (let d = 0; i < s.length; i++) {
const c = s[i];
if ("({".includes(c)) d++;
else if (")}".includes(c)) {
if (!d) break;
d--;
} else if (!d && c === ",") break;
else if (head) continue;
else if (!d && c === "." && !/\d/.test(s[i + 1] ?? "")) break;
else if (!d && /^\s[-+*/<>=!]/.test(s.slice(i, i + 2))) break;
}
return s.slice(st, i).trim();
};
const mk = (st: number, o: Omit<Node, "src">): Node => ({ ...o, src: s.slice(st, i).trim() });
const fields = (st: number, n: Node): Node => {
while (take(".")) n = mk(st, { t: "field", a: n, f: take("'") ? quoted() : word() });
return n;
};
const args = (): [string, Node][] => {
const out: [string, Node][] = [];
while (take(",")) {
const k = take("'") ? quoted() : word();
need("=");
out.push([k, expr()]);
}
need(")");
return out;
};
const primary = (): Node => {
ws();
const st = i;
if (take("(")) {
const a = expr();
need(")");
return fields(st, mk(st, { t: "paren", a }));
}
if (take("'")) return mk(st, { t: "str", v: quoted() });
const n = stick(/\d+(?:\.\d+)?/y);
if (n) return mk(st, { t: "num", v: n });
const w = word();
if (w === "찾기" || w === "로직") {
need("(");
ws();
const head = ref(true);
return fields(st, mk(st, { t: w === "찾기" ? "find" : "logic", head, args: args() }));
}
if (["만약", "최소", "최대", ...ROUND].includes(w)) {
need("(");
const items = [expr()];
while (take(",")) items.push(expr());
need(")");
return fields(st, mk(st, { t: "call", f: w, items }));
}
if (s[i] === ":") {
i++;
ref(false);
return fields(st, mk(st, { t: "eref" }));
}
return fields(st, mk(st, { t: "name", v: w }));
};
const unary = (): Node => {
ws();
const st = i;
return take("-") ? mk(st, { t: "neg", a: unary() }) : primary();
};
const chain = (next: () => Node, ops: string[], once = false): Node => {
ws();
const st = i;
let n = next();
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
n = mk(st, { t: "bin", op, a: n, b: next() });
return n;
};
const mul = (): Node => chain(unary, ["*", "/"]);
const add = (): Node => chain(mul, ["+", "-"]);
const cmp = (): Node => chain(add, CMPS, true);
const both = (): Node => chain(cmp, ["그리고"]);
const expr = (): Node => chain(both, ["또는"]);
try {
const node = expr();
ws();
return i === s.length ? node : null;
} catch {
return null;
}
}
const un = (n: Node): Node => (n.t === "paren" ? un(n.a!) : n);
const flat = (s: string): string => s.replace(/\s+/g, "");
const KEY = /^[A-Z]{2}\d{6}$/;
/* ── 읽기 ───────────────────────────────────────────────────────────────── */
interface Ctx {
row: LogicRow;
inputs: Set<string>;
mids: Set<string>;
/** 찾기 글(빈칸 뺌) → 표 조회 번호 */
finds: Map<string, string>;
/** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */
personSum: string | null;
bad: Set<string>;
}
/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */
const SRC = new WeakMap<object, { text: string; snap: string }>();
const keep = <T extends object>(ref: T, text: string): T => {
SRC.set(ref, { text, snap: JSON.stringify(ref) });
return ref;
};
const isSimple = (n: Node, c: Ctx): boolean => {
const u = un(n);
if (u.t === "num" || u.t === "str") return true;
if (u.t === "neg") return un(u.a!).t === "num";
return u.t === "name" && (c.inputs.has(u.v!) || c.mids.has(u.v!));
};
/** 식 나무를 훑어 못 읽는 까닭을 모음 */
function check(n: Node, c: Ctx): void {
const u = un(n);
if (u.t === "bin" && (u.op === "그리고" || u.op === "또는")) c.bad.add(tl("AndOr"));
if (u.t === "find" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("CondInLookup"));
if (u.t === "logic" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("PassCalc"));
if (u.t === "find" || u.t === "logic") return;
if (u.t === "name") {
const v = u.v!;
const known =
c.inputs.has(v) ||
c.mids.has(v) ||
KEY.test(v) ||
["노무비", "재료비", "경비", "계", "줄"].includes(v);
const item = (c.row.호표 ?? []).some((h) => h.이름 === v);
if (!known && !item) c.bad.add(tl("Unknown"));
}
for (const x of [u.a, u.b, ...(u.items ?? [])]) if (x) check(x, c);
}
/** 잎 하나 → 값이 오는 곳 */
function refOf(node: Node, c: Ctx): LbRef | LbExpr {
const u = un(node);
const text = node.src;
if (u.t === "num") return keep({ k: "fixed", value: Number(u.v) }, text);
if (u.t === "neg" && un(u.a!).t === "num")
return keep({ k: "fixed", value: -Number(un(u.a!).v) }, text);
if (u.t === "str") return keep({ k: "fixed", value: u.v! }, text);
if (u.t === "name" && c.inputs.has(u.v!)) return keep({ k: "input", name: u.v! }, text);
if (u.t === "name" && c.mids.has(u.v!)) return keep({ k: "calc", name: u.v! }, text);
if (u.t === "name" && ["노무비", "재료비", "경비"].includes(u.v!))
return keep({ k: "cost", name: u.v as "노무비" | "재료비" | "경비" }, text);
if (c.personSum !== null && flat(text) === c.personSum)
return keep({ k: "cost", name: "사람줄합" }, text);
if (u.t === "field" && u.a!.t === "find") {
const id = c.finds.get(flat(u.src));
if (id) return keep({ k: "lookup", id }, text);
}
if (u.t === "logic" || (u.t === "field" && u.a!.t === "logic")) {
const call = u.t === "logic" ? u : u.a!;
return keep({ k: "logic", key: call.head!, field: u.t === "field" ? u.f! : null }, text);
}
return { k: "expr", text };
}
const isLeaf = (n: Node): boolean => {
const u = un(n);
return (
["num", "str", "name", "field", "logic", "eref"].includes(u.t) ||
(u.t === "neg" && un(u.a!).t === "num")
);
};
/** 같은 연산 사슬 — a op b op c … */
function chainOf(n: Node, ops: string[]): { items: Node[]; ops: string[] } {
const u = un(n);
if (u.t !== "bin" || !ops.includes(u.op!) || u !== n) return { items: [n], ops: [] };
const left = chainOf(u.a!, ops);
return { items: [...left.items, u.b!], ops: [...left.ops, u.op!] };
}
function calcOf(text: string, at: LbCalc["at"], name: string | null, c: Ctx): LbCalc {
const base = { name, at, text, parts: [] as (LbRef | LbExpr)[] };
const node = parse(text);
if (!node) {
c.bad.add(tl("BadText"));
return { ...base, kind: "단계" };
}
check(node, c);
const u = un(node);
const inner = (): CondRead | undefined => {
const r = readCond(text, c.row);
return r && r.via === null ? r : undefined;
};
if (isLeaf(u)) return { ...base, kind: "값", parts: [refOf(node, c)] };
if (u.t === "call") {
const kind: LbCalcKind =
u.f === "만약" ? "조건목록" : ROUND.includes(u.f!) ? "올림" : "최소최대";
const cond = inner();
const whole =
cond &&
cond.blocks.length === 1 &&
cond.blocks[0].p[0] === 0 &&
cond.blocks[0].p[1] >= cond.text.length;
return whole ? { ...base, kind, cond } : { ...base, kind: "단계", cond };
}
if (u.t === "bin" && u.op === "*") {
const r = un(u.b!);
if (r.t === "bin" && r.op === "+" && un(r.a!).t === "num" && un(r.a!).v === "1") {
const rate = un(r.b!);
if (rate.t === "bin" && rate.op === "/" && un(rate.b!).t === "num" && un(rate.b!).v === "100")
return { ...base, kind: "할증", parts: [refOf(u.a!, c), refOf(rate.a!, c)], cond: inner() };
}
}
for (const [kind, ops] of [
["곱나눔", ["*", "/"]],
["더하기", ["+", "-"]],
] as const) {
const ch = chainOf(u === node ? node : u, [...ops]);
if (ch.items.length > 1)
return {
...base,
kind,
parts: ch.items.map((x) => refOf(x, c)),
ops: ch.ops as LbCalc["ops"],
cond: inner(),
};
}
return { ...base, kind: "단계", cond: inner() };
}
/** 사람 줄 노임 합 — 「(수량 * 노임 + …)」 꼴 글 · 사람 줄이 없으면 null */
function personSumText(row: LogicRow): string | null {
const terms = (row.호표 ?? [])
.filter((h) => h.종류 === "인력")
.map((h) => {
const q = String(h.수량 ?? "").trim();
return `${/^[\p{L}\p{N}_.]+$/u.test(q) ? q : `(${q})`} * ${String(h.요소)}`;
});
return terms.length ? `(${terms.join(" + ")})` : null;
}
/** 식 자리 모두 — 차례 = 중간 · 수량 · 요소 · 비율 · 결과 */
function placesOf(row: LogicRow): { part: LbPart; index: number; text: string; label: string }[] {
const ho = row.호표 ?? [];
return [
...(row.중간 ?? []).map((m, i) => ({
part: "중간" as const,
index: i,
text: m.식,
label: m.이름,
})),
...ho.map((h, i) => ({
part: "수량" as const,
index: i,
text: String(h.수량 ?? ""),
label: `${h.이름 ?? ""} ${tl("Qty")}`,
})),
...ho
.map((h, i) => ({ part: "요소" as const, index: i, text: h.요소, label: h.이름 ?? "" }))
.filter(
(p): p is { part: "요소"; index: number; text: string; label: string } =>
typeof p.text === "string" && /^\s*로직\s*\(/.test(p.text),
),
...(row.덧줄 ?? []).map((d, i) => ({
part: "비율" as const,
index: i,
text: d.식,
label: d.이름,
})),
...(row.결과
? [{ part: "결과" as const, index: 0, text: row.결과.식, label: tl("Result") }]
: []),
];
}
/** 로직 줄 → 블록 */
export function readLogic(
row: LogicRow,
meta: { key: string | null; version: string | null; isBase: boolean },
): LbLogic {
const doc = toDoc(row, meta);
const ps = personSumText(row);
const c: Ctx = {
row,
inputs: new Set((row.입력 ?? []).map((x) => x.이름)),
mids: new Set((row.중간 ?? []).map((x) => x.이름)),
finds: new Map(),
personSum: ps === null ? null : flat(ps),
bad: new Set(),
};
const lookups: LbLookup[] = [];
const blocked: string[] = [];
const places = placesOf(row);
for (const p of places) {
const calls = readFinds(p.text);
if (!calls) {
blocked.push(`${p.label} — ${tl("BadText")}`);
continue;
}
for (const call of calls) {
const key = flat(p.text.slice(call.start, call.end));
let id = c.finds.get(key);
if (!id) {
id = `L${lookups.length + 1}`;
c.finds.set(key, id);
const look: LbLookup = { id, table: call.table, col: call.col, conds: [], at: [] };
look.conds = call.args.map((a) => ({ col: a.key, from: argRef(a, c) }));
ARGS.set(look, call.args);
lookups.push(look);
}
lookups
.find((x) => x.id === id)!
.at.push({ part: p.part, index: p.index, start: call.start, end: call.end });
}
}
const calcs: LbCalc[] = [];
for (const p of places) {
if (p.part === "요소") {
const n = parse(p.text);
c.bad.clear();
if (n) check(n, c);
else c.bad.add(tl("BadText"));
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
continue;
}
c.bad.clear();
const name = p.part === "중간" ? p.label : null;
calcs.push(calcOf(p.text, { part: p.part, index: p.index }, name, c));
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
}
const resultCalc = calcs.find((x) => x.at.part === "결과");
return {
doc,
inputs: row.입력 ?? [],
lookups,
calcs,
result: resultCalc
? { kind: "값", unit: row.결과단위, calc: resultCalc }
: { kind: "금액", unit: row.결과단위 },
blocked: [...new Set(blocked)],
};
}
/** 표 조회의 원래 조건 글 — 안 바꾼 조건은 이 글 그대로 */
const ARGS = new WeakMap<LbLookup, FindArg[]>();
function argRef(a: FindArg, c: Ctx): LbRef | LbExpr {
const n = parse(a.rhs);
return n && isSimple(n, c) ? refOf(n, c) : { k: "expr", text: a.rhs };
}
/* ── 쓰기 ───────────────────────────────────────────────────────────────── */
/** 블록 → 로직 줄 */
export function writeLogic(lb: LbLogic): LogicRow {
return toRow(lb.doc);
}
/** 단계 안내가 채울 빈 로직 */
export function newLogic(name = "", per = "㎡"): LbLogic {
return readLogic(emptyRow(name, per), { key: null, version: null, isBase: false });
}
const metaOf = (lb: LbLogic) => ({
key: lb.doc.key,
version: lb.doc.version,
isBase: lb.doc.isBase,
});
/** 한 자리 글 읽기 · 쓰기 */
function textAt(row: LogicRow, part: LbPart, index: number): string {
if (part === "중간") return row.중간[index].식;
if (part === "비율") return row.덧줄![index].식;
if (part === "결과") return row.결과!.식;
const h = row.호표![index];
return String(part === "수량" ? h.수량 : h.요소);
}
function putText(row: LogicRow, part: LbPart, index: number, text: string): void {
if (part === "중간") row.중간[index] = { ...row.중간[index], 식: text };
else if (part === "비율") row.덧줄![index] = { ...row.덧줄![index], 식: text };
else if (part === "결과") row.결과 = { ...row.결과!, 식: text };
else if (part === "수량") row.호표![index] = { ...row.호표![index], 수량: text };
else row.호표![index] = { ...row.호표![index], 요소: text };
}
/** 자리마다 [처음, 끝, 새 글] 을 뒤에서부터 갈아끼우고 다시 읽음 */
function splice(
lb: LbLogic,
edits: { part: LbPart; index: number; start: number; end: number; text: string }[],
): LbLogic {
const row = writeLogic(lb);
const groups = new Map<string, typeof edits>();
for (const e of edits)
groups.set(`${e.part}:${e.index}`, [...(groups.get(`${e.part}:${e.index}`) ?? []), e]);
for (const list of groups.values()) {
const { part, index } = list[0];
let text = textAt(row, part, index);
for (const e of [...list].sort((x, y) => y.start - x.start))
text = text.slice(0, e.start) + e.text + text.slice(e.end);
putText(row, part, index, text);
}
return readLogic(row, metaOf(lb));
}
const numText = (n: number): string => {
const t = String(Number(n.toPrecision(12)));
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
};
/** 조각 → 글 — 안 바뀐 조각은 원래 글 */
function refText(ref: LbRef | LbExpr, lb: LbLogic): string {
const src = SRC.get(ref);
if (src && src.snap === JSON.stringify(ref)) return src.text;
switch (ref.k) {
case "expr":
return ref.text;
case "input":
case "calc":
return ref.name;
case "fixed":
return typeof ref.value === "number" ? numText(ref.value) : `'${ref.value}'`;
case "cost":
return ref.name === "사람줄합" ? (personSumText(writeLogic(lb)) ?? "0") : ref.name;
case "logic":
return `로직(${ref.key})${ref.field ? `.${ref.field}` : ""}`;
case "lookup": {
const look = lb.lookups.find((x) => x.id === ref.id);
return look ? findText(look, look.conds, look.table) : "0";
}
}
}
/** 사칙 사슬 칸 — 낮은 연산(+ -)이 든 조각은 괄호로 */
function wrap(text: string, strong: boolean): string {
const n = parse(text);
const low = n && n.t === "bin" && ["+", "-"].includes(n.op!);
const mid = n && n.t === "bin" && ["*", "/"].includes(n.op!);
return low || (strong && mid) ? `(${text})` : text;
}
function findText(look: LbLookup, conds: LbLookup["conds"], table: string): string {
const raw = ARGS.get(look) ?? [];
const args: FindArg[] = conds.map((cd) => {
const old = raw.find((a) => a.key === cd.col);
const same = old && look.conds.find((x) => x.col === cd.col)?.from === cd.from;
return {
key: cd.col,
rhs: same ? old.rhs : refText(cd.from, { lookups: [] } as unknown as LbLogic),
};
});
return writeFind(table, args, look.col);
}
/** 표 조회 조건 칸 하나 바꾸기 · from = null 은 조건 뺌(무관) */
export function setCond(
lb: LbLogic,
lookupId: string,
col: string,
from: LbRef | LbExpr | null,
): LbLogic {
const look = lb.lookups.find((x) => x.id === lookupId);
if (!look) return lb;
const has = look.conds.some((x) => x.col === col);
const conds =
from === null
? look.conds.filter((x) => x.col !== col)
: has
? look.conds.map((x) => (x.col === col ? { col, from } : x))
: [...look.conds, { col, from }];
const text = findText(look, conds, look.table);
return splice(
lb,
look.at.map((a) => ({ ...a, text })),
);
}
/** 표 조회가 보는 표 바꾸기(현장 사본 등) */
export function setLookupTable(lb: LbLogic, lookupId: string, table: string): LbLogic {
const look = lb.lookups.find((x) => x.id === lookupId);
if (!look) return lb;
const text = findText(look, look.conds, table);
return splice(
lb,
look.at.map((a) => ({ ...a, text })),
);
}
/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬(patch.text 가 있으면 그 글) */
export function setCalc(lb: LbLogic, calc: LbCalc, patch: Partial<LbCalc>): LbLogic {
const next: LbCalc = { ...calc, ...patch };
const old = textAt(writeLogic(lb), calc.at.part, calc.at.index);
const text = patch.text !== undefined ? patch.text : buildText(next, lb, old);
return splice(lb, [{ ...calc.at, start: 0, end: old.length, text }]);
}
function buildText(c: LbCalc, lb: LbLogic, old: string): string {
const parts = c.parts.map((p) => refText(p, lb));
switch (c.kind) {
case "값":
return parts[0] ?? old;
case "할증":
return `${wrap(parts[0], false)} * (1 + ${wrap(parts[1], true)} / 100)`;
case "곱나눔":
case "더하기": {
const ops = c.ops ?? parts.slice(1).map(() => (c.kind === "곱나눔" ? "*" : "+"));
const strong = c.kind === "곱나눔";
return parts.reduce(
(acc, p, i) =>
i ? `${acc} ${ops[i - 1]} ${wrap(p, strong && ops[i - 1] === "/")}` : wrap(p, false),
"",
);
}
default:
return c.cond ? writeRead(c.cond) : old;
}
}
-114
View File
@@ -1,114 +0,0 @@
"""로직 만들기 판 시험 길(PLAN 35-2) — 고친 표로 시험 계산 · 표를 쓰는 로직.
POST /api/m01/lb/calc {row, inputs, tables: {표 키: 표 통째}} → `/calc` 와 같은 답
GET /api/m01/lb/table-users ?key=표 키 → {key, users: [{key, 이름, via}]}
계산은 `/calc`(`M01_MasterData_Store.calc`)와 같은 길 — 부를 때마다 새로 읽은 마스터 사본
(`cm.load`)에 로직 줄 · 표를 바꿔 끼워 셈. 전역 캐시 · 파일은 안 건드림(동시 요청에 안전) ·
표 고침은 저장하지 않음(정함 5). 권한 = `/api/m01` 과 같음(등록 자리 `main.py` 한 줄 ·
폴더째 지우면 그 줄도 지움).
"""
from __future__ import annotations
import json
import re
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.M01_MasterData_Router import _num
cm = store.cm
router = APIRouter(prefix="/api/m01/lb", tags=["M01 LogicBuilder (시험)"])
class LbCalcBody(BaseModel):
row: dict[str, Any]
inputs: dict[str, Any] = {}
tables: dict[str, dict[str, Any]] = {}
def _put_tables(files: dict[str, dict], tables: dict[str, dict]) -> None:
"""표 키 → 고친 표 — 그 표가 든 파일 사본 자리에 바꿔 끼움 · 키는 못 바꿈 · 없는 표는 404."""
for key, table in tables.items():
for data in files.values():
if data.get("그룹") not in cm.mf.TABLE_GROUPS:
continue
items = data.get("표") or []
at = next((i for i, t in enumerate(items) if str(t.get("키")) == key), None)
if at is not None:
items[at] = {**store._dec(table), "키": key}
break
else:
raise store.StoreError(404, f"없는 표 「{key}」")
def calc(row: dict, inputs: dict, tables: dict[str, dict]) -> dict:
"""`Store.calc` 와 같은 셈 + 표 바꿔 끼우기 — 답 모양도 같음."""
given = {k: store._dec(v) for k, v in inputs.items()}
files = cm.load(folder=store.FOLDER) # 부를 때마다 새 사본 — 캐시 아님
_put_tables(files, tables)
key = store._swap(files, str(row.get("키") or ""), row, None)
try:
given = cm.mf.fill_empty(cm.mf.Master(files).logic(key), given)
result = cm.calc(files, key, given)
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
if "결과" in result:
return {"ok": True, "result": result["결과"], "middle": result["중간"]}
return {
"ok": True,
"lines": result["줄"],
"sums": {k: result[k] for k in cm.mf.BUNDLE},
"middle": result["중간"],
}
def table_users(key: str) -> dict:
"""표를 쓰는 로직 — 표 「용도.로직키」 + 로직 글에 표 키가 든 것(읽기 전용 캐시에서)."""
files, _ = store.loaded()
table = next(
(
t
for data in files.values()
if data.get("그룹") in cm.mf.TABLE_GROUPS
for t in data.get("표") or []
if str(t.get("키")) == key
),
None,
)
if table is None:
raise store.StoreError(404, f"없는 표 「{key}」")
listed = {str(k) for k in (table.get("용도") or {}).get("로직키") or []}
named = re.compile(rf"(?<![A-Z0-9]){re.escape(key)}(?![0-9])")
users = []
for data in files.values():
if data.get("그룹") != "로직":
continue
for row in data.get("줄") or []:
in_use = str(row.get("키")) in listed
in_text = bool(named.search(json.dumps(row, ensure_ascii=False, default=str)))
if in_use or in_text:
via = "둘" if in_use and in_text else "용도" if in_use else "글"
users.append({"key": row.get("키"), "이름": row.get("이름"), "via": via})
return {"key": key, "users": users}
def _call(fn, *args):
try:
return _num(fn(*args))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.post("/calc")
def post_calc(body: LbCalcBody) -> dict:
return _call(calc, body.row, body.inputs, body.tables)
@router.get("/table-users")
def get_table_users(key: str) -> dict:
return _call(table_users, key)
-266
View File
@@ -1,266 +0,0 @@
/* =============================================================================
* M01_LB_Check.ts
* 로직 만들기 판 — 검사(정함 7 · 설계초안 4절)
* ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈)
* ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복
* ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패
* 시험표 줄은 sessionStorage 초안만(저장 안 함)
*
* 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 ·
* 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈.
* DOM 없음.
* ========================================================================== */
import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { lbCalcNow, type LbTables } from "./M01_LB_Api";
import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks";
type Cell = string | number | boolean | null | [number | null, number | null];
export interface CheckTable {
키?: string;
범위규칙?: string;
조건?: Record<string, string>;
값칸?: Record<string, string>;
줄: Record<string, unknown>[];
}
/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
function match(
table: CheckTable,
row: Record<string, unknown>,
cond: string,
want: unknown,
): boolean {
if (!(cond in row)) return true; // 빈 조건 칸 = 무관
const have = row[cond] as Cell;
if (table.조건?.[cond] === "범위") {
if (!Array.isArray(have) || !isNum(want)) return false;
const [low, high] = have;
const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · ");
const rule = head.split(" (")[0];
const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low);
if (rule === "이상·이하") return above(false) && (high === null || want <= high);
if (rule === "초과·이하" || rule === "이하")
return above(true) && (high === null || want <= high);
if (rule === "이상·미만") {
const tops = table.줄
.map((r) => r[cond])
.filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null)
.map((c) => c[1]);
const closed =
more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops);
return above(false) && (high === null || want < high || (closed && want === high));
}
return false; // 모르는 범위규칙 — 엔진은 멈춤
}
if (isNum(have) && isNum(want)) return have === want;
return typeof have === "string" && typeof want === "string" && have === want;
}
/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */
export function findRows(table: CheckTable, conds: Record<string, unknown>, want = ""): number[] {
let hits = table.줄
.map((row, i) => ({ row, i }))
.filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v)));
const first = (table.범위규칙 ?? "").split(" · ")[0];
if (hits.length > 1 && (first === "이하" || first === "이상·이하")) {
for (const c of Object.keys(conds)) {
if (table.조건?.[c] !== "범위") continue;
const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]);
if (!ups.length || ups.includes(null)) continue;
const low = Math.min(...(ups as number[]));
hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low);
}
}
if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row);
return hits.map((h) => h.i);
}
/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */
export interface Gap {
table: string;
/** 어느 표 조회에서 나왔나(화면 번호) */
lookup: string;
conds: Record<string, unknown>;
kind: "없음" | "중복";
/** 중복이면 걸린 줄 차례 */
rows: number[];
}
/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */
function combos(lb: LbLogic, lk: LbLookup): Record<string, unknown>[] | null {
const lists: [string, unknown[]][] = [];
for (const { col, from } of lk.conds) {
if (from.k === "fixed") lists.push([col, [from.value]]);
else if (from.k === "input") {
const spec = lb.inputs.find((x) => x.이름 === from.name);
if (!spec?.고르기?.length) return null;
lists.push([col, spec.고르기]);
} else return null;
}
let out: Record<string, unknown>[] = [{}];
for (const [col, values] of lists)
out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v })));
return out;
}
/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */
export function gaps(lb: LbLogic, tables: Record<string, unknown>): Gap[] {
const out: Gap[] = [];
const seen = new Set<string>();
for (const lk of lb.lookups) {
const table = tables[lk.table] as CheckTable | undefined;
const sets = table?.줄 ? combos(lb, lk) : null;
if (!table || !sets) continue;
for (const conds of sets) {
const rows = findRows(table, conds, lk.col);
if (rows.length === 1) continue;
const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`;
if (seen.has(tag)) continue;
seen.add(tag);
out.push({
table: lk.table,
lookup: lk.id,
conds,
kind: rows.length ? "중복" : "없음",
rows,
});
}
}
return out;
}
/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */
export function gapSkipped(lb: LbLogic, tables: Record<string, unknown>): string[] {
return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id);
}
/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */
/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */
export type LbHit = number | null | "없음" | "중복";
export function lookupHits(
lb: LbLogic,
tables: Record<string, unknown>,
values: Record<string, unknown>,
): Record<string, LbHit> {
const out: Record<string, LbHit> = {};
for (const lk of lb.lookups) {
const table = tables[lk.table] as CheckTable | undefined;
const conds: Record<string, unknown> = {};
let known = !!table?.줄;
for (const { col, from } of lk.conds) {
if (from.k === "fixed") conds[col] = from.value;
else if (from.k === "input" && values[from.name] !== undefined)
conds[col] = values[from.name];
else known = false;
}
if (!known || !table) {
out[lk.id] = null;
continue;
}
const rows = findRows(table, conds, lk.col);
out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음";
}
return out;
}
/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */
export interface ExpectRow {
/** 예시값 */
inputs: Record<string, unknown>;
/** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */
expect: Record<string, number>;
}
export interface ExpectResult {
ok: boolean;
got: Record<string, number>;
/** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */
why: string | null;
}
/** 계산 답 → 볼 값 모음 */
export function pickValues(ans: CalcAnswer): Record<string, number> {
const got: Record<string, number> = {};
if (!ans.ok) return got;
if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"];
if (isNum(ans.result)) got["결과"] = ans.result;
for (const line of ans.lines ?? []) {
if (isNum(line.수량)) got[line.이름] = line.수량;
if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액;
}
return got;
}
/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */
export function sameAsExpect(got: number | undefined, expect: number): boolean {
if (!isNum(got)) return false;
const places = (String(expect).split(".")[1] ?? "").length;
return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9;
}
type CalcFn = (
row: LogicRow,
inputs: Record<string, unknown>,
tables: LbTables,
) => Promise<CalcAnswer>;
/** LB Api 바로 계산 — 화면 예시값 계산(0.3초 · 최신만)과 안 부딪힘 · 멈춤 까닭은 쉬운 말로 */
const calcNow: CalcFn = async (row, inputs, tables) => {
let plain = "";
const ans = await lbCalcNow(row, inputs, tables, (p) => (plain = p));
if (!ans) throw new Error(plain);
return ans.ok ? ans : { ok: false, reason: plain || ans.reason };
};
/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */
export async function runExpect(
lb: LbLogic,
tables: LbTables,
rows: ExpectRow[],
calc: CalcFn = calcNow,
): Promise<ExpectResult[]> {
const row = writeLogic(lb);
const out: ExpectResult[] = [];
for (const r of rows) {
try {
const ans = await calc(row, r.inputs, tables);
const got = pickValues(ans);
const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v));
out.push({ ok, got, why: ans.ok ? null : ans.reason });
} catch (e) {
out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) });
}
}
return out;
}
/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */
const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`;
export function loadExpectRows(logic: string): ExpectRow[] {
try {
const raw = sessionStorage.getItem(draftKey(logic));
const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : [];
return Array.isArray(rows) ? rows : [];
} catch {
return [];
}
}
export function saveExpectRows(logic: string, rows: ExpectRow[]): void {
try {
if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows));
else sessionStorage.removeItem(draftKey(logic));
} catch {
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */
}
}
@@ -1,242 +0,0 @@
/* 로직 만들기 판 — 큰 창 · 시작 화면 · 위 띠 · 세 칸 */
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 · 칸마다 따로 굴림 */
.ui-modal.m01-lb {
--modal-max: 92vh;
width: min(1680px, 98vw);
height: var(--modal-max);
font-size: var(--text-body);
}
.m01-lb .ui-modal__body {
flex: 1;
min-height: 0;
gap: var(--spacing-12);
}
.m01-lb__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-lb__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m01-lb__note:empty {
display: none;
}
.m01-lb__big {
min-height: 56px;
padding: 0 var(--spacing-24);
font-size: var(--text-body);
}
.m01-lb .is-pending {
opacity: 0.45;
}
/* 시작 화면 */
.m01-lb__start {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--spacing-24);
min-height: 0;
overflow: auto;
}
.m01-lb__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01-lb__start-col h4 {
margin: var(--spacing-8) 0 0;
}
.m01-lb__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-lb__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-lb__item:hover {
border-color: var(--color-accent);
}
.m01-lb__item-name {
flex: 1;
min-width: 0;
}
.m01-lb__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
/* 위 띠 */
.m01-lb__strip {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
padding-bottom: var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m01-lb__name {
flex: 1 1 280px;
}
.m01-lb__applied {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: nowrap;
}
.m01-lb__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
/* 세 칸 — 원문 · 산출근거 · 결과 */
.m01-lb__cols {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1fr);
gap: var(--spacing-12);
}
.m01-lb__col {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01-lb__col-head {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m01-lb__col-head h4 {
flex: 1;
margin: 0;
}
.m01-lb__col-body {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.m01-lb__col-body h5 {
margin: var(--spacing-8) 0 0;
}
.m01-lb__total {
font-size: var(--text-subheading);
font-weight: 700;
}
.m01-lb__examples {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.m01-lb__example-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-lb__example {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
font-size: var(--text-body-sm);
}
/* 좁은 화면 — 세 칸을 위아래로 */
@media (max-width: 1100px) {
.m01-lb__cols {
grid-template-columns: minmax(0, 1fr);
overflow: auto;
}
}
/* 결과 칸 — 빈틈 검사 · 기대값 시험표 */
.m01-lb__check {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
.m01-lb__check-tools {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-lb__check-out ul {
margin: 0;
padding-left: var(--spacing-16);
font-size: var(--text-body-sm);
}
.m01-lb__expect-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px auto auto;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m01-lb__expect-row .is-ok {
color: var(--color-success, var(--color-text));
font-weight: 600;
}
.m01-lb__expect-row .is-bad {
color: var(--color-danger);
font-weight: 600;
}
-693
View File
@@ -1,693 +0,0 @@
/* =============================================================================
* M01_LB_Entry.ts
* 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기
* 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
* 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid)
* · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본)
* → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내.
* 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
import {
copyEasy,
deleteEasy,
editEasy,
listEasy,
newEasy,
openEasy,
plainError,
usesOf,
type EasyListItem,
} from "../EasyCalc/M01_EasyCalc_Api";
import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet";
import { fetchLbTable, lbCalcLater, type LbTables } from "./M01_LB_Api";
import { readLogic, setCond, writeLogic, type LbLogic, type TableEdit } from "./M01_LB_Blocks";
import { lookupHits } from "./M01_LB_Check";
import { drawExamples, renderChecks } from "./M01_LB_Entry_Result";
import { renderGrid } from "./M01_LB_Grid";
import { renderSource, type LbRole } from "./M01_LB_Source";
import { renderSteps } from "./M01_LB_Steps";
import { openWizard } from "./M01_LB_Wizard";
import "./M01_LB_Entry.css";
const TEXT = {
Open: ["로직 만들기(시험)", "Logic builder (trial)"],
Title: ["로직 만들기", "Logic builder"],
CloseWindow: ["창 닫기", "Close window"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"],
Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"],
Start_Found: ["찾은 것", "Found"],
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
Start_None: ["찾은 것 없음", "Nothing found"],
Start_Field: ["현장 일위대가", "Field unit prices"],
Start_FieldNone: ["아직 없음", "None yet"],
Start_Wizard: ["단계 안내로 새로 만들기", "Create step by step"],
Standard: ["표준", "Standard"],
Field: ["현장", "Field"],
Per: ["{u} 당", "per {u}"],
Name: ["이름", "Name"],
Applied: ["적용 품셈", "Applied standard"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
CopyEdit: ["복사 편집", "Copy and edit"],
Copying: ["현장 일위대가로 복사하는 중", "Copying to a field unit price"],
Copied: ["현장 일위대가로 복사했음 — 이제 고칠 수 있음", "Copied — you can edit it now"],
Unsaved: ["저장 안 함", "Not saved"],
StandardNote: [
"표준은 읽기만 — 고치려면 「복사 편집」으로 현장 일위대가를 만듦",
"Standard items are read-only — use “Copy and edit” to make a field copy",
],
Col_Source: ["원문", "Source"],
Col_Basis: ["산출근거", "Basis"],
Col_Result: ["결과", "Result"],
View_Sentence: ["문장", "Sentences"],
View_Grid: ["격자", "Grid"],
Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
Value: ["결과 {v} {u}", "Result {v} {u}"],
Sheet: ["일위대가표", "Unit-cost sheet"],
NoBlocks: ["이 로직은 블록으로 못 읽음 — 일위대가표만 봄", "This logic can't be shown as blocks"],
Blocked: ["못 읽은 곳:", "Not readable:"],
Role_Result: ["결과 칸 바꾸기는 아직 안 됨", "Changing the result column is not ready yet"],
Role_NoInput: [
"「{v}」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함",
"To use “{v}” as a condition, add an input with the same name",
],
NoName: ["이름을 넣어 주세요", "Enter a name"],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함", "Not saved"],
Delete: ["삭제", "Delete"],
Delete_Ask: ["「{v}」 을(를) 삭제할까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Deleted: ["삭제했음", "Deleted"],
Uses_Wait: ["사용처를 찾는 중 — 몇 초 걸림", "Looking for places that use it"],
Uses_Block: ["사용처가 있어 삭제 못 함 — {v}", "Cannot delete — used by {v}"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
type TextKey = keyof typeof TEXT;
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
/** 시작 화면에 먼저 보이는 시험 로직 여섯(정함 10) */
const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"];
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
const SHOWN = 50;
/** 「원/㎡」 → 「㎡」 */
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
/** 「㎡」 → 「1㎡」 · 「100㎡」 은 그대로 */
const amountOf = (resultUnit: string): string => {
const unit = unitOf(resultUnit);
return /^\d/.test(unit) ? unit : `1${unit}`;
};
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */
interface Paper {
key: string | null;
version: string | null;
isBase: boolean;
row: LogicRow;
trial: Record<string, unknown>;
lb: LbLogic | null;
tables: LbTables;
changed: Set<string>;
}
/** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */
interface Snap {
lb: LbLogic | null;
tables: LbTables;
changed: Set<string>;
}
type View = "sentence" | "grid";
/** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */
const blocksOf = (
row: LogicRow,
key: string | null,
version: string | null,
isBase: boolean,
): LbLogic | null => {
try {
return readLogic(row, { key, version, isBase });
} catch {
return null;
}
};
/** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
export function createLogicBuilderButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openLogicBuilder });
}
function openLogicBuilder(): void {
let body: HTMLElement;
/** 세 칸 화면이 열려 있을 때만 */
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
let logics: EasyListItem[] | null = null;
let query = "";
let view: View = "sentence";
const mayLeave = async (): Promise<boolean> =>
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(단계 안내 등 다른 창은 제 몫) */
const onKey = (ev: KeyboardEvent): void => {
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
};
/* ---------- 시작 화면 ---------- */
const listRow = (item: EasyListItem): HTMLElement => {
const row = el("button", {
className: "m01-lb__item",
attrs: { type: "button", title: item.where },
children: [
el("span", { className: "m01-lb__item-name", text: item.name }),
createTag(item.isBase ? te("Standard") : te("Field"), item.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-lb__item-unit",
text: isMoney(item.resultUnit)
? te("Per", { u: amountOf(item.resultUnit) })
: item.resultUnit,
}),
],
});
row.addEventListener("click", () => void openPaper(item.key));
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
const status = el("p", { className: "m01-lb__muted", text: te("Loading") });
const trialHost = el("div", { className: "m01-lb__list" });
const fieldHost = el("div", { className: "m01-lb__list" });
const foundHost = el("div", { className: "m01-lb__list" });
const search = createInputField({
type: "search",
placeholder: te("Start_Search"),
value: query,
onInput: (v) => {
query = v;
drawFound();
},
});
const drawFound = (): void => {
if (!logics) return;
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const hits = words.length
? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w)))
: [];
status.textContent = !words.length
? ""
: !hits.length
? te("Start_None")
: hits.length > SHOWN
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
: "";
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
};
body.replaceChildren(
el("div", {
className: "m01-lb__start",
children: [
el("div", {
className: "m01-lb__start-col",
children: [
el("h4", { text: te("Start_Trial") }),
trialHost,
search.root,
el("h4", { text: te("Start_Found") }),
status,
foundHost,
],
}),
el("div", {
className: "m01-lb__start-col",
children: [
bigButton(te("Start_Wizard"), () => void openNew()),
el("h4", { text: te("Start_Field") }),
fieldHost,
],
}),
],
}),
);
search.input.focus();
try {
logics ??= await listEasy("");
} catch (error) {
status.textContent = te("LoadFailed", { v: plainError(error) });
return;
}
const byKey = new Map(logics.map((x) => [x.key, x]));
trialHost.replaceChildren(
...TRIAL_KEYS.flatMap((k) => {
const item = byKey.get(k);
return item ? [listRow(item)] : [];
}),
);
const field = logics.filter((x) => !x.isBase);
fieldHost.replaceChildren(
...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]),
);
drawFound();
};
/* ---------- 세 칸 화면 ---------- */
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(muted(te("Loading")));
try {
const o = await openEasy(key);
const lb = blocksOf(o.row, key, o.version, o.isBase);
showPaper({ key, version: o.version, isBase: o.isBase, row: o.row, lb, trial: o.trial });
} catch (error) {
showToast(plainError(error), "error");
void showStart();
}
};
const openNew = async (): Promise<void> => {
const lb = await openWizard();
if (!lb) return;
const row = writeLogic(lb);
showPaper({ key: null, version: null, isBase: false, row, lb, trial: autoTrial(lb.inputs) });
};
const showPaper = (first: Omit<Paper, "tables" | "changed">): void => {
const p: Paper = { ...first, tables: {}, changed: new Set() };
let ans: CalcAnswer | null = null;
let busy = false;
const savedLb = p.key ? p.lb : null;
let stack: Snap[] = [];
const readOnly = p.isBase || !p.lb;
paper = { dirty: () => p.lb !== savedLb, save: () => doSave(), undo: () => doUndo() };
const unsaved = createTag(te("Unsaved"), "warning");
const total = el("div", { className: "m01-lb__total" });
const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } });
const sheetHost = el("div");
const examplesHost = el("div", { className: "m01-lb__examples" });
const checksHost = el("div");
const sourceHost = el("div", { className: "m01-lb__slot" });
const basisHost = el("div", { className: "m01-lb__slot" });
const blockedNote = el("p", { className: "m01-lb__note" });
const rowNow = (): LogicRow => (p.lb ? writeLogic(p.lb) : p.row);
const unitNow = (): string => p.lb?.doc.resultUnit ?? p.row.결과단위 ?? "";
const tableName = (key: string): string | null =>
(p.tables[key] as { 이름?: string } | undefined)?.이름 ?? null;
/** 서버에 넘길 표 — 고친 표만 */
const edited = (): LbTables => Object.fromEntries([...p.changed].map((k) => [k, p.tables[k]]));
const name = createInputField({
label: te("Name"),
value: p.lb?.doc.name ?? p.row.이름,
onInput: (v) => {
if (p.lb) edit({ ...p.lb, doc: { ...p.lb.doc, name: v } });
},
});
name.input.disabled = readOnly;
name.root.classList.add("m01-lb__name");
/** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */
const later = (host: HTMLElement, draw: () => void, answer: boolean): void => {
const a = document.activeElement;
const typing = a instanceof HTMLInputElement || a instanceof HTMLSelectElement;
if (answer && typing && host.contains(a)) {
host.addEventListener("focusout", () => setTimeout(draw), { once: true });
return;
}
draw();
};
const drawSource = (): void => {
if (!p.lb) return void sourceHost.replaceChildren(muted(te("NoBlocks")));
renderSource(sourceHost, p.lb, p.tables, lookupHits(p.lb, p.tables, p.trial), {
edit: onTableEdit,
role: onRole,
});
};
const drawBasis = (): void => {
if (!p.lb) return void basisHost.replaceChildren(muted(te("NoBlocks")));
const on = { change: (lb: LbLogic) => edit(lb) };
if (view === "sentence") renderSteps(basisHost, p.lb, ans, { ...on, tableName });
else renderGrid(basisHost, p.lb, ans, on, { trial: p.trial });
};
/** 합계 · 일위대가표 · 원문 · 산출근거 — 바뀔 때(pending) · 계산 답이 올 때(answer) */
const draw = (pending: boolean, answer = false): void => {
const unit = unitNow();
const v = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined;
total.textContent = isMoney(unit)
? te("Money", { u: amountOf(unit), v: v === undefined ? "—" : formatMoney(v) })
: te("Value", { u: unit, v: v === undefined ? "—" : formatNumber(v) });
total.classList.toggle("is-pending", pending);
sheetHost.classList.toggle("is-pending", pending);
unsaved.hidden = !paper?.dirty();
undo.disabled = !stack.length;
blockedNote.textContent = p.lb?.blocked.length
? `${te("Blocked")} ${p.lb.blocked.join(" · ")}`
: "";
renderSheet(sheetHost, { name: p.lb?.doc.name ?? p.row.이름, resultUnit: unit }, ans);
later(sourceHost, drawSource, answer);
later(basisHost, drawBasis, answer);
};
const calc = (): void => {
draw(true);
lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => {
ans = next;
note.textContent = plain ?? "";
draw(false, true);
});
};
const drawExamplesNow = (): void =>
drawExamples(examplesHost, p.lb?.inputs ?? p.row.입력 ?? [], p.trial, (next) => {
p.trial = next;
calc();
});
/** 바뀐 조각만 새 객체로 온 lb 를 그대로 쌓음 */
const edit = (next: LbLogic): void => {
if (readOnly || next === p.lb) return;
stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }];
const inputsChanged = next.inputs !== p.lb?.inputs;
p.lb = next;
if (inputsChanged) {
p.trial = { ...autoTrial(next.inputs), ...p.trial };
drawExamplesNow();
}
void loadTables();
calc();
};
/** 표 칸 고침 — 메모리 사본만 · 저장 안 함(정함 5) */
const onTableEdit = (e: TableEdit): void => {
const t = p.tables[e.table] as { 줄: Record<string, unknown>[] } | undefined;
if (!t) return;
stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }];
const 줄 = t.줄.map((r, i) => (i === e.row ? { ...r, [e.col]: e.value } : r));
p.tables = { ...p.tables, [e.table]: { ...t, 줄 } };
p.changed = new Set([...p.changed, e.table]);
calc();
};
/** 칸 역할 — 조건 = 같은 이름 입력값 · 고정 = 걸린 줄(없으면 첫 줄) 값 · 결과 칸 바꾸기는 아직 */
const onRole = (table: string, col: string, role: LbRole): void => {
const lb = p.lb;
if (!lb || readOnly) return;
if (role === "결과") return void (note.textContent = te("Role_Result"));
const hits = lookupHits(lb, p.tables, p.trial);
const rows = (p.tables[table] as { 줄?: Record<string, unknown>[] } | undefined)?.줄;
let next = lb;
lb.lookups.forEach((lk, i) => {
if (lk.table !== table || lk.col === col) return;
const id = next.lookups[i]?.id ?? lk.id;
if (role === "조건") {
if (lb.inputs.some((x) => x.이름 === col))
next = setCond(next, id, col, { k: "input", name: col });
return;
}
const hit = hits[lk.id];
const cell = rows?.[typeof hit === "number" ? hit : 0]?.[col];
const value = Array.isArray(cell) ? (cell[0] ?? cell[1]) : cell;
if (typeof value === "number" || typeof value === "string")
next = setCond(next, id, col, { k: "fixed", value });
});
if (next === lb) note.textContent = te("Role_NoInput", { v: col });
else edit(next);
};
/** 로직이 보는 원문 표 — 처음 보는 표만 받아 옴 */
const loadTables = async (): Promise<void> => {
const keys = [...new Set(p.lb?.lookups.map((lk) => lk.table) ?? [])];
const want = keys.filter((k) => !(k in p.tables));
if (!want.length) return;
const got = await Promise.all(
want.map((k) =>
fetchLbTable(k).then(
(t) => [k, t.table] as const,
() => [k, undefined] as const,
),
),
);
p.tables = { ...p.tables, ...Object.fromEntries(got) };
drawSource();
drawBasis();
};
const doUndo = (): void => {
const back = stack[stack.length - 1];
if (!back) return;
stack = stack.slice(0, -1);
({ lb: p.lb, tables: p.tables, changed: p.changed } = back);
name.input.value = p.lb?.doc.name ?? p.row.이름;
drawExamplesNow();
calc();
};
/** 저장 뒤 · 복사 편집 뒤 — 서버가 준 줄로 다시 엶 · 실행 취소 기록은 새로 */
const reopen = (row: LogicRow, key: string, version: string): void => {
const lb = blocksOf(row, key, version, false);
showPaper({ key, version, isBase: false, row, lb, trial: p.trial });
};
const doSave = async (): Promise<void> => {
if (readOnly || busy || !p.lb) return;
const row = writeLogic(p.lb);
if (!row.이름.trim()) return void (note.textContent = te("NoName"));
busy = true;
note.textContent = te("Saving");
try {
const saved =
p.key && p.version ? await editEasy(p.key, p.version, row) : await newEasy(row);
logics = null;
showToast(te("Saved"), "success");
reopen(saved.row, saved.key, saved.version);
} catch (error) {
note.textContent = `${te("SaveFailed")} — ${plainError(error)}`;
} finally {
busy = false;
}
};
const doCopy = async (): Promise<void> => {
if (!p.key || busy) return;
busy = true;
note.textContent = te("Copying");
try {
const saved = await copyEasy(p.key);
logics = null;
showToast(te("Copied"), "success");
reopen(saved.row, saved.key, saved.version);
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
}
};
const doDelete = async (): Promise<void> => {
if (!p.key || !p.version || p.isBase || busy) return;
busy = true;
note.textContent = te("Uses_Wait");
try {
const uses = (await usesOf(p.key)).filter((u) => u.key);
if (uses.length) {
note.textContent = te("Uses_Block", { v: uses.map((u) => u.name).join(", ") });
return;
}
note.textContent = "";
const ask = te("Delete_Ask", { v: rowNow().이름 });
if (!(await showConfirmDialog(ask, te("Delete")))) return;
await deleteEasy(p.key, p.version);
logics = null;
paper = null;
showToast(te("Deleted"), "success");
void showStart();
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
}
};
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
const close = createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) void showStart();
})(),
});
const fieldActions = [
undo,
createButton({ label: te("Save"), onClick: () => void doSave() }),
...(p.key
? [
createButton({
label: te("Delete"),
variant: "danger",
onClick: () => void doDelete(),
}),
]
: []),
close,
];
const actions = el("div", {
className: "m01-lb__actions",
children: p.isBase
? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close]
: fieldActions,
});
const viewButtons = (["sentence", "grid"] as View[]).map((which) => {
const b = createButton({
label: te(which === "sentence" ? "View_Sentence" : "View_Grid"),
variant: view === which ? "filled" : "ghost",
size: "sm",
onClick: () => {
view = which;
for (const x of viewButtons) {
const on = x.dataset.view === view;
x.classList.toggle("ui-btn--filled", on);
x.classList.toggle("ui-btn--ghost", !on);
x.setAttribute("aria-pressed", String(on));
}
drawBasis();
},
});
b.dataset.view = which;
b.setAttribute("aria-pressed", String(view === which));
return b;
});
body.replaceChildren(
el("div", {
className: "m01-lb__strip",
children: [
name.root,
createTag(p.isBase ? te("Standard") : te("Field"), p.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-lb__applied",
text: `${te("Applied")} · ${p.row.출처 || "—"}`,
}),
unsaved,
actions,
],
}),
note,
...(p.isBase ? [muted(te("StandardNote"))] : []),
el("div", {
className: "m01-lb__cols",
children: [
column(te("Col_Source"), [sourceHost]),
column(te("Col_Basis"), [blockedNote, basisHost], viewButtons),
column(te("Col_Result"), [
total,
examplesHost,
el("h5", { text: te("Sheet") }),
sheetHost,
checksHost,
]),
],
}),
);
drawExamplesNow();
if (p.lb) {
renderChecks(checksHost, {
lb: () => p.lb as LbLogic,
tables: () => p.tables,
trial: () => p.trial,
ans: () => ans,
draftKey: p.key ?? "새",
});
}
void loadTables();
calc();
};
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
openModal({
title: te("Title"),
closeLabel: te("CloseWindow"),
dialogClass: "m01-lb",
beforeClose: mayLeave,
mount: (host) => {
body = host;
(host.parentElement ?? host).addEventListener("keydown", onKey);
void showStart();
},
});
}
/** 단계 안내 · 새 입력값의 예시값 — 서버 자동값과 같은 규칙(선택 = 첫째 · 범위 = 낮은 끝 · 숫자 0 · 가격 값은 비움) */
function autoTrial(inputs: LogicInput[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const spec of inputs) {
if (String(spec.단위 ?? "").includes("원")) continue;
out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0);
}
return out;
}
const muted = (text: string): HTMLElement => el("p", { className: "m01-lb__muted", text });
/** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */
function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement {
return el("section", {
className: "m01-lb__col",
children: [
el("div", {
className: "m01-lb__col-head",
children: [el("h4", { text: title }), ...tools],
}),
el("div", { className: "m01-lb__col-body", children }),
],
});
}
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m01-lb__big");
return button;
}
@@ -1,273 +0,0 @@
/* =============================================================================
* M01_LB_Entry_Result.ts
* 로직 만들기 판 오른쪽 결과 칸의 도구 — 예시값 띠 · 빈틈 검사 · 기대값 시험표(Entry 가 부름)
* 빈틈 · 시험표 셈은 Check(sub8) 것 · 시험표 줄은 화면 초안(sessionStorage)만 · 저장 안 함(정함 7)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api";
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
import type { LbTables } from "./M01_LB_Api";
import type { LbLogic } from "./M01_LB_Blocks";
import {
gapSkipped,
gaps,
loadExpectRows,
pickValues,
runExpect,
saveExpectRows,
type ExpectRow,
} from "./M01_LB_Check";
const TEXT = {
Examples: ["예시값", "Example values"],
Examples_Note: [
"예시값은 계산을 보려는 값 — 저장 안 됨",
"Example values are only for trying — not saved",
],
Pick: ["선택", "Choose"],
Gaps: ["빈틈 검사", "Check for gaps"],
Gaps_None: [
"빈틈 없음 — 모든 선택 조합에 맞는 줄이 하나씩 있음",
"No gaps — every combination finds one row",
],
Gaps_No: ["해당 없음", "No match"],
Gaps_Dup: ["중복", "Duplicate"],
Gaps_Rows: ["줄 {v}", "rows {v}"],
Gaps_Skip: [
"못 따진 표 조회 {n} 개 — 입력값이 숫자라 조합을 셀 수 없음",
"{n} lookups not checked — numeric inputs",
],
Gaps_More: ["그 밖 {n} 건", "{n} more"],
Expect: ["기대값 시험표", "Expected-value table"],
Expect_Add: ["지금 예시값으로 한 줄 더하기", "Add a row from the current examples"],
Expect_Run: ["모두 돌리기", "Run all"],
Expect_Total: ["합계", "Total"],
Expect_Ok: ["맞음", "Match"],
Expect_Bad: ["다름 — 나온 값 {v}", "Differs — got {v}"],
Expect_Del: ["삭제", "Delete"],
Expect_None: [
"줄 없음 — 예시값을 정한 뒤 한 줄 더하기",
"No rows — set examples, then add a row",
],
Expect_Note: [
"시험표는 이 화면에만 남음 — 저장 안 됨",
"The table stays on this screen only — not saved",
],
} as const;
type TextKey = keyof typeof TEXT;
const tr = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
/** 볼 값 이름 — 합계는 「계」 · 값 결과는 「결과」 */
const totalKey = (ans: CalcAnswer | null): "계" | "결과" =>
ans?.ok && ans.result !== undefined ? "결과" : "계";
/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */
export function drawExamples(
host: HTMLElement,
inputs: LogicInput[],
trial: Record<string, unknown>,
onChange: (trial: Record<string, unknown>) => void,
): void {
host.hidden = !inputs.length;
if (!inputs.length) return void host.replaceChildren();
const state = { ...trial };
const field = (spec: LogicInput): HTMLElement => {
const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름;
let box: HTMLInputElement | HTMLSelectElement;
if (spec.고르기?.length) {
box = el("select", {
className: "ui-select ui-select--sm",
children: [
el("option", { attrs: { value: "" }, text: tr("Pick") }),
...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })),
],
});
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
box.addEventListener("change", () => {
const picked = spec.고르기?.find((v) => String(v) === box.value);
if (picked === undefined) delete state[spec.이름];
else state[spec.이름] = picked;
onChange({ ...state });
});
} else {
box = el("input", {
className: "ui-input ui-input--sm",
attrs: {
type: "number",
...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}),
},
});
box.value = state[spec.이름] == null ? "" : String(state[spec.이름]);
box.addEventListener("change", () => {
if (box.value === "") delete state[spec.이름];
else state[spec.이름] = Number(box.value);
onChange({ ...state });
});
}
box.setAttribute("aria-label", spec.이름);
return el("label", {
className: "m01-lb__example",
children: [el("span", { text: label }), box],
});
};
host.replaceChildren(
el("strong", { text: tr("Examples") }),
el("div", { className: "m01-lb__example-list", children: inputs.map(field) }),
el("p", { className: "m01-lb__muted", text: tr("Examples_Note") }),
);
}
const condText = (conds: Record<string, unknown>): string =>
Object.entries(conds)
.map(([k, v]) => `${k} ${Array.isArray(v) ? v.join("~") : String(v)}`)
.join(" · ");
/** 빈틈 검사 · 기대값 시험표 — 화면 초안 열쇠 = 로직 번호(새 로직은 「새」) */
export function renderChecks(
host: HTMLElement,
s: {
lb(): LbLogic;
tables(): LbTables;
trial(): Record<string, unknown>;
ans(): CalcAnswer | null;
draftKey: string;
},
): void {
const gapHost = el("div", { className: "m01-lb__check-out" });
const rowsHost = el("div", { className: "m01-lb__check-out" });
let rows: ExpectRow[] = loadExpectRows(s.draftKey);
let results: (boolean | number | null)[] = [];
const keep = (): void => saveExpectRows(s.draftKey, rows);
const runGaps = (): void => {
const found = gaps(s.lb(), s.tables());
const skipped = gapSkipped(s.lb(), s.tables()).length;
const lines = found.slice(0, 20).map((g) =>
el("li", {
text: [
g.kind === "없음" ? tr("Gaps_No") : tr("Gaps_Dup"),
condText(g.conds),
g.kind === "중복" ? tr("Gaps_Rows", { v: g.rows.map((r) => r + 1).join(", ") }) : "",
]
.filter(Boolean)
.join(" — "),
}),
);
gapHost.replaceChildren(
found.length
? el("ul", { children: lines })
: el("p", { className: "m01-lb__muted", text: tr("Gaps_None") }),
...(found.length > 20 ? [el("p", { text: tr("Gaps_More", { n: found.length - 20 }) })] : []),
...(skipped
? [el("p", { className: "m01-lb__muted", text: tr("Gaps_Skip", { n: skipped }) })]
: []),
);
};
const drawRows = (): void => {
if (!rows.length) {
rowsHost.replaceChildren(el("p", { className: "m01-lb__muted", text: tr("Expect_None") }));
return;
}
rowsHost.replaceChildren(
...rows.map((row, i) => {
const key = Object.keys(row.expect)[0] ?? "계";
const box = el("input", {
className: "ui-input ui-input--sm",
attrs: { type: "number", "aria-label": tr("Expect_Total") },
});
box.value = String(row.expect[key] ?? "");
box.addEventListener("change", () => {
row.expect = { [key]: Number(box.value) };
results[i] = null;
keep();
});
const r = results[i];
return el("div", {
className: "m01-lb__expect-row",
children: [
el("span", { text: condText(row.inputs) || "—" }),
box,
el("span", {
className: r === true ? "is-ok" : typeof r === "number" ? "is-bad" : "",
text:
r === true
? tr("Expect_Ok")
: typeof r === "number"
? tr("Expect_Bad", { v: formatNumber(r) })
: "",
}),
createButton({
label: tr("Expect_Del"),
variant: "ghost",
size: "sm",
onClick: () => {
rows = rows.filter((_, j) => j !== i);
results = results.filter((_, j) => j !== i);
keep();
drawRows();
},
}),
],
});
}),
);
};
const addRow = (): void => {
const ans = s.ans();
const key = totalKey(ans);
const now = ans?.ok ? pickValues(ans)[key] : undefined;
rows = [...rows, { inputs: { ...s.trial() }, expect: { [key]: now ?? 0 } }];
keep();
drawRows();
};
const runRows = async (): Promise<void> => {
const got = await runExpect(s.lb(), s.tables(), rows);
results = got.map((g, i) => {
const key = Object.keys(rows[i].expect)[0] ?? "계";
return g.ok ? true : (g.got[key] ?? null);
});
drawRows();
};
host.replaceChildren(
el("div", {
className: "m01-lb__check",
children: [
createButton({ label: tr("Gaps"), variant: "ghost", size: "sm", onClick: runGaps }),
gapHost,
el("strong", { text: tr("Expect") }),
el("div", {
className: "m01-lb__check-tools",
children: [
createButton({
label: tr("Expect_Add"),
variant: "ghost",
size: "sm",
onClick: addRow,
}),
createButton({
label: tr("Expect_Run"),
variant: "ghost",
size: "sm",
onClick: () => void runRows(),
}),
],
}),
rowsHost,
el("p", { className: "m01-lb__muted", text: tr("Expect_Note") }),
],
}),
);
drawRows();
}
-109
View File
@@ -1,109 +0,0 @@
.m01-lbgrid__scroll {
max-width: 100%;
max-height: 70vh;
overflow: auto;
}
.m01-lbgrid__table {
width: 100%;
min-width: 720px;
border-collapse: separate;
border-spacing: 0;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-lbgrid__table th,
.m01-lbgrid__table td {
padding: var(--spacing-4) var(--spacing-8);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.m01-lbgrid__table th:first-child,
.m01-lbgrid__table td:first-child {
border-left: 1px solid var(--color-border);
}
.m01-lbgrid__table thead th {
position: sticky;
top: 0;
z-index: 1;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: 600;
white-space: nowrap;
}
.m01-lbgrid__row {
cursor: pointer;
}
.m01-lbgrid__row--first td {
border-top: 2px solid var(--color-border);
}
.m01-lbgrid__row--sub .m01-lbgrid__name {
padding-left: var(--spacing-16);
color: var(--color-text-secondary);
}
.m01-lbgrid__step {
color: var(--color-text-secondary);
font-weight: 600;
white-space: nowrap;
}
.m01-lbgrid__table td.m01-lbgrid__num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m01-lbgrid__src {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */
.m01-lbgrid__row--on td {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
font-weight: 600;
}
.m01-lbgrid__row--pre td {
background: color-mix(in srgb, var(--color-warning) 28%, transparent);
}
.m01-lbgrid__row--pre td:first-child {
box-shadow: inset 4px 0 0 var(--color-warning);
}
.m01-lbgrid__input {
box-sizing: border-box;
width: 96px;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
text-align: right;
}
.m01-lbgrid__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m01-lbgrid__note,
.m01-lbgrid__hint {
margin: var(--spacing-4) 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
-540
View File
@@ -1,540 +0,0 @@
/* =============================================================================
* M01_LB_Grid.ts
* 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change
* 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값
* 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만
* 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model";
import {
setCalc,
setCond,
type LbCalc,
type LbExpr,
type LbLogic,
type LbRef,
} from "./M01_LB_Blocks";
import "./M01_LB_Grid.css";
const TEXT = {
Step1: ["① 입력값", "① Input"],
Step2: ["② 표 조회", "② Table lookup"],
Step3: ["③ 계산", "③ Calculation"],
Step4: ["④ 항목 연결", "④ Items"],
Step5: ["⑤ 결과값", "⑤ Result"],
HStep: ["단계", "Step"],
HName: ["이름", "Name"],
HHow: ["무엇으로", "From"],
HValue: ["값", "Value"],
HUnit: ["단위", "Unit"],
HSource: ["출처", "Source"],
Busy: ["계산 중", "Calculating"],
Stopped: ["계산하지 못했어요", "Could not calculate"],
ReadOnly: [
"표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요",
"Standard is read-only — use “Copy and edit” first",
],
Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"],
Typed: ["숫자 칸", "Number"],
Choice: ["고르기", "Choice"],
Range: ["범위", "Range"],
FromInput: ["입력값", "Input"],
FromCalc: ["계산", "Calc"],
FromLookup: ["표 조회", "Lookup"],
FromFixed: ["고정값", "Fixed"],
FromLogic: ["다른 일위대가 결과", "Other unit-price result"],
FromPeople: ["사람 품값 합", "People cost total"],
FromNone: ["무관", "Any"],
Own: ["직접 쓴 계산", "Written by hand"],
Cond: ["조건", "Condition"],
Table: ["표", "Table"],
Number: ["숫자", "Number"],
Calc: ["계산", "Calculation"],
Sum: ["항목 합계", "Item total"],
UseName: ["계산 이름", "Calc name"],
UseQty: ["항목 수량", "Item amount"],
UseRate: ["항목 비율", "Item rate"],
UseResult: ["결과값", "Result"],
Won: ["원", "KRW"],
Unnamed: ["수량 계산", "Amount calc"],
Price: ["단가", "Unit price"],
Result: ["결과", "Result"],
KValue: ["값", "Value"],
KMul: ["곱하기 · 나누기", "Multiply / divide"],
KRaise: ["할증", "Surcharge"],
KAdd: ["더하기", "Add"],
KCond: ["조건 목록", "Conditions"],
KMinMax: ["최소 · 최대", "Min / max"],
KRound: ["올림 · 버림", "Round"],
KSteps: ["여러 단계", "Several steps"],
Person: ["사람", "Person"],
Material: ["재료", "Material"],
Machine: ["기계", "Machine"],
Logic: ["다른 일위대가", "Other unit price"],
Percent: ["덧붙임", "Add-on %"],
Locked: ["그 밖 줄", "Other line"],
Base: ["바탕", "Base"],
Rate: ["율(%)", "Rate (%)"],
First: ["처음", "First"],
Times: ["곱하기", "times"],
Divide: ["나누기", "divided by"],
Plus: ["더하기", "plus"],
Minus: ["빼기", "minus"],
} as const;
type TextKey = keyof typeof TEXT;
const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex];
type Part = Exclude<LbCalc["at"]["part"], never>;
type Ref = LbRef | LbExpr;
type Cell = string | HTMLElement;
interface GridRow {
id: string;
step: string;
sub: boolean;
name: Cell;
how: Cell;
value: Cell;
unit: string;
source: string;
/** 이 줄이 쓰는 줄 번호 */
uses: string[];
}
const KIND_KEY: Record<LbCalc["kind"], TextKey> = {
값: "KValue",
곱나눔: "KMul",
할증: "KRaise",
더하기: "KAdd",
조건목록: "KCond",
최소최대: "KMinMax",
올림: "KRound",
단계: "KSteps",
};
const CARD_KEY: Record<EasyCard["kind"], TextKey> = {
person: "Person",
material: "Material",
machine: "Machine",
logic: "Logic",
percent: "Percent",
locked: "Locked",
};
const USE_KEY: Record<Part, TextKey> = {
중간: "UseName",
수량: "UseQty",
비율: "UseRate",
결과: "UseResult",
};
const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const show = (v: unknown): string =>
typeof v === "number"
? num(v)
: v === undefined || v === null || typeof v === "object"
? ""
: String(v);
/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */
const picked = new WeakMap<HTMLElement, string>();
/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */
function mentions(text: string, name: string): boolean {
if (!name) return false;
if (!/^\w+$/.test(name)) return text.includes(name);
return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text);
}
export interface GridOptions {
/** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */
trial?: Record<string, unknown>;
}
/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */
export function renderGrid(
host: HTMLElement,
lb: LbLogic,
ans: CalcAnswer | null,
on: { change(lb: LbLogic): void },
opts: GridOptions = {},
): void {
const readOnly = lb.doc.isBase;
const okAns = ans && ans.ok ? ans : null;
const middle = okAns?.middle ?? {};
const lines = okAns?.lines ?? [];
const msg = el("p", { className: "m01-lbgrid__msg" });
const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name);
const apply = (make: () => LbLogic): void => {
if (readOnly) return;
msg.textContent = "";
try {
on.change(make());
} catch {
msg.textContent = tg("Cant");
}
};
/* ---- 값 · 줄 번호 ---- */
const rowOf = (r: Ref): string[] => {
if (r.k === "input") return [`in:${r.name}`];
if (r.k === "lookup") return [`lk:${r.id}`];
if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : [];
if (r.k === "expr") return textRows(r.text);
return [];
};
const textRows = (text: string, self = -1): string[] => [
...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`),
...lb.calcs.flatMap((c, i) =>
i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [],
),
];
const lookupsAt = (part: Part | "요소", index: number): string[] =>
lb.lookups
.filter((l) => l.at.some((a) => a.part === part && a.index === index))
.map((l) => `lk:${l.id}`);
const lineOf = (card: EasyCard) =>
lines.find((l) => l.이름 === card.name && l.단위 === card.unit);
const lookupValue = (id: string): unknown => {
const solo = lb.calcs.find(
(c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id,
);
if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name];
const at = lb.lookups
.find((l) => l.id === id)
?.at.find((a) => a.part === "수량" && a.start === 0);
const card = lb.doc.cards.find(
(c) => c.source?.part === "호표" && c.source.index === at?.index,
);
const line = card && lineOf(card);
return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : "";
};
const valueOf = (r: Ref): unknown => {
if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name];
if (r.k === "fixed") return r.value;
if (r.k === "calc") return middle[r.name];
if (r.k === "lookup") return lookupValue(r.id);
if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name];
return "";
};
const unitOf = (r: Ref): string =>
r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : "";
/* ---- 칸 만들기 ---- */
const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => {
const box = el("input", {
className: "m01-lbgrid__input",
attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") },
});
box.value = show(value);
box.readOnly = readOnly;
box.addEventListener("click", (e) => e.stopPropagation());
box.addEventListener("change", () => {
const n = Number(box.value.trim());
if (box.value.trim() === "" || Number.isNaN(n)) {
msg.textContent = tg("Bad");
box.value = show(value);
return;
}
apply(() => make(n));
});
return box;
};
const refLabel = (r: Ref): string => {
if (r.k === "input") return `${tg("FromInput")} · ${r.name}`;
if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`;
if (r.k === "lookup") {
const l = lb.lookups.find((x) => x.id === r.id);
return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`;
}
if (r.k === "fixed") return tg("FromFixed");
if (r.k === "logic") return tg("FromLogic");
if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name;
return tg("Own");
};
const refKey = (r: Ref): string =>
r.k === "input"
? `input:${r.name}`
: r.k === "calc"
? `calc:${r.name}`
: r.k === "lookup"
? `lookup:${r.id}`
: r.k === "fixed"
? "fixed"
: r.k === "logic"
? `logic:${r.key}`
: r.k === "cost"
? `cost:${r.name}`
: "expr";
/** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */
const refSelect = (
cur: Ref,
pool: Ref[],
none: boolean,
pick: (r: Ref | null) => LbLogic,
): HTMLElement => {
const all = [...pool];
if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur);
const options = [
...(none ? [{ value: "none", text: tg("FromNone") }] : []),
...all.map((r) => ({ value: refKey(r), text: refLabel(r) })),
];
const field = createSelectField({
options,
value: refKey(cur),
compact: true,
ariaLabel: refLabel(cur),
disabled: readOnly || cur.k === "expr",
onChange: (v) => {
if (v === refKey(cur)) return;
apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur)));
},
});
field.root.addEventListener("click", (e) => e.stopPropagation());
return field.root;
};
const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 }));
const calcRefs = (before: number): Ref[] =>
lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : []));
const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id }));
/* ---- 줄 모으기 ---- */
const rows: GridRow[] = [];
const add = (r: Partial<GridRow> & { id: string; name: Cell }): GridRow => {
const row: GridRow = {
step: "",
sub: false,
how: "",
value: "",
unit: "",
source: "",
uses: [],
...r,
};
rows.push(row);
return row;
};
lb.inputs.forEach((inp, i) => {
add({
id: `in:${inp.이름}`,
step: i === 0 ? tg("Step1") : "",
name: inp.이름,
how: inp.고르기?.length ? tg("Choice") : tg("Typed"),
value: show(valueOf({ k: "input", name: inp.이름 })),
unit: inp.단위 ?? "",
source: inp.고르기?.length
? inp.고르기.join(" · ")
: inp.범위
? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}`
: "",
});
});
lb.lookups.forEach((lk, i) => {
const head = add({
id: `lk:${lk.id}`,
step: i === 0 ? tg("Step2") : "",
name: lk.col,
how: tg("FromLookup"),
value: show(lookupValue(lk.id)),
source: `${tg("Table")} ${lk.table}`,
});
lk.conds.forEach((cd) => {
const from = cd.from;
const fixedNum = from.k === "fixed" && typeof from.value === "number";
const subRow = add({
id: `lc:${lk.id}:${cd.col}`,
sub: true,
name: `${tg("Cond")} · ${cd.col}`,
how: refSelect(
from,
[
...inputRefs,
...calcRefs(lb.calcs.length).filter((r) => {
const c = lb.calcs.find((x) => x.name === (r as { name: string }).name);
return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기(");
}),
],
true,
(r) => setCond(lb, lk.id, cd.col, r),
),
value:
fixedNum && from.k === "fixed"
? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n }))
: show(valueOf(from)),
unit: unitOf(from),
uses: rowOf(from),
});
head.uses.push(...subRow.uses);
});
});
lb.calcs.forEach((c, i) => {
const first = i === 0;
const own = lookupsAt(c.at.part, c.at.index);
const head = add({
id: `ca:${i}`,
step: first ? tg("Step3") : "",
name: c.name ?? `${tg("Unnamed")} ${i + 1}`,
how: tg(KIND_KEY[c.kind]),
value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""),
source: tg(USE_KEY[c.at.part]),
uses: [...textRows(c.text, i), ...own],
});
if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return;
c.parts.forEach((p, pi) => {
const label =
c.kind === "할증"
? tg(pi === 0 ? "Base" : "Rate")
: c.kind === "값"
? tg("KValue")
: pi === 0
? tg("First")
: tg(
({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[
c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*")
],
);
const fixedNum = p.k === "fixed" && typeof p.value === "number";
const setPart = (r: Ref | null): LbLogic => {
const parts = c.parts.slice();
if (r) parts[pi] = r;
return setCalc(lb, c, { parts });
};
const sub = add({
id: `cp:${i}:${pi}`,
sub: true,
name: label,
how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart),
value:
fixedNum && p.k === "fixed"
? numberCell(p.value, (n) => setPart({ k: "fixed", value: n }))
: show(valueOf(p)),
unit: unitOf(p),
uses: rowOf(p),
});
head.uses.push(...sub.uses);
});
});
lb.doc.cards.forEach((card, i) => {
const sp = card.source;
const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"];
const viaLookup = sp
? parts
.flatMap((p) => lookupsAt(p, sp.index))
.concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : [])
: [];
const viaCalc = sp
? lb.calcs.flatMap((c, ci) =>
parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [],
)
: [];
const line = lineOf(card);
const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc");
add({
id: `it:${card.id}`,
step: i === 0 ? tg("Step4") : "",
name: card.spec ? `${card.name} ${card.spec}` : card.name,
how: tg(CARD_KEY[card.kind]),
value:
card.qty !== null
? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) }))
: show(line?.수량),
unit: card.unit,
source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`,
uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])],
});
});
const res = lb.result;
add({
id: "res",
step: tg("Step5"),
name: tg(res.kind === "금액" ? "Price" : "Result"),
how: res.kind,
value:
res.kind === "금액"
? okAns
? formatMoney(okAns.sums?.["계"] ?? 0)
: ""
: show(okAns?.result),
unit: res.unit,
source: res.kind === "금액" ? tg("Sum") : tg("Calc"),
uses:
res.kind === "금액"
? lb.doc.cards.map((c) => `it:${c.id}`)
: [
...textRows(res.calc.text),
...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])),
],
});
/* ---- 그리기 ---- */
const tbody = el("tbody");
const paint = (): void => {
const sel = picked.get(host);
const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []);
tbody.querySelectorAll<HTMLElement>("tr").forEach((tr) => {
const id = tr.dataset.row ?? "";
tr.classList.toggle("m01-lbgrid__row--on", id === sel);
tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id));
});
};
const td = (c: Cell, className = ""): HTMLElement =>
el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) });
rows.forEach((r) => {
const tr = el("tr", {
className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`,
attrs: { "data-row": r.id },
children: [
td(r.step, "m01-lbgrid__step"),
td(r.name, "m01-lbgrid__name"),
td(r.how),
td(r.value, "m01-lbgrid__num"),
td(r.unit),
td(r.source, "m01-lbgrid__src"),
],
});
tr.addEventListener("click", () => {
if (picked.get(host) === r.id) picked.delete(host);
else picked.set(host, r.id);
paint();
});
tbody.append(tr);
});
const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"];
const status =
ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy");
host.replaceChildren(
...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []),
...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []),
el("div", {
className: "m01-lbgrid__scroll",
children: [
el("table", {
className: "m01-lbgrid__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })],
}),
tbody,
],
}),
],
}),
msg,
el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }),
);
paint();
}
@@ -1,196 +0,0 @@
/* 로직 만들기 판 — 왼쪽 원문 */
.m01-lbsrc {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
font-size: var(--text-body-sm);
}
.m01-lbsrc p {
margin: 0;
}
.m01-lbsrc__muted {
color: var(--color-text-muted);
}
.m01-lbsrc__muted:empty {
display: none;
}
.m01-lbsrc__preview {
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas));
color: var(--color-text);
}
.m01-lbsrc__table {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
min-width: 0;
}
.m01-lbsrc__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
}
.m01-lbsrc__users {
margin-left: auto;
padding: 0;
border: 0;
background: none;
color: var(--color-accent);
font: inherit;
cursor: pointer;
text-decoration: underline;
}
.m01-lbsrc__warn {
color: var(--color-danger);
font-weight: 600;
}
/* 엑셀 같은 격자 */
.m01-lbsrc__grid {
max-height: 22rem;
overflow: auto;
border: 1px solid var(--color-border);
}
.m01-lbsrc__grid table {
border-collapse: collapse;
width: 100%;
}
.m01-lbsrc__grid th,
.m01-lbsrc__grid td {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
white-space: nowrap;
text-align: left;
}
.m01-lbsrc__grid thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-paper);
color: var(--color-text-secondary);
font-weight: 500;
vertical-align: top;
}
.m01-lbsrc__grid thead th span {
display: block;
}
.m01-lbsrc__grid .m01-lbsrc__no {
width: 2.5rem;
background: var(--color-paper);
color: var(--color-text-muted);
text-align: right;
}
.m01-lbsrc__grid td {
cursor: cell;
}
.m01-lbsrc__grid td.is-value {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m01-lbsrc__grid td.is-col {
font-weight: 700;
}
.m01-lbsrc__grid tr.is-hit td {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-lbsrc__grid td.is-edited {
box-shadow: inset 0 0 0 2px var(--color-accent);
}
.m01-lbsrc__grid td.is-bad {
box-shadow: inset 0 0 0 2px var(--color-danger);
}
.m01-lbsrc__input {
width: 100%;
min-width: 4rem;
padding: 0 var(--spacing-4);
border: 1px solid var(--color-accent);
font: inherit;
background: var(--color-canvas);
color: var(--color-text);
}
/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */
.m01-lbsrc__chip {
margin-top: var(--spacing-4);
padding: 0 var(--spacing-8);
border: 1px solid currentColor;
border-radius: 999px;
background: transparent;
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
}
.m01-lbsrc__chip.is-cond {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.m01-lbsrc__chip.is-fixed {
color: var(--color-text-secondary);
}
.m01-lbsrc__chip.is-result {
color: var(--color-success, #2f6b4f);
background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent);
}
.m01-lbsrc__chip.is-free {
color: var(--color-text-muted);
border-style: dashed;
}
.m01-lbsrc__notes summary {
cursor: pointer;
color: var(--color-text-secondary);
}
.m01-lbsrc__notes ol {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-lbsrc__notes li.is-used {
font-weight: 600;
}
.m01-lbsrc__tag {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-4);
border-radius: 4px;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
font-size: var(--text-caption);
font-weight: 500;
}
@@ -1,371 +0,0 @@
/* =============================================================================
* M01_LB_Source.ts
* 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절)
* 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과)
* · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처.
* 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { tableUsers, type LbTables } from "./M01_LB_Api";
import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks";
import type { LbHit } from "./M01_LB_Check";
import "./M01_LB_Source.css";
const TEXT = {
Preview: [
"표 고침은 미리 보기 — 저장은 결정 뒤",
"Table edits are a preview — saving comes later",
],
Users: ["사용처", "Used by"],
UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"],
UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"],
UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"],
Loading: ["불러오는 중", "Loading"],
Missing: ["표를 불러오지 못함", "Couldn't load the table"],
NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"],
DupRow: [
"중복 — 예시값에 맞는 줄이 둘 이상",
"Duplicate — more than one row fits the sample values",
],
Notes: ["주", "Notes"],
NoteUsed: ["계산에 쓰임", "Used in the calculation"],
Cond: ["조건", "Condition"],
Fixed: ["고정", "Fixed"],
Result: ["결과", "Result"],
Free: ["무관", "Any"],
RoleHint: ["눌러 역할 바꿈", "Tap to change the role"],
EditHint: [
"칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소",
"Tap a cell to edit · Enter to finish · Esc to cancel",
],
Edited: ["고친 칸", "Edited cell"],
} as const;
const ts = (key: keyof typeof TEXT, n?: number): string =>
TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? ""));
export type LbRole = "조건" | "고정" | "결과";
type Cell = string | number | boolean | null | [number | null, number | null] | undefined;
interface SourceTable {
키?: string;
이름?: string;
원문번호?: string;
기준?: string;
범위규칙?: string;
조건?: Record<string, string>;
값칸?: Record<string, string>;
주?: string[];
용도?: { 로직키?: string[] };
줄: Record<string, Cell>[];
}
export interface SourceOn {
edit(e: TableEdit): void;
role(table: string, col: string, role: LbRole): void;
}
/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */
const isRange = (v: unknown): v is [number | null, number | null] =>
Array.isArray(v) && v.length === 2;
/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */
export function showCell(v: Cell): string {
if (v === null || v === undefined) return "";
if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
return String(v);
}
/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */
export function parseCell(text: string, before: Cell): TableEdit["value"] | null {
const t = text.trim();
const num = (s: string): number | null | undefined => {
const c = s.replace(/,/g, "").trim();
if (!c) return null;
return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined;
};
if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) {
const parts = t.split(/[~~]/);
if (parts.length !== 2) return null;
const [a, b] = parts.map(num);
if (a === undefined || b === undefined || (a === null && b === null)) return null;
if (a !== null && b !== null && a > b) return null;
return [a, b];
}
if (!t) return null;
const n = num(t);
if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) {
return typeof n === "number" ? n : null;
}
return t;
}
/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */
/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */
export function roleOf(lookups: LbLookup[], col: string): LbRole | null {
for (const lk of lookups) {
if (lk.col === col) return "결과";
const c = lk.conds.find((x) => x.col === col);
if (c) return c.from.k === "fixed" ? "고정" : "조건";
}
return null;
}
const NEXT: Record<LbRole, LbRole> = { 조건: "고정", 고정: "결과", 결과: "조건" };
/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */
export function usedNotes(row: unknown): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
const add = (where: string, mark: string): void => {
const set = out.get(where) ?? new Set<string>();
set.add(mark);
out.set(where, set);
};
const walk = (v: unknown): void => {
if (typeof v === "string") {
for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]);
} else if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === "object") Object.values(v).forEach(walk);
};
walk(row);
return out;
}
/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */
function hitsOf(
key: string,
lookups: LbLookup[],
hits: Record<string, LbHit | undefined>,
): { rows: Set<number>; none: boolean; dup: boolean } {
const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null);
return {
rows: new Set(got.filter((h): h is number => typeof h === "number")),
none: got.includes("없음"),
dup: got.includes("중복"),
};
}
/* ── 그리기 ─────────────────────────────────────────────────────── */
/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */
const originals = new Map<string, string[][]>();
function snapshot(key: string, table: SourceTable, cols: string[]): string[][] {
const had = originals.get(key);
if (had && had.length === table.줄.length) return had;
const shot = table.줄.map((r) => cols.map((c) => showCell(r[c])));
originals.set(key, shot);
return shot;
}
function roleChip(
key: string,
col: string,
role: LbRole | null,
isCond: boolean,
on: SourceOn,
): HTMLElement {
const shown = role ?? (isCond ? null : undefined);
if (shown === undefined) return el("span");
const label = shown
? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result")
: ts("Free");
const chip = el("button", {
className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`,
text: label,
attrs: { type: "button", title: ts("RoleHint") },
});
chip.addEventListener("click", (ev) => {
ev.stopPropagation();
on.role(key, col, shown ? NEXT[shown] : "조건");
});
return chip;
}
function editCell(
td: HTMLTableCellElement,
before: Cell,
done: (v: TableEdit["value"]) => void,
): void {
if (td.querySelector("input")) return;
const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } });
input.value = showCell(before);
const text = td.textContent ?? "";
td.replaceChildren(input);
input.focus();
input.select();
let closed = false;
const close = (save: boolean): void => {
if (closed) return;
closed = true;
const value = save ? parseCell(input.value, before) : null;
const same = value !== null && showCell(value) === showCell(before);
td.textContent = value === null || same ? text : showCell(value);
td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text);
if (value !== null && !same) done(value);
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") close(true);
if (ev.key === "Escape") close(false);
ev.stopPropagation();
});
input.addEventListener("blur", () => close(true));
}
function renderTable(
key: string,
table: SourceTable | undefined,
lb: LbLogic,
hits: Record<string, LbHit | undefined>,
notes: Map<string, Set<string>>,
on: SourceOn,
): HTMLElement {
const lookups = lb.lookups.filter((lk) => lk.table === key);
if (!table?.줄) {
return el("section", {
className: "m01-lbsrc__table",
children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })],
});
}
const conds = Object.keys(table.조건 ?? {});
const vals = Object.keys(table.값칸 ?? {});
const cols = [...conds, ...vals];
const orig = snapshot(key, table, cols);
const hit = hitsOf(key, lookups, hits);
const users = (table.용도?.로직키 ?? []).length;
const userBtn = el("button", {
className: "m01-lbsrc__users",
text: users ? ts("UsersN", users) : ts("UsersNone"),
attrs: { type: "button" },
});
const userList = el("p", { className: "m01-lbsrc__muted" });
userBtn.addEventListener("click", () => {
userList.textContent = ts("Loading");
tableUsers(key)
.then((list) => {
userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone");
userList.textContent = list.map((u) => u.이름).join(" · ");
})
.catch(() => (userList.textContent = ts("UsersFail")));
});
const head = el("tr", {
children: [
el("th", { className: "m01-lbsrc__no", text: "" }),
...cols.map((c) => {
const unit = table.값칸?.[c];
return el("th", {
className: vals.includes(c) ? "is-value" : "",
children: [
el("span", { text: unit ? `${c} (${unit})` : c }),
roleChip(key, c, roleOf(lookups, c), conds.includes(c), on),
],
});
}),
],
});
const body = table.줄.map((row, i) => {
const tr = el("tr", {
className: hit.rows.has(i) ? "is-hit" : "",
children: [
el("td", { className: "m01-lbsrc__no", text: String(i + 1) }),
...cols.map((c, j) => {
const td = el("td", {
className: [
vals.includes(c) ? "is-value" : "",
lookups.some((lk) => lk.col === c) ? "is-col" : "",
showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "",
].join(" "),
text: showCell(row[c]),
});
td.addEventListener("click", () =>
editCell(td, row[c], (value) => {
td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]);
on.edit({ table: key, row: i, col: c, value });
}),
);
return td;
}),
],
});
return tr;
});
const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]);
const noteList = (table.주 ?? []).map((n) => {
const used = marks.has(n.trim().charAt(0));
return el("li", {
className: used ? "is-used" : "",
children: [
el("span", { text: n }),
...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []),
],
});
});
return el("section", {
className: "m01-lbsrc__table",
children: [
el("div", {
className: "m01-lbsrc__head",
children: [
el("strong", { text: table.이름 ?? "" }),
el("span", {
className: "m01-lbsrc__muted",
text: [table.원문번호, table.기준].filter(Boolean).join(" · "),
}),
userBtn,
],
}),
userList,
...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []),
...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []),
el("div", {
className: "m01-lbsrc__grid",
children: [
el("table", {
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
}),
],
}),
...(noteList.length
? [
el("details", {
className: "m01-lbsrc__notes",
attrs: marks.size ? { open: "" } : {},
children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })],
}),
]
: []),
],
});
}
/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */
export function renderSource(
host: HTMLElement,
lb: LbLogic,
tables: LbTables,
hits: Record<string, LbHit | undefined>,
on: SourceOn,
): void {
const keys = [...new Set(lb.lookups.map((lk) => lk.table))];
const notes = usedNotes(lb.doc?.row);
host.replaceChildren(
el("div", {
className: "m01-lbsrc",
children: [
el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }),
el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }),
...keys.map((k) =>
renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on),
),
],
}),
);
}

Some files were not shown because too many files have changed in this diff Show More