From 22e53ec4a7a1dead6523f0938d633a3ffd5f1ca1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:19:08 +0900 Subject: [PATCH 01/24] auto: 2026-10-04 17:19 (ESD_LAPTOP) --- .../raw/M02_구조물조합개편/사용자요구_1004.md | 50 ++++++++ docs/raw/M02_구조물조합개편/설계_정함.md | 105 +++++++++++++++++ ...가산출_흐름도_조합다이어그램_마스터요소.md | 108 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004.md create mode 100644 docs/raw/M02_구조물조합개편/설계_정함.md create mode 100644 docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004.md b/docs/raw/M02_구조물조합개편/사용자요구_1004.md new file mode 100644 index 000000000..936caa071 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004.md @@ -0,0 +1,50 @@ +# M02 구조물 조합 개편 — 사용자 요구 (2026-10-04 · 원문 그대로) + +1번(계획서 32장 이후 정리)은 브레인이 처리함(c8269a969). 7 · 8번은 원문에 없음. + +## 2. 일위대가 조합 → 구조물 조합 하위 + +M02페이지의 마스터 템플릿 페이지에서 일위대가 조합은 구조물 조합의 하위 컨테이너로 넣을 것.(구조물 도면과 면적산출 사이에 배치) +- 일위대가 조합의 좌측 패널의 기존 보기 방식인 트리구조는 필요없으며 구조물 도면 처럼 리스트형태로 보임 +- 추가로 구조물 도면, 일위대가, 면적산출의 최상위에는 전체항목을 넣어주고 우측 상세에는 리스트로 보여줌, M01 항목을 보면 양식화되어 있는 페이지 헤이아웃 있음 + +## 3. 이름 + +이동한 일위대가 조합은 일위대가로 명칭 변경 + +## 4. 구분선 + +구조물 조합 컨테이너 내부의 서브 컨테이너 항목과 트리구조 사이에 수평 구분선 추가 + +## 5. 사용자별 구조물 조합 읽기 / 쓰기 / 복사 권한 점검 및 개선 + +- 시스템 관리자 : 아이슬로 프로그램 관리자로 마스터를 제공하여 회사관리자나 일반 사용자가 사용하기 쉽게 한다. +- 시스템관리자가 대시보드의 컨테이너 제목의 우측의 회사명을 선택함에 따라 변경한 목적은 해당 회사의 정보를 접근하고나 AS을 목적으로 고객을 도와주기 위함임. 복사항목에는 마스터나 시스템 관리자 회사, 개인으로 들어갈 항목 뿐만 아니라 고객 회사의 특정 폴더로 복사할 수 있는 기능이 있어야함. 수정/생성권한 포함 +- 회사관리자 : 고객으로 본인회사의 양식을 만들거나 개인 프로젝트에 사용할 개인 마스터데이터 관리 +- 회사관리자는 개인버튼을 누를경우 최상위 트리에는 다른 개인들의 정보를 보고 복사하여 개인에게 복사하거나 수정, 생성도 가능해야함. +- 개인은 마스터와 회사는 읽기만, 다른 개인의 정보는 볼수 없음. 트리는 현재를 유지해야함. + +## 6. 구조물 조합 항목 변경 + +- 최상위 구조물 조합에 구조물 도면, 일위대가, 면적산출로만 표현(기존의 재료산출의 사용자가 추가하는 재료들과 값은 엑셀을 사용하지 않고 일위대가의 담은로직 처럼 하위에 별도의 컨테이너로 추가되는 자재항목을 넣어야함. 이때 M01자재목록에서 하나씩 가져오되 수량은 면적산출의 알약으로 값을 싱크하거나 별도의 모달창에서 셀을 지정하게 구현) +- 재료산출 항목은 삭제 +- 선택입력 항목은 삭제(구조물 조합에서는 각 옵션의 상세 구조물에 따라 미리 옵션을 다 지정할 생각임으로 그럴필요 없음) +- 면적산출의 미리보기에서 아래 알약 그룹 행을 하이드/쇼 되게 반영(기본값은 하이드), 단, 면적산출 상세에서는 알약 행 보이기가 기본값으로 +- 면적산출 상세 화면의 우측 패널에서 현재의 일위대가 조합 목록을 검색하는 기능이 아니라 현재 구조물도의 일위대가 담은 로직과 추가될 사용자 지정 재료를 보여줄것, +- 일위대가 상세화면에서는 미리보기(보기만/저장안함) 컨테이너는 각 단가산출 로직에 입력이 필요한 정보들임. 구조물별로 하나씩만들어져야함으로 구조물의 일위대가 항목에 옵션값이 저장되야함. 그리고 이값에 대한 지정은 담은 로직 컨테이너의 항목을 누르면 모달창이 나오고 사용자가 입력 및 변경할수 있게함. (하위에 지정된 항목과 값을 작은 글씨로 표현되게 반영) +- 담은 로직에는 기준 단가를 기준으로 넣었을때 나온 값과 수량산출값을 넣었을때 값에 대한 노무비, 재료비, 경비, 합계를 같이 보여줌. 이때 수량산출은 면적산출의 특정 셀값이나 사용자가 수기로 입력한 값이 될수 있음. 이렇게 되면 미리보기 계산 컨테이너 필요 없음. +- 일위대가의 상세 화면의 순서는 담은로직 > 자재목록 > 다이어그램 순서임. +- 면적산출 산출 상세의 우측 패널에 보여져야하는 결과는 결국 일위대가의 담은 로직컨테이너와 자재목록임. +- 추가된 자재목록에도 기준에 대한 단가와, 면적산출 엑셀의 특정 셀과 연결되었을때 나온 단가가 같이 나와야함. + +## 9. 오른쪽 클릭 + +프로그램 B와 M영역의 페이지들에 마우스 오른쪽 클릭을 할 경우 브라우저 옵션 안보이게 할것.(우리 프로그램의 옵션은 허용, 공통양식 사용) + +## 사용자 답 (10-4 · 일위대가 · 원문 그대로) + +일위대가의 경우 +1. 구조물 조합을 복사한 경우 신규 키를 생성하여 만듬. (값과 로직은 일단 같이 복사) +2. 신규 구조물 조합을 생성하는 경우 미리보기 페이지에는 새로만들기와 복사생성하기를 선택하게 하고 복사 생성인 경우에는 해당 리스트를 보여주고 선택하게 보여주는 값은 개인인 경우에는 개인을, 회사관리자인경우 개인, 시스템관리자인 경우 마스터를 기본값으로 보여주고 탭을 눌러 다른 항목에서 가져올수 있는 항목들을 보여준다. +3. 다시 말해 최종적으로는 구조물 조합에 일위대가, 도면, 면적산출이 연되는게 맞으나 특정항목만 다른 값을 복사하여 가져올수 있어야함. 저장이 된 데이터라도 변경가능해야함. +4. 현재 매칭이 안된 데이터는 나중에 삭제할예정이나 마스터 데이터를 수동 생성을 위해서 필요한 데이터들임. 차라리 실무문서라는 항목을 마스터에 별도로 만들어서 나중에 한번에 지우게 할수 있게 반영. diff --git a/docs/raw/M02_구조물조합개편/설계_정함.md b/docs/raw/M02_구조물조합개편/설계_정함.md new file mode 100644 index 000000000..229b350fd --- /dev/null +++ b/docs/raw/M02_구조물조합개편/설계_정함.md @@ -0,0 +1,105 @@ +# M02 구조물 조합 개편 — 정함 (브레인 · 10-4) + +근거 = `사용자요구_1004.md` · 조사 보고 A~H(각 창 `tmp/조사_39-0X.md`). 사용자가 바꿀 수 있음. + +## 1. 자료 틀 (B · sub7) + +- 일위대가 = 구조물 열마다 `labor/<열id>.json` · 번호 `SU-######`(도번 · 면적번과 같은 뒷자리 · 층마다 셈) · 자리 = 시스템 `resources/master_template/labor/` · 회사 · 개인 `…/templates/labor/` · 새 프로젝트 씨앗도 KINDS 로 함께. +- 문서 꼴: + +```json +{ + "종류": "일위대가", "판": 1, "열": "item1", "일위대가번": "SU-000001", "단위": "개소", + "본뜬": { "층": "system", "주인": null, "키": "UA000008" }, + "담은로직": [ + { "로직": "GF000219", "메모": "", "입력값": { "형식": "메쌓기" }, "수량": null }, + { "로직": "GF000137", "메모": "", "입력값": {}, "수량": { "손": 3 } } + ], + "자재": [ { "코드": "MT000008", "이름": "치즐", "규격": "", "단위": "EA", "수량": null } ], + "계산값": { "줄": [], "자재": [], "기준합": {}, "반영합": {} } +} +``` + +- 이름 = 구조물 열 이름(따로 안 둠) · 단위 = 자기 칸 · 같은 로직 두 번 금지 · 빈 일위대가 저장 됨 · 이름 겹침 검사 없음 · 입력값 = 그 로직 입력 이름만(모르는 이름 버림 · 빈 값 = 자동값). +- 수량 = `null`(면적산출 알약 연결 따름 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」 · 칸 연결 정본 = 면적산출 문서 `연결` 한 곳) | `{손: 수}`(손 입력 · 손이 칸보다 앞섬). +- 금액 = 기준(결과단위 그대로 · 원/10㎡ 면 10㎡ 값 · 단위 같이 표기) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 「원/1,000본」 배 1000(`_PER` 고침) · 자재 기준 단가 = M01 `master_material.element` 기본값. +- 계산 자리 = 기준 ③ 서버(M01 엔진 · calc 길 · 화면은 최신 응답만) · 반영 곱 ① 짝(`M02_MasterTemplete_Labor_Calc.ts` ↔ `M02_Labor_Doc.py` · 두 파일 머리에 짝 · 거울 시험 · 면적 칸 값이 바뀌면 화면이 바로 곱함) · [저장] 때 서버가 `계산값` 을 다시 적음(브라우저 값 버림 · 목록 합계용) · 상세는 늘 새로 셈. +- 길 = 문서 `GET/PUT /api/m02/layers/{층}/templates/labor/{열}`(양식 길 · owner) · 계산 `POST /api/m02/layers/{층}/labor/{열}/calc` {doc?, 수량?: {키: 값}} → {줄[{로직, 분모, 배, 수량, 수량출처, 기준{노무비,재료비,경비,계}, 반영{…}, 까닭}], 자재[{코드, 기준단가, 수량, 수량출처, 반영, 까닭}], 기준합, 반영합} · 알약 `GET /api/m02/layers/{층}/labor/{열}/pills`(로직 물량 + 자재). +- 집계표 열 `일위대가` · `자재조합` 칸 지움(정본 = labor 파일) · SS 장부 줄 = `일위대가번` · `도번` · `면적번`. +- 옮기기(한 번 · `M02_Labor_Migrate.py`) = 층마다 열마다 labor 파일 · 열 `일위대가` 키 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · 수량 null · 자재 []) · 여러 열이 쓰던 조합(UP10-000001)은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 몇 번 돌려도 같음 · 시스템 층(git)은 바로 · storage 층은 압축 사본 뒤 브레인이 돌림. +- 실무문서 = 마스터 `resources/master_data/일위대가조합.json` 통째(37 줄 · 손대지 않음 · 옮긴 뒤 어느 열도 안 가리킴 · M01 일위대가 조합 화면도 그대로) · 회사 · 개인 옛 조합(UC · UP)은 파일 그대로 · 화면에서 뺌. +- 프로젝트 = 스냅샷 `일위대가.json` 은 labor 문서 + 담은 로직 줄 · `입력값.json`(모양 그대로) 첫 값 = labor 입력값 · `단가.json` 에 자재 MT 줄 · 계산은 반영 같은 함수. +- 은퇴(화면이 안 부른 뒤) = 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 · 미리 보기 길(preview) · 일위대가 조합 나무 길. +- 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서(32장 「로직 지우기 서버 검사」 줄 끝남). + +## 2. 좌측 패널 (A · sub5) + +- 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · 구조물 조합 안 차례 = 층 단추 + 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출. +- 하위마다 첫 줄 「전체 항목」(누르면 `onAll(kind)` → H 목록) · 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = `onOpen({kind: "labor", name: 열})`(C 상세). +- 이름 「일위대가」(하위 제목 · 상세 제목 · 대시보드 카드 글) · 해시 `open=일위대가` = 구조물 조합 펼침 + 일위대가 하위 펼침 + 전체 항목. +- 「실무문서」 = 구조물 조합 밖 별도 컨테이너(도면 양식 아래) · 마스터 층 · 시스템 관리자만 · 목록 + 상세 읽기만(새 파일 `M02_MasterTemplete_Practice.ts`) · [통째 지우기](물음 두 번 · 서버 = 시스템 관리자 · 쓰는 곳 0 확인 · 줄을 비움 · 버튼은 사용자가 누를 때만). + +## 3. 일위대가 상세 (C · sub2) + +- UI_Main 종류 `labor` 로 엶(양식 길 읽기 · [저장] · [다시 읽기] · 캐시 = 다른 양식과 같음) · 차례 = 머리 → 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램 · 미리 보기 칸 지움. +- 담은 로직 줄 누름 = 입력 모달(그 로직 입력 전부 · 빈 칸 자동값 · 수량 = 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」 = 면적산출 상세로) · [확인] = 줄 값만(캐시) · 저장은 [저장]. +- 줄 아래 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 빈 입력 「빈 값 n」 붉게. +- 금액 칸 넷(노무비 · 재료비 · 경비 · 합계) · 칸마다 두 줄(위 기준 작게 · 아래 반영) · 맨 아래 합계 줄. +- 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액 · [자재 추가](M01 자재 하나씩 · `M02_Material_Pick.ts`) · 줄 누름 = 수량 모달. +- 다이어그램 = 저장된 줄 값 + calc 답(수량 있으면 반영 · 없으면 기준 · 표기) · 알약 누름 = 입력 모달 · 자재목록 = 카드 하나 → 재료비 칸. +- 보기 갈래(고치기 없음) 부품을 냄 — D 오른쪽 패널 · H 카드가 씀. +- 입력 목록 · 값 받기 부품은 선택 입력에서 옮겨 다시 씀(`M02_Labor_Inputs.py` · `M02_Labor_Values.ts` · `M02_Labor_Values_Model.ts`) · 값 칸 규칙 한 벌(M01 넣을 값) · 로직 입력이 바뀌어 사라진 값은 알림. +- 37장 「미리 보기 값은 구조물 조합으로 안 넘김」 뒤집힘. + +## 4. 면적산출 (D · sub3) + +- 미리보기 카드 알약 줄 켬끔 단추 · 기본 꺼짐 · pref(계정) · 상세는 늘 보임(단추 없음). +- 상세 오른쪽 패널 = 그 열 일위대가의 담은 로직 + 자재목록(C 보기 갈래 · 기준 · 반영) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 지움. +- 패널 줄 누름 = 칸 지정 모달(`M02_MasterTemplete_AreaLink.ts` · [지금 고른 칸으로] · 주소 · [풀기]) → 면적 문서 `연결` · spreadsheet 손잡이 `setLink(키, 칸|null, 항목)`. +- 알약 = 로직 물량 알약 + 자재 알약(`자재|MT키` · 알약 모드 「자재」 다시 씀) · 선택 입력 알약 빠짐. +- 면적 금액 서버 계산은 1장 calc 하나(따로 안 만듦). + +## 5. 목록 · 고르기 · 카드 (H · sub1) + +- 목록 부품 하나 `M02_MasterTemplete_ItemList.ts`(M01 머리 · 쪽 · 표를 import · 옮기지 않음) — 「전체 항목」 · 복사 생성 · 항목 하나 복사가 같이 씀. +- M02 상세 제목 = M01 꼴(18px · 단추 오른쪽) 전부. +- 목록 칸: 도면 = 도번 · 이름 · 폴더 · 단위 · 고친 날 / 면적산출 = 면적번 · … / 일위대가 = 일위대가번 · 이름 · 폴더 · 단위 · 담은 로직 수 · 합계(`계산값` · 없으면 「—」) · 고친 날. +- 「새 항목」 → 상세 칸에 [새로 만들기] [복사 생성] · 복사 생성 = 같은 칸 탭 + 목록 → 지금 보는 층 · 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 일위대가 새 번호. +- 카드 = 도면 · 일위대가 · 면적산출 셋(그 차례) · 빈 카드 = [새로 만들기] [복사 생성] · 찬 카드 = 「복사해 가져오기」(창 · 탭 + 목록 → 확인 → 덮어씀 · 번호 · 열 그대로) · 일위대가 카드 몸 = C 보기 갈래. +- 탭 = 마스터 · 회사 · 개인(나) · 다른 사람(드롭다운 · 맡은 회사일 때) · 실무문서(일위대가 고를 때 · 시스템 관리자) · 기본 = 시스템 관리자 마스터 · 그 밖 개인(회사 없으면 마스터) · 탭 목록 = E `readableSources`. +- 항목 하나 복사 서버 = `copy_from` 에 from_layer · from_owner(B 가 만듦) · 원본이 실무문서 조합이면 조합 → 일위대가 문서(입력값 빈 값). + +## 6. 권한 (E · sub4) + +- 「회사 C 를 맡음」 = 시스템 관리자 · 또는 C 의 회사 관리자 → C 회사 층 · C 모든 개인 층 = 보기 · 만들기 · 고치기 · 지우기 · 복사 대상. +- 일반 = 마스터 · 회사 읽기만 · 남 개인 안 보임 · 나무 그대로 · 프로젝트 apply · sources 도 같은 규칙. +- 사람 나무 = 맡은 회사면 켬 · 회사 사람 전부(나 맨 위 · 빈 사람 「비어 있음」) · 남의 층 첫 쓰기 = 소속 확인 뒤 뼈대. +- 「〇〇로 복사…」 = 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 사람) + 대상 폴더 고르기 창(`M02_MasterTemplete_TreePanel_CopyTo.ts`). +- 남의 층 쓰기 = `system_audit_logs` 한 줄(`M02_OTHER_LAYER_WRITE`). +- 실무문서 = 일위대가 조합 길 전부 시스템 관리자만(서버가 막음). +- 새 길(labor 문서 · calc · pills · 층 넘는 복사)도 `_root` · `_layer_dir` 한 벌을 거침. + +## 7. 지우기 (G · sub6) + +- G1 자재 조합(UM) · G2 재료산출(`material/` · SM · 카드 · 시트 · 옆 판 · 길 · 시험) 먼저 · G3 선택 입력(카드 · `inputs/` · 자리 · 알약 길)은 C 의 입력 모달이 선 뒤. +- `pickMaterial` · `MaterialItem` → `M02_Material_Pick.ts` 로 옮긴 뒤 지움 · 알약 모드 「자재」 · `LinkMaterial`(A00 spreadsheet) 남김 · `고른자재.json` 남김 · 프로젝트 `입력값.json` 남김. +- storage 층(회사 · 개인 · 프로젝트)의 `material/` · `inputs/` · `자재조합.json` 파일은 지우지 않음(읽기만 끊음 · 정리는 사용자) · git 안 시스템 층 · fixture 는 지움. +- 옛 SM 번호 버림 · 장부 `재료번` 칸은 다음 집계표 [저장] 때 빠짐. + +## 8. 파일 주인 (같은 파일 = 몫만 고침 · 받은 뒤 고침) + +| 파일 | 주인 | 다른 창 몫 | +| --- | --- | --- | +| `M02_MasterTemplete_Store.py` · `M02_Template_Layers.py` | sub7 | sub6 = material · inputs 줄 | +| `M02_MasterTemplete_Router_Items.py` | sub4(`_root`) | sub7 = copy 길 from_layer · sub6 = material · inputs 길 | +| `M02_MasterTemplete_Router_Combos.py` · `Combo_Store.py` | sub5(나무 길) | sub4 = 시스템 관리자 문 · sub2 = preview 지움 | +| `M02_MasterTemplete_UI_Side.ts` | sub5 | sub6 = material · matset 줄 | +| `M02_MasterTemplete_UI_Page.ts` | sub5 | sub4 = copyTo · sub1 = onAll 이음 | +| `M02_MasterTemplete_UI_Main.ts` | sub3(면적 길) | sub2 = labor 종류 · sub1 = 제목 꼴 · showView · sub6 = material 길 | +| `M02_MasterTemplete_ItemCards.ts` | sub1(카드 셋 · 빈 카드) | sub3 = 알약 켬끔 · sub6 = 재료 · 선택 입력 카드 | +| `M02_MasterTemplete_Structure.ts` | sub3(pillsFor) | sub1 = pickCopy · sub6 = inputs · material | +| `M02_MasterTemplete_TreePanel.ts`(670줄) | sub1(copyIn · 넘으면 `TreePanel_Clip.ts`) | sub5 = 일위대가 전용 칸 지움 · sub4 = 「〇〇로 복사…」 이음 | +| `M02_MasterTemplete_Labor_*.ts` | sub2 | sub5 = `Labor.ts` · `Labor_Tree.ts` 나무 지움 | +| `M02_MasterTemplete_Api_Fetch.ts` | 각자 자기 함수만 | — | +| `A00_Common/spreadsheet/spreadsheet_links.ts` · `spreadsheet.ts` | sub3 | — | +| 공용 메뉴 · 진입(`main.ts` · `cad_host.ts` · `ui_template_context_menu.*` · `ui_template_tree.*` · `spreadsheet_condition.ts` · `spreadsheet_menu.ts`) | sub8 | — | diff --git a/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md new file mode 100644 index 000000000..fa33578c6 --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md @@ -0,0 +1,108 @@ +# 지난 계획 — 33~38장 (2026-10-01 ~ 10-02) + +단가산출 로직 점검(데스크탑 한시) · 쉬운 단가산출 · 로직 만들기 시험판(세 칸 판 · 시트 로직 · 다이어그램) · 흐름도 시험판 · 흐름도 본 프로그램 적용 · 일위대가 조합 다이어그램 · 마스터 요소 화면 손질. + +## 33. 단가산출 로직 점검 · 수정 (사용자 + 데스크탑 메인 · 보조 · 10-1 한시) + +- 사용자가 데스크탑 메인 · 보조와 직접 진행 · 브레인은 랩탑 sub1~8 로 사용자가 요청하는 다른 일을 맡음(10-1 18:00 무렵까지). +- 근거 = `docs/raw/M01_점검/종합/` · `docs/raw/M01_결정목록/` · `docs/raw/M01_입력목록/` · `docs/raw/M01_고침/`. + +## 34. 쉬운 단가산출 제어 (새 길 · 10-1 · 랩탑) + +- 사용자 확정(10-1 오전): 만든 쉬운 단가산출은 유지(지우지 않음) · 로직 정의 자체를 만드는 판은 제안만 · 허락 뒤 착수 +- 사용자 확정: 지금 단가산출은 그대로 · 「추가」 단추 옆 새 단추로 따로 들어감 · 코드는 `M01_MasterData/EasyCalc/` 한 곳(안 되면 폴더째 지움) · 초등학생도 추가 · 삭제 · 수정 · 시간이 걸려도 검토 먼저. +- 정함(브레인): 새 길은 지금 로직 형식을 그대로 읽고 씀(계산 엔진 안 바꿈) · 폴더 밖 참조는 단추 자리 한 곳만. +- [x] 34-1 검토 — 데이터 · 계산 지도(sub4) · 지금 화면 걸음 재기(sub6) · 실무 모양 + 갈래 비교(sub7) · 지난 시도(sub8) → `docs/raw/M01_쉬운단가/` · 지금 화면 새 로직 38 누름 · 5~6 창 +- [x] 34-2 설계 확정(브레인) — 문장 카드 · 품셈 기본은 「이걸 바탕으로 고치기」 · 쓰기는 `/logic/*` 만 · 1단계 숫자 수량 + 잠금 카드 → `docs/raw/M01_쉬운단가/설계.md` +- [x] 34-3a 단추 + 빈 창(sub1 · 「식으로 새로 만들기」 옆) 검증완료 +- [x] 34-3b 1단계 — Model(sub7) · Api(sub4) · Cards(sub2) · Pick(sub3) · Sheet(sub5) · 틀 · 잇기(sub1) — 설계.md 2 · 3절 · Sheet 검증완료 · Model 검증완료 · Api 검증완료 · Pick 검증완료 +- [x] 34-3c 2단계 — 표 보고 고르기(sub8) · 조건 고치기(sub7 · 조건 줄 93% 읽음) 검증완료 · 카드 단추 잇기(sub2) · 조건 고치기 검증완료 +- [x] 34-4 검증 — 대표 일 여섯 + 예외(빈 값 · 같은 이름 · 저장 안 하고 닫기 · 권한 없음 · 밝음 · 어두움) · 지우기 시험(폴더 밖 참조 줄 수) · 걸음(sub3 고침 뒤) ①21 ②8 ③2 ④4 ⑤2 ⑥3 = 40(목표 36 넘음 4 · ① 새 설계 값 자동값으로 채워 금액 곧장 보임) · 다른 일위대가 넣고 저장 됨 · 권한 없음(일반 사용자) = M01 화면 못 들어감 · /api/m01 403 · 폴더 밖 참조 두 줄 · 검증완료 + +## 35. 단가산출 로직 만들기 판 (제안 단계 · 착수 전) + +- 사용자 확정: 쉬운 단가산출처럼 따로 폴더 시험판 · 로직 몇 개 골라 만듦 · 페이지 보고 판단(마음에 안 들면 지움) · **착수는 허락 뒤** · 인터넷 조사 먼저 · 화면 용어는 단어로 다시 제안. +- 제안서 https://claude.ai/artifact/U4rkgpYWL4Em5VKBEVa4oY (세 칸: 원문 표 · 산출 과정 · 결과) +- [x] 35-0 인터넷 조사 — 견적 프로그램(sub6) · 노코드 규칙 도구(sub8) → `docs/raw/M01_쉬운단가/로직판_조사_*.md` · 제안서 2판에 반영 +- [x] 35-1 용어 단어 제안(제안서 2판 「용어 — 단어로」) → 사용자 확정 · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-2 시험판 — 사용자 허락(10-1 · 갈래가 여럿이면 갈래마다 · 목적 = 코딩 없이 원문 보며 확인 · 수정 · 생성) · A 세 칸 판 `LogicBuilder/` · B 엑셀 시트형(우리 스프레드시트) · 먼저 조사: 엑셀 로직 지도(sub4) · A 설계 초안(sub7) → 브레인 A 대 B 판단 → 구현 · 완료 처리(10-4): 35-2a 로 함 +- [x] 35-2a A 세 칸 판(가운데 문장 / 격자 두 보기) — 정함 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · Blocks(sub7) · 서버 · Api(sub4) · 원문 · 검사(sub8) · 문장(sub2) · 격자(sub5) · 단계 안내(sub3) · 틀 · 잇기 · 끝 검증(sub1) · LB 서버 검증완료 · Blocks 검증완료 · Grid 검증완료 · Steps 검증완료 · Source · Check 검증완료 · Wizard 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 원문 대조 6/6 블록 읽힘 · 걸린 줄 · 출처 보임 ② GF000219 표 칸 0.15→0.20 = 79,280→96,759 원(예상과 같음) · 파일 안 바뀜 · 다시 열면 0.15 ③ 단계 안내 11 걸음 + 저장 → 현장 다시 열림 60,984 원(/calc 같음) → 삭제 2 걸음 ④ 빈틈 해당 없음 10 · 못 따진 표 조회 1 · 기대값 시험표 맞음/다름 · 화면 초안만 ⑤ 표 고침 없을 때 lb/calc = /calc 8/8 · 문장 ↔ 격자 같은 값 · 예외(빈 이름 · 저장 안 하고 닫기 · Ctrl+Z · Ctrl+S · 권한 없음 403 · 밝음 · 어두움) · 폴더 밖 참조 LogicLab.ts 두 줄 + main.py 등록 한 줄 +- [x] 35-2b 남은 것(사용자 판단 뒤) — 문장 / 격자 고르기 · 표 칸 고침 저장 여부 · 적용 품셈 판 · 용어 · 칸 역할 「결과」 바꾸기 · 단계 안내 고르기 창 수량 물음 · GX000001(메인 11:53 · 12:02) 주인 확인 · 보고 https://claude.ai/artifact/NqKfHaJULcQo6dSvnsxy1n · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-3 점검(확인만) — 표끼리의 관계(로직) 자체를 초등학생이 만들고 · 바꾸고 · 지울 수 있나 · 품셈에 없는 관계 더하기 · 시나리오 여섯(sub6) — 결과: 있는 관계의 값만 고침(초등 중) · 관계 더하기 · 지우기 · 다시 잇기 · 새 입력값 · 새 표 · 로직끼리 바꾸기 안 됨 · 문턱만 바꾸면 계산 멈춤 + 엉뚱한 알림 · `docs/raw/M01_쉬운단가/로직판_관계제어_점검.md` +- [x] 35-4 다이어그램 제안(사용자 생각 10-1) — 상자 = 표 · 입력값 · 고정값 · 계산 · 항목 · 다른 로직 · 결과를 직접 다룸 · 선 = 관계를 만듦 · 품셈에 안 묶임(사용자 표 · 사용자 관계) · 관계 제어 = 다이어그램(사용자) · 위 가로 띠 + 아래 고른 상자 다루기 · 결과 · 관계 = 진짜 엑셀 수식(칸 주소 · 함수 · 사용자 확정) · 알약 = 입출력 칸 표시 · 새 종류 「시트 로직」(TS 엔진 한 벌 · 서버 Node) · 제안서 https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 허락 전 착수 금지 · 완료 처리(10-4): 35-5 · 35-7 로 함 +- [x] 35-5 시트 로직 시험판(사용자 허락 10-1 오후) — 위 가로 다이어그램 · 상자 = 시트 · 관계 = 엑셀 수식 · 입출력 = 알약 · 저장 = `M01_MasterData/SheetLogic/_data/`(git 밖 · 폴더째 지움) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` · Model · Eval(sub7) · 서버 · Api(sub4) · Diagram(sub8) · Panel(sub2) · Table(sub3) · Item · Result(sub5) · Import(sub6) · 틀 · 잇기 · 끝 검증(sub1) · SL 서버 검증완료 · Model 검증완료 · Diagram 검증완료 · Item · Result 검증완료 · Table 검증완료 · Import 검증완료 · 잇기 · 끝 검증완료(sub1) — 걸음: S1 ①②③ 각 3(79,280 · 73,085 손셈 같음 · 2.5m 문턱은 품셈 표 3m 부터라 2.5~3m 해당 없음) · S2 ① 3 ② 4(찰쌓기 표로) ③ 5 · S3 ① 약 19(경사 급 65,018) ② 9(내 표 붙여넣기 · 할증 10) ③ 7(잡재료 2% 17,551) · S4 선 끊기 2 · 상자 지우기 2(물음 한 번) · Ctrl+Z 되돌림 · S5 0(상자 · 선 · 값 한 장 · 고르면 이어진 선 진하게) · S6 항목 일위대가 11 · 다른 로직 상자 13(덤프트럭 16.2) · 새 로직(품셈 없이) 25 걸음 → 저장 서버 36,000 = 화면 → 다시 열림 → 지우기 2 · 서버 = 화면 4/4(36,000 · 17,550.9 · 10.12 · 다시 열기) · 가져오기 6/6 /calc 같음(0 · 60,984/79,280 · 2,064,816 · 670 · 16.2 · 57,516) · 예외(빈 이름 · 같은 상자 · 순환 · 빈 입력 물음 · 저장 안 하고 닫기 · 권한 없음 sl 5/5 403 · 밝음 · 어두움) · 세 칸 안쪽 세로 밀기 없음(6개 모든 상자) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 남은 흠(다른 창): 가져온 표 상자 + 결과 칸 목록 예시값이 글자로 들어가 30 등에서 해당 없음(sub5) · 가져온 로직 화면 번호 · 자재 검색어 · 끝 검증완료(S1~S6 · 새 로직 · 서버 = 화면 4/4 · 가져오기 6/6) · 남은 흠: 결과 칸 예시값 선택이 글자 「30」 으로 들어가 표 찾기 해당 없음(sub5 · 사용자 지시 뒤) +- [x] 35-5a 사용자 요청 — 다이어그램 띠 · 고른 상자 시트 · 결과 높이를 안쪽 세로 스크롤이 없을 때까지(sub1) +- [x] 35-6 로직 문법 먼저(사용자 방향 10-1 14시 무렵 · 화면보다 로직) — 바탕 표(인력 378 · 재료 30,767 · 기계 654 · 소요량 2,535 · 계수 498 · 환율 · 요율 · 사용자 표 — 보기 · 만들기 · 비목은 표가 정함) · 단위 넷(값 · 표 · 정보 · 요소) + 묶음(세분화 · 묶기 · 분류 · 묶음째 잇기 · 겹 묶음 · 틀 다시 쓰기) · 흐름 셋(정보 만들기 · 잇기[그대로 · 바꿔서 · 갈라서 · 모아서 · 묶음째] · 내기) · 확인 도구 · 제안서 https://claude.ai/artifact/BFWvm1Q4fhCSZ4Rt34UgYY · 다음 = 문법 확정 → 종이 연습(절 10개) — 허락 전 착수 금지 · 완료 처리(10-4): 35-7 · 35-9 흐름도로 갈음 +- [x] 35-5b 단추 공용 양식(점검 끝 · 고침은 사용자 지시 뒤) — 날 단추 23(목록 줄 8 · 칩 · 역할 · 보기 바꿈 11 · ▾ 2 …) · sm 단추를 44px 로 덮기 3 · 큰 시작 56px 3 · `docs/raw/M01_쉬운단가/시험판_단추_점검.md` · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-7 단가산출 다이어그램 시험판(사용자 기준 10-1 14시 무렵) — 입력 · 함수 · 출력 상자 · 자유 배치 · 함수 = 엑셀 · 출력 하나 · 출력 = 비용 종류 · 원가 · 폴더 `M02_MasterTemplete/CostFlow/`(_data · _sample) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` · Model · Eval(sub7) · 서버(sub4) · Diagram(sub8) · 함수 판(sub2) · 입력 판(sub3) · 출력 · 원가(sub5) · 견본(sub6) · 단추 · 페이지 · 잇기 · 끝 검증(sub1) · Model 검증완료 · Output · Summary 검증완료 · Sample 검증완료 · FuncPanel 검증완료 · Diagram 검증완료 · CF 서버 검증완료 · InputPanel 검증완료(사용자 요소 만들기는 서버 Api 가 main 에 오면 확인) · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 손으로 메쌓기 꼴(변수 4 · 소요량 표 2 · 인력 2 · 함수 증가율 → 석공 / 보통인부 병렬 · 출력 노무비 2) 9 상자 · 약 138 손놀림 → 원가 148,471 = 견본(증가율 40 · 석공 97,882.512 · 보통인부 50,587.992 손셈 같음) ② 비용 종류 바꾸면 148,471 이 노무비 → 경비 → 재료비 로 옮겨감 · 「값」 이면 원가에서 빠짐(각 1 걸음) ③ 상자 끌어 옮김(1520,190 → 1675,306) · 저장 → 다시 열면 그대로 ④ 함수 출력 하나(다른 칸 고르면 옮겨감 · 알약 하나) · 순환 막힘 · 출력에서 나가는 선 없음 · 빈 받는 자리는 저장 때 물음 ⑤ 사용자 요소(시험 보조공 200,000) 5 걸음 · 마스터 그대로 · _data 에만 ⑥ 저장 때 서버 = 화면 2/2(148,470.504) ⑦ 단위 오른쪽 위(- → 원/㎡) · 색 바꾸기(Entry 판 머리) · 「다이어그램」 이름 · 다이어그램 726px · 안쪽 스크롤은 함수 격자(가상 200줄)만 · 밝음 · 어두움 · 권한 없음(cf 길 6/6 403 · 화면 못 들어감) · 견본 셋 = /calc · 손셈(148,471 · 3,000 · 15.552) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 끝 검증완료(손으로 메쌓기 9 상자 = 견본 148,471 · 비용 종류 옮김 · 자리 저장 · 규칙 · 사용자 요소 · 서버 = 화면 · 견본 셋 손셈) · 함수 출력 기본 이름 겹침 고침(sub7 2be767d18) · 남은 것: M01 단가산출 단추 줄이 셋째 줄까지(시험판 단추 넷 · 사용자 결정) +- [x] 35-7a 브레인 실제 검증(10-1 16:45) 흠 여섯 고침 — ① 고칠 것 칸 지난 경고 · ⑥ 시험판 단추 「시험판 ▾」 로 묶기(sub1) · ② 알약 쉼표 · ③ 표 알약 글 · ⑤ 맞춤 확대 90% 아래 안 감(sub8) · ④ 입력 상자 공통 단위(sub3) · 다시 검증완료(16:55 · 고칠 것 칸 비어 있음 · 알약 쉼표 · 표 · 40줄 · 인력 원/일 · 확대 90% · 막대 한 줄 · 시험판 ▾ 넷 · 원가 148,471) + +## 35-8. 단가산출 다이어그램 2판 — 재료 선택 자리 · 선 규칙 (계획 · 허락 전) + +- 사용자 확정: 고르는 방식은 재료만(다른 그룹은 상자를 늘림) · 설계 때 입력은 오른쪽 아래 검증 칸 · 나중에 프로젝트에서 일위대가로 쓸 때 재료는 목록에서 고르거나 단일 값 · 표에 따라 출력을 가르는 것은 엑셀 수식 대신 선 규칙. +- 정함(브레인): 재료 요소 = 고정 · 선택 자리 · 조건 찾기 · 알약 이름 = 자리 이름 · 선 규칙 = 선 하나에 하나(표에서 찾기 · 조건 통과 · 바꾸기) · 규칙은 받는 칸 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임. +- [x] 35-8a 틀(Model) — 재료 요소 방식 · 범위(구분 · 상세구분 · 찾는 말) · 조건(규격 ← 알약) · 기본 품목 · 선 규칙 칸 · 규칙 → 받는 칸 수식 · 조건 선 겹침 · 안 맞음 검사 — node 시험(sub7) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8b 서버 — 선택 자리 기본 품목 · 조건 찾기 단가(맞음 0 · 여럿 알림) · 「설계 때 정할 것」 목록(변수 + 재료 자리) 내보내기 — 시험(sub4) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8c 입력 판 — 재료 방식 · 범위 · 조건 규칙 · 기본 품목 · 찾기 고침(쪽 넘기기 · 띄어쓰기 AND · 규격까지) — ORCA(sub3) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8d 검증 칸 — 재료 선택 자리를 변수처럼(범위 안 목록 고르기 · 찾기) · 조건 자리는 잡힌 품목 표시 — ORCA(sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8e 선 규칙 — 선 위 규칙 글 · 선 누르면 아래 판에서 규칙 고르기(표 찾기: 조건 칸 ↔ 알약 · 꺼낼 칸 / 조건 통과: 알약 · 같음 · 다름 · 이상 · 이하 · 값 / 바꾸기: × · ÷ · +% · 단위 · 끝수) · 고른 상자 판에 들어오는 선 규칙 목록 — ORCA(sub8 · 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8f 견본 · 끝 검증 — 견본 둘 더(지반에 따라 굴삭기 / 브레이커 가르기 · 흄관 관경 조건 찾기) · 사용자처럼: 함수 상자 없이 표 찾기 · 가르기 · 검증 칸에서 재료 고르기 · 방법 걸음 수(sub6 견본 · sub1 잇기 · 끝 검증) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8g 다이어그램 마우스 — 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자가 선택되지 않음(다이어그램 영역 글자 선택 막음) · 상자 옮기기는 상자 머리 왼쪽 끌기 그대로 — ORCA(sub8) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8h 「고른 상자」 → 「상세 내용」 · 상자뿐 아니라 알약 · 선을 골라도 내용이 바뀜(알약 = 값 · 단위 · 어디서 오나 · 어디로 가나 / 선 = 선 규칙 고르기) — ORCA(sub1 잇기 · 알약 · 선 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8i 상세 내용 단추 · 칸 크기 — 함수 판 꾸밈이 공용 작은 단추를 44px 로 덮는 줄(`M02_CF_FuncPanel.css` `.m01-cfpanel .ui-btn …`)을 지우고 판 안 단추를 모두 공용 작은 크기(createButton size "sm")로 · 입력 · 고르기 칸도 공용 크기 — ORCA 수치(sub2 · sub3 · sub5 각자 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8j 상세 내용 머리 — 왼쪽 「상세 내용」 · 오른쪽에 고른 상자 제목 · 알약을 골랐으면 그 오른쪽에 알약 이름(예 「높이 증가율 찾기 › 증가율」) · 상세 내용 안 칸 차례 정리(머리 · 상자 설정 · 본문 · 경고) — ORCA(sub1) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8k 단위는 고르기 — 단위 칸을 목록 고르기로(치면 걸러져 보이고 고르기는 사용자 · 손으로 쓴 글은 안 받음) · 목록 = 단위 표준(35-8l) 단위 + 마스터 결과단위 + 금액 단위(원) · 「-」 = 없음 · 함수 · 출력 · 변수 · 사용자 요소 단위 모두 같은 고르기 — 공용 고르기 파일 `M02_CF_UnitPick.ts`(sub2) · 판마다 바꿔 끼움(sub2 · sub3 · sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8l 단위 표준 표 — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` · 종목 약 70 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③)을 표로 — 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 흠은 사용자가 고침(시험판에서 보고 고치는 표 · 저장 `CostFlow/_data` · 처음 값 `CostFlow/_standard/`) — 시험(sub6) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8m 금액 단위 표준 — 같은 절 「금액의 단위표준」(설계서 총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 모두 미만 버림)을 같은 표 자리에 — 출력 상자(비용) 금액 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림 · 바꿀 수 있음(sub6 표 · sub5 출력 · 원가) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8n 끝수 고르기 — 함수 상자 출력 · 출력 상자에 「끝수」(소수자리 · 반올림 / 올림 / 버림) · 단위를 고르면 단위 표준에서 소수자리를 제안(주 ① 반올림 · ② 항목 값이 우선) · 고른 끝수는 출력 칸 수식(ROUND 계열)으로 — 엔진 한 벌(sub7 틀 · sub2 함수 판 · sub5 출력 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8o 로직끼리 잇기 · 흐름 따라가기 — 저장한 다른 로직을 입력 상자로 불러 그 출력 알약을 받음(일위대가 → 일위대가) · 알약 · 선을 고르면 값이 어디서 와서(출처 사슬) 어디로 가는지(가는 곳) 다이어그램에서 길을 칠하고 상세 내용에 줄로 · 값이 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건) 표시 — 시험 · ORCA(sub7 틀 · sub8 다이어그램 · sub1 상세 내용) · 완료 처리(10-4): 35-9 에서 함 +- 차례(사용자 10-1 · 「데이터의 흐름과 값의 연결에 집중」): **1단계 = 흐름 · 연결** a 틀 · b 서버 · e 선 규칙 · o 로직끼리 · 흐름 따라가기 · h 알약 · 선 고르기 · c · d 재료 자리 · f 견본 · 끝 검증(흐름 기준 — 값이 어디서 와서 어떤 규칙을 지나 어디로 가는지) / **2단계 = 값의 뜻 · 보기** k 단위 고르기 · l 단위 표준 · m 금액 표준 · n 끝수 · j 머리 · g 마우스 · i 단추 + +## 35-9. 단가산출 흐름도 시험판 (사용자 지시 10-1 17시 무렵 · 브레인이 끝까지 · 35-8 전체) + +- 사용자 확정: 브레인이 전체 제어해 새 시험판 하나 · 단가산출 다이어그램(시험)만 남기고 옛 시험판 셋 삭제 · 새 폴더(필요 없으면 바로 지움) · 데이터 흐름 · 값 연결 먼저 · 자유도 높고 이해하기 쉬운 화면 · 끝까지 구현 · 검증. +- 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md` · 폴더 `M02_MasterTemplete/DataFlow/` +- [x] 35-9 창 여덟 — 옮기기 · Model · Eval(sub7) · 옛 시험판 지우기 · 들어가는 길 · 상세 내용 머리 · 잇기 · 끝 검증(sub1) · 서버(sub4) · 다이어그램(sub8) · 상세 내용 판 · 단위 고르기(sub2) · 입력 판(sub3) · 출력 · 검증 칸(sub5) · 표준 표 · 견본(sub6) · 표준 · 견본 검증완료 · Model 검증완료 · 출력 · 검증 칸 검증완료 · Diagram 검증완료 · 입력 판 검증완료(서버 자재 찾기 · UnitPick 이 main 에 오면 다시 확인) · DF 서버 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 함수 상자 없이 네 흐름 73 걸음: 재료 조건 찾기(관경 → 흄관 자리 · 13 개 중 하나 · 125,870) 21 · 선 규칙 표에서 찾기(높이 증가율표 → 출력) 19 · 조건 통과 두 갈래(지반 = / ≠ 토사) 27 · 다른 로직 잇기(견본 지반 · 지반 알약 이음) 6 ② 알약 · 선 고름 → 온 길 · 가는 길 칠함 + 상세 내용 줄(지반 → 다른 로직 · 두 조건 선 · 흄관 ← 관경) ③ 끊긴 곳 넷 보임(빈 받는 자리 · 통과 안 됨 · 표에 맞는 줄 없음 · 맞는 재료 없음 — 알약 · 선 「!」 · 끊긴 곳 줄 누르면 그 자리 고름) ④ 검증 칸 관경 600 → 800 = Φ800 품목 215,530 · 지반 토사 → 암 = 내 두 갈래 뒤집힘 + 다른 로직 2,000 → 10,000 · 다른 로직 설계 값 바꿈 10,000 → 20,000 ⑤ 단위 고르기 「mm」 = 3 개로 걸러짐 · 손글 안 받음 · 끝수 80,000.37 = 기본 225,870 / 1원 올림 225,871 · 표준 표 고침 저장(3 → 2) → 처음 값으로 되돌림 ⑥ 가운데 끌기 = 판 옮김(−60, −30) · 왼쪽 끌기 = 판 그대로 · 글자 선택 막힘 · 단추 18px 모두 sm · 칸 30px ⑦ 저장 → 다시 열기 = 상자 9 · 선 6 · 자리 · 규칙 글 그대로 · 서버 다시 셈 = 화면 187,910 · 견본 다섯 사본 저장 서버 = 화면(148,470.504 · 2,000 · 84,680 · 3,216.6 · 값 15.552) · 밝음 · 어두움 · 로그인 없음 df 길 6/6 401(관리자 아닌 계정 403 은 계정 없어 못 함) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 검증 중 고친 내 흠(047f3a279 · 28469de62): 재료 조건 찾기 · 선택 자리 값을 서버에 안 물음(흄관 견본 관경 600 → 400 = 211,700 → 116,600 · 1m 84,680 → 46,640 · 1000 = 235,120 · 선택 자리 Φ600 → Φ400 = 211,700 → 116,600) · 「상자 이름」 칸(입력 · 함수 · 같은 이름 · 빈 이름 막음 · 출력은 출력 판) · 머리 이름 · 단위 칸 작은 크기(30px) · 「새로 만들기」 단추 76px(칸 폭 710 아님) · 표준 표 모달 제목 한 번 · 남은 흠(sub2): 선 규칙 표에서 찾기 조건 칸에 범위 이름(「높이」)이 없어 범위 찾기를 화면에서 못 만듦(높이 5 → 줄 없음 · 4 만 맞음) · 브레인 실제 검증완료(흠 14 고침 · 흐름도 시험 81 · 견본 다섯 서버 = 화면 · 보고 JgfqRzLPYgqXG7vB2cV1Yj) · 검증 대기 + +## 36. 단가산출 흐름도 본 프로그램 적용 (사용자 지시 10-1 21:40 · 결정 반영 22시 · 착수 승인 22:20 · 밤새 진행 · 아침 보고) + +- 사용자 확정: 흐름도 = 최종 · 로직 목록에서 로직을 열면 지금 내용 그대로 + 오른쪽 흐름도 패널(같은 로직) · 「식으로 새로 만들기」 지우고 「신규 로직」(흐름도 새로 만들기) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽) · 시험판 단추 · 입구 지움 · DataFlow 폴더 유지. +- 근거(마스터 로직 57 파일 · 1,472 로직 · 입력 3,518 · 중간 식 3,168 · 호표 줄 5,265): 식 말 = 찾기(표 · 조건 · 칸) 8,152 · 만약 3,532 · 또는 185 · 최대/최소 173 · 올림/반올림/버림 141 · 로직() 41 → 흐름도 표에서 찾기 · 조건 · 함수 · 다른 로직 입력으로 옮길 수 있음. +- 사용자 결정 D1(그대로): 「품셈원문도 제대로 구현된게 아니기 때문에 내가 흐름도까지 만들어서 로직을 볼려는 거잖아. 그러니 당연히 둘다 같은 내용 … 원문과 대조하면서 검증할때 잘못된게 있으면 흐름도에서 수정할거야」 → 한 로직 · 두 보기: 왼쪽 지금 내용 = 보기 · 고치기는 흐름도(공용 로직 포함 · 시스템 관리자) · 흐름도로 저장한 로직은 흐름도가 정본 · 왼쪽은 흐름도에서 만든 같은 꼴 · 아직 저장 안 한 로직은 지금 내용에서 자동으로 그린 흐름도(값 대조로 같음). +- 사용자 결정 D2(그대로): 「흐름에 대한 로직은 costflow폴더에 있을테니 일단은 그냥 두고 쓸대없는 견본과 버튼만 삭제」 → CostFlow 폴더 그대로 · 시험판 단추 · 견본 목록 지움(흐름도는 DataFlow 폴더만으로 돌아감 · CostFlow 를 부르지 않음). +- 사용자 결정(함수 상자): 「함수박스를 기준으로 하나의 결과가 나가는게 맞는것같아. 여러개가 동시에 나가면 혼란만 가중」 → 함수 상자 하나 = 결과 알약 하나(그대로) · 식은 상자 겉에 넣지 않음(상자가 커짐) · 함수 상자 · 선을 고르면 상세 내용에 사칙연산 · 조건이 보이고 고치면 바로 따라감. +- 칸 연결 = M02 방식 그대로(칸을 고른 채 알약을 눌러 하나씩 연결 · 식은 칸 주소 · `spreadsheet_links.ts` 직접 확인) · 계산 엔진 · 이름 풀기 · 이름 규칙 없음 — 보기만 칸 주소를 연결된 알약 이름으로 바꿈. +- 사용자 결정(조건): 「조건문은 현재를 유지하는게 보기 편한것같고 대신 어떤값을 지정할지는 입력된 리스트에서 선택 … 조건 값을 내가 넣으면 오기 … 복수 조건문도 넣어주고」 · 「조건 만들기도 엑셀 기능 내부에서 존재해야해」 → 선 조건은 지금 웹앱 모양 · 조건 값은 고른 알약이 가질 수 있는 값 목록에서 고름(변수 고르기 · 표 칸 값 · 재료 규격 등 · 목록이 없는 수는 숫자 칸 + 범위 검사) · 조건 여러 줄(그리고 / 또는) · 함수 상자 엑셀 시트 안 「조건 만들기」(칸 고름 → 만약 · 아니면 만약 · 아니면 줄 → 그 칸에 IF 식 · 값은 같은 목록에서 고름). +- 안전: 화면 시험 저장은 마스터 사본 폴더에서만(서버가 환경 변수로 사본 폴더를 씀 · 실제 resources/master_data 는 안 바뀜 · git status 로 확인). +- 창: sub2 36-0(판 · 조건 만들기) · sub7 36-0b(조건 틀 · 값 목록) → 36-2 · sub6 36-1 → 36-7 · sub8 36-3 · sub4 36-4 서버 · 사본 폴더 · sub5 36-4 왼쪽 보기 · sub1 36-5 · 잇기 · 36-8 · sub3 36-6 +- 브레인 정함(조사 뒤 · 아침 보고에 알림): 옛 로직 「기계 · 직종 · 유가 자리」(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 입력값으로 요소를 고름 · 71 로직)는 새 모드 없이 이미 있는 「조건 찾기」(이름 · 키 같음 · 변수 알약)를 인력 · 기계 · 유가 요소에도 씀 · 대조 입력 = check_master.시험입력 · 로직 셈을 부르는 곳(Store.calc · text · master_combo.run · M02_Item_Prices.calc · /logic/auto)은 한 곳에서 흐름 갈래. +- [x] 36-0 함수 상자 안 보기 · 칸 연결 조정(36-2 보다 먼저) — ① 상세 내용 「식 보기」: 결과 칸 식을 칸 주소 → 연결된 알약 이름으로(예: 1m 재료비 = 흄관 단가 ÷ 2.5) + 값 들어간 식(= 211,700 ÷ 2.5 = 84,680) · IF 는 만약 / 아니면 줄 · 지금 타는 갈래 표시 · 중간 칸 식도 차례대로 · 칸 식을 고치면 바로 다시 셈 → 다이어그램 · 원가 따라감 ② 「칸 연결 표」(칸 · 연결 알약 · 입력 / 결과 · 이어진 선 · 표에서 바로 바꿈 · 풀기) + 칸 색(입력 파랑 · 결과 초록 · 상자 색과 같음) · 결과 칸 하나 ③ 선을 고르면 그 선 규칙도 같은 「식 보기」 꼴(예: 굴착량 → 지반 = 토사 일 때만 → 굴삭기로 파기) ④ 엑셀 시트 안 「조건 만들기」 — 칸을 고르고 열면 만약 [알약] [비교] [값 고르기] → [알약 · 값] 줄 · 아니면 만약 더하기 · 아니면 → 그 칸에 IF 식(엑셀로 쓴 IF 도 같은 줄로 다시 열림 · 못 바꾸는 식은 식 그대로) · 검증완료(sub2 · 식 보기 · 칸 연결 표 · 조건 만들기 · 선 조건 여러 줄 · 시험 8) · 검증 대기 +- [x] 36-0b 선 조건 손질 — 조건 값 = 고른 알약의 값 목록에서 고름(Model 값 목록 함수 · 변수 고르기 · 표 칸 값 · 재료 규격 · 목록 없는 수는 숫자 칸) · 조건 여러 줄(그리고 / 또는) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 위 이름표 · 겹침 · 안 맞음 검사 그대로 · 검증완료(sub7 · 조건 여러 줄 그리고/또는 · 값 목록 · 서버 = 화면 · 시험 30) · 검증 대기 +- [x] 36-1 조사 — 옛 식 문법 전수(서버 파서 · 요소 부르기 꼴 · 따옴표 글) · 변환 못 하는 꼴 목록 · /calc 를 부르는 곳 · 검증완료 +- [x] 36-2 변환 — 옛 로직(입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수) → 흐름도(입력 상자 · 표 알약 + 표에서 찾기 · 함수 · 출력 상자) + 자동 배치 · 서버 파서로 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(만약 → IF · 최대 → MAX · 찾기 → 표에서 찾기) · 함수 상자 하나 = 결과 하나 · 전수 대조(변환 흐름도 값 = 옛 계산 값 · 맞는 비율 · 안 맞는 까닭) · 검증완료(sub7 · M02_DF_Convert · 전수 1,472 시험입력 96.7% · 기본 입력 97.4% · 안 맞는 49 = 재료 검색어 자리 18 · 재료 빈 검색어 13 · 표 범위 규칙 경계 15 · 단가 빈 품목 3 · tmp/convert_report_36.json) · 검증 대기 · 마지막 대조: 시험입력 · 기본 입력 모두 1,472/1,472(100%) · 빈 단가 = 옛 셈 뜻(그 값 쓰는 식 빈 값 · 비목 합 0 · 끊긴 곳 「단가 없는 품목을 씀」) +- [x] 36-3 오른쪽 패널 — 로직 상세 오른쪽에 흐름도(다이어그램 · 상세 내용 · 원가 · 검증) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면) · 검증완료 +- [x] 36-4 저장 · 정본 — 로직 줄에 「흐름」 칸(화면 [저장] 때만 씀) · 흐름이 있으면 /calc 가 흐름도로 셈(서버 다시 셈) · 왼쪽 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산은 흐름도에서 만든 같은 꼴(보기) · 공용 로직도 흐름도로 고침(시스템 관리자) · 캐시 → [저장] 한 번 · [고친 것 버리기] 함께 · 왼쪽 보기 검증완료(sub5) · 서버 검증완료(sub4) +- [x] 36-5 단추 — 「식으로 새로 만들기」 지움 → 「신규 로직」(빈 흐름도 + 기본정보 · 저장 = 자체 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(흐름도도 복사 · 신규 로직 오른쪽) · 「시험판 ▾」 지움 · 잇기 검증완료(sub1 · accf8ecc4 · abbf61971 · 0d45014e3) — 「식으로 새로 만들기」 · 시험판 ▾ · 시험판 단추 둘 지움 · 단추 차례 ← 로직 목록 · 신규 로직 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장 · 두 칸(LogicLab_Flow) · 오른쪽 패널(흐름 받기 · 고침 → drafts → [저장] 한 번 · 버리기 함께 · 흐름 로직이면 왼쪽 흐름 보기) · 신규 로직 기본정보 칸(LogicLab_Basic) → 저장 = 서버 added 키로 엶 +- [x] 36-6 다른 로직 입력 — 대상 = 마스터 로직 전체(찾기) · 흐름도 로직이면 흐름도 셈 · 아니면 옛 셈 · 목록 · 찾기 · 쪽 · 알약 이음 검증완료(서버 흐름 받기 · 값 셈 길이 main 에 오면 GF 로직 값 확인) +- [x] 36-7 정리 — 「시험판 ▾」 · 시험판 입구 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 resources/tester 로 · 시험 저장 길 숨김 · DataFlow · CostFlow 폴더 그대로 · 검증완료 +- [x] 36-8 끝 검증 — 흐름 기준 · 옛 로직 열기 → 오른쪽 흐름도 값 = 왼쪽 계 · 신규 로직 → 저장 → 다시 열기 · 복사 · 권한 · 브레인 실제 검증 · 보고 · 끝 검증완료(sub1 · 사본 폴더 tmp/master_copy_36 + AISLO_MASTER_DIR · 네 포트) — ① 옛 로직 셋: GF000001(찾기) 오른쪽 34,413 = /calc 34,413.6 = 왼쪽 34,414(보임 끝수만 다름) · 변환 알림 한 줄 / GF000007(만약) 0 ≠ 597,285 · GF000288(로직()) 0 ≠ 1,234,760.96 — 아래 흠 ② GF000001 함수 식 ×1.5 → 오른쪽 · 왼쪽 51,620 따라감 → [저장](공용 GF · 사본) → 다시 열기 상자 6 · 변환 알림 없음 · /calc flow 51,620.4 = 화면 ③ 신규 로직 65 걸음(변수 셋 · 함수 · 조건 만들기 · 출력 둘 · 조건 선) → 저장 GX000004 → 다시 열기 상자 4 · 선 4 · 화면 100,000 = /calc 100,000 = 왼쪽 계(높이 2 = 0 · 지반 암 = 50,000) ④ 로직 복사/생성 2 걸음 → GX000005 상자 4 · 선 4 · 100,000 ⑤ 선 조건 두 줄 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 값 고르기(토사|암) · 높이 2 → 0 · 또는 → 50,000 · 조건 만들기 =IF(A2>=3,A3,0) ⑥ 식 보기 「결과 1 = 금액 × 높이 ÷ 4」 · IF 만약 / 아니면 줄 · 칸 연결 표 ⑦ 크게 보기 1484×1076 · 거기서 고친 것 패널로 · 접기 40px · 펴기 · 너비 320 → 522 ⑧ 고친 것 버리기 5 → 4 · 캐시 비움 · 로그인 없음 flow · save · calc · logics 401 · 시험판 · 옛 단추 글 0 · resources/master_data 바뀜 0 · 흠(다른 창): 변환 숫자 입력 예시값이 비어 있음(시험입력 · 기본 입력을 안 넣음 — GF000288 나무높이 6 · 집재거리 100 · GF000007 정리량 50 …) · GF000007 「표 QF000033 → 할증」 표에 맞는 줄 없음(서버 477,828 = 할증 25% 빠짐 · 화면 0) · GF000288 다른 로직 GC000270 기계 자리 「EQ:{기계} … 자리」 못 풂(#N/A → 0) · 칸 #N/A 일 때 화면 셈 0 · 서버 셈 그대로(둘이 다름)(sub7) · 식 보기 값 줄이 선 규칙을 지난 값을 안 씀(「(#N/A × 0) = #VALUE!」 · 결과 알약은 34,413.6)(sub2) · 조건 통과 둘째 줄 값 목록 「number:0」 글 · 조건 만들기 만약 목록에 놓인 알약 없음(sub2) · 옛 로직 왼쪽 계 반올림 · 흐름 원가 계금 1원 버림(1원 차이) +- 36 마무리(10-2 02시 · 브레인 실제 검증완료): 전수 변환 대조 시험입력 1,469/1,472(99.8%) · 기본 1,466(99.6%) · 남은 3 = 마스터 빈 단가 · 시험 141 + 변환 시험 초록 · 사본 서버 저장 → 사본만 바뀜 · 다시 열기 흐름 정본 · GF000001 34,414 = 34,414 · GF000003 8.946 = 8.946 · 식 보기 값 줄 · 검증 대기 + +## 37. 일위대가 조합 다이어그램 (사용자 지시 10-2 09시 · 계획 · 착수) + +- 사용자 확정: 「복잡한 경우가 있으니 일위대가 조합도 다이어그램으로 표현 … 중요하게 생각하는 정보가 다르니 잘 구분」 · 조합은 계산하지 않고 로직을 뭉쳐 비용 구분별로 표기 · 입력은 로직별로 묶어 빈 값은 사용자가 넣음 · 출력은 로직마다 · 미리 보기 값은 구조물 조합으로 안 넘김 · 「흐름도는 별도 로직이 아님」(단가산출 로직을 쓰는 방법). +- 모양: 왼쪽 = 로직별 입력 묶음(받아야 할 값 · 빈 값 표시) → 가운데 = 로직 카드(키 · 이름 · 결과 단위 · 식 · 함수 안 보임) → 오른쪽 = 비용 구분 칸 셋(노무비 · 재료비 · 경비 · 로직마다 선으로 모임) + 값 로직은 「값」 칸 · 이름이 같은 입력(지역 · 자재지역 등)은 점선으로 이어 보이기만(검사 없음) · 로직 카드를 누르면 그 로직의 단가산출 로직 화면(흐름도)으로 · 계산 · 고치기 없음(묶음 고치기는 지금 표) · 흐름도와 모양 · 색을 달리함. +- 자리: 일위대가 조합 상세(M02_MasterTemplete_Labor_Edit)의 미리 보기와 같은 값 — 미리 보기 입력 칸에 넣은 값이 다이어그램에도 · 다이어그램 빈 입력 알약을 누르면 미리 보기 그 칸으로 · 미리 보기 결과(로직별 노무비 · 재료비 · 경비 · 계)가 카드 · 칸에. +- [x] 37-1 다이어그램 판 — M02_MasterTemplete_Labor_Diagram.ts(+css) 새 파일 · 고정 세 칸 배치(자동) · 판 옮기기 · 확대 · 한눈에 · 밝음 · 어두움 · 좁은 폭 +- [x] 37-2 잇기 — Labor_Edit 에 「다이어그램」 칸(접기) · 미리 보기와 값 한 벌 · 빈 입력 누름 → 미리 보기 칸 · 로직 카드 누름 → M01 단가산출 로직 그 로직 열기(setPendingLogicKey 꼴) +- [x] 37-3 검증 — 조합 셋(로직 2 · 9 · 14개)으로 입력 묶음 수 = 로직 입력 수 · 출력 = 미리 보기 줄 · 비용 칸 합 = 미리 보기 합 · 빈 값 표시 · 같은 이름 점선 · 로직 열기 · 브레인 실제 검증 · 보고 +- 창: sub2 37-1 · 37-2 · 37-3 + +## 38. 마스터 요소 화면 손질 (사용자 지시 10-2 09:40 · 착수) + +- 사용자 확정: ① 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 상세의 제목 · 단추 · 찾기 라벨 양식 = 단가산출 로직과 같게 ② 재료 컨테이너의 유가 · 전력을 별도 컨테이너로 ③ 단가산출 로직 「막힘만」 체크 — 의미 없으면 지움 ④ 단가산출 로직 컨테이너 「전체」 를 누르면 상세 목록 보기가 안 됨(다른 컨테이너는 됨). +- 브레인 정함: 「막힘만」 = 로직 검사에 걸린 로직만 거르기 · 지금 1,469 중 0 · 목록 줄에 「⛔ 막힘」 표시 · 까닭 말풍선은 이미 있음 → 체크만 지움(표시는 둠). +- [x] 38-1 양식 맞춤 — 일곱 상세 화면 머리(제목 · 단추 줄 · 찾기 칸 라벨)를 단가산출 로직 화면 꼴로 · 공용 단추 · 칸 크기(sub5) · 검증완료 +- [x] 38-2 유가 · 전력 컨테이너 — 왼쪽 패널에 재료 다음 「유가 · 전력」 컨테이너(재료_유가전력.json) · 재료 컨테이너에서는 뺌 · 고르기 · 찾기 · 상세 그대로(sub6) · 검증완료 +- [x] 38-3 「막힘만」 체크 지움 · 38-4 「전체」 목록 보기 고침(sub1) · 검증완료 — 원인: 로직 컨테이너 거름이 목록만 거르고 가운데 화면 전환(onLogic)을 안 부름 → Side 거름 run 에서 로직이면 onLogic · 「전체」 는 찾는 글도 비움 · 화면(5174): 로직 1,469 줄 → 인력 전체 → 로직 「전체」 = 로직 화면 1,469 줄(찾는 글 「굴착」 비움) → 산림품셈 330 → 03장 작업장 관리 10 → 로직 열기 → 「전체」 = 상세 닫힘 · 1,469 줄 · 막힘만 체크 0 · 화면 오류 0 +- 「고친 것 버리기」(사용자 물음 · 확인만): 저장 안 한 고친 것(화면 캐시)을 버리고 파일의 로직을 다시 엶 — 사본 저장은 없음 · 그대로 둠(사용자 결정 기다림) From 9e8a1c1f63a605e72dab66506890f080e92181da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:19:16 +0900 Subject: [PATCH 02/24] auto: 2026-10-04 17:19 (ESD_LAPTOP) --- .../raw/M02_구조물조합개편/사용자요구_1004.md | 50 ++++++++ docs/raw/M02_구조물조합개편/설계_정함.md | 105 +++++++++++++++++ ...가산출_흐름도_조합다이어그램_마스터요소.md | 108 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004.md create mode 100644 docs/raw/M02_구조물조합개편/설계_정함.md create mode 100644 docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004.md b/docs/raw/M02_구조물조합개편/사용자요구_1004.md new file mode 100644 index 000000000..936caa071 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004.md @@ -0,0 +1,50 @@ +# M02 구조물 조합 개편 — 사용자 요구 (2026-10-04 · 원문 그대로) + +1번(계획서 32장 이후 정리)은 브레인이 처리함(c8269a969). 7 · 8번은 원문에 없음. + +## 2. 일위대가 조합 → 구조물 조합 하위 + +M02페이지의 마스터 템플릿 페이지에서 일위대가 조합은 구조물 조합의 하위 컨테이너로 넣을 것.(구조물 도면과 면적산출 사이에 배치) +- 일위대가 조합의 좌측 패널의 기존 보기 방식인 트리구조는 필요없으며 구조물 도면 처럼 리스트형태로 보임 +- 추가로 구조물 도면, 일위대가, 면적산출의 최상위에는 전체항목을 넣어주고 우측 상세에는 리스트로 보여줌, M01 항목을 보면 양식화되어 있는 페이지 헤이아웃 있음 + +## 3. 이름 + +이동한 일위대가 조합은 일위대가로 명칭 변경 + +## 4. 구분선 + +구조물 조합 컨테이너 내부의 서브 컨테이너 항목과 트리구조 사이에 수평 구분선 추가 + +## 5. 사용자별 구조물 조합 읽기 / 쓰기 / 복사 권한 점검 및 개선 + +- 시스템 관리자 : 아이슬로 프로그램 관리자로 마스터를 제공하여 회사관리자나 일반 사용자가 사용하기 쉽게 한다. +- 시스템관리자가 대시보드의 컨테이너 제목의 우측의 회사명을 선택함에 따라 변경한 목적은 해당 회사의 정보를 접근하고나 AS을 목적으로 고객을 도와주기 위함임. 복사항목에는 마스터나 시스템 관리자 회사, 개인으로 들어갈 항목 뿐만 아니라 고객 회사의 특정 폴더로 복사할 수 있는 기능이 있어야함. 수정/생성권한 포함 +- 회사관리자 : 고객으로 본인회사의 양식을 만들거나 개인 프로젝트에 사용할 개인 마스터데이터 관리 +- 회사관리자는 개인버튼을 누를경우 최상위 트리에는 다른 개인들의 정보를 보고 복사하여 개인에게 복사하거나 수정, 생성도 가능해야함. +- 개인은 마스터와 회사는 읽기만, 다른 개인의 정보는 볼수 없음. 트리는 현재를 유지해야함. + +## 6. 구조물 조합 항목 변경 + +- 최상위 구조물 조합에 구조물 도면, 일위대가, 면적산출로만 표현(기존의 재료산출의 사용자가 추가하는 재료들과 값은 엑셀을 사용하지 않고 일위대가의 담은로직 처럼 하위에 별도의 컨테이너로 추가되는 자재항목을 넣어야함. 이때 M01자재목록에서 하나씩 가져오되 수량은 면적산출의 알약으로 값을 싱크하거나 별도의 모달창에서 셀을 지정하게 구현) +- 재료산출 항목은 삭제 +- 선택입력 항목은 삭제(구조물 조합에서는 각 옵션의 상세 구조물에 따라 미리 옵션을 다 지정할 생각임으로 그럴필요 없음) +- 면적산출의 미리보기에서 아래 알약 그룹 행을 하이드/쇼 되게 반영(기본값은 하이드), 단, 면적산출 상세에서는 알약 행 보이기가 기본값으로 +- 면적산출 상세 화면의 우측 패널에서 현재의 일위대가 조합 목록을 검색하는 기능이 아니라 현재 구조물도의 일위대가 담은 로직과 추가될 사용자 지정 재료를 보여줄것, +- 일위대가 상세화면에서는 미리보기(보기만/저장안함) 컨테이너는 각 단가산출 로직에 입력이 필요한 정보들임. 구조물별로 하나씩만들어져야함으로 구조물의 일위대가 항목에 옵션값이 저장되야함. 그리고 이값에 대한 지정은 담은 로직 컨테이너의 항목을 누르면 모달창이 나오고 사용자가 입력 및 변경할수 있게함. (하위에 지정된 항목과 값을 작은 글씨로 표현되게 반영) +- 담은 로직에는 기준 단가를 기준으로 넣었을때 나온 값과 수량산출값을 넣었을때 값에 대한 노무비, 재료비, 경비, 합계를 같이 보여줌. 이때 수량산출은 면적산출의 특정 셀값이나 사용자가 수기로 입력한 값이 될수 있음. 이렇게 되면 미리보기 계산 컨테이너 필요 없음. +- 일위대가의 상세 화면의 순서는 담은로직 > 자재목록 > 다이어그램 순서임. +- 면적산출 산출 상세의 우측 패널에 보여져야하는 결과는 결국 일위대가의 담은 로직컨테이너와 자재목록임. +- 추가된 자재목록에도 기준에 대한 단가와, 면적산출 엑셀의 특정 셀과 연결되었을때 나온 단가가 같이 나와야함. + +## 9. 오른쪽 클릭 + +프로그램 B와 M영역의 페이지들에 마우스 오른쪽 클릭을 할 경우 브라우저 옵션 안보이게 할것.(우리 프로그램의 옵션은 허용, 공통양식 사용) + +## 사용자 답 (10-4 · 일위대가 · 원문 그대로) + +일위대가의 경우 +1. 구조물 조합을 복사한 경우 신규 키를 생성하여 만듬. (값과 로직은 일단 같이 복사) +2. 신규 구조물 조합을 생성하는 경우 미리보기 페이지에는 새로만들기와 복사생성하기를 선택하게 하고 복사 생성인 경우에는 해당 리스트를 보여주고 선택하게 보여주는 값은 개인인 경우에는 개인을, 회사관리자인경우 개인, 시스템관리자인 경우 마스터를 기본값으로 보여주고 탭을 눌러 다른 항목에서 가져올수 있는 항목들을 보여준다. +3. 다시 말해 최종적으로는 구조물 조합에 일위대가, 도면, 면적산출이 연되는게 맞으나 특정항목만 다른 값을 복사하여 가져올수 있어야함. 저장이 된 데이터라도 변경가능해야함. +4. 현재 매칭이 안된 데이터는 나중에 삭제할예정이나 마스터 데이터를 수동 생성을 위해서 필요한 데이터들임. 차라리 실무문서라는 항목을 마스터에 별도로 만들어서 나중에 한번에 지우게 할수 있게 반영. diff --git a/docs/raw/M02_구조물조합개편/설계_정함.md b/docs/raw/M02_구조물조합개편/설계_정함.md new file mode 100644 index 000000000..229b350fd --- /dev/null +++ b/docs/raw/M02_구조물조합개편/설계_정함.md @@ -0,0 +1,105 @@ +# M02 구조물 조합 개편 — 정함 (브레인 · 10-4) + +근거 = `사용자요구_1004.md` · 조사 보고 A~H(각 창 `tmp/조사_39-0X.md`). 사용자가 바꿀 수 있음. + +## 1. 자료 틀 (B · sub7) + +- 일위대가 = 구조물 열마다 `labor/<열id>.json` · 번호 `SU-######`(도번 · 면적번과 같은 뒷자리 · 층마다 셈) · 자리 = 시스템 `resources/master_template/labor/` · 회사 · 개인 `…/templates/labor/` · 새 프로젝트 씨앗도 KINDS 로 함께. +- 문서 꼴: + +```json +{ + "종류": "일위대가", "판": 1, "열": "item1", "일위대가번": "SU-000001", "단위": "개소", + "본뜬": { "층": "system", "주인": null, "키": "UA000008" }, + "담은로직": [ + { "로직": "GF000219", "메모": "", "입력값": { "형식": "메쌓기" }, "수량": null }, + { "로직": "GF000137", "메모": "", "입력값": {}, "수량": { "손": 3 } } + ], + "자재": [ { "코드": "MT000008", "이름": "치즐", "규격": "", "단위": "EA", "수량": null } ], + "계산값": { "줄": [], "자재": [], "기준합": {}, "반영합": {} } +} +``` + +- 이름 = 구조물 열 이름(따로 안 둠) · 단위 = 자기 칸 · 같은 로직 두 번 금지 · 빈 일위대가 저장 됨 · 이름 겹침 검사 없음 · 입력값 = 그 로직 입력 이름만(모르는 이름 버림 · 빈 값 = 자동값). +- 수량 = `null`(면적산출 알약 연결 따름 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」 · 칸 연결 정본 = 면적산출 문서 `연결` 한 곳) | `{손: 수}`(손 입력 · 손이 칸보다 앞섬). +- 금액 = 기준(결과단위 그대로 · 원/10㎡ 면 10㎡ 값 · 단위 같이 표기) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 「원/1,000본」 배 1000(`_PER` 고침) · 자재 기준 단가 = M01 `master_material.element` 기본값. +- 계산 자리 = 기준 ③ 서버(M01 엔진 · calc 길 · 화면은 최신 응답만) · 반영 곱 ① 짝(`M02_MasterTemplete_Labor_Calc.ts` ↔ `M02_Labor_Doc.py` · 두 파일 머리에 짝 · 거울 시험 · 면적 칸 값이 바뀌면 화면이 바로 곱함) · [저장] 때 서버가 `계산값` 을 다시 적음(브라우저 값 버림 · 목록 합계용) · 상세는 늘 새로 셈. +- 길 = 문서 `GET/PUT /api/m02/layers/{층}/templates/labor/{열}`(양식 길 · owner) · 계산 `POST /api/m02/layers/{층}/labor/{열}/calc` {doc?, 수량?: {키: 값}} → {줄[{로직, 분모, 배, 수량, 수량출처, 기준{노무비,재료비,경비,계}, 반영{…}, 까닭}], 자재[{코드, 기준단가, 수량, 수량출처, 반영, 까닭}], 기준합, 반영합} · 알약 `GET /api/m02/layers/{층}/labor/{열}/pills`(로직 물량 + 자재). +- 집계표 열 `일위대가` · `자재조합` 칸 지움(정본 = labor 파일) · SS 장부 줄 = `일위대가번` · `도번` · `면적번`. +- 옮기기(한 번 · `M02_Labor_Migrate.py`) = 층마다 열마다 labor 파일 · 열 `일위대가` 키 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · 수량 null · 자재 []) · 여러 열이 쓰던 조합(UP10-000001)은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 몇 번 돌려도 같음 · 시스템 층(git)은 바로 · storage 층은 압축 사본 뒤 브레인이 돌림. +- 실무문서 = 마스터 `resources/master_data/일위대가조합.json` 통째(37 줄 · 손대지 않음 · 옮긴 뒤 어느 열도 안 가리킴 · M01 일위대가 조합 화면도 그대로) · 회사 · 개인 옛 조합(UC · UP)은 파일 그대로 · 화면에서 뺌. +- 프로젝트 = 스냅샷 `일위대가.json` 은 labor 문서 + 담은 로직 줄 · `입력값.json`(모양 그대로) 첫 값 = labor 입력값 · `단가.json` 에 자재 MT 줄 · 계산은 반영 같은 함수. +- 은퇴(화면이 안 부른 뒤) = 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 · 미리 보기 길(preview) · 일위대가 조합 나무 길. +- 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서(32장 「로직 지우기 서버 검사」 줄 끝남). + +## 2. 좌측 패널 (A · sub5) + +- 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · 구조물 조합 안 차례 = 층 단추 + 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출. +- 하위마다 첫 줄 「전체 항목」(누르면 `onAll(kind)` → H 목록) · 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = `onOpen({kind: "labor", name: 열})`(C 상세). +- 이름 「일위대가」(하위 제목 · 상세 제목 · 대시보드 카드 글) · 해시 `open=일위대가` = 구조물 조합 펼침 + 일위대가 하위 펼침 + 전체 항목. +- 「실무문서」 = 구조물 조합 밖 별도 컨테이너(도면 양식 아래) · 마스터 층 · 시스템 관리자만 · 목록 + 상세 읽기만(새 파일 `M02_MasterTemplete_Practice.ts`) · [통째 지우기](물음 두 번 · 서버 = 시스템 관리자 · 쓰는 곳 0 확인 · 줄을 비움 · 버튼은 사용자가 누를 때만). + +## 3. 일위대가 상세 (C · sub2) + +- UI_Main 종류 `labor` 로 엶(양식 길 읽기 · [저장] · [다시 읽기] · 캐시 = 다른 양식과 같음) · 차례 = 머리 → 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램 · 미리 보기 칸 지움. +- 담은 로직 줄 누름 = 입력 모달(그 로직 입력 전부 · 빈 칸 자동값 · 수량 = 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」 = 면적산출 상세로) · [확인] = 줄 값만(캐시) · 저장은 [저장]. +- 줄 아래 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 빈 입력 「빈 값 n」 붉게. +- 금액 칸 넷(노무비 · 재료비 · 경비 · 합계) · 칸마다 두 줄(위 기준 작게 · 아래 반영) · 맨 아래 합계 줄. +- 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액 · [자재 추가](M01 자재 하나씩 · `M02_Material_Pick.ts`) · 줄 누름 = 수량 모달. +- 다이어그램 = 저장된 줄 값 + calc 답(수량 있으면 반영 · 없으면 기준 · 표기) · 알약 누름 = 입력 모달 · 자재목록 = 카드 하나 → 재료비 칸. +- 보기 갈래(고치기 없음) 부품을 냄 — D 오른쪽 패널 · H 카드가 씀. +- 입력 목록 · 값 받기 부품은 선택 입력에서 옮겨 다시 씀(`M02_Labor_Inputs.py` · `M02_Labor_Values.ts` · `M02_Labor_Values_Model.ts`) · 값 칸 규칙 한 벌(M01 넣을 값) · 로직 입력이 바뀌어 사라진 값은 알림. +- 37장 「미리 보기 값은 구조물 조합으로 안 넘김」 뒤집힘. + +## 4. 면적산출 (D · sub3) + +- 미리보기 카드 알약 줄 켬끔 단추 · 기본 꺼짐 · pref(계정) · 상세는 늘 보임(단추 없음). +- 상세 오른쪽 패널 = 그 열 일위대가의 담은 로직 + 자재목록(C 보기 갈래 · 기준 · 반영) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 지움. +- 패널 줄 누름 = 칸 지정 모달(`M02_MasterTemplete_AreaLink.ts` · [지금 고른 칸으로] · 주소 · [풀기]) → 면적 문서 `연결` · spreadsheet 손잡이 `setLink(키, 칸|null, 항목)`. +- 알약 = 로직 물량 알약 + 자재 알약(`자재|MT키` · 알약 모드 「자재」 다시 씀) · 선택 입력 알약 빠짐. +- 면적 금액 서버 계산은 1장 calc 하나(따로 안 만듦). + +## 5. 목록 · 고르기 · 카드 (H · sub1) + +- 목록 부품 하나 `M02_MasterTemplete_ItemList.ts`(M01 머리 · 쪽 · 표를 import · 옮기지 않음) — 「전체 항목」 · 복사 생성 · 항목 하나 복사가 같이 씀. +- M02 상세 제목 = M01 꼴(18px · 단추 오른쪽) 전부. +- 목록 칸: 도면 = 도번 · 이름 · 폴더 · 단위 · 고친 날 / 면적산출 = 면적번 · … / 일위대가 = 일위대가번 · 이름 · 폴더 · 단위 · 담은 로직 수 · 합계(`계산값` · 없으면 「—」) · 고친 날. +- 「새 항목」 → 상세 칸에 [새로 만들기] [복사 생성] · 복사 생성 = 같은 칸 탭 + 목록 → 지금 보는 층 · 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 일위대가 새 번호. +- 카드 = 도면 · 일위대가 · 면적산출 셋(그 차례) · 빈 카드 = [새로 만들기] [복사 생성] · 찬 카드 = 「복사해 가져오기」(창 · 탭 + 목록 → 확인 → 덮어씀 · 번호 · 열 그대로) · 일위대가 카드 몸 = C 보기 갈래. +- 탭 = 마스터 · 회사 · 개인(나) · 다른 사람(드롭다운 · 맡은 회사일 때) · 실무문서(일위대가 고를 때 · 시스템 관리자) · 기본 = 시스템 관리자 마스터 · 그 밖 개인(회사 없으면 마스터) · 탭 목록 = E `readableSources`. +- 항목 하나 복사 서버 = `copy_from` 에 from_layer · from_owner(B 가 만듦) · 원본이 실무문서 조합이면 조합 → 일위대가 문서(입력값 빈 값). + +## 6. 권한 (E · sub4) + +- 「회사 C 를 맡음」 = 시스템 관리자 · 또는 C 의 회사 관리자 → C 회사 층 · C 모든 개인 층 = 보기 · 만들기 · 고치기 · 지우기 · 복사 대상. +- 일반 = 마스터 · 회사 읽기만 · 남 개인 안 보임 · 나무 그대로 · 프로젝트 apply · sources 도 같은 규칙. +- 사람 나무 = 맡은 회사면 켬 · 회사 사람 전부(나 맨 위 · 빈 사람 「비어 있음」) · 남의 층 첫 쓰기 = 소속 확인 뒤 뼈대. +- 「〇〇로 복사…」 = 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 사람) + 대상 폴더 고르기 창(`M02_MasterTemplete_TreePanel_CopyTo.ts`). +- 남의 층 쓰기 = `system_audit_logs` 한 줄(`M02_OTHER_LAYER_WRITE`). +- 실무문서 = 일위대가 조합 길 전부 시스템 관리자만(서버가 막음). +- 새 길(labor 문서 · calc · pills · 층 넘는 복사)도 `_root` · `_layer_dir` 한 벌을 거침. + +## 7. 지우기 (G · sub6) + +- G1 자재 조합(UM) · G2 재료산출(`material/` · SM · 카드 · 시트 · 옆 판 · 길 · 시험) 먼저 · G3 선택 입력(카드 · `inputs/` · 자리 · 알약 길)은 C 의 입력 모달이 선 뒤. +- `pickMaterial` · `MaterialItem` → `M02_Material_Pick.ts` 로 옮긴 뒤 지움 · 알약 모드 「자재」 · `LinkMaterial`(A00 spreadsheet) 남김 · `고른자재.json` 남김 · 프로젝트 `입력값.json` 남김. +- storage 층(회사 · 개인 · 프로젝트)의 `material/` · `inputs/` · `자재조합.json` 파일은 지우지 않음(읽기만 끊음 · 정리는 사용자) · git 안 시스템 층 · fixture 는 지움. +- 옛 SM 번호 버림 · 장부 `재료번` 칸은 다음 집계표 [저장] 때 빠짐. + +## 8. 파일 주인 (같은 파일 = 몫만 고침 · 받은 뒤 고침) + +| 파일 | 주인 | 다른 창 몫 | +| --- | --- | --- | +| `M02_MasterTemplete_Store.py` · `M02_Template_Layers.py` | sub7 | sub6 = material · inputs 줄 | +| `M02_MasterTemplete_Router_Items.py` | sub4(`_root`) | sub7 = copy 길 from_layer · sub6 = material · inputs 길 | +| `M02_MasterTemplete_Router_Combos.py` · `Combo_Store.py` | sub5(나무 길) | sub4 = 시스템 관리자 문 · sub2 = preview 지움 | +| `M02_MasterTemplete_UI_Side.ts` | sub5 | sub6 = material · matset 줄 | +| `M02_MasterTemplete_UI_Page.ts` | sub5 | sub4 = copyTo · sub1 = onAll 이음 | +| `M02_MasterTemplete_UI_Main.ts` | sub3(면적 길) | sub2 = labor 종류 · sub1 = 제목 꼴 · showView · sub6 = material 길 | +| `M02_MasterTemplete_ItemCards.ts` | sub1(카드 셋 · 빈 카드) | sub3 = 알약 켬끔 · sub6 = 재료 · 선택 입력 카드 | +| `M02_MasterTemplete_Structure.ts` | sub3(pillsFor) | sub1 = pickCopy · sub6 = inputs · material | +| `M02_MasterTemplete_TreePanel.ts`(670줄) | sub1(copyIn · 넘으면 `TreePanel_Clip.ts`) | sub5 = 일위대가 전용 칸 지움 · sub4 = 「〇〇로 복사…」 이음 | +| `M02_MasterTemplete_Labor_*.ts` | sub2 | sub5 = `Labor.ts` · `Labor_Tree.ts` 나무 지움 | +| `M02_MasterTemplete_Api_Fetch.ts` | 각자 자기 함수만 | — | +| `A00_Common/spreadsheet/spreadsheet_links.ts` · `spreadsheet.ts` | sub3 | — | +| 공용 메뉴 · 진입(`main.ts` · `cad_host.ts` · `ui_template_context_menu.*` · `ui_template_tree.*` · `spreadsheet_condition.ts` · `spreadsheet_menu.ts`) | sub8 | — | diff --git a/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md new file mode 100644 index 000000000..fa33578c6 --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md @@ -0,0 +1,108 @@ +# 지난 계획 — 33~38장 (2026-10-01 ~ 10-02) + +단가산출 로직 점검(데스크탑 한시) · 쉬운 단가산출 · 로직 만들기 시험판(세 칸 판 · 시트 로직 · 다이어그램) · 흐름도 시험판 · 흐름도 본 프로그램 적용 · 일위대가 조합 다이어그램 · 마스터 요소 화면 손질. + +## 33. 단가산출 로직 점검 · 수정 (사용자 + 데스크탑 메인 · 보조 · 10-1 한시) + +- 사용자가 데스크탑 메인 · 보조와 직접 진행 · 브레인은 랩탑 sub1~8 로 사용자가 요청하는 다른 일을 맡음(10-1 18:00 무렵까지). +- 근거 = `docs/raw/M01_점검/종합/` · `docs/raw/M01_결정목록/` · `docs/raw/M01_입력목록/` · `docs/raw/M01_고침/`. + +## 34. 쉬운 단가산출 제어 (새 길 · 10-1 · 랩탑) + +- 사용자 확정(10-1 오전): 만든 쉬운 단가산출은 유지(지우지 않음) · 로직 정의 자체를 만드는 판은 제안만 · 허락 뒤 착수 +- 사용자 확정: 지금 단가산출은 그대로 · 「추가」 단추 옆 새 단추로 따로 들어감 · 코드는 `M01_MasterData/EasyCalc/` 한 곳(안 되면 폴더째 지움) · 초등학생도 추가 · 삭제 · 수정 · 시간이 걸려도 검토 먼저. +- 정함(브레인): 새 길은 지금 로직 형식을 그대로 읽고 씀(계산 엔진 안 바꿈) · 폴더 밖 참조는 단추 자리 한 곳만. +- [x] 34-1 검토 — 데이터 · 계산 지도(sub4) · 지금 화면 걸음 재기(sub6) · 실무 모양 + 갈래 비교(sub7) · 지난 시도(sub8) → `docs/raw/M01_쉬운단가/` · 지금 화면 새 로직 38 누름 · 5~6 창 +- [x] 34-2 설계 확정(브레인) — 문장 카드 · 품셈 기본은 「이걸 바탕으로 고치기」 · 쓰기는 `/logic/*` 만 · 1단계 숫자 수량 + 잠금 카드 → `docs/raw/M01_쉬운단가/설계.md` +- [x] 34-3a 단추 + 빈 창(sub1 · 「식으로 새로 만들기」 옆) 검증완료 +- [x] 34-3b 1단계 — Model(sub7) · Api(sub4) · Cards(sub2) · Pick(sub3) · Sheet(sub5) · 틀 · 잇기(sub1) — 설계.md 2 · 3절 · Sheet 검증완료 · Model 검증완료 · Api 검증완료 · Pick 검증완료 +- [x] 34-3c 2단계 — 표 보고 고르기(sub8) · 조건 고치기(sub7 · 조건 줄 93% 읽음) 검증완료 · 카드 단추 잇기(sub2) · 조건 고치기 검증완료 +- [x] 34-4 검증 — 대표 일 여섯 + 예외(빈 값 · 같은 이름 · 저장 안 하고 닫기 · 권한 없음 · 밝음 · 어두움) · 지우기 시험(폴더 밖 참조 줄 수) · 걸음(sub3 고침 뒤) ①21 ②8 ③2 ④4 ⑤2 ⑥3 = 40(목표 36 넘음 4 · ① 새 설계 값 자동값으로 채워 금액 곧장 보임) · 다른 일위대가 넣고 저장 됨 · 권한 없음(일반 사용자) = M01 화면 못 들어감 · /api/m01 403 · 폴더 밖 참조 두 줄 · 검증완료 + +## 35. 단가산출 로직 만들기 판 (제안 단계 · 착수 전) + +- 사용자 확정: 쉬운 단가산출처럼 따로 폴더 시험판 · 로직 몇 개 골라 만듦 · 페이지 보고 판단(마음에 안 들면 지움) · **착수는 허락 뒤** · 인터넷 조사 먼저 · 화면 용어는 단어로 다시 제안. +- 제안서 https://claude.ai/artifact/U4rkgpYWL4Em5VKBEVa4oY (세 칸: 원문 표 · 산출 과정 · 결과) +- [x] 35-0 인터넷 조사 — 견적 프로그램(sub6) · 노코드 규칙 도구(sub8) → `docs/raw/M01_쉬운단가/로직판_조사_*.md` · 제안서 2판에 반영 +- [x] 35-1 용어 단어 제안(제안서 2판 「용어 — 단어로」) → 사용자 확정 · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-2 시험판 — 사용자 허락(10-1 · 갈래가 여럿이면 갈래마다 · 목적 = 코딩 없이 원문 보며 확인 · 수정 · 생성) · A 세 칸 판 `LogicBuilder/` · B 엑셀 시트형(우리 스프레드시트) · 먼저 조사: 엑셀 로직 지도(sub4) · A 설계 초안(sub7) → 브레인 A 대 B 판단 → 구현 · 완료 처리(10-4): 35-2a 로 함 +- [x] 35-2a A 세 칸 판(가운데 문장 / 격자 두 보기) — 정함 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · Blocks(sub7) · 서버 · Api(sub4) · 원문 · 검사(sub8) · 문장(sub2) · 격자(sub5) · 단계 안내(sub3) · 틀 · 잇기 · 끝 검증(sub1) · LB 서버 검증완료 · Blocks 검증완료 · Grid 검증완료 · Steps 검증완료 · Source · Check 검증완료 · Wizard 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 원문 대조 6/6 블록 읽힘 · 걸린 줄 · 출처 보임 ② GF000219 표 칸 0.15→0.20 = 79,280→96,759 원(예상과 같음) · 파일 안 바뀜 · 다시 열면 0.15 ③ 단계 안내 11 걸음 + 저장 → 현장 다시 열림 60,984 원(/calc 같음) → 삭제 2 걸음 ④ 빈틈 해당 없음 10 · 못 따진 표 조회 1 · 기대값 시험표 맞음/다름 · 화면 초안만 ⑤ 표 고침 없을 때 lb/calc = /calc 8/8 · 문장 ↔ 격자 같은 값 · 예외(빈 이름 · 저장 안 하고 닫기 · Ctrl+Z · Ctrl+S · 권한 없음 403 · 밝음 · 어두움) · 폴더 밖 참조 LogicLab.ts 두 줄 + main.py 등록 한 줄 +- [x] 35-2b 남은 것(사용자 판단 뒤) — 문장 / 격자 고르기 · 표 칸 고침 저장 여부 · 적용 품셈 판 · 용어 · 칸 역할 「결과」 바꾸기 · 단계 안내 고르기 창 수량 물음 · GX000001(메인 11:53 · 12:02) 주인 확인 · 보고 https://claude.ai/artifact/NqKfHaJULcQo6dSvnsxy1n · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-3 점검(확인만) — 표끼리의 관계(로직) 자체를 초등학생이 만들고 · 바꾸고 · 지울 수 있나 · 품셈에 없는 관계 더하기 · 시나리오 여섯(sub6) — 결과: 있는 관계의 값만 고침(초등 중) · 관계 더하기 · 지우기 · 다시 잇기 · 새 입력값 · 새 표 · 로직끼리 바꾸기 안 됨 · 문턱만 바꾸면 계산 멈춤 + 엉뚱한 알림 · `docs/raw/M01_쉬운단가/로직판_관계제어_점검.md` +- [x] 35-4 다이어그램 제안(사용자 생각 10-1) — 상자 = 표 · 입력값 · 고정값 · 계산 · 항목 · 다른 로직 · 결과를 직접 다룸 · 선 = 관계를 만듦 · 품셈에 안 묶임(사용자 표 · 사용자 관계) · 관계 제어 = 다이어그램(사용자) · 위 가로 띠 + 아래 고른 상자 다루기 · 결과 · 관계 = 진짜 엑셀 수식(칸 주소 · 함수 · 사용자 확정) · 알약 = 입출력 칸 표시 · 새 종류 「시트 로직」(TS 엔진 한 벌 · 서버 Node) · 제안서 https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 허락 전 착수 금지 · 완료 처리(10-4): 35-5 · 35-7 로 함 +- [x] 35-5 시트 로직 시험판(사용자 허락 10-1 오후) — 위 가로 다이어그램 · 상자 = 시트 · 관계 = 엑셀 수식 · 입출력 = 알약 · 저장 = `M01_MasterData/SheetLogic/_data/`(git 밖 · 폴더째 지움) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` · Model · Eval(sub7) · 서버 · Api(sub4) · Diagram(sub8) · Panel(sub2) · Table(sub3) · Item · Result(sub5) · Import(sub6) · 틀 · 잇기 · 끝 검증(sub1) · SL 서버 검증완료 · Model 검증완료 · Diagram 검증완료 · Item · Result 검증완료 · Table 검증완료 · Import 검증완료 · 잇기 · 끝 검증완료(sub1) — 걸음: S1 ①②③ 각 3(79,280 · 73,085 손셈 같음 · 2.5m 문턱은 품셈 표 3m 부터라 2.5~3m 해당 없음) · S2 ① 3 ② 4(찰쌓기 표로) ③ 5 · S3 ① 약 19(경사 급 65,018) ② 9(내 표 붙여넣기 · 할증 10) ③ 7(잡재료 2% 17,551) · S4 선 끊기 2 · 상자 지우기 2(물음 한 번) · Ctrl+Z 되돌림 · S5 0(상자 · 선 · 값 한 장 · 고르면 이어진 선 진하게) · S6 항목 일위대가 11 · 다른 로직 상자 13(덤프트럭 16.2) · 새 로직(품셈 없이) 25 걸음 → 저장 서버 36,000 = 화면 → 다시 열림 → 지우기 2 · 서버 = 화면 4/4(36,000 · 17,550.9 · 10.12 · 다시 열기) · 가져오기 6/6 /calc 같음(0 · 60,984/79,280 · 2,064,816 · 670 · 16.2 · 57,516) · 예외(빈 이름 · 같은 상자 · 순환 · 빈 입력 물음 · 저장 안 하고 닫기 · 권한 없음 sl 5/5 403 · 밝음 · 어두움) · 세 칸 안쪽 세로 밀기 없음(6개 모든 상자) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 남은 흠(다른 창): 가져온 표 상자 + 결과 칸 목록 예시값이 글자로 들어가 30 등에서 해당 없음(sub5) · 가져온 로직 화면 번호 · 자재 검색어 · 끝 검증완료(S1~S6 · 새 로직 · 서버 = 화면 4/4 · 가져오기 6/6) · 남은 흠: 결과 칸 예시값 선택이 글자 「30」 으로 들어가 표 찾기 해당 없음(sub5 · 사용자 지시 뒤) +- [x] 35-5a 사용자 요청 — 다이어그램 띠 · 고른 상자 시트 · 결과 높이를 안쪽 세로 스크롤이 없을 때까지(sub1) +- [x] 35-6 로직 문법 먼저(사용자 방향 10-1 14시 무렵 · 화면보다 로직) — 바탕 표(인력 378 · 재료 30,767 · 기계 654 · 소요량 2,535 · 계수 498 · 환율 · 요율 · 사용자 표 — 보기 · 만들기 · 비목은 표가 정함) · 단위 넷(값 · 표 · 정보 · 요소) + 묶음(세분화 · 묶기 · 분류 · 묶음째 잇기 · 겹 묶음 · 틀 다시 쓰기) · 흐름 셋(정보 만들기 · 잇기[그대로 · 바꿔서 · 갈라서 · 모아서 · 묶음째] · 내기) · 확인 도구 · 제안서 https://claude.ai/artifact/BFWvm1Q4fhCSZ4Rt34UgYY · 다음 = 문법 확정 → 종이 연습(절 10개) — 허락 전 착수 금지 · 완료 처리(10-4): 35-7 · 35-9 흐름도로 갈음 +- [x] 35-5b 단추 공용 양식(점검 끝 · 고침은 사용자 지시 뒤) — 날 단추 23(목록 줄 8 · 칩 · 역할 · 보기 바꿈 11 · ▾ 2 …) · sm 단추를 44px 로 덮기 3 · 큰 시작 56px 3 · `docs/raw/M01_쉬운단가/시험판_단추_점검.md` · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-7 단가산출 다이어그램 시험판(사용자 기준 10-1 14시 무렵) — 입력 · 함수 · 출력 상자 · 자유 배치 · 함수 = 엑셀 · 출력 하나 · 출력 = 비용 종류 · 원가 · 폴더 `M02_MasterTemplete/CostFlow/`(_data · _sample) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` · Model · Eval(sub7) · 서버(sub4) · Diagram(sub8) · 함수 판(sub2) · 입력 판(sub3) · 출력 · 원가(sub5) · 견본(sub6) · 단추 · 페이지 · 잇기 · 끝 검증(sub1) · Model 검증완료 · Output · Summary 검증완료 · Sample 검증완료 · FuncPanel 검증완료 · Diagram 검증완료 · CF 서버 검증완료 · InputPanel 검증완료(사용자 요소 만들기는 서버 Api 가 main 에 오면 확인) · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 손으로 메쌓기 꼴(변수 4 · 소요량 표 2 · 인력 2 · 함수 증가율 → 석공 / 보통인부 병렬 · 출력 노무비 2) 9 상자 · 약 138 손놀림 → 원가 148,471 = 견본(증가율 40 · 석공 97,882.512 · 보통인부 50,587.992 손셈 같음) ② 비용 종류 바꾸면 148,471 이 노무비 → 경비 → 재료비 로 옮겨감 · 「값」 이면 원가에서 빠짐(각 1 걸음) ③ 상자 끌어 옮김(1520,190 → 1675,306) · 저장 → 다시 열면 그대로 ④ 함수 출력 하나(다른 칸 고르면 옮겨감 · 알약 하나) · 순환 막힘 · 출력에서 나가는 선 없음 · 빈 받는 자리는 저장 때 물음 ⑤ 사용자 요소(시험 보조공 200,000) 5 걸음 · 마스터 그대로 · _data 에만 ⑥ 저장 때 서버 = 화면 2/2(148,470.504) ⑦ 단위 오른쪽 위(- → 원/㎡) · 색 바꾸기(Entry 판 머리) · 「다이어그램」 이름 · 다이어그램 726px · 안쪽 스크롤은 함수 격자(가상 200줄)만 · 밝음 · 어두움 · 권한 없음(cf 길 6/6 403 · 화면 못 들어감) · 견본 셋 = /calc · 손셈(148,471 · 3,000 · 15.552) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 끝 검증완료(손으로 메쌓기 9 상자 = 견본 148,471 · 비용 종류 옮김 · 자리 저장 · 규칙 · 사용자 요소 · 서버 = 화면 · 견본 셋 손셈) · 함수 출력 기본 이름 겹침 고침(sub7 2be767d18) · 남은 것: M01 단가산출 단추 줄이 셋째 줄까지(시험판 단추 넷 · 사용자 결정) +- [x] 35-7a 브레인 실제 검증(10-1 16:45) 흠 여섯 고침 — ① 고칠 것 칸 지난 경고 · ⑥ 시험판 단추 「시험판 ▾」 로 묶기(sub1) · ② 알약 쉼표 · ③ 표 알약 글 · ⑤ 맞춤 확대 90% 아래 안 감(sub8) · ④ 입력 상자 공통 단위(sub3) · 다시 검증완료(16:55 · 고칠 것 칸 비어 있음 · 알약 쉼표 · 표 · 40줄 · 인력 원/일 · 확대 90% · 막대 한 줄 · 시험판 ▾ 넷 · 원가 148,471) + +## 35-8. 단가산출 다이어그램 2판 — 재료 선택 자리 · 선 규칙 (계획 · 허락 전) + +- 사용자 확정: 고르는 방식은 재료만(다른 그룹은 상자를 늘림) · 설계 때 입력은 오른쪽 아래 검증 칸 · 나중에 프로젝트에서 일위대가로 쓸 때 재료는 목록에서 고르거나 단일 값 · 표에 따라 출력을 가르는 것은 엑셀 수식 대신 선 규칙. +- 정함(브레인): 재료 요소 = 고정 · 선택 자리 · 조건 찾기 · 알약 이름 = 자리 이름 · 선 규칙 = 선 하나에 하나(표에서 찾기 · 조건 통과 · 바꾸기) · 규칙은 받는 칸 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임. +- [x] 35-8a 틀(Model) — 재료 요소 방식 · 범위(구분 · 상세구분 · 찾는 말) · 조건(규격 ← 알약) · 기본 품목 · 선 규칙 칸 · 규칙 → 받는 칸 수식 · 조건 선 겹침 · 안 맞음 검사 — node 시험(sub7) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8b 서버 — 선택 자리 기본 품목 · 조건 찾기 단가(맞음 0 · 여럿 알림) · 「설계 때 정할 것」 목록(변수 + 재료 자리) 내보내기 — 시험(sub4) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8c 입력 판 — 재료 방식 · 범위 · 조건 규칙 · 기본 품목 · 찾기 고침(쪽 넘기기 · 띄어쓰기 AND · 규격까지) — ORCA(sub3) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8d 검증 칸 — 재료 선택 자리를 변수처럼(범위 안 목록 고르기 · 찾기) · 조건 자리는 잡힌 품목 표시 — ORCA(sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8e 선 규칙 — 선 위 규칙 글 · 선 누르면 아래 판에서 규칙 고르기(표 찾기: 조건 칸 ↔ 알약 · 꺼낼 칸 / 조건 통과: 알약 · 같음 · 다름 · 이상 · 이하 · 값 / 바꾸기: × · ÷ · +% · 단위 · 끝수) · 고른 상자 판에 들어오는 선 규칙 목록 — ORCA(sub8 · 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8f 견본 · 끝 검증 — 견본 둘 더(지반에 따라 굴삭기 / 브레이커 가르기 · 흄관 관경 조건 찾기) · 사용자처럼: 함수 상자 없이 표 찾기 · 가르기 · 검증 칸에서 재료 고르기 · 방법 걸음 수(sub6 견본 · sub1 잇기 · 끝 검증) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8g 다이어그램 마우스 — 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자가 선택되지 않음(다이어그램 영역 글자 선택 막음) · 상자 옮기기는 상자 머리 왼쪽 끌기 그대로 — ORCA(sub8) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8h 「고른 상자」 → 「상세 내용」 · 상자뿐 아니라 알약 · 선을 골라도 내용이 바뀜(알약 = 값 · 단위 · 어디서 오나 · 어디로 가나 / 선 = 선 규칙 고르기) — ORCA(sub1 잇기 · 알약 · 선 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8i 상세 내용 단추 · 칸 크기 — 함수 판 꾸밈이 공용 작은 단추를 44px 로 덮는 줄(`M02_CF_FuncPanel.css` `.m01-cfpanel .ui-btn …`)을 지우고 판 안 단추를 모두 공용 작은 크기(createButton size "sm")로 · 입력 · 고르기 칸도 공용 크기 — ORCA 수치(sub2 · sub3 · sub5 각자 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8j 상세 내용 머리 — 왼쪽 「상세 내용」 · 오른쪽에 고른 상자 제목 · 알약을 골랐으면 그 오른쪽에 알약 이름(예 「높이 증가율 찾기 › 증가율」) · 상세 내용 안 칸 차례 정리(머리 · 상자 설정 · 본문 · 경고) — ORCA(sub1) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8k 단위는 고르기 — 단위 칸을 목록 고르기로(치면 걸러져 보이고 고르기는 사용자 · 손으로 쓴 글은 안 받음) · 목록 = 단위 표준(35-8l) 단위 + 마스터 결과단위 + 금액 단위(원) · 「-」 = 없음 · 함수 · 출력 · 변수 · 사용자 요소 단위 모두 같은 고르기 — 공용 고르기 파일 `M02_CF_UnitPick.ts`(sub2) · 판마다 바꿔 끼움(sub2 · sub3 · sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8l 단위 표준 표 — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` · 종목 약 70 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③)을 표로 — 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 흠은 사용자가 고침(시험판에서 보고 고치는 표 · 저장 `CostFlow/_data` · 처음 값 `CostFlow/_standard/`) — 시험(sub6) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8m 금액 단위 표준 — 같은 절 「금액의 단위표준」(설계서 총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 모두 미만 버림)을 같은 표 자리에 — 출력 상자(비용) 금액 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림 · 바꿀 수 있음(sub6 표 · sub5 출력 · 원가) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8n 끝수 고르기 — 함수 상자 출력 · 출력 상자에 「끝수」(소수자리 · 반올림 / 올림 / 버림) · 단위를 고르면 단위 표준에서 소수자리를 제안(주 ① 반올림 · ② 항목 값이 우선) · 고른 끝수는 출력 칸 수식(ROUND 계열)으로 — 엔진 한 벌(sub7 틀 · sub2 함수 판 · sub5 출력 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8o 로직끼리 잇기 · 흐름 따라가기 — 저장한 다른 로직을 입력 상자로 불러 그 출력 알약을 받음(일위대가 → 일위대가) · 알약 · 선을 고르면 값이 어디서 와서(출처 사슬) 어디로 가는지(가는 곳) 다이어그램에서 길을 칠하고 상세 내용에 줄로 · 값이 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건) 표시 — 시험 · ORCA(sub7 틀 · sub8 다이어그램 · sub1 상세 내용) · 완료 처리(10-4): 35-9 에서 함 +- 차례(사용자 10-1 · 「데이터의 흐름과 값의 연결에 집중」): **1단계 = 흐름 · 연결** a 틀 · b 서버 · e 선 규칙 · o 로직끼리 · 흐름 따라가기 · h 알약 · 선 고르기 · c · d 재료 자리 · f 견본 · 끝 검증(흐름 기준 — 값이 어디서 와서 어떤 규칙을 지나 어디로 가는지) / **2단계 = 값의 뜻 · 보기** k 단위 고르기 · l 단위 표준 · m 금액 표준 · n 끝수 · j 머리 · g 마우스 · i 단추 + +## 35-9. 단가산출 흐름도 시험판 (사용자 지시 10-1 17시 무렵 · 브레인이 끝까지 · 35-8 전체) + +- 사용자 확정: 브레인이 전체 제어해 새 시험판 하나 · 단가산출 다이어그램(시험)만 남기고 옛 시험판 셋 삭제 · 새 폴더(필요 없으면 바로 지움) · 데이터 흐름 · 값 연결 먼저 · 자유도 높고 이해하기 쉬운 화면 · 끝까지 구현 · 검증. +- 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md` · 폴더 `M02_MasterTemplete/DataFlow/` +- [x] 35-9 창 여덟 — 옮기기 · Model · Eval(sub7) · 옛 시험판 지우기 · 들어가는 길 · 상세 내용 머리 · 잇기 · 끝 검증(sub1) · 서버(sub4) · 다이어그램(sub8) · 상세 내용 판 · 단위 고르기(sub2) · 입력 판(sub3) · 출력 · 검증 칸(sub5) · 표준 표 · 견본(sub6) · 표준 · 견본 검증완료 · Model 검증완료 · 출력 · 검증 칸 검증완료 · Diagram 검증완료 · 입력 판 검증완료(서버 자재 찾기 · UnitPick 이 main 에 오면 다시 확인) · DF 서버 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 함수 상자 없이 네 흐름 73 걸음: 재료 조건 찾기(관경 → 흄관 자리 · 13 개 중 하나 · 125,870) 21 · 선 규칙 표에서 찾기(높이 증가율표 → 출력) 19 · 조건 통과 두 갈래(지반 = / ≠ 토사) 27 · 다른 로직 잇기(견본 지반 · 지반 알약 이음) 6 ② 알약 · 선 고름 → 온 길 · 가는 길 칠함 + 상세 내용 줄(지반 → 다른 로직 · 두 조건 선 · 흄관 ← 관경) ③ 끊긴 곳 넷 보임(빈 받는 자리 · 통과 안 됨 · 표에 맞는 줄 없음 · 맞는 재료 없음 — 알약 · 선 「!」 · 끊긴 곳 줄 누르면 그 자리 고름) ④ 검증 칸 관경 600 → 800 = Φ800 품목 215,530 · 지반 토사 → 암 = 내 두 갈래 뒤집힘 + 다른 로직 2,000 → 10,000 · 다른 로직 설계 값 바꿈 10,000 → 20,000 ⑤ 단위 고르기 「mm」 = 3 개로 걸러짐 · 손글 안 받음 · 끝수 80,000.37 = 기본 225,870 / 1원 올림 225,871 · 표준 표 고침 저장(3 → 2) → 처음 값으로 되돌림 ⑥ 가운데 끌기 = 판 옮김(−60, −30) · 왼쪽 끌기 = 판 그대로 · 글자 선택 막힘 · 단추 18px 모두 sm · 칸 30px ⑦ 저장 → 다시 열기 = 상자 9 · 선 6 · 자리 · 규칙 글 그대로 · 서버 다시 셈 = 화면 187,910 · 견본 다섯 사본 저장 서버 = 화면(148,470.504 · 2,000 · 84,680 · 3,216.6 · 값 15.552) · 밝음 · 어두움 · 로그인 없음 df 길 6/6 401(관리자 아닌 계정 403 은 계정 없어 못 함) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 검증 중 고친 내 흠(047f3a279 · 28469de62): 재료 조건 찾기 · 선택 자리 값을 서버에 안 물음(흄관 견본 관경 600 → 400 = 211,700 → 116,600 · 1m 84,680 → 46,640 · 1000 = 235,120 · 선택 자리 Φ600 → Φ400 = 211,700 → 116,600) · 「상자 이름」 칸(입력 · 함수 · 같은 이름 · 빈 이름 막음 · 출력은 출력 판) · 머리 이름 · 단위 칸 작은 크기(30px) · 「새로 만들기」 단추 76px(칸 폭 710 아님) · 표준 표 모달 제목 한 번 · 남은 흠(sub2): 선 규칙 표에서 찾기 조건 칸에 범위 이름(「높이」)이 없어 범위 찾기를 화면에서 못 만듦(높이 5 → 줄 없음 · 4 만 맞음) · 브레인 실제 검증완료(흠 14 고침 · 흐름도 시험 81 · 견본 다섯 서버 = 화면 · 보고 JgfqRzLPYgqXG7vB2cV1Yj) · 검증 대기 + +## 36. 단가산출 흐름도 본 프로그램 적용 (사용자 지시 10-1 21:40 · 결정 반영 22시 · 착수 승인 22:20 · 밤새 진행 · 아침 보고) + +- 사용자 확정: 흐름도 = 최종 · 로직 목록에서 로직을 열면 지금 내용 그대로 + 오른쪽 흐름도 패널(같은 로직) · 「식으로 새로 만들기」 지우고 「신규 로직」(흐름도 새로 만들기) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽) · 시험판 단추 · 입구 지움 · DataFlow 폴더 유지. +- 근거(마스터 로직 57 파일 · 1,472 로직 · 입력 3,518 · 중간 식 3,168 · 호표 줄 5,265): 식 말 = 찾기(표 · 조건 · 칸) 8,152 · 만약 3,532 · 또는 185 · 최대/최소 173 · 올림/반올림/버림 141 · 로직() 41 → 흐름도 표에서 찾기 · 조건 · 함수 · 다른 로직 입력으로 옮길 수 있음. +- 사용자 결정 D1(그대로): 「품셈원문도 제대로 구현된게 아니기 때문에 내가 흐름도까지 만들어서 로직을 볼려는 거잖아. 그러니 당연히 둘다 같은 내용 … 원문과 대조하면서 검증할때 잘못된게 있으면 흐름도에서 수정할거야」 → 한 로직 · 두 보기: 왼쪽 지금 내용 = 보기 · 고치기는 흐름도(공용 로직 포함 · 시스템 관리자) · 흐름도로 저장한 로직은 흐름도가 정본 · 왼쪽은 흐름도에서 만든 같은 꼴 · 아직 저장 안 한 로직은 지금 내용에서 자동으로 그린 흐름도(값 대조로 같음). +- 사용자 결정 D2(그대로): 「흐름에 대한 로직은 costflow폴더에 있을테니 일단은 그냥 두고 쓸대없는 견본과 버튼만 삭제」 → CostFlow 폴더 그대로 · 시험판 단추 · 견본 목록 지움(흐름도는 DataFlow 폴더만으로 돌아감 · CostFlow 를 부르지 않음). +- 사용자 결정(함수 상자): 「함수박스를 기준으로 하나의 결과가 나가는게 맞는것같아. 여러개가 동시에 나가면 혼란만 가중」 → 함수 상자 하나 = 결과 알약 하나(그대로) · 식은 상자 겉에 넣지 않음(상자가 커짐) · 함수 상자 · 선을 고르면 상세 내용에 사칙연산 · 조건이 보이고 고치면 바로 따라감. +- 칸 연결 = M02 방식 그대로(칸을 고른 채 알약을 눌러 하나씩 연결 · 식은 칸 주소 · `spreadsheet_links.ts` 직접 확인) · 계산 엔진 · 이름 풀기 · 이름 규칙 없음 — 보기만 칸 주소를 연결된 알약 이름으로 바꿈. +- 사용자 결정(조건): 「조건문은 현재를 유지하는게 보기 편한것같고 대신 어떤값을 지정할지는 입력된 리스트에서 선택 … 조건 값을 내가 넣으면 오기 … 복수 조건문도 넣어주고」 · 「조건 만들기도 엑셀 기능 내부에서 존재해야해」 → 선 조건은 지금 웹앱 모양 · 조건 값은 고른 알약이 가질 수 있는 값 목록에서 고름(변수 고르기 · 표 칸 값 · 재료 규격 등 · 목록이 없는 수는 숫자 칸 + 범위 검사) · 조건 여러 줄(그리고 / 또는) · 함수 상자 엑셀 시트 안 「조건 만들기」(칸 고름 → 만약 · 아니면 만약 · 아니면 줄 → 그 칸에 IF 식 · 값은 같은 목록에서 고름). +- 안전: 화면 시험 저장은 마스터 사본 폴더에서만(서버가 환경 변수로 사본 폴더를 씀 · 실제 resources/master_data 는 안 바뀜 · git status 로 확인). +- 창: sub2 36-0(판 · 조건 만들기) · sub7 36-0b(조건 틀 · 값 목록) → 36-2 · sub6 36-1 → 36-7 · sub8 36-3 · sub4 36-4 서버 · 사본 폴더 · sub5 36-4 왼쪽 보기 · sub1 36-5 · 잇기 · 36-8 · sub3 36-6 +- 브레인 정함(조사 뒤 · 아침 보고에 알림): 옛 로직 「기계 · 직종 · 유가 자리」(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 입력값으로 요소를 고름 · 71 로직)는 새 모드 없이 이미 있는 「조건 찾기」(이름 · 키 같음 · 변수 알약)를 인력 · 기계 · 유가 요소에도 씀 · 대조 입력 = check_master.시험입력 · 로직 셈을 부르는 곳(Store.calc · text · master_combo.run · M02_Item_Prices.calc · /logic/auto)은 한 곳에서 흐름 갈래. +- [x] 36-0 함수 상자 안 보기 · 칸 연결 조정(36-2 보다 먼저) — ① 상세 내용 「식 보기」: 결과 칸 식을 칸 주소 → 연결된 알약 이름으로(예: 1m 재료비 = 흄관 단가 ÷ 2.5) + 값 들어간 식(= 211,700 ÷ 2.5 = 84,680) · IF 는 만약 / 아니면 줄 · 지금 타는 갈래 표시 · 중간 칸 식도 차례대로 · 칸 식을 고치면 바로 다시 셈 → 다이어그램 · 원가 따라감 ② 「칸 연결 표」(칸 · 연결 알약 · 입력 / 결과 · 이어진 선 · 표에서 바로 바꿈 · 풀기) + 칸 색(입력 파랑 · 결과 초록 · 상자 색과 같음) · 결과 칸 하나 ③ 선을 고르면 그 선 규칙도 같은 「식 보기」 꼴(예: 굴착량 → 지반 = 토사 일 때만 → 굴삭기로 파기) ④ 엑셀 시트 안 「조건 만들기」 — 칸을 고르고 열면 만약 [알약] [비교] [값 고르기] → [알약 · 값] 줄 · 아니면 만약 더하기 · 아니면 → 그 칸에 IF 식(엑셀로 쓴 IF 도 같은 줄로 다시 열림 · 못 바꾸는 식은 식 그대로) · 검증완료(sub2 · 식 보기 · 칸 연결 표 · 조건 만들기 · 선 조건 여러 줄 · 시험 8) · 검증 대기 +- [x] 36-0b 선 조건 손질 — 조건 값 = 고른 알약의 값 목록에서 고름(Model 값 목록 함수 · 변수 고르기 · 표 칸 값 · 재료 규격 · 목록 없는 수는 숫자 칸) · 조건 여러 줄(그리고 / 또는) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 위 이름표 · 겹침 · 안 맞음 검사 그대로 · 검증완료(sub7 · 조건 여러 줄 그리고/또는 · 값 목록 · 서버 = 화면 · 시험 30) · 검증 대기 +- [x] 36-1 조사 — 옛 식 문법 전수(서버 파서 · 요소 부르기 꼴 · 따옴표 글) · 변환 못 하는 꼴 목록 · /calc 를 부르는 곳 · 검증완료 +- [x] 36-2 변환 — 옛 로직(입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수) → 흐름도(입력 상자 · 표 알약 + 표에서 찾기 · 함수 · 출력 상자) + 자동 배치 · 서버 파서로 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(만약 → IF · 최대 → MAX · 찾기 → 표에서 찾기) · 함수 상자 하나 = 결과 하나 · 전수 대조(변환 흐름도 값 = 옛 계산 값 · 맞는 비율 · 안 맞는 까닭) · 검증완료(sub7 · M02_DF_Convert · 전수 1,472 시험입력 96.7% · 기본 입력 97.4% · 안 맞는 49 = 재료 검색어 자리 18 · 재료 빈 검색어 13 · 표 범위 규칙 경계 15 · 단가 빈 품목 3 · tmp/convert_report_36.json) · 검증 대기 · 마지막 대조: 시험입력 · 기본 입력 모두 1,472/1,472(100%) · 빈 단가 = 옛 셈 뜻(그 값 쓰는 식 빈 값 · 비목 합 0 · 끊긴 곳 「단가 없는 품목을 씀」) +- [x] 36-3 오른쪽 패널 — 로직 상세 오른쪽에 흐름도(다이어그램 · 상세 내용 · 원가 · 검증) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면) · 검증완료 +- [x] 36-4 저장 · 정본 — 로직 줄에 「흐름」 칸(화면 [저장] 때만 씀) · 흐름이 있으면 /calc 가 흐름도로 셈(서버 다시 셈) · 왼쪽 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산은 흐름도에서 만든 같은 꼴(보기) · 공용 로직도 흐름도로 고침(시스템 관리자) · 캐시 → [저장] 한 번 · [고친 것 버리기] 함께 · 왼쪽 보기 검증완료(sub5) · 서버 검증완료(sub4) +- [x] 36-5 단추 — 「식으로 새로 만들기」 지움 → 「신규 로직」(빈 흐름도 + 기본정보 · 저장 = 자체 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(흐름도도 복사 · 신규 로직 오른쪽) · 「시험판 ▾」 지움 · 잇기 검증완료(sub1 · accf8ecc4 · abbf61971 · 0d45014e3) — 「식으로 새로 만들기」 · 시험판 ▾ · 시험판 단추 둘 지움 · 단추 차례 ← 로직 목록 · 신규 로직 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장 · 두 칸(LogicLab_Flow) · 오른쪽 패널(흐름 받기 · 고침 → drafts → [저장] 한 번 · 버리기 함께 · 흐름 로직이면 왼쪽 흐름 보기) · 신규 로직 기본정보 칸(LogicLab_Basic) → 저장 = 서버 added 키로 엶 +- [x] 36-6 다른 로직 입력 — 대상 = 마스터 로직 전체(찾기) · 흐름도 로직이면 흐름도 셈 · 아니면 옛 셈 · 목록 · 찾기 · 쪽 · 알약 이음 검증완료(서버 흐름 받기 · 값 셈 길이 main 에 오면 GF 로직 값 확인) +- [x] 36-7 정리 — 「시험판 ▾」 · 시험판 입구 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 resources/tester 로 · 시험 저장 길 숨김 · DataFlow · CostFlow 폴더 그대로 · 검증완료 +- [x] 36-8 끝 검증 — 흐름 기준 · 옛 로직 열기 → 오른쪽 흐름도 값 = 왼쪽 계 · 신규 로직 → 저장 → 다시 열기 · 복사 · 권한 · 브레인 실제 검증 · 보고 · 끝 검증완료(sub1 · 사본 폴더 tmp/master_copy_36 + AISLO_MASTER_DIR · 네 포트) — ① 옛 로직 셋: GF000001(찾기) 오른쪽 34,413 = /calc 34,413.6 = 왼쪽 34,414(보임 끝수만 다름) · 변환 알림 한 줄 / GF000007(만약) 0 ≠ 597,285 · GF000288(로직()) 0 ≠ 1,234,760.96 — 아래 흠 ② GF000001 함수 식 ×1.5 → 오른쪽 · 왼쪽 51,620 따라감 → [저장](공용 GF · 사본) → 다시 열기 상자 6 · 변환 알림 없음 · /calc flow 51,620.4 = 화면 ③ 신규 로직 65 걸음(변수 셋 · 함수 · 조건 만들기 · 출력 둘 · 조건 선) → 저장 GX000004 → 다시 열기 상자 4 · 선 4 · 화면 100,000 = /calc 100,000 = 왼쪽 계(높이 2 = 0 · 지반 암 = 50,000) ④ 로직 복사/생성 2 걸음 → GX000005 상자 4 · 선 4 · 100,000 ⑤ 선 조건 두 줄 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 값 고르기(토사|암) · 높이 2 → 0 · 또는 → 50,000 · 조건 만들기 =IF(A2>=3,A3,0) ⑥ 식 보기 「결과 1 = 금액 × 높이 ÷ 4」 · IF 만약 / 아니면 줄 · 칸 연결 표 ⑦ 크게 보기 1484×1076 · 거기서 고친 것 패널로 · 접기 40px · 펴기 · 너비 320 → 522 ⑧ 고친 것 버리기 5 → 4 · 캐시 비움 · 로그인 없음 flow · save · calc · logics 401 · 시험판 · 옛 단추 글 0 · resources/master_data 바뀜 0 · 흠(다른 창): 변환 숫자 입력 예시값이 비어 있음(시험입력 · 기본 입력을 안 넣음 — GF000288 나무높이 6 · 집재거리 100 · GF000007 정리량 50 …) · GF000007 「표 QF000033 → 할증」 표에 맞는 줄 없음(서버 477,828 = 할증 25% 빠짐 · 화면 0) · GF000288 다른 로직 GC000270 기계 자리 「EQ:{기계} … 자리」 못 풂(#N/A → 0) · 칸 #N/A 일 때 화면 셈 0 · 서버 셈 그대로(둘이 다름)(sub7) · 식 보기 값 줄이 선 규칙을 지난 값을 안 씀(「(#N/A × 0) = #VALUE!」 · 결과 알약은 34,413.6)(sub2) · 조건 통과 둘째 줄 값 목록 「number:0」 글 · 조건 만들기 만약 목록에 놓인 알약 없음(sub2) · 옛 로직 왼쪽 계 반올림 · 흐름 원가 계금 1원 버림(1원 차이) +- 36 마무리(10-2 02시 · 브레인 실제 검증완료): 전수 변환 대조 시험입력 1,469/1,472(99.8%) · 기본 1,466(99.6%) · 남은 3 = 마스터 빈 단가 · 시험 141 + 변환 시험 초록 · 사본 서버 저장 → 사본만 바뀜 · 다시 열기 흐름 정본 · GF000001 34,414 = 34,414 · GF000003 8.946 = 8.946 · 식 보기 값 줄 · 검증 대기 + +## 37. 일위대가 조합 다이어그램 (사용자 지시 10-2 09시 · 계획 · 착수) + +- 사용자 확정: 「복잡한 경우가 있으니 일위대가 조합도 다이어그램으로 표현 … 중요하게 생각하는 정보가 다르니 잘 구분」 · 조합은 계산하지 않고 로직을 뭉쳐 비용 구분별로 표기 · 입력은 로직별로 묶어 빈 값은 사용자가 넣음 · 출력은 로직마다 · 미리 보기 값은 구조물 조합으로 안 넘김 · 「흐름도는 별도 로직이 아님」(단가산출 로직을 쓰는 방법). +- 모양: 왼쪽 = 로직별 입력 묶음(받아야 할 값 · 빈 값 표시) → 가운데 = 로직 카드(키 · 이름 · 결과 단위 · 식 · 함수 안 보임) → 오른쪽 = 비용 구분 칸 셋(노무비 · 재료비 · 경비 · 로직마다 선으로 모임) + 값 로직은 「값」 칸 · 이름이 같은 입력(지역 · 자재지역 등)은 점선으로 이어 보이기만(검사 없음) · 로직 카드를 누르면 그 로직의 단가산출 로직 화면(흐름도)으로 · 계산 · 고치기 없음(묶음 고치기는 지금 표) · 흐름도와 모양 · 색을 달리함. +- 자리: 일위대가 조합 상세(M02_MasterTemplete_Labor_Edit)의 미리 보기와 같은 값 — 미리 보기 입력 칸에 넣은 값이 다이어그램에도 · 다이어그램 빈 입력 알약을 누르면 미리 보기 그 칸으로 · 미리 보기 결과(로직별 노무비 · 재료비 · 경비 · 계)가 카드 · 칸에. +- [x] 37-1 다이어그램 판 — M02_MasterTemplete_Labor_Diagram.ts(+css) 새 파일 · 고정 세 칸 배치(자동) · 판 옮기기 · 확대 · 한눈에 · 밝음 · 어두움 · 좁은 폭 +- [x] 37-2 잇기 — Labor_Edit 에 「다이어그램」 칸(접기) · 미리 보기와 값 한 벌 · 빈 입력 누름 → 미리 보기 칸 · 로직 카드 누름 → M01 단가산출 로직 그 로직 열기(setPendingLogicKey 꼴) +- [x] 37-3 검증 — 조합 셋(로직 2 · 9 · 14개)으로 입력 묶음 수 = 로직 입력 수 · 출력 = 미리 보기 줄 · 비용 칸 합 = 미리 보기 합 · 빈 값 표시 · 같은 이름 점선 · 로직 열기 · 브레인 실제 검증 · 보고 +- 창: sub2 37-1 · 37-2 · 37-3 + +## 38. 마스터 요소 화면 손질 (사용자 지시 10-2 09:40 · 착수) + +- 사용자 확정: ① 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 상세의 제목 · 단추 · 찾기 라벨 양식 = 단가산출 로직과 같게 ② 재료 컨테이너의 유가 · 전력을 별도 컨테이너로 ③ 단가산출 로직 「막힘만」 체크 — 의미 없으면 지움 ④ 단가산출 로직 컨테이너 「전체」 를 누르면 상세 목록 보기가 안 됨(다른 컨테이너는 됨). +- 브레인 정함: 「막힘만」 = 로직 검사에 걸린 로직만 거르기 · 지금 1,469 중 0 · 목록 줄에 「⛔ 막힘」 표시 · 까닭 말풍선은 이미 있음 → 체크만 지움(표시는 둠). +- [x] 38-1 양식 맞춤 — 일곱 상세 화면 머리(제목 · 단추 줄 · 찾기 칸 라벨)를 단가산출 로직 화면 꼴로 · 공용 단추 · 칸 크기(sub5) · 검증완료 +- [x] 38-2 유가 · 전력 컨테이너 — 왼쪽 패널에 재료 다음 「유가 · 전력」 컨테이너(재료_유가전력.json) · 재료 컨테이너에서는 뺌 · 고르기 · 찾기 · 상세 그대로(sub6) · 검증완료 +- [x] 38-3 「막힘만」 체크 지움 · 38-4 「전체」 목록 보기 고침(sub1) · 검증완료 — 원인: 로직 컨테이너 거름이 목록만 거르고 가운데 화면 전환(onLogic)을 안 부름 → Side 거름 run 에서 로직이면 onLogic · 「전체」 는 찾는 글도 비움 · 화면(5174): 로직 1,469 줄 → 인력 전체 → 로직 「전체」 = 로직 화면 1,469 줄(찾는 글 「굴착」 비움) → 산림품셈 330 → 03장 작업장 관리 10 → 로직 열기 → 「전체」 = 상세 닫힘 · 1,469 줄 · 막힘만 체크 0 · 화면 오류 0 +- 「고친 것 버리기」(사용자 물음 · 확인만): 저장 안 한 고친 것(화면 캐시)을 버리고 파일의 로직을 다시 엶 — 사본 저장은 없음 · 그대로 둠(사용자 결정 기다림) From 056ddd882261fb2edfa82a795afd503edee04120 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:19:18 +0900 Subject: [PATCH 03/24] auto: 2026-10-04 17:19 (ESD_LAPTOP) --- .../raw/M02_구조물조합개편/사용자요구_1004.md | 50 ++++++++ docs/raw/M02_구조물조합개편/설계_정함.md | 105 +++++++++++++++++ ...가산출_흐름도_조합다이어그램_마스터요소.md | 108 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004.md create mode 100644 docs/raw/M02_구조물조합개편/설계_정함.md create mode 100644 docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004.md b/docs/raw/M02_구조물조합개편/사용자요구_1004.md new file mode 100644 index 000000000..936caa071 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004.md @@ -0,0 +1,50 @@ +# M02 구조물 조합 개편 — 사용자 요구 (2026-10-04 · 원문 그대로) + +1번(계획서 32장 이후 정리)은 브레인이 처리함(c8269a969). 7 · 8번은 원문에 없음. + +## 2. 일위대가 조합 → 구조물 조합 하위 + +M02페이지의 마스터 템플릿 페이지에서 일위대가 조합은 구조물 조합의 하위 컨테이너로 넣을 것.(구조물 도면과 면적산출 사이에 배치) +- 일위대가 조합의 좌측 패널의 기존 보기 방식인 트리구조는 필요없으며 구조물 도면 처럼 리스트형태로 보임 +- 추가로 구조물 도면, 일위대가, 면적산출의 최상위에는 전체항목을 넣어주고 우측 상세에는 리스트로 보여줌, M01 항목을 보면 양식화되어 있는 페이지 헤이아웃 있음 + +## 3. 이름 + +이동한 일위대가 조합은 일위대가로 명칭 변경 + +## 4. 구분선 + +구조물 조합 컨테이너 내부의 서브 컨테이너 항목과 트리구조 사이에 수평 구분선 추가 + +## 5. 사용자별 구조물 조합 읽기 / 쓰기 / 복사 권한 점검 및 개선 + +- 시스템 관리자 : 아이슬로 프로그램 관리자로 마스터를 제공하여 회사관리자나 일반 사용자가 사용하기 쉽게 한다. +- 시스템관리자가 대시보드의 컨테이너 제목의 우측의 회사명을 선택함에 따라 변경한 목적은 해당 회사의 정보를 접근하고나 AS을 목적으로 고객을 도와주기 위함임. 복사항목에는 마스터나 시스템 관리자 회사, 개인으로 들어갈 항목 뿐만 아니라 고객 회사의 특정 폴더로 복사할 수 있는 기능이 있어야함. 수정/생성권한 포함 +- 회사관리자 : 고객으로 본인회사의 양식을 만들거나 개인 프로젝트에 사용할 개인 마스터데이터 관리 +- 회사관리자는 개인버튼을 누를경우 최상위 트리에는 다른 개인들의 정보를 보고 복사하여 개인에게 복사하거나 수정, 생성도 가능해야함. +- 개인은 마스터와 회사는 읽기만, 다른 개인의 정보는 볼수 없음. 트리는 현재를 유지해야함. + +## 6. 구조물 조합 항목 변경 + +- 최상위 구조물 조합에 구조물 도면, 일위대가, 면적산출로만 표현(기존의 재료산출의 사용자가 추가하는 재료들과 값은 엑셀을 사용하지 않고 일위대가의 담은로직 처럼 하위에 별도의 컨테이너로 추가되는 자재항목을 넣어야함. 이때 M01자재목록에서 하나씩 가져오되 수량은 면적산출의 알약으로 값을 싱크하거나 별도의 모달창에서 셀을 지정하게 구현) +- 재료산출 항목은 삭제 +- 선택입력 항목은 삭제(구조물 조합에서는 각 옵션의 상세 구조물에 따라 미리 옵션을 다 지정할 생각임으로 그럴필요 없음) +- 면적산출의 미리보기에서 아래 알약 그룹 행을 하이드/쇼 되게 반영(기본값은 하이드), 단, 면적산출 상세에서는 알약 행 보이기가 기본값으로 +- 면적산출 상세 화면의 우측 패널에서 현재의 일위대가 조합 목록을 검색하는 기능이 아니라 현재 구조물도의 일위대가 담은 로직과 추가될 사용자 지정 재료를 보여줄것, +- 일위대가 상세화면에서는 미리보기(보기만/저장안함) 컨테이너는 각 단가산출 로직에 입력이 필요한 정보들임. 구조물별로 하나씩만들어져야함으로 구조물의 일위대가 항목에 옵션값이 저장되야함. 그리고 이값에 대한 지정은 담은 로직 컨테이너의 항목을 누르면 모달창이 나오고 사용자가 입력 및 변경할수 있게함. (하위에 지정된 항목과 값을 작은 글씨로 표현되게 반영) +- 담은 로직에는 기준 단가를 기준으로 넣었을때 나온 값과 수량산출값을 넣었을때 값에 대한 노무비, 재료비, 경비, 합계를 같이 보여줌. 이때 수량산출은 면적산출의 특정 셀값이나 사용자가 수기로 입력한 값이 될수 있음. 이렇게 되면 미리보기 계산 컨테이너 필요 없음. +- 일위대가의 상세 화면의 순서는 담은로직 > 자재목록 > 다이어그램 순서임. +- 면적산출 산출 상세의 우측 패널에 보여져야하는 결과는 결국 일위대가의 담은 로직컨테이너와 자재목록임. +- 추가된 자재목록에도 기준에 대한 단가와, 면적산출 엑셀의 특정 셀과 연결되었을때 나온 단가가 같이 나와야함. + +## 9. 오른쪽 클릭 + +프로그램 B와 M영역의 페이지들에 마우스 오른쪽 클릭을 할 경우 브라우저 옵션 안보이게 할것.(우리 프로그램의 옵션은 허용, 공통양식 사용) + +## 사용자 답 (10-4 · 일위대가 · 원문 그대로) + +일위대가의 경우 +1. 구조물 조합을 복사한 경우 신규 키를 생성하여 만듬. (값과 로직은 일단 같이 복사) +2. 신규 구조물 조합을 생성하는 경우 미리보기 페이지에는 새로만들기와 복사생성하기를 선택하게 하고 복사 생성인 경우에는 해당 리스트를 보여주고 선택하게 보여주는 값은 개인인 경우에는 개인을, 회사관리자인경우 개인, 시스템관리자인 경우 마스터를 기본값으로 보여주고 탭을 눌러 다른 항목에서 가져올수 있는 항목들을 보여준다. +3. 다시 말해 최종적으로는 구조물 조합에 일위대가, 도면, 면적산출이 연되는게 맞으나 특정항목만 다른 값을 복사하여 가져올수 있어야함. 저장이 된 데이터라도 변경가능해야함. +4. 현재 매칭이 안된 데이터는 나중에 삭제할예정이나 마스터 데이터를 수동 생성을 위해서 필요한 데이터들임. 차라리 실무문서라는 항목을 마스터에 별도로 만들어서 나중에 한번에 지우게 할수 있게 반영. diff --git a/docs/raw/M02_구조물조합개편/설계_정함.md b/docs/raw/M02_구조물조합개편/설계_정함.md new file mode 100644 index 000000000..229b350fd --- /dev/null +++ b/docs/raw/M02_구조물조합개편/설계_정함.md @@ -0,0 +1,105 @@ +# M02 구조물 조합 개편 — 정함 (브레인 · 10-4) + +근거 = `사용자요구_1004.md` · 조사 보고 A~H(각 창 `tmp/조사_39-0X.md`). 사용자가 바꿀 수 있음. + +## 1. 자료 틀 (B · sub7) + +- 일위대가 = 구조물 열마다 `labor/<열id>.json` · 번호 `SU-######`(도번 · 면적번과 같은 뒷자리 · 층마다 셈) · 자리 = 시스템 `resources/master_template/labor/` · 회사 · 개인 `…/templates/labor/` · 새 프로젝트 씨앗도 KINDS 로 함께. +- 문서 꼴: + +```json +{ + "종류": "일위대가", "판": 1, "열": "item1", "일위대가번": "SU-000001", "단위": "개소", + "본뜬": { "층": "system", "주인": null, "키": "UA000008" }, + "담은로직": [ + { "로직": "GF000219", "메모": "", "입력값": { "형식": "메쌓기" }, "수량": null }, + { "로직": "GF000137", "메모": "", "입력값": {}, "수량": { "손": 3 } } + ], + "자재": [ { "코드": "MT000008", "이름": "치즐", "규격": "", "단위": "EA", "수량": null } ], + "계산값": { "줄": [], "자재": [], "기준합": {}, "반영합": {} } +} +``` + +- 이름 = 구조물 열 이름(따로 안 둠) · 단위 = 자기 칸 · 같은 로직 두 번 금지 · 빈 일위대가 저장 됨 · 이름 겹침 검사 없음 · 입력값 = 그 로직 입력 이름만(모르는 이름 버림 · 빈 값 = 자동값). +- 수량 = `null`(면적산출 알약 연결 따름 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」 · 칸 연결 정본 = 면적산출 문서 `연결` 한 곳) | `{손: 수}`(손 입력 · 손이 칸보다 앞섬). +- 금액 = 기준(결과단위 그대로 · 원/10㎡ 면 10㎡ 값 · 단위 같이 표기) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 「원/1,000본」 배 1000(`_PER` 고침) · 자재 기준 단가 = M01 `master_material.element` 기본값. +- 계산 자리 = 기준 ③ 서버(M01 엔진 · calc 길 · 화면은 최신 응답만) · 반영 곱 ① 짝(`M02_MasterTemplete_Labor_Calc.ts` ↔ `M02_Labor_Doc.py` · 두 파일 머리에 짝 · 거울 시험 · 면적 칸 값이 바뀌면 화면이 바로 곱함) · [저장] 때 서버가 `계산값` 을 다시 적음(브라우저 값 버림 · 목록 합계용) · 상세는 늘 새로 셈. +- 길 = 문서 `GET/PUT /api/m02/layers/{층}/templates/labor/{열}`(양식 길 · owner) · 계산 `POST /api/m02/layers/{층}/labor/{열}/calc` {doc?, 수량?: {키: 값}} → {줄[{로직, 분모, 배, 수량, 수량출처, 기준{노무비,재료비,경비,계}, 반영{…}, 까닭}], 자재[{코드, 기준단가, 수량, 수량출처, 반영, 까닭}], 기준합, 반영합} · 알약 `GET /api/m02/layers/{층}/labor/{열}/pills`(로직 물량 + 자재). +- 집계표 열 `일위대가` · `자재조합` 칸 지움(정본 = labor 파일) · SS 장부 줄 = `일위대가번` · `도번` · `면적번`. +- 옮기기(한 번 · `M02_Labor_Migrate.py`) = 층마다 열마다 labor 파일 · 열 `일위대가` 키 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · 수량 null · 자재 []) · 여러 열이 쓰던 조합(UP10-000001)은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 몇 번 돌려도 같음 · 시스템 층(git)은 바로 · storage 층은 압축 사본 뒤 브레인이 돌림. +- 실무문서 = 마스터 `resources/master_data/일위대가조합.json` 통째(37 줄 · 손대지 않음 · 옮긴 뒤 어느 열도 안 가리킴 · M01 일위대가 조합 화면도 그대로) · 회사 · 개인 옛 조합(UC · UP)은 파일 그대로 · 화면에서 뺌. +- 프로젝트 = 스냅샷 `일위대가.json` 은 labor 문서 + 담은 로직 줄 · `입력값.json`(모양 그대로) 첫 값 = labor 입력값 · `단가.json` 에 자재 MT 줄 · 계산은 반영 같은 함수. +- 은퇴(화면이 안 부른 뒤) = 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 · 미리 보기 길(preview) · 일위대가 조합 나무 길. +- 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서(32장 「로직 지우기 서버 검사」 줄 끝남). + +## 2. 좌측 패널 (A · sub5) + +- 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · 구조물 조합 안 차례 = 층 단추 + 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출. +- 하위마다 첫 줄 「전체 항목」(누르면 `onAll(kind)` → H 목록) · 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = `onOpen({kind: "labor", name: 열})`(C 상세). +- 이름 「일위대가」(하위 제목 · 상세 제목 · 대시보드 카드 글) · 해시 `open=일위대가` = 구조물 조합 펼침 + 일위대가 하위 펼침 + 전체 항목. +- 「실무문서」 = 구조물 조합 밖 별도 컨테이너(도면 양식 아래) · 마스터 층 · 시스템 관리자만 · 목록 + 상세 읽기만(새 파일 `M02_MasterTemplete_Practice.ts`) · [통째 지우기](물음 두 번 · 서버 = 시스템 관리자 · 쓰는 곳 0 확인 · 줄을 비움 · 버튼은 사용자가 누를 때만). + +## 3. 일위대가 상세 (C · sub2) + +- UI_Main 종류 `labor` 로 엶(양식 길 읽기 · [저장] · [다시 읽기] · 캐시 = 다른 양식과 같음) · 차례 = 머리 → 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램 · 미리 보기 칸 지움. +- 담은 로직 줄 누름 = 입력 모달(그 로직 입력 전부 · 빈 칸 자동값 · 수량 = 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」 = 면적산출 상세로) · [확인] = 줄 값만(캐시) · 저장은 [저장]. +- 줄 아래 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 빈 입력 「빈 값 n」 붉게. +- 금액 칸 넷(노무비 · 재료비 · 경비 · 합계) · 칸마다 두 줄(위 기준 작게 · 아래 반영) · 맨 아래 합계 줄. +- 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액 · [자재 추가](M01 자재 하나씩 · `M02_Material_Pick.ts`) · 줄 누름 = 수량 모달. +- 다이어그램 = 저장된 줄 값 + calc 답(수량 있으면 반영 · 없으면 기준 · 표기) · 알약 누름 = 입력 모달 · 자재목록 = 카드 하나 → 재료비 칸. +- 보기 갈래(고치기 없음) 부품을 냄 — D 오른쪽 패널 · H 카드가 씀. +- 입력 목록 · 값 받기 부품은 선택 입력에서 옮겨 다시 씀(`M02_Labor_Inputs.py` · `M02_Labor_Values.ts` · `M02_Labor_Values_Model.ts`) · 값 칸 규칙 한 벌(M01 넣을 값) · 로직 입력이 바뀌어 사라진 값은 알림. +- 37장 「미리 보기 값은 구조물 조합으로 안 넘김」 뒤집힘. + +## 4. 면적산출 (D · sub3) + +- 미리보기 카드 알약 줄 켬끔 단추 · 기본 꺼짐 · pref(계정) · 상세는 늘 보임(단추 없음). +- 상세 오른쪽 패널 = 그 열 일위대가의 담은 로직 + 자재목록(C 보기 갈래 · 기준 · 반영) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 지움. +- 패널 줄 누름 = 칸 지정 모달(`M02_MasterTemplete_AreaLink.ts` · [지금 고른 칸으로] · 주소 · [풀기]) → 면적 문서 `연결` · spreadsheet 손잡이 `setLink(키, 칸|null, 항목)`. +- 알약 = 로직 물량 알약 + 자재 알약(`자재|MT키` · 알약 모드 「자재」 다시 씀) · 선택 입력 알약 빠짐. +- 면적 금액 서버 계산은 1장 calc 하나(따로 안 만듦). + +## 5. 목록 · 고르기 · 카드 (H · sub1) + +- 목록 부품 하나 `M02_MasterTemplete_ItemList.ts`(M01 머리 · 쪽 · 표를 import · 옮기지 않음) — 「전체 항목」 · 복사 생성 · 항목 하나 복사가 같이 씀. +- M02 상세 제목 = M01 꼴(18px · 단추 오른쪽) 전부. +- 목록 칸: 도면 = 도번 · 이름 · 폴더 · 단위 · 고친 날 / 면적산출 = 면적번 · … / 일위대가 = 일위대가번 · 이름 · 폴더 · 단위 · 담은 로직 수 · 합계(`계산값` · 없으면 「—」) · 고친 날. +- 「새 항목」 → 상세 칸에 [새로 만들기] [복사 생성] · 복사 생성 = 같은 칸 탭 + 목록 → 지금 보는 층 · 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 일위대가 새 번호. +- 카드 = 도면 · 일위대가 · 면적산출 셋(그 차례) · 빈 카드 = [새로 만들기] [복사 생성] · 찬 카드 = 「복사해 가져오기」(창 · 탭 + 목록 → 확인 → 덮어씀 · 번호 · 열 그대로) · 일위대가 카드 몸 = C 보기 갈래. +- 탭 = 마스터 · 회사 · 개인(나) · 다른 사람(드롭다운 · 맡은 회사일 때) · 실무문서(일위대가 고를 때 · 시스템 관리자) · 기본 = 시스템 관리자 마스터 · 그 밖 개인(회사 없으면 마스터) · 탭 목록 = E `readableSources`. +- 항목 하나 복사 서버 = `copy_from` 에 from_layer · from_owner(B 가 만듦) · 원본이 실무문서 조합이면 조합 → 일위대가 문서(입력값 빈 값). + +## 6. 권한 (E · sub4) + +- 「회사 C 를 맡음」 = 시스템 관리자 · 또는 C 의 회사 관리자 → C 회사 층 · C 모든 개인 층 = 보기 · 만들기 · 고치기 · 지우기 · 복사 대상. +- 일반 = 마스터 · 회사 읽기만 · 남 개인 안 보임 · 나무 그대로 · 프로젝트 apply · sources 도 같은 규칙. +- 사람 나무 = 맡은 회사면 켬 · 회사 사람 전부(나 맨 위 · 빈 사람 「비어 있음」) · 남의 층 첫 쓰기 = 소속 확인 뒤 뼈대. +- 「〇〇로 복사…」 = 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 사람) + 대상 폴더 고르기 창(`M02_MasterTemplete_TreePanel_CopyTo.ts`). +- 남의 층 쓰기 = `system_audit_logs` 한 줄(`M02_OTHER_LAYER_WRITE`). +- 실무문서 = 일위대가 조합 길 전부 시스템 관리자만(서버가 막음). +- 새 길(labor 문서 · calc · pills · 층 넘는 복사)도 `_root` · `_layer_dir` 한 벌을 거침. + +## 7. 지우기 (G · sub6) + +- G1 자재 조합(UM) · G2 재료산출(`material/` · SM · 카드 · 시트 · 옆 판 · 길 · 시험) 먼저 · G3 선택 입력(카드 · `inputs/` · 자리 · 알약 길)은 C 의 입력 모달이 선 뒤. +- `pickMaterial` · `MaterialItem` → `M02_Material_Pick.ts` 로 옮긴 뒤 지움 · 알약 모드 「자재」 · `LinkMaterial`(A00 spreadsheet) 남김 · `고른자재.json` 남김 · 프로젝트 `입력값.json` 남김. +- storage 층(회사 · 개인 · 프로젝트)의 `material/` · `inputs/` · `자재조합.json` 파일은 지우지 않음(읽기만 끊음 · 정리는 사용자) · git 안 시스템 층 · fixture 는 지움. +- 옛 SM 번호 버림 · 장부 `재료번` 칸은 다음 집계표 [저장] 때 빠짐. + +## 8. 파일 주인 (같은 파일 = 몫만 고침 · 받은 뒤 고침) + +| 파일 | 주인 | 다른 창 몫 | +| --- | --- | --- | +| `M02_MasterTemplete_Store.py` · `M02_Template_Layers.py` | sub7 | sub6 = material · inputs 줄 | +| `M02_MasterTemplete_Router_Items.py` | sub4(`_root`) | sub7 = copy 길 from_layer · sub6 = material · inputs 길 | +| `M02_MasterTemplete_Router_Combos.py` · `Combo_Store.py` | sub5(나무 길) | sub4 = 시스템 관리자 문 · sub2 = preview 지움 | +| `M02_MasterTemplete_UI_Side.ts` | sub5 | sub6 = material · matset 줄 | +| `M02_MasterTemplete_UI_Page.ts` | sub5 | sub4 = copyTo · sub1 = onAll 이음 | +| `M02_MasterTemplete_UI_Main.ts` | sub3(면적 길) | sub2 = labor 종류 · sub1 = 제목 꼴 · showView · sub6 = material 길 | +| `M02_MasterTemplete_ItemCards.ts` | sub1(카드 셋 · 빈 카드) | sub3 = 알약 켬끔 · sub6 = 재료 · 선택 입력 카드 | +| `M02_MasterTemplete_Structure.ts` | sub3(pillsFor) | sub1 = pickCopy · sub6 = inputs · material | +| `M02_MasterTemplete_TreePanel.ts`(670줄) | sub1(copyIn · 넘으면 `TreePanel_Clip.ts`) | sub5 = 일위대가 전용 칸 지움 · sub4 = 「〇〇로 복사…」 이음 | +| `M02_MasterTemplete_Labor_*.ts` | sub2 | sub5 = `Labor.ts` · `Labor_Tree.ts` 나무 지움 | +| `M02_MasterTemplete_Api_Fetch.ts` | 각자 자기 함수만 | — | +| `A00_Common/spreadsheet/spreadsheet_links.ts` · `spreadsheet.ts` | sub3 | — | +| 공용 메뉴 · 진입(`main.ts` · `cad_host.ts` · `ui_template_context_menu.*` · `ui_template_tree.*` · `spreadsheet_condition.ts` · `spreadsheet_menu.ts`) | sub8 | — | diff --git a/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md new file mode 100644 index 000000000..fa33578c6 --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md @@ -0,0 +1,108 @@ +# 지난 계획 — 33~38장 (2026-10-01 ~ 10-02) + +단가산출 로직 점검(데스크탑 한시) · 쉬운 단가산출 · 로직 만들기 시험판(세 칸 판 · 시트 로직 · 다이어그램) · 흐름도 시험판 · 흐름도 본 프로그램 적용 · 일위대가 조합 다이어그램 · 마스터 요소 화면 손질. + +## 33. 단가산출 로직 점검 · 수정 (사용자 + 데스크탑 메인 · 보조 · 10-1 한시) + +- 사용자가 데스크탑 메인 · 보조와 직접 진행 · 브레인은 랩탑 sub1~8 로 사용자가 요청하는 다른 일을 맡음(10-1 18:00 무렵까지). +- 근거 = `docs/raw/M01_점검/종합/` · `docs/raw/M01_결정목록/` · `docs/raw/M01_입력목록/` · `docs/raw/M01_고침/`. + +## 34. 쉬운 단가산출 제어 (새 길 · 10-1 · 랩탑) + +- 사용자 확정(10-1 오전): 만든 쉬운 단가산출은 유지(지우지 않음) · 로직 정의 자체를 만드는 판은 제안만 · 허락 뒤 착수 +- 사용자 확정: 지금 단가산출은 그대로 · 「추가」 단추 옆 새 단추로 따로 들어감 · 코드는 `M01_MasterData/EasyCalc/` 한 곳(안 되면 폴더째 지움) · 초등학생도 추가 · 삭제 · 수정 · 시간이 걸려도 검토 먼저. +- 정함(브레인): 새 길은 지금 로직 형식을 그대로 읽고 씀(계산 엔진 안 바꿈) · 폴더 밖 참조는 단추 자리 한 곳만. +- [x] 34-1 검토 — 데이터 · 계산 지도(sub4) · 지금 화면 걸음 재기(sub6) · 실무 모양 + 갈래 비교(sub7) · 지난 시도(sub8) → `docs/raw/M01_쉬운단가/` · 지금 화면 새 로직 38 누름 · 5~6 창 +- [x] 34-2 설계 확정(브레인) — 문장 카드 · 품셈 기본은 「이걸 바탕으로 고치기」 · 쓰기는 `/logic/*` 만 · 1단계 숫자 수량 + 잠금 카드 → `docs/raw/M01_쉬운단가/설계.md` +- [x] 34-3a 단추 + 빈 창(sub1 · 「식으로 새로 만들기」 옆) 검증완료 +- [x] 34-3b 1단계 — Model(sub7) · Api(sub4) · Cards(sub2) · Pick(sub3) · Sheet(sub5) · 틀 · 잇기(sub1) — 설계.md 2 · 3절 · Sheet 검증완료 · Model 검증완료 · Api 검증완료 · Pick 검증완료 +- [x] 34-3c 2단계 — 표 보고 고르기(sub8) · 조건 고치기(sub7 · 조건 줄 93% 읽음) 검증완료 · 카드 단추 잇기(sub2) · 조건 고치기 검증완료 +- [x] 34-4 검증 — 대표 일 여섯 + 예외(빈 값 · 같은 이름 · 저장 안 하고 닫기 · 권한 없음 · 밝음 · 어두움) · 지우기 시험(폴더 밖 참조 줄 수) · 걸음(sub3 고침 뒤) ①21 ②8 ③2 ④4 ⑤2 ⑥3 = 40(목표 36 넘음 4 · ① 새 설계 값 자동값으로 채워 금액 곧장 보임) · 다른 일위대가 넣고 저장 됨 · 권한 없음(일반 사용자) = M01 화면 못 들어감 · /api/m01 403 · 폴더 밖 참조 두 줄 · 검증완료 + +## 35. 단가산출 로직 만들기 판 (제안 단계 · 착수 전) + +- 사용자 확정: 쉬운 단가산출처럼 따로 폴더 시험판 · 로직 몇 개 골라 만듦 · 페이지 보고 판단(마음에 안 들면 지움) · **착수는 허락 뒤** · 인터넷 조사 먼저 · 화면 용어는 단어로 다시 제안. +- 제안서 https://claude.ai/artifact/U4rkgpYWL4Em5VKBEVa4oY (세 칸: 원문 표 · 산출 과정 · 결과) +- [x] 35-0 인터넷 조사 — 견적 프로그램(sub6) · 노코드 규칙 도구(sub8) → `docs/raw/M01_쉬운단가/로직판_조사_*.md` · 제안서 2판에 반영 +- [x] 35-1 용어 단어 제안(제안서 2판 「용어 — 단어로」) → 사용자 확정 · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-2 시험판 — 사용자 허락(10-1 · 갈래가 여럿이면 갈래마다 · 목적 = 코딩 없이 원문 보며 확인 · 수정 · 생성) · A 세 칸 판 `LogicBuilder/` · B 엑셀 시트형(우리 스프레드시트) · 먼저 조사: 엑셀 로직 지도(sub4) · A 설계 초안(sub7) → 브레인 A 대 B 판단 → 구현 · 완료 처리(10-4): 35-2a 로 함 +- [x] 35-2a A 세 칸 판(가운데 문장 / 격자 두 보기) — 정함 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · Blocks(sub7) · 서버 · Api(sub4) · 원문 · 검사(sub8) · 문장(sub2) · 격자(sub5) · 단계 안내(sub3) · 틀 · 잇기 · 끝 검증(sub1) · LB 서버 검증완료 · Blocks 검증완료 · Grid 검증완료 · Steps 검증완료 · Source · Check 검증완료 · Wizard 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 원문 대조 6/6 블록 읽힘 · 걸린 줄 · 출처 보임 ② GF000219 표 칸 0.15→0.20 = 79,280→96,759 원(예상과 같음) · 파일 안 바뀜 · 다시 열면 0.15 ③ 단계 안내 11 걸음 + 저장 → 현장 다시 열림 60,984 원(/calc 같음) → 삭제 2 걸음 ④ 빈틈 해당 없음 10 · 못 따진 표 조회 1 · 기대값 시험표 맞음/다름 · 화면 초안만 ⑤ 표 고침 없을 때 lb/calc = /calc 8/8 · 문장 ↔ 격자 같은 값 · 예외(빈 이름 · 저장 안 하고 닫기 · Ctrl+Z · Ctrl+S · 권한 없음 403 · 밝음 · 어두움) · 폴더 밖 참조 LogicLab.ts 두 줄 + main.py 등록 한 줄 +- [x] 35-2b 남은 것(사용자 판단 뒤) — 문장 / 격자 고르기 · 표 칸 고침 저장 여부 · 적용 품셈 판 · 용어 · 칸 역할 「결과」 바꾸기 · 단계 안내 고르기 창 수량 물음 · GX000001(메인 11:53 · 12:02) 주인 확인 · 보고 https://claude.ai/artifact/NqKfHaJULcQo6dSvnsxy1n · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-3 점검(확인만) — 표끼리의 관계(로직) 자체를 초등학생이 만들고 · 바꾸고 · 지울 수 있나 · 품셈에 없는 관계 더하기 · 시나리오 여섯(sub6) — 결과: 있는 관계의 값만 고침(초등 중) · 관계 더하기 · 지우기 · 다시 잇기 · 새 입력값 · 새 표 · 로직끼리 바꾸기 안 됨 · 문턱만 바꾸면 계산 멈춤 + 엉뚱한 알림 · `docs/raw/M01_쉬운단가/로직판_관계제어_점검.md` +- [x] 35-4 다이어그램 제안(사용자 생각 10-1) — 상자 = 표 · 입력값 · 고정값 · 계산 · 항목 · 다른 로직 · 결과를 직접 다룸 · 선 = 관계를 만듦 · 품셈에 안 묶임(사용자 표 · 사용자 관계) · 관계 제어 = 다이어그램(사용자) · 위 가로 띠 + 아래 고른 상자 다루기 · 결과 · 관계 = 진짜 엑셀 수식(칸 주소 · 함수 · 사용자 확정) · 알약 = 입출력 칸 표시 · 새 종류 「시트 로직」(TS 엔진 한 벌 · 서버 Node) · 제안서 https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 허락 전 착수 금지 · 완료 처리(10-4): 35-5 · 35-7 로 함 +- [x] 35-5 시트 로직 시험판(사용자 허락 10-1 오후) — 위 가로 다이어그램 · 상자 = 시트 · 관계 = 엑셀 수식 · 입출력 = 알약 · 저장 = `M01_MasterData/SheetLogic/_data/`(git 밖 · 폴더째 지움) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` · Model · Eval(sub7) · 서버 · Api(sub4) · Diagram(sub8) · Panel(sub2) · Table(sub3) · Item · Result(sub5) · Import(sub6) · 틀 · 잇기 · 끝 검증(sub1) · SL 서버 검증완료 · Model 검증완료 · Diagram 검증완료 · Item · Result 검증완료 · Table 검증완료 · Import 검증완료 · 잇기 · 끝 검증완료(sub1) — 걸음: S1 ①②③ 각 3(79,280 · 73,085 손셈 같음 · 2.5m 문턱은 품셈 표 3m 부터라 2.5~3m 해당 없음) · S2 ① 3 ② 4(찰쌓기 표로) ③ 5 · S3 ① 약 19(경사 급 65,018) ② 9(내 표 붙여넣기 · 할증 10) ③ 7(잡재료 2% 17,551) · S4 선 끊기 2 · 상자 지우기 2(물음 한 번) · Ctrl+Z 되돌림 · S5 0(상자 · 선 · 값 한 장 · 고르면 이어진 선 진하게) · S6 항목 일위대가 11 · 다른 로직 상자 13(덤프트럭 16.2) · 새 로직(품셈 없이) 25 걸음 → 저장 서버 36,000 = 화면 → 다시 열림 → 지우기 2 · 서버 = 화면 4/4(36,000 · 17,550.9 · 10.12 · 다시 열기) · 가져오기 6/6 /calc 같음(0 · 60,984/79,280 · 2,064,816 · 670 · 16.2 · 57,516) · 예외(빈 이름 · 같은 상자 · 순환 · 빈 입력 물음 · 저장 안 하고 닫기 · 권한 없음 sl 5/5 403 · 밝음 · 어두움) · 세 칸 안쪽 세로 밀기 없음(6개 모든 상자) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 남은 흠(다른 창): 가져온 표 상자 + 결과 칸 목록 예시값이 글자로 들어가 30 등에서 해당 없음(sub5) · 가져온 로직 화면 번호 · 자재 검색어 · 끝 검증완료(S1~S6 · 새 로직 · 서버 = 화면 4/4 · 가져오기 6/6) · 남은 흠: 결과 칸 예시값 선택이 글자 「30」 으로 들어가 표 찾기 해당 없음(sub5 · 사용자 지시 뒤) +- [x] 35-5a 사용자 요청 — 다이어그램 띠 · 고른 상자 시트 · 결과 높이를 안쪽 세로 스크롤이 없을 때까지(sub1) +- [x] 35-6 로직 문법 먼저(사용자 방향 10-1 14시 무렵 · 화면보다 로직) — 바탕 표(인력 378 · 재료 30,767 · 기계 654 · 소요량 2,535 · 계수 498 · 환율 · 요율 · 사용자 표 — 보기 · 만들기 · 비목은 표가 정함) · 단위 넷(값 · 표 · 정보 · 요소) + 묶음(세분화 · 묶기 · 분류 · 묶음째 잇기 · 겹 묶음 · 틀 다시 쓰기) · 흐름 셋(정보 만들기 · 잇기[그대로 · 바꿔서 · 갈라서 · 모아서 · 묶음째] · 내기) · 확인 도구 · 제안서 https://claude.ai/artifact/BFWvm1Q4fhCSZ4Rt34UgYY · 다음 = 문법 확정 → 종이 연습(절 10개) — 허락 전 착수 금지 · 완료 처리(10-4): 35-7 · 35-9 흐름도로 갈음 +- [x] 35-5b 단추 공용 양식(점검 끝 · 고침은 사용자 지시 뒤) — 날 단추 23(목록 줄 8 · 칩 · 역할 · 보기 바꿈 11 · ▾ 2 …) · sm 단추를 44px 로 덮기 3 · 큰 시작 56px 3 · `docs/raw/M01_쉬운단가/시험판_단추_점검.md` · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-7 단가산출 다이어그램 시험판(사용자 기준 10-1 14시 무렵) — 입력 · 함수 · 출력 상자 · 자유 배치 · 함수 = 엑셀 · 출력 하나 · 출력 = 비용 종류 · 원가 · 폴더 `M02_MasterTemplete/CostFlow/`(_data · _sample) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` · Model · Eval(sub7) · 서버(sub4) · Diagram(sub8) · 함수 판(sub2) · 입력 판(sub3) · 출력 · 원가(sub5) · 견본(sub6) · 단추 · 페이지 · 잇기 · 끝 검증(sub1) · Model 검증완료 · Output · Summary 검증완료 · Sample 검증완료 · FuncPanel 검증완료 · Diagram 검증완료 · CF 서버 검증완료 · InputPanel 검증완료(사용자 요소 만들기는 서버 Api 가 main 에 오면 확인) · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 손으로 메쌓기 꼴(변수 4 · 소요량 표 2 · 인력 2 · 함수 증가율 → 석공 / 보통인부 병렬 · 출력 노무비 2) 9 상자 · 약 138 손놀림 → 원가 148,471 = 견본(증가율 40 · 석공 97,882.512 · 보통인부 50,587.992 손셈 같음) ② 비용 종류 바꾸면 148,471 이 노무비 → 경비 → 재료비 로 옮겨감 · 「값」 이면 원가에서 빠짐(각 1 걸음) ③ 상자 끌어 옮김(1520,190 → 1675,306) · 저장 → 다시 열면 그대로 ④ 함수 출력 하나(다른 칸 고르면 옮겨감 · 알약 하나) · 순환 막힘 · 출력에서 나가는 선 없음 · 빈 받는 자리는 저장 때 물음 ⑤ 사용자 요소(시험 보조공 200,000) 5 걸음 · 마스터 그대로 · _data 에만 ⑥ 저장 때 서버 = 화면 2/2(148,470.504) ⑦ 단위 오른쪽 위(- → 원/㎡) · 색 바꾸기(Entry 판 머리) · 「다이어그램」 이름 · 다이어그램 726px · 안쪽 스크롤은 함수 격자(가상 200줄)만 · 밝음 · 어두움 · 권한 없음(cf 길 6/6 403 · 화면 못 들어감) · 견본 셋 = /calc · 손셈(148,471 · 3,000 · 15.552) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 끝 검증완료(손으로 메쌓기 9 상자 = 견본 148,471 · 비용 종류 옮김 · 자리 저장 · 규칙 · 사용자 요소 · 서버 = 화면 · 견본 셋 손셈) · 함수 출력 기본 이름 겹침 고침(sub7 2be767d18) · 남은 것: M01 단가산출 단추 줄이 셋째 줄까지(시험판 단추 넷 · 사용자 결정) +- [x] 35-7a 브레인 실제 검증(10-1 16:45) 흠 여섯 고침 — ① 고칠 것 칸 지난 경고 · ⑥ 시험판 단추 「시험판 ▾」 로 묶기(sub1) · ② 알약 쉼표 · ③ 표 알약 글 · ⑤ 맞춤 확대 90% 아래 안 감(sub8) · ④ 입력 상자 공통 단위(sub3) · 다시 검증완료(16:55 · 고칠 것 칸 비어 있음 · 알약 쉼표 · 표 · 40줄 · 인력 원/일 · 확대 90% · 막대 한 줄 · 시험판 ▾ 넷 · 원가 148,471) + +## 35-8. 단가산출 다이어그램 2판 — 재료 선택 자리 · 선 규칙 (계획 · 허락 전) + +- 사용자 확정: 고르는 방식은 재료만(다른 그룹은 상자를 늘림) · 설계 때 입력은 오른쪽 아래 검증 칸 · 나중에 프로젝트에서 일위대가로 쓸 때 재료는 목록에서 고르거나 단일 값 · 표에 따라 출력을 가르는 것은 엑셀 수식 대신 선 규칙. +- 정함(브레인): 재료 요소 = 고정 · 선택 자리 · 조건 찾기 · 알약 이름 = 자리 이름 · 선 규칙 = 선 하나에 하나(표에서 찾기 · 조건 통과 · 바꾸기) · 규칙은 받는 칸 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임. +- [x] 35-8a 틀(Model) — 재료 요소 방식 · 범위(구분 · 상세구분 · 찾는 말) · 조건(규격 ← 알약) · 기본 품목 · 선 규칙 칸 · 규칙 → 받는 칸 수식 · 조건 선 겹침 · 안 맞음 검사 — node 시험(sub7) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8b 서버 — 선택 자리 기본 품목 · 조건 찾기 단가(맞음 0 · 여럿 알림) · 「설계 때 정할 것」 목록(변수 + 재료 자리) 내보내기 — 시험(sub4) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8c 입력 판 — 재료 방식 · 범위 · 조건 규칙 · 기본 품목 · 찾기 고침(쪽 넘기기 · 띄어쓰기 AND · 규격까지) — ORCA(sub3) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8d 검증 칸 — 재료 선택 자리를 변수처럼(범위 안 목록 고르기 · 찾기) · 조건 자리는 잡힌 품목 표시 — ORCA(sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8e 선 규칙 — 선 위 규칙 글 · 선 누르면 아래 판에서 규칙 고르기(표 찾기: 조건 칸 ↔ 알약 · 꺼낼 칸 / 조건 통과: 알약 · 같음 · 다름 · 이상 · 이하 · 값 / 바꾸기: × · ÷ · +% · 단위 · 끝수) · 고른 상자 판에 들어오는 선 규칙 목록 — ORCA(sub8 · 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8f 견본 · 끝 검증 — 견본 둘 더(지반에 따라 굴삭기 / 브레이커 가르기 · 흄관 관경 조건 찾기) · 사용자처럼: 함수 상자 없이 표 찾기 · 가르기 · 검증 칸에서 재료 고르기 · 방법 걸음 수(sub6 견본 · sub1 잇기 · 끝 검증) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8g 다이어그램 마우스 — 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자가 선택되지 않음(다이어그램 영역 글자 선택 막음) · 상자 옮기기는 상자 머리 왼쪽 끌기 그대로 — ORCA(sub8) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8h 「고른 상자」 → 「상세 내용」 · 상자뿐 아니라 알약 · 선을 골라도 내용이 바뀜(알약 = 값 · 단위 · 어디서 오나 · 어디로 가나 / 선 = 선 규칙 고르기) — ORCA(sub1 잇기 · 알약 · 선 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8i 상세 내용 단추 · 칸 크기 — 함수 판 꾸밈이 공용 작은 단추를 44px 로 덮는 줄(`M02_CF_FuncPanel.css` `.m01-cfpanel .ui-btn …`)을 지우고 판 안 단추를 모두 공용 작은 크기(createButton size "sm")로 · 입력 · 고르기 칸도 공용 크기 — ORCA 수치(sub2 · sub3 · sub5 각자 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8j 상세 내용 머리 — 왼쪽 「상세 내용」 · 오른쪽에 고른 상자 제목 · 알약을 골랐으면 그 오른쪽에 알약 이름(예 「높이 증가율 찾기 › 증가율」) · 상세 내용 안 칸 차례 정리(머리 · 상자 설정 · 본문 · 경고) — ORCA(sub1) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8k 단위는 고르기 — 단위 칸을 목록 고르기로(치면 걸러져 보이고 고르기는 사용자 · 손으로 쓴 글은 안 받음) · 목록 = 단위 표준(35-8l) 단위 + 마스터 결과단위 + 금액 단위(원) · 「-」 = 없음 · 함수 · 출력 · 변수 · 사용자 요소 단위 모두 같은 고르기 — 공용 고르기 파일 `M02_CF_UnitPick.ts`(sub2) · 판마다 바꿔 끼움(sub2 · sub3 · sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8l 단위 표준 표 — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` · 종목 약 70 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③)을 표로 — 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 흠은 사용자가 고침(시험판에서 보고 고치는 표 · 저장 `CostFlow/_data` · 처음 값 `CostFlow/_standard/`) — 시험(sub6) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8m 금액 단위 표준 — 같은 절 「금액의 단위표준」(설계서 총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 모두 미만 버림)을 같은 표 자리에 — 출력 상자(비용) 금액 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림 · 바꿀 수 있음(sub6 표 · sub5 출력 · 원가) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8n 끝수 고르기 — 함수 상자 출력 · 출력 상자에 「끝수」(소수자리 · 반올림 / 올림 / 버림) · 단위를 고르면 단위 표준에서 소수자리를 제안(주 ① 반올림 · ② 항목 값이 우선) · 고른 끝수는 출력 칸 수식(ROUND 계열)으로 — 엔진 한 벌(sub7 틀 · sub2 함수 판 · sub5 출력 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8o 로직끼리 잇기 · 흐름 따라가기 — 저장한 다른 로직을 입력 상자로 불러 그 출력 알약을 받음(일위대가 → 일위대가) · 알약 · 선을 고르면 값이 어디서 와서(출처 사슬) 어디로 가는지(가는 곳) 다이어그램에서 길을 칠하고 상세 내용에 줄로 · 값이 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건) 표시 — 시험 · ORCA(sub7 틀 · sub8 다이어그램 · sub1 상세 내용) · 완료 처리(10-4): 35-9 에서 함 +- 차례(사용자 10-1 · 「데이터의 흐름과 값의 연결에 집중」): **1단계 = 흐름 · 연결** a 틀 · b 서버 · e 선 규칙 · o 로직끼리 · 흐름 따라가기 · h 알약 · 선 고르기 · c · d 재료 자리 · f 견본 · 끝 검증(흐름 기준 — 값이 어디서 와서 어떤 규칙을 지나 어디로 가는지) / **2단계 = 값의 뜻 · 보기** k 단위 고르기 · l 단위 표준 · m 금액 표준 · n 끝수 · j 머리 · g 마우스 · i 단추 + +## 35-9. 단가산출 흐름도 시험판 (사용자 지시 10-1 17시 무렵 · 브레인이 끝까지 · 35-8 전체) + +- 사용자 확정: 브레인이 전체 제어해 새 시험판 하나 · 단가산출 다이어그램(시험)만 남기고 옛 시험판 셋 삭제 · 새 폴더(필요 없으면 바로 지움) · 데이터 흐름 · 값 연결 먼저 · 자유도 높고 이해하기 쉬운 화면 · 끝까지 구현 · 검증. +- 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md` · 폴더 `M02_MasterTemplete/DataFlow/` +- [x] 35-9 창 여덟 — 옮기기 · Model · Eval(sub7) · 옛 시험판 지우기 · 들어가는 길 · 상세 내용 머리 · 잇기 · 끝 검증(sub1) · 서버(sub4) · 다이어그램(sub8) · 상세 내용 판 · 단위 고르기(sub2) · 입력 판(sub3) · 출력 · 검증 칸(sub5) · 표준 표 · 견본(sub6) · 표준 · 견본 검증완료 · Model 검증완료 · 출력 · 검증 칸 검증완료 · Diagram 검증완료 · 입력 판 검증완료(서버 자재 찾기 · UnitPick 이 main 에 오면 다시 확인) · DF 서버 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 함수 상자 없이 네 흐름 73 걸음: 재료 조건 찾기(관경 → 흄관 자리 · 13 개 중 하나 · 125,870) 21 · 선 규칙 표에서 찾기(높이 증가율표 → 출력) 19 · 조건 통과 두 갈래(지반 = / ≠ 토사) 27 · 다른 로직 잇기(견본 지반 · 지반 알약 이음) 6 ② 알약 · 선 고름 → 온 길 · 가는 길 칠함 + 상세 내용 줄(지반 → 다른 로직 · 두 조건 선 · 흄관 ← 관경) ③ 끊긴 곳 넷 보임(빈 받는 자리 · 통과 안 됨 · 표에 맞는 줄 없음 · 맞는 재료 없음 — 알약 · 선 「!」 · 끊긴 곳 줄 누르면 그 자리 고름) ④ 검증 칸 관경 600 → 800 = Φ800 품목 215,530 · 지반 토사 → 암 = 내 두 갈래 뒤집힘 + 다른 로직 2,000 → 10,000 · 다른 로직 설계 값 바꿈 10,000 → 20,000 ⑤ 단위 고르기 「mm」 = 3 개로 걸러짐 · 손글 안 받음 · 끝수 80,000.37 = 기본 225,870 / 1원 올림 225,871 · 표준 표 고침 저장(3 → 2) → 처음 값으로 되돌림 ⑥ 가운데 끌기 = 판 옮김(−60, −30) · 왼쪽 끌기 = 판 그대로 · 글자 선택 막힘 · 단추 18px 모두 sm · 칸 30px ⑦ 저장 → 다시 열기 = 상자 9 · 선 6 · 자리 · 규칙 글 그대로 · 서버 다시 셈 = 화면 187,910 · 견본 다섯 사본 저장 서버 = 화면(148,470.504 · 2,000 · 84,680 · 3,216.6 · 값 15.552) · 밝음 · 어두움 · 로그인 없음 df 길 6/6 401(관리자 아닌 계정 403 은 계정 없어 못 함) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 검증 중 고친 내 흠(047f3a279 · 28469de62): 재료 조건 찾기 · 선택 자리 값을 서버에 안 물음(흄관 견본 관경 600 → 400 = 211,700 → 116,600 · 1m 84,680 → 46,640 · 1000 = 235,120 · 선택 자리 Φ600 → Φ400 = 211,700 → 116,600) · 「상자 이름」 칸(입력 · 함수 · 같은 이름 · 빈 이름 막음 · 출력은 출력 판) · 머리 이름 · 단위 칸 작은 크기(30px) · 「새로 만들기」 단추 76px(칸 폭 710 아님) · 표준 표 모달 제목 한 번 · 남은 흠(sub2): 선 규칙 표에서 찾기 조건 칸에 범위 이름(「높이」)이 없어 범위 찾기를 화면에서 못 만듦(높이 5 → 줄 없음 · 4 만 맞음) · 브레인 실제 검증완료(흠 14 고침 · 흐름도 시험 81 · 견본 다섯 서버 = 화면 · 보고 JgfqRzLPYgqXG7vB2cV1Yj) · 검증 대기 + +## 36. 단가산출 흐름도 본 프로그램 적용 (사용자 지시 10-1 21:40 · 결정 반영 22시 · 착수 승인 22:20 · 밤새 진행 · 아침 보고) + +- 사용자 확정: 흐름도 = 최종 · 로직 목록에서 로직을 열면 지금 내용 그대로 + 오른쪽 흐름도 패널(같은 로직) · 「식으로 새로 만들기」 지우고 「신규 로직」(흐름도 새로 만들기) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽) · 시험판 단추 · 입구 지움 · DataFlow 폴더 유지. +- 근거(마스터 로직 57 파일 · 1,472 로직 · 입력 3,518 · 중간 식 3,168 · 호표 줄 5,265): 식 말 = 찾기(표 · 조건 · 칸) 8,152 · 만약 3,532 · 또는 185 · 최대/최소 173 · 올림/반올림/버림 141 · 로직() 41 → 흐름도 표에서 찾기 · 조건 · 함수 · 다른 로직 입력으로 옮길 수 있음. +- 사용자 결정 D1(그대로): 「품셈원문도 제대로 구현된게 아니기 때문에 내가 흐름도까지 만들어서 로직을 볼려는 거잖아. 그러니 당연히 둘다 같은 내용 … 원문과 대조하면서 검증할때 잘못된게 있으면 흐름도에서 수정할거야」 → 한 로직 · 두 보기: 왼쪽 지금 내용 = 보기 · 고치기는 흐름도(공용 로직 포함 · 시스템 관리자) · 흐름도로 저장한 로직은 흐름도가 정본 · 왼쪽은 흐름도에서 만든 같은 꼴 · 아직 저장 안 한 로직은 지금 내용에서 자동으로 그린 흐름도(값 대조로 같음). +- 사용자 결정 D2(그대로): 「흐름에 대한 로직은 costflow폴더에 있을테니 일단은 그냥 두고 쓸대없는 견본과 버튼만 삭제」 → CostFlow 폴더 그대로 · 시험판 단추 · 견본 목록 지움(흐름도는 DataFlow 폴더만으로 돌아감 · CostFlow 를 부르지 않음). +- 사용자 결정(함수 상자): 「함수박스를 기준으로 하나의 결과가 나가는게 맞는것같아. 여러개가 동시에 나가면 혼란만 가중」 → 함수 상자 하나 = 결과 알약 하나(그대로) · 식은 상자 겉에 넣지 않음(상자가 커짐) · 함수 상자 · 선을 고르면 상세 내용에 사칙연산 · 조건이 보이고 고치면 바로 따라감. +- 칸 연결 = M02 방식 그대로(칸을 고른 채 알약을 눌러 하나씩 연결 · 식은 칸 주소 · `spreadsheet_links.ts` 직접 확인) · 계산 엔진 · 이름 풀기 · 이름 규칙 없음 — 보기만 칸 주소를 연결된 알약 이름으로 바꿈. +- 사용자 결정(조건): 「조건문은 현재를 유지하는게 보기 편한것같고 대신 어떤값을 지정할지는 입력된 리스트에서 선택 … 조건 값을 내가 넣으면 오기 … 복수 조건문도 넣어주고」 · 「조건 만들기도 엑셀 기능 내부에서 존재해야해」 → 선 조건은 지금 웹앱 모양 · 조건 값은 고른 알약이 가질 수 있는 값 목록에서 고름(변수 고르기 · 표 칸 값 · 재료 규격 등 · 목록이 없는 수는 숫자 칸 + 범위 검사) · 조건 여러 줄(그리고 / 또는) · 함수 상자 엑셀 시트 안 「조건 만들기」(칸 고름 → 만약 · 아니면 만약 · 아니면 줄 → 그 칸에 IF 식 · 값은 같은 목록에서 고름). +- 안전: 화면 시험 저장은 마스터 사본 폴더에서만(서버가 환경 변수로 사본 폴더를 씀 · 실제 resources/master_data 는 안 바뀜 · git status 로 확인). +- 창: sub2 36-0(판 · 조건 만들기) · sub7 36-0b(조건 틀 · 값 목록) → 36-2 · sub6 36-1 → 36-7 · sub8 36-3 · sub4 36-4 서버 · 사본 폴더 · sub5 36-4 왼쪽 보기 · sub1 36-5 · 잇기 · 36-8 · sub3 36-6 +- 브레인 정함(조사 뒤 · 아침 보고에 알림): 옛 로직 「기계 · 직종 · 유가 자리」(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 입력값으로 요소를 고름 · 71 로직)는 새 모드 없이 이미 있는 「조건 찾기」(이름 · 키 같음 · 변수 알약)를 인력 · 기계 · 유가 요소에도 씀 · 대조 입력 = check_master.시험입력 · 로직 셈을 부르는 곳(Store.calc · text · master_combo.run · M02_Item_Prices.calc · /logic/auto)은 한 곳에서 흐름 갈래. +- [x] 36-0 함수 상자 안 보기 · 칸 연결 조정(36-2 보다 먼저) — ① 상세 내용 「식 보기」: 결과 칸 식을 칸 주소 → 연결된 알약 이름으로(예: 1m 재료비 = 흄관 단가 ÷ 2.5) + 값 들어간 식(= 211,700 ÷ 2.5 = 84,680) · IF 는 만약 / 아니면 줄 · 지금 타는 갈래 표시 · 중간 칸 식도 차례대로 · 칸 식을 고치면 바로 다시 셈 → 다이어그램 · 원가 따라감 ② 「칸 연결 표」(칸 · 연결 알약 · 입력 / 결과 · 이어진 선 · 표에서 바로 바꿈 · 풀기) + 칸 색(입력 파랑 · 결과 초록 · 상자 색과 같음) · 결과 칸 하나 ③ 선을 고르면 그 선 규칙도 같은 「식 보기」 꼴(예: 굴착량 → 지반 = 토사 일 때만 → 굴삭기로 파기) ④ 엑셀 시트 안 「조건 만들기」 — 칸을 고르고 열면 만약 [알약] [비교] [값 고르기] → [알약 · 값] 줄 · 아니면 만약 더하기 · 아니면 → 그 칸에 IF 식(엑셀로 쓴 IF 도 같은 줄로 다시 열림 · 못 바꾸는 식은 식 그대로) · 검증완료(sub2 · 식 보기 · 칸 연결 표 · 조건 만들기 · 선 조건 여러 줄 · 시험 8) · 검증 대기 +- [x] 36-0b 선 조건 손질 — 조건 값 = 고른 알약의 값 목록에서 고름(Model 값 목록 함수 · 변수 고르기 · 표 칸 값 · 재료 규격 · 목록 없는 수는 숫자 칸) · 조건 여러 줄(그리고 / 또는) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 위 이름표 · 겹침 · 안 맞음 검사 그대로 · 검증완료(sub7 · 조건 여러 줄 그리고/또는 · 값 목록 · 서버 = 화면 · 시험 30) · 검증 대기 +- [x] 36-1 조사 — 옛 식 문법 전수(서버 파서 · 요소 부르기 꼴 · 따옴표 글) · 변환 못 하는 꼴 목록 · /calc 를 부르는 곳 · 검증완료 +- [x] 36-2 변환 — 옛 로직(입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수) → 흐름도(입력 상자 · 표 알약 + 표에서 찾기 · 함수 · 출력 상자) + 자동 배치 · 서버 파서로 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(만약 → IF · 최대 → MAX · 찾기 → 표에서 찾기) · 함수 상자 하나 = 결과 하나 · 전수 대조(변환 흐름도 값 = 옛 계산 값 · 맞는 비율 · 안 맞는 까닭) · 검증완료(sub7 · M02_DF_Convert · 전수 1,472 시험입력 96.7% · 기본 입력 97.4% · 안 맞는 49 = 재료 검색어 자리 18 · 재료 빈 검색어 13 · 표 범위 규칙 경계 15 · 단가 빈 품목 3 · tmp/convert_report_36.json) · 검증 대기 · 마지막 대조: 시험입력 · 기본 입력 모두 1,472/1,472(100%) · 빈 단가 = 옛 셈 뜻(그 값 쓰는 식 빈 값 · 비목 합 0 · 끊긴 곳 「단가 없는 품목을 씀」) +- [x] 36-3 오른쪽 패널 — 로직 상세 오른쪽에 흐름도(다이어그램 · 상세 내용 · 원가 · 검증) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면) · 검증완료 +- [x] 36-4 저장 · 정본 — 로직 줄에 「흐름」 칸(화면 [저장] 때만 씀) · 흐름이 있으면 /calc 가 흐름도로 셈(서버 다시 셈) · 왼쪽 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산은 흐름도에서 만든 같은 꼴(보기) · 공용 로직도 흐름도로 고침(시스템 관리자) · 캐시 → [저장] 한 번 · [고친 것 버리기] 함께 · 왼쪽 보기 검증완료(sub5) · 서버 검증완료(sub4) +- [x] 36-5 단추 — 「식으로 새로 만들기」 지움 → 「신규 로직」(빈 흐름도 + 기본정보 · 저장 = 자체 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(흐름도도 복사 · 신규 로직 오른쪽) · 「시험판 ▾」 지움 · 잇기 검증완료(sub1 · accf8ecc4 · abbf61971 · 0d45014e3) — 「식으로 새로 만들기」 · 시험판 ▾ · 시험판 단추 둘 지움 · 단추 차례 ← 로직 목록 · 신규 로직 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장 · 두 칸(LogicLab_Flow) · 오른쪽 패널(흐름 받기 · 고침 → drafts → [저장] 한 번 · 버리기 함께 · 흐름 로직이면 왼쪽 흐름 보기) · 신규 로직 기본정보 칸(LogicLab_Basic) → 저장 = 서버 added 키로 엶 +- [x] 36-6 다른 로직 입력 — 대상 = 마스터 로직 전체(찾기) · 흐름도 로직이면 흐름도 셈 · 아니면 옛 셈 · 목록 · 찾기 · 쪽 · 알약 이음 검증완료(서버 흐름 받기 · 값 셈 길이 main 에 오면 GF 로직 값 확인) +- [x] 36-7 정리 — 「시험판 ▾」 · 시험판 입구 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 resources/tester 로 · 시험 저장 길 숨김 · DataFlow · CostFlow 폴더 그대로 · 검증완료 +- [x] 36-8 끝 검증 — 흐름 기준 · 옛 로직 열기 → 오른쪽 흐름도 값 = 왼쪽 계 · 신규 로직 → 저장 → 다시 열기 · 복사 · 권한 · 브레인 실제 검증 · 보고 · 끝 검증완료(sub1 · 사본 폴더 tmp/master_copy_36 + AISLO_MASTER_DIR · 네 포트) — ① 옛 로직 셋: GF000001(찾기) 오른쪽 34,413 = /calc 34,413.6 = 왼쪽 34,414(보임 끝수만 다름) · 변환 알림 한 줄 / GF000007(만약) 0 ≠ 597,285 · GF000288(로직()) 0 ≠ 1,234,760.96 — 아래 흠 ② GF000001 함수 식 ×1.5 → 오른쪽 · 왼쪽 51,620 따라감 → [저장](공용 GF · 사본) → 다시 열기 상자 6 · 변환 알림 없음 · /calc flow 51,620.4 = 화면 ③ 신규 로직 65 걸음(변수 셋 · 함수 · 조건 만들기 · 출력 둘 · 조건 선) → 저장 GX000004 → 다시 열기 상자 4 · 선 4 · 화면 100,000 = /calc 100,000 = 왼쪽 계(높이 2 = 0 · 지반 암 = 50,000) ④ 로직 복사/생성 2 걸음 → GX000005 상자 4 · 선 4 · 100,000 ⑤ 선 조건 두 줄 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 값 고르기(토사|암) · 높이 2 → 0 · 또는 → 50,000 · 조건 만들기 =IF(A2>=3,A3,0) ⑥ 식 보기 「결과 1 = 금액 × 높이 ÷ 4」 · IF 만약 / 아니면 줄 · 칸 연결 표 ⑦ 크게 보기 1484×1076 · 거기서 고친 것 패널로 · 접기 40px · 펴기 · 너비 320 → 522 ⑧ 고친 것 버리기 5 → 4 · 캐시 비움 · 로그인 없음 flow · save · calc · logics 401 · 시험판 · 옛 단추 글 0 · resources/master_data 바뀜 0 · 흠(다른 창): 변환 숫자 입력 예시값이 비어 있음(시험입력 · 기본 입력을 안 넣음 — GF000288 나무높이 6 · 집재거리 100 · GF000007 정리량 50 …) · GF000007 「표 QF000033 → 할증」 표에 맞는 줄 없음(서버 477,828 = 할증 25% 빠짐 · 화면 0) · GF000288 다른 로직 GC000270 기계 자리 「EQ:{기계} … 자리」 못 풂(#N/A → 0) · 칸 #N/A 일 때 화면 셈 0 · 서버 셈 그대로(둘이 다름)(sub7) · 식 보기 값 줄이 선 규칙을 지난 값을 안 씀(「(#N/A × 0) = #VALUE!」 · 결과 알약은 34,413.6)(sub2) · 조건 통과 둘째 줄 값 목록 「number:0」 글 · 조건 만들기 만약 목록에 놓인 알약 없음(sub2) · 옛 로직 왼쪽 계 반올림 · 흐름 원가 계금 1원 버림(1원 차이) +- 36 마무리(10-2 02시 · 브레인 실제 검증완료): 전수 변환 대조 시험입력 1,469/1,472(99.8%) · 기본 1,466(99.6%) · 남은 3 = 마스터 빈 단가 · 시험 141 + 변환 시험 초록 · 사본 서버 저장 → 사본만 바뀜 · 다시 열기 흐름 정본 · GF000001 34,414 = 34,414 · GF000003 8.946 = 8.946 · 식 보기 값 줄 · 검증 대기 + +## 37. 일위대가 조합 다이어그램 (사용자 지시 10-2 09시 · 계획 · 착수) + +- 사용자 확정: 「복잡한 경우가 있으니 일위대가 조합도 다이어그램으로 표현 … 중요하게 생각하는 정보가 다르니 잘 구분」 · 조합은 계산하지 않고 로직을 뭉쳐 비용 구분별로 표기 · 입력은 로직별로 묶어 빈 값은 사용자가 넣음 · 출력은 로직마다 · 미리 보기 값은 구조물 조합으로 안 넘김 · 「흐름도는 별도 로직이 아님」(단가산출 로직을 쓰는 방법). +- 모양: 왼쪽 = 로직별 입력 묶음(받아야 할 값 · 빈 값 표시) → 가운데 = 로직 카드(키 · 이름 · 결과 단위 · 식 · 함수 안 보임) → 오른쪽 = 비용 구분 칸 셋(노무비 · 재료비 · 경비 · 로직마다 선으로 모임) + 값 로직은 「값」 칸 · 이름이 같은 입력(지역 · 자재지역 등)은 점선으로 이어 보이기만(검사 없음) · 로직 카드를 누르면 그 로직의 단가산출 로직 화면(흐름도)으로 · 계산 · 고치기 없음(묶음 고치기는 지금 표) · 흐름도와 모양 · 색을 달리함. +- 자리: 일위대가 조합 상세(M02_MasterTemplete_Labor_Edit)의 미리 보기와 같은 값 — 미리 보기 입력 칸에 넣은 값이 다이어그램에도 · 다이어그램 빈 입력 알약을 누르면 미리 보기 그 칸으로 · 미리 보기 결과(로직별 노무비 · 재료비 · 경비 · 계)가 카드 · 칸에. +- [x] 37-1 다이어그램 판 — M02_MasterTemplete_Labor_Diagram.ts(+css) 새 파일 · 고정 세 칸 배치(자동) · 판 옮기기 · 확대 · 한눈에 · 밝음 · 어두움 · 좁은 폭 +- [x] 37-2 잇기 — Labor_Edit 에 「다이어그램」 칸(접기) · 미리 보기와 값 한 벌 · 빈 입력 누름 → 미리 보기 칸 · 로직 카드 누름 → M01 단가산출 로직 그 로직 열기(setPendingLogicKey 꼴) +- [x] 37-3 검증 — 조합 셋(로직 2 · 9 · 14개)으로 입력 묶음 수 = 로직 입력 수 · 출력 = 미리 보기 줄 · 비용 칸 합 = 미리 보기 합 · 빈 값 표시 · 같은 이름 점선 · 로직 열기 · 브레인 실제 검증 · 보고 +- 창: sub2 37-1 · 37-2 · 37-3 + +## 38. 마스터 요소 화면 손질 (사용자 지시 10-2 09:40 · 착수) + +- 사용자 확정: ① 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 상세의 제목 · 단추 · 찾기 라벨 양식 = 단가산출 로직과 같게 ② 재료 컨테이너의 유가 · 전력을 별도 컨테이너로 ③ 단가산출 로직 「막힘만」 체크 — 의미 없으면 지움 ④ 단가산출 로직 컨테이너 「전체」 를 누르면 상세 목록 보기가 안 됨(다른 컨테이너는 됨). +- 브레인 정함: 「막힘만」 = 로직 검사에 걸린 로직만 거르기 · 지금 1,469 중 0 · 목록 줄에 「⛔ 막힘」 표시 · 까닭 말풍선은 이미 있음 → 체크만 지움(표시는 둠). +- [x] 38-1 양식 맞춤 — 일곱 상세 화면 머리(제목 · 단추 줄 · 찾기 칸 라벨)를 단가산출 로직 화면 꼴로 · 공용 단추 · 칸 크기(sub5) · 검증완료 +- [x] 38-2 유가 · 전력 컨테이너 — 왼쪽 패널에 재료 다음 「유가 · 전력」 컨테이너(재료_유가전력.json) · 재료 컨테이너에서는 뺌 · 고르기 · 찾기 · 상세 그대로(sub6) · 검증완료 +- [x] 38-3 「막힘만」 체크 지움 · 38-4 「전체」 목록 보기 고침(sub1) · 검증완료 — 원인: 로직 컨테이너 거름이 목록만 거르고 가운데 화면 전환(onLogic)을 안 부름 → Side 거름 run 에서 로직이면 onLogic · 「전체」 는 찾는 글도 비움 · 화면(5174): 로직 1,469 줄 → 인력 전체 → 로직 「전체」 = 로직 화면 1,469 줄(찾는 글 「굴착」 비움) → 산림품셈 330 → 03장 작업장 관리 10 → 로직 열기 → 「전체」 = 상세 닫힘 · 1,469 줄 · 막힘만 체크 0 · 화면 오류 0 +- 「고친 것 버리기」(사용자 물음 · 확인만): 저장 안 한 고친 것(화면 캐시)을 버리고 파일의 로직을 다시 엶 — 사본 저장은 없음 · 그대로 둠(사용자 결정 기다림) From 6c28357fb74c4055da8334561735c966457fedd9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:19:20 +0900 Subject: [PATCH 04/24] auto: 2026-10-04 17:19 (ESD_LAPTOP) --- .../raw/M02_구조물조합개편/사용자요구_1004.md | 50 ++++++++ docs/raw/M02_구조물조합개편/설계_정함.md | 105 +++++++++++++++++ ...가산출_흐름도_조합다이어그램_마스터요소.md | 108 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004.md create mode 100644 docs/raw/M02_구조물조합개편/설계_정함.md create mode 100644 docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004.md b/docs/raw/M02_구조물조합개편/사용자요구_1004.md new file mode 100644 index 000000000..936caa071 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004.md @@ -0,0 +1,50 @@ +# M02 구조물 조합 개편 — 사용자 요구 (2026-10-04 · 원문 그대로) + +1번(계획서 32장 이후 정리)은 브레인이 처리함(c8269a969). 7 · 8번은 원문에 없음. + +## 2. 일위대가 조합 → 구조물 조합 하위 + +M02페이지의 마스터 템플릿 페이지에서 일위대가 조합은 구조물 조합의 하위 컨테이너로 넣을 것.(구조물 도면과 면적산출 사이에 배치) +- 일위대가 조합의 좌측 패널의 기존 보기 방식인 트리구조는 필요없으며 구조물 도면 처럼 리스트형태로 보임 +- 추가로 구조물 도면, 일위대가, 면적산출의 최상위에는 전체항목을 넣어주고 우측 상세에는 리스트로 보여줌, M01 항목을 보면 양식화되어 있는 페이지 헤이아웃 있음 + +## 3. 이름 + +이동한 일위대가 조합은 일위대가로 명칭 변경 + +## 4. 구분선 + +구조물 조합 컨테이너 내부의 서브 컨테이너 항목과 트리구조 사이에 수평 구분선 추가 + +## 5. 사용자별 구조물 조합 읽기 / 쓰기 / 복사 권한 점검 및 개선 + +- 시스템 관리자 : 아이슬로 프로그램 관리자로 마스터를 제공하여 회사관리자나 일반 사용자가 사용하기 쉽게 한다. +- 시스템관리자가 대시보드의 컨테이너 제목의 우측의 회사명을 선택함에 따라 변경한 목적은 해당 회사의 정보를 접근하고나 AS을 목적으로 고객을 도와주기 위함임. 복사항목에는 마스터나 시스템 관리자 회사, 개인으로 들어갈 항목 뿐만 아니라 고객 회사의 특정 폴더로 복사할 수 있는 기능이 있어야함. 수정/생성권한 포함 +- 회사관리자 : 고객으로 본인회사의 양식을 만들거나 개인 프로젝트에 사용할 개인 마스터데이터 관리 +- 회사관리자는 개인버튼을 누를경우 최상위 트리에는 다른 개인들의 정보를 보고 복사하여 개인에게 복사하거나 수정, 생성도 가능해야함. +- 개인은 마스터와 회사는 읽기만, 다른 개인의 정보는 볼수 없음. 트리는 현재를 유지해야함. + +## 6. 구조물 조합 항목 변경 + +- 최상위 구조물 조합에 구조물 도면, 일위대가, 면적산출로만 표현(기존의 재료산출의 사용자가 추가하는 재료들과 값은 엑셀을 사용하지 않고 일위대가의 담은로직 처럼 하위에 별도의 컨테이너로 추가되는 자재항목을 넣어야함. 이때 M01자재목록에서 하나씩 가져오되 수량은 면적산출의 알약으로 값을 싱크하거나 별도의 모달창에서 셀을 지정하게 구현) +- 재료산출 항목은 삭제 +- 선택입력 항목은 삭제(구조물 조합에서는 각 옵션의 상세 구조물에 따라 미리 옵션을 다 지정할 생각임으로 그럴필요 없음) +- 면적산출의 미리보기에서 아래 알약 그룹 행을 하이드/쇼 되게 반영(기본값은 하이드), 단, 면적산출 상세에서는 알약 행 보이기가 기본값으로 +- 면적산출 상세 화면의 우측 패널에서 현재의 일위대가 조합 목록을 검색하는 기능이 아니라 현재 구조물도의 일위대가 담은 로직과 추가될 사용자 지정 재료를 보여줄것, +- 일위대가 상세화면에서는 미리보기(보기만/저장안함) 컨테이너는 각 단가산출 로직에 입력이 필요한 정보들임. 구조물별로 하나씩만들어져야함으로 구조물의 일위대가 항목에 옵션값이 저장되야함. 그리고 이값에 대한 지정은 담은 로직 컨테이너의 항목을 누르면 모달창이 나오고 사용자가 입력 및 변경할수 있게함. (하위에 지정된 항목과 값을 작은 글씨로 표현되게 반영) +- 담은 로직에는 기준 단가를 기준으로 넣었을때 나온 값과 수량산출값을 넣었을때 값에 대한 노무비, 재료비, 경비, 합계를 같이 보여줌. 이때 수량산출은 면적산출의 특정 셀값이나 사용자가 수기로 입력한 값이 될수 있음. 이렇게 되면 미리보기 계산 컨테이너 필요 없음. +- 일위대가의 상세 화면의 순서는 담은로직 > 자재목록 > 다이어그램 순서임. +- 면적산출 산출 상세의 우측 패널에 보여져야하는 결과는 결국 일위대가의 담은 로직컨테이너와 자재목록임. +- 추가된 자재목록에도 기준에 대한 단가와, 면적산출 엑셀의 특정 셀과 연결되었을때 나온 단가가 같이 나와야함. + +## 9. 오른쪽 클릭 + +프로그램 B와 M영역의 페이지들에 마우스 오른쪽 클릭을 할 경우 브라우저 옵션 안보이게 할것.(우리 프로그램의 옵션은 허용, 공통양식 사용) + +## 사용자 답 (10-4 · 일위대가 · 원문 그대로) + +일위대가의 경우 +1. 구조물 조합을 복사한 경우 신규 키를 생성하여 만듬. (값과 로직은 일단 같이 복사) +2. 신규 구조물 조합을 생성하는 경우 미리보기 페이지에는 새로만들기와 복사생성하기를 선택하게 하고 복사 생성인 경우에는 해당 리스트를 보여주고 선택하게 보여주는 값은 개인인 경우에는 개인을, 회사관리자인경우 개인, 시스템관리자인 경우 마스터를 기본값으로 보여주고 탭을 눌러 다른 항목에서 가져올수 있는 항목들을 보여준다. +3. 다시 말해 최종적으로는 구조물 조합에 일위대가, 도면, 면적산출이 연되는게 맞으나 특정항목만 다른 값을 복사하여 가져올수 있어야함. 저장이 된 데이터라도 변경가능해야함. +4. 현재 매칭이 안된 데이터는 나중에 삭제할예정이나 마스터 데이터를 수동 생성을 위해서 필요한 데이터들임. 차라리 실무문서라는 항목을 마스터에 별도로 만들어서 나중에 한번에 지우게 할수 있게 반영. diff --git a/docs/raw/M02_구조물조합개편/설계_정함.md b/docs/raw/M02_구조물조합개편/설계_정함.md new file mode 100644 index 000000000..229b350fd --- /dev/null +++ b/docs/raw/M02_구조물조합개편/설계_정함.md @@ -0,0 +1,105 @@ +# M02 구조물 조합 개편 — 정함 (브레인 · 10-4) + +근거 = `사용자요구_1004.md` · 조사 보고 A~H(각 창 `tmp/조사_39-0X.md`). 사용자가 바꿀 수 있음. + +## 1. 자료 틀 (B · sub7) + +- 일위대가 = 구조물 열마다 `labor/<열id>.json` · 번호 `SU-######`(도번 · 면적번과 같은 뒷자리 · 층마다 셈) · 자리 = 시스템 `resources/master_template/labor/` · 회사 · 개인 `…/templates/labor/` · 새 프로젝트 씨앗도 KINDS 로 함께. +- 문서 꼴: + +```json +{ + "종류": "일위대가", "판": 1, "열": "item1", "일위대가번": "SU-000001", "단위": "개소", + "본뜬": { "층": "system", "주인": null, "키": "UA000008" }, + "담은로직": [ + { "로직": "GF000219", "메모": "", "입력값": { "형식": "메쌓기" }, "수량": null }, + { "로직": "GF000137", "메모": "", "입력값": {}, "수량": { "손": 3 } } + ], + "자재": [ { "코드": "MT000008", "이름": "치즐", "규격": "", "단위": "EA", "수량": null } ], + "계산값": { "줄": [], "자재": [], "기준합": {}, "반영합": {} } +} +``` + +- 이름 = 구조물 열 이름(따로 안 둠) · 단위 = 자기 칸 · 같은 로직 두 번 금지 · 빈 일위대가 저장 됨 · 이름 겹침 검사 없음 · 입력값 = 그 로직 입력 이름만(모르는 이름 버림 · 빈 값 = 자동값). +- 수량 = `null`(면적산출 알약 연결 따름 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」 · 칸 연결 정본 = 면적산출 문서 `연결` 한 곳) | `{손: 수}`(손 입력 · 손이 칸보다 앞섬). +- 금액 = 기준(결과단위 그대로 · 원/10㎡ 면 10㎡ 값 · 단위 같이 표기) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 「원/1,000본」 배 1000(`_PER` 고침) · 자재 기준 단가 = M01 `master_material.element` 기본값. +- 계산 자리 = 기준 ③ 서버(M01 엔진 · calc 길 · 화면은 최신 응답만) · 반영 곱 ① 짝(`M02_MasterTemplete_Labor_Calc.ts` ↔ `M02_Labor_Doc.py` · 두 파일 머리에 짝 · 거울 시험 · 면적 칸 값이 바뀌면 화면이 바로 곱함) · [저장] 때 서버가 `계산값` 을 다시 적음(브라우저 값 버림 · 목록 합계용) · 상세는 늘 새로 셈. +- 길 = 문서 `GET/PUT /api/m02/layers/{층}/templates/labor/{열}`(양식 길 · owner) · 계산 `POST /api/m02/layers/{층}/labor/{열}/calc` {doc?, 수량?: {키: 값}} → {줄[{로직, 분모, 배, 수량, 수량출처, 기준{노무비,재료비,경비,계}, 반영{…}, 까닭}], 자재[{코드, 기준단가, 수량, 수량출처, 반영, 까닭}], 기준합, 반영합} · 알약 `GET /api/m02/layers/{층}/labor/{열}/pills`(로직 물량 + 자재). +- 집계표 열 `일위대가` · `자재조합` 칸 지움(정본 = labor 파일) · SS 장부 줄 = `일위대가번` · `도번` · `면적번`. +- 옮기기(한 번 · `M02_Labor_Migrate.py`) = 층마다 열마다 labor 파일 · 열 `일위대가` 키 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · 수량 null · 자재 []) · 여러 열이 쓰던 조합(UP10-000001)은 열마다 복사 · 키 없는 열 = 빈 일위대가 · 몇 번 돌려도 같음 · 시스템 층(git)은 바로 · storage 층은 압축 사본 뒤 브레인이 돌림. +- 실무문서 = 마스터 `resources/master_data/일위대가조합.json` 통째(37 줄 · 손대지 않음 · 옮긴 뒤 어느 열도 안 가리킴 · M01 일위대가 조합 화면도 그대로) · 회사 · 개인 옛 조합(UC · UP)은 파일 그대로 · 화면에서 뺌. +- 프로젝트 = 스냅샷 `일위대가.json` 은 labor 문서 + 담은 로직 줄 · `입력값.json`(모양 그대로) 첫 값 = labor 입력값 · `단가.json` 에 자재 MT 줄 · 계산은 반영 같은 함수. +- 은퇴(화면이 안 부른 뒤) = 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 · 미리 보기 길(preview) · 일위대가 조합 나무 길. +- 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서(32장 「로직 지우기 서버 검사」 줄 끝남). + +## 2. 좌측 패널 (A · sub5) + +- 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · 구조물 조합 안 차례 = 층 단추 + 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출. +- 하위마다 첫 줄 「전체 항목」(누르면 `onAll(kind)` → H 목록) · 일위대가 줄 = 열마다 「SU-… 이름」 · 누름 = `onOpen({kind: "labor", name: 열})`(C 상세). +- 이름 「일위대가」(하위 제목 · 상세 제목 · 대시보드 카드 글) · 해시 `open=일위대가` = 구조물 조합 펼침 + 일위대가 하위 펼침 + 전체 항목. +- 「실무문서」 = 구조물 조합 밖 별도 컨테이너(도면 양식 아래) · 마스터 층 · 시스템 관리자만 · 목록 + 상세 읽기만(새 파일 `M02_MasterTemplete_Practice.ts`) · [통째 지우기](물음 두 번 · 서버 = 시스템 관리자 · 쓰는 곳 0 확인 · 줄을 비움 · 버튼은 사용자가 누를 때만). + +## 3. 일위대가 상세 (C · sub2) + +- UI_Main 종류 `labor` 로 엶(양식 길 읽기 · [저장] · [다시 읽기] · 캐시 = 다른 양식과 같음) · 차례 = 머리 → 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램 · 미리 보기 칸 지움. +- 담은 로직 줄 누름 = 입력 모달(그 로직 입력 전부 · 빈 칸 자동값 · 수량 = 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」 = 면적산출 상세로) · [확인] = 줄 값만(캐시) · 저장은 [저장]. +- 줄 아래 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 빈 입력 「빈 값 n」 붉게. +- 금액 칸 넷(노무비 · 재료비 · 경비 · 합계) · 칸마다 두 줄(위 기준 작게 · 아래 반영) · 맨 아래 합계 줄. +- 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액 · [자재 추가](M01 자재 하나씩 · `M02_Material_Pick.ts`) · 줄 누름 = 수량 모달. +- 다이어그램 = 저장된 줄 값 + calc 답(수량 있으면 반영 · 없으면 기준 · 표기) · 알약 누름 = 입력 모달 · 자재목록 = 카드 하나 → 재료비 칸. +- 보기 갈래(고치기 없음) 부품을 냄 — D 오른쪽 패널 · H 카드가 씀. +- 입력 목록 · 값 받기 부품은 선택 입력에서 옮겨 다시 씀(`M02_Labor_Inputs.py` · `M02_Labor_Values.ts` · `M02_Labor_Values_Model.ts`) · 값 칸 규칙 한 벌(M01 넣을 값) · 로직 입력이 바뀌어 사라진 값은 알림. +- 37장 「미리 보기 값은 구조물 조합으로 안 넘김」 뒤집힘. + +## 4. 면적산출 (D · sub3) + +- 미리보기 카드 알약 줄 켬끔 단추 · 기본 꺼짐 · pref(계정) · 상세는 늘 보임(단추 없음). +- 상세 오른쪽 패널 = 그 열 일위대가의 담은 로직 + 자재목록(C 보기 갈래 · 기준 · 반영) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 지움. +- 패널 줄 누름 = 칸 지정 모달(`M02_MasterTemplete_AreaLink.ts` · [지금 고른 칸으로] · 주소 · [풀기]) → 면적 문서 `연결` · spreadsheet 손잡이 `setLink(키, 칸|null, 항목)`. +- 알약 = 로직 물량 알약 + 자재 알약(`자재|MT키` · 알약 모드 「자재」 다시 씀) · 선택 입력 알약 빠짐. +- 면적 금액 서버 계산은 1장 calc 하나(따로 안 만듦). + +## 5. 목록 · 고르기 · 카드 (H · sub1) + +- 목록 부품 하나 `M02_MasterTemplete_ItemList.ts`(M01 머리 · 쪽 · 표를 import · 옮기지 않음) — 「전체 항목」 · 복사 생성 · 항목 하나 복사가 같이 씀. +- M02 상세 제목 = M01 꼴(18px · 단추 오른쪽) 전부. +- 목록 칸: 도면 = 도번 · 이름 · 폴더 · 단위 · 고친 날 / 면적산출 = 면적번 · … / 일위대가 = 일위대가번 · 이름 · 폴더 · 단위 · 담은 로직 수 · 합계(`계산값` · 없으면 「—」) · 고친 날. +- 「새 항목」 → 상세 칸에 [새로 만들기] [복사 생성] · 복사 생성 = 같은 칸 탭 + 목록 → 지금 보는 층 · 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 일위대가 새 번호. +- 카드 = 도면 · 일위대가 · 면적산출 셋(그 차례) · 빈 카드 = [새로 만들기] [복사 생성] · 찬 카드 = 「복사해 가져오기」(창 · 탭 + 목록 → 확인 → 덮어씀 · 번호 · 열 그대로) · 일위대가 카드 몸 = C 보기 갈래. +- 탭 = 마스터 · 회사 · 개인(나) · 다른 사람(드롭다운 · 맡은 회사일 때) · 실무문서(일위대가 고를 때 · 시스템 관리자) · 기본 = 시스템 관리자 마스터 · 그 밖 개인(회사 없으면 마스터) · 탭 목록 = E `readableSources`. +- 항목 하나 복사 서버 = `copy_from` 에 from_layer · from_owner(B 가 만듦) · 원본이 실무문서 조합이면 조합 → 일위대가 문서(입력값 빈 값). + +## 6. 권한 (E · sub4) + +- 「회사 C 를 맡음」 = 시스템 관리자 · 또는 C 의 회사 관리자 → C 회사 층 · C 모든 개인 층 = 보기 · 만들기 · 고치기 · 지우기 · 복사 대상. +- 일반 = 마스터 · 회사 읽기만 · 남 개인 안 보임 · 나무 그대로 · 프로젝트 apply · sources 도 같은 규칙. +- 사람 나무 = 맡은 회사면 켬 · 회사 사람 전부(나 맨 위 · 빈 사람 「비어 있음」) · 남의 층 첫 쓰기 = 소속 확인 뒤 뼈대. +- 「〇〇로 복사…」 = 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 사람) + 대상 폴더 고르기 창(`M02_MasterTemplete_TreePanel_CopyTo.ts`). +- 남의 층 쓰기 = `system_audit_logs` 한 줄(`M02_OTHER_LAYER_WRITE`). +- 실무문서 = 일위대가 조합 길 전부 시스템 관리자만(서버가 막음). +- 새 길(labor 문서 · calc · pills · 층 넘는 복사)도 `_root` · `_layer_dir` 한 벌을 거침. + +## 7. 지우기 (G · sub6) + +- G1 자재 조합(UM) · G2 재료산출(`material/` · SM · 카드 · 시트 · 옆 판 · 길 · 시험) 먼저 · G3 선택 입력(카드 · `inputs/` · 자리 · 알약 길)은 C 의 입력 모달이 선 뒤. +- `pickMaterial` · `MaterialItem` → `M02_Material_Pick.ts` 로 옮긴 뒤 지움 · 알약 모드 「자재」 · `LinkMaterial`(A00 spreadsheet) 남김 · `고른자재.json` 남김 · 프로젝트 `입력값.json` 남김. +- storage 층(회사 · 개인 · 프로젝트)의 `material/` · `inputs/` · `자재조합.json` 파일은 지우지 않음(읽기만 끊음 · 정리는 사용자) · git 안 시스템 층 · fixture 는 지움. +- 옛 SM 번호 버림 · 장부 `재료번` 칸은 다음 집계표 [저장] 때 빠짐. + +## 8. 파일 주인 (같은 파일 = 몫만 고침 · 받은 뒤 고침) + +| 파일 | 주인 | 다른 창 몫 | +| --- | --- | --- | +| `M02_MasterTemplete_Store.py` · `M02_Template_Layers.py` | sub7 | sub6 = material · inputs 줄 | +| `M02_MasterTemplete_Router_Items.py` | sub4(`_root`) | sub7 = copy 길 from_layer · sub6 = material · inputs 길 | +| `M02_MasterTemplete_Router_Combos.py` · `Combo_Store.py` | sub5(나무 길) | sub4 = 시스템 관리자 문 · sub2 = preview 지움 | +| `M02_MasterTemplete_UI_Side.ts` | sub5 | sub6 = material · matset 줄 | +| `M02_MasterTemplete_UI_Page.ts` | sub5 | sub4 = copyTo · sub1 = onAll 이음 | +| `M02_MasterTemplete_UI_Main.ts` | sub3(면적 길) | sub2 = labor 종류 · sub1 = 제목 꼴 · showView · sub6 = material 길 | +| `M02_MasterTemplete_ItemCards.ts` | sub1(카드 셋 · 빈 카드) | sub3 = 알약 켬끔 · sub6 = 재료 · 선택 입력 카드 | +| `M02_MasterTemplete_Structure.ts` | sub3(pillsFor) | sub1 = pickCopy · sub6 = inputs · material | +| `M02_MasterTemplete_TreePanel.ts`(670줄) | sub1(copyIn · 넘으면 `TreePanel_Clip.ts`) | sub5 = 일위대가 전용 칸 지움 · sub4 = 「〇〇로 복사…」 이음 | +| `M02_MasterTemplete_Labor_*.ts` | sub2 | sub5 = `Labor.ts` · `Labor_Tree.ts` 나무 지움 | +| `M02_MasterTemplete_Api_Fetch.ts` | 각자 자기 함수만 | — | +| `A00_Common/spreadsheet/spreadsheet_links.ts` · `spreadsheet.ts` | sub3 | — | +| 공용 메뉴 · 진입(`main.ts` · `cad_host.ts` · `ui_template_context_menu.*` · `ui_template_tree.*` · `spreadsheet_condition.ts` · `spreadsheet_menu.ts`) | sub8 | — | diff --git a/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md new file mode 100644 index 000000000..fa33578c6 --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_단가산출_흐름도_조합다이어그램_마스터요소.md @@ -0,0 +1,108 @@ +# 지난 계획 — 33~38장 (2026-10-01 ~ 10-02) + +단가산출 로직 점검(데스크탑 한시) · 쉬운 단가산출 · 로직 만들기 시험판(세 칸 판 · 시트 로직 · 다이어그램) · 흐름도 시험판 · 흐름도 본 프로그램 적용 · 일위대가 조합 다이어그램 · 마스터 요소 화면 손질. + +## 33. 단가산출 로직 점검 · 수정 (사용자 + 데스크탑 메인 · 보조 · 10-1 한시) + +- 사용자가 데스크탑 메인 · 보조와 직접 진행 · 브레인은 랩탑 sub1~8 로 사용자가 요청하는 다른 일을 맡음(10-1 18:00 무렵까지). +- 근거 = `docs/raw/M01_점검/종합/` · `docs/raw/M01_결정목록/` · `docs/raw/M01_입력목록/` · `docs/raw/M01_고침/`. + +## 34. 쉬운 단가산출 제어 (새 길 · 10-1 · 랩탑) + +- 사용자 확정(10-1 오전): 만든 쉬운 단가산출은 유지(지우지 않음) · 로직 정의 자체를 만드는 판은 제안만 · 허락 뒤 착수 +- 사용자 확정: 지금 단가산출은 그대로 · 「추가」 단추 옆 새 단추로 따로 들어감 · 코드는 `M01_MasterData/EasyCalc/` 한 곳(안 되면 폴더째 지움) · 초등학생도 추가 · 삭제 · 수정 · 시간이 걸려도 검토 먼저. +- 정함(브레인): 새 길은 지금 로직 형식을 그대로 읽고 씀(계산 엔진 안 바꿈) · 폴더 밖 참조는 단추 자리 한 곳만. +- [x] 34-1 검토 — 데이터 · 계산 지도(sub4) · 지금 화면 걸음 재기(sub6) · 실무 모양 + 갈래 비교(sub7) · 지난 시도(sub8) → `docs/raw/M01_쉬운단가/` · 지금 화면 새 로직 38 누름 · 5~6 창 +- [x] 34-2 설계 확정(브레인) — 문장 카드 · 품셈 기본은 「이걸 바탕으로 고치기」 · 쓰기는 `/logic/*` 만 · 1단계 숫자 수량 + 잠금 카드 → `docs/raw/M01_쉬운단가/설계.md` +- [x] 34-3a 단추 + 빈 창(sub1 · 「식으로 새로 만들기」 옆) 검증완료 +- [x] 34-3b 1단계 — Model(sub7) · Api(sub4) · Cards(sub2) · Pick(sub3) · Sheet(sub5) · 틀 · 잇기(sub1) — 설계.md 2 · 3절 · Sheet 검증완료 · Model 검증완료 · Api 검증완료 · Pick 검증완료 +- [x] 34-3c 2단계 — 표 보고 고르기(sub8) · 조건 고치기(sub7 · 조건 줄 93% 읽음) 검증완료 · 카드 단추 잇기(sub2) · 조건 고치기 검증완료 +- [x] 34-4 검증 — 대표 일 여섯 + 예외(빈 값 · 같은 이름 · 저장 안 하고 닫기 · 권한 없음 · 밝음 · 어두움) · 지우기 시험(폴더 밖 참조 줄 수) · 걸음(sub3 고침 뒤) ①21 ②8 ③2 ④4 ⑤2 ⑥3 = 40(목표 36 넘음 4 · ① 새 설계 값 자동값으로 채워 금액 곧장 보임) · 다른 일위대가 넣고 저장 됨 · 권한 없음(일반 사용자) = M01 화면 못 들어감 · /api/m01 403 · 폴더 밖 참조 두 줄 · 검증완료 + +## 35. 단가산출 로직 만들기 판 (제안 단계 · 착수 전) + +- 사용자 확정: 쉬운 단가산출처럼 따로 폴더 시험판 · 로직 몇 개 골라 만듦 · 페이지 보고 판단(마음에 안 들면 지움) · **착수는 허락 뒤** · 인터넷 조사 먼저 · 화면 용어는 단어로 다시 제안. +- 제안서 https://claude.ai/artifact/U4rkgpYWL4Em5VKBEVa4oY (세 칸: 원문 표 · 산출 과정 · 결과) +- [x] 35-0 인터넷 조사 — 견적 프로그램(sub6) · 노코드 규칙 도구(sub8) → `docs/raw/M01_쉬운단가/로직판_조사_*.md` · 제안서 2판에 반영 +- [x] 35-1 용어 단어 제안(제안서 2판 「용어 — 단어로」) → 사용자 확정 · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-2 시험판 — 사용자 허락(10-1 · 갈래가 여럿이면 갈래마다 · 목적 = 코딩 없이 원문 보며 확인 · 수정 · 생성) · A 세 칸 판 `LogicBuilder/` · B 엑셀 시트형(우리 스프레드시트) · 먼저 조사: 엑셀 로직 지도(sub4) · A 설계 초안(sub7) → 브레인 A 대 B 판단 → 구현 · 완료 처리(10-4): 35-2a 로 함 +- [x] 35-2a A 세 칸 판(가운데 문장 / 격자 두 보기) — 정함 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · Blocks(sub7) · 서버 · Api(sub4) · 원문 · 검사(sub8) · 문장(sub2) · 격자(sub5) · 단계 안내(sub3) · 틀 · 잇기 · 끝 검증(sub1) · LB 서버 검증완료 · Blocks 검증완료 · Grid 검증완료 · Steps 검증완료 · Source · Check 검증완료 · Wizard 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 원문 대조 6/6 블록 읽힘 · 걸린 줄 · 출처 보임 ② GF000219 표 칸 0.15→0.20 = 79,280→96,759 원(예상과 같음) · 파일 안 바뀜 · 다시 열면 0.15 ③ 단계 안내 11 걸음 + 저장 → 현장 다시 열림 60,984 원(/calc 같음) → 삭제 2 걸음 ④ 빈틈 해당 없음 10 · 못 따진 표 조회 1 · 기대값 시험표 맞음/다름 · 화면 초안만 ⑤ 표 고침 없을 때 lb/calc = /calc 8/8 · 문장 ↔ 격자 같은 값 · 예외(빈 이름 · 저장 안 하고 닫기 · Ctrl+Z · Ctrl+S · 권한 없음 403 · 밝음 · 어두움) · 폴더 밖 참조 LogicLab.ts 두 줄 + main.py 등록 한 줄 +- [x] 35-2b 남은 것(사용자 판단 뒤) — 문장 / 격자 고르기 · 표 칸 고침 저장 여부 · 적용 품셈 판 · 용어 · 칸 역할 「결과」 바꾸기 · 단계 안내 고르기 창 수량 물음 · GX000001(메인 11:53 · 12:02) 주인 확인 · 보고 https://claude.ai/artifact/NqKfHaJULcQo6dSvnsxy1n · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-3 점검(확인만) — 표끼리의 관계(로직) 자체를 초등학생이 만들고 · 바꾸고 · 지울 수 있나 · 품셈에 없는 관계 더하기 · 시나리오 여섯(sub6) — 결과: 있는 관계의 값만 고침(초등 중) · 관계 더하기 · 지우기 · 다시 잇기 · 새 입력값 · 새 표 · 로직끼리 바꾸기 안 됨 · 문턱만 바꾸면 계산 멈춤 + 엉뚱한 알림 · `docs/raw/M01_쉬운단가/로직판_관계제어_점검.md` +- [x] 35-4 다이어그램 제안(사용자 생각 10-1) — 상자 = 표 · 입력값 · 고정값 · 계산 · 항목 · 다른 로직 · 결과를 직접 다룸 · 선 = 관계를 만듦 · 품셈에 안 묶임(사용자 표 · 사용자 관계) · 관계 제어 = 다이어그램(사용자) · 위 가로 띠 + 아래 고른 상자 다루기 · 결과 · 관계 = 진짜 엑셀 수식(칸 주소 · 함수 · 사용자 확정) · 알약 = 입출력 칸 표시 · 새 종류 「시트 로직」(TS 엔진 한 벌 · 서버 Node) · 제안서 https://claude.ai/artifact/4iCAGPhE9maauGNTkXg5he · 허락 전 착수 금지 · 완료 처리(10-4): 35-5 · 35-7 로 함 +- [x] 35-5 시트 로직 시험판(사용자 허락 10-1 오후) — 위 가로 다이어그램 · 상자 = 시트 · 관계 = 엑셀 수식 · 입출력 = 알약 · 저장 = `M01_MasterData/SheetLogic/_data/`(git 밖 · 폴더째 지움) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` · Model · Eval(sub7) · 서버 · Api(sub4) · Diagram(sub8) · Panel(sub2) · Table(sub3) · Item · Result(sub5) · Import(sub6) · 틀 · 잇기 · 끝 검증(sub1) · SL 서버 검증완료 · Model 검증완료 · Diagram 검증완료 · Item · Result 검증완료 · Table 검증완료 · Import 검증완료 · 잇기 · 끝 검증완료(sub1) — 걸음: S1 ①②③ 각 3(79,280 · 73,085 손셈 같음 · 2.5m 문턱은 품셈 표 3m 부터라 2.5~3m 해당 없음) · S2 ① 3 ② 4(찰쌓기 표로) ③ 5 · S3 ① 약 19(경사 급 65,018) ② 9(내 표 붙여넣기 · 할증 10) ③ 7(잡재료 2% 17,551) · S4 선 끊기 2 · 상자 지우기 2(물음 한 번) · Ctrl+Z 되돌림 · S5 0(상자 · 선 · 값 한 장 · 고르면 이어진 선 진하게) · S6 항목 일위대가 11 · 다른 로직 상자 13(덤프트럭 16.2) · 새 로직(품셈 없이) 25 걸음 → 저장 서버 36,000 = 화면 → 다시 열림 → 지우기 2 · 서버 = 화면 4/4(36,000 · 17,550.9 · 10.12 · 다시 열기) · 가져오기 6/6 /calc 같음(0 · 60,984/79,280 · 2,064,816 · 670 · 16.2 · 57,516) · 예외(빈 이름 · 같은 상자 · 순환 · 빈 입력 물음 · 저장 안 하고 닫기 · 권한 없음 sl 5/5 403 · 밝음 · 어두움) · 세 칸 안쪽 세로 밀기 없음(6개 모든 상자) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 남은 흠(다른 창): 가져온 표 상자 + 결과 칸 목록 예시값이 글자로 들어가 30 등에서 해당 없음(sub5) · 가져온 로직 화면 번호 · 자재 검색어 · 끝 검증완료(S1~S6 · 새 로직 · 서버 = 화면 4/4 · 가져오기 6/6) · 남은 흠: 결과 칸 예시값 선택이 글자 「30」 으로 들어가 표 찾기 해당 없음(sub5 · 사용자 지시 뒤) +- [x] 35-5a 사용자 요청 — 다이어그램 띠 · 고른 상자 시트 · 결과 높이를 안쪽 세로 스크롤이 없을 때까지(sub1) +- [x] 35-6 로직 문법 먼저(사용자 방향 10-1 14시 무렵 · 화면보다 로직) — 바탕 표(인력 378 · 재료 30,767 · 기계 654 · 소요량 2,535 · 계수 498 · 환율 · 요율 · 사용자 표 — 보기 · 만들기 · 비목은 표가 정함) · 단위 넷(값 · 표 · 정보 · 요소) + 묶음(세분화 · 묶기 · 분류 · 묶음째 잇기 · 겹 묶음 · 틀 다시 쓰기) · 흐름 셋(정보 만들기 · 잇기[그대로 · 바꿔서 · 갈라서 · 모아서 · 묶음째] · 내기) · 확인 도구 · 제안서 https://claude.ai/artifact/BFWvm1Q4fhCSZ4Rt34UgYY · 다음 = 문법 확정 → 종이 연습(절 10개) — 허락 전 착수 금지 · 완료 처리(10-4): 35-7 · 35-9 흐름도로 갈음 +- [x] 35-5b 단추 공용 양식(점검 끝 · 고침은 사용자 지시 뒤) — 날 단추 23(목록 줄 8 · 칩 · 역할 · 보기 바꿈 11 · ▾ 2 …) · sm 단추를 44px 로 덮기 3 · 큰 시작 56px 3 · `docs/raw/M01_쉬운단가/시험판_단추_점검.md` · 완료 처리(10-4): 시험판 지움(35-9)으로 접음 +- [x] 35-7 단가산출 다이어그램 시험판(사용자 기준 10-1 14시 무렵) — 입력 · 함수 · 출력 상자 · 자유 배치 · 함수 = 엑셀 · 출력 하나 · 출력 = 비용 종류 · 원가 · 폴더 `M02_MasterTemplete/CostFlow/`(_data · _sample) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` · Model · Eval(sub7) · 서버(sub4) · Diagram(sub8) · 함수 판(sub2) · 입력 판(sub3) · 출력 · 원가(sub5) · 견본(sub6) · 단추 · 페이지 · 잇기 · 끝 검증(sub1) · Model 검증완료 · Output · Summary 검증완료 · Sample 검증완료 · FuncPanel 검증완료 · Diagram 검증완료 · CF 서버 검증완료 · InputPanel 검증완료(사용자 요소 만들기는 서버 Api 가 main 에 오면 확인) · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 손으로 메쌓기 꼴(변수 4 · 소요량 표 2 · 인력 2 · 함수 증가율 → 석공 / 보통인부 병렬 · 출력 노무비 2) 9 상자 · 약 138 손놀림 → 원가 148,471 = 견본(증가율 40 · 석공 97,882.512 · 보통인부 50,587.992 손셈 같음) ② 비용 종류 바꾸면 148,471 이 노무비 → 경비 → 재료비 로 옮겨감 · 「값」 이면 원가에서 빠짐(각 1 걸음) ③ 상자 끌어 옮김(1520,190 → 1675,306) · 저장 → 다시 열면 그대로 ④ 함수 출력 하나(다른 칸 고르면 옮겨감 · 알약 하나) · 순환 막힘 · 출력에서 나가는 선 없음 · 빈 받는 자리는 저장 때 물음 ⑤ 사용자 요소(시험 보조공 200,000) 5 걸음 · 마스터 그대로 · _data 에만 ⑥ 저장 때 서버 = 화면 2/2(148,470.504) ⑦ 단위 오른쪽 위(- → 원/㎡) · 색 바꾸기(Entry 판 머리) · 「다이어그램」 이름 · 다이어그램 726px · 안쪽 스크롤은 함수 격자(가상 200줄)만 · 밝음 · 어두움 · 권한 없음(cf 길 6/6 403 · 화면 못 들어감) · 견본 셋 = /calc · 손셈(148,471 · 3,000 · 15.552) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 끝 검증완료(손으로 메쌓기 9 상자 = 견본 148,471 · 비용 종류 옮김 · 자리 저장 · 규칙 · 사용자 요소 · 서버 = 화면 · 견본 셋 손셈) · 함수 출력 기본 이름 겹침 고침(sub7 2be767d18) · 남은 것: M01 단가산출 단추 줄이 셋째 줄까지(시험판 단추 넷 · 사용자 결정) +- [x] 35-7a 브레인 실제 검증(10-1 16:45) 흠 여섯 고침 — ① 고칠 것 칸 지난 경고 · ⑥ 시험판 단추 「시험판 ▾」 로 묶기(sub1) · ② 알약 쉼표 · ③ 표 알약 글 · ⑤ 맞춤 확대 90% 아래 안 감(sub8) · ④ 입력 상자 공통 단위(sub3) · 다시 검증완료(16:55 · 고칠 것 칸 비어 있음 · 알약 쉼표 · 표 · 40줄 · 인력 원/일 · 확대 90% · 막대 한 줄 · 시험판 ▾ 넷 · 원가 148,471) + +## 35-8. 단가산출 다이어그램 2판 — 재료 선택 자리 · 선 규칙 (계획 · 허락 전) + +- 사용자 확정: 고르는 방식은 재료만(다른 그룹은 상자를 늘림) · 설계 때 입력은 오른쪽 아래 검증 칸 · 나중에 프로젝트에서 일위대가로 쓸 때 재료는 목록에서 고르거나 단일 값 · 표에 따라 출력을 가르는 것은 엑셀 수식 대신 선 규칙. +- 정함(브레인): 재료 요소 = 고정 · 선택 자리 · 조건 찾기 · 알약 이름 = 자리 이름 · 선 규칙 = 선 하나에 하나(표에서 찾기 · 조건 통과 · 바꾸기) · 규칙은 받는 칸 엑셀 수식으로 바뀌어 엔진 한 벌 · 선 위 규칙 글 늘 보임. +- [x] 35-8a 틀(Model) — 재료 요소 방식 · 범위(구분 · 상세구분 · 찾는 말) · 조건(규격 ← 알약) · 기본 품목 · 선 규칙 칸 · 규칙 → 받는 칸 수식 · 조건 선 겹침 · 안 맞음 검사 — node 시험(sub7) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8b 서버 — 선택 자리 기본 품목 · 조건 찾기 단가(맞음 0 · 여럿 알림) · 「설계 때 정할 것」 목록(변수 + 재료 자리) 내보내기 — 시험(sub4) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8c 입력 판 — 재료 방식 · 범위 · 조건 규칙 · 기본 품목 · 찾기 고침(쪽 넘기기 · 띄어쓰기 AND · 규격까지) — ORCA(sub3) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8d 검증 칸 — 재료 선택 자리를 변수처럼(범위 안 목록 고르기 · 찾기) · 조건 자리는 잡힌 품목 표시 — ORCA(sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8e 선 규칙 — 선 위 규칙 글 · 선 누르면 아래 판에서 규칙 고르기(표 찾기: 조건 칸 ↔ 알약 · 꺼낼 칸 / 조건 통과: 알약 · 같음 · 다름 · 이상 · 이하 · 값 / 바꾸기: × · ÷ · +% · 단위 · 끝수) · 고른 상자 판에 들어오는 선 규칙 목록 — ORCA(sub8 · 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8f 견본 · 끝 검증 — 견본 둘 더(지반에 따라 굴삭기 / 브레이커 가르기 · 흄관 관경 조건 찾기) · 사용자처럼: 함수 상자 없이 표 찾기 · 가르기 · 검증 칸에서 재료 고르기 · 방법 걸음 수(sub6 견본 · sub1 잇기 · 끝 검증) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8g 다이어그램 마우스 — 판 옮기기 = 가운데 단추 끌기 · 고르기 = 왼쪽 누름 · 왼쪽 끌기로 글자가 선택되지 않음(다이어그램 영역 글자 선택 막음) · 상자 옮기기는 상자 머리 왼쪽 끌기 그대로 — ORCA(sub8) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8h 「고른 상자」 → 「상세 내용」 · 상자뿐 아니라 알약 · 선을 골라도 내용이 바뀜(알약 = 값 · 단위 · 어디서 오나 · 어디로 가나 / 선 = 선 규칙 고르기) — ORCA(sub1 잇기 · 알약 · 선 판 sub2) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8i 상세 내용 단추 · 칸 크기 — 함수 판 꾸밈이 공용 작은 단추를 44px 로 덮는 줄(`M02_CF_FuncPanel.css` `.m01-cfpanel .ui-btn …`)을 지우고 판 안 단추를 모두 공용 작은 크기(createButton size "sm")로 · 입력 · 고르기 칸도 공용 크기 — ORCA 수치(sub2 · sub3 · sub5 각자 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8j 상세 내용 머리 — 왼쪽 「상세 내용」 · 오른쪽에 고른 상자 제목 · 알약을 골랐으면 그 오른쪽에 알약 이름(예 「높이 증가율 찾기 › 증가율」) · 상세 내용 안 칸 차례 정리(머리 · 상자 설정 · 본문 · 경고) — ORCA(sub1) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8k 단위는 고르기 — 단위 칸을 목록 고르기로(치면 걸러져 보이고 고르기는 사용자 · 손으로 쓴 글은 안 받음) · 목록 = 단위 표준(35-8l) 단위 + 마스터 결과단위 + 금액 단위(원) · 「-」 = 없음 · 함수 · 출력 · 변수 · 사용자 요소 단위 모두 같은 고르기 — 공용 고르기 파일 `M02_CF_UnitPick.ts`(sub2) · 판마다 바꿔 끼움(sub2 · sub3 · sub5) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8l 단위 표준 표 — 산림품셈 1-2-2 「설계서의 단위 및 소수의 표준」(원문 `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-02_설계_및_수량.md` · 종목 약 70 · 규격 단위 · 소수자리 · 단위수량 단위 · 소수자리 · 비고 · 주 ①~③)을 표로 — 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 원문 흠은 사용자가 고침(시험판에서 보고 고치는 표 · 저장 `CostFlow/_data` · 처음 값 `CostFlow/_standard/`) — 시험(sub6) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8m 금액 단위 표준 — 같은 절 「금액의 단위표준」(설계서 총액 1,000원 · 소계 · 금액란 · 일위대가표 계금 1원 · 일위대가표 금액란 0.1원 · 모두 미만 버림)을 같은 표 자리에 — 출력 상자(비용) 금액 기본 = 일위대가표 금액란 0.1원 미만 버림 · 원가 합계 기본 = 계금 1원 미만 버림 · 바꿀 수 있음(sub6 표 · sub5 출력 · 원가) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8n 끝수 고르기 — 함수 상자 출력 · 출력 상자에 「끝수」(소수자리 · 반올림 / 올림 / 버림) · 단위를 고르면 단위 표준에서 소수자리를 제안(주 ① 반올림 · ② 항목 값이 우선) · 고른 끝수는 출력 칸 수식(ROUND 계열)으로 — 엔진 한 벌(sub7 틀 · sub2 함수 판 · sub5 출력 판) · 완료 처리(10-4): 35-9 에서 함 +- [x] 35-8o 로직끼리 잇기 · 흐름 따라가기 — 저장한 다른 로직을 입력 상자로 불러 그 출력 알약을 받음(일위대가 → 일위대가) · 알약 · 선을 고르면 값이 어디서 와서(출처 사슬) 어디로 가는지(가는 곳) 다이어그램에서 길을 칠하고 상세 내용에 줄로 · 값이 끊긴 곳(빈 받는 자리 · 통과 안 된 조건 · 표에 없는 조건) 표시 — 시험 · ORCA(sub7 틀 · sub8 다이어그램 · sub1 상세 내용) · 완료 처리(10-4): 35-9 에서 함 +- 차례(사용자 10-1 · 「데이터의 흐름과 값의 연결에 집중」): **1단계 = 흐름 · 연결** a 틀 · b 서버 · e 선 규칙 · o 로직끼리 · 흐름 따라가기 · h 알약 · 선 고르기 · c · d 재료 자리 · f 견본 · 끝 검증(흐름 기준 — 값이 어디서 와서 어떤 규칙을 지나 어디로 가는지) / **2단계 = 값의 뜻 · 보기** k 단위 고르기 · l 단위 표준 · m 금액 표준 · n 끝수 · j 머리 · g 마우스 · i 단추 + +## 35-9. 단가산출 흐름도 시험판 (사용자 지시 10-1 17시 무렵 · 브레인이 끝까지 · 35-8 전체) + +- 사용자 확정: 브레인이 전체 제어해 새 시험판 하나 · 단가산출 다이어그램(시험)만 남기고 옛 시험판 셋 삭제 · 새 폴더(필요 없으면 바로 지움) · 데이터 흐름 · 값 연결 먼저 · 자유도 높고 이해하기 쉬운 화면 · 끝까지 구현 · 검증. +- 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md` · 폴더 `M02_MasterTemplete/DataFlow/` +- [x] 35-9 창 여덟 — 옮기기 · Model · Eval(sub7) · 옛 시험판 지우기 · 들어가는 길 · 상세 내용 머리 · 잇기 · 끝 검증(sub1) · 서버(sub4) · 다이어그램(sub8) · 상세 내용 판 · 단위 고르기(sub2) · 입력 판(sub3) · 출력 · 검증 칸(sub5) · 표준 표 · 견본(sub6) · 표준 · 견본 검증완료 · Model 검증완료 · 출력 · 검증 칸 검증완료 · Diagram 검증완료 · 입력 판 검증완료(서버 자재 찾기 · UnitPick 이 main 에 오면 다시 확인) · DF 서버 검증완료 · 잇기 · 끝 검증완료(sub1) — ① 빈 판에서 함수 상자 없이 네 흐름 73 걸음: 재료 조건 찾기(관경 → 흄관 자리 · 13 개 중 하나 · 125,870) 21 · 선 규칙 표에서 찾기(높이 증가율표 → 출력) 19 · 조건 통과 두 갈래(지반 = / ≠ 토사) 27 · 다른 로직 잇기(견본 지반 · 지반 알약 이음) 6 ② 알약 · 선 고름 → 온 길 · 가는 길 칠함 + 상세 내용 줄(지반 → 다른 로직 · 두 조건 선 · 흄관 ← 관경) ③ 끊긴 곳 넷 보임(빈 받는 자리 · 통과 안 됨 · 표에 맞는 줄 없음 · 맞는 재료 없음 — 알약 · 선 「!」 · 끊긴 곳 줄 누르면 그 자리 고름) ④ 검증 칸 관경 600 → 800 = Φ800 품목 215,530 · 지반 토사 → 암 = 내 두 갈래 뒤집힘 + 다른 로직 2,000 → 10,000 · 다른 로직 설계 값 바꿈 10,000 → 20,000 ⑤ 단위 고르기 「mm」 = 3 개로 걸러짐 · 손글 안 받음 · 끝수 80,000.37 = 기본 225,870 / 1원 올림 225,871 · 표준 표 고침 저장(3 → 2) → 처음 값으로 되돌림 ⑥ 가운데 끌기 = 판 옮김(−60, −30) · 왼쪽 끌기 = 판 그대로 · 글자 선택 막힘 · 단추 18px 모두 sm · 칸 30px ⑦ 저장 → 다시 열기 = 상자 9 · 선 6 · 자리 · 규칙 글 그대로 · 서버 다시 셈 = 화면 187,910 · 견본 다섯 사본 저장 서버 = 화면(148,470.504 · 2,000 · 84,680 · 3,216.6 · 값 15.552) · 밝음 · 어두움 · 로그인 없음 df 길 6/6 401(관리자 아닌 계정 403 은 계정 없어 못 함) · 폴더 밖 참조 LogicLab.ts 2 + main.py 1 · _data 비움 · 검증 중 고친 내 흠(047f3a279 · 28469de62): 재료 조건 찾기 · 선택 자리 값을 서버에 안 물음(흄관 견본 관경 600 → 400 = 211,700 → 116,600 · 1m 84,680 → 46,640 · 1000 = 235,120 · 선택 자리 Φ600 → Φ400 = 211,700 → 116,600) · 「상자 이름」 칸(입력 · 함수 · 같은 이름 · 빈 이름 막음 · 출력은 출력 판) · 머리 이름 · 단위 칸 작은 크기(30px) · 「새로 만들기」 단추 76px(칸 폭 710 아님) · 표준 표 모달 제목 한 번 · 남은 흠(sub2): 선 규칙 표에서 찾기 조건 칸에 범위 이름(「높이」)이 없어 범위 찾기를 화면에서 못 만듦(높이 5 → 줄 없음 · 4 만 맞음) · 브레인 실제 검증완료(흠 14 고침 · 흐름도 시험 81 · 견본 다섯 서버 = 화면 · 보고 JgfqRzLPYgqXG7vB2cV1Yj) · 검증 대기 + +## 36. 단가산출 흐름도 본 프로그램 적용 (사용자 지시 10-1 21:40 · 결정 반영 22시 · 착수 승인 22:20 · 밤새 진행 · 아침 보고) + +- 사용자 확정: 흐름도 = 최종 · 로직 목록에서 로직을 열면 지금 내용 그대로 + 오른쪽 흐름도 패널(같은 로직) · 「식으로 새로 만들기」 지우고 「신규 로직」(흐름도 새로 만들기) · 「본떠 만들기」 → 「로직 복사/생성」(신규 로직 오른쪽) · 시험판 단추 · 입구 지움 · DataFlow 폴더 유지. +- 근거(마스터 로직 57 파일 · 1,472 로직 · 입력 3,518 · 중간 식 3,168 · 호표 줄 5,265): 식 말 = 찾기(표 · 조건 · 칸) 8,152 · 만약 3,532 · 또는 185 · 최대/최소 173 · 올림/반올림/버림 141 · 로직() 41 → 흐름도 표에서 찾기 · 조건 · 함수 · 다른 로직 입력으로 옮길 수 있음. +- 사용자 결정 D1(그대로): 「품셈원문도 제대로 구현된게 아니기 때문에 내가 흐름도까지 만들어서 로직을 볼려는 거잖아. 그러니 당연히 둘다 같은 내용 … 원문과 대조하면서 검증할때 잘못된게 있으면 흐름도에서 수정할거야」 → 한 로직 · 두 보기: 왼쪽 지금 내용 = 보기 · 고치기는 흐름도(공용 로직 포함 · 시스템 관리자) · 흐름도로 저장한 로직은 흐름도가 정본 · 왼쪽은 흐름도에서 만든 같은 꼴 · 아직 저장 안 한 로직은 지금 내용에서 자동으로 그린 흐름도(값 대조로 같음). +- 사용자 결정 D2(그대로): 「흐름에 대한 로직은 costflow폴더에 있을테니 일단은 그냥 두고 쓸대없는 견본과 버튼만 삭제」 → CostFlow 폴더 그대로 · 시험판 단추 · 견본 목록 지움(흐름도는 DataFlow 폴더만으로 돌아감 · CostFlow 를 부르지 않음). +- 사용자 결정(함수 상자): 「함수박스를 기준으로 하나의 결과가 나가는게 맞는것같아. 여러개가 동시에 나가면 혼란만 가중」 → 함수 상자 하나 = 결과 알약 하나(그대로) · 식은 상자 겉에 넣지 않음(상자가 커짐) · 함수 상자 · 선을 고르면 상세 내용에 사칙연산 · 조건이 보이고 고치면 바로 따라감. +- 칸 연결 = M02 방식 그대로(칸을 고른 채 알약을 눌러 하나씩 연결 · 식은 칸 주소 · `spreadsheet_links.ts` 직접 확인) · 계산 엔진 · 이름 풀기 · 이름 규칙 없음 — 보기만 칸 주소를 연결된 알약 이름으로 바꿈. +- 사용자 결정(조건): 「조건문은 현재를 유지하는게 보기 편한것같고 대신 어떤값을 지정할지는 입력된 리스트에서 선택 … 조건 값을 내가 넣으면 오기 … 복수 조건문도 넣어주고」 · 「조건 만들기도 엑셀 기능 내부에서 존재해야해」 → 선 조건은 지금 웹앱 모양 · 조건 값은 고른 알약이 가질 수 있는 값 목록에서 고름(변수 고르기 · 표 칸 값 · 재료 규격 등 · 목록이 없는 수는 숫자 칸 + 범위 검사) · 조건 여러 줄(그리고 / 또는) · 함수 상자 엑셀 시트 안 「조건 만들기」(칸 고름 → 만약 · 아니면 만약 · 아니면 줄 → 그 칸에 IF 식 · 값은 같은 목록에서 고름). +- 안전: 화면 시험 저장은 마스터 사본 폴더에서만(서버가 환경 변수로 사본 폴더를 씀 · 실제 resources/master_data 는 안 바뀜 · git status 로 확인). +- 창: sub2 36-0(판 · 조건 만들기) · sub7 36-0b(조건 틀 · 값 목록) → 36-2 · sub6 36-1 → 36-7 · sub8 36-3 · sub4 36-4 서버 · 사본 폴더 · sub5 36-4 왼쪽 보기 · sub1 36-5 · 잇기 · 36-8 · sub3 36-6 +- 브레인 정함(조사 뒤 · 아침 보고에 알림): 옛 로직 「기계 · 직종 · 유가 자리」(EQ:{기계} · LB:{직종} · MO:경유:{지역} · 입력값으로 요소를 고름 · 71 로직)는 새 모드 없이 이미 있는 「조건 찾기」(이름 · 키 같음 · 변수 알약)를 인력 · 기계 · 유가 요소에도 씀 · 대조 입력 = check_master.시험입력 · 로직 셈을 부르는 곳(Store.calc · text · master_combo.run · M02_Item_Prices.calc · /logic/auto)은 한 곳에서 흐름 갈래. +- [x] 36-0 함수 상자 안 보기 · 칸 연결 조정(36-2 보다 먼저) — ① 상세 내용 「식 보기」: 결과 칸 식을 칸 주소 → 연결된 알약 이름으로(예: 1m 재료비 = 흄관 단가 ÷ 2.5) + 값 들어간 식(= 211,700 ÷ 2.5 = 84,680) · IF 는 만약 / 아니면 줄 · 지금 타는 갈래 표시 · 중간 칸 식도 차례대로 · 칸 식을 고치면 바로 다시 셈 → 다이어그램 · 원가 따라감 ② 「칸 연결 표」(칸 · 연결 알약 · 입력 / 결과 · 이어진 선 · 표에서 바로 바꿈 · 풀기) + 칸 색(입력 파랑 · 결과 초록 · 상자 색과 같음) · 결과 칸 하나 ③ 선을 고르면 그 선 규칙도 같은 「식 보기」 꼴(예: 굴착량 → 지반 = 토사 일 때만 → 굴삭기로 파기) ④ 엑셀 시트 안 「조건 만들기」 — 칸을 고르고 열면 만약 [알약] [비교] [값 고르기] → [알약 · 값] 줄 · 아니면 만약 더하기 · 아니면 → 그 칸에 IF 식(엑셀로 쓴 IF 도 같은 줄로 다시 열림 · 못 바꾸는 식은 식 그대로) · 검증완료(sub2 · 식 보기 · 칸 연결 표 · 조건 만들기 · 선 조건 여러 줄 · 시험 8) · 검증 대기 +- [x] 36-0b 선 조건 손질 — 조건 값 = 고른 알약의 값 목록에서 고름(Model 값 목록 함수 · 변수 고르기 · 표 칸 값 · 재료 규격 · 목록 없는 수는 숫자 칸) · 조건 여러 줄(그리고 / 또는) · 규칙 → 받는 칸 엑셀 식 · 서버 Compute 같은 뜻 · 선 위 이름표 · 겹침 · 안 맞음 검사 그대로 · 검증완료(sub7 · 조건 여러 줄 그리고/또는 · 값 목록 · 서버 = 화면 · 시험 30) · 검증 대기 +- [x] 36-1 조사 — 옛 식 문법 전수(서버 파서 · 요소 부르기 꼴 · 따옴표 글) · 변환 못 하는 꼴 목록 · /calc 를 부르는 곳 · 검증완료 +- [x] 36-2 변환 — 옛 로직(입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수) → 흐름도(입력 상자 · 표 알약 + 표에서 찾기 · 함수 · 출력 상자) + 자동 배치 · 서버 파서로 · 옛 식에 나온 이름마다 알약을 칸에 연결하고 식은 칸 주소로(만약 → IF · 최대 → MAX · 찾기 → 표에서 찾기) · 함수 상자 하나 = 결과 하나 · 전수 대조(변환 흐름도 값 = 옛 계산 값 · 맞는 비율 · 안 맞는 까닭) · 검증완료(sub7 · M02_DF_Convert · 전수 1,472 시험입력 96.7% · 기본 입력 97.4% · 안 맞는 49 = 재료 검색어 자리 18 · 재료 빈 검색어 13 · 표 범위 규칙 경계 15 · 단가 빈 품목 3 · tmp/convert_report_36.json) · 검증 대기 · 마지막 대조: 시험입력 · 기본 입력 모두 1,472/1,472(100%) · 빈 단가 = 옛 셈 뜻(그 값 쓰는 식 빈 값 · 비목 합 0 · 끊긴 곳 「단가 없는 품목을 씀」) +- [x] 36-3 오른쪽 패널 — 로직 상세 오른쪽에 흐름도(다이어그램 · 상세 내용 · 원가 · 검증) · 너비 끌어 조절 · 접기 · 「크게 보기」(전체 화면) · 검증완료 +- [x] 36-4 저장 · 정본 — 로직 줄에 「흐름」 칸(화면 [저장] 때만 씀) · 흐름이 있으면 /calc 가 흐름도로 셈(서버 다시 셈) · 왼쪽 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산은 흐름도에서 만든 같은 꼴(보기) · 공용 로직도 흐름도로 고침(시스템 관리자) · 캐시 → [저장] 한 번 · [고친 것 버리기] 함께 · 왼쪽 보기 검증완료(sub5) · 서버 검증완료(sub4) +- [x] 36-5 단추 — 「식으로 새로 만들기」 지움 → 「신규 로직」(빈 흐름도 + 기본정보 · 저장 = 자체 GX 새 키) · 「본떠 만들기」 → 「로직 복사/생성」(흐름도도 복사 · 신규 로직 오른쪽) · 「시험판 ▾」 지움 · 잇기 검증완료(sub1 · accf8ecc4 · abbf61971 · 0d45014e3) — 「식으로 새로 만들기」 · 시험판 ▾ · 시험판 단추 둘 지움 · 단추 차례 ← 로직 목록 · 신규 로직 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장 · 두 칸(LogicLab_Flow) · 오른쪽 패널(흐름 받기 · 고침 → drafts → [저장] 한 번 · 버리기 함께 · 흐름 로직이면 왼쪽 흐름 보기) · 신규 로직 기본정보 칸(LogicLab_Basic) → 저장 = 서버 added 키로 엶 +- [x] 36-6 다른 로직 입력 — 대상 = 마스터 로직 전체(찾기) · 흐름도 로직이면 흐름도 셈 · 아니면 옛 셈 · 목록 · 찾기 · 쪽 · 알약 이음 검증완료(서버 흐름 받기 · 값 셈 길이 main 에 오면 GF 로직 값 확인) +- [x] 36-7 정리 — 「시험판 ▾」 · 시험판 입구 · 흐름도 시작 화면 견본 목록 지움 · 시험이 쓰는 견본은 resources/tester 로 · 시험 저장 길 숨김 · DataFlow · CostFlow 폴더 그대로 · 검증완료 +- [x] 36-8 끝 검증 — 흐름 기준 · 옛 로직 열기 → 오른쪽 흐름도 값 = 왼쪽 계 · 신규 로직 → 저장 → 다시 열기 · 복사 · 권한 · 브레인 실제 검증 · 보고 · 끝 검증완료(sub1 · 사본 폴더 tmp/master_copy_36 + AISLO_MASTER_DIR · 네 포트) — ① 옛 로직 셋: GF000001(찾기) 오른쪽 34,413 = /calc 34,413.6 = 왼쪽 34,414(보임 끝수만 다름) · 변환 알림 한 줄 / GF000007(만약) 0 ≠ 597,285 · GF000288(로직()) 0 ≠ 1,234,760.96 — 아래 흠 ② GF000001 함수 식 ×1.5 → 오른쪽 · 왼쪽 51,620 따라감 → [저장](공용 GF · 사본) → 다시 열기 상자 6 · 변환 알림 없음 · /calc flow 51,620.4 = 화면 ③ 신규 로직 65 걸음(변수 셋 · 함수 · 조건 만들기 · 출력 둘 · 조건 선) → 저장 GX000004 → 다시 열기 상자 4 · 선 4 · 화면 100,000 = /calc 100,000 = 왼쪽 계(높이 2 = 0 · 지반 암 = 50,000) ④ 로직 복사/생성 2 걸음 → GX000005 상자 4 · 선 4 · 100,000 ⑤ 선 조건 두 줄 「지반 = 토사 그리고 높이 ≥ 3 일 때만」 · 값 고르기(토사|암) · 높이 2 → 0 · 또는 → 50,000 · 조건 만들기 =IF(A2>=3,A3,0) ⑥ 식 보기 「결과 1 = 금액 × 높이 ÷ 4」 · IF 만약 / 아니면 줄 · 칸 연결 표 ⑦ 크게 보기 1484×1076 · 거기서 고친 것 패널로 · 접기 40px · 펴기 · 너비 320 → 522 ⑧ 고친 것 버리기 5 → 4 · 캐시 비움 · 로그인 없음 flow · save · calc · logics 401 · 시험판 · 옛 단추 글 0 · resources/master_data 바뀜 0 · 흠(다른 창): 변환 숫자 입력 예시값이 비어 있음(시험입력 · 기본 입력을 안 넣음 — GF000288 나무높이 6 · 집재거리 100 · GF000007 정리량 50 …) · GF000007 「표 QF000033 → 할증」 표에 맞는 줄 없음(서버 477,828 = 할증 25% 빠짐 · 화면 0) · GF000288 다른 로직 GC000270 기계 자리 「EQ:{기계} … 자리」 못 풂(#N/A → 0) · 칸 #N/A 일 때 화면 셈 0 · 서버 셈 그대로(둘이 다름)(sub7) · 식 보기 값 줄이 선 규칙을 지난 값을 안 씀(「(#N/A × 0) = #VALUE!」 · 결과 알약은 34,413.6)(sub2) · 조건 통과 둘째 줄 값 목록 「number:0」 글 · 조건 만들기 만약 목록에 놓인 알약 없음(sub2) · 옛 로직 왼쪽 계 반올림 · 흐름 원가 계금 1원 버림(1원 차이) +- 36 마무리(10-2 02시 · 브레인 실제 검증완료): 전수 변환 대조 시험입력 1,469/1,472(99.8%) · 기본 1,466(99.6%) · 남은 3 = 마스터 빈 단가 · 시험 141 + 변환 시험 초록 · 사본 서버 저장 → 사본만 바뀜 · 다시 열기 흐름 정본 · GF000001 34,414 = 34,414 · GF000003 8.946 = 8.946 · 식 보기 값 줄 · 검증 대기 + +## 37. 일위대가 조합 다이어그램 (사용자 지시 10-2 09시 · 계획 · 착수) + +- 사용자 확정: 「복잡한 경우가 있으니 일위대가 조합도 다이어그램으로 표현 … 중요하게 생각하는 정보가 다르니 잘 구분」 · 조합은 계산하지 않고 로직을 뭉쳐 비용 구분별로 표기 · 입력은 로직별로 묶어 빈 값은 사용자가 넣음 · 출력은 로직마다 · 미리 보기 값은 구조물 조합으로 안 넘김 · 「흐름도는 별도 로직이 아님」(단가산출 로직을 쓰는 방법). +- 모양: 왼쪽 = 로직별 입력 묶음(받아야 할 값 · 빈 값 표시) → 가운데 = 로직 카드(키 · 이름 · 결과 단위 · 식 · 함수 안 보임) → 오른쪽 = 비용 구분 칸 셋(노무비 · 재료비 · 경비 · 로직마다 선으로 모임) + 값 로직은 「값」 칸 · 이름이 같은 입력(지역 · 자재지역 등)은 점선으로 이어 보이기만(검사 없음) · 로직 카드를 누르면 그 로직의 단가산출 로직 화면(흐름도)으로 · 계산 · 고치기 없음(묶음 고치기는 지금 표) · 흐름도와 모양 · 색을 달리함. +- 자리: 일위대가 조합 상세(M02_MasterTemplete_Labor_Edit)의 미리 보기와 같은 값 — 미리 보기 입력 칸에 넣은 값이 다이어그램에도 · 다이어그램 빈 입력 알약을 누르면 미리 보기 그 칸으로 · 미리 보기 결과(로직별 노무비 · 재료비 · 경비 · 계)가 카드 · 칸에. +- [x] 37-1 다이어그램 판 — M02_MasterTemplete_Labor_Diagram.ts(+css) 새 파일 · 고정 세 칸 배치(자동) · 판 옮기기 · 확대 · 한눈에 · 밝음 · 어두움 · 좁은 폭 +- [x] 37-2 잇기 — Labor_Edit 에 「다이어그램」 칸(접기) · 미리 보기와 값 한 벌 · 빈 입력 누름 → 미리 보기 칸 · 로직 카드 누름 → M01 단가산출 로직 그 로직 열기(setPendingLogicKey 꼴) +- [x] 37-3 검증 — 조합 셋(로직 2 · 9 · 14개)으로 입력 묶음 수 = 로직 입력 수 · 출력 = 미리 보기 줄 · 비용 칸 합 = 미리 보기 합 · 빈 값 표시 · 같은 이름 점선 · 로직 열기 · 브레인 실제 검증 · 보고 +- 창: sub2 37-1 · 37-2 · 37-3 + +## 38. 마스터 요소 화면 손질 (사용자 지시 10-2 09:40 · 착수) + +- 사용자 확정: ① 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 상세의 제목 · 단추 · 찾기 라벨 양식 = 단가산출 로직과 같게 ② 재료 컨테이너의 유가 · 전력을 별도 컨테이너로 ③ 단가산출 로직 「막힘만」 체크 — 의미 없으면 지움 ④ 단가산출 로직 컨테이너 「전체」 를 누르면 상세 목록 보기가 안 됨(다른 컨테이너는 됨). +- 브레인 정함: 「막힘만」 = 로직 검사에 걸린 로직만 거르기 · 지금 1,469 중 0 · 목록 줄에 「⛔ 막힘」 표시 · 까닭 말풍선은 이미 있음 → 체크만 지움(표시는 둠). +- [x] 38-1 양식 맞춤 — 일곱 상세 화면 머리(제목 · 단추 줄 · 찾기 칸 라벨)를 단가산출 로직 화면 꼴로 · 공용 단추 · 칸 크기(sub5) · 검증완료 +- [x] 38-2 유가 · 전력 컨테이너 — 왼쪽 패널에 재료 다음 「유가 · 전력」 컨테이너(재료_유가전력.json) · 재료 컨테이너에서는 뺌 · 고르기 · 찾기 · 상세 그대로(sub6) · 검증완료 +- [x] 38-3 「막힘만」 체크 지움 · 38-4 「전체」 목록 보기 고침(sub1) · 검증완료 — 원인: 로직 컨테이너 거름이 목록만 거르고 가운데 화면 전환(onLogic)을 안 부름 → Side 거름 run 에서 로직이면 onLogic · 「전체」 는 찾는 글도 비움 · 화면(5174): 로직 1,469 줄 → 인력 전체 → 로직 「전체」 = 로직 화면 1,469 줄(찾는 글 「굴착」 비움) → 산림품셈 330 → 03장 작업장 관리 10 → 로직 열기 → 「전체」 = 상세 닫힘 · 1,469 줄 · 막힘만 체크 0 · 화면 오류 0 +- 「고친 것 버리기」(사용자 물음 · 확인만): 저장 안 한 고친 것(화면 캐시)을 버리고 파일의 로직을 다시 엶 — 사본 저장은 없음 · 그대로 둠(사용자 결정 기다림) From 1871b6ffc2a06985f72c07081b8d1a4f507d5c73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:20:47 +0900 Subject: [PATCH 05/24] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B4=EB=93=9C=20[=EC=95=8C=EC=95=BD]=20?= =?UTF-8?q?=EC=BC=AC=EB=81=94=20=E2=80=94=20=EC=95=84=EB=9E=98=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EC=A4=84=20=EA=B8=B0=EB=B3=B8=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=C2=B7=20=EA=B3=84=EC=A0=95=20=EC=B7=A8=ED=96=A5=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=C2=B7=20=EC=83=81=EC=84=B8=EB=8A=94=20=EB=8A=98=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=84=20(PLAN=2039-4=20D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/b_page_state.ts | 2 ++ .../M02_MasterTemplete_ItemCards.ts | 18 ++++++++++++++++++ .../M02_MasterTemplete_Structure.css | 5 +++++ resources/tester/test_m02_card_sheet_edit.py | 14 ++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cca13de9d..9f9e3ae48 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -62,6 +62,8 @@ export const STATE_REGISTRY = { /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, + /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ + "m02-card-pills": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index fa329afec..3b3a24b3f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -13,6 +13,7 @@ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { LinkOptions, SpreadsheetHandle, @@ -62,6 +63,8 @@ const TEXT = { readFailed: "{value} 파일을 못 읽음 — {why}", discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", sheetHelp: "단축키 — 칸을 고르고 바로 침", + pills: "알약", + pillsTip: "아래 알약 줄 보임 · 숨김", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -221,6 +224,20 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return card; }; const sheets = [sheetCard("area"), sheetCard("material")]; + // 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임) + const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" }); + pills.title = TEXT.pillsTip; + const paintPills = (shown: boolean): void => { + pills.setAttribute("aria-pressed", String(shown)); + sheets[0].host.classList.toggle("is-pills-off", !shown); + }; + pills.addEventListener("click", () => { + const shown = pills.getAttribute("aria-pressed") !== "true"; + writeStateRaw("m02-card-pills", String(shown)); + paintPills(shown); + }); + paintPills(readStateRaw("m02-card-pills") === "true"); + sheets[0].shell.tools.prepend(pills); const cards: Card[] = [info, ...sheets]; const defaults: Record = { structure: TEXT.edit, @@ -244,6 +261,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.saver.button.hidden = c.help.hidden = !can; c.edit.hidden = !opts.editable(); } + pills.hidden = !sheetEdit; }; const drop = (): void => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index b44d120e2..57eaacbce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -247,3 +247,8 @@ .m02-struct__sheet-grid .ss-links__list { max-height: 8rem; } + +/* 면적산출 카드 [알약] 끔(기본) — 아래 알약 줄만 숨김 · 연결 · 칸 세모는 그대로 */ +.m02-struct__sheet.is-pills-off .ss-links { + display: none; +} diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index a4b3e62c6..6dbe46cf5 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -75,3 +75,17 @@ def test_저장_안_한_카드_고침_물음(): assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main + + +def test_면적산출_카드_알약_줄_켬끔_기본_숨김(): + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김 + assert 'writeStateRaw("m02-card-pills", String(shown));' in cards + assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards + assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음) + css = _text(M02 / "M02_MasterTemplete_Structure.css") + assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css) + state = _text(ROOT / "A00_Common" / "b_page_state.ts") + assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state + # 상세(전체 편집기)는 켬끔 없음 — 늘 보임 + assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts") From 8fde124df5ab88c22890733e5187742fd5a0e7b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:21:12 +0900 Subject: [PATCH 06/24] =?UTF-8?q?refactor(M02):=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EB=94=B0=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20M02=5FMaterial=5FPick.ts(pickMaterial=20=C2=B7=20Ma?= =?UTF-8?q?terialItem)=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_Materials.ts | 68 +--------------- .../M02_MasterTemplete_Matset.ts | 2 +- M02_MasterTemplete/M02_Material_Pick.ts | 77 +++++++++++++++++++ 3 files changed, 80 insertions(+), 67 deletions(-) create mode 100644 M02_MasterTemplete/M02_Material_Pick.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts index 809c92d72..93697e6fb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts @@ -5,15 +5,7 @@ * (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂. * ========================================================================== */ -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types"; import { @@ -22,6 +14,7 @@ import { type MaterialCombo, type MaterialItem, } from "./M02_MasterTemplete_Api_Fetch"; +import { pickMaterial } from "./M02_Material_Pick"; import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; @@ -51,12 +44,6 @@ const TEXT = { askAlone: "이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀", failed: "자재 조합을 못 고침 — {value}", - pickTitle: "자재 추가", - pickClose: "닫기", - search: "자재 이름 · 규격 · 코드", - searching: "찾는 중…", - noMatch: "맞는 자재 없음", - searchFailed: "자재 목록을 못 받음", }; const fill = (text: string, values: Record): string => @@ -122,57 +109,6 @@ export interface MaterialPanelOptions { onApplied?: () => void; } -/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(좌측 자재 조합 상세도 씀) */ -export function pickMaterial(done: (item: MaterialItem) => void): void { - openModal({ - title: TEXT.pickTitle, - closeLabel: TEXT.pickClose, - dialogClass: "m02-modal m02-pick", - mount: (body, close) => { - const search = createInputField({ placeholder: TEXT.search, type: "search" }); - const list = el("div", { className: "m02-pick__list" }); - let seq = 0; - const paint = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); - try { - const hits = await searchMaterials(search.input.value.trim()); - if (mine !== seq) return; - list.replaceChildren( - ...(hits.length - ? hits.map((m) => { - const row = el("button", { - className: "m02-pick__item", - attrs: { type: "button", "data-material": m.코드 }, - children: [ - el("span", { className: "m02-pick__key", text: m.코드 }), - `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), - ], - }); - row.addEventListener("click", () => { - done(m); - close(); - }); - return row; - }) - : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), - ); - } catch { - if (mine === seq) showToast(TEXT.searchFailed, "error"); - } - }; - let timer = 0; - search.input.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void paint(), 250); - }); - body.append(search.root, list); - void paint(); - search.input.focus(); - }, - }); -} - /** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { let key = opts.state.key; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Matset.ts b/M02_MasterTemplete/M02_MasterTemplete_Matset.ts index 931e58d67..03f6367a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Matset.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Matset.ts @@ -22,7 +22,7 @@ import { StaleError, type MaterialCombo, } from "./M02_MasterTemplete_Api_Fetch"; -import { pickMaterial } from "./M02_MasterTemplete_Materials"; +import { pickMaterial } from "./M02_Material_Pick"; import { tm } from "./M02_MasterTemplete_Matset_Text"; export interface MatsetSectionOptions { diff --git a/M02_MasterTemplete/M02_Material_Pick.ts b/M02_MasterTemplete/M02_Material_Pick.ts new file mode 100644 index 000000000..8b0e604b5 --- /dev/null +++ b/M02_MasterTemplete/M02_Material_Pick.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * M02_Material_Pick.ts + * M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀). + * 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`. + * ========================================================================== */ + +import { createInputField, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; + +/** 고른 자재 한 줄 — M01 자재(키 `MT`) */ +export interface MaterialItem { + 코드: string; + 이름: string | null; + 규격: string | null; + 단위: string | null; +} + +const TEXT = { + pickTitle: "자재 추가", + pickClose: "닫기", + search: "자재 이름 · 규격 · 코드", + searching: "찾는 중…", + noMatch: "맞는 자재 없음", + searchFailed: "자재 목록을 못 받음", +}; + +/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 */ +export function pickMaterial(done: (item: MaterialItem) => void): void { + openModal({ + title: TEXT.pickTitle, + closeLabel: TEXT.pickClose, + dialogClass: "m02-modal m02-pick", + mount: (body, close) => { + const search = createInputField({ placeholder: TEXT.search, type: "search" }); + const list = el("div", { className: "m02-pick__list" }); + let seq = 0; + const paint = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); + try { + const hits = await searchMaterials(search.input.value.trim()); + if (mine !== seq) return; + list.replaceChildren( + ...(hits.length + ? hits.map((m) => { + const row = el("button", { + className: "m02-pick__item", + attrs: { type: "button", "data-material": m.코드 }, + children: [ + el("span", { className: "m02-pick__key", text: m.코드 }), + `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), + ], + }); + row.addEventListener("click", () => { + done(m); + close(); + }); + return row; + }) + : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), + ); + } catch { + if (mine === seq) showToast(TEXT.searchFailed, "error"); + } + }; + let timer = 0; + search.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void paint(), 250); + }); + body.append(search.root, list); + void paint(); + search.input.focus(); + }, + }); +} From eee8f9a459b6ebaed5001c2e2a7b3302579e2c1c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:21:17 +0900 Subject: [PATCH 07/24] =?UTF-8?q?feat(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=ED=83=AD=20readableSources=20=C2=B7=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EB=A7=A1=EC=9D=8C=20manages=20=E2=80=94=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1=20=C2=B7=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=ED=95=B4=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=ED=83=AD=20=EB=AA=A9=EB=A1=9D(39-6=20E5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Layer.ts | 72 +++++++++++++++++++ resources/tester/test_m02_layer_screen.py | 49 +++++++++++++ ui_template/ui_template_locale_m2.ts | 2 + 3 files changed, 123 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 742f8f20a..3b94c655c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -93,6 +93,78 @@ export function accessFor( return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; } +/** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ +export function manages(company: string | null, user: SessionUser | null): boolean { + if (!company || !user) return false; + if (user.role === "SYSTEM_ADMIN") return true; + const companyAdmin = user.role === "ADMIN" || !!user.is_master; + return companyAdmin && company === ownOwner("company", user); +} + +/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */ +export interface Source { + id: "master" | "company" | "personal" | "people" | "practice"; + layer: ManageLayer; + /** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */ + owner: string | null; + label: string; +} + +/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭. + * 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */ +export function readableSources( + user: SessionUser | null, + company: string | null, + opt: { practice?: boolean } = {}, +): { tabs: Source[]; first: Source["id"] } { + const isSystem = user?.role === "SYSTEM_ADMIN"; + const own = ownOwner("personal", user); + const tabs: Source[] = [ + { id: "master", layer: "system", owner: null, label: L("M02_PickerSystem") }, + ...(company + ? [ + { + id: "company" as const, + layer: "company" as const, + owner: company === ownOwner("company", user) ? null : company, + label: L("M02_PickerCompany"), + }, + ] + : []), + ...(own + ? [ + { + id: "personal" as const, + layer: "personal" as const, + owner: null, + label: L("M02_PickerPersonal"), + }, + ] + : []), + ...(manages(company, user) + ? [ + { + id: "people" as const, + layer: "personal" as const, + owner: null, + label: L("M02_SourcePeople"), + }, + ] + : []), + ...(opt.practice && isSystem + ? [ + { + id: "practice" as const, + layer: "system" as const, + owner: null, + label: L("M02_SourcePractice"), + }, + ] + : []), + ]; + return { tabs, first: isSystem || !own ? "master" : "personal" }; +} + /** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */ export function buildNeedCompany(): HTMLElement { const go = createButton({ label: L("M02_NeedCompanyGo"), variant: "filled" }); diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index c7bb3f17f..f731a5644 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -87,6 +87,55 @@ def test_주소_인자를_라우트에서_뗌(): assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_가져올_곳_탭(): + """39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 · + 일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터).""" + text = _text(LAYER) + parts = [ + re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M), + re.search(r"^export function manages[\s\S]*?^}", text, re.M), + re.search(r"^export interface Source[\s\S]*?^}", text, re.M), + re.search(r"^export function readableSources[\s\S]*?^}", text, re.M), + ] + assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음" + script = ( + 'type ManageLayer = "system" | "company" | "personal";\n' + "interface SessionUser { id: string; role?: string; company_id?: number | null;" + " is_master?: boolean }\n" + "const L = (key: string): string => key;\n" + + "\n".join(p.group(0).replace("export ", "") for p in parts if p) + + "\nconst cases = JSON.parse(process.argv[1]);" + + "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {" + + "\n const got = readableSources(u, c, { practice });" + + "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];" + + "\n})));" + ) + sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1} + admin = {"id": "5", "role": "ADMIN", "company_id": 3} + master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True} + user = {"id": "7", "role": "USER", "company_id": 3} + orphan = {"id": "8", "role": "USER", "company_id": None} + cases = [ + [sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False], + [user, "3", True], [orphan, None, False], + ] # fmt: skip + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(cases)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄 + ["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님 + ["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음 + ["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자 + ["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음 + ["master", ["master"]], # 회사 없음 + ] # fmt: skip + + @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_볼_회사는_시스템_관리자만_고름(): """29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 · diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index eff4717b9..1f89b5c20 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -103,6 +103,8 @@ export const ui_locales_m2 = { M02_RootShared: ["회사 공용", "Company shared"], M02_RootReadOnly: ["읽기", "read-only"], M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], + M02_SourcePeople: ["다른 사람", "Others"], + M02_SourcePractice: ["실무문서", "Practice documents"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", From ab84c763b79f65f093efcc684ab445cb5adb0b36 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:22:24 +0900 Subject: [PATCH 08/24] =?UTF-8?q?refactor(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=E3=80=8D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=C2=B7=20=EC=A1=B0=ED=95=A9=20=EB=82=98=EB=AC=B4=20=C2=B7=20?= =?UTF-8?q?=EB=91=98=EC=A7=B8=20=EC=B8=B5=20=EB=8B=A8=EC=B6=94=20=EB=B2=8C?= =?UTF-8?q?=20=EC=A7=80=EC=9B=80(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor.ts · Labor_Tree.ts 지움 · 페이지 · 좌측 이음 · 해시 표시(mark) 지움 - 나무 부품 일위대가 전용 칸(clipKind · maxDepth · item) 지움 - 옛 나무 시험을 새 짜임 검사로 갈음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Labor.ts | 222 ------------------ .../M02_MasterTemplete_Labor_Tree.ts | 45 ---- .../M02_MasterTemplete_TreePanel.ts | 23 +- .../M02_MasterTemplete_UI_Page.ts | 36 +-- .../M02_MasterTemplete_UI_Side.ts | 24 +- .../M02_MasterTemplete_UI_Style.css | 8 - resources/tester/test_m02_labor_tree.py | 131 ++--------- resources/tester/test_m02_layer_screen.py | 13 +- 8 files changed, 39 insertions(+), 463 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts deleted file mode 100644 index 985d1a1f8..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor.ts - * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추 - * (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` · - * 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`). - * 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김). - * ========================================================================== */ - -import { el, showConfirmDialog } from "@ui/ui_template_elements"; -import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; -import { - editComboTree, - editComboTreeMany, - fetchComboList, - pasteCombos, - type ComboList, - type ComboTreeEdit, - type LaborScope, -} from "./M02_MasterTemplete_Labor_Api"; -import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit"; -import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; - -/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */ -function treeError(error: unknown): Error { - if (!(error instanceof ApiError)) - return error instanceof Error ? error : new Error(String(error)); - const d = error.detail as { errors?: string[]; stale?: unknown } | string | null; - const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`; - return error.status === 409 ? new StaleError(text) : new Error(text); -} - -export interface LaborLibraryOptions { - scope: LaborScope; - canEdit: boolean; - /** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */ - copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[]; - /** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */ - reveal: () => Promise; -} - -export interface LaborLibrary { - /** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */ - root: HTMLElement; - /** 좌측 패널 나무 */ - tree: HTMLElement; - /** 나무 맨 위 더하기 「+」(층 단추 옆) */ - add: HTMLElement; - /** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */ - back: () => Promise; - refresh: () => Promise; - /** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */ - tools: HTMLElement | null; - /** 메인 칸 「저장 안 한 고침」 손잡이 */ - bind: (setDirty: (dirty: boolean) => void) => void; -} - -export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { - const { scope } = opt; - let setDirty: (dirty: boolean) => void = () => {}; - let keys = new Set(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나 - const took = (list: ComboList): ComboList => { - keys = new Set(list.줄.map((c) => c.키)); - editor.setList(list.줄); - return list; - }; - const editor = createLaborEditor({ - scope, - canEdit: opt.canEdit, - onDirty: (dirty) => setDirty(dirty), - onSaved: async (key) => { - await tree.refresh(); - tree.select(key); - }, - }); - const tree = createTreePanel({ - layer: scope.layer, - owner: scope.owner, - canEdit: opt.canEdit, - maxDepth: 2, - item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) }, - api: { - fetch: async () => { - return toView(took(await fetchComboList(scope))); - }, - edit: async (_layer, edit, 판) => { - const body = { ...edit, 판 } as ComboTreeEdit; - const got = await editComboTree(body, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), noteOf(edit, got.지운조합)); - }, - // 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장 - many: async (_layer, body, 판) => { - const ops = body.대상.map((ref) => ({ - op: body.op, - ...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }), - ...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}), - })); - const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), { op: body.op, 지운항목: got.지운조합 }); - }, - // 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로 - paste: async (layer, owner, clip, dest) => { - const to = { layer, owner } as LaborScope; - const pick = { - 키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])), - 폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])), - }; - const got = await fetchComboList(to) - .then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)) - .catch((error: unknown) => { - throw treeError(error); - }); - const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null); - return toView(took(here ? got : await fetchComboList(scope))); - }, - }, - clipKind: "일위대가", - copyTo: opt.copyTo, - onPick: async (item) => { - await opt.reveal(); - tree.select(item.key); - void editor.open(item.key); - }, - // 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로) - settle: async () => { - if (!editor.isDirty()) return "clean"; - if (!(await showConfirmDialog(tc("BackAsk")))) return false; - editor.showList(); - return "changed"; - }, - // 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록 - onChanged: async () => { - const open = editor.current(); - if (!open) return; - if (keys.has(open)) await editor.open(open); - else editor.showList(); - }, - }); - // 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름 - tree.search - .querySelector("input") - ?.addEventListener("input", (event) => - editor.setQuery((event.target as HTMLInputElement).value), - ); - return { - root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }), - tree: tree.root, - add: tree.add, - back: async () => { - await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움 - if (editor.current() === null) return; - if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return; - editor.showList(); - }, - refresh: async () => { - const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6) - try { - await tree.refresh(); - } finally { - stop(); - } - }, - tools: editor.newButton, - bind: (fn) => (setDirty = fn), - }; -} - -export interface LaborSectionOptions { - /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ - show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void; - /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ - confirmLeave: () => Promise; - /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ - onOpen: () => void; - /** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */ - make: (reveal: () => Promise) => LaborLibrary; - /** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */ - bar?: (add: HTMLElement, back: () => void) => HTMLElement; - /** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */ - wrap?: (tree: HTMLElement) => HTMLElement; -} - -export interface LaborSection { - title: string; - body: HTMLElement; - open: () => Promise; - /** 다른 화면을 열었음 — 고름 표시만 지움 */ - clear: () => void; -} - -/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */ -export function buildLaborSection(opt: LaborSectionOptions): LaborSection { - let opened = false; - const mark = (on: boolean): void => { - opened = on; - }; - - async function open(): Promise { - if (opened || !(await opt.confirmLeave())) return; - library.bind(opt.show(tc("Library"), library.root, library.tools)); - opt.onOpen(); - mark(true); - await library.refresh(); - } - // 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음 - const library = opt.make(open); - void library.refresh(); - const body = el("div", { - className: "m02-side__items ui-sidebar-list", - children: [ - ...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []), - opt.wrap ? opt.wrap(library.tree) : library.tree, - ], - }); - return { title: tc("Title"), body, open, clear: () => mark(false) }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts deleted file mode 100644 index d18017cae..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Tree.ts - * 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림. - * 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키). - * ========================================================================== */ - -import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch"; -import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api"; - -/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */ -export const pathOf = (c: ComboSummary): string[] => - c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : []; - -/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */ -export const toView = (list: ComboList, note?: TreeNote): TreeView => ({ - 판: list.판, - 폴더: list.폴더, - items: list.줄.map((c) => ({ - key: c.키, - 열: c.키, - 경로: [...pathOf(c), c.이름], - 이름: c.이름, - 단위: c.단위, - })), - ...(note ? { 알림: note } : {}), -}); - -/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */ -export function noteOf(edit: TreeEdit, gone: string[]): TreeNote { - const at = edit.경로 ?? []; - const last = at[at.length - 1] ?? ""; - if (edit.키 && edit.op !== "delete") { - const where = edit.op === "move" ? (edit.새경로 ?? []) : at; - return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone }; - } - const path = - edit.op === "add-folder" - ? [...at, edit.이름 ?? ""] - : edit.op === "rename" - ? [...at.slice(0, -1), edit.이름 ?? ""] - : edit.op === "move" - ? [...(edit.새경로 ?? []), last] - : undefined; - return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 426b52b8d..014e9c9b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -81,18 +81,12 @@ export interface TreePanelOptions { dest: string[], ) => Promise; }; - /** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */ - clipKind?: string; /** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */ pickCombo?: () => Promise<{ key: string; label: string } | null>; /** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */ copyTo?: { layer: Layer; owner: string | null; label: string }[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; - /** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */ - maxDepth?: number; - /** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */ - item?: { add: string; name: string; onAdd?: (path: string[]) => void }; /** 항목을 누름 — 표에서 그 열을 고름 */ onPick: (item: TreeItem) => void; /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ @@ -123,7 +117,7 @@ const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const owner = opt.owner ?? null; const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree }; - const clipKind = opt.clipKind ?? "구조물"; + const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임 let view: TreeView | null = null; let text = ""; let activeCol: string | null = null; @@ -213,13 +207,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(target.kind === "folder" ? [ - ...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth - ? [] - : [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]), - { - label: opt.item?.add ?? L("M02_TreeAddItem"), - run: () => void addItem(target.path), - }, + { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, + { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, ] : []), ...(isTop @@ -466,7 +455,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { async function addItem(parent: string[]): Promise { if (!(await ready())) return; - if (opt.item?.onAdd) return opt.item.onAdd(parent); // 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2) const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : []; const got = await askNewItem({ @@ -490,10 +478,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const got = await askText(L("M02_TreeRename"), [ { key: "name", - label: - target.kind === "item" - ? (opt.item?.name ?? L("M02_TreeItemName")) - : L("M02_TreeFolderName"), + label: target.kind === "item" ? L("M02_TreeItemName") : L("M02_TreeFolderName"), value: nameOf(target), }, ]); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 2be94a20d..a19bc482a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -27,7 +27,6 @@ import { type OpenTarget, type Root, } from "./M02_MasterTemplete_Layer"; -import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; import { headRenameEdit, pickCombo, @@ -86,14 +85,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); - // 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 · - // 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로) - let view: OpenTarget = entry.open; - const mark = (open: OpenTarget): void => { - view = open; - const query = new URLSearchParams(entryQuery({ ...entry, open })); - history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`); - }; const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 if (!(await main.confirmLeave())) return; @@ -102,9 +93,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void): HTMLElement => createLayerBar({ user, @@ -127,11 +118,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void Promise.all([side.refresh(), tree.refresh()]), - () => { - side.paint(); - // 일위대가 화면에서 양식으로 옮김(일위대가 · 옵션 화면은 current 없음) - if (main.current() && view === "일위대가") mark("구조물조합"); - }, + () => side.paint(), { top: readOnlyBar?.root, search: tree.search, @@ -168,19 +155,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - labor: ({ onOpen }) => - buildLaborSection({ - show: main.showView, - confirmLeave: main.confirmLeave, - onOpen: () => { - onOpen(); - mark("일위대가"); - }, - bar: (add, back) => layerBar("일위대가", add, back), - wrap: (panel) => peopleTree("일위대가", panel), - make: (reveal) => - buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }), - }), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); @@ -200,8 +174,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise Promise; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; - /** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */ - labor?: (hooks: { onOpen: () => void }) => LaborSection; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; } @@ -85,7 +82,7 @@ export interface SideHandle { refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; - /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */ + /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ reveal: (target: "옵션" | "도면" | "일위대가") => void; } @@ -136,7 +133,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), labor?.clear(), paint()), + onOpen: () => (options.clear(), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -148,9 +145,8 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), labor?.clear(), paint()), + onOpen: () => (matset.clear(), paint()), }); - const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { @@ -181,15 +177,9 @@ export function buildSide(opt: SideOptions): SideHandle { : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [ - ...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []), - summary, - ...(extras ? [extras.옵션, extras.도면] : []), - ], + children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], }); attachCollapsible(listHost); - // 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침) - labor?.body.closest("section")?.classList.remove("is-collapsed"); const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); @@ -263,7 +253,6 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); - if (opt.getOpen()) labor?.clear(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) @@ -388,10 +377,7 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - if (target === "일위대가") { - labor?.body.closest("section")?.classList.remove("is-collapsed"); - return void labor?.open(); - } + if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침 extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 0e323ca8c..c4c51d1ca 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -222,14 +222,6 @@ font-size: 11px; } -/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */ -.m02-labor { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - .m02-main__head + .m02-tree__search { margin-bottom: var(--spacing-8); } diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 9268309c8..755dac40b 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -1,124 +1,35 @@ -"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정. +"""좌측 패널 일위대가(PLAN 39-2) — 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · +일위대가는 구조물 조합 하위(구조물 도면 → 일위대가 → 면적산출). 글 검사(화면은 ORCA). -못박는 것 - - 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키. - - 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 · - 지우기는 고름 없음. +옛 일위대가 라이브러리 나무 시험(PLAN 27-2b · 30-3 · 30-5)은 나무를 지우며 이 검사로 갈음. """ -import json -import shutil -import subprocess from pathlib import Path -import pytest - ROOT = Path(__file__).resolve().parents[2] -SCRIPT = r""" -const fs = require("fs"); -const ts = require(process.argv[1] + "/config/node_modules/typescript"); -const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts"; -const src = fs.readFileSync(process.argv[1] + file, "utf8"); -const js = ts.transpileModule(src, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; -const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" }); -const list = { - 판: "v1", - 폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]], - 줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null), - row("UP1-000003", "다", null, "무시")], -}; -const view = m.toView(list); -const notes = [ - { op: "add-folder", 경로: ["석축"], 이름: "메쌓기" }, - { op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" }, - { op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] }, - { op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] }, - { op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" }, - { op: "delete", 경로: ["석축"], 키: "UP1-000002" }, - { op: "delete", 경로: ["빈"] }, -].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : [])); -console.log(JSON.stringify({ view, notes })); -""" +M02 = ROOT / "M02_MasterTemplete" -@pytest.fixture(scope="module") -def got(): - if shutil.which("node") is None: - pytest.skip("node 가 없음") - proc = subprocess.run( - ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) +def _text(name: str) -> str: + return (M02 / name).read_text("utf-8") -def test_조합이_나무_항목으로(got): - items = got["view"]["items"] - assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]] - assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"] - assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]] - assert "알림" not in got["view"] +def test_일위대가_나무_파일과_이음이_없음(): + assert not (M02 / "M02_MasterTemplete_Labor.ts").exists() + assert not (M02 / "M02_MasterTemplete_Labor_Tree.ts").exists() + page = _text("M02_MasterTemplete_UI_Page.ts") + assert "buildLaborSection" not in page and 'layerBar("일위대가"' not in page + side = _text("M02_MasterTemplete_UI_Side.ts") + assert "LaborSection" not in side and "labor?." not in side -def test_나무_고침_알림(got): - add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"] - assert add["경로"] == ["석축", "메쌓기"] and "열" not in add - assert rename["경로"] == ["석축", "찰"] - assert move["경로"] == ["찰쌓기"] - assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"] - assert rename_item["경로"] == ["석축", "나나"] - assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"] - assert "경로" not in delete_folder +def test_나무_부품에_일위대가_전용_칸_없음(): + panel = _text("M02_MasterTemplete_TreePanel.ts") + for gone in ("maxDepth", "opt.item", "opt.clipKind", "clipKind?:"): + assert gone not in panel, gone -def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤(): - """브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) · - 일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시 - `open=일위대가`(다시 그리지 않고 주소만).""" - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길 - assert ( - 'bar: (add, back) => layerBar("일위대가", add, back),' in page - and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page - ) - labor = page.split("buildLaborSection({")[1].split("make:")[0] - assert 'mark("일위대가")' in labor # 여는 순간 해시 open - assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림) - assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page - - -def test_일위대가_나무에_여러_개_복사_붙여넣기_연결(): - """PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 · - 붙임 종류는 따로(구조물과 안 섞임).""" - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor - assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기 - assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층 - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록 - - -def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표(): - """PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선). - 머리 줄도 공용 표 모양.""" - edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8") - assert ( - "newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit - ) - assert ( - 'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit - ) # 목록 안 옛 자리 - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor - main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") - assert ( - 'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))' - in main - ) - css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8") - assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css +def test_층_단추는_구조물_조합_한_벌(): + page = _text("M02_MasterTemplete_UI_Page.ts") + assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 + assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index c7bb3f17f..299ec5e4b 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -136,7 +136,7 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page - assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page + assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움 panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( "api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel @@ -219,17 +219,13 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): - """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · - 저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · - 끌어다 옮기기는 쓸 수 있는 층만.""" + """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · + 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page - assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page - labor = _text(M02 / "M02_MasterTemplete_Labor.ts") - assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") @@ -259,10 +255,9 @@ def test_구조물_조합_제목_줄_새_항목(): def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움(): - """31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸 + """31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸 자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로).""" side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") - assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side assert 'summary.classList.remove("is-collapsed");' in side drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") mark = drag.split("const dropMark = ")[1].split("};")[0] From 8739148bba03d03b7bbc8287f5fc9fc28fa67cab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:23:01 +0900 Subject: [PATCH 09/24] =?UTF-8?q?feat(spreadsheet):=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20setLink=20=E2=80=94=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EC=B9=B8=EC=97=90=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=ED=92=80=EA=B8=B0=20=C2=B7=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EB=90=A8=20(PLAN=2039-4=20D4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet.ts | 13 +++ .../spreadsheet/spreadsheet_link_set.ts | 55 +++++++++++ .../spreadsheet/spreadsheet_link_set_entry.ts | 92 +++++++++++++++++++ resources/tester/test_spreadsheet_set_link.py | 68 ++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_link_set.ts create mode 100644 resources/tester/spreadsheet/spreadsheet_link_set_entry.ts create mode 100644 resources/tester/test_spreadsheet_set_link.py diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 87ec2cc7b..d452f6b95 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -24,6 +24,7 @@ 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"; @@ -48,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 { /** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */ @@ -76,6 +78,9 @@ export interface SpreadsheetHandle { recalc(): CalcValues; /** 알약 키 → 연결 칸 값(빈 칸 0) */ getLinkedValues(): Record; + /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · + * 주소가 틀리거나 읽기만이면 false */ + setLink(key: string, cell: string | null, info?: LinkInfo): boolean; destroy(): void; } @@ -288,6 +293,14 @@ export function createSpreadsheet( } return out; }, + setLink(key, cell, info) { + if (readOnly) return false; + const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info); + if (command === false) return false; + if (command) dispatch(command); + loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 + return true; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts new file mode 100644 index 000000000..8f740992c --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * spreadsheet_link_set.ts (주인 D) + * 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`). + * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 · + * 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). + * ========================================================================== */ + +import { parseA1, toA1 } from "./spreadsheet_address"; +import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types"; + +/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */ +export interface LinkInfo { + 이름?: string; + 단위?: string; + 자재?: LinkMaterial; +} + +/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */ +export function linkCommand( + book: Workbook, + sheetId: string, + key: string, + cell: string | null, + info: LinkInfo = {}, +): 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; + if (a1 && 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 && !(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 }; +} diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts new file mode 100644 index 000000000..ce15d5b73 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -0,0 +1,92 @@ +/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ +import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; +import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +const book = (): Workbook => + ({ + 종류: "통합문서", + 판: 1, + 열: "t", + 서식: [{}], + 시트: [ + { + id: "s1", + 이름: "면적산출", + 칸: { B1: { 값: 12.5 } }, + 연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }], + }, + { id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] }, + ], + 활성: "s1", + }) as unknown as Workbook; +const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null); +const run = (b: Workbook, c: Command | null | false) => { + if (!c) return null; + return applyCommand(b, c).undo; +}; + +// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음 +let b = book(); +const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" }; +let undo = run( + b, + linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }), +); +eq("새 연결", b.시트[0].연결?.[1], { + 키: "자재|MT000008", + 칸: "D2", + 이름: "치즐", + 단위: "EA", + 자재: mt, +}); +// 되돌리기 = 처음 그대로 +run(b, undo); +eq("되돌림", links(b), links(book())); + +// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", "B1")); +eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]); + +// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나) +b = book(); +run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" })); +eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); + +// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다 +b = book(); +undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1")); +eq("시트 넘어 옮김", links(b), [ + [ + { 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }, + { 키: "치기|㎥", 칸: "A1", 이름: "치기" }, + ], + null, +]); +run(b, undo); +eq("시트 넘어 되돌림", links(b), links(book())); + +// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", null)); +eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]); + +// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false +b = book(); +eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null); +eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null); +eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false); +eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); +eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); +eq("문서 그대로", links(b), links(book())); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py new file mode 100644 index 000000000..13e3ba179 --- /dev/null +++ b/resources/tester/test_spreadsheet_set_link.py @@ -0,0 +1,68 @@ +"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4). + +순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 — +새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소. +손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_setLink_순수_길(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 13 + + +def test_손잡이_setLink_몸(): + src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") + assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src + body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0] + assert "if (readOnly) return false;" in body + assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body + assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) + assert "loadLinks();" in body From 0b158636ccb6d8233aac4a914db2ae472e91f254 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:23:56 +0900 Subject: [PATCH 10/24] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B8=20=EC=A7=80=EC=A0=95=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20AreaLink=20=E2=80=94=20=EC=A7=80=EA=B8=88=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A1=9C=20=EC=9E=87=EA=B8=B0=20=C2=B7=20=ED=92=80?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A0=95=EB=B3=B8=EC=9D=80=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EB=AC=B8=EC=84=9C=20=EC=97=B0=EA=B2=B0(setLink)=20?= =?UTF-8?q?(PLAN=2039-4=20D5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_AreaLink.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts new file mode 100644 index 000000000..9bd76f293 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaLink.ts + * 면적산출 상세 — 칸 지정 모달(PLAN 39-4 D5). 오른쪽 패널의 담은 로직 · 자재 줄을 누르면 엶. + * 그 줄 알약 키(로직 물량 「이름|단위」 · 자재 「자재|MT키」)를 면적산출 칸에 잇기 · 옮기기 · 풀기. + * 정본 = 면적산출 문서 `연결` 한 곳 — 스프레드시트 손잡이 `setLink`(알약 누름과 같은 규칙 · 되돌리기 됨)라 + * 아래 알약 줄 · 칸 세모 · 패널 수량이 같이 따라감. 손 입력 수량은 일위대가 몫(여기 없음) · 저장은 [저장]. + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; + +const TEXT = { + title: "칸 지정 — {value}", + close: "닫기", + now: "지금 연결", + none: "연결 없음", + picked: "지금 고른 칸으로 ({value})", + address: "칸 주소", + hint: "예 B6", + link: "잇기", + unlink: "풀기", + bad: "칸 주소가 틀림 — 예 B6", + readOnly: "읽기만 — 고칠 수 없음", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface AreaLinkOptions { + /** 줄 이름(모달 제목) */ + label: string; + /** 알약 키 */ + key: string; + /** 연결에 같이 적을 이름 · 단위 · 자재 */ + info: LinkInfo; + sheet: SpreadsheetHandle; + /** 시트에서 지금 고른 칸(A1) */ + picked: () => string; + readOnly: boolean; +} + +/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */ +function linkedText(opts: AreaLinkOptions): string | null { + const link = opts.sheet + .getDoc() + .시트.flatMap((s) => s.연결 ?? []) + .find((l) => l.키 === opts.key); + if (!link) return null; + const value = opts.sheet.getLinkedValues()[opts.key]; + return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim(); +} + +export function openAreaLink(opts: AreaLinkOptions): void { + const { close } = openModal({ + title: fill(TEXT.title, opts.label), + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-area-link", + mount: (body) => { + const now = el("p", { className: "m02-area-link__now" }); + const paint = (): void => { + const text = linkedText(opts); + now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`; + unlink.disabled = opts.readOnly || !text; + }; + const done = (cell: string | null): boolean => { + if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false; + close(); + return true; + }; + const here = opts.picked(); + const pick = createButton({ + label: fill(TEXT.picked, here), + variant: "filled", + size: "sm", + onClick: () => void done(here), + }); + const address = createInputField({ label: TEXT.address, placeholder: TEXT.hint }); + const link = createButton({ + label: TEXT.link, + variant: "ghost", + size: "sm", + onClick: () => { + if (!done(address.input.value)) address.setError(TEXT.bad); + }, + }); + const unlink = createButton({ + label: TEXT.unlink, + variant: "ghost", + size: "sm", + onClick: () => void done(null), + }); + address.input.addEventListener("input", () => address.setError(undefined)); + address.input.addEventListener("keydown", (ev) => ev.key === "Enter" && link.click()); + pick.disabled = link.disabled = address.input.disabled = opts.readOnly; + paint(); + body.append( + now, + ...(opts.readOnly + ? [el("p", { className: "m02-struct__empty", text: TEXT.readOnly })] + : []), + el("div", { className: "m02-area-link__row", children: [pick, unlink] }), + el("div", { className: "m02-area-link__row", children: [address.root, link] }), + ); + if (!opts.readOnly) address.input.focus(); + }, + }); +} From 18a063925351ff1e5b72deddf1b7ecaf3522859c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:24:00 +0900 Subject: [PATCH 11/24] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=20=EB=AA=A9=EB=A1=9D=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20=E2=80=94=20M01=20=EC=B0=BE=EA=B8=B0=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EC=AA=BD=20=C2=B7=20=ED=91=9C=20=EA=BC=B4=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EB=A7=88=EB=8B=A4=20=EC=B9=B8(=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=ED=86=B5=EC=A7=B8)=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=B9=B8=20=EC=88=98=EC=A0=95=EC=9D=BC?= =?UTF-8?q?=20=C2=B7=20labor(=EB=B2=88=ED=98=B8=20=C2=B7=20=EB=8B=B4?= =?UTF-8?q?=EC=9D=80=20=EB=A1=9C=EC=A7=81=20=EC=88=98=20=C2=B7=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H1 · H6 — 전체 항목 · 복사 생성 · 항목 하나 복사가 같이 쓸 목록 하나. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 182 ++++++++++++++++++ .../M02_MasterTemplete_Items.py | 40 +++- resources/tester/helper_m02_item_list.cjs | 65 +++++++ resources/tester/test_m02_item_list.py | 94 +++++++++ resources/tester/test_m02_layers_store.py | 8 +- 5 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemList.ts create mode 100644 resources/tester/helper_m02_item_list.cjs create mode 100644 resources/tester/test_m02_item_list.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts new file mode 100644 index 000000000..363c48e5c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -0,0 +1,182 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemList.ts + * 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나 + * 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표 + * (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음). + * 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다. + * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). + * ========================================================================== */ + +import { createInputField, el } from "@ui/ui_template_elements"; +import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; +import "../M01_MasterData/M01_MasterData_UI_Style.css"; +import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; +import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model"; + +/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */ +export type ListKind = "structure" | "area" | "labor" | "item"; + +/** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */ +export interface ItemRow extends TreeItem { + 도번?: string | null; + 면적번?: string | null; + 수정일?: Partial>; + labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; +} + +export interface Col { + head: string; + get: (row: R) => string; +} + +const TEXT = { + search: "찾기", + none: "항목이 없음", + noMatch: "맞는 항목이 없음", + loading: "불러오는 중", +}; + +const SIZE = 30; +const DASH = "—"; +const day = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); +const money = (value: number | null | undefined): string => + typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; +/** 가장 늦게 고친 날 — 항목 통째 */ +const latest = (row: ItemRow): string => + day( + Object.values(row.수정일 ?? {}) + .sort() + .pop(), + ); + +const NAME: Col = { head: "이름", get: (r) => leafName(r) }; +const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; +const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; +const changed = (part: "structure" | "area" | "labor"): Col => ({ + head: "고친 날", + get: (r) => day(r.수정일?.[part]), +}); + +/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */ +export const ITEM_COLS: Record[]> = { + structure: [ + { head: "도번", get: (r) => r.도번 || DASH }, + NAME, + FOLDER, + UNIT, + changed("structure"), + ], + area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")], + labor: [ + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + NAME, + FOLDER, + UNIT, + { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, + { head: "합계", get: (r) => money(r.labor?.합계) }, + changed("labor"), + ], + item: [ + NAME, + FOLDER, + UNIT, + { head: "도번", get: (r) => r.도번 || DASH }, + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + { head: "면적번", get: (r) => r.면적번 || DASH }, + { head: "고친 날", get: latest }, + ], +}; + +/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ +export function pageOf( + rows: R[], + cols: Col[], + query: string, + page: number, + size = SIZE, +): { shown: R[]; page: number; total: number } { + const hits = rows.filter((r) => + matchText( + cols.map((c) => c.get(r)), + query, + ), + ); + const last = Math.max(1, Math.ceil(hits.length / size)); + const at = Math.min(Math.max(1, page), last); + return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length }; +} + +/** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */ +export const loadItems = async (layer: Layer, owner: string | null): Promise => + (await fetchTree(layer, owner)).items as ItemRow[]; + +export interface ItemListHandle { + root: HTMLElement; + /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ + show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; + /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ + message: (text?: string) => void; +} + +export function buildItemList(): ItemListHandle { + let draw = (): void => {}; + let page = 1; + const searchField = createInputField({ type: "search", placeholder: TEXT.search }); + const search = searchField.input; + const grid = el("div", { className: "m01-master__grid-wrap" }); + let pager = buildPager(0, SIZE, 1, () => {}); + const note = (text: string): void => { + grid.replaceChildren(el("p", { className: "m01-master__empty", text })); + }; + + const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { + page = 1; + draw = (): void => { + const got = pageOf(rows, cols, search.value, page); + page = got.page; + const next = buildPager(got.total, SIZE, page, (p) => { + page = p; + draw(); + }); + pager.replaceWith(next); + pager = next; + if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none); + const body = got.shown.map((row) => { + const tr = el("tr", { + className: "m01-master__table-row", + children: cols.map((c) => { + const text = c.get(row); + return el("td", { text, attrs: { title: text } }); + }), + }); + tr.addEventListener("click", () => onPick(row)); + return tr; + }); + grid.replaceChildren( + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })], + }), + el("tbody", { children: body }), + ], + }), + ); + }; + draw(); + }; + + search.addEventListener("input", () => { + page = 1; + draw(); + }); + const root = el("div", { + className: "m02-itemlist", + children: [buildHeadFilters([searchField.root, pager]), grid], + }); + // 처음 = 빈 표 자리(줄이 오면 `show`) + note(TEXT.loading); + return { root, show, message: (text = TEXT.loading) => note(text) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 90f0dacd7..385c46d28 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from datetime import datetime from pathlib import Path from typing import Any @@ -85,10 +86,47 @@ def items(folder: Path) -> list[dict[str, Any]]: k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번", "재료번") }, } - out.append({"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}, "names": names}) + out.append( + { + "key": r.get("키"), + **{k: r.get(k) for k in ITEM_FIELDS}, + "names": names, + **_list_extras(Path(folder), str(r.get("열"))), + } + ) return out +def _list_extras(folder: Path, col: str) -> dict[str, Any]: + """목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의 + 번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음.""" + stamps: dict[str, str] = {} + for kind in ("structure", "area", "labor"): + path = folder / kind / f"{col}.json" + if path.is_file(): + stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat( + timespec="seconds" + ) + labor = None + path = folder / "labor" / f"{col}.json" + if path.is_file(): + try: + doc = json.loads(path.read_bytes()) + except ValueError: + doc = {} + total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계") + try: + total = None if total is None or isinstance(total, bool) else float(total) + except (TypeError, ValueError): + total = None # 수 아닌 값(옛 꼴)은 「—」 + labor = { + "번호": doc.get("일위대가번"), + "로직수": len(doc.get("담은로직") or []), + "합계": total, + } + return {"수정일": stamps, "labor": labor} + + def _fresh_id(want: str, used: set[str]) -> str: out, n = want, 2 while out in used: diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs new file mode 100644 index 000000000..875074b62 --- /dev/null +++ b/resources/tester/helper_m02_item_list.cjs @@ -0,0 +1,65 @@ +/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 + * JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete"); +const load = (name, stubs) => { + const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const need = (id) => (id in stubs ? stubs[id] : {}); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +}; +const model = load("M02_MasterTemplete_TreePanel_Model.ts", {}); +const m = load("M02_MasterTemplete_ItemList.ts", { + "./M02_MasterTemplete_TreePanel_Model": model, +}); + +const row = (열, 경로, extra = {}) => ({ + key: `SS-${열}`, + 열, + 경로, + 이름: 경로.join(" "), + 단위: "m", + 도번: `SD-${열}`, + 면적번: `SA-${열}`, + 수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" }, + labor: null, + ...extra, +}); +const rows = [ + row("c1", ["기슭막이", "메쌓기", "1.2m"], { + labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 }, + 수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" }, + }), + row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }), + row("c3", ["옹벽"], { 도번: "", 수정일: {} }), +]; +const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r))); +const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`])); + +process.stdout.write( + JSON.stringify({ + heads: Object.fromEntries( + Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]), + ), + cells: { + structure: cells("structure"), + area: cells("area"), + labor: cells("labor"), + item: cells("item"), + }, + find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열), + findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열), + findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1), + page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))( + m.pageOf(many, m.ITEM_COLS.structure, "", 3), + ), + pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, + pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, + }), +); diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py new file mode 100644 index 000000000..63b809bd7 --- /dev/null +++ b/resources/tester/test_m02_item_list.py @@ -0,0 +1,94 @@ +"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) · +서버 목록 칸(임시 storage). + +① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 · + 고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽. +② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 · + 합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +from resources.tester import test_m02_tree as base +from resources.tester.test_m02_tree import Tree, _as + +world = base.world # 같은 임시 storage 판 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_item_list.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_종류마다_칸(ts): + assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["labor"] == [ + "일위대가번", + "이름", + "폴더", + "단위", + "담은 로직 수", + "합계", + "고친 날", + ] + assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), []) + + +def test_칸_값_빈_번호_합계_날짜(ts): + s, labor = ts["cells"]["structure"], ts["cells"]["labor"] + assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"] + assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음) + assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림 + assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음 + assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날 + + +def test_찾기_쪽(ts): + assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸) + assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음 + assert ts["findNone"] == {"shown": [], "page": 1, "total": 0} + assert ts["page3"] == {"page": 3, "total": 65, "n": 5} + assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1 + + +def test_서버_목록_칸_수정일_labor(world): + t = Tree(_as(world), "personal") + t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") + t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m") + root = world["storage"] / "7" / "42" / "templates" + ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"]) + assert set(ditch["수정일"]) >= {"structure", "area"} + assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO + assert ditch["labor"] is None # 일위대가 파일 없음 + (root / "labor").mkdir(exist_ok=True) + doc = { + "일위대가번": "SU-000001", + "담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}], + "계산값": {"반영합": {"계": 12345.6}}, + } + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + (root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8") + rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6} + assert "labor" in rows[ditch["열"]]["수정일"] + assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None} + doc["계산값"] = {"반영합": {"계": "글"}} + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + again = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert again[ditch["열"]]["labor"]["합계"] is None diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py index 1be487e98..9398233f1 100644 --- a/resources/tester/test_m02_layers_store.py +++ b/resources/tester/test_m02_layers_store.py @@ -77,7 +77,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world): rows = client.get("/api/m02/items").json() ss = store.read_combos()["줄"] assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001" - assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS} + assert set(rows[0]) == { + "key", + "names", + "수정일", # 목록 칸(39-5) + "labor", + *items_router.items_store.ITEM_FIELDS, + } assert set(rows[0]["names"]) == {"일위대가", "자재조합", "도번", "면적번", "재료번"} assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos() # 시스템 층 쓰기는 SYSTEM_ADMIN 만 From 134a727c9cf6e98832496cc162f4faf3b87225c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:27:02 +0900 Subject: [PATCH 12/24] =?UTF-8?q?feat(M02):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20M01=20=EB=A8=B8=EB=A6=AC=20=EA=BC=B4=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=9C=EB=AA=A9=2018px=20=EC=99=BC=EC=AA=BD=20?= =?UTF-8?q?=C2=B7=20=E3=80=8C=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0=E3=80=8D?= =?UTF-8?q?=20=EB=8A=94=20=EC=A0=9C=EB=AA=A9=20=EC=95=9E=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EC=98=A4=EB=A5=B8=EC=AA=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 마스터 템플릿 상세 머리 전부(구조물 조합 · 도면 · 면적산출 · 일위대가 · 양식 밖 화면). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts | 3 ++- M02_MasterTemplete/M02_MasterTemplete_UI_Style.css | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 480f213f2..522751dc9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -152,7 +152,8 @@ export function createMain( ...(extras.top ? [extras.top] : []), el("div", { className: "m02-main__head", - children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], + // M01 머리 꼴(39-5) — ← 는 제목 앞 · 제목 18px 왼쪽 · 단추는 오른쪽(slot 이 늘어나 밂) + children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], }), ...(extras.search ? [extras.search] : []), notice, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 0e323ca8c..4b4fa823a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -42,7 +42,7 @@ overflow-wrap: anywhere; /* 번호 + 이름 — 잘리지 않게 줄바꿈 */ margin: 0; color: var(--color-text); - font-size: var(--text-body); + font-size: 18px; /* M01 머리 제목과 같음(39-5) */ } .m02-main__head .ui-btn { From 43a59a68932d1d80e4ae7779d2e9bf44c107ab0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:27:30 +0900 Subject: [PATCH 13/24] =?UTF-8?q?refactor(M02):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EB=84=A3=EC=9D=84=20=EA=B0=92=20=EC=B9=B8=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EC=98=AE=EA=B9=80(39-3=20C2)=20=E2=80=94=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=9E=85=EB=A0=A5=EC=97=90=EC=84=9C=20M02?= =?UTF-8?q?=5FLabor=5FInputs.py=20=C2=B7=20M02=5FLabor=5FValues(=5FModel).?= =?UTF-8?q?ts=20=EB=A1=9C=20=C2=B7=20=EC=98=9B=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EB=8B=A4=EC=8B=9C=20=EB=82=B4=EB=B3=B4=EB=83=84=20?= =?UTF-8?q?=C2=B7=20=EC=9E=85=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20=EA=B8=B8=20?= =?UTF-8?q?logic-lib/inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 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 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Labor_Inputs.py | 94 ++++++ M02_MasterTemplete/M02_Labor_Values.css | 99 ++++++ M02_MasterTemplete/M02_Labor_Values.ts | 316 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Values_Model.ts | 116 +++++++ .../M02_MasterTemplete_Inputs.css | 98 ------ .../M02_MasterTemplete_Inputs.py | 65 +--- .../M02_MasterTemplete_Inputs.ts | 299 +---------------- .../M02_MasterTemplete_Inputs_Model.ts | 58 +--- .../M02_MasterTemplete_Router_Combos.py | 7 + resources/tester/helper_m02_inputs.cjs | 19 +- resources/tester/test_item_picker_detail.py | 9 +- resources/tester/test_m02_card_shell.py | 4 +- resources/tester/test_m02_labor_values.py | 101 ++++++ 13 files changed, 770 insertions(+), 515 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Inputs.py create mode 100644 M02_MasterTemplete/M02_Labor_Values.css create mode 100644 M02_MasterTemplete/M02_Labor_Values.ts create mode 100644 M02_MasterTemplete/M02_Labor_Values_Model.ts create mode 100644 resources/tester/test_m02_labor_values.py diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py new file mode 100644 index 000000000..068c64b33 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -0,0 +1,94 @@ +"""M02 일위대가 입력 목록(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs` 에서 옮김). + +담은 단가산출 로직마다 입력 전부 · 로직마다 갈라 보이고 이름이 같아도 합치지 않음 · 입력 목록은 +그 로직 줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만) · 자동값 = +`master_auto.values`(보기용 · 저장 안 함). 일위대가 입력 모달(`GET /api/m02/logic-lib/inputs`)과 +옛 선택 입력 길(`inputs`)이 같이 씀. M01 은 읽기만 · 권한은 부르는 쪽 몫. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos + +_SPEC = ("이름", "설명", "단위", "고르기", "범위") + + +def _num(value: Any) -> Any: + """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_num(v) for v in value] + if isinstance(value, dict): + return {k: _num(v) for k, v in value.items()} + return value + + +def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: + """([로직 줄 …], 빠진 로직 키) — 키 차례 그대로.""" + whole = m01.loaded()[1] + found, missing = [], [] + for code in codes: + try: + found.append(whole.logic(code)) + except combo_store.cm.mf.FormulaError: + missing.append(code) + return found, missing + + +def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: + """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" + if not ua: + return None, [], [] + row = combos.find(str(ua)) # 마스터 UA · 회사 UC · 개인 UP(27-2c) + if row is None: + return None, [], [] + found, missing = _logics(combo_store.mcb.logic_keys(row)) + return row, found, missing + + +def _listed(logics: list[dict]) -> list[dict[str, Any]]: + """로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`.""" + whole = m01.loaded()[1] + out = [] + for logic in logics: + auto = m01.mau.values(whole, logic) + specs = [] + for spec in logic.get("입력") or []: + one = {k: spec[k] for k in _SPEC if k in spec} + one.setdefault("설명", None) + one.setdefault("단위", None) + specs.append({**one, "자동값": auto.get(spec["이름"])}) + out.append( + { + "로직": logic.get("키"), + "이름": logic.get("이름"), + "결과단위": logic.get("결과단위"), + "입력": specs, + } + ) + return out + + +def logic_inputs(codes: list[str]) -> dict[str, Any]: + """일위대가 입력 모달 — 로직 키들의 입력 목록 `{로직: [...], 빠진로직}`(키 차례 · 겹침 한 번)""" + found, missing = _logics(list(dict.fromkeys(c for c in codes if c))) + return _num({"로직": _listed(found), "빠진로직": missing}) + + +def inputs_of(ua: str | None) -> dict[str, Any]: + """옛 선택 입력 목록 — `{일위대가, 이름, 로직: [...], 빠진로직}` · 일위대가 없으면 로직 없음.""" + row, logics, missing = combo_logics(ua) + return _num( + { + "일위대가": ua or None, + "이름": row.get("이름") if row else None, + "로직": _listed(logics), + "빠진로직": missing, + } + ) diff --git a/M02_MasterTemplete/M02_Labor_Values.css b/M02_MasterTemplete/M02_Labor_Values.css new file mode 100644 index 000000000..dca291790 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.css @@ -0,0 +1,99 @@ +/* 넣을 값 칸(39-3 · 옛 선택 입력 CSS 에서 옮김) — 프로젝트 값 카드 · 일위대가 입력 모달이 같이 씀. 색은 테마 토큰. */ + +/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ +.m02-inputval__wrap { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: var(--m02-struct-body-min, 16rem); + min-width: 0; + gap: var(--spacing-8); +} + +.m02-inputval { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.m02-inputval__places, +.m02-inputval__missing { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-inputval__logic { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-inputval__logic-name { + margin: 0; + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__row { + display: grid; + grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); + gap: var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__name { + color: var(--color-text); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-inputval__cell { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-8); + align-items: center; + min-width: 0; +} + +.m02-inputval__control { + flex: 1 1 6rem; + min-width: 0; + max-width: 24rem; +} + +/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ +.m02-inputval__value.is-auto, +.m02-inputval__control.is-auto, +.m02-inputval__control::placeholder { + color: var(--color-text-secondary); + font-style: italic; + opacity: 1; +} + +.m02-inputval__state { + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-inputval__state.is-confirmed { + color: var(--color-text); + font-weight: 600; +} + +.m02-inputval__error { + flex: 1 0 100%; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-inputval__control[aria-invalid="true"] { + border-color: var(--color-danger); +} diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts new file mode 100644 index 000000000..a7453b93a --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -0,0 +1,316 @@ +/* ============================================================================= + * M02_Labor_Values.ts + * 넣을 값 칸(39-3 · 옛 선택 입력 `M02_MasterTemplete_Inputs.ts` 에서 옮김) — 값 칸 규칙 한 벌: + * 고르기 = 목록 · 범위 = 숫자(밖이면 붉은 글) · 빈 칸 = 자동값(흐리게 · placeholder). + * · `createValueControl` — 칸 하나(일위대가 입력 모달 · 프로젝트 값 카드가 같이 씀) + * · `createInputsValueCard` — 프로젝트 고르기 모달 값 카드(옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { ItemInput, ItemInputs, ItemLogic, InputLinks } from "./M02_MasterTemplete_Api_Fetch"; +import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; +import { inputKey, pickedGroups, seatsOf } from "./M02_MasterTemplete_Inputs_Model"; +import { choicesOf, isOutside, rangeOf, storedOf, type ValueDoc } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; + +const fill = (text: string, values: Record): string => + text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); + +const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; + +const autoValue = (input: ItemInput): string | number | null => { + const auto = input.자동값; + return typeof auto === "string" || typeof auto === "number" ? auto : null; +}; + +export interface ValueControl { + control: HTMLInputElement | HTMLSelectElement; + /** 범위 밖 글(빈 글이면 비움) */ + error: HTMLElement; + /** 지금 범위 밖인가 — 부르는 쪽이 셈(값 카드 「막힌 칸 수」) */ + wasBad: boolean; +} + +/** 값 칸 하나 — `value` = 저장 값(null = 자동값) · 바뀔 때마다 `onChange(저장 값, 범위 밖)` */ +export function createValueControl( + input: ItemInput, + value: string | number | null, + onChange: (value: string | number | null, bad: boolean) => void, +): ValueControl { + const auto = autoValue(input); + const range = rangeOf(input); + const choices = choicesOf(input); + const control = ( + choices.length + ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) + : el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: range ? "number" : "text", + ...(range?.min !== undefined ? { min: String(range.min) } : {}), + ...(range?.max !== undefined ? { max: String(range.max) } : {}), + ...(range ? { step: "any" } : {}), + placeholder: auto === null ? "" : String(auto), + }, + }) + ) as HTMLInputElement | HTMLSelectElement; + if (control instanceof HTMLSelectElement) { + control.append( + el("option", { + attrs: { value: "" }, + text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, + }), + ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), + ); + } + const empty = value === null || value === ""; + control.value = empty ? "" : String(value); + control.classList.toggle("is-auto", empty); + const error = el("span", { className: "m02-inputval__error" }); + const out: ValueControl = { control, error, wasBad: false }; + control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", () => { + const raw = control.value; + const bad = isOutside(input, raw); + control.setAttribute("aria-invalid", bad ? "true" : "false"); + error.textContent = bad + ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) + : ""; + control.classList.toggle("is-auto", raw === ""); + onChange(storedOf(input, raw), bad); + }); + return out; +} + +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── + * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 + * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 + * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. + * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ + +export interface InputsValueData { + list: ItemInputs; + link: InputLinks | null; + values: ValueDoc; +} + +export interface InputsValueOptions { + mode: "view" | "value"; + /** mode "view" — [선택 입력 수정] */ + onEdit?: () => void; + /** mode "value" — 값이 바뀔 때마다 */ + onChange?: (values: ValueDoc) => void; +} + +export interface InputsValueHandle { + root: HTMLElement; + /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ + set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; + /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ + loading: () => () => void; + /** 지금 값(확인한 것 · 값이 든 것만) */ + getValues: () => ValueDoc; + /** 범위 밖 값이 없나 */ + isValid: () => boolean; + /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ + setEditLabel: (label: string) => void; +} + +const VALUE_TEXT = { + title: "선택 입력", + missing: "빠진 로직 {value}", + edit: "선택 입력 수정", + none: "선택으로 이은 입력이 없음", + auto: "자동값", + unconfirmed: "확인 안 함", + confirmed: "✓ 확인함", + confirmBox: "확인", + summary: "선택 {value}", + places: "면적산출 알약 {area} · 재료산출 알약 {material}", + noList: "입력 목록을 못 읽음 — {value}", +}; + +type ValueEntry = ValueDoc[string][string]; + +export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { + let data: InputsValueData | null = null; + let values: ValueDoc = {}; + let invalid = 0; + + const summary = el("span", { className: "m02-struct__no" }); + const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); + edit.hidden = opts.mode !== "view"; + if (opts.onEdit) edit.addEventListener("click", opts.onEdit); + const places = el("p", { className: "m02-inputval__places" }); + const body = el("div", { className: "m02-struct__sheet m02-inputval" }); + // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 + const shell = cardShell({ + title: VALUE_TEXT.title, + sub: [summary], + body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), + stateHost: body, + }); + shell.tools.append(edit); + const root = shell.root; + + const groups = (): ReturnType => + data ? pickedGroups(data.list, data.link) : []; + const one = (logic: string, name: string): ValueEntry => + values[logic]?.[name] ?? { 값: null, 확인: false }; + const isEmpty = (value: string | number | null): boolean => value === null || value === ""; + const put = (logic: string, name: string, next: ValueEntry): void => { + (values[logic] ??= {})[name] = next; + }; + const autoOf = autoValue; + + const paintSummary = (): void => { + let total = 0; + let confirmed = 0; + for (const { logic, inputs } of groups()) + for (const input of inputs) { + total += 1; + if (one(logic.로직, input.이름).확인) confirmed += 1; + } + summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; + }; + + /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ + const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { + const stored = one(logic.로직, input.이름); + const auto = autoOf(input); + const state = el("span", { className: "m02-inputval__state" }); + const paintState = (): void => { + const now = one(logic.로직, input.이름); + state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; + state.classList.toggle("is-confirmed", now.확인); + }; + if (opts.mode === "view") { + const text = isEmpty(stored.값) ? auto : stored.값; + const shown = el("span", { + className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, + text: text === null ? "—" : String(text), + attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [shown, state] }); + } + + const emit = (): void => { + paintState(); + paintSummary(); + opts.onChange?.(values); + }; + const range = rangeOf(input); + const field = createValueControl(input, stored.값, (value, bad) => { + if (bad !== field.wasBad) invalid += bad ? 1 : -1; + field.wasBad = bad; + // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) + put(logic.로직, input.이름, { 값: bad ? null : value, 확인: value !== null && !bad }); + box.checked = value !== null && !bad; + emit(); + }); + const { control, error } = field; + const box = el("input", { + attrs: { + type: "checkbox", + title: VALUE_TEXT.confirmBox, + "aria-label": VALUE_TEXT.confirmBox, + }, + }) as HTMLInputElement; + box.checked = stored.확인; + // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 + box.addEventListener("change", () => { + const now = one(logic.로직, input.이름); + if (box.checked && isEmpty(now.값) && auto !== null) { + control.value = String(auto); + control.classList.remove("is-auto"); + put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); + } else if (box.checked && isEmpty(now.값)) { + box.checked = false; // 받아들일 값이 없음 + } else { + put(logic.로직, input.이름, { ...now, 확인: box.checked }); + } + emit(); + }); + paintState(); + return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); + }; + + const paint = (note?: string, retry?: () => void): void => { + invalid = 0; + paintSummary(); + if (!data) { + places.textContent = ""; + if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); + return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); + } + const seats = [...seatsOf(data.link).values()]; + const area = seats.filter((seat) => seat === "면적").length; + const material = seats.filter((seat) => seat === "재료").length; + places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; + const shown = groups(); + const missing = data.list.빠진로직.length + ? [ + el("p", { + className: "m02-inputval__missing", + text: fill(VALUE_TEXT.missing, { value: data.list.빠진로직.join(", ") }), + }), + ] + : []; + if (!shown.length) { + body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); + return; + } + body.replaceChildren( + ...shown.map(({ logic, inputs }) => + el("section", { + className: "m02-inputval__logic", + attrs: { "data-logic": logic.로직 }, + children: [ + el("h4", { + className: "m02-inputval__logic-name", + text: logic.이름 || logic.로직, + attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, + }), + ...inputs.map((input) => + el("div", { + className: "m02-inputval__row", + attrs: { "data-input": inputKey(logic.로직, input.이름) }, + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: input.설명 ? { title: input.설명 } : {}, + }), + valueCell(logic, input), + ], + }), + ), + ], + }), + ), + ...missing, + ); + }; + + return { + root, + set: (next, note, retry) => { + data = next; + values = next ? structuredClone(next.values) : {}; + paint(note, retry); + }, + loading: () => shell.loading(), + getValues: () => { + // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) + const out: ValueDoc = {}; + for (const [logic, byName] of Object.entries(values)) + for (const [name, entry] of Object.entries(byName)) + if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; + return out; + }, + isValid: () => invalid === 0, + setEditLabel: (label) => { + (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; + }, + }; +} diff --git a/M02_MasterTemplete/M02_Labor_Values_Model.ts b/M02_MasterTemplete/M02_Labor_Values_Model.ts new file mode 100644 index 000000000..046ee7cc8 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Values_Model.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * M02_Labor_Values_Model.ts + * 넣을 값 셈(화면 없음 · 39-3) — 옛 선택 입력 `M02_MasterTemplete_Inputs_Model.ts` 의 값 함수를 옮김. + * 일위대가 입력 모달 · 프로젝트 고르기 모달 값 카드가 같은 값 칸 규칙(고르기 = 목록 · 범위 = 숫자 · + * 빈 값 = 자동값)을 씀. 일위대가 입력값 = `{입력 이름: 값}`(빈 값은 안 둠 = 자동값 · 모르는 이름 버림). + * ========================================================================== */ + +import type { ItemInput } from "./M02_MasterTemplete_Api_Fetch"; + +const text = (value: unknown): string => + value === null || value === undefined + ? "" + : typeof value === "object" + ? JSON.stringify(value) + : String(value); + +/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ +export function valueHint(input: ItemInput): string { + if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + const range = input.범위; + if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; + if (range && typeof range === "object") { + const r = range as Record; + const lo = r.최소 ?? r.min; + const hi = r.최대 ?? r.max; + if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; + } + return text(range); +} + +/** 자동값 글 — 없으면 빈 글 */ +export const autoText = (input: ItemInput): string => text(input.자동값); + +/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ +export type ValueDoc = Record< + string, + Record +>; + +const number = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ +export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { + const raw = input.범위; + if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; + if (raw && typeof raw === "object") { + const r = raw as Record; + return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; + } + return null; +} + +/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ +export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { + return (input.고르기 ?? []).map((one) => { + const object = + typeof one === "object" && one !== null ? (one as Record) : null; + const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; + return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + }); +} + +/** 범위 밖 글인가 — 빈 글 · 범위 없는 입력은 아님 */ +export function isOutside(input: ItemInput, raw: string): boolean { + const range = rangeOf(input); + if (!range || raw.trim() === "") return false; + const value = Number(raw); + return ( + !Number.isFinite(value) || + (range.min !== undefined && value < range.min) || + (range.max !== undefined && value > range.max) + ); +} + +/** 칸 글 → 저장 값 — 빈 글 = null(자동값) · 범위 = 수 · 고르기 = 원래 모양 · 그 밖 = 글 */ +export function storedOf(input: ItemInput, raw: string): string | number | null { + if (raw === "") return null; + if (rangeOf(input)) return Number(raw); + return choicesOf(input).find((c) => c.값 === raw)?.raw ?? raw; +} + +/* ── 일위대가 입력값(39-3) — 로직 한 줄의 `{입력 이름: 값}` ───────────────────────────── */ + +export type LaborValues = Record; + +/** 로직 입력 목록에 있는 이름 · 빈 값 아닌 것만 남김 + 사라진 이름(로직 입력이 바뀌어 버려질 값) */ +export function keepKnown( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { kept: LaborValues; lost: string[] } { + const names = new Set(inputs.map((i) => i.이름)); + const kept: LaborValues = {}; + const lost: string[] = []; + for (const [name, value] of Object.entries(values ?? {})) { + if (!names.has(name)) lost.push(name); + else if (value !== "" && value !== null && value !== undefined) kept[name] = value; + } + return { kept, lost }; +} + +/** 줄 아래 작은 글 — 「지역 서울 · 규격 25mm」(넣은 값만 · 입력 차례) + 빈 값 수(자동값도 없는 입력) */ +export function valueLine( + inputs: ItemInput[], + values: LaborValues | null | undefined, +): { text: string; empty: number } { + const parts: string[] = []; + let empty = 0; + for (const input of inputs) { + const value = values?.[input.이름]; + if (value !== undefined && value !== "") { + parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`); + } else if (autoText(input) === "") empty += 1; + } + return { text: parts.join(" · "), empty }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css index d90721bea..fff107c95 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.css @@ -103,101 +103,3 @@ .m02-inputs__seat:disabled { opacity: 0.7; } - -/* ── 프로젝트에서 값 받기(PLAN 25-2d) — 값 · 확인 표시 카드 ── */ -.m02-inputval__wrap { - display: flex; - flex-direction: column; - flex: 1 1 0; - min-height: var(--m02-struct-body-min, 16rem); - min-width: 0; - gap: var(--spacing-8); -} - -.m02-inputval { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - overflow: auto; - min-height: 0; - min-width: 0; -} - -.m02-inputval__places, -.m02-inputval__missing { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.m02-inputval__logic { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - min-width: 0; -} - -.m02-inputval__logic-name { - margin: 0; - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__row { - display: grid; - grid-template-columns: minmax(6rem, 2fr) minmax(8rem, 3fr); - gap: var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__name { - color: var(--color-text); - font-size: var(--text-caption); - overflow-wrap: anywhere; -} - -.m02-inputval__cell { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4) var(--spacing-8); - align-items: center; - min-width: 0; -} - -.m02-inputval__control { - flex: 1 1 6rem; - min-width: 0; - max-width: 24rem; -} - -/* 자동값 = 저장값이 아님 — 기울임 + 보조 글자색(글자 대비 4.5 이상 유지) */ -.m02-inputval__value.is-auto, -.m02-inputval__control.is-auto, -.m02-inputval__control::placeholder { - color: var(--color-text-secondary); - font-style: italic; - opacity: 1; -} - -.m02-inputval__state { - color: var(--color-text-secondary); - font-size: var(--text-caption); - white-space: nowrap; -} - -.m02-inputval__state.is-confirmed { - color: var(--color-text); - font-weight: 600; -} - -.m02-inputval__error { - flex: 1 0 100%; - color: var(--color-danger); - font-size: var(--text-caption); -} - -.m02-inputval__control[aria-invalid="true"] { - border-color: var(--color-danger); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py index 91bc32d87..d6c130984 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -14,30 +14,18 @@ M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · from __future__ import annotations import json -from decimal import Decimal from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json -from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Labor_Inputs import _num, combo_logics PLACES = ("면적", "재료", "선택", None) PILL_HEAD = "입력" -_SPEC = ("이름", "설명", "단위", "고르기", "범위") - - -def _num(value: Any) -> Any: - """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" - if isinstance(value, Decimal): - return int(value) if value == value.to_integral_value() else float(value) - if isinstance(value, list): - return [_num(v) for v in value] - if isinstance(value, dict): - return {k: _num(v) for k, v in value.items()} - return value def pill_key(logic: str, name: str) -> str: @@ -56,55 +44,10 @@ def _combo_row(ua: str) -> dict[str, Any] | None: return combos.find(ua) # 마스터 UA · 회사 UC · 개인 UP(27-2c) -def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: - """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" - if not ua: - return None, [], [] - row = _combo_row(str(ua)) - if row is None: - return None, [], [] - whole = m01.loaded()[1] - found, missing = [], [] - for one in combo_store.mcb.items(row): - code = str(one.get("로직") or "") if isinstance(one, dict) else "" - try: - found.append(whole.logic(code)) - except combo_store.cm.mf.FormulaError: - missing.append(code) - return row, found, missing - - def inputs(key: str) -> dict[str, Any]: """25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`. - 일위대가 없는 항목은 로직 빈 목록.""" - ua = _item(key).get("일위대가") - row, logics, missing = combo_logics(ua) - whole = m01.loaded()[1] - out = [] - for logic in logics: - auto = m01.mau.values(whole, logic) - specs = [] - for spec in logic.get("입력") or []: - one = {k: spec[k] for k in _SPEC if k in spec} - one.setdefault("설명", None) - one.setdefault("단위", None) - specs.append({**one, "자동값": auto.get(spec["이름"])}) - out.append( - { - "로직": logic.get("키"), - "이름": logic.get("이름"), - "결과단위": logic.get("결과단위"), - "입력": specs, - } - ) - return _num( - { - "일위대가": ua or None, - "이름": row.get("이름") if row else None, - "로직": out, - "빠진로직": missing, - } - ) + 일위대가 없는 항목은 로직 빈 목록 · 몸은 `M02_Labor_Inputs`(일위대가 입력 모달과 한 벌).""" + return labor_inputs.inputs_of(_item(key).get("일위대가")) # ── 항목 연결 파일 ───────────────────────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 8b31ae48f..2b9b8a143 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -10,7 +10,7 @@ * · 면적 · 재료로 고른 입력은 그 시트 편집 화면 알약 줄에 뜸(`inputPills` → `pillsFor`). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import { fetchItemInputs, @@ -18,28 +18,21 @@ import { getActiveLayer, readInputLinks, saveInputLinks, - type InputLinks, type InputSeat, - type ItemInput, type ItemInputs, - type ItemLogic, type ItemRow, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { autoText, - choicesOf, countOf, inputKey, linesOf, - pickedGroups, pillsOf, - rangeOf, seatsOf, SEAT_OPTIONS, valueHint, type SeatMap, - type ValueDoc, } from "./M02_MasterTemplete_Inputs_Model"; import { cardNote, cardShell } from "./M02_MasterTemplete_CardShell"; import "./M02_MasterTemplete_Inputs.css"; @@ -288,286 +281,10 @@ export async function inputPills(column: string, seat: "면적" | "재료"): Pro } } -/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 고르기 모달 카드 ───────────────────────────── - * 같은 카드 틀 · 같은 입력 목록인데 여기서는 자리 고르기 대신 **값**을 받음: 자리가 「선택」 인 입력만 - * 로직마다 묶어, 고르기 = 목록 · 범위 = 숫자 · 자동값은 흐리게 · 「확인함」 표시. 자동값은 저장값이 아님 - * (확인 안 한 입력은 계산 때 「빠진 입력」). 값은 로직마다 따로 — 이름이 같아도 합치지 않음. - * mode "view" 보기만 + [선택 입력 수정] · mode "value" 값 받기(범위 밖은 저장 막음) */ - -export interface InputsValueData { - list: ItemInputs; - link: InputLinks | null; - values: ValueDoc; -} - -export interface InputsValueOptions { - mode: "view" | "value"; - /** mode "view" — [선택 입력 수정] */ - onEdit?: () => void; - /** mode "value" — 값이 바뀔 때마다 */ - onChange?: (values: ValueDoc) => void; -} - -export interface InputsValueHandle { - root: HTMLElement; - /** 자료를 바꿔 끼움 — `null` 은 못 읽은 경우(까닭 글 `note`) */ - set: (data: InputsValueData | null, note?: string, retry?: () => void) => void; - /** 목록을 받는 동안 0.3초 넘으면 「불러오는 중」(31-7) — 돌려받은 함수 = 끝 */ - loading: () => () => void; - /** 지금 값(확인한 것 · 값이 든 것만) */ - getValues: () => ValueDoc; - /** 범위 밖 값이 없나 */ - isValid: () => boolean; - /** [선택 입력 수정] 글 — 아직 안 가져온 항목이면 「프로젝트로 가져와 고침」 */ - setEditLabel: (label: string) => void; -} - -const VALUE_TEXT = { - edit: "선택 입력 수정", - none: "선택으로 이은 입력이 없음", - auto: "자동값", - unconfirmed: "확인 안 함", - confirmed: "✓ 확인함", - confirmBox: "확인", - outOfRange: "범위 {value} 밖", - summary: "선택 {value}", - places: "면적산출 알약 {area} · 재료산출 알약 {material}", - noList: "입력 목록을 못 읽음 — {value}", -}; - -type ValueEntry = ValueDoc[string][string]; - -export function createInputsValueCard(opts: InputsValueOptions): InputsValueHandle { - let data: InputsValueData | null = null; - let values: ValueDoc = {}; - let invalid = 0; - - const summary = el("span", { className: "m02-struct__no" }); - const edit = createButton({ label: VALUE_TEXT.edit, variant: "ghost", size: "sm" }); - edit.hidden = opts.mode !== "view"; - if (opts.onEdit) edit.addEventListener("click", opts.onEdit); - const places = el("p", { className: "m02-inputval__places" }); - const body = el("div", { className: "m02-struct__sheet m02-inputval" }); - // 공용 카드 틀(31-7) — 부제 = 확인 n/m · 제목 줄 오른쪽 = [선택 입력 수정] · 상태 글은 목록 칸에 - const shell = cardShell({ - title: TEXT.title, - sub: [summary], - body: el("div", { className: "m02-inputval__wrap", children: [places, body] }), - stateHost: body, - }); - shell.tools.append(edit); - const root = shell.root; - - const groups = (): ReturnType => - data ? pickedGroups(data.list, data.link) : []; - const one = (logic: string, name: string): ValueEntry => - values[logic]?.[name] ?? { 값: null, 확인: false }; - const isEmpty = (value: string | number | null): boolean => value === null || value === ""; - const put = (logic: string, name: string, next: ValueEntry): void => { - (values[logic] ??= {})[name] = next; - }; - const autoOf = (input: ItemInput): string | number | null => { - const auto = input.자동값; - return typeof auto === "string" || typeof auto === "number" ? auto : null; - }; - - const paintSummary = (): void => { - let total = 0; - let confirmed = 0; - for (const { logic, inputs } of groups()) - for (const input of inputs) { - total += 1; - if (one(logic.로직, input.이름).확인) confirmed += 1; - } - summary.textContent = data ? fill(VALUE_TEXT.summary, { value: `${confirmed}/${total}` }) : ""; - }; - - /** 값 칸 하나 — 자동값은 흐리게(기울임 · placeholder · 첫 줄) */ - const valueCell = (logic: ItemLogic, input: ItemInput): HTMLElement => { - const stored = one(logic.로직, input.이름); - const auto = autoOf(input); - const state = el("span", { className: "m02-inputval__state" }); - const paintState = (): void => { - const now = one(logic.로직, input.이름); - state.textContent = now.확인 ? VALUE_TEXT.confirmed : VALUE_TEXT.unconfirmed; - state.classList.toggle("is-confirmed", now.확인); - }; - if (opts.mode === "view") { - const text = isEmpty(stored.값) ? auto : stored.값; - const shown = el("span", { - className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, - text: text === null ? "—" : String(text), - attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [shown, state] }); - } - - const emit = (): void => { - paintState(); - paintSummary(); - opts.onChange?.(values); - }; - const range = rangeOf(input); - const choices = choicesOf(input); - const control = ( - choices.length - ? el("select", { className: "ui-select ui-select--sm m02-inputval__control" }) - : el("input", { - className: "ui-input m02-inputval__control", - attrs: { - type: range ? "number" : "text", - ...(range?.min !== undefined ? { min: String(range.min) } : {}), - ...(range?.max !== undefined ? { max: String(range.max) } : {}), - ...(range ? { step: "any" } : {}), - placeholder: auto === null ? "" : String(auto), - }, - }) - ) as HTMLInputElement | HTMLSelectElement; - const error = el("span", { className: "m02-inputval__error" }); - const box = el("input", { - attrs: { - type: "checkbox", - title: VALUE_TEXT.confirmBox, - "aria-label": VALUE_TEXT.confirmBox, - }, - }) as HTMLInputElement; - - if (control instanceof HTMLSelectElement) { - control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${VALUE_TEXT.auto} ${String(auto)}`, - }), - ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), - ); - } - control.value = isEmpty(stored.값) ? "" : String(stored.값); - control.classList.toggle("is-auto", isEmpty(stored.값)); - box.checked = stored.확인; - - const outside = (raw: string): boolean => { - if (!range || raw.trim() === "") return false; - const value = Number(raw); - return ( - !Number.isFinite(value) || - (range.min !== undefined && value < range.min) || - (range.max !== undefined && value > range.max) - ); - }; - let wasBad = false; - const changed = (): void => { - const raw = control.value; - const bad = outside(raw); - if (bad !== wasBad) invalid += bad ? 1 : -1; - wasBad = bad; - control.setAttribute("aria-invalid", bad ? "true" : "false"); - error.textContent = bad - ? fill(VALUE_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) - : ""; - control.classList.toggle("is-auto", raw === ""); - const value: string | number | null = - raw === "" ? null : range ? Number(raw) : (choices.find((c) => c.값 === raw)?.raw ?? raw); - // 값을 넣으면 확인 · 비우면 확인 풀림(자동값은 저장값이 아님) - put(logic.로직, input.이름, { 값: bad ? null : value, 확인: raw !== "" && !bad }); - box.checked = raw !== "" && !bad; - emit(); - }; - control.addEventListener(control instanceof HTMLSelectElement ? "change" : "input", changed); - // 확인 상자 — 비운 채 켜면 자동값을 그대로 받아들임 · 끄면 확인만 풀림 - box.addEventListener("change", () => { - const now = one(logic.로직, input.이름); - if (box.checked && isEmpty(now.값) && auto !== null) { - control.value = String(auto); - control.classList.remove("is-auto"); - put(logic.로직, input.이름, { 값: range ? Number(auto) : auto, 확인: true }); - } else if (box.checked && isEmpty(now.값)) { - box.checked = false; // 받아들일 값이 없음 - } else { - put(logic.로직, input.이름, { ...now, 확인: box.checked }); - } - emit(); - }); - paintState(); - return el("div", { className: "m02-inputval__cell", children: [control, box, state, error] }); - }; - - const paint = (note?: string, retry?: () => void): void => { - invalid = 0; - paintSummary(); - if (!data) { - places.textContent = ""; - if (note && retry) return shell.fail(fill(VALUE_TEXT.noList, { value: note }), retry); - return shell.note(note ? fill(VALUE_TEXT.noList, { value: note }) : VALUE_TEXT.none); - } - const seats = [...seatsOf(data.link).values()]; - const area = seats.filter((seat) => seat === "면적").length; - const material = seats.filter((seat) => seat === "재료").length; - places.textContent = area + material ? fill(VALUE_TEXT.places, { area, material }) : ""; - const shown = groups(); - const missing = data.list.빠진로직.length - ? [ - el("p", { - className: "m02-inputval__missing", - text: fill(TEXT.missing, { value: data.list.빠진로직.join(", ") }), - }), - ] - : []; - if (!shown.length) { - body.replaceChildren(cardNote(VALUE_TEXT.none), ...missing); - return; - } - body.replaceChildren( - ...shown.map(({ logic, inputs }) => - el("section", { - className: "m02-inputval__logic", - attrs: { "data-logic": logic.로직 }, - children: [ - el("h4", { - className: "m02-inputval__logic-name", - text: logic.이름 || logic.로직, - attrs: { title: `${logic.로직}${logic.결과단위 ? ` · ${logic.결과단위}` : ""}` }, - }), - ...inputs.map((input) => - el("div", { - className: "m02-inputval__row", - attrs: { "data-input": inputKey(logic.로직, input.이름) }, - children: [ - el("span", { - className: "m02-inputval__name", - text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, - attrs: input.설명 ? { title: input.설명 } : {}, - }), - valueCell(logic, input), - ], - }), - ), - ], - }), - ), - ...missing, - ); - }; - - return { - root, - set: (next, note, retry) => { - data = next; - values = next ? structuredClone(next.values) : {}; - paint(note, retry); - }, - loading: () => shell.loading(), - getValues: () => { - // 확인한 것 · 값이 든 것만 — 입력 목록에서 없어진 입력은 그대로 둠(다음 가져오기가 정함) - const out: ValueDoc = {}; - for (const [logic, byName] of Object.entries(values)) - for (const [name, entry] of Object.entries(byName)) - if (entry.확인 || !isEmpty(entry.값)) (out[logic] ??= {})[name] = entry; - return out; - }, - isValid: () => invalid === 0, - setEditLabel: (label) => { - (edit.querySelector(".ui-btn__label") ?? edit).textContent = label; - }, - }; -} +/* ── 프로젝트 복사본에서 값 받기(PLAN 25-2d) — 39-3 에 `M02_Labor_Values.ts` 로 옮김 · 옛 이름 그대로 내보냄 ── */ +export { + createInputsValueCard, + type InputsValueData, + type InputsValueHandle, + type InputsValueOptions, +} from "./M02_Labor_Values"; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts index a6ab719f2..bbdb8fc53 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs_Model.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Inputs_Model.ts - * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록 · 값 글. + * 「선택 입력」 카드의 셈(화면 없음) — 입력 키 · 알약 키 · 자리 고름 · 줄 만들기 · 알약 목록. + * 값 글 · 값 칸 규칙은 `M02_Labor_Values_Model.ts`(일위대가 입력 모달과 한 벌 · 여기서 다시 내보냄). * 약속 = PLAN 25-1b: 입력 키 `{로직키}|{입력이름}` · 알약 키 `입력|{로직키}|{입력이름}` · * 같은 이름의 입력도 로직마다 따로. 처음은 모두 「안 씀」 — 프로그램이 추측해 나누지 않음. * ========================================================================== */ @@ -78,62 +79,11 @@ export function pillsOf( return out; } -const text = (value: unknown): string => - value === null || value === undefined - ? "" - : typeof value === "object" - ? JSON.stringify(value) - : String(value); - -/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ -export function valueHint(input: ItemInput): string { - if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); - const range = input.범위; - if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; - if (range && typeof range === "object") { - const r = range as Record; - const lo = r.최소 ?? r.min; - const hi = r.최대 ?? r.max; - if (lo !== undefined || hi !== undefined) return `${text(lo)} ~ ${text(hi)}`; - } - return text(range); -} - -/** 자동값 글 — 없으면 빈 글 */ -export const autoText = (input: ItemInput): string => text(input.자동값); +/* ── 값 함수 — 일위대가 입력 모달과 한 벌(39-3 · `M02_Labor_Values_Model.ts` 로 옮김 · 옛 이름 그대로 내보냄) ── */ +export { autoText, choicesOf, rangeOf, valueHint, type ValueDoc } from "./M02_Labor_Values_Model"; /* ── 프로젝트에서 값 받기(PLAN 25-2d) — 자리 「선택」 인 입력만 로직마다 묶어 값 · 확인 표시 ── */ -/** `입력값.json` — `{로직키: {입력이름: {값, 확인}}}` (서버 검사 모양 · 값은 수 · 글 · 빈 값) */ -export type ValueDoc = Record< - string, - Record ->; - -const number = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; - -/** 범위 모양 둘(`[최소, 최대]` · `{최소, 최대}`)을 하나로 — 범위가 아니면 null */ -export function rangeOf(input: ItemInput): { min?: number; max?: number } | null { - const raw = input.범위; - if (Array.isArray(raw)) return { min: number(raw[0]), max: number(raw[1]) }; - if (raw && typeof raw === "object") { - const r = raw as Record; - return { min: number(r.최소 ?? r.min), max: number(r.최대 ?? r.max) }; - } - return null; -} - -/** 고르기 목록 → 화면 값 · 이름 짝 — `raw` 는 원래 모양(숫자 목록이면 숫자로 저장) */ -export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: string | number }[] { - return (input.고르기 ?? []).map((one) => { - const object = - typeof one === "object" && one !== null ? (one as Record) : null; - const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; - return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; - }); -} - /** 자리가 「선택」 인 입력만 로직 차례로 묶음 — 값 받기 카드가 보이는 입력 */ export function pickedGroups( list: ItemInputs, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..0944f8d6d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos @@ -230,3 +231,9 @@ def get_logic(key: str) -> dict: def get_logic_auto(key: str) -> dict: """견본 입력 — 미리 보기용 자동값.""" return _read(m01.auto, key) + + +@router.get("/logic-lib/inputs", dependencies=[Depends(verify_session)]) +def get_logic_inputs(keys: str = "") -> dict: + """일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`).""" + return _read(labor_inputs.logic_inputs, keys.split(",")) diff --git a/resources/tester/helper_m02_inputs.cjs b/resources/tester/helper_m02_inputs.cjs index 6f014a6a1..1e14e5c27 100644 --- a/resources/tester/helper_m02_inputs.cjs +++ b/resources/tester/helper_m02_inputs.cjs @@ -4,13 +4,18 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); -const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts"); -const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; +// 상대 경로 TS(값 함수 = `M02_Labor_Values_Model.ts` 다시 내보냄)는 이어 트랜스파일 +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const local = (request) => + request.startsWith(".") ? loadTs(`${path.resolve(path.dirname(file), request)}.ts`) : require(request); + new Function("exports", "module", "require", js)(box.exports, box, local); + return box.exports; +} +const m = loadTs(path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_Inputs_Model.ts")); // 같은 이름(높이)의 입력이 두 로직에 있음 const list = { diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index f5a58dfe4..e0b785b12 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -137,11 +137,14 @@ def test_모달은_M02_와_같은_카드_한_벌을_씀(): def test_선택_입력_카드는_sub2_파일_한_벌(out): - """두 벌 금지 — 모달의 값 받기 카드는 `M02_MasterTemplete_Inputs.ts`(M02 카드와 같은 파일).""" + """두 벌 금지 — 모달의 값 받기 카드는 `M02_Labor_Values.ts`(39-3 · 일위대가 입력 모달과 같은 + 값 칸) · 옛 자리 `M02_MasterTemplete_Inputs.ts` 가 다시 내보냄.""" assert not (M02 / "M02_MasterTemplete_InputsCard.ts").exists() inputs = (M02 / "M02_MasterTemplete_Inputs.ts").read_text(encoding="utf-8") + values = (M02 / "M02_Labor_Values.ts").read_text(encoding="utf-8") assert "export function createInputsCard(" in inputs # M02 자리 고르기(link) - assert "export function createInputsValueCard(" in inputs # 모달 값 받기(view · value) + assert "export function createInputsValueCard(" in values # 모달 값 받기(view · value) + assert "createInputsValueCard," in inputs and '} from "./M02_Labor_Values";' in inputs for name in ("ui_template_item_picker_detail.ts", "ui_template_item_picker_edit.ts"): text = (UI / name).read_text(encoding="utf-8") assert "M02_MasterTemplete_Inputs" in text and 'InputsCard"' not in text, name @@ -183,6 +186,8 @@ def test_소스가_700줄을_넘지_않음(): M02 / "M02_MasterTemplete_ItemSource.ts", M02 / "M02_MasterTemplete_Inputs.ts", M02 / "M02_MasterTemplete_Inputs_Model.ts", + M02 / "M02_Labor_Values.ts", + M02 / "M02_Labor_Values_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 8501c9945..f0070d654 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -44,8 +44,8 @@ def test_네_카드가_틀을_씀_수정_단추_한_모양(): assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards - inputs = _text("M02_MasterTemplete_Inputs.ts") - assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드 + inputs = _text("M02_MasterTemplete_Inputs.ts") + _text("M02_Labor_Values.ts") + assert inputs.count("cardShell({") == 2 # 선택 입력 · 값 카드(39-3 에 `M02_Labor_Values.ts` 로) assert "shell.fail(" in inputs and "shell.loading()" in inputs assert "m02-struct__card ui-collapsible" not in inputs # 손으로 만든 겉 틀 없음 drawing = _text("M02_MasterTemplete_DrawingCard.ts") diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py new file mode 100644 index 000000000..1757129cb --- /dev/null +++ b/resources/tester/test_m02_labor_values.py @@ -0,0 +1,101 @@ +"""일위대가 넣을 값(39-3) — 입력 목록 길 `GET /api/m02/logic-lib/inputs` · 값 셈(TS 모델). + +못박는 것 + - 입력 목록 = 키 차례 · 겹친 키 한 번 · 입력마다 자동값 · 없는 키는 「빠진로직」. + - 옛 선택 입력 길(`M02_MasterTemplete_Inputs.inputs`)과 몸 한 벌(`M02_Labor_Inputs`). + - 값 셈 = 칸 글 → 저장 값(빈 글 = 자동값 · 범위 = 수 · 고르기 = 원래 모양) · 범위 밖 · + 모르는 이름 버림(사라진 값 알림) · 줄 아래 작은 글 · 빈 값 수(자동값도 없는 입력만). +화면 TS 는 그 자리에서 트랜스파일해 node 로 돌림 · 정본은 읽기만. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos_router + +ROOT = Path(__file__).resolve().parents[2] +A, B = "GF000222", "GF000221" # 찰쌓기 인력 · 장비 + + +@pytest.fixture() +def client() -> TestClient: + app = FastAPI() + app.include_router(combos_router.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": "USER"} + return TestClient(app) + + +def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(client: TestClient) -> None: + res = client.get("/api/m02/logic-lib/inputs", params={"keys": f"{B},{A},{B},GF999999,"}) + assert res.status_code == 200, res.text + got = res.json() + assert [x["로직"] for x in got["로직"]] == [B, A] + assert got["빠진로직"] == ["GF999999"] + one = got["로직"][1] + assert one["이름"] and one["결과단위"] + assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"]) + # 옛 선택 입력 목록과 같은 몸 + assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"] + + +def test_빈_키는_빈_목록(client: TestClient) -> None: + got = client.get("/api/m02/logic-lib/inputs").json() + assert got == {"로직": [], "빠진로직": []} + + +SCRIPT = r""" +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const file = path.join(process.argv[1], "M02_MasterTemplete", "M02_Labor_Values_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; +const height = { 이름: "높이", 단위: "m", 범위: [0.5, 5], 자동값: 1.2 }; +const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] }; +const size = { 이름: "규격", 고르기: [25, 40] }; +const area = { 이름: "지역" }; +const inputs = [height, kind, size, area]; +console.log(JSON.stringify({ + stored: [ + m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"), + m.storedOf(size, "40"), m.storedOf(area, "서울"), + ], + outside: [ + m.isOutside(height, "9"), m.isOutside(height, "0.5"), m.isOutside(height, ""), + m.isOutside(height, "abc"), m.isOutside(area, "9"), + ], + known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }), + line: m.valueLine(inputs, { 높이: 2, 규격: 40 }), + lineEmpty: m.valueLine(inputs, {}), +})); +""" + + +def test_값_셈() -> None: + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + out = subprocess.run( + [node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert out.returncode == 0, out.stderr + got = json.loads(out.stdout) + assert got["stored"] == [None, 2.5, "막돌", 40, "서울"] + assert got["outside"] == [True, False, False, True, False] + assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]} + assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음 + assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님 From 80a635d3b9c62017266ec9b6a78c06444089aaa2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:30:41 +0900 Subject: [PATCH 14/24] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=EC=84=A0=20=C2=B7=20=ED=95=98=EC=9C=84=20=E3=80=8C?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=A0=84=EC=B2=B4=20=ED=95=AD=EB=AA=A9=E3=80=8D=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20onAll=20=EC=86=90=EC=9E=A1=EC=9D=B4(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 층 단추 · 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출(재료산출 · 자재 조합은 sub6 몫으로 그대로) - 하위마다 맨 위 「전체 항목」 — 누르면 SideOptions.onAll(kind)(화면은 39-5) · 양식 · 다른 보기가 열리면 고름 지움 - 이름 「일위대가」(하위 제목 · 대시보드 카드 글) · 해시 open=일위대가 = 일위대가 하위 펼침 + 전체 항목 - 일위대가 열 줄은 SU 번호(39-1a) 뒤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_UI_Side.ts | 83 +++++++++++++++---- .../M02_MasterTemplete_UI_Style.css | 7 ++ resources/tester/test_m02_labor_tree.py | 16 ++++ .../tester/test_m02_structure_options.py | 16 ++-- resources/tester/test_m02_structure_panel.py | 21 +++-- ui_template/ui_template_locale_m2.ts | 4 +- 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f2133c829..691426e65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 · - * 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`. + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 · + * 면적산출 · 재료산출 · 자재 조합 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기). + * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`. @@ -46,13 +47,22 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */ -type ColumnKind = "structure" | "area" | "material"; -const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [ - { kind: "structure", key: "도번", label: () => L("M02_KindStructure") }, - { kind: "area", key: "면적번", label: () => L("M02_KindArea") }, - { kind: "material", key: "재료번", label: () => L("M02_KindMaterial") }, +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */ +type ColumnKind = "structure" | "labor" | "area" | "material"; +const COLUMN_KINDS: { + kind: ColumnKind; + key: keyof StructureNumbers | null; + label: () => string; + all: boolean; +}[] = [ + { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, + { kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true }, + { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, + { kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false }, ]; +/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */ +export type AllKind = "structure" | "labor" | "area"; /** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */ const STUB_DRAWING = { format: 6, @@ -75,6 +85,8 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; + /** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */ + onAll?: (kind: AllKind) => void; } export interface SideHandle { @@ -102,6 +114,8 @@ export function buildSide(opt: SideOptions): SideHandle { let items: TemplateInfo[] = []; let columns: StructureColumn[] = []; let numbers: Record = {}; + /** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */ + let allPicked: AllKind | null = null; let seq = 0; const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => @@ -133,7 +147,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), paint()), + onOpen: () => (options.clear(), (allPicked = null), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -145,7 +159,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), paint()), + onOpen: () => (matset.clear(), (allPicked = null), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -154,12 +168,14 @@ export function buildSide(opt: SideOptions): SideHandle { }); return sec; }; - // 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · + const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])); + // 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · // 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6) const summary = section( L("M02_KindTable"), el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }), - ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), + el("hr", { className: "m02-side__rule" }), + ...columnSections, matsetSection, ); summary.classList.remove("is-collapsed"); @@ -219,9 +235,25 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 구조물 도면 · 면적산출 · 재료산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ - function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void { + /** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */ + function allRow(kind: AllKind): HTMLElement { + const b = el("button", { + className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`, + text: L("M02_ItemsAll"), + attrs: { type: "button", "data-all": kind }, + }); + b.addEventListener("click", () => void selectAll(kind)); + return b; + } + + /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ + function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { + const { key } = k; + const head = k.all ? [allRow(k.kind as AllKind)] : []; + if (key === null) return body.replaceChildren(...head); + const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤 body.replaceChildren( + ...head, ...(columns.length ? columns.map((col) => { const num = numbers[col.id]?.[key] || L("M02_StructureNone"); @@ -236,8 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle { : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } - const paintColumns = (): void => - COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); + const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k)); /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { @@ -248,6 +279,7 @@ export function buildSide(opt: SideOptions): SideHandle { /** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음 * (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */ function paint(): void { + if (opt.getOpen()) allPicked = null; paintList(); paintColumns(); paintButtons(); @@ -255,13 +287,22 @@ export function buildSide(opt: SideOptions): SideHandle { if (opt.getOpen()) options.clear(); } + async function selectAll(kind: AllKind): Promise { + if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; + matset.clear(); + options.clear(); + allPicked = kind; + opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 + paint(); + } + // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) async function select(info: TemplateInfo): Promise { if (!isOpen(info) && !(await opt.confirmLeave())) return; opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { + async function selectColumn(col: StructureColumn, kind: Kind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); @@ -377,7 +418,13 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침 + // 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」 + if (target === "일위대가") { + columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove( + "is-collapsed", + ); + return void selectAll("labor"); + } extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index c4c51d1ca..aa14b148e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -15,6 +15,13 @@ margin-left: var(--spacing-8); } +/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */ +.m02-side__rule { + margin: var(--spacing-8) 0; + border: 0; + border-top: 1px solid var(--color-border); +} + .m02-side__row { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 755dac40b..0776b560f 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -29,6 +29,22 @@ def test_나무_부품에_일위대가_전용_칸_없음(): assert gone not in panel, gone +def test_하위_차례_전체_항목_이름(): + side = _text("M02_MasterTemplete_UI_Side.ts") + kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] + order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] + assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 + assert 'label: () => L("M02_KindLabor"), all: true' in kinds + assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 + assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side + assert 'return void selectAll("labor");' in side # 해시 open=일위대가 + locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") + assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale + assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale + css = _text("M02_MasterTemplete_UI_Style.css") + assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css + + def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 6df8d180b..5d1a98e5e 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,17 +120,11 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식 - order = [ - "...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]" - " : []),", - " summary,", - " ...(extras ? [extras.옵션, extras.도면] : []),", - ] - assert "\n".join(order) in side - # 자재 조합을 열면 이 줄 · 일위대가 고름 지움 / 이 줄을 열면 자재 조합 · 일위대가 고름 지움 - assert "onOpen: () => (options.clear(), labor?.clear(), paint())" in side - assert "onOpen: () => (matset.clear(), labor?.clear(), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 + assert "children: [summary, ...(extras ? [extras.옵션, extras.도면" in side + # 자재 조합을 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · 「전체 항목」 고름 지움 + assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (matset.clear(), (allPicked = null), paint())" in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index bb9b3341f..b10a492d8 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -154,9 +154,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만 assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만 assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만 - # 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) + # 구조물 도면 · 일위대가 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) · + # 층 단추 · 나무와 하위 사이 수평선(39-2) summary = side.split("const summary = section(")[1].split(");")[0] - assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary + assert ( + summary.index("opt.tree") + < summary.index("m02-side__rule") + < summary.index("...columnSections") + ) + assert "matsetSection" in summary + assert "const columnSections = COLUMN_KINDS.map(" in side assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a) ROOT / "ui_template" / "ui_template_locale_m2.ts" ) @@ -184,7 +191,11 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림.""" side = _text(SIDE) assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄 - assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side) + assert re.search( + r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*" + r"paintList\(\);\s*paintColumns\(\);", + side, + ) # 양식이 열리면 「전체 항목」 고름도 지움(39-2) for fn in ("async function select(", "async function selectColumn("): body = side.split(fn)[1].split("\n }\n")[0] assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음 @@ -192,8 +203,8 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): # 자재 조합 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 자재 조합 고름 지움 assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN)) assert "if (opt.getOpen()) matset.clear();" in side.split("function paint(): void {")[1] - # 편집 칸이 열 때마다 좌측 `paint`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리) - assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts")) + # 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움) + assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts") def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘(): diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index eff4717b9..1058adaa6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -4,7 +4,7 @@ * ========================================================================== */ export const ui_locales_m2 = { - B01_Dashboard_CardUnitCombo: ["일위대가 조합", "Unit cost combos"], + B01_Dashboard_CardUnitCombo: ["일위대가", "Unit costs"], B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"], B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"], B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"], @@ -172,4 +172,6 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], + M02_KindLabor: ["일위대가", "Unit costs"], + M02_ItemsAll: ["전체 항목", "All items"], } as const; From eb132f13d0d86d8add08b58e0c90b9228a2d2567 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:31:11 +0900 Subject: [PATCH 15/24] =?UTF-8?q?feat(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20ItemPick=20?= =?UTF-8?q?=E2=80=94=20=ED=83=AD(=EB=A7=88=EC=8A=A4=ED=84=B0=20=C2=B7=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=C2=B7=20=EA=B0=9C=EC=9D=B8=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EB=9E=8C=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=EB=AC=B8=EC=84=9C)=20+=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=B9=B8=20=EC=95=88=20=C2=B7=20?= =?UTF-8?q?=EC=B0=BD=20=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H3 — 탭 목록 · 기본 탭 = readableSources(39-6 E5) · 다른 사람 = 사람 고르기 하나 · 실무문서 = 마스터 일위대가 조합(일위대가 고를 때). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.css | 15 ++ .../M02_MasterTemplete_ItemPick.ts | 178 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemPick.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css new file mode 100644 index 000000000..70f57c665 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -0,0 +1,15 @@ +/* 가져올 곳 고르기(PLAN 39-5) — 탭 줄 + 목록 · 창이면 목록이 넓게 */ +.m02-itempick { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick .m01-master__grid-wrap { + max-height: min(60vh, calc(100vh - 300px)); +} + +.ui-modal.m02-itempick__dialog { + width: min(960px, 94vw); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts new file mode 100644 index 000000000..dc1fbc122 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemPick.ts + * 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`). + * 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀. + * 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈 + * (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로. + * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). + * ========================================================================== */ + +import { createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + buildItemList, + ITEM_COLS, + loadItems, + type Col, + type ItemRow, + type ListKind, +} from "./M02_MasterTemplete_ItemList"; +import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; +import { readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; +import "./M02_MasterTemplete_TreePanel.css"; +import "./M02_MasterTemplete_ItemPick.css"; + +const TEXT = { + close: "닫기", + person: "사람", + noPeople: "다른 사람이 없음", + failed: "목록을 못 받음 — {value}", +}; + +/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ +export interface From { + layer: Layer; + owner: string | null; + label: string; + practice: boolean; +} + +export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary }; + +/** 고른 것 이름 — 확인 글에 씀 */ +export const pickedName = (p: Picked): string => + "row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`; + +const PRACTICE_COLS: Col[] = [ + { head: "키", get: (c) => c.키 }, + { head: "이름", get: (c) => c.이름 }, + { head: "구분", get: (c) => c.구분 ?? "" }, + { head: "상세구분", get: (c) => c.상세구분 ?? "" }, + { head: "단위", get: (c) => c.단위 ?? "" }, + { head: "담은 로직 수", get: (c) => String(c.count) }, +]; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export interface PickerOptions { + user: SessionUser | null; + /** 볼 회사(`viewCompany`) */ + company: string | null; + /** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */ + kind: ListKind; + /** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */ + skip?: (from: From, row: ItemRow) => boolean; + onPick: (picked: Picked) => void; +} + +/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */ +export function buildPicker(opt: PickerOptions): { root: HTMLElement } { + const { tabs, first } = readableSources(opt.user, opt.company, { + practice: opt.kind === "labor", + }); + const list = buildItemList(); + const people = createSelectField({ options: [], compact: true, label: TEXT.person }); + people.root.hidden = true; + let owners: { owner: string; name: string }[] | null = null; + let seq = 0; + + const buttons = tabs.map((tab) => { + const b = el("button", { + className: "m02-layers__btn", + text: tab.label, + attrs: { type: "button", "data-source": tab.id }, + }); + b.addEventListener("click", () => void show(tab)); + return b; + }); + + async function show(tab: Source): Promise { + const mine = ++seq; + buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id)); + people.root.hidden = tab.id !== "people"; + list.message(); + try { + if (tab.id === "practice") { + const combos = await fetchCombos(); + if (mine !== seq) return; + const from = { layer: tab.layer, owner: null, label: tab.label, practice: true }; + return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo })); + } + let owner = tab.owner; + let label = tab.label; + if (tab.id === "people") { + owners ??= await fetchOwners("personal", opt.company); + if (mine !== seq) return; + if (!owners.length) return list.message(TEXT.noPeople); + if (!people.select.value) { + people.setOptions( + owners.map((o) => ({ value: o.owner, text: o.name })), + owners[0].owner, + ); + } + owner = people.select.value; + label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`; + } + const rows = await loadItems(tab.layer, owner); + if (mine !== seq) return; + const from = { layer: tab.layer, owner, label, practice: false }; + list.show( + rows.filter((row) => !opt.skip?.(from, row)), + ITEM_COLS[opt.kind], + (row) => opt.onPick({ from, row }), + ); + } catch (error) { + if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); + } + } + + people.select.addEventListener("change", () => { + const tab = tabs.find((t) => t.id === "people"); + if (tab) void show(tab); + }); + const start = tabs.find((t) => t.id === first) ?? tabs[0]; + if (start) void show(start); + return { + root: el("div", { + className: "m02-itempick", + children: [ + el("div", { className: "m02-layers__row", children: [...buttons, people.root] }), + list.root, + ], + }), + }; +} + +/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ +export function pickFrom( + opt: Omit & { title: string; ask?: (picked: Picked) => string }, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: Picked | null): void => { + if (!done) resolve(value); + done = true; + }; + openModal({ + title: opt.title, + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-itempick__dialog", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const picker = buildPicker({ + ...opt, + onPick: (picked) => + void (async () => { + if (opt.ask && !(await showConfirmDialog(opt.ask(picked)))) return; + finish(picked); + close(); + })(), + }); + body.append(picker.root); + }, + }); + }); +} From af92f0553e7c58009e3b449e88745dc64763791c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:34:10 +0900 Subject: [PATCH 16/24] =?UTF-8?q?feat(M02):=20=E3=80=8C=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=E3=80=8D=20=ED=99=94=EB=A9=B4=20=E2=80=94?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=8F=84=EB=A9=B4=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=EC=82=B0=EC=B6=9C=20=ED=95=98=EC=9C=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=83=81=EC=84=B8=20=EC=B9=B8=EC=97=90=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H2 — 좌측 onAll(39-2) 이음 · 제목 「하위 · 전체 항목」 · 목록 = ItemList(M01 꼴). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 17 ++++++++++++ .../M02_MasterTemplete_UI_Page.ts | 26 +++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 363c48e5c..4d015ca6b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -35,6 +35,7 @@ const TEXT = { none: "항목이 없음", noMatch: "맞는 항목이 없음", loading: "불러오는 중", + failed: "목록을 못 받음", }; const SIZE = 30; @@ -111,6 +112,22 @@ export function pageOf( export const loadItems = async (layer: Layer, owner: string | null): Promise => (await fetchTree(layer, owner)).items as ItemRow[]; +/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */ +export function buildAllItems( + kind: ListKind, + layer: Layer, + owner: string | null, + onOpen: (row: ItemRow) => void, +): HTMLElement { + const list = buildItemList(); + loadItems(layer, owner).then( + (rows) => list.show(rows, ITEM_COLS[kind], onOpen), + (error: unknown) => + list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`), + ); + return list.root; +} + export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index a19bc482a..9b792a9ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, @@ -41,9 +41,17 @@ import { seesOthers, } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; -import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; +import { buildAllItems } from "./M02_MasterTemplete_ItemList"; import "./M02_MasterTemplete_UI_Style.css"; +/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ +const ALL_TITLE = { + structure: "M02_KindStructure", + labor: "M02_KindLabor", + area: "M02_KindArea", +} as const satisfies Record; + export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); const entry = entryFromHash(); @@ -155,6 +163,20 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], + // 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`) + onAll: (kind) => + void main.showView( + `${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`, + buildAllItems(kind, layer, owner, (row) => { + void main.open({ + layer, + projectId: null, + kind: kind as Kind, + name: row.열, + label: row.이름, + }); + }), + ), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); From 516719d50d6a5b7cca9b83acfd8b46865f86aa5f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:36:44 +0900 Subject: [PATCH 17/24] =?UTF-8?q?feat(M02):=20=EC=B8=B5=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=E3=80=8C=ED=9A=8C=EC=82=AC=20=EB=A7=A1=EC=9D=8C?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=A7=A1=EC=9D=80=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=20=C2=B7=20=EA=B7=B8=20=ED=9A=8C=EC=82=AC=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=20=EC=B8=B5=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EB=82=A8=EC=9D=98?= =?UTF-8?q?=20=EC=B8=B5=20=EC=B2=AB=20=EC=93=B0=EA=B8=B0=20=EC=86=8C?= =?UTF-8?q?=EC=86=8D=20=ED=99=95=EC=9D=B8=20=EB=92=A4=20=EB=BC=88=EB=8C=80?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EA=B8=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EB=A7=8C=20=C2=B7=20=EC=82=AC=EB=9E=8C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=84=EB=B6=80=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99(39-6=20E1~E4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- M02_MasterTemplete/M02_Layer_Access.py | 39 +++++----- .../M02_MasterTemplete_Router_Combos.py | 14 +++- .../M02_MasterTemplete_Router_Items.py | 55 +++++++++++++- .../M02_MasterTemplete_Router_Layers.py | 39 +++++++--- resources/tester/test_m02_combo_layers.py | 20 ++++++ resources/tester/test_m02_layer_access.py | 71 ++++++++++++++++--- resources/tester/test_m02_template_layers.py | 34 +++++++-- 7 files changed, 227 insertions(+), 45 deletions(-) diff --git a/M02_MasterTemplete/M02_Layer_Access.py b/M02_MasterTemplete/M02_Layer_Access.py index fe1b71430..2a0cc5dc8 100644 --- a/M02_MasterTemplete/M02_Layer_Access.py +++ b/M02_MasterTemplete/M02_Layer_Access.py @@ -1,13 +1,12 @@ """M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀 (PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`. +「회사 C 를 맡음」(`manages` · 39-6) = SYSTEM_ADMIN · 또는 C 의 회사 관리자(ADMIN · 마스터). system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN - company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) · - 남의 회사 = SYSTEM_ADMIN 읽기만 - personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 · - 다른 회사 = SYSTEM_ADMIN 읽기만 -owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) · -읽기로 남의 층에 뼈대를 안 만듦. + company {C} 읽기 = C 사람 · SYSTEM_ADMIN · 수정 = C 를 맡은 사람 + personal {C}/{U} 본인 = 읽기 · 수정 · 남 = C 를 맡은 사람만 읽기 · 수정(일반 = 403) +owner 글 없음 = 자기 층 · 남의 층 읽기는 그 층 폴더가 있을 때만(없으면 404) · +읽기로 남의 층에 뼈대를 안 만듦(쓰기는 부르는 쪽이 소속을 본 뒤 `folder(…, create=True)`). """ from __future__ import annotations @@ -33,6 +32,14 @@ def is_company_admin(session: dict[str, Any]) -> bool: return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) +def manages(session: dict[str, Any], company: Any) -> bool: + """회사 `company` 를 맡음 — 그 회사 층 · 그 회사 모든 개인 층을 보고 고침.""" + if is_system_admin(session): + return True + own = session.get("company_id") + return own is not None and str(own) == str(company) and is_company_admin(session) + + @dataclass(frozen=True) class Grant: """층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨).""" @@ -63,15 +70,14 @@ def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Gran raise HTTPException(status_code=409, detail=NO_COMPANY) if not _SHAPE[layer].match(who): raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」") + company = who.split("/")[0] if who == own: - return Grant(layer, who, layer == "personal" or is_company_admin(session), True) - same_company = who.split("/")[0] == str(session.get("company_id")) - colleague = layer == "personal" and same_company and is_company_admin(session) - if not (is_system_admin(session) or colleague): + return Grant(layer, who, layer == "personal" or manages(session, company), True) + if not manages(session, company): raise HTTPException( status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음" ) - return Grant(layer, who, False, False) + return Grant(layer, who, True, False) def require( @@ -82,16 +88,15 @@ def require( if write and not got.write: if layer == "system": detail = "마스터 템플릿은 시스템 관리자만 고침." - elif got.mine: - detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." else: - detail = "남의 층은 읽기만." + detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." raise HTTPException(status_code=403, detail=detail) return got -def folder(got: Grant) -> Path: - """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦).""" +def folder(got: Grant, create: bool = False) -> Path: + """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함 · `create` = 쓰기(부르는 쪽이 소속을 + 본 뒤 뼈대를 만듦).""" if got.owner is None: raise HTTPException(status_code=409, detail=NO_COMPANY) try: @@ -102,6 +107,6 @@ def folder(got: Grant) -> Path: path = layers.personal_dir(company, user) except ValueError as error: raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error - if not got.mine and not path.is_dir(): + if not (got.mine or create) and not path.is_dir(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return path diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..8495ad84c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -7,13 +7,14 @@ 저장소 = `M02_MasterTemplete_Combo_Store`. 담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. +39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). """ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from common_util.common_util_auth import verify_session @@ -23,7 +24,16 @@ from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"]) + +def _door(request: Request, session: Session = Depends(verify_session)) -> None: + """일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + if "/logic-lib/" not in request.url.path and not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.") + + +router = APIRouter( + prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)] +) class ComboBody(BaseModel): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 4321aee0f..0a40524d3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -10,13 +10,17 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) from __future__ import annotations +import logging from pathlib import Path from typing import Any, Literal +import anyio.from_thread from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel +from common_util.common_util_audit import record_audit from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool from M02_MasterTemplete import M02_Item_Paste as paste_store from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Branch as branch @@ -32,6 +36,7 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import ( UseComboBody, ) +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) Layer = Literal["system", "company", "personal"] @@ -74,14 +79,60 @@ class CopyItemBody(BaseModel): key: str +async def _member(owner: str) -> bool: + """층 주인이 있나 — 회사 `{C}` = 그 회사 · 개인 `{C}/{U}` = U 가 C 소속.""" + company, _, user = owner.partition("/") + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + if user: + await cursor.execute( + "SELECT 1 FROM users WHERE id = %s AND company_id = %s AND deleted_at IS NULL", + (int(user), int(company)), + ) + else: + await cursor.execute( + "SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (int(company),) + ) + return await cursor.fetchone() is not None + + +async def _note(session: Session, got: access.Grant) -> None: + """남의 층 쓰기 한 줄(39-6 · 시스템 로그) — 기록이 못 돼도 쓰기는 막지 않음.""" + try: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await record_audit( + cursor, + actor_id=int(session["user_id"]), + action="M02_OTHER_LAYER_WRITE", + resource_type=f"m02_{got.layer}", + resource_ref=got.owner, + ) + await connection.commit() + except Exception: + logger.exception("M02 남의 층 쓰기 기록 실패: %s %s", got.layer, got.owner) + + +async def others_write(session: Session, got: access.Grant, folder: Path) -> None: + """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 쓸 때마다 시스템 로그.""" + if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): + if not await _member(str(got.owner)): + raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님") + items_store.ensure(folder) + await _note(session, got) + + def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: - """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦.""" + """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦 · + 남의 층 쓰기(맡은 회사 · 39-6)는 `others_write`.""" got = access.require(session, layer, owner, write=write) if layer == "system": return store.FOLDER - folder = access.folder(got) + folder = access.folder(got, create=write) if got.mine: items_store.ensure(folder) + elif write: + anyio.from_thread.run(others_write, session, got, folder) elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return folder diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index a9d65b1ab..f3097f50b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers @@ -164,8 +165,10 @@ def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int: async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None: + """남의 개인 양식 = 그 회사를 맡은 사람만(39-6 · 층 길과 한 규칙) · 그 회사 사람이어야 함.""" if user_id == session.get("user_id"): return + access.grant(session, "personal", f"{company_id}/{user_id}") if await _user_company(user_id) != company_id: raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.") @@ -192,7 +195,12 @@ async def _layer_dir( who = session["user_id"] if user_id is None else user_id owner = str(company_id) if layer == "company" else f"{company_id}/{who}" got = access.require(session, layer, owner, write=write) - return _ensured(access.folder(got)) if got.mine else access.folder(got) + folder = access.folder(got, create=write) + if got.mine: + return _ensured(folder) + if write: # 남의 층 쓰기(맡은 회사 · 39-6) — 구조물 길과 한 벌 + await items_router.others_write(session, got, folder) + return folder def _ensured(folder: Path) -> Path: @@ -214,9 +222,9 @@ async def list_owners( company: int | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> list[dict[str, Any]]: - """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌). - 회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 · - 회사 관리자는 자기 회사만).""" + """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]`. + 회사 = SYSTEM_ADMIN 만 · 층 자리가 있는 것만(자기 회사는 늘) · + 개인 = 그 회사를 맡은 사람(39-6)만 · 회사 사람 전부(나 맨 위 · 집계표 없으면 `비어있음`).""" if layer == "company": if not access.is_system_admin(session): raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.") @@ -225,12 +233,16 @@ async def list_owners( target = session.get("company_id") if company is None else company if target is None: raise HTTPException(status_code=409, detail=access.NO_COMPANY) - mine = str(target) == str(session.get("company_id")) - if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))): + if not access.manages(session, target): raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.") me = session.get("user_id") users = await _company_users(int(target)) - rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me] + users.sort(key=lambda u: u["user_id"] != me) # 나 맨 위 + return [ + {"owner": owner, "name": u["name"], "비어있음": _empty(owner)} + for u in users + for owner in [f"{target}/{u['user_id']}"] + ] found = [] for owner, name in rows: got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer)) @@ -242,6 +254,13 @@ async def list_owners( return found +def _empty(owner: str) -> bool: + """개인 층에 집계표가 없음 — 사람 나무 「비어 있음」(폴더를 안 만듦).""" + company, user = owner.split("/") + table = layers.personal_dir(company, user) / "table" / f"{store.STRUCTURE_TABLE}.json" + return not table.is_file() + + @router.get("/layers/{layer}/templates") async def list_layer( layer: Layer, @@ -519,7 +538,11 @@ async def list_sources( ) return rows - users = await _company_users(company_id) + users = [ # 남의 개인 = 그 회사를 맡은 사람만(39-6) + u + for u in await _company_users(company_id) + if u["user_id"] == session.get("user_id") or access.manages(session, company_id) + ] projects = await _company_projects(company_id) return { "system": await asyncio.to_thread(layers.list_templates, layers.system_dir()), diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 7796b80d3..9bcbfb596 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -54,9 +54,29 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: app.include_router(items_router.router) app.include_router(combos_router.router) app.dependency_overrides[verify_session] = lambda: session + # 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙 + app.dependency_overrides[combos_router._door] = lambda: None return {"client": TestClient(app), "session": session, "storage": storage} +def test_조합_길은_시스템_관리자만(world): + """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + world["client"].app.dependency_overrides.pop(combos_router._door) + for role in ("USER", "ADMIN"): + client = _as(world, role) + assert client.get(URL.format("system")).status_code == 403 + assert client.get("/api/m02/combos/all").status_code == 403 + assert client.get("/api/m02/combos/key/UA000001").status_code == 403 + assert _new(client, "personal", _combo("내 것"), 403) is not None + assert client.get("/api/m02/logic-lib/logics").status_code == 200 + world["session"]["is_master"] = True + assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도 + world["session"]["is_master"] = False + admin = _as(world, "SYSTEM_ADMIN") + assert admin.get(URL.format("system")).status_code == 200 + assert admin.get(URL.format("company")).status_code == 200 + + def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: world["session"].update(role=role, user_id=user) return world["client"] diff --git a/resources/tester/test_m02_layer_access.py b/resources/tester/test_m02_layer_access.py index 42e5189b3..f89e29ea9 100644 --- a/resources/tester/test_m02_layer_access.py +++ b/resources/tester/test_m02_layer_access.py @@ -16,6 +16,7 @@ from fastapi import HTTPException from config import config_system from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers @@ -55,16 +56,16 @@ def test_권한_표(): } assert got == { "일반": ["읽기", "읽기", "403", "수정", "403", "403"], - "회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"], - "마스터": ["읽기", "수정", "403", "수정", "읽기", "403"], - "시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"], + "회사관리": ["읽기", "수정", "403", "수정", "수정", "403"], # 맡은 회사(39-6) + "마스터": ["읽기", "수정", "403", "수정", "수정", "403"], + "시스템": ["수정", "수정", "수정", "수정", "수정", "수정"], # 어느 회사나 맡음 } # 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409 assert access.grant(USER, "personal", "7/42").write is True assert _answer(SYSADMIN, "personal", "9") == "400" assert _answer({**USER, "company_id": None}, "company", None) == "409" with pytest.raises(HTTPException) as caught: - access.require(ADMIN, "personal", "7/43", write=True) + access.require(USER, "company", None, write=True) assert caught.value.status_code == 403 @@ -89,8 +90,19 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: monkeypatch.setattr(layers_router, "_company_users", company_users) monkeypatch.setattr(layers_router, "_companies", companies) + notes: list[tuple[int, str, str | None]] = [] + + async def member(owner: str) -> bool: + company, _, user = owner.partition("/") + return users.get(int(user)) == int(company) if user else int(company) in (7, 8, 9) + + async def note(session: dict[str, Any], got: Any) -> None: + notes.append((session["user_id"], got.layer, got.owner)) + + monkeypatch.setattr(items_router, "_member", member) + monkeypatch.setattr(items_router, "_note", note) session: dict[str, Any] = dict(USER) - return {"client": _app(session), "session": session, "storage": storage} + return {"client": _app(session), "session": session, "storage": storage, "notes": notes} def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): @@ -111,14 +123,44 @@ def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): assert client.get(tree).status_code == 200 assert client.get(table).status_code == 200 # 양식 길도 같은 규칙 body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]} - assert client.post(tree, json=body).status_code == 403 # 읽기만 + assert client.post(tree, json=body).status_code == 200 # 맡은 회사 = 고침(39-6) + assert world["notes"] == [(42, "personal", "7/43")] # 남의 층 쓰기 = 시스템 로그 한 줄 assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200 session.update(USER) assert client.get(tree).status_code == 403 assert client.get(table).status_code == 403 + assert ( + client.post(tree, json={**body, "이름": "몰래"}).status_code == 403 + ) # 일반 = 남 개인 막힘 session.update(SYSADMIN) assert client.get(tree).status_code == 200 - assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만 + body = {"op": "add-folder", "경로": [], "이름": "AS", "판": client.get(tree).json()["판"]} + assert client.post(tree, json=body).status_code == 200 # 시스템 관리자 = 어느 회사나 + + +def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any]): + """39-6 — 폴더 없는 남의 층에 쓰면 주인이 있을 때만 뼈대 · 없는 사람 · 남의 회사 사람은 403 · + 읽기는 여전히 뼈대를 안 만듦 · 일위대가 키는 그 층 머리(UC · UP).""" + client, session, storage = world["client"], world["session"], world["storage"] + session.update(ADMIN) + add = {"op": "add-folder", "경로": [], "이름": "새", "판": ""} + assert client.get("/api/m02/layers/personal/tree?owner=7/44").status_code == 404 + assert not (storage / "7/44").exists() + assert client.post("/api/m02/layers/personal/tree?owner=7/99", json=add).status_code == 403 + assert not (storage / "7/99").exists() # 없는 사람 폴더를 안 만듦 + assert client.post("/api/m02/layers/personal/tree?owner=9/90", json=add).status_code == 403 + first = client.post("/api/m02/layers/personal/tree?owner=7/44", json=add) + assert first.status_code == 409 # 뼈대는 섰고 판이 달라 409 — 다시 읽고 고침 + assert (storage / "7/44/templates/table/구조물집계표.json").is_file() + add["판"] = client.get("/api/m02/layers/personal/tree?owner=7/44").json()["판"] + assert client.post("/api/m02/layers/personal/tree?owner=7/44", json=add).status_code == 200 + session.update(SYSADMIN) + assert client.post("/api/m02/layers/company/tree?owner=5", json=add).status_code == 403 + assert not (storage / "5").exists() # 없는 회사 + add["판"] = "" + assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409 + assert (storage / "9/templates/table/구조물집계표.json").is_file() + assert [n[2] for n in world["notes"]] == ["7/44", "7/44", "9"] def test_남의_층_목록(world: dict[str, Any]): @@ -129,14 +171,21 @@ def test_남의_층_목록(world: dict[str, Any]): people = "/api/m02/layers/personal/owners" assert client.get(people).status_code == 403 # 일반 사용자 assert client.get("/api/m02/layers/company/owners").status_code == 403 + (storage / "7/43/templates/table").mkdir(parents=True) + (storage / "7/43/templates/table/구조물집계표.json").write_text("{}", encoding="utf-8") session.update(ADMIN) - assert client.get(people).json() == [ - {"owner": "7/43", "name": "u43"} - ] # 본인 · 44(자리 없음) 뺌 + assert client.get(people).json() == [ # 39-6 — 회사 사람 전부 · 나 맨 위 · 빈 사람 표시 + {"owner": "7/42", "name": "u42", "비어있음": True}, + {"owner": "7/43", "name": "u43", "비어있음": False}, + {"owner": "7/44", "name": "u44", "비어있음": True}, + ] + assert not (storage / "7/44").exists() # 목록이 폴더를 안 만듦 assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람 session.update(SYSADMIN) assert client.get("/api/m02/layers/company/owners").json() == [ {"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도 {"owner": "9", "name": "c9"}, ] - assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}] + assert client.get(f"{people}?company=9").json() == [ + {"owner": "9/90", "name": "u90", "비어있음": True} + ] diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 92c9f1bc6..4724c5ab2 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -13,6 +13,7 @@ from fastapi.testclient import TestClient from common_util import common_util_storage from common_util.common_util_auth import verify_session from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.helper_m02_master_sample import sample_table @@ -135,15 +136,23 @@ def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company" -def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None: +def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴( + world: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: client, session = world["client"], world["session"] + notes: list[str] = [] + + async def note(_session: dict[str, Any], got: Any) -> None: + notes.append(got.owner) + + monkeypatch.setattr(items_router, "_note", note) body = {"to": "personal", "종류": "table", "이름": "구조물집계표"} assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200 mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}" assert client.get(mine).status_code == 200 session.update(user_id=43) theirs = mine + "&user_id=42" - # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만 + # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만(맡은 회사 · 39-6) assert client.get(theirs).status_code == 403 session["role"] = "ADMIN" assert client.get(theirs).status_code == 200 @@ -152,8 +161,9 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42", json={"판": None, "문서": {"열": []}}, ) - assert put.status_code == 403 - assert not (world["storage"] / "7/42/templates/table/내표.json").exists() + assert put.status_code == 200 # 회사 관리자 = 같은 회사 개인 고침 · 시스템 로그 + assert (world["storage"] / "7/42/templates/table/내표.json").is_file() + assert notes == ["7/42"] session["role"] = "USER" # 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀 put = client.put( @@ -165,7 +175,12 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor got = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} ) - assert got.status_code == 200 + assert got.status_code == 403 # 일반 = 남 개인 못 가져옴(39-6) + session["role"] = "ADMIN" + got = client.post( + f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} + ) + assert got.status_code == 200 # 회사 관리자 = 같은 회사 개인 가져옴 assert client.get(mine.replace(P1, P_OTHER)).status_code == 403 far = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90} @@ -186,6 +201,15 @@ def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str sources = client.get(f"/api/m02/projects/{P1}/sources").json() assert [row["project_id"] for row in sources["project"]] == [P2] assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"} + for user in (42, 43): # 개인 층 둘 — 일반은 내 것만 · 회사 관리자는 회사 사람 전부(39-6) + layers.write_template(layers.personal_dir(7, user), "table", "표", {"열": []}) + assert [row["user_id"] for row in sources_of(client)] == [42] + world["session"]["role"] = "ADMIN" + assert [row["user_id"] for row in sources_of(client)] == [42, 43] + + +def sources_of(client: TestClient) -> list[dict[str, Any]]: + return client.get(f"/api/m02/projects/{P1}/sources").json()["personal"] def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None: From 0e28b5be4abfdc76b457347694a5ec3fa40a00e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:36:51 +0900 Subject: [PATCH 18/24] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=E3=80=8D=20?= =?UTF-8?q?=E2=80=94=20=EC=98=9B=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=83=81=EC=84=B8=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=ED=86=B5=EC=A7=B8=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 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 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Combo_Store.py | 15 ++ .../M02_MasterTemplete_Practice.ts | 224 ++++++++++++++++++ .../M02_MasterTemplete_Router_Combos.py | 9 + .../M02_MasterTemplete_UI_Side.ts | 22 +- .../M02_MasterTemplete_UI_Style.css | 40 ++++ resources/tester/test_m02_practice.py | 59 +++++ .../tester/test_m02_structure_options.py | 13 +- ui_template/ui_template_locale_m2.ts | 26 ++ 8 files changed, 400 insertions(+), 8 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Practice.ts create mode 100644 resources/tester/test_m02_practice.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 52a846549..409fb154c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -289,6 +289,21 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]: return {"키": key, "판": _save(doc, version), "combo": fixed} +def drop_practice(version: str) -> dict[str, Any]: + """실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만.""" + if heads()[-1] != MASTER: + raise store.StoreError(400, "실무문서는 마스터 층에만 있음") + doc, have = _load() + if have != (version or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + keys = [str(r.get("키")) for r in doc.get("줄") or []] + _refuse_used(keys) + if keys: + _m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have) + return {**listing(), "지운조합": keys} + + def delete(key: str, version: str) -> dict[str, Any]: """지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음).""" if heads()[-1] == MASTER: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Practice.ts b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts new file mode 100644 index 000000000..f3b72ceac --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * M02_MasterTemplete_Practice.ts + * 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) — + * 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) · + * [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인). + * 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { t as L } from "@ui/ui_template_locale"; +import { + fetchCombo, + fetchComboList, + type ComboList, + type ComboOne, +} from "./M02_MasterTemplete_Labor_Api"; + +export interface PracticeSectionOptions { + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface PracticeSection { + title: string; + body: HTMLElement; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + clear: () => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */ +function reasonOf(detail: unknown, status: number): string { + if (typeof detail === "string") return detail; + const d = detail as { errors?: string[]; stale?: unknown } | null; + if (d?.errors?.length) return d.errors.join(" · "); + return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`; +} + +/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */ +async function dropPractice(판: string): Promise { + const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, { + method: "DELETE", + credentials: "include", + }); + if (res.ok) return; + const data = (await res.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(reasonOf(data.detail, res.status)); +} + +const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-practice__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }); + +const cells = (values: string[]): HTMLElement[] => + values.map((v) => el("td", { text: v, attrs: { title: v } })); + +export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection { + const row = el("button", { + className: "m02-side__item ui-sidebar-row", + text: L("M02_PracticeRow"), + attrs: { type: "button", "data-practice-row": "" }, + }); + const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); + const view = el("div", { className: "m02-practice" }); + let opened = false; + let nav = 0; // 늦게 온 응답 버림 + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + row.addEventListener("click", () => void open()); + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + opt.show(L("M02_PracticeTitle"), view); + opt.onOpen(); + mark(true); + await drawList(); + } + + async function drawList(): Promise { + const my = ++nav; + const stop = showLoading(view); + let list: ComboList; + try { + list = await fetchComboList(); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList())); + return; + } finally { + stop(); + } + if (my !== nav) return; + const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" }); + drop.disabled = !list.줄.length; + drop.addEventListener("click", () => void dropAll(list, drop)); + const rows = list.줄.map((c) => { + const tr = el("tr", { + className: "m02-practice__row", + attrs: { "data-practice-key": c.키 }, + children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]), + }); + tr.addEventListener("click", () => void drawOne(c.키)); + return tr; + }); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [ + el("span", { + className: "m02-practice__note", + text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)), + }), + drop, + ], + }), + rows.length + ? table( + [ + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeGroup"), + L("M02_PracticeUnit"), + L("M02_PracticeLogics"), + ], + rows, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeEmpty") }), + ); + } + + /** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */ + async function drawOne(key: string): Promise { + const my = ++nav; + const stop = showLoading(view); + let one: ComboOne; + try { + one = await fetchCombo(key); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key))); + return; + } finally { + stop(); + } + if (my !== nav) return; + const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" }); + back.addEventListener("click", () => void drawList()); + const c = one.combo; + const info = [ + [L("M02_PracticeKey"), c.키], + [L("M02_PracticeGroup"), c.구분 ?? ""], + [L("M02_PracticeUnit"), c.단위 ?? ""], + ].map(([k, v]) => el("span", { className: "m02-practice__note", text: `${k} ${v}` })); + const lines = one.줄.map((x) => + el("tr", { + children: cells([ + String(x.차례), + x.로직, + x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""), + x.결과단위 ?? "", + x.메모 ?? "", + ]), + }), + ); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [back, el("strong", { text: c.이름 }), ...info], + }), + ...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []), + lines.length + ? table( + [ + L("M02_PracticeOrder"), + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeUnit"), + L("M02_PracticeMemo"), + ], + lines, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeNoLogic") }), + ); + } + + /** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */ + async function dropAll(list: ComboList, button: HTMLButtonElement): Promise { + const n = String(list.줄.length); + if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete")))) + return; + if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return; + button.disabled = true; + try { + await dropPractice(list.판); + showToast(L("M02_PracticeDropped").replace("{n}", n), "success"); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + await drawList(); + } + + return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..ad8fb8ad4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -157,6 +157,15 @@ def delete_combo( return _call(_root(session, layer, owner, write=True), combos.delete, key, 판) +@router.delete("/practice") +def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict: + """실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409.""" + if not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만") + return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) + + @router.post("/layers/{layer}/combos/tree") def post_tree( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 691426e65..7d10e1875 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -41,6 +41,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; import { buildOptionsSection } from "./M02_MasterTemplete_Options"; +import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ @@ -147,7 +148,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), (allPicked = null), paint()), + onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -159,7 +160,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), (allPicked = null), paint()), + onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -191,9 +192,22 @@ export function buildSide(opt: SideOptions): SideHandle { 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), } : null; + // 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 + const practice = + layer === "system" && opt.isAdmin + ? buildPracticeSection({ + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (matset.clear(), options.clear(), (allPicked = null), paint()), + }) + : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], + children: [ + summary, + ...(extras ? [extras.옵션, extras.도면] : []), + ...(practice ? [section(practice.title, practice.body)] : []), + ], }); attachCollapsible(listHost); @@ -285,12 +299,14 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); + if (opt.getOpen()) practice?.clear(); } async function selectAll(kind: AllKind): Promise { if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; matset.clear(); options.clear(); + practice?.clear(); allPicked = kind; opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 paint(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index aa14b148e..0fd456c41 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -22,6 +22,46 @@ border-top: 1px solid var(--color-border); } +/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */ +.m02-practice { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-practice__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-practice__bar > button:last-child:not(:first-child) { + margin-left: auto; +} + +.m02-practice__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-practice__memo { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + +.m02-practice__row { + cursor: pointer; +} + +.m02-practice__row:hover { + background: var(--color-mist-violet); +} + .m02-side__row { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_m02_practice.py b/resources/tester/test_m02_practice.py new file mode 100644 index 000000000..4466a0ac5 --- /dev/null +++ b/resources/tester/test_m02_practice.py @@ -0,0 +1,59 @@ +"""실무문서(PLAN 39-2) — 옛 마스터 일위대가 조합 표 통째 · 시스템 관리자만 통째 지우기. + +못박는 것 + - `DELETE /api/m02/practice?판=` = 시스템 관리자만(그 밖 403) · 판 다르면 409 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 지우면 줄 0 · 마스터 틀 검사 그대로. + - 화면(`Practice.ts`) = 마스터 층 · 시스템 관리자만 · 물음 두 번 · 상세 읽기만. +정본(마스터)은 읽기만 · 쓰기는 임시 사본. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from resources.tester.test_m02_combo_layers import _as, _list, master_copy, world # noqa: F401 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _drop(client, 판: str, want: int) -> dict: + res = client.delete("/api/m02/practice", params={"판": 판}) + assert res.status_code == want, res.text + return res.json() + + +def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_copy, monkeypatch): # noqa: F811 + admin = _as(world, "SYSTEM_ADMIN") + before = _list(admin, "system") + assert before["줄"] # 사본에 옛 조합이 있음 + for role in ("USER", "ADMIN"): + _drop(_as(world, role), before["판"], 403) + admin = _as(world, "SYSTEM_ADMIN") + _drop(admin, "옛판", 409) + used = before["줄"][0]["키"] + monkeypatch.setattr(combo_store, "combo_users", lambda k: ["항목 하나"] if k == used else []) + got = _drop(admin, before["판"], 409) + assert "항목 하나" in json.dumps(got, ensure_ascii=False) + assert len(_list(admin, "system")["줄"]) == len(before["줄"]) # 아무것도 안 지움 + monkeypatch.setattr(combo_store, "combo_users", lambda k: []) + got = _drop(admin, before["판"], 200) + assert got["줄"] == [] and len(got["지운조합"]) == len(before["줄"]) + doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) + assert doc["줄"] == [] and m01.cm.check_form(combos.FILE, doc) == [] + again = _list(admin, "system") + assert _drop(admin, again["판"], 200)["지운조합"] == [] # 빈 표 = 그대로 + + +def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만(): + side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8") + assert 'layer === "system" && opt.isAdmin' in side + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래 + view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8") + assert view.count("showConfirmDialog(") == 2 + assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 5d1a98e5e..c8f27428d 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,11 +120,14 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 - assert "children: [summary, ...(extras ? [extras.옵션, extras.도면" in side - # 자재 조합을 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · 「전체 항목」 고름 지움 - assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side - assert "onOpen: () => (matset.clear(), (allPicked = null), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서 + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert ( + order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice") + ) + # 자재 조합을 열면 이 줄 · 실무문서 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · … 지움 + assert "onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint())" in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 1058adaa6..737e9f4d2 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -174,4 +174,30 @@ export const ui_locales_m2 = { M02_TreeDone: ["나무를 고침", "Tree updated"], M02_KindLabor: ["일위대가", "Unit costs"], M02_ItemsAll: ["전체 항목", "All items"], + M02_PracticeTitle: ["실무문서", "Practice documents"], + M02_PracticeRow: ["실무문서 목록", "Practice document list"], + M02_PracticeCount: [ + "옛 마스터 일위대가 조합 {n}개 — 보기 · 복사 원본만(고칠 수 없음)", + "{n} old master unit-cost combos — view and copy source only", + ], + M02_PracticeEmpty: ["실무문서 없음", "No practice documents"], + M02_PracticeKey: ["키", "Key"], + M02_PracticeGroup: ["구분", "Group"], + M02_PracticeUnit: ["단위", "Unit"], + M02_PracticeLogics: ["로직 수", "Logics"], + M02_PracticeOrder: ["차례", "No."], + M02_PracticeMemo: ["메모", "Memo"], + M02_PracticeNoLogic: ["담은 로직 없음", "No logics"], + M02_PracticeBack: ["목록으로", "Back to list"], + M02_PracticeDrop: ["통째 지우기", "Delete all"], + M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"], + M02_PracticeDropAsk2: [ + "되돌릴 수 없음 — 정말 통째로 지울까요?", + "This cannot be undone — really delete all?", + ], + M02_PracticeDropped: ["실무문서 {n}개를 지움", "Deleted {n} practice documents"], + M02_PracticeStale: [ + "그 사이 실무문서가 바뀜 — 목록을 다시 받음", + "Practice documents changed — reloaded", + ], } as const; From 364d22ff6fac4a9f09e647f54bf618943bcdb9b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:37:46 +0900 Subject: [PATCH 19/24] =?UTF-8?q?feat(M02):=20=EC=83=88=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=91=90=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=E2=80=94=20=E3=80=8C=EC=83=88=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=E3=80=8D=20=E2=86=92=20=EC=83=81=EC=84=B8=20=EC=B9=B8=20[?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0]=20[=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=83=9D=EC=84=B1]=20=C2=B7=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=3D=20=ED=83=AD=20+=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=E2=86=92=20=EC=A7=80=EA=B8=88=20=EC=B8=B5=20=C2=B7?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B8=20=ED=8F=B4=EB=8D=94=20=C2=B7=20=EC=83=88?= =?UTF-8?q?=20=ED=82=A4=20=C2=B7=20=EC=83=88=20=EB=B2=88=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H4 — 나무 copyIn(붙여넣기 길 · 새 열 하나면 그 열 고름) · folder 손잡이 · ItemPick buildTwoWay(빈 카드도 씀). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.css | 20 +++++++ .../M02_MasterTemplete_ItemPick.ts | 52 ++++++++++++++++++- .../M02_MasterTemplete_TreePanel.ts | 42 +++++++++++++-- .../M02_MasterTemplete_UI_Page.ts | 29 ++++++++++- 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css index 70f57c665..a6713f59b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -13,3 +13,23 @@ .ui-modal.m02-itempick__dialog { width: min(960px, 94vw); } + +/* 두 단추 화면 — 새 구조물 조합 · 빈 카드 */ +.m02-itempick__two { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-itempick__buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index dc1fbc122..54cc67a51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -7,7 +7,7 @@ * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). * ========================================================================== */ -import { createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; @@ -30,6 +30,8 @@ const TEXT = { person: "사람", noPeople: "다른 사람이 없음", failed: "목록을 못 받음 — {value}", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ @@ -146,6 +148,54 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { }; } +export interface TwoWayOptions { + /** 위 안내 한 줄 */ + note: string; + /** [새로 만들기] — 지금 길(창 · 빈 문서) */ + onNew: () => void; + /** [복사 생성] 뒤 고를 목록 */ + picker: Omit; + /** 줄 누름 → 확인 글(취소면 그대로 고름) */ + ask: (picked: Picked) => string; + /** 확인 뒤 — 복사(실패 알림은 부르는 쪽) */ + onCopy: (picked: Picked) => Promise; +} + +/** [새로 만들기] [복사 생성] 두 단추(39-5 · 답 2) — 복사 생성을 누르면 같은 칸 아래 탭 + 목록 */ +export function buildTwoWay(opt: TwoWayOptions): HTMLElement { + const fresh = createButton({ label: TEXT.makeNew, variant: "filled" }); + const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); + const below = el("div", { className: "m02-itempick__below" }); + let busy = false; + fresh.addEventListener("click", opt.onNew); + copy.addEventListener("click", () => { + if (below.firstChild) return; + copy.classList.add("is-active"); + const picker = buildPicker({ + ...opt.picker, + onPick: (picked) => + void (async () => { + if (busy || !(await showConfirmDialog(opt.ask(picked)))) return; + busy = true; // 연달아 눌러도 한 번만 + try { + await opt.onCopy(picked); + } finally { + busy = false; + } + })(), + }); + below.append(picker.root); + }); + return el("div", { + className: "m02-itempick__two", + children: [ + el("p", { className: "m02-itempick__note", text: opt.note }), + el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }), + below, + ], + }); +} + /** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ export function pickFrom( opt: Omit & { title: string; ask?: (picked: Picked) => string }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..bb070c057 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -107,6 +107,14 @@ export interface TreePanelHandle { select: (colId: string | null) => void; /** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */ newItem: () => void; + /** 고른 폴더(하나) — 없으면 맨 위(`[]`) · 새 항목 · 복사 생성이 들어갈 자리 */ + folder: () => string[]; + /** 복사 생성(39-5) — 다른 곳(`from`) 항목 키 하나를 이 층 `dest` 에 새 키로 붙이고 그 열을 고름 */ + copyIn: ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ) => Promise; /** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */ editFromTable: (body: TreeEdit, colId: string | null) => Promise; } @@ -417,8 +425,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 const gone = note?.지운항목 ?? []; const made = note?.열; - const col = - typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null; + // 붙여넣기로 새 열이 하나면(복사 생성) 그 열 + const one = typeof made === "string" ? made : made?.length === 1 ? made[0] : null; + const col = one ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); activeCol = col; const parent = note?.경로 ? note.경로.slice(0, -1) : []; parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); @@ -643,13 +652,36 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { }); /** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */ - const newItem = (): void => { + const folder = (): string[] => { const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined; - void addItem(at?.kind === "folder" ? at.path : []); + return at?.kind === "folder" ? at.path : []; + }; + const newItem = (): void => void addItem(folder()); + + const copyIn = async ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ): Promise => { + const run = api.paste; + if (!run || !opt.canEdit || !(await ready())) return; + await apply(() => + run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest), + ); }; const editFromTable = async (body: TreeEdit, colId: string | null): Promise => { if (opt.canEdit && (await ready())) await edit(body, colId); }; - return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable }; + return { + root, + search: search.root, + add: tools, + refresh, + select, + newItem, + folder, + copyIn, + editFromTable, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..4f4d2a852 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -43,8 +43,13 @@ import { import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; import { buildAllItems } from "./M02_MasterTemplete_ItemList"; +import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; +const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안"; +const NEW_ASK = + "{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름"; + /** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ const ALL_TITLE = { structure: "M02_KindStructure", @@ -130,8 +135,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise tree.newItem()) } : {}), + // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) + ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {}), onColumn: (column) => { readOnlyBar?.setColumn(column); tree.select(column); @@ -145,6 +150,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop"); + main.showView( + L("M02_TreeNewItem"), + buildTwoWay({ + note: NEW_NOTE.replace("{value}", where()), + onNew: () => tree.newItem(), + picker: { user, company, kind: "item" }, + ask: (picked) => + NEW_ASK.replace("{name}", pickedName(picked)) + .replace("{from}", picked.from.label) + .replace("{to}", where()), + onCopy: async (picked) => { + if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder()); + }, + }), + ); + }; // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 const home: Selection = { layer, From a2d14719319b8672d998c0c61a2d93d65bedc7c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:39:38 +0900 Subject: [PATCH 20/24] =?UTF-8?q?feat(M02):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=A7=A1=EC=9D=80=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=E2=80=94=20accessFor=20=EC=A7=9D=20=C2=B7=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=3D=20=ED=9A=8C=EC=82=AC=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=20=EB=82=98=EB=AC=B4(=EB=82=98=20=EB=A7=A8=20?= =?UTF-8?q?=EC=9C=84=20=C2=B7=20=EB=B9=84=EC=96=B4=20=EC=9E=88=EC=9D=8C)?= =?UTF-8?q?=20=C2=B7=20=E3=80=8C=E3=80=87=E3=80=87=EB=A1=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=E2=80=A6=E3=80=8D=20=EB=B3=BC=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=C2=B7=20=EB=8C=80=EC=83=81=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD(39-6=20E5~E?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Api_Fetch.ts | 4 ++- .../M02_MasterTemplete_Layer.ts | 13 ++++++--- .../M02_MasterTemplete_TreePanel.ts | 23 ++++++++------- .../M02_MasterTemplete_TreePanel_CopyTo.ts | 26 +++++++++++++++++ .../M02_MasterTemplete_TreePanel_Roots.ts | 14 ++++++---- .../M02_MasterTemplete_UI_Page.ts | 9 ++++-- resources/tester/test_m02_layer_screen.py | 28 ++++++++++++++----- ui_template/ui_template_locale_m2.ts | 9 +++--- 8 files changed, 91 insertions(+), 35 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 0ef688765..6ac97e28e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -510,9 +510,11 @@ export const pasteTree = ( export interface LayerOwner { owner: string; name: string; + /** 개인 = 집계표가 아직 없음(사람 나무 「비어 있음」 · 39-6) */ + 비어있음?: boolean; } -/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */ +/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사를 맡은 사람 · 회사 사람 전부(나 맨 위) */ export const fetchOwners = ( layer: "company" | "personal", company: string | null = null, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 3b94c655c..c1860e584 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -79,7 +79,8 @@ export interface LayerAccess { needCompany: boolean; } -/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */ +/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`) = 그 회사를 맡았으면 고침(39-6) · + * 읽을 수 있나는 서버가 가름 */ export function accessFor( layer: ManageLayer, owner: string | null, @@ -87,10 +88,14 @@ export function accessFor( ): LayerAccess { if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false }; const own = ownOwner(layer, user); - if (owner && owner !== own) return { canWrite: false, needCompany: false }; + if (owner && owner !== own) { + return { canWrite: manages(owner.split("/")[0], user), needCompany: false }; + } if (!own) return { canWrite: false, needCompany: true }; - const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master; - return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; + return { + canWrite: layer === "personal" || manages(ownOwner("company", user), user), + needCompany: false, + }; } /** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..13c61f1db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -35,6 +35,7 @@ import { type TreeRef, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; +import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo"; import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { @@ -83,8 +84,8 @@ export interface TreePanelOptions { }; /** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */ pickCombo?: () => Promise<{ key: string; label: string } | null>; - /** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */ - copyTo?: { layer: Layer; owner: string | null; label: string }[]; + /** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */ + copyTo?: CopyTarget[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ @@ -533,18 +534,20 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); } - /** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */ - async function copyTo( - list: Target[], - to: { layer: Layer; owner: string | null; label: string }, - ): Promise { + /** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */ + async function copyTo(list: Target[], to: CopyTarget): Promise { if (!api.paste || !list.length) return; const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) }; try { - await api.paste(to.layer, to.owner, clip, []); - const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, ""); + const dest = await askCopyFolder(api.fetch, to, list.length); + if (!dest) return; + await api.paste(to.layer, to.owner, clip, dest); + const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, ""); showToast( - L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where), + L("M02_TreeCopiedTo") + .replace("{n}", String(list.length)) + .replace("{layer}", where) + .replace("{folder}", dest.join(" / ") || L("M02_TreeTop")), "success", ); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts new file mode 100644 index 000000000..38b03c5f6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -0,0 +1,26 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_CopyTo.ts + * 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null. + * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. + * ========================================================================== */ + +import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; + +export interface CopyTarget { + layer: Layer; + owner: string | null; + /** 메뉴 글 — `{n}` = 개수 */ + label: string; +} + +export async function askCopyFolder( + fetchTree: (layer: Layer, owner: string | null) => Promise, + to: CopyTarget, + n: number, +): Promise { + const view = await fetchTree(to.layer, to.owner).catch(() => null); + const folders = view ? listFolders(buildFolders(view)) : []; + return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index bd748d131..4c4ba094e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -4,8 +4,8 @@ * 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임. * 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝) * 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사) - * 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무 - * (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만 + * 볼 회사를 맡으면(`manages` · 39-6 — 시스템 관리자 · 그 회사 관리자) 「개인」 = 그 회사 사람 나무 + * (`createPeopleTree` · 나 맨 위 · 사람 줄 → 그 사람 라이브러리 · 고침) · 일반 = 내 개인 그대로 * 누르면 `go`(부르는 쪽이 해시로 다시 엶). * ========================================================================== */ @@ -18,6 +18,7 @@ import { accessFor, buildNeedCompany, layerOfRoot, + manages, ownOwner, type Root, } from "./M02_MasterTemplete_Layer"; @@ -40,9 +41,9 @@ export interface LayerBarOptions { export const companyOwner = (company: string | null, user: SessionUser | null): string | null => company && company !== ownOwner("company", user) ? company : null; -/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */ +/** 볼 회사를 맡음(39-6) — 「개인」 단추 = 그 회사 사람 나무 */ export const seesOthers = (company: string | null, user: SessionUser | null): boolean => - user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user); + manages(company, user); export function createLayerBar(opt: LayerBarOptions): HTMLElement { const { user, active } = opt; @@ -57,7 +58,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { } function button(root: Root, owner: string | null, people = false): HTMLButtonElement { - const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite; + const readOnly = !people && !accessFor(layerOfRoot(root), owner, user).canWrite; const name = L( root === "master" ? "M02_PickerSystem" @@ -113,7 +114,8 @@ export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement { box.replaceChildren( ...rows.flatMap((one) => { const current = one.owner === opt.active; - const node = { id: one.owner, label: one.name, count: null, folder: true }; + const label = one.비어있음 ? `${one.name} · ${L("M02_PeopleEmpty")}` : one.name; + const node = { id: one.owner, label, count: null, folder: true }; const row = createTreeRow(node, current ? "▾" : "▸", 0, () => { if (!current) return opt.go(one.owner); wrap.hidden = !wrap.hidden; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..63227afb3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -76,12 +76,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { const to = layerOfRoot(one); - if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return []; + const toOwner = one === "company" ? companyOwner(company, user) : null; + if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return []; const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal"; - return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }]; + return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }]; }); const tree = createTreePanel({ layer, diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 6ffa334e9..721a75faa 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -26,11 +26,12 @@ def _text(path: Path) -> str: @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_층별_고칠_권한(): - """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만.""" + """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침.""" text = _text(LAYER) own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M) found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M) - assert own and found, "ownOwner · accessFor 를 못 찾음" + manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M) + assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음" script = ( 'type ManageLayer = "system" | "company" | "personal";\n' "interface SessionUser { id: string; role?: string; company_id?: number | null;" @@ -38,6 +39,8 @@ def test_층별_고칠_권한(): "interface LayerAccess { canWrite: boolean; needCompany: boolean }\n" + own.group(0).replace("export ", "") + found.group(0).replace("export ", "") + + "\n" + + manages.group(0).replace("export ", "") + "\nconst cases = JSON.parse(process.argv[1]);" + "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));" ) @@ -54,7 +57,8 @@ def test_층별_고칠_권한(): ["personal", None, user], ["personal", None, admin], ["personal", None, orphan], ["personal", None, sysadmin], ["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin], - ["personal", "9/90", sysadmin], + ["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin], + ["company", "9", admin], ] # fmt: skip result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -69,7 +73,8 @@ def test_층별_고칠_권한(): ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만 ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만 ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내 - ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만 + ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침 + ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403) ] # fmt: skip @@ -181,7 +186,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰 assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무 assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기 - assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots + assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6) + assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침 + assert 'L("M02_PeopleEmpty")' in roots assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page @@ -269,12 +276,19 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · - 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만.""" + 30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page - assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page + # 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창 + assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page + assert "(to === layer && toOwner === here)) return [];" in page + copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts") + assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to + assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text( + M02 / "M02_MasterTemplete_TreePanel.ts" + ) panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c0c5e4e46..be85441fb 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -105,6 +105,7 @@ export const ui_locales_m2 = { M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], M02_SourcePeople: ["다른 사람", "Others"], M02_SourcePractice: ["실무문서", "Practice documents"], + M02_PeopleEmpty: ["비어 있음", "empty"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", @@ -144,10 +145,10 @@ export const ui_locales_m2 = { M02_NewItemPickCombo: ["고르기", "Pick"], M02_TreePickCombo: ["일위대가 고르기", "Pick unit price"], M02_TableFold: ["표 접기 · 펴기", "Fold or unfold the table"], - M02_TreeCopyToMaster: ["마스터로 복사 ({n}개)", "Copy to master ({n})"], - M02_TreeCopyToCompany: ["회사로 복사 ({n}개)", "Copy to company ({n})"], - M02_TreeCopyToPersonal: ["개인으로 복사 ({n}개)", "Copy to personal ({n})"], - M02_TreeCopiedTo: ["{n}개를 {layer} 맨 위에 복사함", "Copied {n} to the top of {layer}"], + M02_TreeCopyToMaster: ["마스터로 복사… ({n}개)", "Copy to master… ({n})"], + M02_TreeCopyToCompany: ["회사로 복사… ({n}개)", "Copy to company… ({n})"], + M02_TreeCopyToPersonal: ["개인으로 복사… ({n}개)", "Copy to personal… ({n})"], + M02_TreeCopiedTo: ["{n}개를 {layer} 「{folder}」에 복사함", "Copied {n} to {layer} “{folder}”"], M02_TreeFolderName: ["폴더 이름", "Folder name"], M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], From 8f5a573ebcf7613ac9224e120a4651d92ecb7212 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:40:31 +0900 Subject: [PATCH 21/24] =?UTF-8?q?fix(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=E3=80=8C=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=82=98=EB=8A=94=20=EB=BA=8C(?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=ED=83=AD)=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=20=E3=80=8C=EB=B9=84=EC=96=B4=20=EC=9E=88?= =?UTF-8?q?=EC=9D=8C=E3=80=8D=20=C2=B7=20=EC=B2=98=EC=9D=8C=20=3D=20?= =?UTF-8?q?=EC=B2=AB=20=EB=B9=88=20=EC=95=84=EB=8B=8C=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=20=C2=B7=20=EB=B9=88=20=EC=82=AC=EB=9E=8C=EC=9D=80=20=EB=B9=88?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 사람 목록 전부(39-6 E1~E4)에 맞춤. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 54cc67a51..3ee87e241 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -20,7 +20,7 @@ import { type ListKind, } from "./M02_MasterTemplete_ItemList"; import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; -import { readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { ownOwner, readableSources, type Source } from "./M02_MasterTemplete_Layer"; import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; import "./M02_MasterTemplete_TreePanel.css"; import "./M02_MasterTemplete_ItemPick.css"; @@ -29,6 +29,7 @@ const TEXT = { close: "닫기", person: "사람", noPeople: "다른 사람이 없음", + empty: "비어 있음", failed: "목록을 못 받음 — {value}", makeNew: "새로 만들기", makeCopy: "복사 생성", @@ -58,6 +59,8 @@ const PRACTICE_COLS: Col[] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */ +const empty = (o: { owner: string }): boolean => !!(o as { 비어있음?: boolean }).비어있음; export interface PickerOptions { user: SessionUser | null; @@ -106,17 +109,24 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { let owner = tab.owner; let label = tab.label; if (tab.id === "people") { - owners ??= await fetchOwners("personal", opt.company); + // 나는 「개인」 탭 — 뺌 · 빈 사람은 「비어 있음」 · 처음 = 첫 빈 아닌 사람 + const me = ownOwner("personal", opt.user); + owners ??= (await fetchOwners("personal", opt.company)).filter((o) => o.owner !== me); if (mine !== seq) return; if (!owners.length) return list.message(TEXT.noPeople); if (!people.select.value) { people.setOptions( - owners.map((o) => ({ value: o.owner, text: o.name })), - owners[0].owner, + owners.map((o) => ({ + value: o.owner, + text: empty(o) ? `${o.name} · ${TEXT.empty}` : o.name, + })), + (owners.find((o) => !empty(o)) ?? owners[0]).owner, ); } owner = people.select.value; - label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`; + const who = owners.find((o) => o.owner === owner); + label = `${tab.label} · ${who?.name ?? owner}`; + if (who && empty(who)) return list.show([], ITEM_COLS[opt.kind], () => {}); // 층 폴더 없음 } const rows = await loadItems(tab.layer, owner); if (mine !== seq) return; From d847f724cff5f11283b0650579397183c17091e8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:42:59 +0900 Subject: [PATCH 22/24] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=83=81=EC=84=B8=20=EB=B6=80=ED=92=88=20=E2=80=94?= =?UTF-8?q?=20=EB=8B=B4=EC=9D=80=20=EB=A1=9C=EC=A7=81=20=ED=91=9C=20=C2=B7?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EB=AA=A9=EB=A1=9D=20=ED=91=9C=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20calc=20=EA=B0=92=20?= =?UTF-8?q?=C2=B7=20=EB=AF=B8=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80(39-3=20C3=20=C2=B7=20C4=20=C2=B7=20C6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 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 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Combo_Store.py | 6 - .../M02_MasterTemplete_Labor.css | 102 ++++++ .../M02_MasterTemplete_Labor_Api.ts | 125 +++++-- .../M02_MasterTemplete_Labor_Detail.ts | 328 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Diagram.css | 10 + .../M02_MasterTemplete_Labor_Diagram.ts | 146 +++++--- .../M02_MasterTemplete_Labor_Edit.ts | 36 +- .../M02_MasterTemplete_Labor_Input.ts | 241 +++++++++++++ .../M02_MasterTemplete_Labor_Preview.ts | 244 ------------- .../M02_MasterTemplete_Labor_Rows.ts | 283 +++++++++++++++ .../M02_MasterTemplete_Labor_Text.ts | 44 ++- .../M02_MasterTemplete_Router_Combos.py | 20 +- resources/tester/test_m01_combo.py | 15 +- resources/tester/test_m02_labor_diagram.py | 92 +++-- 14 files changed, 1266 insertions(+), 426 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 52a846549..ca8d399dd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -190,12 +190,6 @@ def pills(key: str) -> dict[str, Any]: return combo_store.combo_pills(_find(_load()[0], key)) -def preview(combo: dict | str, inputs: dict | None = None) -> dict[str, Any]: - """저장 안 함 — `combo` 는 조합 줄 통째 또는 지금 층 키.""" - row = _find(_load()[0], combo) if isinstance(combo, str) else combo - return _m01(combo_store.combo_preview, row, inputs) - - # ── 쓰기(회사 · 개인) ────────────────────────────────────────────────── def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]: """서버가 잡는 칸 — 키 · 열 한 벌 · 소유 · 본뜬키는 있는 다른 조합(어느 층이든).""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index f6791ee2e..6f435b42d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -21,3 +21,105 @@ .m02-labor__list tbody tr:hover { background: var(--color-mist-violet); } + +/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */ +.m02-labrow { + width: 100%; +} + +.m02-labrow__row.is-open { + cursor: pointer; +} + +.m02-labrow__row.is-open:hover { + background: var(--color-mist-violet); +} + +.m02-labrow__name { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 12rem; +} + +.m02-labrow__key, +.m02-labrow__note, +.m02-labrow__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); + overflow-wrap: anywhere; +} + +.m02-labrow__empty { + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labrow__money, +.m02-labrow__num { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-labrow__money { + vertical-align: top; +} + +.m02-labrow__base { + display: block; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labrow__applied { + display: block; +} + +.m02-labrow__sum td { + font-weight: 600; + border-top: 2px solid var(--color-border); +} + +.m02-labrow__move { + white-space: nowrap; +} + +/* 입력 모달 */ +.m02-labin { + width: min(40rem, calc(100vw - 32px)); +} + +.m02-labin__qty { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m02-labin__qty-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-labin__muted { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.m02-labin__lost { + margin: 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m02-labin__foot { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); + margin-top: var(--spacing-12); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 4dfb87e84..026566821 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,7 +12,12 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + getActiveLayer, + getActiveOwner, + type ItemLogic, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; /** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ export interface ComboLogic { @@ -56,28 +61,6 @@ export interface ComboOne { combo: Combo; 줄: ComboLine[]; } -export interface PreviewLine { - 차례: number; - 로직: string; - 이름: string; - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} -export interface PreviewAnswer { - 줄: PreviewLine[]; - 노무비: number; - 재료비: number; - 경비: number; - 계: number; - 멈춤: string[]; -} - /** 조합 층 — 층 + 주인(회사 `7` · 개인 `7/42` · 없으면 내 것) */ export interface LaborScope { layer: Exclude; @@ -227,13 +210,6 @@ export const pasteCombos = ( 판, }); -/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ -export const previewCombo = ( - combo: Combo | string, - inputs: Record>, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, "/preview"), "POST", { combo, inputs }); - /* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */ export const fetchLibLogics = (): Promise => request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics); @@ -248,3 +224,92 @@ export const fetchLibAuto = (key: string): Promise> => request<{ 값?: Record }>(`/logic-lib/auto?key=${enc(key)}`) .then((d) => d.값 ?? {}) .catch(() => ({})); + +/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */ + +/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ +export type LaborQty = { 손: number } | null; +/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */ +export interface LaborRow { + 로직: string; + 메모: string | null; + 입력값: Record; + 수량: LaborQty; +} +export interface LaborMat { + 코드: string; + 이름: string | null; + 규격: string | null; + 단위: string | null; + 수량: LaborQty; +} +export interface LaborDoc { + 종류: "일위대가"; + 판?: number; + 열: string; + 일위대가번?: string; + 단위: string | null; + 본뜬?: { 층: string; 주인: string | null; 키: string } | null; + 담은로직: LaborRow[]; + 자재: LaborMat[]; + 계산값?: unknown; +} +export interface LaborCost { + 노무비: number; + 재료비: number; + 경비: number; + 계: number; +} +/** calc 줄 — 기준 = 결과단위 그대로 · 반영 = 기준 × 수량 ÷ 배(수량 없거나 돈 아닌 로직은 null) */ +export interface LaborCalcLine { + 로직: string; + 분모: string | null; + 배: number | null; + 수량: number | null; + 수량출처: string | null; + 기준: LaborCost | null; + 반영: LaborCost | null; + 결과?: number | null; + 까닭: string | null; +} +export interface LaborCalcMat { + 코드: string; + 기준단가: number | null; + 수량: number | null; + 수량출처: string | null; + 반영: number | null; + 까닭: string | null; +} +export interface LaborCalc { + 줄: LaborCalcLine[]; + 자재: LaborCalcMat[]; + 기준합: Partial; + 반영합: Partial; +} + +/** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ +const laborAt = (layer: Layer, column: string, tail: string): string => { + const owner = layer === getActiveLayer() ? getActiveOwner() : null; + const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; + return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; +}; + +/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */ +export const calcLabor = ( + layer: Layer, + column: string, + doc: LaborDoc | null, + 수량?: Record, +): Promise => + request(laborAt(layer, column, "/calc"), "POST", { + ...(doc ? { doc } : {}), + ...(수량 ? { 수량 } : {}), + }); + +/** 담은 로직 입력 목록 + 자동값(입력 모달 · `M02_Labor_Inputs`) */ +export const fetchLogicInputs = ( + keys: string[], +): Promise<{ 로직: ItemLogic[]; 빠진로직: string[] }> => + keys.length + ? request(`/logic-lib/inputs?keys=${enc(keys.join(","))}`) + : Promise.resolve({ 로직: [], 빠진로직: [] }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts new file mode 100644 index 000000000..d56ecc546 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -0,0 +1,328 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Detail.ts + * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 + * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. + * · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장] + * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 + * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { ROUTES } from "@config/config_frontend"; +import { navigateTo } from "../A00_Common/router"; +import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; +import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + calcLabor, + fetchLogicInputs, + type LaborCalc, + type LaborDoc, + type LaborMat, + type LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { + buildLaborDiagram, + diagramTitle, + renderComboDiagram, +} from "./M02_MasterTemplete_Labor_Diagram"; +import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { keepKnown } from "./M02_Labor_Values_Model"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "@ui/ui_template_general_layout.css"; +import "./M02_MasterTemplete_Labor.css"; + +/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ +export function normalizeLabor(doc: unknown, column: string): LaborDoc { + const d = (doc && typeof doc === "object" ? doc : {}) as Partial; + return { + ...d, + 종류: "일위대가", + 열: d.열 ?? column, + 단위: d.단위 ?? null, + 담은로직: (d.담은로직 ?? []).map((r) => ({ + 로직: r.로직, + 메모: r.메모 ?? null, + 입력값: r.입력값 ?? {}, + 수량: r.수량 ?? null, + })), + 자재: (d.자재 ?? []).map((m) => ({ + 코드: m.코드, + 이름: m.이름 ?? null, + 규격: m.규격 ?? null, + 단위: m.단위 ?? null, + 수량: m.수량 ?? null, + })), + }; +} + +export interface LaborDetailOptions { + layer: Layer; + column: string; + readOnly: boolean; + /** 저장 안 한 고침이 생기거나 사라짐 */ + onChanged: (dirty: boolean) => void; + /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; + /** 자재 하나 고르기(`M02_Material_Pick`) — 없으면 [자재 추가] 없음 */ + pickMaterial?: (done: (m: Omit) => void) => void; +} + +export interface LaborDetailHandle { + getDoc: () => LaborDoc; + destroy: () => void; +} + +export function mountLaborDetail( + host: HTMLElement, + doc: unknown, + opt: LaborDetailOptions, +): LaborDetailHandle { + const cur = normalizeLabor(doc, opt.column); + const saved = JSON.stringify(cur); + const edit = !opt.readOnly; + const logics = new Map(); + let calc: LaborCalc | null = null; + let fail: string | null = null; + let seq = 0; + let timer = 0; + let alive = true; + + const logicHost = el("div"); + const matHost = el("div"); + const dgHost = el("div"); + const dgBox = el("details", { + className: "m01lab__box m01-logic__section m02-labor__dg", + attrs: { open: "" }, + children: [el("summary", { text: diagramTitle() }), dgHost], + }); + + const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); + const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); + + const drawDg = (): void => { + if (!dgBox.open) return; + const data = buildLaborDiagram({ + rows: cur.담은로직, + logics: [...logics.values()], + calc, + mats: cur.자재.length, + }); + renderComboDiagram(dgHost, data, { + focusInput: (key) => { + const i = cur.담은로직.findIndex((r) => r.로직 === key); + if (i >= 0) openRow(i); + }, + // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) + openLogic: (key) => { + setPendingLogicKey(key); + navigateTo(ROUTES.M01_MASTER_DATA); + }, + }); + }; + dgBox.addEventListener("toggle", drawDg); + + const draw = (): void => { + logicHost.replaceChildren( + logicTable( + { rows: cur.담은로직, logics, calc, fail }, + // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) + edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, + ), + ); + matHost.replaceChildren( + matTable( + { mats: cur.자재, calc, fail }, + edit ? { open: openMat, remove: removeMat } : { open: openMat }, + ), + ); + drawDg(); + }; + + /** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */ + const recalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const mine = ++seq; + calc = null; + fail = null; + draw(); + try { + const got = await calcLabor(opt.layer, opt.column, cur); + if (!alive || mine !== seq) return; + calc = got; + } catch (error) { + if (!alive || mine !== seq) return; + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + draw(); + }, 150); + }; + + const changed = (): void => { + opt.onChanged(JSON.stringify(cur) !== saved); + recalc(); + }; + + /** 아직 안 받은 로직 입력 목록만 받음 */ + const loadLogics = async (): Promise => { + const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + if (!want.length) return; + try { + const got = await fetchLogicInputs(want); + for (const l of got.로직) logics.set(l.로직, l); + } catch (error) { + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + }; + + function move(i: number, d: number): void { + const j = i + d; + if (j < 0 || j >= cur.담은로직.length) return; + [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; + changed(); + } + function removeRow(i: number): void { + cur.담은로직.splice(i, 1); + changed(); + } + function removeMat(i: number): void { + cur.자재.splice(i, 1); + changed(); + } + + function openRow(i: number): void { + const row: LaborRow = cur.담은로직[i]; + const logic = logics.get(row.로직) ?? null; + const line = lineOf(row.로직); + openLaborInput({ + title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), + logic, + values: row.입력값, + memo: row.메모, + seat: { + qty: row.수량, + linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null, + unit: line?.분모 ?? "", + }, + readOnly: !edit, + goArea: opt.goArea, + done: (next) => { + row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + row.메모 = next.메모; + row.수량 = next.수량; + changed(); + }, + }); + } + + function openMat(i: number): void { + const mat = cur.자재[i]; + const line = matOf(mat.코드); + openQtyModal( + `${mat.코드} ${mat.이름 ?? ""}`.trim(), + { + qty: mat.수량, + linked: + line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null + ? `${line.수량출처} = ${line.수량}` + : null, + unit: mat.단위 ?? "", + }, + !edit, + opt.goArea, + (qty) => { + mat.수량 = qty; + changed(); + }, + ); + } + + const addLogic = createButton({ + label: tc("AddLogic"), + variant: "filled", + size: "sm", + onClick: () => + openLogicPicker((x) => { + if (cur.담은로직.some((r) => r.로직 === x.키)) + return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); + cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); + void loadLogics().then(changed); + }), + }); + const pick = opt.pickMaterial; + const addMat = pick + ? createButton({ + label: tc("Labor_AddMat"), + variant: "filled", + size: "sm", + onClick: () => + pick((m) => { + if (cur.자재.some((x) => x.코드 === m.코드)) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); + cur.자재.push({ ...m, 수량: null }); + changed(); + }), + }) + : null; + + const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); + unit.input.value = cur.단위 ?? ""; + unit.input.readOnly = !edit; + unit.input.maxLength = 20; + unit.input.setAttribute("data-labor-field", "단위"); + unit.input.addEventListener("input", () => { + cur.단위 = unit.input.value.trim() || null; + opt.onChanged(JSON.stringify(cur) !== saved); + }); + const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); + origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.readOnly = true; + + const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement => + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { + className: "m01-logic__section-head", + children: [el("h3", { text: title }), ...(edit && button ? [button] : [])], + }), + body, + ], + }); + + host.replaceChildren( + el("div", { + className: "m01-logic__editor m01-combo m02-labor__edit", + attrs: { "data-labor": opt.column }, + children: [ + el("section", { + className: "m01lab__box m01-logic__section", + children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })], + }), + section(tc("Logics_Title"), addLogic, logicHost), + section(tc("Labor_Mats"), addMat, matHost), + dgBox, + ], + }), + ); + draw(); + void loadLogics().then(() => { + if (alive) recalc(); + }); + + return { + getDoc: () => cur, + destroy: () => { + alive = false; + window.clearTimeout(timer); + host.replaceChildren(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css index fa21deb41..67a16d23a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css @@ -309,3 +309,13 @@ cursor: pointer; font-weight: var(--font-weight-bold); } + +/* 39-3 — 자동값 알약(넣은 값 없음 · 기울임) · 자재목록 카드(누름 없음) */ +.m02-labordg__pill.is-auto .m02-labordg__pill-value { + font-style: italic; + color: var(--color-text-secondary); +} + +.m02-labordg__card.is-material { + cursor: default; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 0348ae7b4..9125b3042 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -1,8 +1,9 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Diagram.ts * 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값) - * 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) - * 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT + * 다이어그램은 계산하지 않음 — 일위대가 calc 답을 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) + * 값 = 저장된 줄 입력값 + calc 답(수량 있으면 반영 · 없으면 기준 · 카드에 표기 · `buildLaborDiagram` · 39-3) · + * 자재목록은 카드 하나로 재료비 칸에 이음 · 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -29,7 +30,9 @@ const TEXT = { "누르면 이 로직을 단가산출 로직 화면에서 엶", "Open this logic in the unit-price logic screen", ], - Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"], + Goto: ["누르면 이 로직 입력 모달을 엶", "Open this logic's inputs"], + Mats: ["자재목록", "Materials"], + Auto: ["자동값", "auto"], Same: ["같은 이름의 넣을 값", "Same input name"], Won: ["원", "KRW"], Title: ["다이어그램", "Diagram"], @@ -46,8 +49,10 @@ type Cost = (typeof COST_BINS)[number]; export interface DiagramInput { name: string; unit: string; - /** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */ + /** 넣은 값 — 빈 글 = 값 없음 */ value: string; + /** 넣은 값이 없어 자동값을 보임 */ + auto?: boolean; } /** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */ @@ -64,6 +69,10 @@ export interface DiagramLogic { unit: string; inputs: DiagramInput[]; out: DiagramOut; + /** 금액이 어느 값인지 — 「기준」 · 「반영」(없으면 안 보임) */ + basis?: string; + /** 자재목록 카드 — 누름 없음 */ + material?: boolean; } export interface DiagramData { @@ -73,53 +82,86 @@ export interface DiagramData { export interface DiagramHandlers { /** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */ openLogic(key: string): void; - /** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */ + /** 넣을 값 알약 누름 — 그 로직 입력 모달 */ focusInput(key: string, name: string): void; } -/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */ +/* ── 값 만들기 (일위대가 상세가 부름 · DOM 없음 · 39-3) ───────────────────── */ -interface PreviewLineLike { - ok: boolean; - 까닭?: string; - 갈래?: string; - 결과?: number; - 노무비?: number; - 재료비?: number; - 경비?: number; - 계?: number; -} +type Money = { 노무비: number; 재료비: number; 경비: number; 계: number }; -export interface DiagramSource { +export interface LaborDiagramSource { /** 담은 차례대로 */ - rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[]; - /** 로직 키 → 입력 이름 → 넣은 값 */ - values: Record>; - /** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */ - lines: PreviewLineLike[] | null; + rows: { 로직: string; 입력값: Record }[]; + /** 로직 입력 목록(`/logic-lib/inputs`) */ + logics: { + 로직: string; + 이름: string; + 결과단위?: string; + 입력: { 이름: string; 단위?: string; 자동값?: unknown }[]; + }[]; + /** calc 답 · 아직이면 null */ + calc: { + 줄: { + 로직: string; + 기준: Money | null; + 반영: Money | null; + 결과?: number | null; + 까닭: string | null; + }[]; + 자재: { 기준단가: number | null; 반영: number | null }[]; + } | null; + /** 담은 자재 수 — 0 이면 자재 카드 없음 */ + mats: number; } -const outOf = (l: PreviewLineLike | undefined): DiagramOut => { - if (!l) return null; - if (!l.ok) return { 멈춤: l.까닭 ?? "" }; - if (l.갈래 === "결과") return { 값: l.결과 ?? 0 }; - return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 }; -}; +const autoOf = (v: unknown): string => + typeof v === "string" || typeof v === "number" ? String(v) : ""; -export function buildDiagramData(src: DiagramSource): DiagramData { - return { - logics: src.rows.map((r, i) => ({ - key: r.key, - name: r.name, - unit: r.unit, - inputs: r.inputs.map((x) => ({ - name: x.name, - unit: x.unit, - value: src.values[r.key]?.[x.name] ?? "", - })), - out: outOf(src.lines?.[i]), - })), - }; +export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { + const logics: DiagramLogic[] = src.rows.map((r) => { + const def = src.logics.find((l) => l.로직 === r.로직); + const line = src.calc?.줄.find((l) => l.로직 === r.로직); + let out: DiagramOut = null; + let basis: string | undefined; + if (line?.까닭) out = { 멈춤: line.까닭 }; + else if (line && !line.기준 && typeof line.결과 === "number") out = { 값: line.결과 }; + else if (line && (line.반영 || line.기준)) { + out = { ...((line.반영 ?? line.기준) as Money) }; + basis = line.반영 ? "반영" : "기준"; + } + return { + key: r.로직, + name: def?.이름 ?? r.로직, + unit: def?.결과단위 ?? "", + inputs: (def?.입력 ?? []).map((x) => { + const mine = r.입력값[x.이름]; + const has = mine !== undefined && mine !== ""; + return { + name: x.이름, + unit: x.단위 ?? "", + value: has ? String(mine) : autoOf(x.자동값), + ...(has || !autoOf(x.자동값) ? {} : { auto: true }), + }; + }), + out, + ...(basis ? { basis } : {}), + }; + }); + if (src.mats > 0) { + const mats = src.calc?.자재 ?? null; + const sum = mats?.reduce((t, m) => t + (m.반영 ?? m.기준단가 ?? 0), 0) ?? 0; + logics.push({ + key: "", + name: `${td("Mats")} ${src.mats}`, + unit: "", + inputs: [], + out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null, + ...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}), + material: true, + }); + } + return { logics }; } /* ── 글 ─────────────────────────────────────────────────────────────────── */ @@ -134,7 +176,7 @@ const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o; const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0); function summary(l: DiagramLogic): string { - if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; + if (isMoney(l.out)) return `${l.basis ?? td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim(); if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`; return td("Wait"); @@ -360,8 +402,12 @@ export function renderComboDiagram( ? l.inputs.map((x) => { const empty = x.value.trim() === ""; const pill = el("button", { - className: `m02-labordg__pill${empty ? " is-empty" : ""}`, - attrs: { type: "button", "data-name": x.name, title: td("Goto") }, + className: `m02-labordg__pill${empty ? " is-empty" : ""}${x.auto ? " is-auto" : ""}`, + attrs: { + type: "button", + "data-name": x.name, + title: x.auto ? `${td("Auto")} · ${td("Goto")}` : td("Goto"), + }, children: [ el("span", { className: "m02-labordg__pill-name", text: x.name }), el("span", { @@ -376,8 +422,12 @@ export function renderComboDiagram( : [], }); const card = el("button", { - className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`, - attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") }, + className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}${l.material ? " is-material" : ""}`, + attrs: { + type: "button", + style: `grid-column:3;grid-row:${row}`, + ...(l.material ? {} : { title: td("Open") }), + }, children: [ el("span", { className: "m02-labordg__card-key", text: l.key }), el("strong", { className: "m02-labordg__card-name", text: l.name }), @@ -385,7 +435,7 @@ export function renderComboDiagram( el("span", { className: "m02-labordg__card-out", text: summary(l) }), ], }); - card.addEventListener("click", () => on.openLogic(l.key)); + if (!l.material) card.addEventListener("click", () => on.openLogic(l.key)); const mine = bins.map((b, c) => { const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b); sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index f09b181de..dfa1780f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Edit.ts * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 - * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b). + * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 · + * 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감. * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. * ========================================================================== */ @@ -12,10 +13,7 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { ROUTES } from "@config/config_frontend"; -import { navigateTo } from "../A00_Common/router"; import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { deleteCombo, editCombo, @@ -26,9 +24,7 @@ import { type ComboSummary, type LaborScope, } from "./M02_MasterTemplete_Labor_Api"; -import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { matchText } from "./M02_MasterTemplete_TreePanel_Model"; import { loadingError, showLoading } from "@ui/ui_template_loading"; @@ -215,31 +211,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }); return f.root; }; - const preview = buildPreview(() => combo); - // 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면 - const dgHost = el("div"); - const dgBox = el("details", { - className: "m01lab__box m01-logic__section m02-labor__dg", - attrs: { open: "" }, - children: [el("summary", { text: diagramTitle() }), dgHost], - }); - const drawDg = (): void => { - if (dgBox.open) - renderComboDiagram(dgHost, preview.data(), { - focusInput: preview.focusInput, - openLogic: async (logic) => { - if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return; - setPendingLogicKey(logic); - navigateTo(ROUTES.M01_MASTER_DATA); - }, - }); - }; - let dgTimer = 0; - preview.onUpdate(() => { - window.clearTimeout(dgTimer); - dgTimer = window.setTimeout(drawDg, 120); - }); - dgBox.addEventListener("toggle", drawDg); const table = el("div", { className: "m01-master__grid-wrap" }); const drawTable = (): void => { const move = (i: number, d: number): void => { @@ -306,7 +277,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { }), ...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]), ); - void preview.redraw(); }; const add = createButton({ @@ -409,8 +379,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { table, ], }), - dgBox, - preview.root, ); drawTable(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts new file mode 100644 index 000000000..e5d913bff --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -0,0 +1,241 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Input.ts + * 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량 + * (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`). + * [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborQty } from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { createValueControl } from "./M02_Labor_Values"; +import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model"; +import "./M02_Labor_Values.css"; +import "./M02_MasterTemplete_Labor.css"; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface QtySeat { + /** 지금 수량 */ + qty: LaborQty; + /** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */ + linked: string | null; + /** 손 입력 단위 표기(분모 · 자재 단위) */ + unit: string; +} + +interface QtyPart { + root: HTMLElement; + /** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */ + read: () => LaborQty | undefined; +} + +/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ +function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart { + const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`; + const radio = (value: "cell" | "hand"): HTMLInputElement => { + const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement; + input.disabled = readOnly; + return input; + }; + const cell = radio("cell"); + const hand = radio("hand"); + const number = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "number", step: "any", "data-labor-qty": "hand" }, + }) as HTMLInputElement; + number.readOnly = readOnly; + if (seat.qty) number.value = String(seat.qty.손); + (seat.qty ? hand : cell).checked = true; + number.disabled = !hand.checked; + for (const r of [cell, hand]) + r.addEventListener("change", () => { + number.disabled = !hand.checked; + if (hand.checked) number.focus(); + }); + const go = goArea + ? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea }) + : null; + const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement => + el("label", { + className: "m02-labin__qty-line", + children: [input, el("span", { text: label }), ...rest], + }); + const root = el("section", { + className: "m02-labin__qty", + children: [ + el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }), + line( + cell, + tc("In_QtyCell"), + el("small", { + className: "m02-labin__muted", + text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"), + }), + ...(go ? [go] : []), + ), + line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })), + ], + }); + return { + root, + read: () => { + if (!hand.checked) return null; + const v = Number(number.value); + return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined; + }, + }; +} + +function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement { + return el("div", { + className: "m02-labin__foot", + children: readOnly + ? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })] + : [ + createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }), + createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }), + ], + }); +} + +export interface LaborInputResult { + 입력값: LaborValues; + 메모: string | null; + 수량: LaborQty; +} + +export interface LaborInputOptions { + /** 「GF000219 메쌓기」 */ + title: string; + /** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */ + logic: ItemLogic | null; + values: LaborValues; + memo: string | null; + seat: QtySeat; + readOnly: boolean; + /** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */ + goArea: (() => void) | null; + done: (next: LaborInputResult) => void; +} + +/** 담은 로직 줄 입력 모달 */ +export function openLaborInput(opt: LaborInputOptions): void { + const inputs: ItemInput[] = opt.logic?.입력 ?? []; + const { kept, lost } = keepKnown(inputs, opt.values); + const draft: LaborValues = { ...kept }; + const bad = new Set(); + openModal({ + title: fill(tc("In_Title"), opt.title), + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const rows = inputs.map((input) => { + const field = createValueControl(input, draft[input.이름] ?? null, (value, isBad) => { + if (isBad) bad.add(input.이름); + else bad.delete(input.이름); + if (value === null) delete draft[input.이름]; + else draft[input.이름] = value; + }); + field.control.disabled = opt.readOnly; + field.control.setAttribute("data-labor-input", input.이름); + const hint = valueHint(input); + return el("div", { + className: "m02-inputval__row", + children: [ + el("span", { + className: "m02-inputval__name", + text: input.단위 ? `${input.이름} (${input.단위})` : input.이름, + attrs: { title: [input.설명, hint].filter(Boolean).join(" · ") }, + }), + el("div", { className: "m02-inputval__cell", children: [field.control, field.error] }), + ], + }); + }); + const memo = el("input", { + className: "ui-input m02-inputval__control", + attrs: { type: "text", maxlength: "200", "data-labor-memo": "" }, + }) as HTMLInputElement; + memo.value = opt.memo ?? ""; + memo.readOnly = opt.readOnly; + const qty = qtyPart( + opt.seat, + opt.readOnly, + opt.goArea && + (() => { + close(); + opt.goArea?.(); + }), + ); + const ok = (): void => { + if (bad.size) return showToast(tc("In_Bad"), "error"); + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 }); + close(); + }; + body.append( + el("div", { + className: "m02-inputval", + children: [ + ...(lost.length + ? [ + el("p", { + className: "m02-labin__lost", + text: fill(tc("Labor_Lost"), lost.join(", ")), + }), + ] + : []), + ...(rows.length + ? rows + : [el("p", { className: "m02-labin__muted", text: tc("In_None") })]), + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { className: "m02-inputval__name", text: tc("In_Memo") }), + memo, + ], + }), + qty.root, + ], + }), + footer(opt.readOnly, ok, close), + ); + }, + }); +} + +/** 자재 줄 수량 모달 */ +export function openQtyModal( + title: string, + seat: QtySeat, + readOnly: boolean, + goArea: (() => void) | null, + done: (qty: LaborQty) => void, +): void { + openModal({ + title, + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin", + mount: (body, close) => { + const qty = qtyPart( + seat, + readOnly, + goArea && + (() => { + close(); + goArea(); + }), + ); + const ok = (): void => { + const 수량 = qty.read(); + if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); + done(수량); + close(); + }; + body.append(qty.root, footer(readOnly, ok, close)); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts deleted file mode 100644 index 68cc72e01..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Preview.ts - * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST …/combos/preview`)에 실어 비목별 합계를 받음 - * 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장) - * ========================================================================== */ - -import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import type { LogicInput } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; -import { - fetchLibAuto as fetchAuto, - fetchLibLogic as fetchLogic, - previewCombo, - type Combo, -} from "./M02_MasterTemplete_Labor_Api"; -import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram"; -import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text"; - -const COSTS = ["노무비", "재료비", "경비"] as const; -const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { - 노무비: "Prev_Labor", - 재료비: "Prev_Material", - 경비: "Prev_Expense", -}; - -const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); -/** 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ -const fmt = formatMoney; - -/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ -const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); - -/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */ -function inputCell( - input: LogicInput, - key: string, - values: Record>, - onEdit: () => void, - onInput: () => void, -): HTMLElement { - const mine = (values[key] ??= {}); - const value = mine[input.이름] ?? initial(input); - mine[input.이름] = value; - const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) })); - const field = choices - ? createSelectField({ options: choices, value, compact: true, label: input.이름 }) - : createInputField({ type: "text", label: input.이름, compact: true }); - const control = "select" in field ? field.select : field.input; - control.value = value; - control.setAttribute("data-input", `${key}|${input.이름}`); - const edit = (): void => { - mine[input.이름] = control.value; - onInput(); - }; - control.addEventListener("input", edit); - control.addEventListener("change", () => { - edit(); - onEdit(); // 바꾸면 다시 미리 보기 계산 - }); - return field.root; -} - -export interface PreviewHandle { - root: HTMLElement; - /** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */ - redraw: () => Promise; - /** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */ - data: () => DiagramData; - /** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */ - onUpdate: (fn: () => void) => () => void; - /** 그 입력 칸으로 가서 깜빡임 */ - focusInput: (key: string, name: string) => void; -} - -export function buildPreview(combo: () => Combo): PreviewHandle { - const values: Record> = {}; - /** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */ - const inputs = new Map(); - /** 로직마다 자동값은 한 번만 받음 */ - const asked = new Set(); - /** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */ - const meta = new Map(); - let lines: Parameters[0]["lines"] = null; - const listeners = new Set<() => void>(); - const notify = (): void => listeners.forEach((f) => f()); - const form = el("div", { className: "m01-logic__stack" }); - const out = el("div", { - className: "m01-master__grid-wrap", - attrs: { "data-combo": "preview" }, - }); - const rowOf = (cells: string[], note = "", bold = false): HTMLElement => - el("tr", { - className: bold ? "m01lab__subtotal" : "", - children: cells.map((t, i) => { - const text = i === 0 && note ? `${t} — ${note}` : t; - return el("td", { text, attrs: { title: text } }); - }), - }); - - const run = async (): Promise => { - out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); - lines = null; - notify(); - const send: Record> = {}; - for (const [key, defs] of inputs) { - send[key] = {}; - for (const i of defs) { - const raw = values[key]?.[i.이름] ?? initial(i); - if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) - send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); - } - } - let got; - try { - got = await previewCombo(combo(), send); - lines = got.줄; - notify(); - } catch (error) { - out.replaceChildren( - el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }), - ); - return; - } - const trs = got.줄.map((l) => - rowOf( - [ - `${l.로직} ${l.이름}`, - fmt(l.노무비 ?? 0), - fmt(l.재료비 ?? 0), - fmt(l.경비 ?? 0), - fmt(l.계 ?? 0), - ], - l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`, - ), - ); - trs.push( - rowOf( - [tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)], - "", - true, - ), - ); - const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")]; - out.replaceChildren( - el("table", { - className: "m01-master__grid", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: trs }), - ], - }), - ); - }; - - const button = el("button", { - className: "ui-btn ui-btn--filled", - text: tc("Prev_Run"), - attrs: { type: "button", "data-combo": "run" }, - }); - button.addEventListener("click", () => void run()); - - const redraw = async (): Promise => { - const blocks: HTMLElement[] = []; - inputs.clear(); - for (const row of combo().담은로직) { - try { - const [one, auto] = await Promise.all([ - fetchLogic(row.로직), - asked.has(row.로직) ? Promise.resolve({}) : fetchAuto(row.로직), - ]); - asked.add(row.로직); - const mine = (values[row.로직] ??= {}); - for (const i of one.logic.입력) { - const got = (auto as Record)[i.이름]; - if (got === undefined || got === null || (mine[i.이름] ?? "").trim() !== "") continue; - mine[i.이름] = String(got); - } - inputs.set(row.로직, one.logic.입력); - meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" }); - blocks.push( - el("div", { - className: "m01lab__info", - children: [ - el("strong", { - className: "m01lab__cell--wide", - text: `${row.로직} ${one.logic.이름}`, - }), - ...one.logic.입력.map((i) => - inputCell(i, row.로직, values, () => void run(), notify), - ), - ], - }), - ); - } catch { - /* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */ - } - } - form.replaceChildren(...blocks); - notify(); - if (blocks.length) - await run(); // 열자마자 한 번 미리 보기 계산 - else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") })); - }; - - const root = el("section", { - className: "m01lab__box m01-logic__section", - children: [ - el("div", { - className: "m01-logic__section-head", - children: [el("h3", { text: tc("Prev_Title") })], - }), - el("h4", { text: tc("Prev_Inputs") }), - form, - button, - out, - ], - }); - const data = (): DiagramData => - buildDiagramData({ - rows: combo().담은로직.map((r) => ({ - key: r.로직, - name: meta.get(r.로직)?.name ?? r.로직, - unit: meta.get(r.로직)?.unit ?? "", - inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })), - })), - values, - lines, - }); - const focusInput = (key: string, name: string): void => { - const box = root.querySelector(`[data-input="${CSS.escape(`${key}|${name}`)}"]`); - if (!box) return; - box.scrollIntoView({ block: "center", behavior: "smooth" }); - box.focus({ preventScroll: true }); - box.classList.add("m02-labor__flash"); - window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600); - }; - const onUpdate = (fn: () => void): (() => void) => { - listeners.add(fn); - return () => listeners.delete(fn); - }; - return { root, redraw, data, onUpdate, focusInput }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts new file mode 100644 index 000000000..f8c637ccc --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -0,0 +1,283 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Rows.ts + * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. + * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · + * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. + * 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」). + * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; +import type { + LaborCalc, + LaborCalcLine, + LaborCalcMat, + LaborCost, + LaborMat, + LaborRow, +} from "./M02_MasterTemplete_Labor_Api"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_MasterTemplete_Labor.css"; +import { valueLine } from "./M02_Labor_Values_Model"; + +const COSTS = ["노무비", "재료비", "경비", "계"] as const; +const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { + 노무비: "Prev_Labor", + 재료비: "Prev_Material", + 경비: "Prev_Expense", + 계: "Prev_Sum", +}; + +const num = (v: number): string => + Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */ +export function qtyText( + line: { 수량: number | null; 수량출처: string | null } | undefined, + unit: string, +): string { + if (!line || line.수량 === null || line.수량 === undefined) return ""; + const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; + if (!line.수량출처 || line.수량출처 === "손") + return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; + return `${tc("Labor_Qty")} ${line.수량출처} = ${v}`; +} + +/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */ +function moneyCell( + base: number | null | undefined, + applied: number | null | undefined, + wait: boolean, +): HTMLElement { + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + return el("td", { + className: "m02-labrow__money", + children: [ + el("small", { + className: "m02-labrow__base", + text: show(base), + attrs: { title: tc("Labor_Base") }, + }), + el("span", { + className: "m02-labrow__applied", + text: show(applied), + attrs: { title: tc("Labor_Applied") }, + }), + ], + }); +} + +const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap", + children: [ + el("table", { + className: "ui-general-block__table m02-labrow", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ...(foot.length ? [el("tfoot", { children: foot })] : []), + ], + }), + ], + }); + +/** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */ +export interface RowHandlers { + open: (i: number) => void; + move?: (i: number, d: number) => void; + remove?: (i: number) => void; +} + +export interface LogicTableData { + rows: LaborRow[]; + logics: Map; + /** calc 답 — null = 계산 중 · 못 셈은 `fail` */ + calc: LaborCalc | null; + fail?: string | null; +} + +/** 담은 로직 표 — 보기 갈래는 `on` 없이 */ +export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { + const wait = !data.calc && !data.fail; + const { move, remove } = on ?? {}; + const tools = !!(move && remove); + const lineOf = (key: string): LaborCalcLine | undefined => + data.calc?.줄.find((l) => l.로직 === key); + const trs = data.rows.map((row, i) => { + const logic = data.logics.get(row.로직); + const line = lineOf(row.로직); + const unit = logic?.결과단위 ?? ""; + const values = valueLine(logic?.입력 ?? [], row.입력값); + const qty = qtyText(line, line?.분모 ?? ""); + const note = [values.text, qty].filter(Boolean).join(" · "); + const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); + const small = (label: string, run: () => void): HTMLElement => + createButton({ + label, + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); // 줄 누름(입력 모달)과 따로 + run(); + }, + }); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-logic": row.로직 }, + children: [ + el("td", { text: String(i + 1) }), + el("td", { + className: "m02-labrow__name", + children: [ + el("span", { className: "m02-labrow__key", text: row.로직 }), + el("strong", { text: logic?.이름 ?? row.로직 }), + ...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []), + ...(values.empty + ? [ + el("small", { + className: "m02-labrow__empty", + text: tc("Labor_Empty").replace("{n}", String(values.empty)), + }), + ] + : []), + ...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []), + ...(why + ? [ + el("small", { + className: "m02-labrow__empty", + text: `${tc("Labor_Calc_Fail")}: ${why}`, + }), + ] + : []), + ], + }), + el("td", { text: unit }), + ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)), + ...(move && remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + small(tc("Up"), () => move(i, -1)), + small(tc("Down"), () => move(i, 1)), + small(tc("Remove"), () => remove(i)), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => + part?.[c] ?? null; + const foot = data.rows.length + ? [ + el("tr", { + className: "m02-labrow__sum", + children: [ + el("td", { text: "" }), + el("td", { text: tc("Prev_Sum") }), + el("td", { text: "" }), + ...COSTS.map((c) => + moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait), + ), + ...(tools ? [el("td", { text: "" })] : []), + ], + }), + ] + : []; + const heads = [ + tc("Col_Order"), + tc("Col_Name"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(HEAD[c])), + ...(tools ? [tc("Col_Move")] : []), + ]; + return data.rows.length + ? table(heads, trs, foot) + : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); +} + +export interface MatTableData { + mats: LaborMat[]; + calc: LaborCalc | null; + fail?: string | null; +} + +/** 자재목록 표 — 보기 갈래는 `on` 없이 */ +export function matTable(data: MatTableData, on?: Omit): HTMLElement { + const wait = !data.calc && !data.fail; + const remove = on?.remove; + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + const trs = data.mats.map((mat, i) => { + const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); + const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량); + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-mat": mat.코드 }, + children: [ + el("td", { text: mat.코드 }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: mat.이름 ?? "" }), + ...(line?.까닭 + ? [el("small", { className: "m02-labrow__empty", text: line.까닭 })] + : []), + ], + }), + el("td", { text: mat.규격 ?? "" }), + el("td", { text: mat.단위 ?? "" }), + el("td", { + className: "m02-labrow__num", + text: qty, + attrs: { title: line?.수량출처 ?? "" }, + }), + el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }), + el("td", { className: "m02-labrow__num", text: show(line?.반영) }), + ...(remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: (e) => { + e.stopPropagation(); + remove(i); + }, + }), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const heads = [ + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Col_Unit"), + tc("Labor_Qty"), + tc("Labor_BasePrice"), + tc("Labor_AppliedSum"), + ...(remove ? [tc("Col_Move")] : []), + ]; + return data.mats.length + ? table(heads, trs, []) + : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 962d3eab9..5521c55d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -65,16 +65,48 @@ const TEXT = { Pick_None: ["찾은 로직 없음", "No logics found"], Pick_Close: ["닫기", "Close"], Pick_Blocked: ["막힘", "Blocked"], - Prev_Title: ["미리 보기 (보기만 · 저장 안 함)", "Preview (view only)"], - Prev_Inputs: ["넣을 값", "Inputs"], - Prev_Run: ["미리 보기 계산", "Run preview"], Prev_Labor: ["노무비", "Labor"], Prev_Material: ["재료비", "Material"], Prev_Expense: ["경비", "Expense"], Prev_Sum: ["합계", "Total"], - Prev_Row: ["로직", "Logic"], - Prev_Fail: ["못 셈", "Failed"], - Prev_Wait: ["미리 보기 계산을 누르면 값이 나옴", "Press the button to see values"], + // 일위대가 상세(39-3) — 구조물 열마다 하나 + Labor_Title: ["일위대가", "Unit price"], + Labor_Unit: ["단위", "Unit"], + Labor_Origin: ["본뜬 곳", "Copied from"], + Labor_Mats: ["자재목록", "Materials"], + Labor_NoMat: ["담은 자재 없음", "No materials yet"], + Labor_AddMat: ["자재 추가", "Add material"], + Labor_Qty: ["수량", "Quantity"], + Labor_Base: ["기준", "Base"], + Labor_Applied: ["반영", "Applied"], + Labor_BasePrice: ["기준 단가", "Base price"], + Labor_AppliedSum: ["반영 금액", "Applied amount"], + Labor_Calc_Wait: ["계산 중…", "Calculating…"], + Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], + Labor_Empty: ["빈 값 {n}", "{n} empty"], + Labor_Lost: [ + "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", + "Inputs no longer in the logic (dropped on save) — {value}", + ], + Labor_Code: ["코드", "Code"], + Labor_Spec: ["규격", "Spec"], + Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"], + Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], + In_Title: ["넣을 값 — {value}", "Inputs — {value}"], + In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], + In_Memo: ["메모", "Memo"], + In_Qty: ["수량", "Quantity"], + In_QtyCell: ["면적산출 칸", "Area sheet cell"], + In_QtyHand: ["손 입력", "Manual"], + In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], + In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], + In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"], + In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"], + In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"], + In_Ok: ["확인", "OK"], + In_Cancel: ["취소", "Cancel"], + In_Close: ["닫기", "Close"], + In_Loading: ["입력 목록 받는 중…", "Loading inputs…"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 0944f8d6d..83bdf69ea 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -5,7 +5,7 @@ company 읽기 = 같은 회사 · 쓰기 = 회사 관리자(키 UC{회사}-######) personal 읽기 · 쓰기 = 본인(키 UP{사용자}-######) 저장소 = `M02_MasterTemplete_Combo_Store`. -담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 +담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. """ @@ -62,13 +62,6 @@ class PasteBody(BaseModel): 판: str = "" -class PreviewBody(BaseModel): - """저장 전 미리 보기 — `combo` 는 조합 줄 통째 또는 그 층 키 · `inputs` 는 로직마다.""" - - combo: dict[str, Any] | str - inputs: dict[str, dict[str, Any]] = {} - - @router.get("/combos/all") def get_all(session: Session = Depends(verify_session)) -> list[dict]: """고르기(use-combo · 갈라 내기) — 세 층 `[{layer, owner, 판, 폴더, 줄}]` · 회사 없음 빈 층.""" @@ -193,17 +186,6 @@ def post_paste( return _call(folder, combo_copy.paste, source, body.model_dump()) -@router.post("/layers/{layer}/combos/preview") -def post_preview( - layer: Layer, - body: PreviewBody, - owner: str | None = None, - session: Session = Depends(verify_session), -): - """보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 · 읽기 권한.""" - return _call(_root(session, layer, owner), combos.preview, body.combo, body.inputs) - - # ── 담을 로직(읽기만) — M01 `/logics` · `/subs?group=로직` · `/logic` · `/logic/auto` 모양 ── def _read(fn, *args): try: diff --git a/resources/tester/test_m01_combo.py b/resources/tester/test_m01_combo.py index 6c793c1e2..0b747fe4d 100644 --- a/resources/tester/test_m01_combo.py +++ b/resources/tester/test_m01_combo.py @@ -25,6 +25,7 @@ from fastapi.testclient import TestClient from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Router as router_module from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as combos_router sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) @@ -285,6 +286,12 @@ def test_메모가_글자_아니면_막는다(client: TestClient) -> None: # ── 미리 보기 ───────────────────────────────────────────────────────── +# M02 미리 보기 길은 39-3 에 은퇴(일위대가 상세 = calc 길) — 같은 계산(`combo_preview`)을 바로 부름 +def _preview(body: dict) -> dict: + got = combo_store.combo_preview(body["combo"], body["inputs"]) + return json.loads(json.dumps(got, default=str, ensure_ascii=False)) + + def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별로_같다(client: TestClient) -> None: key = _make(client)["key"] each = {k: _inputs(k) for k in SAMPLE} @@ -293,7 +300,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 for name in ("노무비", "재료비", "경비", "계") } - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": each}) + got = _preview({"combo": key, "inputs": each}) assert got["멈춤"] == [], got["멈춤"] assert [one["로직"] for one in got["줄"]] == list(SAMPLE) for name in ("노무비", "재료비", "경비", "계"): @@ -308,7 +315,7 @@ def test_미리보기_합계가_담은_로직들의_시험_계산과_비목별 def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하지_않는다(client: TestClient) -> None: before = _list(client) row = {"이름": "저장 안 한 것", "담은로직": [{"로직": STACK, "메모": None}]} - got = _post(client, f"{SYS}/preview", {"combo": row, "inputs": {STACK: _inputs(STACK)}}) + got = _preview({"combo": row, "inputs": {STACK: _inputs(STACK)}}) assert got["멈춤"] == [] and Decimal(str(got["계"])) > 0 assert _list(client) == before # 아무것도 안 생김 @@ -316,7 +323,7 @@ def test_미리보기는_저장_전_조합_줄도_받고_아무것도_저장하 def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: TestClient) -> None: key = _make(client, keys=(STACK, LAYER))["key"] only = {STACK: _inputs(STACK)} # LAYER 입력은 안 줌 — 그 줄만 멈춤 - got = _post(client, f"{SYS}/preview", {"combo": key, "inputs": only}) + got = _preview({"combo": key, "inputs": only}) stuck = [one for one in got["줄"] if not one["ok"]] assert len(stuck) == 1 and stuck[0]["로직"] == LAYER and stuck[0]["까닭"] assert len(got["멈춤"]) == 1 @@ -327,6 +334,6 @@ def test_못_도는_줄은_까닭만_적고_나머지_합계는_낸다(client: T def test_정본_로직은_조합을_만들어도_안_바뀐다(client: TestClient) -> None: before = _get(client, "/api/m01/logic", key=STACK) key = _make(client)["key"] - _post(client, f"{SYS}/preview", {"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) + _preview({"combo": key, "inputs": {k: _inputs(k) for k in SAMPLE}}) after = _get(client, "/api/m01/logic", key=STACK) assert after["version"] == before["version"] and after["logic"] == before["logic"] diff --git a/resources/tester/test_m02_labor_diagram.py b/resources/tester/test_m02_labor_diagram.py index bc78539fe..9e07ca7b4 100644 --- a/resources/tester/test_m02_labor_diagram.py +++ b/resources/tester/test_m02_labor_diagram.py @@ -1,4 +1,4 @@ -"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력. +"""일위대가 다이어그램 값 만들기(`Labor_Diagram.ts` 의 `buildLaborDiagram` · 39-3) — calc 줄 → 카드 출력. 화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 · 금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠. @@ -39,7 +39,7 @@ function load(f) { } const m = load(file); const jobs = JSON.parse(fs.readFileSync(0, "utf8")); -process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j)))); +process.stdout.write(JSON.stringify(jobs.map((j) => m.buildLaborDiagram(j)))); """ @@ -57,54 +57,76 @@ def run(jobs: list[dict]) -> list: return json.loads(done.stdout) -ROWS = [ +LOGICS = [ { - "key": "GC000001", - "name": "돌쌓기", - "unit": "원/㎡", - "inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}], + "로직": "GC000001", + "이름": "돌쌓기", + "결과단위": "원/㎡", + "입력": [{"이름": "높이", "단위": "m"}, {"이름": "지역", "자동값": "서울"}], }, - { - "key": "GF000002", - "name": "작업량", - "unit": "㎥/시간", - "inputs": [{"name": "지역", "unit": ""}], - }, - {"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []}, + {"로직": "GF000002", "이름": "작업량", "결과단위": "㎥/시간", "입력": [{"이름": "지역"}]}, + {"로직": "GC000003", "이름": "막힌 로직", "결과단위": "원/m", "입력": []}, ] +ROWS = [ + {"로직": "GC000001", "입력값": {"높이": 4}}, + {"로직": "GF000002", "입력값": {"지역": "부산"}}, + {"로직": "GC000003", "입력값": {}}, +] +MONEY = {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} +APPLIED = {"노무비": 300, "재료비": 150, "경비": 0, "계": 450} -def test_돈_값_멈춤_세_갈래(): - lines = [ - {"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150}, - {"ok": True, "갈래": "결과", "결과": 8.946}, - {"ok": False, "까닭": "표에 맞는 줄이 없음"}, - ] - (got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}]) +def _line(key: str, **more) -> dict: + return {"로직": key, "기준": None, "반영": None, "까닭": None, **more} + + +def test_돈_값_멈춤_세_갈래_반영이_있으면_반영(): + calc = { + "줄": [ + _line("GC000001", 기준=MONEY, 반영=APPLIED), + _line("GF000002", 결과=8.946), + _line("GC000003", 까닭="표에 맞는 줄이 없음"), + ], + "자재": [], + } + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": calc, "mats": 0}]) outs = [x["out"] for x in got["logics"]] - assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} + assert outs[0] == APPLIED and got["logics"][0]["basis"] == "반영" assert outs[1] == {"값": 8.946} assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"} -def test_넣은_값은_로직별_빈_값은_빈_글(): - (got,) = run( - [ - { - "rows": ROWS, - "values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}}, - "lines": None, - } - ] - ) +def test_수량_없으면_기준(): + calc = {"줄": [_line("GC000001", 기준=MONEY)], "자재": []} + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert got["logics"][0]["out"] == MONEY and got["logics"][0]["basis"] == "기준" + + +def test_넣은_값은_로직별_빈_값은_자동값_또는_빈_글(): + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) first, second, third = got["logics"] - assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")] + assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "서울")] + assert first["inputs"][1].get("auto") is True and "auto" not in first["inputs"][0] assert first["inputs"][0]["unit"] == "m" - assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")] + assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "부산")] assert third["inputs"] == [] def test_아직_안_셌으면_출력_없음(): - (got,) = run([{"rows": ROWS, "values": {}, "lines": None}]) + (got,) = run([{"rows": ROWS, "logics": LOGICS, "calc": None, "mats": 0}]) assert [x["out"] for x in got["logics"]] == [None, None, None] assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"] + + +def test_자재목록은_카드_하나_재료비로(): + calc = { + "줄": [_line("GC000001", 기준=MONEY)], + "자재": [{"기준단가": 1000, "반영": 5000}, {"기준단가": 200, "반영": None}], + } + (got,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 2}]) + card = got["logics"][-1] + assert card["material"] is True and card["inputs"] == [] + assert card["out"] == {"노무비": 0, "재료비": 5200, "경비": 0, "계": 5200} + assert card["basis"] == "기준" # 반영 없는 자재가 있음 + (none,) = run([{"rows": ROWS[:1], "logics": LOGICS, "calc": calc, "mats": 0}]) + assert not any(x.get("material") for x in none["logics"]) From 3ad7678e15cb2bc9a2eba85ac53629928487af53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:44:57 +0900 Subject: [PATCH 23/24] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EC=9E=AC=EB=AA=A9=EB=A1=9D=20[=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=B6=94=EA=B0=80]=20=E2=80=94=20M01=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=ED=95=98=EB=82=98=EC=94=A9(M02=5FMaterial=5FPick)?= =?UTF-8?q?=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EC=9E=90=EC=9E=AC=20=EB=91=90?= =?UTF-8?q?=20=EB=B2=88=20=EB=A7=89=EC=9D=8C(39-3=20C5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor_Detail.ts | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index d56ecc546..dfddeb889 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -17,7 +17,6 @@ import { fetchLogicInputs, type LaborCalc, type LaborDoc, - type LaborMat, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { @@ -30,6 +29,7 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; +import { pickMaterial } from "./M02_Material_Pick"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; @@ -67,8 +67,6 @@ export interface LaborDetailOptions { onChanged: (dirty: boolean) => void; /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */ goArea: (() => void) | null; - /** 자재 하나 고르기(`M02_Material_Pick`) — 없으면 [자재 추가] 없음 */ - pickMaterial?: (done: (m: Omit) => void) => void; } export interface LaborDetailHandle { @@ -253,24 +251,21 @@ export function mountLaborDetail( void loadLogics().then(changed); }), }); - const pick = opt.pickMaterial; - const addMat = pick - ? createButton({ - label: tc("Labor_AddMat"), - variant: "filled", - size: "sm", - onClick: () => - pick((m) => { - if (cur.자재.some((x) => x.코드 === m.코드)) - return showToast( - tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), - "error", - ); - cur.자재.push({ ...m, 수량: null }); - changed(); - }), - }) - : null; + const addMat = createButton({ + label: tc("Labor_AddMat"), + variant: "filled", + size: "sm", + onClick: () => + pickMaterial((m) => { + if (cur.자재.some((x) => x.코드 === m.코드)) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }), + }); const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); unit.input.value = cur.단위 ?? ""; From 5b4aed1b2088bb4af068028d5e264b9bbf73a9c9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 18:03:54 +0900 Subject: [PATCH 24/24] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=83=81=EC=84=B8=EB=A5=BC=20UI=5FMain=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20labor=20=EB=A1=9C=20=EC=97=B6=20=C2=B7=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B0=88=EB=9E=98=20=EB=B6=80=ED=92=88=20=C2=B7=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=EB=A7=8C=20=EB=B0=94=EB=80=8C=EB=A9=B4=20?= =?UTF-8?q?=EC=A7=9D=20=EC=85=88=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B1=ED=95=A8(39-3=20C7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Api_Fetch.ts | 2 +- .../M02_MasterTemplete_Labor.css | 13 ++ .../M02_MasterTemplete_Labor_Api.ts | 90 +++++++---- .../M02_MasterTemplete_Labor_Detail.ts | 81 +++++----- .../M02_MasterTemplete_Labor_Diagram.ts | 10 +- .../M02_MasterTemplete_Labor_Input.ts | 7 +- .../M02_MasterTemplete_Labor_Rows.ts | 20 ++- .../M02_MasterTemplete_Labor_View.ts | 152 ++++++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 15 ++ 9 files changed, 303 insertions(+), 87 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6ac97e28e..0c89cac51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · * 「면적산출」 = area · 「재료산출」 = material(둘 다 구조물집계표 열마다 한 통합문서) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "material"; +export type Kind = "table" | "drawing" | "structure" | "area" | "material" | "labor"; export interface TemplateInfo { 종류: Kind; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 6f435b42d..a5a234504 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -123,3 +123,16 @@ gap: var(--spacing-8); margin-top: var(--spacing-12); } + +/* 보기 갈래(면적산출 오른쪽 패널 · 일위대가 카드) */ +.m02-labview { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-labview__title { + margin: 0; + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 026566821..6a49e2f5d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,6 +12,7 @@ import { type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { LaborCalc } from "./M02_MasterTemplete_Labor_Calc"; import { getActiveLayer, getActiveOwner, @@ -254,39 +255,64 @@ export interface LaborDoc { 자재: LaborMat[]; 계산값?: unknown; } -export interface LaborCost { - 노무비: number; - 재료비: number; - 경비: number; - 계: number; +/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ +export function normalizeLabor(doc: unknown, column: string): LaborDoc { + const d = (doc && typeof doc === "object" ? doc : {}) as Partial; + return { + ...d, + 종류: "일위대가", + 열: d.열 ?? column, + 단위: d.단위 ?? null, + 담은로직: (d.담은로직 ?? []).map((r) => ({ + 로직: r.로직, + 메모: r.메모 ?? null, + 입력값: r.입력값 ?? {}, + 수량: r.수량 ?? null, + })), + 자재: (d.자재 ?? []).map((m) => ({ + 코드: m.코드, + 이름: m.이름 ?? null, + 규격: m.규격 ?? null, + 단위: m.단위 ?? null, + 수량: m.수량 ?? null, + })), + }; } -/** calc 줄 — 기준 = 결과단위 그대로 · 반영 = 기준 × 수량 ÷ 배(수량 없거나 돈 아닌 로직은 null) */ -export interface LaborCalcLine { - 로직: string; - 분모: string | null; - 배: number | null; - 수량: number | null; - 수량출처: string | null; - 기준: LaborCost | null; - 반영: LaborCost | null; - 결과?: number | null; - 까닭: string | null; -} -export interface LaborCalcMat { - 코드: string; - 기준단가: number | null; - 수량: number | null; - 수량출처: string | null; - 반영: number | null; - 까닭: string | null; -} -export interface LaborCalc { - 줄: LaborCalcLine[]; - 자재: LaborCalcMat[]; - 기준합: Partial; - 반영합: Partial; + +/** 면적산출 문서의 알약 → {칸, 값}(식 칸 = 서버 `계산값` · 그 밖 = 칸 값 · 수만) — 서버 `area_values` 와 같은 규칙 */ +export function areaLinks(area: unknown): Map { + const out = new Map(); + const doc = (area && typeof area === "object" ? area : {}) as { + 시트?: { + id?: string; + 칸?: Record; + 연결?: { 키?: string; 칸?: string }[]; + }[]; + 계산값?: Record>; + }; + for (const sheet of doc.시트 ?? []) { + const solved = doc.계산값?.[sheet.id ?? ""] ?? {}; + for (const link of sheet.연결 ?? []) { + if (!link.키 || !link.칸) continue; + const raw = solved[link.칸]?.수 ?? sheet.칸?.[link.칸]?.값; + const n = + raw === null || raw === undefined || raw === "" + ? NaN + : Number(String(raw).replace(/,/g, "")); + out.set(link.키, { 칸: link.칸, 값: Number.isFinite(n) ? n : null }); + } + } + return out; } +/** calc 답 모양 · 수량 · 반영 짝 셈 = `M02_MasterTemplete_Labor_Calc.ts`(sub7 · ↔ `M02_Labor_Doc.py`) */ +export type { + CalcLine as LaborCalcLine, + CalcMaterial as LaborCalcMat, + LaborCalc, + Money as LaborCost, +} from "./M02_MasterTemplete_Labor_Calc"; + /** 지금 층이면 `owner` 를 붙인 층 길(`/layers/{층}/labor/{열}…`) */ const laborAt = (layer: Layer, column: string, tail: string): string => { const owner = layer === getActiveLayer() ? getActiveOwner() : null; @@ -294,13 +320,13 @@ const laborAt = (layer: Layer, column: string, tail: string): string => { return `/layers/${layer}/labor/${enc(column)}${tail}${q}`; }; -/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = 칸 값 바꿔 끼우기 */ +/** 계산(서버 · 저장 안 함) — `doc` = 화면 캐시(없으면 저장본) · `수량` = {알약 키: 칸 값} 바꿔 끼우기 */ export const calcLabor = ( layer: Layer, column: string, doc: LaborDoc | null, 수량?: Record, -): Promise => +): Promise => request(laborAt(layer, column, "/calc"), "POST", { ...(doc ? { doc } : {}), ...(수량 ? { 수량 } : {}), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index dfddeb889..33f599bfe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -11,10 +11,13 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; -import type { ItemLogic, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { + areaLinks, calcLabor, fetchLogicInputs, + normalizeLabor, type LaborCalc, type LaborDoc, type LaborRow, @@ -26,7 +29,7 @@ import { } from "./M02_MasterTemplete_Labor_Diagram"; import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { logicTable, matTable, qtyText } from "./M02_MasterTemplete_Labor_Rows"; +import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; @@ -35,30 +38,6 @@ import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; -/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */ -export function normalizeLabor(doc: unknown, column: string): LaborDoc { - const d = (doc && typeof doc === "object" ? doc : {}) as Partial; - return { - ...d, - 종류: "일위대가", - 열: d.열 ?? column, - 단위: d.단위 ?? null, - 담은로직: (d.담은로직 ?? []).map((r) => ({ - 로직: r.로직, - 메모: r.메모 ?? null, - 입력값: r.입력값 ?? {}, - 수량: r.수량 ?? null, - })), - 자재: (d.자재 ?? []).map((m) => ({ - 코드: m.코드, - 이름: m.이름 ?? null, - 규격: m.규격 ?? null, - 단위: m.단위 ?? null, - 수량: m.수량 ?? null, - })), - }; -} - export interface LaborDetailOptions { layer: Layer; column: string; @@ -88,6 +67,11 @@ export function mountLaborDetail( let seq = 0; let timer = 0; let alive = true; + /** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */ + let links = new Map(); + const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); + const given = (): Record => + Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); const logicHost = el("div"); const matHost = el("div"); @@ -126,14 +110,14 @@ export function mountLaborDetail( const draw = (): void => { logicHost.replaceChildren( logicTable( - { rows: cur.담은로직, logics, calc, fail }, + { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, ), ); matHost.replaceChildren( matTable( - { mats: cur.자재, calc, fail }, + { mats: cur.자재, calc, fail, cells: cells() }, edit ? { open: openMat, remove: removeMat } : { open: openMat }, ), ); @@ -160,9 +144,20 @@ export function mountLaborDetail( }, 150); }; - const changed = (): void => { + /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ + const changed = (qtyOnly = false): void => { opt.onChanged(JSON.stringify(cur) !== saved); - recalc(); + if (qtyOnly && calc) { + calc = settle(calc, cur, given()); + draw(); + } else recalc(); + }; + + /** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */ + const linkedText = (pill: string | null | undefined, unit: string): string | null => { + const at = pill ? links.get(pill) : undefined; + if (!at) return null; + return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`; }; /** 아직 안 받은 로직 입력 목록만 받음 */ @@ -203,16 +198,18 @@ export function mountLaborDetail( memo: row.메모, seat: { qty: row.수량, - linked: line && line.수량출처 !== "손" ? qtyText(line, line.분모 ?? "") || null : null, + linked: linkedText(line?.알약, line?.분모 ?? ""), unit: line?.분모 ?? "", }, readOnly: !edit, goArea: opt.goArea, done: (next) => { - row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값); + row.입력값 = values; row.메모 = next.메모; row.수량 = next.수량; - changed(); + changed(qtyOnly); }, }); } @@ -224,17 +221,14 @@ export function mountLaborDetail( `${mat.코드} ${mat.이름 ?? ""}`.trim(), { qty: mat.수량, - linked: - line && line.수량출처 && line.수량출처 !== "손" && line.수량 !== null - ? `${line.수량출처} = ${line.수량}` - : null, + linked: linkedText(line?.알약, mat.단위 ?? ""), unit: mat.단위 ?? "", }, !edit, opt.goArea, (qty) => { mat.수량 = qty; - changed(); + changed(true); }, ); } @@ -248,7 +242,7 @@ export function mountLaborDetail( if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); - void loadLogics().then(changed); + void loadLogics().then(() => changed()); }), }); const addMat = createButton({ @@ -308,7 +302,14 @@ export function mountLaborDetail( }), ); draw(); - void loadLogics().then(() => { + // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 + void Promise.all([ + loadLogics(), + readTemplate(opt.layer, "area", opt.column, null).then( + (got) => (links = areaLinks(got.문서)), + () => undefined, + ), + ]).then(() => { if (alive) recalc(); }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 9125b3042..fb987d593 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -105,11 +105,11 @@ export interface LaborDiagramSource { 줄: { 로직: string; 기준: Money | null; - 반영: Money | null; - 결과?: number | null; - 까닭: string | null; + 반영?: Money | null; + 결과?: unknown; + 까닭?: string | null; }[]; - 자재: { 기준단가: number | null; 반영: number | null }[]; + 자재: { 기준단가: number | null; 반영?: number | null }[]; } | null; /** 담은 자재 수 — 0 이면 자재 카드 없음 */ mats: number; @@ -157,7 +157,7 @@ export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { unit: "", inputs: [], out: mats ? { 노무비: 0, 재료비: sum, 경비: 0, 계: sum } : null, - ...(mats ? { basis: mats.every((m) => m.반영 !== null) ? "반영" : "기준" } : {}), + ...(mats ? { basis: mats.every((m) => typeof m.반영 === "number") ? "반영" : "기준" } : {}), material: true, }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index e5d913bff..13afe0850 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -137,7 +137,12 @@ export function openLaborInput(opt: LaborInputOptions): void { if (isBad) bad.add(input.이름); else bad.delete(input.이름); if (value === null) delete draft[input.이름]; - else draft[input.이름] = value; + // 고르기 · 범위 없는 칸도 수로 읽히면 수로 둠(M01 넣을 값과 같게) + else + draft[input.이름] = + typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) + ? Number(value) + : value; }); field.control.disabled = opt.readOnly; field.control.setAttribute("data-labor-input", input.이름); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index f8c637ccc..9e3bc3fbe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -33,16 +33,17 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」 · 없으면 빈 글 */ +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */ export function qtyText( - line: { 수량: number | null; 수량출처: string | null } | undefined, + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, + cells: ReadonlyMap = new Map(), ): string { if (!line || line.수량 === null || line.수량 === undefined) return ""; const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; - if (!line.수량출처 || line.수량출처 === "손") - return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; - return `${tc("Labor_Qty")} ${line.수량출처} = ${v}`; + if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; + const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); + return `${tc("Labor_Qty")} ${cell} = ${v}`; } /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */ @@ -100,6 +101,8 @@ export interface LogicTableData { /** calc 답 — null = 계산 중 · 못 셈은 `fail` */ calc: LaborCalc | null; fail?: string | null; + /** 알약 키 → 이은 면적산출 칸(「B6」) */ + cells?: ReadonlyMap; } /** 담은 로직 표 — 보기 갈래는 `on` 없이 */ @@ -114,7 +117,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const line = lineOf(row.로직); const unit = logic?.결과단위 ?? ""; const values = valueLine(logic?.입력 ?? [], row.입력값); - const qty = qtyText(line, line?.분모 ?? ""); + const qty = qtyText(line, line?.분모 ?? "", data.cells); const note = [values.text, qty].filter(Boolean).join(" · "); const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = (label: string, run: () => void): HTMLElement => @@ -210,6 +213,7 @@ export interface MatTableData { mats: LaborMat[]; calc: LaborCalc | null; fail?: string | null; + cells?: ReadonlyMap; } /** 자재목록 표 — 보기 갈래는 `on` 없이 */ @@ -240,7 +244,7 @@ export function matTable(data: MatTableData, on?: Omit): HT el("td", { className: "m02-labrow__num", text: qty, - attrs: { title: line?.수량출처 ?? "" }, + attrs: { title: qtyText(line, mat.단위 ?? "", data.cells) }, }), el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }), el("td", { className: "m02-labrow__num", text: show(line?.반영) }), @@ -271,7 +275,7 @@ export function matTable(data: MatTableData, on?: Omit): HT tc("Labor_Code"), tc("Col_Name"), tc("Labor_Spec"), - tc("Col_Unit"), + tc("Labor_Unit"), tc("Labor_Qty"), tc("Labor_BasePrice"), tc("Labor_AppliedSum"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts new file mode 100644 index 000000000..35d805140 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_View.ts + * 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영). + * 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`). + * · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만) + * · `setQty({알약 키: 칸 값})` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 바로 곱함 + * (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」) + * · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + areaLinks, + calcLabor, + fetchLogicInputs, + normalizeLabor, + type LaborCalc, + type LaborDoc, +} from "./M02_MasterTemplete_Labor_Api"; +import { settle } from "./M02_MasterTemplete_Labor_Calc"; +import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_MasterTemplete_Labor.css"; + +export interface LaborViewRow { + kind: "logic" | "material"; + /** 로직 키 · 자재 코드 */ + key: string; + /** 면적산출 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — calc 전이면 null */ + pill: string | null; + name: string; + unit: string; +} + +export interface LaborViewHandle { + root: HTMLElement; + /** 그 열 일위대가를 읽어 그림 — `수량` = 저장 안 한 면적 칸 값(없으면 저장된 면적산출) */ + show: (layer: Layer, column: string | null, 수량?: Record) => Promise; + /** 면적 칸 값이 바뀜(알약 값 전부) — 받은 기준에 바로 곱함(서버 안 부름) */ + setQty: (수량: Record) => void; + /** 지금 문서(없으면 null) */ + doc: () => LaborDoc | null; + destroy: () => void; +} + +export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle { + const root = el("div", { className: "m02-labview" }); + const logics = new Map(); + let doc: LaborDoc | null = null; + let calc: LaborCalc | null = null; + let fail: string | null = null; + let cells = new Map(); + let seq = 0; + + const paint = (): void => { + if (!doc) return; + const d = doc; + const pick = (row: LaborViewRow): (() => void) | undefined => + onRow ? () => onRow(row) : undefined; + const logicOpen = (i: number): void => { + const row = d.담은로직[i]; + const line = calc?.줄.find((l) => l.로직 === row.로직); + pick({ + kind: "logic", + key: row.로직, + pill: line?.알약 ?? null, + name: logics.get(row.로직)?.이름 ?? row.로직, + unit: line?.분모 ?? "", + })?.(); + }; + const matOpen = (i: number): void => { + const mat = d.자재[i]; + const line = calc?.자재.find((m) => m.코드 === mat.코드); + pick({ + kind: "material", + key: mat.코드, + pill: line?.알약 ?? null, + name: mat.이름 ?? mat.코드, + unit: mat.단위 ?? "", + })?.(); + }; + root.replaceChildren( + el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }), + logicTable( + { rows: d.담은로직, logics, calc, fail, cells }, + onRow ? { open: logicOpen } : undefined, + ), + el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }), + matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined), + ); + }; + + const show = async ( + layer: Layer, + column: string | null, + 수량?: Record, + ): Promise => { + const mine = ++seq; + doc = null; + calc = null; + fail = null; + if (!column) return void root.replaceChildren(); + root.replaceChildren(el("p", { className: "m02-labrow__muted", text: tc("Loading") })); + try { + const got = await readTemplate(layer, "labor", column, null); + if (mine !== seq) return; + doc = normalizeLabor(got.문서, column); + const want = doc.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + const [inputs, area] = await Promise.all([ + fetchLogicInputs(want), + readTemplate(layer, "area", column, null).then( + (a) => a.문서, + () => null, + ), + ]); + if (mine !== seq) return; + for (const l of inputs.로직) logics.set(l.로직, l); + cells = new Map([...areaLinks(area)].map(([k, v]) => [k, v.칸])); + paint(); // 금액 「…」 + calc = await calcLabor(layer, column, null, 수량); + } catch (error) { + if (mine !== seq) return; + if (!doc) { + root.replaceChildren( + el("p", { + className: "m02-labrow__empty", + text: error instanceof Error ? error.message : tc("Load_Failed"), + }), + ); + return; + } + fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); + } + if (mine === seq) paint(); + }; + + return { + root, + show, + setQty: (수량) => { + if (!doc || !calc) return; + calc = settle(calc, doc, 수량); + paint(); + }, + doc: () => doc, + destroy: () => { + seq += 1; + root.remove(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 522751dc9..4675ae01c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -68,6 +68,7 @@ const NUMBER_FIELD: Partial> = { structure: "도번", area: "면적번", material: "재료번", + labor: "일위대가번", }; // 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음 // 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를 @@ -451,6 +452,20 @@ export function createMain( editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy }; return; } + if (at.kind === "labor") { + // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 + const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + editor = mountLaborDetail(host, doc, { + layer: at.layer, + column: at.name, + readOnly, + onChanged: (d) => (dirty = d && !readOnly), + goArea: async () => { + if (await confirmLeave()) void open({ ...at, kind: "area" }); + }, + }); + return; + } if (at.kind === "area" || at.kind === "material") { // 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을 // 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)