From 24068f65e49cdcaead7dae867076464b87f00e2b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:01:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(M01):=20=EC=9B=90=EA=B0=80=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=EB=8B=B4=EC=9D=8C(=ED=95=A9=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- docs/raw/M01_쉬운단가/시험판_단추_점검.md | 110 +++++++++++++++++++ docs/raw/M01_쉬운단가/원가다이어그램_정함.md | 40 +++++++ 2 files changed, 150 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/시험판_단추_점검.md create mode 100644 docs/raw/M01_쉬운단가/원가다이어그램_정함.md diff --git a/docs/raw/M01_쉬운단가/시험판_단추_점검.md b/docs/raw/M01_쉬운단가/시험판_단추_점검.md new file mode 100644 index 000000000..f3ed3d65c --- /dev/null +++ b/docs/raw/M01_쉬운단가/시험판_단추_점검.md @@ -0,0 +1,110 @@ +# 시험판 단추 공용 양식 점검 (35-5 · sub7 · 확인만) + +대상 = `M01_MasterData/EasyCalc` · `LogicBuilder` · `SheetLogic` 의 누르는 것 전부. 화면 수치 = 내 포트 5182 ORCA · `getComputedStyle`(전환 끔 · 밝음 · 어두움) · 판정 직전 `/api/auth/session` 200. + +## 0. 한눈에 + +- **날 단추(`el("button")` 직접) 23 자리** — EasyCalc 5 · LogicBuilder 12 · SheetLogic 6(목록 줄 8 · 칩 11 · 「석공 ▾」 2 · 그림 알약 1 · 글 링크 1). 모두 자체 css 꼴(회색 바탕 · 16px · 테두리 1.75px · 둥글기 8 · 999 · **2px**). +- **공용 `createButton` 을 쓰되 css 가 모양을 덮음 4 곳** — 작은(sm 18px · 12px)을 44px · 16px 로(EasyCalc Cards · LB Steps · SL Panel) · 큰 시작 단추 56px(세 시작 화면). +- **한 화면 안 크기가 섞임** — SL 「+ 상자」 「상자 지우기」 18px 옆에 시트 판 단추 44px · LB 「문장 / 격자」 「빈틈 검사」 18px 옆에 산출근거 단추 44px. +- 색은 모두 테마 토큰 — 어두움에서 자체 꼴도 바뀜(SL 상자 · 알약 · 시트 판 알약 확인) · 문제는 **모양(높이 · 둥글기 · 글 크기 · 테두리)**. +- 시험판 들어가는 단추 셋(「쉬운 단가산출(시험)」 「로직 만들기(시험)」 「시트 로직(시험)」)은 공용 filled md 그대로 — 옆 「식으로 새로 만들기」와 같음(32px · 8px · 14px). + +## 1. 공용 양식 + +| 공용 꼴 | 어디 | 모양(밝음 실측) | 쓰임 | +| --- | --- | --- | --- | +| `createButton` md | `ui_template_elements.ts:43` · 꼴 `ui_template_elements_styles.ts:17-80` | 높이 32 · 둥글기 8px(`--radius-buttons`) · 14px · 500 · 테두리 1px | 모든 단추 | +| `createButton` sm | 같음 `.ui-btn--sm` | 높이 18 · 12px · 안쪽 2px 8px | 표 칸 · 카드 머리 작은 단추 | +| 종류 | `filled` 바탕 rgb(38,17,74) 흰 글 · `ghost` 투명 + 보라 테두리 · `pill` 투명 · 둥글기 1440 · `is-active` 때 mist-violet · `danger` 빨강 · `glass` 3D 위 | 어두움 = filled rgb(185,154,255) · ghost 글 같은 보라 | 탭 · 보기 바꿈 = `pill` + `is-active`(전용 탭 부품 없음) | +| `createTag` | `ui_template_elements.ts:217` · `.ui-tag` | 둥글기 1440 · 12px · 안쪽 4px 16px · accent · neutral · success · warning · danger | 누르지 않는 글 알약 | +| 목록 줄 `.ui-sidebar-row` | `ui_template_overlay.css:384-437` (M01 · M02 공용) | 투명 바탕 · 14px · 둥글기 8 · 안쪽 4px 8px · hover paper · `is-active` mist-violet | 왼쪽 목록 · 찾기 결과 줄 | +| 나무 줄 `createTreeRow` | `ui_template_tree.ts:118` | `.ui-sidebar-row` + 화살표 · 아이콘 · 수 | M02 나무 | +| 모달 `openModal` | `ui_template_modal.ts:20` | 머리 줄 닫기 = ghost md | 세 시험판 모두 씀 ✔ | +| (M01 자체) 알약 `pillButton` | `M01_MasterData_UI_LogicLab_Pill.ts:18` `.m01lab__pill` | M01 지금 화면 것 — ui_template 아님 | 수량 칸 안 표 조각 | +| (엔진 공용) 시트 도구막대 `ss-brush` 등 | `A00_Common/spreadsheet` | 25px · 13px · 테두리 없음 | M02 시트와 같음 — 그대로 | + +## 2. 누르는 것 전수 + +✔ = 공용 그대로 · ▲ = 공용인데 css 가 모양을 덮음 · ✖ = 날 단추(자체 꼴) + +### EasyCalc (쉬운 단가산출) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측(높이 · 둥글기 · 바탕 · 글) | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_EasyCalc_Entry.ts:177` | 시작 목록 줄 「돌쌓기 메쌓기(인력) · 내가 만든 것」 | ✖ `m01-easycalc__item` | 42 · 8 · rgb(246,247,250) · 16px · 테두리 0.9 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Entry.ts:565` | 빈 종이로 새로 만들기 | ▲ filled + `m01-easycalc__big` | **56** · 8 · 16px | 없음(큰 크기 없음) | 그대로(초등학생 큰 시작 단추) — 공용에 큰 크기 하나 청하면 덮기 지움 | +| `M01_EasyCalc_Entry.ts` 위 띠 | 되돌리기 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 · 8 · 14px | — | 그대로 | +| `M01_EasyCalc_Cards.ts:273` | 「석공 ▾」(다시 고르기) | ✖ `m01-easycards-name--pick` | 44 · 8 · 회색 · 16px · 400 | `createButton` ghost | 공용 createButton(ghost)로 | +| `M01_EasyCalc_Cards.css:145` | ▲ ▼ 지움 · 숫자로 바꾸기 | ▲ ghost **sm** → css 44px · 16px | 44 · 8 · 16px | sm 18px | 공용 createButton(ghost · md)로 · sm 덮기 지움 | +| `M01_EasyCalc_Cards.css:179` | + 사람 · + 재료 · + 기계 · + 다른 일위대가 · + 덧붙임 | ▲ ghost md → 44px · 16px | 44 · 8 · 16px | md 32 | 그대로 or 공용 큰 크기(청함) | +| `M01_EasyCalc_Cond.ts:421` | 「표에서 찾은 값」 칩 | ✖ `m01-easycond__chip` 점선 · 1440 | 36 · 999 · 투명 · 16px | `pill` 단추 | 공용 createButton(pill)로 | +| `M01_EasyCalc_Pick.ts:288` | 사람 · 재료 · 기계 · 다른 일위대가(무엇) | ✖ `m01-easypick__kind` | (큰 칸) | `pill` + `is-active` | 공용 pill 로 | +| `M01_EasyCalc_Pick.ts:433` | 고르기 목록 줄 「보통인부 인 하루 172,068」 | ✖ `m01-easypick__row` | **52** · 8 · 회색 · **18px** · 테두리 1.75 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Pick.ts` 아래 | 넣기 · 닫기 | ✔ filled · ghost md | 32 | — | 그대로 | +| `M01_EasyCalc_Table.ts:630` | 표 줄 누르기(tr) | 표 칸 | — | 표 | 그대로(표 줄 고르기) | +| `M01_EasyCalc_Table.ts` 5 곳 | 표 고르기 단추 | ✔ ghost · sm | 18 | — | 그대로 | + +### LogicBuilder (로직 만들기) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_LB_Entry.ts:194` | 시작 목록 줄 「돌쌓기 메쌓기(인력) 표준 1㎡」 | ✖ `m01-lb__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Entry.ts:691` | 단계 안내로 새로 만들기 | ▲ filled + `m01-lb__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_LB_Entry.ts:585-593` | 문장 / 격자(보기 바꿈) | ✔ sm 인데 filled ↔ ghost 를 바꿔 끼움 | **18** · 8 · 12px | `pill` + `is-active`(탭 꼴) | 공용 createButton(pill) + `is-active` 로 | +| `M01_LB_Entry_Result.ts` | 빈틈 검사 · 지금 예시값으로 한 줄 더하기 · 모두 돌리기 | ✔ ghost sm | 18 · 12px | — | 공용 그대로 · 단 옆 산출근거 44px 와 크기 다름 → md 로 | +| `M01_LB_Source.ts:177` | 표 머리 역할 칩 「조건」 「결과」 「고정」 | ✖ `m01-lbsrc__chip` | 20 · 999 · 옅은 보라 / 초록 · 12px | `createTag`(accent · success) 꼴 + 누름 | 공용 pill sm 로(색은 tag 갈래) | +| `M01_LB_Source.ts:241` | 이 표를 쓰는 로직 N 개 | ✖ `m01-lbsrc__users` 글 단추 | 21 · 0 · 테두리 없음 · 14px | 없음(글 링크) | 그대로(글 링크) 또는 ghost sm | +| `M01_LB_Source.ts:286` | 표 칸 누르기(td) | 표 칸 | — | — | 그대로 | +| `M01_LB_Steps.ts:206` | 「석공 ▾」 | ✖ `m01-lbsteps-pick` | (EasyCalc 와 같은 꼴) | ghost | 공용 createButton(ghost)로 | +| `M01_LB_Steps.css:163` | 산출근거 안 단추 모두 | ▲ `.m01-lbsteps .ui-btn` → 44px · 16px | 44 | — | 공용 md 로(덮기 지움) | +| `M01_LB_Grid.ts:509` | 격자 줄 누르기(tr) | 표 줄 | — | — | 그대로 | +| `M01_LB_Wizard.ts:250 · 291` | 단계 안내 절 · 표 목록 줄 | ✖ `m01-lbw__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Wizard.ts:376` | 칸 역할 「조건 · 고정 · 결과」 | ✖ `m01-lbw__role` | (역할 칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard.ts:499` · `Wizard_Calc.ts:199 · 448 · 459` | 종류 · 틀 · 항목 연결 · 건너뛰기 칩 | ✖ `m01-lbw__chip` | (칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard_Calc.ts:172` | 주 고르기 | ✖ `m01-lbw__note` | (칩) | pill + is-active | 공용 pill 로 | + +### SheetLogic (시트 로직) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_SL_Entry.ts:178` | 시작 목록 줄(기존 로직 가져오기) | ✖ `m01-sl__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Entry.ts:297` | 새로 만들기(빈 판) | ▲ filled + `m01-sl__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_SL_Entry_Paper.ts` | 실행 취소 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 | — | 그대로 | +| `M01_SL_Diagram.ts` | + 상자 · 상자 지우기 | ✔ filled sm · danger sm | **18** · 12px | — | 공용 md 로(옆 시트 판 44px 와 맞춤) | +| `M01_SL_Diagram.ts:181` | 상자 양옆 알약 「뒷길이 25」 「? 단가 268,908」 | ✖ `m01-sldg__pill` | 20 · 999 · 입력 옅은 주황 / 출력 옅은 보라 / 빈 칸 점선 · 12px | 그림 요소(공용 없음) | 그대로(다이어그램 그림) — 둥글기 · 글은 tag 와 같음 | +| `M01_SL_Diagram.ts:249` | 상자 누르기(div) | 그림 상자 | 80~152 · 8 | — | 그대로 | +| `M01_SL_Panel.ts:323` | 시트 판 알약 「입력 · 뒷길이 (B2)」 | ✖ `m01-slpanel-pill` | **44** · 1440 · 회색 · 16px · 테두리 1.75 | pill 단추 · 그림 알약(20px)과 같은 것인데 꼴 다름 | 공용 pill 로 · 다이어그램 알약과 한 꼴 | +| `M01_SL_Panel.css:35` | 입력 알약으로 · 출력 알약으로 · 할증 · 나누기 · 만약 · 표에서 찾기 | ▲ ghost **sm** → 44px · 16px | 44 · 8 | sm 18 | 공용 md 로 · sm 덮기 지움 | +| `M01_SL_Table.ts:301 · 338` | 품셈 표 찾기 결과 줄 | ✖ `m01-sltbl__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Table.ts:509` | 칸 역할 「높이 조건 → 입력 알약」 | ✖ `m01-sltbl__role` | **63** · **2px**(토큰 밖) · 회색 · 16px · 테두리 1.75 실선 / 점선 | pill + is-active | 공용 pill 로 · 둥글기 2px 버림 | +| `M01_SL_Item.ts` | 종류 고르기 · 다시 고르기 · 단가 다시 가져오기 | ✔ ghost · sm | — | — | 그대로 | +| 시트 도구막대 | 서식… · 붓 · ↑ … | 엔진 `ss-brush` | 25 · 0 · 13px | M02 시트와 같음 | 그대로 | + +## 3. 어두움(실측 — SL) + +| 꼴 | 바탕 | 글 | +| --- | --- | --- | +| filled md · sm | rgb(185,154,255) | rgb(22,18,31) | +| ghost | 투명 | rgb(185,154,255) | +| danger sm | rgb(232,105,90) | rgb(22,18,31) | +| `m01-sldg__box` | rgb(30,25,48) | rgb(228,224,240) | +| `m01-sldg__pill`(입력) | 어두운 주황 | rgb(229,168,102) | +| `m01-slpanel-pill` | rgb(30,25,48) | rgb(228,224,240) | + +→ 자체 꼴도 토큰이라 어두움에서 깨지지 않음 · 고칠 것은 모양뿐. + +## 4. 고칠 안 (갈래별) + +1. **목록 줄 8 자리**(Easy 시작 · Easy 고르기 · LB 시작 · LB 단계 절 · 표 · SL 시작 · SL 표 찾기 둘) → 공용 `.ui-sidebar-row` 꼴. 초등학생용 큰 줄이 필요하면 ⬜ 공용에 「큰 줄」 갈래 하나를 청함(자체 css 로 덮지 않음). +2. **칩 · 역할 · 틀 11 자리 + 보기 바꿈 1**(Cond 칩 · Pick 종류 · LB 역할 칩 · 단계 안내 칩 넷 · 역할 · 주 · SL 칸 역할 · SL 시트 판 알약 + LB 문장/격자) → 공용 `createButton({ variant: "pill" })` + `is-active`. 역할 색(조건 보라 · 결과 초록)은 `ui-tag` 색 갈래를 빌림. +3. **「석공 ▾」 다시 고르기 2 곳**(Easy Cards · LB Steps) → 공용 `createButton({ variant: "ghost" })`. +4. **sm 를 44px 로 덮는 css 3 곳**(Easy Cards · LB Steps · SL Panel) → 덮기 지우고 공용 md(32px). 한 화면에 18 · 32 · 44 가 섞이지 않게 — SL 「+ 상자」 · LB 「빈틈 검사」 sm 도 md 로. +5. **큰 시작 단추 3 곳**(56px) → 그대로(설계 「초등학생 큰 단추」) · ⬜ 공용에 큰 크기(`lg`)를 더하면 그것으로 바꿈. +6. **그대로** — 다이어그램 상자 · 알약(그림 요소) · 표 줄 · 표 칸 누르기 · 시트 도구막대(엔진 공용) · 「이 표를 쓰는 로직 N 개」 글 링크. +7. SL 칸 역할 둥글기 **2px** 는 토큰(8 · 1440) 밖 — 2번에서 함께 바뀜. + +## 덧 + +- EasyCalc 시작 화면 「내가 만든 것」 줄이 **53 개** — 여러 창의 시험 저장이 `로직_자체.json`(공유 storage)에 남은 것으로 보임(점검 범위 밖 · 사실만). +- 측정 주의 — 보기 바꿈처럼 class 를 바꾼 직후 `getComputedStyle` 은 0.12 초 전환 중 값을 줌 → 전환을 끈 뒤 잼. diff --git a/docs/raw/M01_쉬운단가/원가다이어그램_정함.md b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md new file mode 100644 index 000000000..bc9680deb --- /dev/null +++ b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md @@ -0,0 +1,40 @@ +# 단가산출 다이어그램 시험판 — 정함 (PLAN 35-7 · 브레인 · 10-01 오후) + +사용자 기준(10-01 14시 무렵 · 요약): 다이어그램은 요소 종류로 칸을 나누지 않고 자유 배치 · 단가산출 로직 = 최종 원가를 내는 로직 · 상자는 입력 · 함수 · 출력 · 입력 상자 = M01 저장 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율) · 입력 상자를 누르면 그 정보 — M01 그룹 내용 · 요소 고르기 · M01 표 「추가」 화면을 다시 써서 추가 / 삭제 · 그룹 밖 정보(면적 · 길이 · 부피)는 변수 + 입력 정의 · 함수 상자도 변수 + 입력 정의 · 함수 상자는 받은 입력을 엑셀로 셈해 결과를 다음으로 · 함수 상자 하나 = 함수 하나(연속 연산 가능 · 출력은 하나) · 함수 상자 직렬 · 병렬 · 모든 상자 오른쪽 위에 단위(무차원 · 글 · 어려우면 「-」) · 출력 상자는 입력 · 함수 상자에 이어지고 어떤 비용인지(노무비 · 재료비 · 경비 …) 정함 · 품셈 분석보다 사용자와 브레인이 같이 쓸 프로그램이 먼저 · 상자 색 지정 · 엑셀 · 다이어그램 영역 더 높게 · 「상자와 선」 이름은 「다이어그램」 · 별도 단추로 시험판 페이지 · M02 폴더 · 기존 시험판과 다른 폴더 · 화면에 보일 임시 파일도 같은 폴더. + +## 기준 점검 — 빠진 곳 · 두 뜻인 곳과 브레인 정함(사용자가 바꿀 수 있음) + +1. **요율이 입력 그룹에 없음** — M01 에는 요율(19줄 · 공구손료 등 비율)이 있음 → 입력 그룹에 넣음. +2. **유가/전력 · 품셈재료** — M01 에서는 재료 그룹 안의 파일 → 입력 그룹에서 유가/전력은 따로 · 품셈재료는 재료 안. +3. **소요량 · 계수는 조건 표** — 요소 하나가 아니라 「조건 칸 → 값 칸」 표 → 입력 상자는 표(또는 고른 줄 하나)를 내보내고, 조건에 맞는 줄 찾기는 함수 상자의 엑셀 수식(XLOOKUP · INDEX/MATCH)이 함. +4. **함수 상자 「함수 하나 · 연속 연산 · 출력 하나」** — 상자 안에서는 여러 칸으로 이어 셈해도 되고, 밖으로 나가는 출력은 한 칸만. +5. **비용이 아닌 출력** — 시간당 작업량처럼 다른 로직이 받을 값도 있음 → 출력 종류에 「값(비용 아님)」 더함. +6. **최종 원가 합계** — 출력 상자들을 비목별로 모아 원가(노무비 · 재료비 · 경비 · 계 · 기준단위당)를 보이는 칸을 둠. +7. **「추가 / 삭제」 뜻** — 입력 상자에 넣을 요소를 M01 그룹 화면에서 골라 넣고 빼는 것. 그룹에 없는 새 요소는 M01 표 「추가」 화면 꼴로 만들되 마스터에 쓰지 않고 시험 폴더에 「사용자 요소」로 저장. +8. **단위** — 상자 오른쪽 위 = 그 상자 출력의 단위 · 입력 상자는 요소 단위를 따름(요소가 여럿이면 알약마다 · 상자 표시는 「-」) · 함수 · 출력 상자는 사용자가 정함(기본 「-」). +9. **색** — 종류별 기본 색(입력 · 함수 · 출력이 서로 다름) + 상자마다 색 바꾸기. +10. **들어가는 곳** — 별도 단추 「단가산출 다이어그램(시험)」 = M01 단가산출 막대의 다른 시험판 단추 옆 · 누르면 화면 전체를 덮는 시험판 페이지(닫기로 돌아감) — 길잡이(router)는 안 고침. +11. **폴더** — `M02_MasterTemplete/CostFlow/` · 저장 · 임시 파일 = 그 안 `_data/`(git 밖) · 화면에 처음 보일 견본 로직 = 그 안 `_sample/` · 폴더째 지우면 끝 · SheetLogic · LogicBuilder 폴더에 기대지 않음(필요한 코드는 새 폴더로 옮겨 고쳐 씀 · 엔진 · ui_template · M01 Api 는 가져다 씀). +12. **다이어그램** — 상자는 끌어 자유 배치(자리 저장) · 판 끌기 · 확대 / 축소 · 선은 굽은 선 · 고른 상자와 이어진 선만 진하게 · 「다이어그램」 이름 · 높이 크게(화면 위쪽 큰 영역) · 아래 고른 상자 판도 크게(안쪽 스크롤 없이). +13. **우선** — 품셈 1,469 로직 옮기기 · 분석은 안 함 · 사용자와 브레인이 같이 쓰는 프로그램 먼저. + +## 상자 · 선 약속 + +- 입력 상자: 갈래 = 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율 · 요율) 또는 변수 · 그룹이면 고른 요소마다 출력 알약(이름 · 값 · 단위) · 소요량 / 계수면 표(또는 한 줄) 알약 · 변수면 변수마다 알약(이름 · 갈래 숫자 / 선택 / 글 · 단위 · 범위 · 예시값). +- 함수 상자: 시트 하나 · 입력 알약(받는 칸) 여럿 · 변수(입력 정의) · **출력 알약 하나** · 단위. +- 출력 상자: 받는 자리 하나 · 비용 종류(노무비 · 재료비 · 경비 · 값) · 단위. +- 선: 입력 → 함수 · 입력 → 출력 · 함수 → 함수(직렬) · 여러 함수 → 한 함수(병렬 모음) · 함수 → 출력. 출력에서 나가는 선 없음 · 순환 막음. +- 계산: 우리 스프레드시트 엔진 한 벌 · 화면은 브라우저 · 저장은 서버가 같은 엔진 Node 로 다시 셈 · 입력 값(노임 · 단가 · 표)은 열 때 · 저장 때 마스터에서. + +## 파일 · 창 (`M02_MasterTemplete/CostFlow/` · 700줄 안) + +| 파일 | 창 | +| --- | --- | +| `M02_CF_Model.ts` · `M02_CF_Eval.ts` | sub7 — 타입 먼저 | +| `M02_CF_Router.py` · `M02_CF_Api.ts` · `.gitignore` | sub4 | +| `M02_CF_Diagram.ts` + css | sub8 | +| `M02_CF_FuncPanel.ts` + css | sub2 | +| `M02_CF_InputPanel.ts` + css | sub3 | +| `M02_CF_OutputPanel.ts` · `M02_CF_Summary.ts` + css | sub5 | +| `_sample/*.json` · `M02_CF_Sample.ts` | sub6 | +| `M02_CF_Entry.ts` + css | sub1 — 단추 · 시험판 페이지 · 잇기 · 끝 검증 | From 11d377d9e0da2ea5f2f549b3acf57891be3a0a7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:05:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=83=81=EC=9E=90=20=ED=8C=90(OutputPanel?= =?UTF-8?q?)=20=C2=B7=20=EC=9B=90=EA=B0=80=20=ED=95=A9=EA=B3=84=20?= =?UTF-8?q?=EC=B9=B8(Summary)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출력 상자의 받은 값 · 비용 종류 · 단위 · 이름 판, 원가 합계(노무비 · 재료비 · 경비 · 계) · 출력 상자별 줄 · 값 출력 · 빈 받는 자리 · 오류 · 예시값. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../CostFlow/M02_CF_OutputPanel.css | 85 ++++++ .../CostFlow/M02_CF_OutputPanel.ts | 195 ++++++++++++ .../CostFlow/M02_CF_Summary.css | 132 +++++++++ M02_MasterTemplete/CostFlow/M02_CF_Summary.ts | 278 ++++++++++++++++++ 4 files changed, 690 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Summary.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Summary.ts diff --git a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css new file mode 100644 index 000000000..0ce7fa6d2 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css @@ -0,0 +1,85 @@ +.m01-cfout__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m01-cfout__body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-cfout__got { + min-width: 220px; + color: var(--color-text-body); +} + +.m01-cfout__got dl { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-4) var(--spacing-8); + margin: var(--spacing-4) 0 0; + font-size: var(--text-body-sm); +} + +.m01-cfout__got dt { + color: var(--color-text-secondary); +} + +.m01-cfout__got dd { + margin: 0; +} + +.m01-cfout__num { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m01-cfout__fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); +} + +.m01-cfout__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-cfout__input { + box-sizing: border-box; + width: 160px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-cfout__warn { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} + +.m01-cfout__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-cfout__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts new file mode 100644 index 000000000..ffb358c12 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts @@ -0,0 +1,195 @@ +/* ============================================================================= + * M02_CF_OutputPanel.ts + * 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸 + * 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름 + * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { evalLogic, type CfEvalResult, type CfValue } from "./M02_CF_Eval"; +import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_CF_Model"; +import "./M02_CF_OutputPanel.css"; + +const TEXT = { + Title: ["출력", "Output"], + Name: ["이름", "Name"], + Cost: ["비용 종류", "Cost kind"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Plain: ["값(비용 아님)", "Value (not a cost)"], + Unit: ["단위", "Unit"], + UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"], + Got: ["받은 값", "Received value"], + From: ["어디서", "From"], + Value: ["값", "Value"], + NoLink: [ + "아직 이어진 선이 없어요 — 입력이나 함수 상자에서 이 상자로 선을 이어 주세요", + "Nothing is linked yet — draw a line from an input or function box", + ], + Table: ["표", "Table"], + Calc: ["계산 중이 아니에요", "Not calculated"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + PlainHint: [ + "원가 합계에 안 넣고 값으로만 보여요", + "Shown as a value, not added to the cost total", + ], +} as const; + +type TextKey = keyof typeof TEXT; +const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const COSTS: [CfCost, TextKey][] = [ + ["노무비", "Labor"], + ["재료비", "Material"], + ["경비", "Expense"], + ["값", "Plain"], +]; + +/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */ +export function showValue(v: CfValue | undefined, money = false): string { + if (v === undefined || v === null) return "—"; + if (Array.isArray(v)) return to("Table"); + if (typeof v === "number") + return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + return String(v); +} + +const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({ + ...lg, + boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)), +}); + +/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */ +export function renderOutputPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, + opts: { result?: CfEvalResult } = {}, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + if (!box || box.kind !== "output") { + host.replaceChildren(); + return; + } + const msg = el("p", { className: "m01-cfout__msg" }); + const emit = (make: () => CfLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch { + msg.textContent = to("Cant"); + } + }; + const cost = box.output?.cost ?? "노무비"; + + /* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */ + const link = lg.links.find((l) => l.to.box === box.id); + const from = link && lg.boxes.find((b) => b.id === link.from.box); + let result = opts.result; + if (!result && link) { + try { + result = evalLogic(lg); + } catch { + result = undefined; + } + } + let unit = ""; + if (from && link) { + try { + unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit; + } catch { + unit = from.unit; + } + } + const got = el("div", { className: "m01-cfout__got" }); + if (link && from) { + const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id]; + got.replaceChildren( + el("strong", { text: to("Got") }), + el("dl", { + children: [ + el("dt", { text: to("From") }), + el("dd", { text: `${from.name} · ${link.from.name}` }), + el("dt", { text: to("Value") }), + el("dd", { + className: "m01-cfout__num", + text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`, + }), + ], + }), + ); + } else { + got.replaceChildren( + el("strong", { text: to("Got") }), + el("p", { className: "m01-cfout__warn", text: to("NoLink") }), + ); + } + + /* 이름 · 비용 종류 · 단위 */ + const nameBox = el("input", { + className: "m01-cfout__input", + attrs: { type: "text", "aria-label": to("Name") }, + }); + nameBox.value = box.name; + nameBox.addEventListener("change", () => { + const next = nameBox.value.trim(); + if (!next || next === box.name) { + nameBox.value = box.name; + return; + } + emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next }))); + }); + const costField = createSelectField({ + label: to("Cost"), + options: COSTS.map(([value, key]) => ({ value, text: to(key) })), + value: cost, + compact: true, + onChange: (v) => + emit(() => + withBox(lg, box.id, (b) => ({ + ...b, + output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost }, + })), + ), + }); + const unitBox = el("input", { + className: "m01-cfout__input", + attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") }, + }); + unitBox.value = box.output?.unit ?? box.unit; + unitBox.addEventListener("change", () => { + const next = unitBox.value.trim() || "-"; + emit(() => + withBox(lg, box.id, (b) => ({ + ...b, + unit: next, + output: { cost: b.output?.cost ?? "노무비", unit: next }, + })), + ); + }); + const field = (label: string, control: HTMLElement): HTMLElement => + el("label", { + className: "m01-cfout__field", + children: [el("span", { text: label }), control], + }); + + host.replaceChildren( + el("strong", { className: "m01-cfout__title", text: `${to("Title")} · ${box.name}` }), + el("div", { + className: "m01-cfout__body", + children: [ + got, + el("div", { + className: "m01-cfout__fields", + children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)], + }), + ], + }), + ...(cost === "값" ? [el("p", { className: "m01-cfout__muted", text: to("PlainHint") })] : []), + msg, + ); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.css b/M02_MasterTemplete/CostFlow/M02_CF_Summary.css new file mode 100644 index 000000000..f8982bf37 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Summary.css @@ -0,0 +1,132 @@ +.m01-cfsum__big { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m01-cfsum__big strong { + color: var(--color-text-body); + font-size: 28px; + font-variant-numeric: tabular-nums; +} + +.m01-cfsum__costs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-16); + margin: 0 0 var(--spacing-12); + font-size: var(--text-body-sm); +} + +.m01-cfsum__costs dt { + display: inline; + color: var(--color-text-secondary); +} + +.m01-cfsum__costs dd { + display: inline; + margin: 0 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m01-cfsum__block { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m01-cfsum__scroll { + max-width: 100%; + overflow-x: auto; +} + +.m01-cfsum__table { + width: 100%; + min-width: 360px; + margin-top: var(--spacing-4); + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m01-cfsum__table th, +.m01-cfsum__table td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m01-cfsum__table th { + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-cfsum__table td.m01-cfsum__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-cfsum__strip { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m01-cfsum__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-cfsum__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-cfsum__input { + box-sizing: border-box; + width: 140px; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-cfsum__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-cfsum__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-cfsum__problems { + margin: 0 0 var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-cfsum__problems ul { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts new file mode 100644 index 000000000..f0cfb9284 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts @@ -0,0 +1,278 @@ +/* ============================================================================= + * M02_CF_Summary.ts + * 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄 + * · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈) + * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. + * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { checkLogic, type CfEvalResult } from "./M02_CF_Eval"; +import type { CfBox, CfLogic, CfVariable } from "./M02_CF_Model"; +import { showValue } from "./M02_CF_OutputPanel"; +import "./M02_CF_Summary.css"; + +const TEXT = { + Per: ["{per} 당", "per {per}"], + Total: ["원가 합계", "Cost total"], + Won: ["원", "KRW"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Lines: ["출력 상자별", "By output box"], + HName: ["이름", "Name"], + HCost: ["비용 종류", "Cost kind"], + HValue: ["값", "Value"], + HUnit: ["단위", "Unit"], + Plain: ["값(비용 아님)", "Values (not costs)"], + Sample: ["예시값", "Sample values"], + SampleHint: [ + "여기서 바꾸면 변수 상자의 예시값도 바뀌어요", + "Changing here changes the variable box too", + ], + Problems: ["고칠 것", "To fix"], + NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"], + NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], + Pick: ["고르세요", "Choose"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + Plain3: ["값", "Value"], +} as const; + +type TextKey = keyof typeof TEXT; +const ts = (key: TextKey, fill: Record = {}): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace( + /\{(\w+)\}/g, + (m, k: string) => fill[k] ?? m, + ); + +const COSTS = ["노무비", "재료비", "경비"] as const; +const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; + +const costText = (c: string): string => (c === "값" ? ts("Plain3") : c); + +/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */ +function emptyPlaces(lg: CfLogic): string[] { + const outs = lg.boxes.filter((b) => b.kind === "output"); + if (!outs.length) return [ts("NoOutput")]; + return outs + .filter((o) => !lg.links.some((l) => l.to.box === o.id)) + .map((o) => ts("NoLink", { box: o.name })); +} + +/** 변수 상자의 예시값 칸 하나 */ +function sampleField( + v: CfVariable, + put: (value: number | string) => void, + msg: HTMLElement, +): HTMLElement { + const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); + let control: HTMLElement; + if (v.type === "choice" && v.choices?.length) { + control = createSelectField({ + options: [ + { value: "", text: ts("Pick") }, + ...v.choices.map((c) => ({ value: String(c), text: String(c) })), + ], + value: v.example === null ? "" : String(v.example), + compact: true, + ariaLabel: v.name, + onChange: (s) => { + const hit = v.choices?.find((c) => String(c) === s); + if (hit !== undefined) put(hit); + }, + }).root; + } else { + const box = el("input", { + className: "m01-cfsum__input", + attrs: { + type: "text", + inputmode: v.type === "number" ? "decimal" : "text", + "aria-label": v.name, + }, + }); + if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) }); + box.value = v.example === null ? "" : String(v.example); + box.addEventListener("change", () => { + const raw = box.value.trim(); + if (raw === "") return; + if (v.type !== "number") return put(raw); + const n = Number(raw); + if (Number.isNaN(n)) msg.textContent = ts("Bad"); + else put(n); + }); + control = box; + } + return el("label", { className: "m01-cfsum__field", children: [label, control] }); +} + +/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 */ +export function renderSummary( + host: HTMLElement, + lg: CfLogic, + result: CfEvalResult, + on?: { change(lg: CfLogic): void }, +): void { + const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); + const perUnit = lg.resultUnit.split("/").slice(1).join("/"); + const perText = !perUnit + ? ts("Total") + : ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); + const { cost } = result; + const big = el("p", { + className: "m01-cfsum__big", + children: [ + el("span", { text: money ? perText : ts("Total") }), + el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }), + ], + }); + const costs = el("dl", { + className: "m01-cfsum__costs", + children: COSTS.flatMap((c) => [ + el("dt", { text: ts(COST_KEY[c]) }), + el("dd", { text: formatMoney(cost[c]) }), + ]), + }); + + /* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */ + const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); + const costed = outs.filter((b) => b.output?.cost !== "값"); + const plain = outs.filter((b) => b.output?.cost === "값"); + const line = (b: CfBox, isCost: boolean): HTMLElement => + el("tr", { + children: [ + el("td", { text: b.name }), + ...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []), + el("td", { + className: "m01-cfsum__num", + text: showValue(result.outputs[b.id], isCost), + }), + el("td", { text: b.output?.unit ?? b.unit }), + ], + }); + const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement => + el("div", { + className: "m01-cfsum__block", + children: [ + el("strong", { text: title }), + el("div", { + className: "m01-cfsum__scroll", + children: [ + el("table", { + className: "m01-cfsum__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + el("th", { text: ts("HName") }), + ...(isCost ? [el("th", { text: ts("HCost") })] : []), + el("th", { text: ts("HValue") }), + el("th", { text: ts("HUnit") }), + ], + }), + ], + }), + el("tbody", { children: list.map((b) => line(b, isCost)) }), + ], + }), + ], + }), + ], + }); + + /* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */ + let checked: string[] = []; + try { + checked = checkLogic(lg); + } catch { + checked = []; + } + const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])]; + + /* 예시값 — 변수 상자의 변수마다 */ + const msg = el("p", { className: "m01-cfsum__msg" }); + const fields = lg.boxes + .filter((b) => b.kind === "input" && b.input?.mode === "variable") + .flatMap((b) => + (b.input?.variables ?? []).map((v, i) => + sampleField( + v, + (value) => { + msg.textContent = ""; + if (!on) return; + try { + on.change({ + ...lg, + boxes: lg.boxes.map((x) => + x.id === b.id && x.input + ? { + ...x, + input: { + ...x.input, + variables: x.input.variables.map((y, j) => + j === i ? { ...y, example: value } : y, + ), + }, + } + : x, + ), + }); + } catch { + msg.textContent = ts("Cant"); + } + }, + msg, + ), + ), + ); + + const plainValues = Object.entries(cost.값들); + host.replaceChildren( + big, + costs, + ...(costed.length ? [table(costed, true, ts("Lines"))] : []), + ...(plain.length + ? [table(plain, false, ts("Plain"))] + : plainValues.length + ? [ + el("div", { + className: "m01-cfsum__block", + children: [ + el("strong", { text: ts("Plain") }), + ...plainValues.map(([k, v]) => + el("p", { className: "m01-cfsum__muted", text: `${k} · ${showValue(v)}` }), + ), + ], + }), + ] + : []), + ...(fields.length + ? [ + el("div", { + className: "m01-cfsum__strip", + children: [ + el("strong", { text: ts("Sample") }), + el("div", { className: "m01-cfsum__fields", children: fields }), + el("p", { className: "m01-cfsum__muted", text: ts("SampleHint") }), + msg, + ], + }), + ] + : []), + ...(problems.length + ? [ + el("div", { + className: "m01-cfsum__problems", + children: [ + el("strong", { text: ts("Problems") }), + el("ul", { children: problems.map((t) => el("li", { text: t })) }), + ], + }), + ] + : []), + ); +} From 47eba22e54e5711a0baa7798d06f645edb48c5a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:05:41 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(M02-CostFlow):=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=ED=8C=90=20FuncPanel=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=20=EC=8B=9C=ED=8A=B8=20=ED=8E=B8=EC=A7=91=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=9B=EC=9D=80=20=EC=95=8C=EC=95=BD=20=C2=B7=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=B9=B8=20=ED=95=98=EB=82=98=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=ED=8B=80=20=EB=8B=A8=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- .../CostFlow/M02_CF_FuncPanel.css | 180 +++++ .../CostFlow/M02_CF_FuncPanel.ts | 697 ++++++++++++++++++ 2 files changed, 877 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css new file mode 100644 index 000000000..3203eff96 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.css @@ -0,0 +1,180 @@ +.m01-cfpanel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-cfpanel-head, +.m01-cfpanel-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01-cfpanel-head { + justify-content: space-between; +} + +.m01-cfpanel-title { + font-size: var(--text-subheading); +} + +.m01-cfpanel-unitbox { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + margin-left: auto; +} + +.m01-cfpanel-unit { + width: 6em; + text-align: center; +} + +.m01-cfpanel-cell { + padding: 2px var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m01-cfpanel-label { + font-weight: var(--font-weight-bold); +} + +.m01-cfpanel-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-cfpanel .ui-btn, +.m01-cfpanel .ui-input, +.m01-cfpanel .ui-select { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-cfpanel-form { + align-items: flex-end; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m01-cfpanel-fieldbox { + display: inline-flex; + flex-direction: column; + gap: 2px; +} + +.m01-cfpanel-field { + width: 9em; +} + +.m01-cfpanel-field.is-num { + width: 6em; +} + +.m01-cfpanel-why, +.m01-cfpanel-msg { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m01-cfpanel-chip { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + min-height: 44px; + padding: 0 var(--spacing-12); + border: 2px solid; + border-radius: var(--radius-pills); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); +} + +button.m01-cfpanel-chip { + cursor: pointer; +} + +button.m01-cfpanel-chip:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-cfpanel-chip.is-in { + border-color: #3b82f6; +} + +.m01-cfpanel-chip.is-in:not(.is-placed) { + border-style: dashed; +} + +.m01-cfpanel-chip.is-out { + border-color: #f59e0b; +} + +.m01-cfpanel-chip.is-var { + border-color: #22a06b; +} + +/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ +.m01-cfpanel-sheet { + position: relative; + height: max(640px, calc(100vh - 260px)); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + overflow: hidden; +} + +/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */ +.m01-cfpanel .aislo-cell[data-cf-kind="in"] { + box-shadow: inset 0 0 0 2px #3b82f6; +} + +.m01-cfpanel .aislo-cell[data-cf-kind="var"] { + box-shadow: inset 0 0 0 2px #22a06b; +} + +.m01-cfpanel .aislo-cell[data-cf-kind="out"] { + box-shadow: inset 0 0 0 2px #f59e0b; +} + +.m01-cfpanel .aislo-cell[data-cf-kind]::after { + content: attr(data-cf-name); + position: absolute; + top: 0; + right: 0; + padding: 0 4px; + border-radius: 0 0 0 6px; + background: var(--color-surface); + color: var(--color-text); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-cfpanel .aislo-cell[data-cf-lock]::before { + content: attr(data-cf-lock); + position: absolute; + bottom: 0; + left: 0; + padding: 0 4px; + background: var(--color-surface); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-cfpanel .aislo-cell[data-cf-err] { + outline: 2px dashed var(--color-danger); + outline-offset: -2px; +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts new file mode 100644 index 000000000..f43861d16 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_FuncPanel.ts @@ -0,0 +1,697 @@ +/* ============================================================================= + * M02_CF_FuncPanel.ts + * 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) · + * 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류 + * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_CF_Model 타입 그대로 + * 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import type { + CalcValues, + SpreadsheetHandle, + Workbook, +} from "../../A00_Common/spreadsheet/spreadsheet"; +import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_CF_Model"; +import "./M02_CF_FuncPanel.css"; + +const TEXT = { + Unit: ["단위", "Unit"], + Cell: ["고른 칸", "Selected cell"], + Got: ["받은 알약", "Received pills"], + GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], + NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], + Placed: ["놓임", "placed"], + Free: ["놓을 칸 없음", "not placed"], + Busy: ["이미 쓰는 칸", "That cell is already in use"], + Locked: [ + "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", + "This cell is filled by the wire — cannot edit", + ], + Vars: ["변수", "Variables"], + MakeVar: ["변수 만들기", "Make variable"], + NoVars: ["아직 변수가 없음", "No variables yet"], + VarName: ["이름", "Name"], + VarType: ["갈래", "Type"], + Number: ["숫자", "Number"], + Choice: ["선택", "Choice"], + Text: ["글", "Text"], + VarUnit: ["단위", "Unit"], + VarMin: ["최소", "Min"], + VarMax: ["최대", "Max"], + VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], + VarExample: ["예시값", "Example value"], + VarCell: ["붙을 칸", "Attach to"], + Make: ["만들기", "Make"], + Cancel: ["취소", "Cancel"], + Remove: ["지움", "Remove"], + NeedName: ["이름을 적음", "Enter a name"], + SameName: ["같은 이름이 이미 있음", "That name already exists"], + NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], + BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], + OutBtn: ["출력 칸으로", "Make output cell"], + OutNow: ["출력", "Output"], + OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], + OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], + Frames: ["틀", "Templates"], + FrameMul: ["곱하기", "Multiply"], + FrameMore: ["할증", "Markup"], + FrameDiv: ["나누기", "Divide"], + FrameIf: ["만약", "If"], + FrameMin: ["최소", "Min"], + FrameMax: ["최대", "Max"], + FrameUp: ["올림", "Round up"], + FrameLook: ["표에서 찾기", "Look up in table"], + FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], + ReadOnly: ["읽기만", "Read only"], + Loading: ["시트를 불러오는 중", "Loading sheet"], + Problem: ["⚠ ", "⚠ "], + Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], + Err_DIV0: ["0으로 나눔", "divides by zero"], + Err_NAME: ["알 수 없는 이름", "unknown name"], + Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], + Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], + Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], + Err_NA: ["찾는 값이 없음", "value not found"], + Err_OTHER: ["수식이 틀림", "formula is wrong"], +} as const; + +const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ +const FRAMES: [keyof typeof TEXT, string][] = [ + ["FrameMul", "=B1*B2"], + ["FrameMore", "=B1*(1+B2/100)"], + ["FrameDiv", "=B1/B2"], + ["FrameIf", '=IF(B3="급",10,0)'], + ["FrameMin", "=MIN(B1,B2)"], + ["FrameMax", "=MAX(B1,B2)"], + ["FrameUp", "=ROUNDUP(B4,3)"], + ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], +]; + +const ERR_KEY: Record = { + "#DIV/0!": "Err_DIV0", + "#NAME?": "Err_NAME", + "#REF!": "Err_REF", + "#VALUE!": "Err_VALUE", + "#CYCLE!": "Err_CYCLE", + "#N/A": "Err_NA", +}; + +type Handlers = { change(lg: CfLogic): void }; + +interface VarForm { + name: string; + type: CfVariable["type"]; + unit: string; + min: string; + max: string; + choices: string; + example: string; + why: string; +} + +interface State { + boxId: string; + lg: CfLogic; + on: Handlers; + readOnly: boolean; + handle: SpreadsheetHandle | null; + /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ + snap: CalcValues; + /** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */ + json: string; + cell: string; + form: VarForm | null; + parts: { + head: HTMLElement; + got: HTMLElement; + vars: HTMLElement; + bar: HTMLElement; + msg: HTMLElement; + sheet: HTMLElement; + }; + observer: MutationObserver | null; + redraw(): void; +} + +const states = new WeakMap(); +let sheetModule: Promise | null = null; +/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ +const loadSheet = (): Promise => + (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); + +const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId); +const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func; +const bookOf = (func: CfFunc): Workbook => ({ + 종류: "통합문서", + 판: 1, + 열: "cf", + 서식: [{}], + 시트: [structuredClone(func.sheet)], + 활성: func.sheet.id, +}); +const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet); + +/* ------------------------------ 바꾸기 ------------------------------ */ + +function patchBox(st: State, make: (b: CfBox) => CfBox): void { + const next: CfLogic = { + ...st.lg, + boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)), + }; + st.lg = next; + st.on.change(next); +} + +const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void => + patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b)); + +const usedCells = (f: CfFunc): Map => { + const m = new Map(); + for (const i of f.inputs) m.set(i.cell, i.port); + for (const v of f.variables) if (v.cell) m.set(v.cell, v.name); + if (f.output.cell) m.set(f.output.cell, f.output.name); + return m; +}; + +function placePort(st: State, port: string, cell: string): void { + const f = funcOf(st); + if (!f || st.readOnly) return; + const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell); + if (other) return void showToast(tf("Busy"), "error"); + patchFunc(st, (x) => ({ + ...x, + inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }], + })); +} + +function makeVariable(st: State): void { + const form = st.form; + const f = funcOf(st); + if (!form || !f) return; + const name = form.name.trim(); + const fail = (key: keyof typeof TEXT): void => { + form.why = tf(key); + drawVars(st); + }; + if (!name) return fail("NeedName"); + const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)]; + const mine = f.variables.find((v) => v.name === name); + if (taken.includes(name) && !mine) return fail("SameName"); + const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) }); + if (busy.has(st.cell)) return fail("Busy"); + let example: number | string | null = form.example.trim() === "" ? null : form.example.trim(); + const v: CfVariable = { + name, + type: form.type, + unit: form.unit.trim() || "-", + example: null, + cell: st.cell, + }; + if (form.type === "number") { + if (example !== null) { + const n = Number(String(example).replace(/,/g, "")); + if (!Number.isFinite(n)) return fail("NotNumber"); + example = n; + } + const lo = form.min.trim() === "" ? null : Number(form.min); + const hi = form.max.trim() === "" ? null : Number(form.max); + if (lo !== null && hi !== null) { + if (!(lo < hi)) return fail("BadRange"); + v.range = [lo, hi]; + } + } else if (form.type === "choice") { + v.choices = form.choices + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + } + v.example = example; + const cell = st.cell; + st.form = null; + patchFunc(st, (x) => { + const sheet = structuredClone(x.sheet); + if (example !== null) sheet.칸[cell] = { 값: example }; + return { + ...x, + sheet, + inputs: x.inputs.filter((i) => i.cell !== cell), + variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v], + }; + }); +} + +/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */ +function incoming(st: State): { port: string; unit: string }[] { + const out = new Map(); + for (const l of st.lg.links) { + if (l.to.box !== st.boxId || out.has(l.to.name)) continue; + const src = st.lg.boxes.find((b) => b.id === l.from.box); + const unit = + src?.func && src.func.output.name === l.from.name + ? src.func.output.unit + : (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ?? + src?.input?.variables.find((v) => v.name === l.from.name)?.unit ?? + src?.unit ?? + "-"); + out.set(l.to.name, unit || "-"); + } + return [...out].map(([port, unit]) => ({ port, unit })); +} + +function problems(st: State): string[] { + const f = funcOf(st); + if (!f) return []; + const out: string[] = []; + for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) { + if (typeof v === "object" && "오류" in v) { + out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`); + } + } + for (const p of incoming(st)) { + if (!f.inputs.some((i) => i.port === p.port)) out.push(`${p.port}${tf("Unplaced")}`); + } + if (!f.output.cell) out.push(tf("OutNone")); + return out; +} + +/* ------------------------------ 칸 위 표시 ------------------------------ */ + +function applyMarks(st: State): void { + const f = funcOf(st); + if (!f) return; + const marks = new Map(); + for (const i of f.inputs) marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` }); + for (const v of f.variables) + if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` }); + if (f.output.cell) + marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` }); + const locked = new Set(f.inputs.map((i) => i.cell)); + const list = problems(st); + const bad = new Set(list.map((p) => p.split(" ")[0])); + for (const c of st.parts.sheet.querySelectorAll(".aislo-cell")) { + const addr = c.dataset.addr ?? ""; + for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a]; + const m = marks.get(addr); + if (m) { + c.dataset.cfKind = m.kind; + c.dataset.cfName = m.label; + } + if (locked.has(addr)) c.dataset.cfLock = "🔒"; + if (bad.has(addr)) c.dataset.cfErr = "1"; + } + st.parts.msg.textContent = list.length + ? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}` + : ""; + st.parts.msg.hidden = !list.length; +} + +/* ------------------------------ 위 줄들 ------------------------------ */ + +function drawHead(st: State): void { + const box = boxOf(st); + const unit = el("input", { + className: "ui-input m01-cfpanel-unit", + attrs: { type: "text", "aria-label": tf("Unit") }, + }); + unit.value = box?.unit ?? "-"; + if (st.readOnly) unit.readOnly = true; + unit.addEventListener("change", () => { + const v = unit.value.trim() || "-"; + unit.value = v; + patchBox(st, (b) => ({ + ...b, + unit: v, + func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func, + })); + }); + st.parts.head.replaceChildren( + el("strong", { className: "m01-cfpanel-title", text: box?.name ?? "" }), + el("span", { className: "m01-cfpanel-cell", text: `${tf("Cell")} ${st.cell}` }), + el("label", { + className: "m01-cfpanel-unitbox", + children: [el("span", { className: "m01-cfpanel-note", text: tf("Unit") }), unit], + }), + ); +} + +function drawGot(st: State): void { + const f = funcOf(st); + const items = incoming(st); + const chips = items.map(({ port, unit }) => { + const at = f?.inputs.find((i) => i.port === port)?.cell; + const b = el("button", { + className: `m01-cfpanel-chip is-in${at ? " is-placed" : ""}`, + text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`, + attrs: { type: "button", draggable: st.readOnly ? "false" : "true" }, + }); + b.addEventListener("click", () => placePort(st, port, st.cell)); + b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port)); + return b; + }); + st.parts.got.replaceChildren( + el("span", { className: "m01-cfpanel-label", text: tf("Got") }), + ...(chips.length ? chips : [el("span", { className: "m01-cfpanel-note", text: tf("NoGot") })]), + ...(chips.length && !st.readOnly + ? [el("span", { className: "m01-cfpanel-note", text: tf("GotHint") })] + : []), + ); +} + +function formField( + st: State, + label: keyof typeof TEXT, + key: "name" | "unit" | "min" | "max" | "choices" | "example", + cls = "", +): HTMLElement { + const form = st.form!; + const input = el("input", { + className: `ui-input m01-cfpanel-field ${cls}`, + attrs: { type: "text", "aria-label": tf(label) }, + }); + input.value = form[key]; + input.addEventListener("input", () => (form[key] = input.value)); + input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st)); + return el("label", { + className: "m01-cfpanel-fieldbox", + children: [el("span", { className: "m01-cfpanel-note", text: tf(label) }), input], + }); +} + +function drawVars(st: State): void { + const f = funcOf(st); + const row: HTMLElement[] = [el("span", { className: "m01-cfpanel-label", text: tf("Vars") })]; + for (const v of f?.variables ?? []) { + const chip = el("span", { + className: "m01-cfpanel-chip is-var", + children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`], + }); + if (!st.readOnly) { + chip.append( + createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => + patchFunc(st, (x) => ({ + ...x, + variables: x.variables.filter((o) => o.name !== v.name), + })), + }), + ); + chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`); + } + row.push(chip); + } + if (!f?.variables.length) + row.push(el("span", { className: "m01-cfpanel-note", text: tf("NoVars") })); + const parts: HTMLElement[] = [el("div", { className: "m01-cfpanel-row", children: row })]; + if (!st.readOnly) { + const form = st.form; + if (!form) { + parts.push( + el("div", { + className: "m01-cfpanel-row", + children: [ + createButton({ + label: tf("MakeVar"), + variant: "ghost", + size: "sm", + onClick: () => { + st.form = { + name: "", + type: "number", + unit: "", + min: "", + max: "", + choices: "", + example: "", + why: "", + }; + drawVars(st); + }, + }), + ], + }), + ); + } else { + const type = el("select", { + className: "ui-select m01-cfpanel-field", + attrs: { "aria-label": tf("VarType") }, + }); + for (const [v, k] of [ + ["number", "Number"], + ["choice", "Choice"], + ["text", "Text"], + ] as const) { + type.append(el("option", { attrs: { value: v }, text: tf(k) })); + } + type.value = form.type; + type.addEventListener("change", () => { + form.type = type.value as CfVariable["type"]; + drawVars(st); + }); + parts.push( + el("div", { + className: "m01-cfpanel-row m01-cfpanel-form", + children: [ + formField(st, "VarName", "name"), + el("label", { + className: "m01-cfpanel-fieldbox", + children: [el("span", { className: "m01-cfpanel-note", text: tf("VarType") }), type], + }), + formField(st, "VarUnit", "unit"), + ...(form.type === "number" + ? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")] + : []), + ...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []), + formField(st, "VarExample", "example"), + el("span", { className: "m01-cfpanel-cell", text: `${tf("VarCell")} ${st.cell}` }), + createButton({ + label: tf("Make"), + variant: "filled", + size: "sm", + onClick: () => makeVariable(st), + }), + createButton({ + label: tf("Cancel"), + variant: "ghost", + size: "sm", + onClick: () => ((st.form = null), drawVars(st)), + }), + ...(form.why + ? [el("span", { className: "m01-cfpanel-why", text: `⚠ ${form.why}` })] + : []), + ], + }), + ); + } + } + st.parts.vars.replaceChildren(...parts); +} + +function drawBar(st: State): void { + const f = funcOf(st); + if (st.readOnly) { + st.parts.bar.replaceChildren( + el("span", { className: "m01-cfpanel-note", text: tf("ReadOnly") }), + ); + return; + } + const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone"); + st.parts.bar.replaceChildren( + el("div", { + className: "m01-cfpanel-row", + children: [ + createButton({ + label: tf("OutBtn"), + variant: "ghost", + size: "sm", + onClick: () => setOutput(st, st.cell), + }), + el("span", { className: "m01-cfpanel-chip is-out", text: outNow }), + el("span", { className: "m01-cfpanel-note", text: tf("OutOnly") }), + ], + }), + el("div", { + className: "m01-cfpanel-row", + children: [ + el("span", { className: "m01-cfpanel-label", text: tf("Frames") }), + ...FRAMES.map(([key, formula]) => + createButton({ + label: tf(key), + variant: "ghost", + size: "sm", + onClick: () => putFormula(st, formula), + }), + ), + el("span", { className: "m01-cfpanel-note", text: tf("FrameHint") }), + ], + }), + ); +} + +/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */ +function setOutput(st: State, cell: string): void { + const f = funcOf(st); + if (!f) return; + if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) { + return void showToast(tf("Busy"), "error"); + } + const row = cell.replace(/^[A-Z]+/, ""); + const label = f.sheet.칸[`A${row}`]; + const name = String(label?.값 ?? f.output.name ?? "").trim() || cell; + patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } })); +} + +/* ------------------------------ 편집기 ------------------------------ */ + +/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ +function putFormula(st: State, formula: string): void { + const root = st.parts.sheet; + const bar = root.querySelector(".ss-formula"); + const addr = root.querySelector(".ss-addr"); + if (!bar || !addr || st.readOnly) return; + const at = st.cell; + bar.focus(); + bar.value = formula; + bar.dispatchEvent(new Event("input", { bubbles: true })); + bar.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), + ); + addr.value = at; + addr.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); +} + +/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */ +function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] { + for (const cell of Object.values(sheet.칸)) delete cell.서식; + return sheet; +} + +/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */ +function onBook(st: State, book: Workbook): void { + const f = funcOf(st); + if (!f) return; + const sheet = plain(structuredClone(book.시트[0])); + const hit = f.inputs.find( + (i) => JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]), + ); + if (hit) { + showToast(tf("Locked"), "error"); + st.json = sheetJson(f); + return st.handle?.setDoc(bookOf(f)); + } + st.json = JSON.stringify(sheet); + patchFunc(st, (x) => ({ ...x, sheet })); + setTimeout(() => refreshSnap(st), 0); +} + +/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ +function refreshSnap(st: State): void { + if (!st.handle) return; + st.snap = st.handle.recalc(); + st.redraw(); +} + +async function mountSheet(st: State): Promise { + const { createSpreadsheet } = await loadSheet(); + const f = funcOf(st); + if (!st.parts.sheet.isConnected || st.handle || !f) return; + st.parts.sheet.replaceChildren(); + st.json = sheetJson(f); + st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), { + preview: true, + tabs: false, + readOnly: st.readOnly, + onChange: (b) => onBook(st, b), + onSelect: (s) => { + st.cell = s.칸; + drawHead(st); + if (st.form) drawVars(st); + }, + }); + st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); + st.observer.observe(st.parts.sheet, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-addr"], + }); + st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault()); + st.parts.sheet.addEventListener("drop", (e) => { + e.preventDefault(); + const port = e.dataTransfer?.getData("text/plain"); + const cell = (e.target as Element | null)?.closest(".aislo-cell")?.dataset.addr; + if (port && cell) placePort(st, port, cell); + }); + refreshSnap(st); +} + +/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ +export function renderFuncPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: Handlers, + opts: { readOnly?: boolean } = {}, +): void { + const old = states.get(host); + const readOnly = !!opts.readOnly; + if (old && old.boxId === boxId && old.readOnly === readOnly) { + old.lg = lg; + old.on = on; + const f = funcOf(old); + if (old.handle && f && sheetJson(f) !== old.json) { + old.json = sheetJson(f); + old.handle.setDoc(bookOf(f)); + setTimeout(() => refreshSnap(old), 0); + return; + } + return old.redraw(); + } + if (old) { + old.observer?.disconnect(); + old.handle?.destroy(); + } + const parts: State["parts"] = { + head: el("div", { className: "m01-cfpanel-head" }), + got: el("div", { className: "m01-cfpanel-row" }), + vars: el("div", { className: "m01-cfpanel-vars" }), + bar: el("div", { className: "m01-cfpanel-bar" }), + msg: el("p", { className: "m01-cfpanel-msg", attrs: { role: "status" } }), + sheet: el("div", { className: "m01-cfpanel-sheet", text: tf("Loading") }), + }; + const st: State = { + boxId, + lg, + on, + readOnly, + handle: null, + snap: {}, + json: "", + cell: "A1", + form: null, + parts, + observer: null, + redraw: () => { + drawHead(st); + drawGot(st); + drawVars(st); + drawBar(st); + applyMarks(st); + }, + }; + states.set(host, st); + host.classList.add("m01-cfpanel"); + host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); + parts.msg.hidden = true; + st.redraw(); + void mountSheet(st); +} From 91c743382ba237f77cf1c3db06e7caa1a412f637 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:06:32 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EA=B2=AC=EB=B3=B8=20=EC=85=8B=20=C2=B7=20M02=5FCF=5FSample(?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EB=B3=B5=EC=82=AC=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EC=97=B4=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _sample/masonry.json — 돌쌓기 메쌓기(인력): 변수 · 품표(QF000421) · 증가율표(QF000422) · 인력 → 증가율 찾기 → 품(둘) → 노무비(둘) → 합 → 출력 노무비 - _sample/rate.json — 공구손료(비율): 노무비 × 율 → 출력 재료비 - _sample/value.json — 덤프트럭 시간당 작업량: q · f · E · Cm → 출력 값 - 시험 test_m02_cf_sample.py — 모양 · 알약 이름 규칙 · 견본 ① 노무비 148,470.504 = /calc GF000219 · ③ = GF000133 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M02_MasterTemplete/CostFlow/M02_CF_Sample.ts | 65 ++ .../CostFlow/_sample/masonry.json | 785 ++++++++++++++++++ M02_MasterTemplete/CostFlow/_sample/rate.json | 146 ++++ .../CostFlow/_sample/value.json | 200 +++++ resources/tester/test_m02_cf_sample.py | 249 ++++++ 5 files changed, 1445 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Sample.ts create mode 100644 M02_MasterTemplete/CostFlow/_sample/masonry.json create mode 100644 M02_MasterTemplete/CostFlow/_sample/rate.json create mode 100644 M02_MasterTemplete/CostFlow/_sample/value.json create mode 100644 resources/tester/test_m02_cf_sample.py diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts b/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts new file mode 100644 index 000000000..3673fe54e --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Sample.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * M02_CF_Sample.ts + * 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬) + * ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비 + * ② 공구손료(비율) — 노무비 × 율 → 출력 재료비 + * ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값 + * 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호). + * 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」). + * 모양 = `M02_CF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port · + * output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { CfLogic } from "./M02_CF_Model"; + +const FILES = import.meta.glob("./_sample/*.json", { eager: true, import: "default" }); + +/** 견본마다 한 줄 설명 — [한, 영] */ +const NOTE: Record = { + masonry: [ + "변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비", + "Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost", + ], + rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material cost"], + value: [ + "q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)", + "q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)", + ], +}; + +/** 보여 줄 차례 */ +const ORDER = ["masonry", "rate", "value"]; + +export interface CfSampleItem { + /** 파일 이름(확장자 뺌) */ + id: string; + name: string; + resultUnit: string; + /** 쉬운 말 한 줄 */ + note: string; +} + +const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, ""); +const byId = (): Map => + new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg])); + +/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */ +export function sampleList(): CfSampleItem[] { + const all = byId(); + const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length); + return [...all.entries()] + .sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name)) + .map(([id, lg]) => ({ + id, + name: lg.name, + resultUnit: lg.resultUnit, + note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "", + })); +} + +/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */ +export function sampleLoad(id: string): CfLogic | null { + const lg = byId().get(id); + return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null; +} diff --git a/M02_MasterTemplete/CostFlow/_sample/masonry.json b/M02_MasterTemplete/CostFlow/_sample/masonry.json new file mode 100644 index 000000000..687251cd9 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/_sample/masonry.json @@ -0,0 +1,785 @@ +{ + "key": "SAMPLE-masonry", + "name": "견본 · 돌쌓기 메쌓기(인력)", + "resultUnit": "원/㎡", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "쌓기 조건", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 40, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "뒷길이", + "type": "choice", + "unit": "㎝", + "example": 30, + "choices": [25, 30, 35, 45, 55, 60, 75] + }, + { + "name": "돌", + "type": "choice", + "unit": "-", + "example": "깬돌", + "choices": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] + }, + { + "name": "쌓기", + "type": "choice", + "unit": "-", + "example": "골쌓기", + "choices": ["골쌓기", "켜쌓기"] + }, + { + "name": "높이", + "type": "number", + "unit": "m", + "example": 4.5 + } + ] + } + }, + { + "id": "in-poom", + "kind": "input", + "name": "메쌓기 품표", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 300, + "input": { + "mode": "group", + "group": "소요량", + "elements": [], + "table": { + "key": "QF000421" + }, + "variables": [] + } + }, + { + "id": "in-rate", + "kind": "input", + "name": "높이 증가율표", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 460, + "input": { + "mode": "group", + "group": "계수", + "elements": [], + "table": { + "key": "QF000422" + }, + "variables": [] + } + }, + { + "id": "in-labor", + "kind": "input", + "name": "인력", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 620, + "input": { + "mode": "group", + "group": "인력", + "variables": [], + "elements": [ + { + "ref": "LB000033", + "name": "석공", + "spec": "", + "unit": "원/일" + }, + { + "ref": "LB000002", + "name": "보통인부", + "spec": "", + "unit": "원/일" + } + ] + } + }, + { + "id": "fn-rate", + "kind": "function", + "name": "증가율 찾기", + "color": "#f59e0b", + "unit": "%", + "x": 340, + "y": 420, + "func": { + "sheet": { + "id": "cf-rate", + "이름": "증가율 찾기", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "높이" + }, + "C2": { + "값": "받는 값 · m" + }, + "A3": { + "값": "표에서 찾은 증가율" + }, + "B3": { + "식": "SUMIFS($G$2:$G$100,$E$2:$E$100,\"<\"&B2,$F$2:$F$100,\">=\"&B2)" + }, + "C3": { + "값": "높이가 「아래 < 높이 ≤ 위」 인 줄" + }, + "A4": { + "값": "증가율" + }, + "B4": { + "식": "IF(B2<=3,0,B3)" + }, + "C4": { + "값": "3m 까지는 할증 없음" + } + } + }, + "inputs": [ + { + "port": "높이", + "cell": "B2" + }, + { + "port": "증가율표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "증가율", + "unit": "%" + } + } + }, + { + "id": "fn-poom0", + "kind": "function", + "name": "석공 품", + "color": "#f59e0b", + "unit": "인/㎡", + "x": 640, + "y": 80, + "func": { + "sheet": { + "id": "cf-poom0", + "이름": "석공 품", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "뒷길이" + }, + "C2": { + "값": "받는 값 · ㎝" + }, + "A3": { + "값": "돌" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "쌓기" + }, + "C4": { + "값": "받는 값" + }, + "A5": { + "값": "증가율" + }, + "C5": { + "값": "받는 값 · %" + }, + "A6": { + "값": "표에서 찾은 품" + }, + "B6": { + "식": "SUMIFS($H$2:$H$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" + }, + "C6": { + "값": "세 조건이 맞는 줄의 품" + }, + "A7": { + "값": "석공 품" + }, + "B7": { + "식": "B6*(1+B5/100)" + }, + "C7": { + "값": "높이 할증을 얹음" + } + } + }, + "inputs": [ + { + "port": "뒷길이", + "cell": "B2" + }, + { + "port": "돌", + "cell": "B3" + }, + { + "port": "쌓기", + "cell": "B4" + }, + { + "port": "증가율", + "cell": "B5" + }, + { + "port": "품표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B7", + "name": "석공 품", + "unit": "인/㎡" + } + } + }, + { + "id": "fn-poom1", + "kind": "function", + "name": "보통인부 품", + "color": "#f59e0b", + "unit": "인/㎡", + "x": 640, + "y": 300, + "func": { + "sheet": { + "id": "cf-poom1", + "이름": "보통인부 품", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "뒷길이" + }, + "C2": { + "값": "받는 값 · ㎝" + }, + "A3": { + "값": "돌" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "쌓기" + }, + "C4": { + "값": "받는 값" + }, + "A5": { + "값": "증가율" + }, + "C5": { + "값": "받는 값 · %" + }, + "A6": { + "값": "표에서 찾은 품" + }, + "B6": { + "식": "SUMIFS($I$2:$I$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)" + }, + "C6": { + "값": "세 조건이 맞는 줄의 품" + }, + "A7": { + "값": "보통인부 품" + }, + "B7": { + "식": "B6*(1+B5/100)" + }, + "C7": { + "값": "높이 할증을 얹음" + } + } + }, + "inputs": [ + { + "port": "뒷길이", + "cell": "B2" + }, + { + "port": "돌", + "cell": "B3" + }, + { + "port": "쌓기", + "cell": "B4" + }, + { + "port": "증가율", + "cell": "B5" + }, + { + "port": "품표", + "cell": "E1" + } + ], + "variables": [], + "output": { + "cell": "B7", + "name": "보통인부 품", + "unit": "인/㎡" + } + } + }, + { + "id": "fn-pay0", + "kind": "function", + "name": "석공 노무비", + "color": "#f59e0b", + "unit": "원/㎡", + "x": 940, + "y": 80, + "func": { + "sheet": { + "id": "cf-pay0", + "이름": "석공 노무비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "석공 품" + }, + "C2": { + "값": "받는 값 · 인/㎡" + }, + "A3": { + "값": "석공 노임" + }, + "C3": { + "값": "받는 값 · 원/인" + }, + "A4": { + "값": "석공 노무비" + }, + "B4": { + "식": "B2*B3" + }, + "C4": { + "값": "품 × 노임" + } + } + }, + "inputs": [ + { + "port": "석공 품", + "cell": "B2" + }, + { + "port": "석공 노임", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "석공 노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "fn-pay1", + "kind": "function", + "name": "보통인부 노무비", + "color": "#f59e0b", + "unit": "원/㎡", + "x": 940, + "y": 300, + "func": { + "sheet": { + "id": "cf-pay1", + "이름": "보통인부 노무비", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "보통인부 품" + }, + "C2": { + "값": "받는 값 · 인/㎡" + }, + "A3": { + "값": "보통인부 노임" + }, + "C3": { + "값": "받는 값 · 원/인" + }, + "A4": { + "값": "보통인부 노무비" + }, + "B4": { + "식": "B2*B3" + }, + "C4": { + "값": "품 × 노임" + } + } + }, + "inputs": [ + { + "port": "보통인부 품", + "cell": "B2" + }, + { + "port": "보통인부 노임", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "보통인부 노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "fn-sum", + "kind": "function", + "name": "노무비 합", + "color": "#f59e0b", + "unit": "원/㎡", + "x": 1240, + "y": 190, + "func": { + "sheet": { + "id": "cf-sum", + "이름": "노무비 합", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "석공 노무비" + }, + "C2": { + "값": "받는 값" + }, + "A3": { + "값": "보통인부 노무비" + }, + "C3": { + "값": "받는 값" + }, + "A4": { + "값": "노무비" + }, + "B4": { + "식": "B2+B3" + }, + "C4": { + "값": "둘을 더함" + } + } + }, + "inputs": [ + { + "port": "석공 노무비", + "cell": "B2" + }, + { + "port": "보통인부 노무비", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "노무비", + "unit": "원/㎡" + } + } + }, + { + "id": "out-pay", + "kind": "output", + "name": "노무비", + "color": "#10b981", + "unit": "원/㎡", + "x": 1520, + "y": 190, + "output": { + "cost": "노무비", + "unit": "원/㎡" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "높이" + }, + "to": { + "box": "fn-rate", + "name": "높이" + } + }, + { + "id": "l2", + "from": { + "box": "in-rate", + "name": "표" + }, + "to": { + "box": "fn-rate", + "name": "증가율표" + } + }, + { + "id": "l3", + "from": { + "box": "in-var", + "name": "뒷길이" + }, + "to": { + "box": "fn-poom0", + "name": "뒷길이" + } + }, + { + "id": "l4", + "from": { + "box": "in-var", + "name": "돌" + }, + "to": { + "box": "fn-poom0", + "name": "돌" + } + }, + { + "id": "l5", + "from": { + "box": "in-var", + "name": "쌓기" + }, + "to": { + "box": "fn-poom0", + "name": "쌓기" + } + }, + { + "id": "l6", + "from": { + "box": "fn-rate", + "name": "증가율" + }, + "to": { + "box": "fn-poom0", + "name": "증가율" + } + }, + { + "id": "l7", + "from": { + "box": "in-poom", + "name": "표" + }, + "to": { + "box": "fn-poom0", + "name": "품표" + } + }, + { + "id": "l8", + "from": { + "box": "in-var", + "name": "뒷길이" + }, + "to": { + "box": "fn-poom1", + "name": "뒷길이" + } + }, + { + "id": "l9", + "from": { + "box": "in-var", + "name": "돌" + }, + "to": { + "box": "fn-poom1", + "name": "돌" + } + }, + { + "id": "l10", + "from": { + "box": "in-var", + "name": "쌓기" + }, + "to": { + "box": "fn-poom1", + "name": "쌓기" + } + }, + { + "id": "l11", + "from": { + "box": "fn-rate", + "name": "증가율" + }, + "to": { + "box": "fn-poom1", + "name": "증가율" + } + }, + { + "id": "l12", + "from": { + "box": "in-poom", + "name": "표" + }, + "to": { + "box": "fn-poom1", + "name": "품표" + } + }, + { + "id": "l13", + "from": { + "box": "fn-poom0", + "name": "석공 품" + }, + "to": { + "box": "fn-pay0", + "name": "석공 품" + } + }, + { + "id": "l14", + "from": { + "box": "in-labor", + "name": "석공" + }, + "to": { + "box": "fn-pay0", + "name": "석공 노임" + } + }, + { + "id": "l15", + "from": { + "box": "fn-poom1", + "name": "보통인부 품" + }, + "to": { + "box": "fn-pay1", + "name": "보통인부 품" + } + }, + { + "id": "l16", + "from": { + "box": "in-labor", + "name": "보통인부" + }, + "to": { + "box": "fn-pay1", + "name": "보통인부 노임" + } + }, + { + "id": "l17", + "from": { + "box": "fn-pay0", + "name": "석공 노무비" + }, + "to": { + "box": "fn-sum", + "name": "석공 노무비" + } + }, + { + "id": "l18", + "from": { + "box": "fn-pay1", + "name": "보통인부 노무비" + }, + "to": { + "box": "fn-sum", + "name": "보통인부 노무비" + } + }, + { + "id": "l19", + "from": { + "box": "fn-sum", + "name": "노무비" + }, + "to": { + "box": "out-pay", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/CostFlow/_sample/rate.json b/M02_MasterTemplete/CostFlow/_sample/rate.json new file mode 100644 index 000000000..5a1faa636 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/_sample/rate.json @@ -0,0 +1,146 @@ +{ + "key": "SAMPLE-rate", + "name": "견본 · 공구손료(비율)", + "resultUnit": "원", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "바탕 값", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 80, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "노무비", + "type": "number", + "unit": "원", + "example": 100000 + }, + { + "name": "공구손료율", + "type": "number", + "unit": "%", + "example": 3 + } + ] + } + }, + { + "id": "fn-rate", + "kind": "function", + "name": "공구손료", + "color": "#f59e0b", + "unit": "원", + "x": 340, + "y": 80, + "func": { + "sheet": { + "id": "cf-tool", + "이름": "공구손료", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "노무비" + }, + "C2": { + "값": "받는 값 · 원" + }, + "A3": { + "값": "공구손료율" + }, + "C3": { + "값": "받는 값 · %" + }, + "A4": { + "값": "공구손료" + }, + "B4": { + "식": "B2*B3/100" + }, + "C4": { + "값": "노무비 × 율 ÷ 100" + } + } + }, + "inputs": [ + { + "port": "노무비", + "cell": "B2" + }, + { + "port": "공구손료율", + "cell": "B3" + } + ], + "variables": [], + "output": { + "cell": "B4", + "name": "공구손료", + "unit": "원" + } + } + }, + { + "id": "out-mat", + "kind": "output", + "name": "재료비", + "color": "#10b981", + "unit": "원", + "x": 640, + "y": 80, + "output": { + "cost": "재료비", + "unit": "원" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "노무비" + }, + "to": { + "box": "fn-rate", + "name": "노무비" + } + }, + { + "id": "l2", + "from": { + "box": "in-var", + "name": "공구손료율" + }, + "to": { + "box": "fn-rate", + "name": "공구손료율" + } + }, + { + "id": "l3", + "from": { + "box": "fn-rate", + "name": "공구손료" + }, + "to": { + "box": "out-mat", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/M02_MasterTemplete/CostFlow/_sample/value.json b/M02_MasterTemplete/CostFlow/_sample/value.json new file mode 100644 index 000000000..a9f8e823b --- /dev/null +++ b/M02_MasterTemplete/CostFlow/_sample/value.json @@ -0,0 +1,200 @@ +{ + "key": "SAMPLE-value", + "name": "견본 · 덤프트럭 시간당 작업량(값)", + "resultUnit": "㎥/시간", + "boxes": [ + { + "id": "in-var", + "kind": "input", + "name": "덤프트럭 조건", + "color": "#3b82f6", + "unit": "-", + "x": 40, + "y": 80, + "input": { + "mode": "variable", + "elements": [], + "variables": [ + { + "name": "q", + "type": "number", + "unit": "㎥", + "example": 6 + }, + { + "name": "f", + "type": "number", + "unit": "-", + "example": 1.2 + }, + { + "name": "E", + "type": "number", + "unit": "-", + "example": 0.9 + }, + { + "name": "Cm", + "type": "number", + "unit": "분", + "example": 25 + } + ] + } + }, + { + "id": "fn-q", + "kind": "function", + "name": "시간당 작업량", + "color": "#f59e0b", + "unit": "㎥/시간", + "x": 340, + "y": 80, + "func": { + "sheet": { + "id": "cf-q", + "이름": "시간당 작업량", + "칸": { + "A1": { + "값": "이름" + }, + "B1": { + "값": "값" + }, + "C1": { + "값": "설명" + }, + "A2": { + "값": "q" + }, + "C2": { + "값": "적재량 · ㎥" + }, + "A3": { + "값": "f" + }, + "C3": { + "값": "토량환산계수" + }, + "A4": { + "값": "E" + }, + "C4": { + "값": "작업효율" + }, + "A5": { + "값": "Cm" + }, + "C5": { + "값": "1회 사이클 시간 · 분" + }, + "A6": { + "값": "시간당 작업량" + }, + "B6": { + "식": "ROUND(60*B2*B3*B4/B5,3)" + }, + "C6": { + "값": "60 × q × f × E ÷ Cm · 소수 셋째 자리" + } + } + }, + "inputs": [ + { + "port": "q", + "cell": "B2" + }, + { + "port": "f", + "cell": "B3" + }, + { + "port": "E", + "cell": "B4" + }, + { + "port": "Cm", + "cell": "B5" + } + ], + "variables": [], + "output": { + "cell": "B6", + "name": "시간당 작업량", + "unit": "㎥/시간" + } + } + }, + { + "id": "out-q", + "kind": "output", + "name": "시간당 작업량", + "color": "#10b981", + "unit": "㎥/시간", + "x": 640, + "y": 80, + "output": { + "cost": "값", + "unit": "㎥/시간" + } + } + ], + "links": [ + { + "id": "l1", + "from": { + "box": "in-var", + "name": "q" + }, + "to": { + "box": "fn-q", + "name": "q" + } + }, + { + "id": "l2", + "from": { + "box": "in-var", + "name": "f" + }, + "to": { + "box": "fn-q", + "name": "f" + } + }, + { + "id": "l3", + "from": { + "box": "in-var", + "name": "E" + }, + "to": { + "box": "fn-q", + "name": "E" + } + }, + { + "id": "l4", + "from": { + "box": "in-var", + "name": "Cm" + }, + "to": { + "box": "fn-q", + "name": "Cm" + } + }, + { + "id": "l5", + "from": { + "box": "fn-q", + "name": "시간당 작업량" + }, + "to": { + "box": "out-q", + "name": "값" + } + } + ], + "userElements": [] +} diff --git a/resources/tester/test_m02_cf_sample.py b/resources/tester/test_m02_cf_sample.py new file mode 100644 index 000000000..da6c1e7c8 --- /dev/null +++ b/resources/tester/test_m02_cf_sample.py @@ -0,0 +1,249 @@ +"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`. + +· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음) +· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진 + (Node 번들)으로 차례대로 셈 · 입력 값(노임 · 품셈 표)은 마스터에서 읽기만 +· ③ 값 = `/calc`(GF000133) · ② = 노무비 × 율 / 100 +· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀 +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow" +SAMPLE_TS = FOLDER / "M02_CF_Sample.ts" +SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip + +HOOKS = r""" +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { stripTypeScriptTypes } from "node:module"; +export async function load(url, ctx, next) { + if (!url.endsWith(".ts")) return next(url, ctx); + let src = readFileSync(fileURLToPath(url), "utf8"); + src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples"); + src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => { + const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type ")); + return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : ""; + }); + return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true }; +} +""" + + +def _node(body: str) -> object: + files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()} + script = ( + "import { register } from 'node:module';\n" + f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n" + "globalThis.__stub = { currentLanguageIndex: 0 };\n" + f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n" + f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n" + "const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--no-warnings", "--input-type=module", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr[-2000:] + return json.loads(result.stdout) + + +# ── 모양 ───────────────────────────────────────────────────────────────── +def _ports_out(box: dict) -> list[str]: + if box["kind"] == "function": + return [box["func"]["output"]["name"]] + if box["kind"] == "output": + return [] + inp = box["input"] + return ( + [e["name"] for e in inp.get("elements", [])] + + (["표"] if inp.get("table") else []) + + [v["name"] for v in inp.get("variables", [])] + ) + + +def _ports_in(box: dict) -> list[str]: + if box["kind"] == "function": + return [i["port"] for i in box["func"]["inputs"]] + return ["값"] if box["kind"] == "output" else [] + + +def _order(lg: dict) -> list[dict]: + """함수 상자 차례(앞 값을 받는 것이 뒤) — 순환이면 실패""" + boxes = {b["id"]: b for b in lg["boxes"]} + need = {b["id"]: {link["from"]["box"] for link in lg["links"] if link["to"]["box"] == b["id"]} for b in lg["boxes"]} # fmt: skip + done: list[str] = [] + while len(done) < len(boxes): + ready = [i for i in boxes if i not in done and need[i] <= set(done)] + assert ready, "선이 돌고 돎" + done += ready + return [boxes[i] for i in done] + + +@pytest.mark.parametrize("name", ["masonry", "rate", "value"]) +def test_견본_모양(name: str) -> None: + lg = SAMPLES[name] + assert lg["key"] and lg["name"] and lg["resultUnit"] and lg["userElements"] == [] + boxes = {b["id"]: b for b in lg["boxes"]} + assert len(boxes) == len(lg["boxes"]) + sheets = [b["func"]["sheet"]["id"] for b in lg["boxes"] if b["kind"] == "function"] + assert len(set(sheets)) == len(sheets) + for b in lg["boxes"]: + assert b["kind"] in ("input", "function", "output") + assert ( + b["color"].startswith("#") + and b["unit"] + and isinstance(b["x"], int) + and isinstance(b["y"], int) + ) + if b["kind"] == "function": + fn = b["func"] + cells = fn["sheet"]["칸"] + assert "식" in cells[fn["output"]["cell"]] # 출력 하나 = 수식 칸 + assert len({i["cell"] for i in fn["inputs"]}) == len(fn["inputs"]) + if b["kind"] == "output": + assert b["output"]["cost"] in ("노무비", "재료비", "경비", "값") + for link in lg["links"]: + a, z = boxes[link["from"]["box"]], boxes[link["to"]["box"]] + assert link["from"]["name"] in _ports_out(a), link + assert link["to"]["name"] in _ports_in(z), link + # 받는 자리마다 선 하나 · 출력에서 나가는 선 없음 · 왼 → 오 + for b in lg["boxes"]: + for port in _ports_in(b): + got = [x for x in lg["links"] if x["to"]["box"] == b["id"] and x["to"]["name"] == port] + assert len(got) == 1, (b["id"], port) + for link in lg["links"]: + assert boxes[link["from"]["box"]]["kind"] != "output" + assert boxes[link["from"]["box"]]["x"] < boxes[link["to"]["box"]]["x"] + _order(lg) + + +def test_견본_목록_열기() -> None: + got = _node( + "out({ list: S.sampleList(), one: S.sampleLoad('masonry'), none: S.sampleLoad('없음') });" + ) + assert [x["id"] for x in got["list"]] == ["masonry", "rate", "value"] + assert all(x["note"] for x in got["list"]) + assert got["one"]["key"] is None and got["one"]["boxes"] == SAMPLES["masonry"]["boxes"] + assert got["none"] is None + + +# ── 값 ─────────────────────────────────────────────────────────────────── +@pytest.fixture(scope="module") +def master(): + from M01_MasterData import M01_MasterData_Store as store + + cm = store.cm + files = cm.load(folder=store.FOLDER) + return cm, files + + +def _table_rows(table: dict) -> list[list]: + """품셈 표 → 블록(머리 줄 + 줄) — 서버 「표」 와 같은 규칙(범위 = 「이름·아래」 「이름·위」 · 숫자 하나면 둘 다)""" + conds, lines = table.get("조건") or {}, table.get("줄") or [] + names = [*conds, *(table.get("값칸") or {})] + split = { + n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) + } + head = [h for n in names for h in ((f"{n}·아래", f"{n}·위") if n in split else (n,))] + rows = [] + for r in lines: + line: list = [] + for n in names: + v = r.get(n) + if n in split: + line += list(v) if isinstance(v, list) else [v, v] + else: + line.append(v) + rows.append(line) + return [head, *rows] + + +def _plain(v): + return float(v) if isinstance(v, Decimal) else v + + +def _run(lg: dict, master) -> dict: + """상자 차례대로 — 입력 값(예시값 · 노임 · 표) → 함수 상자 시트에 넣고 엔진으로 셈 → 출력 상자 값""" + from common_util.common_util_spreadsheet import recalc_workbooks + + cm, files = master + m = cm.mf.Master(files) + tables = {str(t["키"]): t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or []} # fmt: skip + ports: dict[str, dict] = {} + for b in _order(lg): + into = {x["to"]["name"]: ports[x["from"]["box"]][x["from"]["name"]] for x in lg["links"] if x["to"]["box"] == b["id"]} # fmt: skip + if b["kind"] == "input": + inp = b["input"] + got = {v["name"]: v["example"] for v in inp.get("variables", [])} + got |= { + e["name"]: _plain(cm.mf.element(m, e["ref"], {})[0]) + for e in inp.get("elements", []) + } + if inp.get("table"): + got["표"] = _table_rows(tables[inp["table"]["key"]]) + ports[b["id"]] = got + elif b["kind"] == "function": + fn = b["func"] + sheet = json.loads(json.dumps(fn["sheet"])) + for i in fn["inputs"]: + value = into[i["port"]] + col, row = ord(i["cell"][0]) - 65, int(i["cell"][1:]) + block = value if isinstance(value, list) else [[value]] + for r, line in enumerate(block): + for c, x in enumerate(line): + if x is not None: + sheet["칸"][f"{chr(65 + col + c)}{row + r}"] = {"값": _plain(x)} + book = {"종류": "통합문서", "판": 1, "열": "cf", "서식": [{}], "시트": [sheet]} + out = recalc_workbooks([book])[0][sheet["id"]][fn["output"]["cell"]] + assert "수" in out, out + ports[b["id"]] = {fn["output"]["name"]: float(out["수"])} + else: + ports[b["id"]] = {"값": into["값"]} + return {b["id"]: ports[b["id"]]["값"] for b in lg["boxes"] if b["kind"] == "output"} + + +def test_견본1_원가_calc_와_같음(master) -> None: + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + cm, files = master + row = next( + r + for d in files.values() + if d.get("그룹") == "로직" + for r in d["줄"] + if r["키"] == "GF000219" + ) + given = {v["name"]: v["example"] for v in SAMPLES["masonry"]["boxes"][0]["input"]["variables"]} + want = lb.calc(json.loads(json.dumps(row, default=_plain)), {**given, "초과증가율": 80}, {}) + assert want["ok"], want + got = _run(SAMPLES["masonry"], master)["out-pay"] + print(f"\n견본 ① 노무비 {got} · /calc {want['sums']['계']}") + assert abs(got - float(want["sums"]["계"])) < 1 + + +def test_견본3_값_calc_와_같음_견본2_비율(master) -> None: + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + + cm, files = master + row = next( + r + for d in files.values() + if d.get("그룹") == "로직" + for r in d["줄"] + if r["키"] == "GF000133" + ) + given = {v["name"]: v["example"] for v in SAMPLES["value"]["boxes"][0]["input"]["variables"]} + given["qt"] = given.pop("q") # 견본은 정함대로 「q」 · 기존 로직 입력 이름은 「qt」 + want = lb.calc(json.loads(json.dumps(row, default=_plain)), given, {}) + assert _run(SAMPLES["value"], master)["out-q"] == float(want["result"]) + assert _run(SAMPLES["rate"], master)["out-mat"] == 100000 * 3 / 100