102 Commits
Author SHA1 Message Date
eomsangdonandClaude Fable 5.1 d39eece45e merge(dev): origin/main_desktop_1 합침 — tools/est-test · stmate-test 는 dev(CODEX · 안티그래비티 워크트리 판) 채택 · 데스크탑 메인 폴더 사본 버림(두 번째)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 15:02:46 +09:00
eomsangdon 53aeccc179 Merge remote-tracking branch 'origin/CODEX' into sub_laptop_8 2026-11-08 15:02:17 +09:00
eomsangdon 389ab245e7 chore(tools): 데스크탑 메인 tools 변경 — est-test 개발분 · line-stack-test 걷힘(804-7) — 전역 깃 싱크(요구 689-3) 2026-11-08 15:01:36 +09:00
eomsangdon 87433105f7 wip(est-test): 개발 중 저장 2 — 전역 깃 싱크(요구 689-3) 2026-11-08 15:01:33 +09:00
eomsangdonandClaude Opus 5.5 eb64bf64ac feat(804-8): M02 단가산출 수식 탭 = M01 과 같은 단추 · 저장 한 번 · 머리 단추 줄바꿈 막음(요구 689)
- 공용 줄 쌓기 화면: 페이지가 저장을 맡는 꼴(`page`)을 M02 단가산출 화면에도 — 탭 위 [고침 버림] · [수식 저장] 걷음 · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지는 그대로 · 「줄 쌓기에서 못 고치는 칸」 안내 줄 걷음(금액 줄 기호 = 율 줄이 앞 줄 금액을 부르는 속 이름 · 화면에서 고칠 값 아님 · 보존 그대로)
- M02 머리 단추 = [초기화] · [삭제] · [되돌림] · [저장] — 단가산출 상세는 [새로고침] 숨김 · [되돌림] · [저장] = 고침 있을 때만(기본정보 + 수식) · [저장] 한 번 = 수식 먼저 저장(새 판 · 수식 칸 받음) → 단가산출 문서 저장 · [되돌림] · 버리고 떠남 = 수식 캐시도 비움
- 보기 고르기(수식 · 단가산출 상세) = M01 처럼 머리 줄 · [항목 추가] · [율 줄 추가] 걷음(줄 더하기 = 수식 탭 [+] 단가산출 · 일위대가 복사) · 일위대가 줄 모달 안 상세는 그대로
- 머리 단추 줄 맞춤(부품 상자 `fitButtonRow`): 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 아이콘만(글 = 풍선 도움말) · M01 단추 다섯 · M02 단추 넷 아이콘 · 단가산출 상세 긴 이름은 제목 안에서 줄바꿈
- 사본 할일 막힘 알림 글 = [저장] 또는 [되돌림] 먼저
- 시험: test_804_7 에 M02 단추 · 저장 한 번 · 줄 맞춤 지킴 더함 · test_804_4 권한 시험에 수식 저장 판 = 페이지 저장 판 이어 씀 확인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:56:31 +09:00
eomsangdon ef5fb0632f Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3 2026-11-08 14:24:47 +09:00
eomsangdon fcf5f457f0 Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_3 2026-11-08 14:23:15 +09:00
eomsangdon 1fee1e950c Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-11-08 14:23:14 +09:00
eomsangdonandClaude Fable 5.1 e699d95857 knowledge(요구689): M02 보기 = M01 · 저장 단추 · 전역 싱크
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 14:23:10 +09:00
eomsangdon ef3cc96509 Merge remote-tracking branch 'origin/CODEX' into main_laptop_1 2026-11-08 14:21:03 +09:00
eomsangdon af2d9826cf Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-08 13:36:24 +09:00
eomsangdonandClaude Opus 5.5 abe722f378 feat(804-7): 줄 쌓기 「수식」 탭 수정 2차(요구 687 · 688)
- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/logic/remove`) → 로직 목록 · 부르는 곳(다른 수식 문서 · 일위대가 조합 · M02 단가산출 바탕)이 있으면 확인 창 전에 막고 까닭(`/logic/users`)
- 머리 단추 다섯 — [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · [저장] 한 번 = 기본정보 + 수식 · [되돌림] = 둘 다 저장 뒤 상태 · 줄 쌓기 [고침 버림] · [수식 저장]은 M01 에서 걷음(M02 사본은 그대로)
- 합계 상자 화면 아래 고정 · 원문 손잡이 브라우저 오른쪽 끝
- 왼쪽: 그룹 머리 한 줄(접어도 [+] · X 자리 그대로) · 「비우면 번호 없음」 · 가나다 체크 걷음 · 금액 줄 한 줄 배치 · 이름 단추 = 그 이름 찾은 찾기 창 · 수량 기호 = 값 + 출처 꼬리표 · [+] 메뉴(단가산출 · 율 · 식 · 근거 글 · 틀 찾기 걷음) · 식 글 눌러 그 자리 고침([식 고치기] 걷음) · 단위 = 공용 단위 고르기(친 글도 씀) · 만약 안 탄 갈래 숨김
- 오른쪽 기계 줄: 비목 수 링크 걷음 · 제목 줄 누름 = 시간당 경비 펼침 · 펼친 제목 보통 글
- 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) — 변환기가 자원 이름으로(`k_하베스터`) · 수식 문서 다시 빌드(10 파일 · 78 기호 · 금액 같음)
- 시험 페이지 `tools/line-stack-test` · main.py 마운트 · vite 프록시 · M01 왼쪽 「시험: 줄 쌓기」 걷음
- 단위 표준 표 읽기 = 로그인만(M02 일반 사용자 단위 목록)
- 시험: test_804_7 새로 · 804_2 · 804_4 이음 글 고침(createStackScreen 줄 나뉨) · 552_3 여는 단추 기본 ⋯ 꼴로 고침(부르는 쪽이 [+] 로 바꿀 수 있음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:33:36 +09:00
eomsangdon 944f52cd6f Merge commit 'e5e34d82fb9a2361b8bb3f3eebffe20a61ededef' into CODEX 2026-11-08 12:53:06 +09:00
eomsangdonandClaude Fable 5.1 fd188fdf2b knowledge(요구688): 그룹 닫으면 X 밀림
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 12:46:16 +09:00
eomsangdonandClaude Fable 5.1 89292a75df knowledge(요구687): 줄 쌓기 수정 18 · 그림 셋
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 12:45:09 +09:00
eomsangdonandClaude Fable 5.1 e5e34d82fb merge(dev): origin/main_desktop_1 합침 — tools/est-test · stmate-test 는 dev(CODEX · 안티그래비티 워크트리 판) 채택 · 데스크탑 메인의 묵은 사본 17 파일 버림 · 경쟁사 프로그램 폴더 재편은 그대로 받음
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 12:16:18 +09:00
eomsangdon 53a33b07c1 Merge remote-tracking branch 'origin/안티그래비티' into sub_laptop_8 2026-11-08 12:15:03 +09:00
eomsangdon ebcec5ffa0 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-11-08 12:15:02 +09:00
eomsangdon 47f4a729b4 chore(tools): line-stack-test 시험 페이지 — 시놀로지가 복사한 랩탑 판(메인 3e64aa2a7 과 같은 내용 · 합칠 때 같은 추가로 풀림) 2026-11-08 12:13:58 +09:00
eomsangdon 640903d6ef wip(stmate-test): 개발 중 저장 — 전역 깃 싱크(요구 686-2) · tools/stmate-test 2026-11-08 12:13:22 +09:00
eomsangdon 1beaefa529 wip(est-test): 개발 중 저장 — 전역 깃 싱크(요구 686-2) · tools/est-test · resources/tester 2026-11-08 12:12:37 +09:00
eomsangdon 2885a51854 knowledge(경쟁사): 경쟁사 프로그램 폴더 재편(STmate 분석 → STmate · 오솔길 분석 → 오솔길/ · 다산소프트 · EST example) + tools est-test · stmate-test 시험 프로그램 — 데스크탑 메인 미커밋 자료(요구 686-2) 2026-11-08 12:08:45 +09:00
eomsangdonandClaude Fable 5.1 db1d976ea8 merge(sub_laptop_2): 802-4b-1 멈춘 wip(5799ccbe9 · 8406fd236 · 32208c357) 보관하고 메인 판 채택 — 충돌 M02_Doc_Sk · Doc_Sk_Path 는 804 흐름(메인)으로 · wip 는 tag wip/802-4b-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 11:47:53 +09:00
eomsangdonandClaude Opus 5.5 32208c3577 wip(802-4b-1): 중지 — 빈 산근 = 엑셀 0 원 줄(전후 다름 6 블록 고침 · 시험 한 건)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:30:28 +09:00
eomsangdonandClaude Opus 5.5 8406fd2367 fix(802-4b-1): M02 파생 SK 옵션 값 = M01 문서 기본(자재지역 자동값 0 → 포항) — 받은 값이 이김 · 금액 그대로
- `M02_Doc_Copy.given_of` = M01 수식 탭 기본(`master_equation.given_of` · 자동값 › 기본 제안) › SK 받은 값(빈 값 = 기본) — 옛 = 엔진 자동값만(입력 기본 없는 자재지역 = 0)
- SK 파생 · 일위대가 · SC 줄 문서 · 가져온 식 문서 모두 같은 길 · 8004 GC000211 바탕 SK 자재지역 포항 · 계 124,419 그대로
- 시험 test_802_4a 옵션 값 한 건(빈 입력 · 빈 글 = M01 기본 · 울진 = 울진 · 금액 = 옛 셈) · 795_1 · 795_2 · 795_3 · 795_4 · 802_4a · 802_4b1 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:29:18 +09:00
eomsangdonandClaude Opus 5.5 5799ccbe90 feat(802-4b-1): 엑셀 불러오기 가져온 식 SK = 수식 문서 — 숫자 값 · 이음 = 연결 + 엑셀 값 기억 · 산근 속 산근 = 형제 단가산출 연결 · 고른 소요량 = 표 연결 · 표지
- 새 `M02_Doc_Import` — 엑셀 단가 기준 엔진 줄(가져온 줄 + SK 고침)을 `master_doc_convert` 로 문서로 옮김(손 값 = 숫자 · 중간 = 식 줄 · EST 얼린 변수 = 숫자 · 덧줄 · 단계 · 끝전 · 자리 · 합밖 그대로)
  · 인력 · 재료 · 유가 · 환율 이음 = 요소 · 기계 EQ = 기계경비 단가산출(칸 = 그 줄 비목 · 시간당 자료 없음 = 숫자 + 설명) · 로직 이음 = 단가산출 · 참조 = 형제 SK 단가산출(칸 = 참조비목)
  · 연결 칸 `엑셀` = 엑셀 값 기억 · 머리 `가져온` {파일, 시트, 행, 갈래, 코드, 표지(797-1a · 못 읽음 까닭 그대로)}
  · 소요량 · 계수 고른 칸(797-2) = 그 수 자리에 표 연결 기호(꼴 pct · kgt = ÷ 배)
- 셈 = 수식 문서 계산기 — [엑셀 단가 그대로] = 엑셀 값 기억 연결을 숫자로 · [우리 단가로] = 연결 값 · 형제 SK = 부르는 쪽 단가기준(안쪽이 바뀌면 따라감)
- SK 금액 = 문서 셈(`M02_Doc_Sk.on_run` · 가져온 식도 수식 사본 · 파생 = 셈마다 지음 · 따로 = 수식 길 저장본 · 가져온 줄 · 이음 · 고름 고침 409)
- 수식 길(…/sangeun/{키}/doc · preview · save) 가져온 식도 · 사본 표지 단가기준 · 단가갱신 = 문서 그대로
- TS 짝 `common_util_formula_doc_import.ts` `importView`(브라우저 계산기 엑셀 보기 · 거울 시험)
- 옮기기 `M02_Doc_Migrate` 가 가져온 식 SK 도 대조 · 씀(--apply --backup) — 실제 storage 보기만: 개인 가져온 식 1 같음
- 시험 test_802_4b1(9) · 불러오기 시험 793_1 · 793_2 · 793_3a · 793_3b · 797_1a · 797_1 · 797_2 · 802_4a 147 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:03:26 +09:00
eomsangdon 5e72aff136 fix(est): apply head cost classification safely 2026-11-07 16:39:55 +09:00
eomsangdon 9eae77d8f2 fix(est): apply selected estimate classification safely 2026-11-07 16:30:44 +09:00
eomsangdon c530ac694c feat(est): replace cost calculation text 2026-11-07 16:18:30 +09:00
eomsangdon cc1c1c659d fix(est): ignore stale Excel quantity selections 2026-11-07 16:13:02 +09:00
eomsangdon 132371f2f3 test(est): cover Korean quantity labels 2026-11-07 16:09:01 +09:00
eomsangdon 36589d1a36 test(est): verify Excel links in new projects 2026-11-07 16:08:02 +09:00
eomsangdon fb54335811 feat(est): preserve mismatched quantity results 2026-11-07 16:06:33 +09:00
eomsangdon 7d6d9769d5 fix(est): reserve deleted Excel link targets 2026-11-07 16:01:55 +09:00
eomsangdon 041d4e653e feat(est): exchange Excel quantity links 2026-11-07 15:59:24 +09:00
eomsangdon fd6e2e6933 feat(est): persist and refresh Excel quantity links 2026-11-07 15:56:04 +09:00
eomsangdon aac94d6508 feat(est): apply quantity from an Excel cell 2026-11-07 15:49:57 +09:00
eomsangdon c788960bf2 fix(est): create quantity sheets on remote HTTP 2026-11-07 15:44:05 +09:00
eomsangdon f6e7095e3e test(est): cover combined quantity edits 2026-11-07 15:41:32 +09:00
eomsangdon d02fa37723 fix(est): preserve quantity results on label changes 2026-11-07 15:39:23 +09:00
eomsangdon 8d4d23e783 feat(est): save quantity item labels in ESTX 2026-11-07 15:37:20 +09:00
eomsangdon 1d1f27bdff feat(est): configure separate print margins 2026-11-07 15:33:06 +09:00
eomsangdon 7d9837a081 feat(est): locate cost formula errors 2026-11-07 15:29:31 +09:00
eomsangdon 94c7d29b11 fix(est): name copied projects when saving 2026-11-07 13:18:48 +09:00
eomsangdon 07a292a009 feat(est): open backups and support remote creation 2026-11-07 13:16:11 +09:00
eomsangdon 2bcb8bf9b9 feat(est): allow access on configured network address 2026-11-07 11:37:13 +09:00
eomsangdon 600d7b0ab1 feat(est): show standard price design changes 2026-11-07 11:35:02 +09:00
eomsangdon f915e0c33b feat(est): inspect standard price usage 2026-11-07 11:31:12 +09:00
eomsangdon ba95a6d606 fix(est): preserve notes on added report forms 2026-11-07 11:27:41 +09:00
eomsangdon fb460cd0a3 fix(est): keep progress round names unique 2026-11-07 11:25:41 +09:00
eomsangdon 20ef5a6a7f fix(est): reject oversized attachments early 2026-11-07 11:23:39 +09:00
eomsangdon d2a8889475 fix(est): reject duplicate quantity sheet codes 2026-11-07 11:19:28 +09:00
eomsangdon cb0a4fbfec feat(est): clear estimate quantities 2026-11-07 11:17:58 +09:00
eomsangdon 46b3c93f66 feat(est): reset all progress rounds 2026-11-07 11:15:31 +09:00
eomsangdon 083ad3b46b feat(est): manage local project attachments 2026-11-07 11:13:59 +09:00
eomsangdon 2cf1f17c2a feat(est): set precision on selected rows 2026-11-07 11:08:48 +09:00
eomsangdon 539aa04712 fix(est): avoid duplicate copied group codes 2026-11-07 11:06:05 +09:00
eomsangdon 83d3d43f10 feat(est): apply all quantity sheets 2026-11-07 11:04:23 +09:00
eomsangdon b6a3392bc4 feat(est): add data issue search 2026-11-07 11:02:20 +09:00
eomsangdon ad01861c48 feat(stmate-test): 100% COSTN.SMT interpreter, 5-tier X/D/B/BOQ/Kanjub cascade engine, 30-sheet reports & 6-sheet Excel/XML export 2026-11-07 11:01:37 +09:00
eomsangdon db02fa9994 feat(est): edit review summary in workflow 2026-11-07 10:56:50 +09:00
eomsangdon cfe78e2e3b fix(est): guard invalid contract review totals 2026-11-07 10:54:02 +09:00
eomsangdon 12950205f6 feat(est): reorder manual quantity formulas 2026-11-07 10:51:42 +09:00
eomsangdon 0098ba58e2 feat(est): apply progress to schedule 2026-11-07 10:50:03 +09:00
eomsangdon 078fe9034f feat(est): export report results to Excel 2026-11-07 10:47:39 +09:00
eomsangdon 9787b4b1f6 feat(est): print annual item allocations 2026-11-07 10:42:16 +09:00
eomsangdon 7422a70ac4 fix(est): match repeated escalation items 2026-11-07 10:40:30 +09:00
eomsangdon c1a4b9a178 fix(est): reject ambiguous progress inputs 2026-11-07 10:38:36 +09:00
eomsangdon 448b92276f fix(est): preserve unmatched progress exclusions 2026-11-07 10:36:30 +09:00
eomsangdon 5302684c93 fix(est): guard cross-table row paste 2026-11-07 10:34:40 +09:00
eomsangdon 4f33cafb12 feat(est): move estimate rows safely 2026-11-07 10:33:22 +09:00
eomsangdon 0f635cef0f fix(est): distinguish repeated change items 2026-11-07 10:31:12 +09:00
eomsangdon eb3dba0e9e feat(est): compare design change cost items 2026-11-07 10:29:31 +09:00
eomsangdon 5f9b5432ec feat(est): print progress and adjustment costs 2026-11-07 10:27:09 +09:00
eomsangdon e4b7322bb6 feat(est): exchange price adjustment inputs 2026-11-07 08:53:42 +09:00
eomsangdon 95c7acb7d4 feat(est): print annual allocations 2026-11-07 08:49:33 +09:00
eomsangdon cffeb84ce4 feat(est): print progress and price adjustments 2026-11-07 08:48:25 +09:00
eomsangdon b3bd093d42 fix(est): preserve fixed review amounts 2026-11-07 08:45:49 +09:00
eomsangdon 3a10256e69 feat(est): reset individual review entries 2026-11-07 08:30:16 +09:00
eomsangdon 35d3cc936c fix(est): validate imported review targets 2026-11-07 08:28:29 +09:00
eomsangdon a0461bf042 fix(est): retain legacy quantity links in Excel 2026-11-07 08:25:13 +09:00
eomsangdon bc41c8716f feat(est): exchange annual and draft progress 2026-11-07 08:24:00 +09:00
eomsangdon 50d66fae78 feat(est): exchange contract review inputs 2026-11-07 08:20:03 +09:00
eomsangdon 2f20e29d31 feat(est): record contract review reasons 2026-11-07 08:18:19 +09:00
eomsangdon 1d02b0205a fix(est): block missing quantity markers 2026-11-07 08:14:32 +09:00
eomsangdon ab5a07ff34 feat(est): exchange quantity documents in Excel 2026-11-07 08:12:27 +09:00
eomsangdon a8c20c3f19 feat(est): exchange quantity and schedule sheets 2026-11-07 08:07:11 +09:00
eomsangdon f20cbe44d1 feat(est): print contract review summary 2026-11-07 08:00:37 +09:00
eomsangdon 922c9a05a1 feat(est): add contract review workflow 2026-11-07 07:59:24 +09:00
eomsangdon 4e50e2fe94 feat(est): reorder annual work stages 2026-11-07 07:53:54 +09:00
eomsangdon b279b964ce feat(est): link quantity to individual rows 2026-11-07 07:51:42 +09:00
eomsangdon b6d98ac9d5 feat(est): print quantity formulas and results 2026-11-07 07:47:58 +09:00
eomsangdon 9380080afe fix(est): show changes without item codes 2026-11-07 07:46:09 +09:00
eomsangdon 19d7093050 fix(est): avoid reused quantity references 2026-11-07 07:44:29 +09:00
eomsangdon 8c1da2c29e feat(est): populate schedule from estimates 2026-11-07 07:42:34 +09:00
eomsangdon 4c6bb9d70a fix(est): apply selected quantity calculations 2026-11-07 07:37:40 +09:00
eomsangdon 864ff1f55b feat(est): manage quantity calculation sheets 2026-11-07 07:33:57 +09:00
eomsangdon 6cea0c97f2 fix(est): reject reserved annual names 2026-11-07 02:43:36 +09:00
eomsangdon 042d574fee test(est): cover original text style boundary 2026-11-07 02:41:05 +09:00
eomsangdon 19097b7255 fix(est): match prior unnamed cost codes 2026-11-07 02:38:42 +09:00
eomsangdon c03929e369 fix(est): combine quantity text edits 2026-11-07 02:35:12 +09:00
eomsangdon 170086a5b1 fix(est): reject ambiguous quantity targets 2026-11-07 02:33:07 +09:00
8197 changed files with 16381 additions and 3080 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ const TEXT = {
Before_After: ["계", "Sum"],
Job_Done: ["적용함", "Applied"],
Dirty_First: [
"저장 안 한 고침이 있음 — [수식 저장] 또는 [고침 버림] 먼저",
"저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저",
"Save or discard edits first",
],
No_Key: [
+103 -20
View File
@@ -38,7 +38,21 @@
display: none;
}
/* 산출근거(요구 683) — 왼쪽을 내려도 화면에 고정 · 길면 자기 스크롤(높이 = 스크롤 영역 · fit()) */
/* 합계 상자(요구 687-3) — 스크롤해도 화면 아래 고정 */
.lstk__foot {
position: sticky;
inset-block-end: 0;
z-index: 2;
padding-block: var(--spacing-8);
background: var(--color-surface);
border-block-start: 1px solid var(--color-border);
}
.lstk__foot:empty {
display: none;
}
/* 산출근거(요구 683) — 왼쪽을 내려도 화면에 고정 · 길면 자기 스크롤(높이 = 스크롤 영역 − 합계 상자 · fit()) */
.lstk[data-edit="1"] .lstk__right {
position: sticky;
inset-block-start: 0;
@@ -81,7 +95,9 @@
margin-block-start: calc(-1 * var(--spacing-8));
}
/* 그룹 머리 한 줄(요구 687-6 · 688) — 이름 칸이 늘고 줄어 [+] · X 는 늘 오른쪽 끝(접어도 그대로) */
.lstk-ihead {
flex-wrap: nowrap;
align-items: center;
padding: var(--spacing-4) var(--spacing-8);
border-inline-start: 4px solid var(--color-accent);
@@ -93,7 +109,18 @@
border-inline-start-color: var(--color-border-strong);
}
.lstk-ihead > .lstk-f-l {
flex: 1 1 auto;
min-width: 4em;
}
.lstk-count {
flex: none;
white-space: nowrap;
}
.lstk-no {
flex: none;
min-width: 1.5em;
font-weight: var(--font-weight-bold);
color: var(--color-text);
@@ -109,14 +136,12 @@
}
.lstk-ihead,
.lstk-row,
.lstk-fhead,
.lstk-frow,
.lstk-find,
.lstk-ltools {
.lstk-find {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
align-items: center;
gap: var(--spacing-4) var(--spacing-8);
min-width: 0;
}
@@ -125,28 +150,74 @@
align-items: center;
}
/* 줄 카드 — 금액 · 글 = 한 줄(요구 687-7 · [손잡이][종류][이름][수량][단위][비목][X] · 좁으면 접힘) · 공식 = 머리 줄 + 식 줄 */
.lstk-line {
position: relative;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
padding-inline: var(--spacing-24) var(--spacing-32);
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4) var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
/* 글 · 근거 칸 = 한 줄 높이(세로 묶음에서 늘지 않게) */
.lstk-line > .lstk-f-l {
.lstk-line.k-공식 {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
position: relative;
padding-inline-start: var(--spacing-24);
}
.lstk-line > .lstk-x,
.lstk-fhead > .lstk-x {
margin-inline-start: auto;
}
.lstk-line > .lstk-f-l,
.lstk-line > .lstk-qty {
flex: 1 1 8em;
min-width: 6em;
}
.lstk-line > .lstk-f-m {
flex: 1 1 7em;
min-width: 6em;
width: auto;
}
.lstk-line .lstk-kind {
flex: none;
min-width: 2.5em;
}
.lstk-wide {
flex-basis: 100%;
}
.lstk-qty.ui-btn {
justify-content: flex-start;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lstk-upick {
flex: none;
}
.lstk-ltools {
position: absolute;
inset-block-start: var(--spacing-4);
inset-inline-end: var(--spacing-4);
gap: 0;
/* 보이는 글을 눌러 고침(요구 687-14) */
.lstk-tap {
cursor: text;
border-radius: var(--radius-sm);
}
.lstk-tap:hover,
.lstk-tap:focus-visible {
background: var(--color-surface-sunken);
text-decoration: underline dotted;
}
/* 끌기(804-5) — 손잡이 · 끄는 것 반투명 · 놓을 자리 = 위 · 아래 선 · 빈 항목 = 점선 */
@@ -155,9 +226,13 @@
}
.lstk-line > .lstk-grip {
flex: none;
}
.lstk-line.k-공식 > .lstk-grip {
position: absolute;
inset-block-start: var(--spacing-12);
inset-inline-start: var(--spacing-8);
inset-block-start: var(--spacing-8);
inset-inline-start: var(--spacing-4);
}
.lstk-edit[data-dragging] {
@@ -185,6 +260,8 @@
}
.lstk-res {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
}
@@ -264,8 +341,14 @@
color: var(--color-text-secondary);
}
/* 펼친 문서 제목 = 보통 글(링크 꼴 없음 · 요구 687-11) */
.lstk-out tr.subhead .lstk-h {
color: var(--color-accent);
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.lstk-ftitle {
cursor: pointer;
}
.lstk-gap {
+161 -102
View File
@@ -11,11 +11,13 @@
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createRowMenu } from "@ui/ui_template_context_menu";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import {
dropSyms,
@@ -36,8 +38,11 @@ import {
addPicked,
aliasText,
repick,
shownUnit,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock } from "./line_stack_rows";
@@ -67,12 +72,14 @@ const field = (
size: "s" | "m" | "l" = "m",
): HTMLElement => {
const f = createInputField({
label,
placeholder: label,
value: value === undefined || value === null ? "" : String(value),
compact: true,
size: 24,
});
f.root.classList.add(`lstk-f-${size}`);
f.input.setAttribute("aria-label", label);
f.input.title = label;
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
@@ -87,7 +94,7 @@ const KIND_KEY: Record<MoneyKind, LsKey> = {
율: "K_율",
};
/** 금액 줄 몸 */
/** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const d = h.doc;
const line = (d.금액 ?? [])[i];
@@ -96,64 +103,45 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
h.change((doc) => fn(doc.금액![i]));
const head = el("div", {
className: "lstk-row",
children: [
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "l"),
field(lt("Unit"), line.단위, (t) => put((l) => (l.단위 = t)), "s"),
...(line.비목 === "묶음"
? []
: [
createSelectField({
label: lt("Cost"),
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
value: line.비목 ?? "노무비",
compact: true,
size: 24,
onChange: (x) => put((l) => (l.비목 = x)),
}).root,
]),
],
});
// 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const shown = (t: unknown): string => aliasText(String(t ?? ""), alias);
const real = (t: string): string => aliasText(t.trim(), alias, true);
const qty = field(
lt("Qty"),
shown(line.수량),
(t) => put((l) => (t.trim() ? (l.수량 = real(t)) : delete l.수량)),
"l",
);
const price: HTMLElement[] = [];
const head: HTMLElement[] = [];
if (own && v) {
// 이름 단추(요구 687-9) — 누르면 찾아 넣기 창이 그 이름을 찾은 채 열림 · 고르면 바꿈
const link = v.연결 ?? {};
const mat = link.재료 as Record<string, unknown> | undefined;
const spec = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
const label = link.키
? kind === "기계" && spec
? spec // 기계 = 기종 이름 · 규격(679 — 코드 번호 아님)
: link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: mat
? String(mat.검색어 ?? mat.이름 ?? mat.상세구분 ?? "")
: "";
const size = (line as { 규격?: string }).규격;
const name = String(line.이름 ?? "") || String(mat?.검색어 ?? mat?.이름 ?? "");
const res = link.키
? link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: "";
const pick = createButton({
label: (size && !label.includes(size) ? `${label} ${size}` : label) || "—",
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
variant: "ghost",
tone: "link",
size: 24,
iconStart: "search",
onClick: () => openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))),
onClick: () =>
openStackPick(
kind,
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
undefined,
String(mat?.검색어 ?? line.이름 ?? ""),
),
});
pick.classList.add("lstk-res");
pick.title = lt("Repick");
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
pick.dataset.repick = String(i);
price.push(pick);
head.push(pick);
} else
price.push(field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"));
head.push(
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"),
);
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
@@ -161,25 +149,97 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
places?.[p] ? `${places[p]} ` : all,
);
const unit = el("span", { className: "lstk-upick" });
const drawUnit = (): void =>
unit.replaceChildren(
createUnitPick({
value: String(line.단위 ?? "") || NO_UNIT,
size: 24,
free: true,
ariaLabel: lt("Unit"),
extra: [...new Set((d.금액 ?? []).map((m) => String(m.단위 ?? "")).filter(Boolean))],
onPick: (u) => put((l) => (u === NO_UNIT ? delete l.단위 : (l.단위 = u))),
}),
);
drawUnit();
return [
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
head,
el("div", {
className: "lstk-row",
children: [qty, el("span", { className: "lstk-unit", text: "×" }), ...price],
}),
...head,
// 율 · 식 줄(자원 없음)에 수량이 없으면 칸 안 보임(한 줄 유지)
...(own || String(line.수량 ?? "") ? [qtyBox(h, i, shown, real)] : []),
unit,
...(line.비목 === "묶음"
? []
: [
createSelectField({
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
value: line.비목 ?? "노무비",
compact: true,
size: 24,
ariaLabel: lt("Cost"),
onChange: (x) => put((l) => (l.비목 = x)),
}).root,
]),
...(places
? [
el("div", {
className: "lstk-hint",
className: "lstk-hint lstk-wide",
text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`,
}),
]
: []),
...(why ? [el("div", { className: "lstk-miss", text: why })] : []),
...(why ? [el("div", { className: "lstk-miss lstk-wide", text: why })] : []),
];
}
/** 수량 — 기호 하나면 값 + 출처 꼬리표(「0.39 ← 표 · 잡석 수량」 · 요구 687-12 · 기호는 마우스 글) · 누르면 그 자리에서 식 고침 */
function qtyBox(
h: EditHooks,
i: number,
shown: (t: unknown) => string,
real: (t: string) => string,
): HTMLElement {
const line = (h.doc.금액 ?? [])[i];
const text = String(line.수량 ?? "");
const v = h.doc.변수?.find((x) => varId(x) === text);
const at = `qty:${i}`;
const edit = (): HTMLElement => {
const f = field(lt("Qty"), shown(text), (t) =>
h.change((doc) => {
h.open.delete(at);
const l = doc.금액![i];
if (t.trim()) l.수량 = real(t);
else delete l.수량;
}),
);
f.classList.add("lstk-qty");
f.dataset.qty = String(i);
return f;
};
if (!v || h.open.has(at)) return edit();
const val = (h.answer.값 ?? {})[text];
const link = v.연결 ?? {};
const kind = link.갈래 ?? "식";
const from =
v.이름 || (link.키 || link.표 ? h.name(String(link.키 ?? link.표)) : "") || shown(text);
const b = createButton({
label: `${isNum(val) ? eqText(val) : "—"}${v.단위 && shownUnit(v.단위) ? ` ${shownUnit(v.단위)}` : ""} ← ${kind} · ${from}`,
variant: "ghost",
size: 24,
onClick: () => {
h.open.add(at);
h.change(() => undefined);
requestAnimationFrame(() =>
document.querySelector<HTMLInputElement>(`[data-qty="${i}"] input`)?.focus(),
);
},
});
b.classList.add("lstk-qty");
b.title = `${lt("Qty")} = ${shown(text)}`;
b.dataset.qty = String(i);
return b;
}
/** [+ 줄] 고르개 값 → 새 줄 */
function addLine(h: EditHooks, at: number, kind: string): void {
if (["인력", "재료", "기계", "단가산출"].includes(kind))
@@ -202,10 +262,6 @@ function addLine(h: EditHooks, at: number, kind: string): void {
(d.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
it.줄.push({ 꼴: "공식", 기호: [id] });
h.open.add(`${at}.${it.줄.length - 1}`);
} else if (kind === "틀") {
const id = freshId(d, "Q", "틀");
(d.변수 ??= []).push(newVar(id, { 갈래: "단가산출", 키: "", 입력: {} }));
it.줄.push({ 꼴: "공식", 기호: [id] });
} else if (kind === "글") it.줄.push({ 꼴: "글", 글: "" });
});
}
@@ -221,28 +277,23 @@ function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
}
export function buildEdit(h: EditHooks): HTMLElement {
// [+] 줄 더함 메뉴(요구 687-13 · 18) — 인력 · 재료 · 기계 · 단가산출 · 율 · 식 · 근거 글
const add = (at: number): HTMLElement => {
const pick = createSelectField({
options: [
{ value: "", text: `${lt("Add_Line")} ▾` },
...(["인력", "재료", "기계", "단가산출", "율"] as MoneyKind[]).map((k) => ({
value: k,
text: lt(k === "단가산출" && h.take ? "K_복사" : KIND_KEY[k]),
})),
{ value: "공식", text: lt("K_새공식") },
{ value: "틀", text: lt("K_틀") },
{ value: "글", text: lt("K_글") },
],
value: "",
compact: true,
size: 24,
ariaLabel: lt("Add_Line"),
onChange: (x) => {
pick.select.value = ""; // 창을 닫아도 [+ 줄 ▾] 로 돌아옴
if (x) addLine(h, at, x);
},
});
return pick.root;
const kinds: [string, LsKey][] = [
["인력", "K_인력"],
["재료", "K_재료"],
["기계", "K_기계"],
["단가산출", h.take ? "K_복사" : "K_단가산출"],
["율", "Add_율"],
["공식", "Add_식"],
["글", "K_글"],
];
const menu = createRowMenu(
kinds.map(([k, label]) => ({ label: lt(label), onClick: () => addLine(h, at, k) })),
{ icon: "plus", title: lt("Add_Line"), size: 24 },
);
menu.classList.add("lstk-add");
return menu;
};
// X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만)
const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => {
@@ -278,7 +329,7 @@ export function buildEdit(h: EditHooks): HTMLElement {
h.items.forEach((_, k) => h.open.delete(`fold:${k}`));
flags.forEach((f, k) => f && h.open.add(`fold:${k}`));
};
// 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 가나다 = 가. 나.)
// 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 옛 가나다 항목 = 가. 나. · 체크 걷음 요구 687-5)
let n = 0;
let sub = 0;
const nos = h.items.map((it) => {
@@ -287,9 +338,21 @@ export function buildEdit(h: EditHooks): HTMLElement {
if (!it.가나다) sub = 0;
return no;
});
// 만약(IF) 안 탄 갈래(요구 687-10) — 갈래를 다 열면 닿고 지금 값으로는 안 닿는 기호 · 꺼진 금액 줄 = 숨김(산출근거와 같음)
const vals = (h.answer.값 ?? {}) as Values;
const taken = new Set(reach(h.doc, vals));
const every = new Set(reach(h.doc, {}, true));
const steps = lineMap(h.doc);
const off = (line: StackItem["줄"][number]): boolean =>
line.꼴 === "금액"
? offLine((h.doc.금액 ?? [])[line.i] ?? {}, vals, steps)
: line.꼴 === "공식" &&
line.기호.length > 0 &&
line.기호.every((id) => every.has(id) && !taken.has(id));
const items = h.items.map((it, i) => {
const folded = h.open.has(`fold:${i}`);
const itemGrip = grip();
// 그룹 머리 한 줄(요구 687-6 · 688) — 손잡이 · 접기 · 번호 · 이름 · [+] · X · 접어도 자리 그대로
const head = el("div", {
className: "lstk-ihead",
children: [
@@ -306,15 +369,10 @@ export function buildEdit(h: EditHooks): HTMLElement {
}),
el("b", { className: "lstk-no", text: nos[i] }),
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
createCheckbox(lt("Gana"), {
checked: !!it.가나다,
size: 24,
onChange: (c) =>
h.change((_, items) => (c ? (items[i].가나다 = true) : delete items[i].가나다)),
}).root,
...(folded
? [el("span", { className: "lstk-hint", text: `${lt("K_줄")} ${it.줄.length}` })]
: []),
el("span", {
className: "lstk-hint lstk-count",
text: folded ? `${lt("K_줄")} ${it.줄.length}` : "",
}),
add(i),
x(lt("Del_Item"), () =>
drop(it.제목.trim() || lt("Item"), (d, items) => {
@@ -325,34 +383,35 @@ export function buildEdit(h: EditHooks): HTMLElement {
),
],
});
const lines = it.줄.map((line, j) => {
const lines = it.줄.flatMap((line, j) => {
if (off(line)) return [];
const lineGrip = grip();
const tools = el("div", {
className: "lstk-ltools",
children: [
x(lt("Del"), () =>
drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) =>
dropLine(d, items, i, j),
),
),
],
});
const del = x(lt("Del"), () =>
drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) =>
dropLine(d, items, i, j),
),
);
const body =
line.꼴 === "금액"
? moneyBody(h, line.i)
: line.꼴 === "글"
? [
el("span", { className: "lstk-kind", text: lt("K_글") }),
field(
lt("K_글"),
lt("Text_Ph"),
line.글,
(t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)),
"l",
),
]
: buildBlock(h, i, j, line);
: buildBlock(h, i, j, line, (id) => every.has(id) && !taken.has(id));
// 금액 · 글 = 한 줄(X 끝) · 공식 묶음 = 머리 줄 끝에 X
const card = el("div", {
className: `lstk-line k-${line.꼴}`,
children: [lineGrip, tools, ...body],
children:
line.꼴 === "공식"
? [lineGrip, ...body.slice(0, 1).map((hd) => (hd.append(del), hd)), ...body.slice(1)]
: [lineGrip, ...body, del],
});
card.dataset.loc = `${i}.${j}`;
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
@@ -365,7 +424,7 @@ export function buildEdit(h: EditHooks): HTMLElement {
h.change((_, items) => moveLine(items, i, j, k, p));
},
});
return card;
return [card];
});
const box = el("div", {
className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`,
+22 -10
View File
@@ -2,12 +2,13 @@
* line_stack_out.ts
* 줄 쌓기 산출근거 출력(PLAN 804-2 · 요구 670-1 · 677) — 경쟁사 「단가산출」 시트 꼴 표(산출근거 · 합계 · 노무비 · 재료비 · 경비)
* 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 × 수량 단위 = 금액」
* 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 단가 수 누름(▸) = 그 문서(기계 시간당 경비 등)를
* 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 접으면 한 줄 · 출력엔 펼친 것만
* 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 제목 줄(「굴착기 0.6㎥」) 누름 = 그 문서(기계 시간당 경비 등)를
* 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 비목 수는 글(링크 꼴 없음 · 요구 687-11) ·
* 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만
* 값 = 계산기 셈 답 그대로(`calcDoc` · 줄 `answer.줄` · 합 `노무비` …) — 여기서 다시 셈 안 함 · 조판 = KaTeX(`line_stack_tex.ts`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el } from "@ui/ui_template_elements";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
@@ -127,6 +128,21 @@ function rows(
b.dataset.expand = path;
return b;
};
/** 기계 · 단가산출 제목 줄 — 접기 단추 + 제목(누르면 펼침 · 요구 687-11) */
const fold = (id: string, title: string): HTMLElement[] => {
const path = `${at}/${id}`;
const on = h.open.has(path);
const b = createIconButton({
icon: on ? "chevronDown" : "chevronRight",
title: lt(on ? "Fold_Sub" : "Unfold_Sub"),
size: 20,
onClick: (e) => (e.stopPropagation(), h.toggle(path)),
});
b.dataset.expand = path;
const t = el("span", { className: "lstk-ftitle", text: title });
t.addEventListener("click", (e) => (e.stopPropagation(), h.toggle(path)));
return [b, t];
};
const expand = (id: string): void => {
const path = `${at}/${id}`;
const sub = subOf(id);
@@ -251,18 +267,14 @@ function rows(
const s = priceSym(line);
const bundle = (r.단가 ?? {}) as Values;
const cols: Cols = {};
push({ h: [name || (s ? (subOf(s)?.이름 ?? s) : "")], cls: "lname" });
const title = name || (s ? (subOf(s)?.이름 ?? s) : "");
push({ h: s && subOf(s) ? fold(s, title) : [title], cls: "lname" });
for (const c of COSTS) {
const part = r.비목?.[c];
if (!isNum(part) || (!part && !bundle[c])) continue;
cols[c] = part;
push({
h: [
`${COST_SHOW[c]} : `,
s ? opener(s, eqText(bundle[c])) : eqText(bundle[c]),
...qtyNode(c),
` = ${one(part)}`,
],
h: [`${COST_SHOW[c]} : `, eqText(bundle[c]), ...qtyNode(c), ` = ${one(part)}`],
});
}
if (s) expand(s);
+9 -2
View File
@@ -39,8 +39,14 @@ export interface StackTake {
take: (src: string) => void;
}
/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 · `m02` 있고 다른 단가산출 = 복사해 펼침 */
export function openStackPick(kind: MoneyKind, done: (p: Picked) => void, m02?: StackTake): void {
/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 · `m02` 있고 다른 단가산출 = 복사해 펼침 ·
* `query` = 찾기 칸에 넣고 연 결과(이름 단추 · 요구 687-9) */
export function openStackPick(
kind: MoneyKind,
done: (p: Picked) => void,
m02?: StackTake,
query = "",
): void {
const want = KINDS[kind];
if (!want) return;
const spec = (e: { 규격: string }) => (e.규격 ? { 규격: e.규격 } : {});
@@ -50,6 +56,7 @@ export function openStackPick(kind: MoneyKind, done: (p: Picked) => void, m02?:
owner: copy?.owner ?? null,
title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`,
kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)
query: query.trim() || undefined,
ref: copy
? (r) =>
r.키 === copy.me && r.층 === copy.layer && (!copy.owner || r.주인 === copy.owner)
+158 -125
View File
@@ -1,14 +1,16 @@
/* =============================================================================
* line_stack_rows.ts
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
* 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 보임 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 ·
* 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로)
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
* 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
@@ -66,10 +68,21 @@ const input = (
return f.root;
};
export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTMLElement[] {
export function buildBlock(
h: EditHooks,
i: number,
j: number,
line: Block,
hide: (id: string) => boolean = () => false,
): HTMLElement[] {
const d = h.doc;
const at = `${i}.${j}`;
const open = h.open.has(at);
const editing = (id: string): boolean => h.open.has(`${at}:${id}`);
/** 그 줄 고침 켬 · 끔(화면 상태) */
const flip = (id: string, on: boolean): void => {
if (on) h.open.add(`${at}:${id}`);
else h.open.delete(`${at}:${id}`);
};
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
@@ -104,15 +117,17 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
className: "lstk-fhead",
children: [
el("span", { className: "lstk-kind", text: label }),
createButton({
label: open ? lt("Fold") : lt("Unfold"),
variant: "ghost",
createIconButton({
icon: "plus",
title: lt("Add_Row"),
size: 24,
onClick: () => {
if (open) h.open.delete(at);
else h.open.add(at);
h.change(() => undefined);
},
onClick: () =>
h.change((doc, items) => {
const id = freshId(doc, "x");
(doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
blockOf(items).기호.push(id);
flip(id, true);
}),
}),
],
});
@@ -177,118 +192,136 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
items[i].줄.splice(j + 1, 0, { 꼴: "공식", 기호: moved, 틀: key, 틀꼴: shapeOf(doc, moved) });
if (!blk.기호.length) items[i].줄.splice(j, 1);
});
const rows = line.기호.map((id) => {
const v = vars.get(id);
const link = v?.연결;
const expr = lines.get(id);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link?.갈래 === "표" && !expr
? tableBtn(id, link)
: link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
createButton({
label: lt("Unfold"),
variant: "ghost",
size: 24,
onClick: () => unfoldRow(id, sub, link.키!),
}),
);
if (link?.갈래 === "단가산출" && !link.키)
tools.push(
createButton({
label: lt("Find_Title"),
variant: "ghost",
size: 24,
onClick: () =>
openFindPicker({
layer: "system",
owner: null,
title: lt("K_틀"),
kinds: ["logic"],
results: true,
logic: (x) =>
h.change((doc) => {
const y = doc.변수?.find((z) => varId(z) === id);
if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} };
}),
}),
}),
);
const name = el("b", { className: "lstk-sym", text: show(id) });
const valueOf =
expr ??
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
let body: (HTMLElement | string)[];
if (open)
body = [
input(show(id), lt("Row_Name"), "lstk-f-s", (t) =>
renameRow(id, t.trim() === show(id) ? id : t),
),
"=",
input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
h.change((doc) => {
let x = doc.변수?.find((y) => varId(y) === id);
if (!x) (doc.변수 ??= []).push((x = newVar(id, { 갈래: "식" })));
x.단위 = t.trim();
/** 누르면 그 줄 고침(보이는 글을 고침) */
const tap = (node: HTMLElement, id: string): HTMLElement => {
node.classList.add("lstk-tap");
node.tabIndex = 0;
node.setAttribute("role", "button");
node.title = lt("Edit_Hint");
const go = (): void => {
flip(id, true);
h.change(() => undefined);
};
node.addEventListener("click", go);
node.addEventListener("keydown", (e) => e.key === "Enter" && go());
return node;
};
/** 고친 뒤 닫음 */
const done = (id: string, fn: () => void): void => {
flip(id, false);
fn();
};
const rows = line.기호
.filter((id) => !hide(id))
.map((id) => {
const v = vars.get(id);
const link = v?.연결;
const expr = lines.get(id);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link?.갈래 === "표" && !expr
? tableBtn(id, link)
: link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
createButton({
label: lt("Unfold"),
variant: "ghost",
size: 24,
onClick: () => unfoldRow(id, sub, link.키!),
}),
),
el("span", { className: "lstk-val", text: `= ${valText(id)}` }),
createButton({
label: "×",
variant: "danger-ghost",
size: 24,
onClick: () => h.change((doc) => dropSyms(doc, [id])),
}),
];
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
body = [
name,
`= ${shownText(expr ?? "")}${expr ? " =" : ""}`,
el("span", { className: "lstk-val", text: valText(id) }),
];
else
body = [
name,
"=",
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
...(shownUnit(v?.단위)
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
: []),
];
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
children: [...body, ...(tag ? [tag] : []), ...tools],
);
if (link?.갈래 === "단가산출" && !link.키)
tools.push(
createButton({
label: lt("Find_Title"),
variant: "ghost",
size: 24,
onClick: () =>
openFindPicker({
layer: "system",
owner: null,
title: lt("K_단가산출"),
kinds: ["logic"],
results: true,
logic: (x) =>
h.change((doc) => {
const y = doc.변수?.find((z) => varId(z) === id);
if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} };
}),
}),
}),
);
const name = tap(el("b", { className: "lstk-sym", text: show(id) }), id);
const valueOf =
expr ??
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
let body: (HTMLElement | string)[];
const open = editing(id);
if (open)
body = [
input(show(id), lt("Row_Name"), "lstk-f-s", (t) =>
done(id, () => renameRow(id, t.trim() === show(id) ? id : t)),
),
"=",
input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) =>
done(id, () => h.change((doc) => setRow(doc, id, realText(t)))),
),
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
done(id, () =>
h.change((doc) => {
let x = doc.변수?.find((y) => varId(y) === id);
if (!x) (doc.변수 ??= []).push((x = newVar(id, { 갈래: "식" })));
x.단위 = t.trim();
}),
),
),
el("span", { className: "lstk-val", text: `= ${valText(id)}` }),
createButton({
label: "×",
variant: "danger-ghost",
size: 24,
onClick: () => done(id, () => h.change((doc) => dropSyms(doc, [id]))),
}),
createIconButton({
icon: "check",
title: lt("Close"),
size: 24,
onClick: () => done(id, () => h.change(() => undefined)),
}),
];
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
body = [
name,
"=",
...(expr
? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id), "="]
: []),
el("span", { className: "lstk-val", text: valText(id) }),
];
else
body = [
name,
"=",
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
...(shownUnit(v?.단위)
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
: []),
];
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
children: [...body, ...(tag ? [tag] : []), ...tools],
});
row.dataset.sym = id;
return row;
});
row.dataset.sym = id;
return row;
});
const more = open
? [
createButton({
label: lt("Add_Row"),
variant: "ghost",
size: 24,
onClick: () =>
h.change((doc, items) => {
const id = freshId(doc, "x");
(doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
blockOf(items).기호.push(id);
}),
}),
]
: [];
return [head, ...rows, ...more];
return [head, ...rows];
}
+32 -28
View File
@@ -1,12 +1,14 @@
/* =============================================================================
* line_stack_screen.ts
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
* 위 = 옵션 가로(`formula_doc_options.ts`) · [고침 버림] · [수식 저장](관리자 · 층 쓰기) · M02 사본 [단가 갱신] · [재생성]
* 위 = 옵션 가로(`formula_doc_options.ts`) · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지(층 쓰기)
* `page` 있음(M01 804-7 · M02 804-8) = 저장 · 되돌림 단추 없음 — 페이지 [저장] · [되돌림]이 `save` · `discard` 를 부름 ·
* 고침이 바뀌면 `page()` · `page` 없음(일위대가 줄 모달 등) = 위에 [고침 버림] · [수식 저장]
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계)
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 · 화면 아래 고정 · 요구 687-3)
* 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
* 고침 = 캐시(sessionStorage)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
* ========================================================================== */
@@ -27,7 +29,6 @@ import type { DocTable } from "../common_util/common_util_formula_doc_table";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
docOf,
notesOf,
stackOf,
type StackDoc,
type StackItem,
@@ -60,6 +61,8 @@ export interface StackScreenOptions {
copy?: CopyOptions;
/** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */
scope?: { layer: Layer; owner: string | null };
/** 페이지가 저장 · 되돌림을 맡음(M01 804-7 · M02 804-8) — 고침 있음이 바뀔 때 부름 · 화면 위 [고침 버림] · [수식 저장] 없음 */
page?: () => void;
}
type Docs = Record<string, FormulaDoc>;
@@ -77,20 +80,20 @@ const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
export function createStackScreen(opt: StackScreenOptions) {
const head = el("div", { className: "lstk__head" });
const notes = el("div", { className: "lstk__notes" });
const left = el("div", { className: "lstk__left" });
const right = el("div", { className: "lstk__right" });
const foot = el("div", { className: "lstk__foot" });
const root = el("div", {
className: "lstk",
attrs: { "data-line-stack": "" },
children: [head, notes, el("div", { className: "lstk__areas", children: [left, right] }), foot],
children: [head, el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
// 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css)
const fit = (): void => {
let p = root.parentElement;
while (p && !/(auto|scroll)/.test(getComputedStyle(p).overflowY)) p = p.parentElement;
root.style.setProperty("--lstk-view", `${p?.clientHeight ?? innerHeight}px`);
const view = (p?.clientHeight ?? innerHeight) - foot.offsetHeight;
root.style.setProperty("--lstk-view", `${Math.max(240, view)}px`);
};
window.addEventListener("resize", fit);
const cp = opt.copy;
@@ -148,6 +151,7 @@ export function createStackScreen(opt: StackScreenOptions) {
} catch {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
opt.page?.();
};
// ── 셈 ──
@@ -240,7 +244,7 @@ export function createStackScreen(opt: StackScreenOptions) {
if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
/** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [수식 저장] 때 서버가 다시 셈) */
/** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [저장] 때 서버가 다시 셈) */
const takeIn = async (src: string): Promise<void> => {
try {
const got = await call<Taken>(`/take?src=${encodeURIComponent(src)}`);
@@ -258,7 +262,7 @@ export function createStackScreen(opt: StackScreenOptions) {
const edit = (): boolean => opt.admin && (!!key || !cp);
const draw = (): void => {
if (!doc || !answer) {
[head, notes, left, right, foot].forEach((x) => x.replaceChildren());
[head, left, right, foot].forEach((x) => x.replaceChildren());
return;
}
const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
@@ -269,10 +273,10 @@ export function createStackScreen(opt: StackScreenOptions) {
draw();
});
const busy = dirty();
const tools =
edit() && key
const tools = [
...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : []),
...(edit() && key && !opt.page
? [
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
createButton({
label: ft("Discard"),
variant: "ghost",
@@ -288,7 +292,8 @@ export function createStackScreen(opt: StackScreenOptions) {
onClick: () => void save(),
}),
]
: [];
: []),
];
head.replaceChildren(
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
el("div", {
@@ -305,17 +310,6 @@ export function createStackScreen(opt: StackScreenOptions) {
],
}),
);
const kept = notesOf(doc);
notes.replaceChildren(
...(kept.length
? [
el("p", {
className: "ui-text ui-text--muted",
text: `${lt("Notes")} — ${kept.join(" · ")}`,
}),
]
: []),
);
const items = stackOf(doc);
root.dataset.edit = edit() ? "1" : "";
left.replaceChildren(
@@ -384,8 +378,10 @@ export function createStackScreen(opt: StackScreenOptions) {
if (await askJob(job, got, cache.names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
};
const save = async (job?: Record<string, unknown>, done = ft("Saved")): Promise<void> => {
if (!doc || (!job && !dirty())) return;
let loading: Promise<void> = Promise.resolve();
const save = async (job?: Record<string, unknown>, done = ft("Saved")): Promise<boolean> => {
await loading; // 새 로직 저장 뒤 여는 중이면 다 읽고(캐시 이어 받음)
if (!doc || (!job && !dirty())) return true;
const send = job ?? (cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc });
const was = saved;
showLoadingOverlay();
@@ -402,9 +398,11 @@ export function createStackScreen(opt: StackScreenOptions) {
recalc();
draw();
if (cp && got.판) cp.onSaved?.(got.판);
showToast(done, "success");
if (done) showToast(done, "success");
return true;
} catch (e) {
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
return false;
} finally {
hideLoadingOverlay();
}
@@ -459,6 +457,7 @@ export function createStackScreen(opt: StackScreenOptions) {
if (all[next]) doc = clone(all[next] as StackDoc);
recalc();
draw();
opt.page?.(); // 캐시에서 이어 받은 고침 — 페이지 [저장] · [되돌림] 켬
} catch (e) {
if (mine === seq)
head.replaceChildren(
@@ -483,9 +482,14 @@ export function createStackScreen(opt: StackScreenOptions) {
root,
/** 로직 · SK 키로 그림(빈 키 = 저장 전 새 것) — 같은 키는 그대로 · `force` = 다시 읽음(탭 바꿈) */
draw: (next: string, force = false): void => {
if (force || next !== shown) void open(next);
if (force || next !== shown) loading = open(next);
},
canLeave,
dirty,
/** 페이지 [저장](804-7) — 고침 있으면 서버 정본 저장 · 알림은 페이지가(`done` 빈 글) · 실패 = false */
save: (): Promise<boolean> => save(undefined, ""),
/** 페이지 [되돌림] — 저장 뒤 상태로(캐시 지움) */
discard: (): Promise<void> => discard(),
/** 시험 · 확인용 */
state: () => ({ doc, answer, dirty: dirty(), items: doc ? stackOf(doc) : [] }),
};
+14 -17
View File
@@ -12,7 +12,9 @@ const TEXT = {
Sum: ["계", "Sum"],
Sub_Sum: ["소 계", "Subtotal"],
Add_Item: ["+ 그룹", "+ Group"],
Add_Line: ["+ 줄", "+ Line"],
Add_Line: ["줄 더함", "Add line"],
Add_율: ["율 — 그룹 소계의 %", "Rate — % of subtotal"],
Add_식: ["식 — 공식 틀", "Formula"],
Del_Item: ["그룹 지움", "Delete group"],
Del: ["지움", "Delete"],
Item: ["그룹", "Group"],
@@ -20,23 +22,22 @@ const TEXT = {
Drag: ["끌어 옮기기", "Drag to move"],
Fold_Item: ["그룹 접기", "Collapse group"],
Unfold_Item: ["그룹 펼치기", "Expand group"],
Unfold_Sub: ["시간당 경비 · 산출 펼치기", "Show breakdown"],
Fold_Sub: ["접기", "Hide breakdown"],
Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"],
Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"],
Title: ["그룹 이름(비우면 번호 없음)", "Group title"],
Gana: ["가나다", "Sub"],
Title: ["그룹 이름", "Group title"],
K_인력: ["인력", "Labor"],
K_재료: ["재료", "Material"],
K_기계: ["기계", "Machine"],
K_단가산출: ["다른 단가산출", "Unit price"],
K_복사: ["다른 단가산출 · 일위대가 복사", "Copy unit price · combo"],
K_단가산출: ["단가산출", "Unit price"],
K_복사: ["단가산출 · 일위대가 복사", "Copy unit price · combo"],
Self_Take: ["지금 단가산출 자신은 못 가져옴", "Cannot copy itself"],
Take_Failed: ["복사 못 함", "Copy failed"],
K_율: ["율 · 식", "Rate"],
K_율: ["율", "Rate"],
K_공식: ["공식", "Formula"],
K_표값: ["표 값", "Table values"],
K_새공식: ["공식 — 새 공식(빈 묶음)", "Formula — new"],
K_틀: ["공식 — 틀 찾기(단가산출 결과)", "Formula — template"],
K_글: ["글 · 근거", "Text"],
K_글: ["근거 글", "Basis note"],
Name: ["이름", "Name"],
Unit: ["단위", "Unit"],
Cost: ["비목", "Cost"],
@@ -46,23 +47,19 @@ const TEXT = {
Find: ["찾기", "Find"],
Pick: ["고름 ▾", "Pick ▾"],
None_Found: ["찾은 것 없음", "Nothing found"],
Unfold: ["식 고치기", "Edit formula"],
Fold: ["접기", "Fold"],
Unfold: ["식으로 풀기", "Unfold"],
Edit_Hint: ["눌러 고침", "Click to edit"],
Add_Row: ["식 줄 더함", "Add row"],
New_Formula: ["새 공식", "New formula"],
My_Formula: ["내 공식", "My formula"],
My_Tpl: ["내 틀", "My template"],
Changed_From: ["에서 변경", "changed"],
Add_Row: ["+ 「이름 = 식」 줄", "+ Row"],
Row_Name: ["이름", "Name"],
Row_Expr: ["값 또는 식", "Value or expr"],
Bad_Name: ["못 쓰는 이름 · 같은 이름 있음", "Bad or duplicate name"],
Empty_Item: ["줄 없음 — [+ 줄]", "No lines — [+ Line]"],
Empty_Item: ["줄 없음 — [+]", "No lines — [+]"],
Empty: ["빈 단가산출 — [+ 그룹] 으로 시작", "Empty — start with [+ Group]"],
Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"],
Notes: [
"줄 쌓기에서 못 고치는 칸(보존됨 · 수식 탭에서 고침)",
"Kept fields (edit in Equation tab)",
],
Unsaved_New: ["저장한 단가산출만 — 페이지 [저장] 뒤", "Saved unit prices only"],
Find_Title: ["찾아 넣기", "Find and add"],
Repick: ["찾아 바꾸기", "Find and change"],
+54
View File
@@ -143,3 +143,57 @@ def table(key: str) -> dict:
rec = mr.Rec(ml.Links(store.loaded()[1], {}))
rec.see_table(key)
return rec.표[key]
# ── 로직 지우기(804-7 · 요구 687-1) — 로직 줄 + 수식 문서를 서버 정본에서 함께 ──
def users(key: str) -> list[str]:
"""이 로직을 부르는 곳 — 다른 수식 문서(단가산출 연결) · 일위대가 조합 · M02 단가산출(바탕).
다른 로직 줄이 부르는 것은 로직 저장 검사(422)가 막음."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게
out = []
for k, d in docs().items():
if k != key and any(
(v.get("연결") or {}).get("갈래") == "단가산출"
and str((v.get("연결") or {}).get("키")) == key
for v in d.get("변수") or []
):
out.append(f"단가산출 {k} {d.get('이름') or ''}".strip())
for c in combo_store.combos_of_logic(key)["combos"]:
out.append(f"일위대가 조합 {c.get('키')} {c.get('이름') or ''}".strip())
for where, path in layers.layer_files("sangeun"):
try:
sk = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
if str((sk.get("바탕") or {}).get("로직") or "") == key:
out.append(f"M02 단가산출 {where} {sk.get('이름') or path.stem}")
return out
def drop(key: str) -> None:
"""수식 문서 줄 하나 지움(없으면 그냥)."""
name = _where(key)
if not name:
return
path = folder() / name
data = ml.load_file(path)
data["줄"] = [d for d in data.get("줄") or [] if str(d.get("키")) != key]
path.write_text(ml.dump(data), encoding="utf-8", newline="\n")
_memo.clear()
def remove(key: str, file: str, version: str, confirmed: bool) -> dict:
"""로직 지우기 — 부르는 곳이 있으면 409 {쓰는곳} · 로직 저장 길(개정 확인 · 판 · 검사) 뒤 문서도."""
from M01_MasterData import M01_MasterData_Store_Make as make
used = users(key)
if used:
raise store.StoreError(409, {"쓰는곳": used})
got = make.save(
[{"file": file, "version": version, "changes": [{"op": "delete", "key": key}]}],
confirmed,
)
drop(key)
return got
+19
View File
@@ -415,6 +415,25 @@ def post_logic_doc_save(body: DocBody) -> dict:
return _call(doc_screen.save, body.문서)
class RemoveBody(BaseModel):
key: str
file: str
version: str
개정확인: bool = False
@router.get("/logic/users")
def get_logic_users(key: str) -> dict:
"""로직을 부르는 곳(지우기 전 확인 · 읽기만) — {쓰는곳}."""
return {"쓰는곳": doc_screen.users(key)}
@router.post("/logic/remove")
def post_logic_remove(body: RemoveBody) -> dict:
"""로직 지우기(804-7) — 로직 줄 + 수식 문서 · 부르는 곳 있으면 409 {쓰는곳}."""
return _call(doc_screen.remove, body.key, body.file, body.version, body.개정확인)
@router.post("/logic/doc/links")
def post_logic_doc_links(body: LinksBody) -> dict:
"""연결 후보 — 요소 · 표 · 단가산출(수식 문서) 이름 찾기(읽기만)."""
+3 -1
View File
@@ -5,6 +5,7 @@
* 제목 크기 · 자리 · 단추 차례 · 공용 단추 크기 · 찾기 칸 꼴이 한 벌로 같음.
* ========================================================================== */
import { fitButtonRow } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import "./M01_MasterData_UI_Head.css";
@@ -19,7 +20,7 @@ export interface HeadOptions {
buttons: HTMLElement[];
}
/** 제목 줄 — 제목은 18px · 왼쪽 · 남는 자리를 차지해 단추를 오른쪽으로 밂 */
/** 제목 줄 — 제목은 18px · 왼쪽 · 남는 자리를 차지해 단추를 오른쪽으로 밂 · 넘치면 아이콘 단추는 아이콘만(804-8) */
export function buildHeadBar(opts: HeadOptions): { root: HTMLElement; title: HTMLElement } {
const title = typeof opts.title === "string" ? el("h2", { text: opts.title }) : opts.title;
title.classList.add("m01-head__title");
@@ -27,6 +28,7 @@ export function buildHeadBar(opts: HeadOptions): { root: HTMLElement; title: HTM
className: "m01-head",
children: [...(opts.lead ?? []), title, ...(opts.status ?? []), ...opts.buttons],
});
fitButtonRow(root);
return { root, title };
}
+77 -15
View File
@@ -6,6 +6,9 @@
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* 머리 단추(804-7 · 요구 687-2) = [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장]
* [저장] = 기본정보 · 별칭(로직 줄 `/save`) + 수식(줄 쌓기 `/logic/doc/save`) 한 번에 · [되돌림] = 둘 다 저장 뒤 상태로
* [로직 삭제] = 확인 → 서버 정본에서 바로(`/logic/remove` — 로직 줄 + 수식 문서 · 부르는 곳 있으면 막고 까닭) → 로직 목록
* ========================================================================== */
import { t as L } from "@ui/ui_template_locale";
@@ -23,6 +26,8 @@ import {
fetchLogicFiles,
fetchLogics,
fetchLogicSubs,
fetchLogicUsers,
removeLogic,
saveFiles,
type CalcLine,
type TextAnswer,
@@ -108,7 +113,12 @@ export async function mountM01LogicLab(
let recalc: number | undefined;
const drive = createDrive();
// 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts
const stack = createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts" });
const stack = createStackScreen({
base: "/m01/logic/doc",
admin,
cache: "m01_doc_drafts",
page: () => persist(),
});
const leave = (): Promise<boolean> => stack.canLeave();
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
@@ -166,8 +176,7 @@ export async function mountM01LogicLab(
list.setMarks(marks, extra, aliases);
const n = Object.keys(drafts).length;
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
saveButton.disabled = discardButton.disabled = n === 0;
saveButton.hidden = discardButton.hidden = n === 0; // 저장 안 한 고친 것이 없으면 숨김
saveButton.disabled = discardButton.disabled = n === 0 && !stack.dirty(); // 로직 줄 · 수식 둘 다 봄
};
const touch = (): void => {
@@ -307,6 +316,7 @@ export async function mountM01LogicLab(
};
let allLogics: LogicSummary[] = [];
let saving = false; // [저장] 뒤 목록 다시 읽는 중 — 거름 되살림이 연 로직을 떠나지 않게(저장 뒤 다시 엶)
const reload = async (): Promise<void> => {
const [logics, subs] = await Promise.all([fetchLogics(), fetchLogicSubs()]);
@@ -324,7 +334,7 @@ export async function mountM01LogicLab(
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened) void leave().then((ok) => ok && show(null));
if (opened && !saving) void leave().then((ok) => ok && show(null));
},
}),
);
@@ -351,8 +361,21 @@ export async function mountM01LogicLab(
}
};
/** 부르는 곳 까닭 띠 */
const usedBy = (used: string[]): void => {
errors.replaceChildren(
el("strong", { text: tx("Delete_Used") }),
...used.map((x) => el("div", { text: x })),
);
errors.hidden = false;
};
const onDelete = async (): Promise<void> => {
if (!opened?.row) return;
if (opened.origKey) {
const used = (await fetchLogicUsers(opened.origKey).catch(() => null))?.쓰는곳 ?? [];
if (used.length) return usedBy(used); // 확인 창 전에 막음
}
if (
!(await showConfirmDialog(
tx("Confirm_Delete", { v: opened.row.원문번호 || opened.row.키 }),
@@ -360,21 +383,38 @@ export async function mountM01LogicLab(
))
)
return;
if (opened.origKey === null) {
delete drafts[opened.id];
const was = opened;
if (was.origKey === null) {
delete drafts[was.id];
persist();
show(null);
return;
}
drafts[opened.id] = pick(opened, null);
opened.row = null;
persist();
show(opened);
const key = was.origKey;
showLoadingOverlay();
try {
const one = await fetchLogic(key); // 지금 판(캐시에 옛 판이 남았어도)
const done = await saveRevised((ok) => removeLogic(key, one.file, one.version, ok));
if (!done) return; // 개정 확인 취소
delete drafts[was.id];
await stack.discard(); // 그 로직 수식 캐시도 비움
await reload();
show(null); // 로직 목록
showToast(tx("Delete_Done", { v: was.row?.원문번호 || key }), "success");
} catch (error) {
const used =
error instanceof ApiError ? (error.detail as { 쓰는곳?: string[] })?.쓰는곳 : null;
if (used?.length) usedBy(used);
else failed(error);
} finally {
hideLoadingOverlay();
}
};
const onDiscard = async (): Promise<void> => {
if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return;
drafts = {};
await stack.discard();
persist();
const was = opened;
if (was && was.origKey !== null)
@@ -393,7 +433,10 @@ export async function mountM01LogicLab(
byFile.set(d.file, part);
}
if (!byFile.size) {
showToast(tx("Save_Nothing"), "info");
// 수식만 고침 — 줄 쌓기 정본 저장
if (!stack.dirty()) return void showToast(tx("Save_Nothing"), "info");
if (await stack.save()) showToast(tx("Save_Done"), "success");
persist();
return;
}
showLoadingOverlay();
@@ -403,13 +446,16 @@ export async function mountM01LogicLab(
if (!saved) return; // 개정 확인 취소 = 캐시 그대로
const was = opened;
drafts = {};
await reload();
saving = true;
await reload().finally(() => (saving = false));
showToast(tx("Save_Done"), "success");
const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined;
const made = newKey ? allLogics.find((x) => x.키 === newKey) : undefined;
if (made) await open(logicId(made.구분, made.키), made.구분, made.키);
else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키);
else show(null);
if (opened) await stack.save(); // 수식 고침도 같은 [저장]에(여는 중이면 다 읽고 · 새 로직 = 새 키로 이어 받은 뒤 · 고침 없으면 그냥)
persist();
} catch (error) {
failed(error);
} finally {
@@ -456,15 +502,21 @@ export async function mountM01LogicLab(
}
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const saveButton = createButton({
label: tx("Bar_Save"),
iconStart: "check",
onClick: () => void onSave(),
});
const discardButton = createButton({
label: tx("Bar_Discard"),
variant: "ghost",
iconStart: "undo",
onClick: () => void onDiscard(),
});
const copyButton = createButton({
label: tl("Copy_Open"),
variant: "ghost",
iconStart: "copy",
onClick: () =>
opened?.origKey
? void openCopyScreen({
@@ -483,9 +535,19 @@ export async function mountM01LogicLab(
status: [pending],
buttons: [
views.root,
createButton({ label: tl("New"), variant: "filled", onClick: () => void onNew() }),
createButton({
label: tl("New"),
variant: "filled",
iconStart: "plus",
onClick: () => void onNew(),
}),
copyButton,
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
createButton({
label: tx("Bar_Delete"),
variant: "danger",
iconStart: "trash",
onClick: () => void onDelete(),
}),
discardButton,
saveButton,
],
@@ -61,3 +61,8 @@
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
box-shadow: -3px 0 0 var(--color-accent);
}
/* 원문 손잡이 = 브라우저 오른쪽 끝(요구 687-17) — 칸을 페이지 오른쪽 여백(로직 칸 12 + 패널 24)만큼 밀어 붙임 · 본문이 그 자리까지 */
html:not([data-size="compact"], [data-size="medium"]) .m01-master__panel .m01-logic .m01lab-side {
margin-inline-end: calc(-1 * (var(--spacing-12) + var(--spacing-24)));
}
@@ -307,6 +307,18 @@ export const saveFiles = (
개정확인 = false,
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files, 개정확인 });
/** 로직을 부르는 곳(지우기 전 · 804-7) — 다른 수식 문서 · 일위대가 조합 · M02 단가산출 */
export const fetchLogicUsers = (key: string): Promise<{ 쓰는곳: string[] }> =>
request(`/logic/users?key=${encodeURIComponent(key)}`);
/** 로직 지우기(804-7) — 로직 줄 + 수식 문서 · 409 {쓰는곳} = 부르는 곳 있어 막음 · 409 {개정} = 정본 개정 확인 */
export const removeLogic = (
key: string,
file: string,
version: string,
개정확인 = false,
): Promise<unknown> => request("/logic/remove", { key, file, version, 개정확인 });
/** 본떠 만들기 — 정본·자체 둘 다 본뜸 · 새 키(`GX…`)는 서버가 줌(계약 5장) */
export const copyLogic = (
key: string,
@@ -27,7 +27,7 @@ const TEXT = {
Bar_Back: ["← 로직 목록", "← Logics"],
Bar_Delete: ["로직 삭제", "Delete logic"],
Bar_Save: ["저장", "Save"],
Bar_Discard: ["캐시 삭제", "Clear cache"],
Bar_Discard: ["되돌림", "Revert"],
Bar_Pending: ["저장 안 한 로직 {n} 개", "{n} unsaved logics"],
Head_File: ["파일", "File"],
Head_Key: ["키", "Key"],
@@ -101,12 +101,14 @@ const TEXT = {
Lab_Title: ["단가산출 기초", "Unit-price logic"],
Loading: ["불러오는 중", "Loading"],
Confirm_Delete: [
"로직 「{v}」 을 지울까요? [저장] 때 파일에서 빠짐",
"Delete logic “{v}”? Removed on save",
"로직 「{v}」 을 지울까요? 로직 · 수식이 서버에서 바로 지워짐",
"Delete logic “{v}”? Logic and formula are removed on the server now",
],
Delete_Done: ["로직 「{v}」 지움", "Deleted logic “{v}”"],
Delete_Used: ["부르는 곳이 있어 못 지움", "In use — not deleted"],
Confirm_Discard: [
"저장 안 한 고친 것(캐시)을 모두 지울까요?",
"Clear all unsaved edits (cache)?",
"저장 안 한 고친 것(기본정보 · 수식)을 버리고 저장 뒤 상태로 되돌릴까요?",
"Discard unsaved edits (info · formula) and revert to the saved state?",
],
Confirm_Leave: [
"저장 안 한 로직이 있음 — 다른 로직을 열어도 고친 것은 남음",
+1 -7
View File
@@ -517,15 +517,9 @@ ${first.file}`
aliasSec
.querySelector(".ui-collapsible__title")
?.addEventListener("click", () => void openAlias());
// 시험 페이지 「줄 쌓기 단가산출」(PLAN 804-1) — 새 탭 · 지울 때 이 덩이 + main.py 마운트 + vite.config.ts 줄 + tools/line-stack-test 폴더
const lineStackSec = section("시험: 줄 쌓기", null);
lineStackSec.classList.add("m01-side__leaf");
lineStackSec
.querySelector(".ui-collapsible__title")
?.addEventListener("click", () => window.open("/tools/line-stack-test/", "_blank"));
const root = el("div", {
className: "m01-side ui-sidebar-panel",
children: [...groups, logic, createDivider(), optionsSec, aliasSec, lineStackSec],
children: [...groups, logic, createDivider(), optionsSec, aliasSec],
});
attachCollapsible(root);
void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) =>
+1 -1
View File
@@ -1,7 +1,7 @@
"""단위 · 금액 표준 표 읽기 — `GET /api/m02/df/standards` → {unit, money, saved}.
부르는 곳 = 단위 고르기(`M02_DF_UnitPick.ts`) · 끝수 표준(`M02_DF_Round.ts`).
표 = `_standard/` 파일(git 이 따라감) · 권한 = 시스템 관리자(`main.py` 한 줄).
표 = `_standard/` 파일(git 이 따라감) · 권한 = 로그인만(읽기만 · `main.py` 한 줄 · M02 줄 쌓기 단위 고르기).
"""
from __future__ import annotations
+14 -2
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_DF_UnitPick.ts
* 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음) · 「-」 = 없음
* 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음 · `free` = 목록에 없는 친 글도 씀) · 「-」 = 없음
* 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원
* 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 서버 /standards) — 다른 판도 이것을 씀
* 글 [한, 영] 짝은 아래 TEXT
@@ -8,6 +8,7 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { UiSize } from "@ui/ui_template_button";
import { cfStandards } from "./M02_DF_Api";
import "./M02_DF_UnitPick.css";
import { createEmpty } from "@ui/ui_template_empty";
@@ -17,6 +18,7 @@ const TEXT = {
None: ["없음", "none"],
Filter: ["단위를 쳐서 찾음", "Type to filter units"],
Empty: ["맞는 단위가 없음 — 목록에서만 고름", "No match — pick from the list only"],
Free: ["「{u}」 그대로 씀", "Use “{u}”"],
Pick: ["단위 고르기", "Pick a unit"],
} as const;
@@ -99,6 +101,10 @@ export interface UnitPickOptions {
extra?: string[];
readOnly?: boolean;
ariaLabel?: string;
/** 크기 단(줄 쌓기 24 등) — 없으면 옛 꼴 */
size?: UiSize;
/** 목록에 없는 친 글도 단위로 씀(줄 쌓기 · 804-7) */
free?: boolean;
}
/** 단위 고르기 — 단추를 누르면 걸러지는 목록 · 목록에서 고르기만 됨 */
@@ -108,6 +114,7 @@ export function createUnitPick(opts: UnitPickOptions): HTMLElement {
const open = createButton({
label: opts.value || NO_UNIT,
variant: "ghost",
size: opts.size,
disabled: opts.readOnly,
onClick: () => toggle(),
});
@@ -136,13 +143,18 @@ export function createUnitPick(opts: UnitPickOptions): HTMLElement {
const q = input.value.trim().toLowerCase();
shown = all.filter((u) => u === NO_UNIT || !q || u.toLowerCase().includes(q));
if (q && !NO_UNIT.includes(q)) shown = shown.filter((u) => u !== NO_UNIT);
const typed = input.value.trim();
if (opts.free && typed && !all.includes(typed)) shown.push(typed); // 맨 끝 「그대로 씀」 줄
at = Math.min(at, Math.max(0, shown.length - 1));
list.replaceChildren(
...(shown.length
? shown.map((u, i) => {
const b = el("button", {
className: `ui-list-row ui-sidebar-row m02-df-unitpick__item${i === at ? " is-on" : ""}${u === (opts.value || NO_UNIT) ? " is-now" : ""}`,
text: label(u),
text:
opts.free && u === input.value.trim() && !all.includes(u)
? tu("Free").replace("{u}", u)
: label(u),
attrs: { type: "button", role: "option" },
});
b.addEventListener("click", () => pick(u));
@@ -5,9 +5,11 @@
* [초기화] = 서버 `-reset` 미리 보기(걷힐 고침 수) → 물음 → 그 층에 씀 → 다시 읽음.
* 덮개 · 뺀줄 · 더한줄 단가를 M01 키 값으로 되돌림 · 옵션(입력값) · 기본정보는 그대로(서버 740-1).
* 둘 다 쓰기 권한이 있는 층에서만 보임(`update`) · 상세에 저장 안 한 고침이 있으면 먼저 물음.
* 단가산출 상세(804-8 · M01 단가산출 기초와 같은 단추 규칙) = [새로고침] 숨김 · [되돌림] = 물음 → 다시 읽음(수식 캐시도 비움) ·
* [되돌림] · [저장] = 고침 있을 때만(`dim`)
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { createButton, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { askConfirm } from "@ui/ui_template_modal";
import {
@@ -41,6 +43,11 @@ const TEXT = {
Done: ["M01 값으로 되돌렸음", "Reverted to M01 values"],
DeleteAsk: ["「{name}」 을(를) 지울까?", "Delete 「{name}」?"],
Deleted: ["지웠음", "Deleted"],
Undo: ["되돌림", "Revert"],
UndoAsk: [
"저장 안 한 고친 것(기본정보 · 수식)을 버리고 저장 뒤 상태로 되돌릴까?",
"Discard unsaved edits (info · formula) and revert to the saved state?",
],
Failed: ["못 했음 — {why}", "Failed — {why}"],
} as const;
const tx = (key: keyof typeof TEXT): string =>
@@ -58,6 +65,8 @@ export interface MainActionsCtx {
open: (sel: Selection | null) => Promise<void>;
/** 좌측 목록을 다시 읽음 */
changed: () => void;
/** 페이지 머리 단추 — 단가산출 상세에서 숨김 · 흐림 */
page: { reload: HTMLButtonElement; save: HTMLButtonElement };
}
/** 문서 안 모든 `뺀줄` 길이 합(단가산출 · 일위대가 줄 안 덮개까지) */
@@ -74,10 +83,15 @@ const why = (error: unknown): string => (error instanceof Error ? error.message
export function createMainActions(ctx: MainActionsCtx): {
reset: HTMLButtonElement;
del: HTMLButtonElement;
undo: HTMLButtonElement;
update: (writable: boolean) => void;
/** 고침 있음 — 단가산출 상세의 [되돌림] · [저장] 흐림 */
dim: (dirty: boolean) => void;
} {
const reset = createButton({ label: tx("Reset"), variant: "ghost" });
const del = createButton({ label: tx("Delete"), variant: "danger-ghost" });
const reset = createButton({ label: tx("Reset"), variant: "ghost", iconStart: "rotateCcw" });
const del = createButton({ label: tx("Delete"), variant: "danger-ghost", iconStart: "trash" });
const undo = createButton({ label: tx("Undo"), variant: "ghost", iconStart: "undo" });
undo.dataset.mainUndo = "";
reset.title = tx("ResetTip");
del.title = tx("DeleteTip");
reset.dataset.mainReset = "";
@@ -145,12 +159,27 @@ export function createMainActions(ctx: MainActionsCtx): {
}
});
undo.addEventListener("click", async () => {
const at = ctx.sel();
if (at && (await showConfirmDialog(tx("UndoAsk"), tx("Undo")))) await ctx.open(at);
});
const sk = (): boolean => target()?.kind === "sangeun";
const dim = (dirty: boolean): void => {
undo.disabled = !dirty;
ctx.page.save.disabled = sk() && !dirty;
};
return {
reset,
del,
undo,
update: (writable) => {
const on = writable && !!target();
reset.hidden = del.hidden = !on;
undo.hidden = !on || !sk();
if (sk()) ctx.page.reload.hidden = true;
dim(false);
},
dim,
};
}
@@ -1,13 +1,15 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Detail.ts
* M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로.
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(공용 수식 문서 화면 · 802-4b-2 · 저장은 그 탭 [수식 저장] → 판 · 수식 칸 새로) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
* 보기 둘(`_Sangeun_Tabs` · 799-2) = 수식(공용 수식 문서 화면 · 802-4b-2 · 저장 = 페이지 [저장] 한 번(804-8 · 수식 먼저 → 판 · 수식 칸 새로)) · 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 =
* HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 기본정보 = 바탕 로직(맨 위 접힘) — 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식은 걷음
* 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만
* 옵션 칸 = SK `입력값`(727-3 · 저장 · 열린입력만 시험 값) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판)
* 둘째 기초(727-2) = 서버 `펼침` 세부 줄 · 인력 · 재료 · 경비 상자에 함께 + 꼬리표(기초 둘 이상) · 세부 덮개 ·
* 뺀줄 · [기초 빼기] · M01 개정 알림 · 옵션 칸 = 바탕 입력 + 더한줄 기초 입력
* [항목 추가] 기초 = 바탕 비었으면 바탕(727-2 · 단위가 비거나 「식」이면 기초 결과단위) · 있으면 더한줄 · 바탕 없이 줄만도 됨
* 단가산출 화면(`source` · 804-8) = M01 과 같은 꼴 — 보기 고르기는 페이지 머리 줄(`slot`) · [항목 추가] · [율 줄 추가] 걷음
* (줄 더하기 = 수식 탭 [+] 단가산출 복사) · 수식 고침도 페이지 [저장] 한 번에(`flush` 먼저) · 고침 알림 = 기본 + 수식
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
@@ -36,6 +38,7 @@ import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
import {
@@ -65,6 +68,8 @@ export interface SangeunDetailOptions {
source?: boolean;
/** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */
onVersion?: (version: string) => void;
/** 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에 */
slot?: HTMLElement;
}
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
@@ -74,7 +79,13 @@ export function mountSangeunDetail(
host: HTMLElement,
doc0: unknown,
opt: SangeunDetailOptions,
): { getDoc: () => SkDoc; getValues: () => Record<string, string>; destroy: () => void } {
): {
getDoc: () => SkDoc;
getValues: () => Record<string, string>;
/** 페이지 [저장] 앞 — 수식 고침 저장 · 실패면 던짐 */
flush: () => Promise<void>;
destroy: () => void;
} {
setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1)
const doc = clone((doc0 ?? {}) as SkDoc);
let saved = JSON.stringify(doc);
@@ -100,7 +111,13 @@ export function mountSangeunDetail(
const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } });
const notes = el("div");
const editor = el("div", { className: "m01-logic__editor" });
const tabs = createSangeunTabs(editor, { edit, scope, onSaved: (v) => void formulaSaved(v) });
let refreshing: Promise<void> = Promise.resolve();
const tabs = createSangeunTabs(editor, {
edit,
scope,
onSaved: (v) => void (refreshing = formulaSaved(v)),
page: opt.source ? () => report() : undefined, // 804-8 페이지 [저장] · [되돌림]
});
/** 수식 탭 저장 = 파일 판 · `수식` 칸이 바뀜 — 페이지 판 · 이 화면 문서 `수식` 을 새로(상세 탭 셈이 씀) */
async function formulaSaved(version: string): Promise<void> {
opt.onVersion?.(version);
@@ -132,7 +149,7 @@ export function mountSangeunDetail(
touch();
}),
});
addButton.hidden = !edit;
addButton.hidden = !edit || !!opt.source;
// 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴)
const rateButton = createButton({
label: L("M02_SkRateAdd"),
@@ -144,7 +161,7 @@ export function mountSangeunDetail(
(line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()),
),
});
rateButton.hidden = !edit;
rateButton.hidden = !edit || !!opt.source;
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
let qty = 1;
@@ -177,12 +194,22 @@ export function mountSangeunDetail(
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [tabs.picker, addButton, rateButton] }),
...(opt.slot
? []
: [
el("div", {
className: "m02-sk__head",
children: [tabs.picker, addButton, rateButton],
}),
]),
tabs.host,
editor,
],
});
host.replaceChildren(side ? side.wrap(main) : main);
opt.slot?.replaceChildren(
el("span", { className: "ui-toolbar m02-main__tools", children: [tabs.picker] }),
);
const opened = (): boolean => (doc.열린입력 ?? []).length > 0;
// 바탕 없는 SK 의 지문 = 이름 · 단위 따라 바뀜 — 바탕이 있을 때만 M01 개정으로 봄(725-3) · 둘째 기초 지문(727-2)
@@ -229,8 +256,12 @@ export function mountSangeunDetail(
.filter((i) => !seen.has(i.이름) && seen.add(i.이름));
}
/** 고침 알림 — 기본(이름 · 단위 · 옵션 · 덮개) + 수식 탭 */
function report(): void {
if (alive) opt.onChanged(edit && (JSON.stringify(doc) !== saved || tabs.dirty()));
}
const touch = (again = true): void => {
opt.onChanged(edit && JSON.stringify(doc) !== saved);
report();
if (again) void recalc();
else drawHead();
};
@@ -516,8 +547,14 @@ export function mountSangeunDetail(
return clone(doc);
},
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
flush: async () => {
if (!tabs.dirty()) return;
if (!(await tabs.save())) throw new Error(tl("View_Equation"));
await refreshing; // 새 판 · `수식` 칸 받은 뒤 페이지가 저장
},
destroy: () => {
alive = false;
if (tabs.dirty()) void tabs.discard(); // 페이지가 버리고 떠남 — 수식 캐시도 비움
host.replaceChildren();
},
};
@@ -2,7 +2,8 @@
* M02_MasterTemplete_Sangeun_Tabs.ts
* M02 단가산출(SK) 상세 보기 고르기(PLAN 799-2 · 804-2 · 804-6 · 요구 686-1) — 수식(첫 탭 · 줄 쌓기) · 단가산출 상세.
* 수식 = 공용 줄 쌓기 화면(`A00_Common/line_stack_screen.ts` · M01 과 같은 꼴) — 길 앞머리만 M02 사본 길
* (`/m02/layers/{층}/sangeun/{키}/doc` · 802-4a) · 층 쓰기 권한 없으면 읽기만 · 저장 = 그 탭 [수식 저장](페이지 [저장] 아님)
* (`/m02/layers/{층}/sangeun/{키}/doc` · 802-4a) · 층 쓰기 권한 없으면 읽기만
* 저장(804-8 · `page` 있음 = 단가산출 화면) = 페이지 [저장] · [되돌림] 한 번에 — 탭 위 저장 단추 없음(M01 과 같음)
* 옛 수식 문서 탭(802-4b-2)은 804-6 에 걷음 · 단가산출 상세 = 상세가 쥔 `editor`(수식일 때 숨김)
* 저장 안 한 SK(일위대가 줄 모달 · 새 SK) = 「저장한 단가산출만」 글
* ========================================================================== */
@@ -18,8 +19,10 @@ type View = "stack" | "ho";
export interface SkEquationOptions {
scope: { layer: Layer; owner: string | null };
edit: boolean;
/** [수식 저장] · [단가 갱신] · [재생성] 뒤 그 SK 새 판 */
/** 수식 저장 · [단가 갱신] · [재생성] 뒤 그 SK 새 판 */
onSaved: (version: string) => void;
/** 페이지가 저장 · 되돌림을 맡음(804-8) — 수식 고침 있음이 바뀔 때 부름 */
page?: () => void;
}
/** SK 수식 길 — 꼬리 물음(`/table?key=…`)에 주인 물음을 이어 붙임 */
@@ -42,6 +45,9 @@ export function createSangeunTabs(
/** 저장한 SK 키(빈 키 = 저장 전) */
draw: (key: string) => void;
canLeave: () => Promise<boolean>;
dirty: () => boolean;
save: () => Promise<boolean>;
discard: () => Promise<void>;
} {
const stack = createStackScreen({
base: "",
@@ -49,6 +55,7 @@ export function createSangeunTabs(
cache: "m02_sk_doc_drafts",
copy: { route: (k, p) => skDocPath(opt.scope, k, p), onSaved: opt.onSaved },
scope: opt.scope, // 804-4 다른 단가산출 · 일위대가 복사
page: opt.page,
});
let key = "";
let view: View = "stack";
@@ -81,5 +88,8 @@ export function createSangeunTabs(
if (view === "stack") stack.draw(k);
},
canLeave: () => stack.canLeave(),
dirty: () => stack.dirty(),
save: () => stack.save(),
discard: () => stack.discard(),
};
}
@@ -2,6 +2,7 @@
* M02_MasterTemplete_Sangeun_View.ts
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
* 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`.
* 804-8 = 페이지 [저장] 한 번에 수식도(`flush` 먼저 · 새 판) · 보기 고르기는 머리 줄 `slot`.
* ========================================================================== */
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
@@ -13,12 +14,15 @@ export type SangeunViewOptions = Pick<Selection, "layer" | "owner" | "name"> & {
onChanged: (dirty: boolean) => void;
/** 수식 탭 저장 뒤 그 SK 새 판(802-4b-2) */
onVersion?: (version: string) => void;
/** 머리 줄 빈 자리(804-8) */
slot?: HTMLElement;
};
export function mountSangeunView(
host: HTMLElement,
doc: unknown,
opt: SangeunViewOptions,
): { getDoc: () => unknown; destroy: () => void } {
return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
): { getDoc: () => Promise<unknown>; destroy: () => void } {
const one = mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
return { getDoc: () => one.flush().then(() => one.getDoc()), destroy: one.destroy };
}
@@ -39,6 +39,7 @@ import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { createEmpty } from "@ui/ui_template_empty";
import { fitButtonRow } from "@ui/ui_template_button";
import { createMainActions } from "./M02_MasterTemplete_Main_Actions";
export interface Selection {
layer: Layer;
@@ -140,8 +141,8 @@ export function createMain(
extras: MainExtras = {},
): MainHandle {
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
const save = createButton({ label: L("M02_Save"), variant: "filled" });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost", iconStart: "refresh" });
const save = createButton({ label: L("M02_Save"), variant: "filled", iconStart: "check" });
// 단가산출 · 일위대가 상세 [초기화] [삭제](740-2 · 738-2) — 차례 … [초기화] [삭제] [저장]
const actions = createMainActions({
sel: () => sel,
@@ -150,6 +151,7 @@ export function createMain(
confirmLeave: () => confirmLeave(),
open: (at) => open(at),
changed: () => onSaved?.(),
page: { reload, save },
});
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
const back = createButton({ label: L("M02_Back") });
@@ -183,6 +185,7 @@ export function createMain(
reload,
actions.reset,
actions.del,
actions.undo,
save,
...(extras.newItem ? [extras.newItem] : []),
],
@@ -193,6 +196,7 @@ export function createMain(
],
});
const root = column;
fitButtonRow(column.querySelector<HTMLElement>(".m02-main__head")!); // 넘치면 단추 글 대신 아이콘(804-8)
const isCombo = (at: Selection | null): boolean =>
!!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project";
// 「새 항목」 · 표 접기(제목)는 「구조물 조합」일 때만
@@ -373,10 +377,10 @@ export function createMain(
host.replaceChildren();
if (at.kind === "sangeun") {
const { mountSangeunView } = await import("./M02_MasterTemplete_Sangeun_View"); // 717-2a
const onChanged = (d: boolean): boolean => (dirty = d && !readOnly);
const onChanged = (d: boolean): void => actions.dim((dirty = d && !readOnly));
const onVersion = (v: string): string => (version = v); // 수식 탭 저장(802-4b-2)
if (mine === seq)
editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged, onVersion });
editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged, onVersion, slot });
return;
}
if (at.kind === "table") {
@@ -601,7 +605,7 @@ export function createMain(
);
version = info.판;
loaded = doc;
dirty = false;
actions.dim((dirty = false));
toDrawing.drop(); // 놓기 기다림 · 보내기 취소는 이 저장으로 끝
// 구조물집계표 — 서버가 새 열 파일 · 번호를 만듦 · 번호를 다시 받아 도면 줄 · 카드를 새로
// (안 하면 새 열은 다시 열 때까지 「도면 없음」 · 번호 빈칸)
@@ -52,6 +52,15 @@ html.is-bm-route .m02-main__head {
font-size: 18px; /* M01 머리 제목과 같음(39-5) */
}
/* 단가산출 상세(804-8) — 제목이 남는 자리를 차지 · 긴 이름은 제목 안에서 줄바꿈(단추가 아래로 안 떨어짐) */
.m02-main__head:has(> .m02-main__slot [data-sk-views]) > .m02-main__title {
flex: 1 1 8rem;
}
.m02-main__head:has(> .m02-main__slot [data-sk-views]) > .m02-main__slot {
flex: 0 0 auto;
}
.m02-main__head .ui-btn {
white-space: nowrap;
}
+1 -1
View File
@@ -281,7 +281,7 @@ export function docOf(doc: StackDoc, items: StackItem[]): StackDoc {
return out;
}
/** 줄 쌓기가 못 고치는 문서 칸(보존은 됨 · 수식 탭에서 고침) */
/** 줄 쌓기가 못 고치는 문서 칸(보존은 됨 · 시험이 봄 · 화면 안내 줄은 804-8 에 걷음) */
export function notesOf(doc: FormulaDoc): string[] {
const out: string[] = [];
if (doc.단계?.length) out.push("단계");
@@ -407,3 +407,40 @@ M02 의 엑셀 불러오기에서 경쟁사 2개의 프로그램의 출력을
1. 현 단가산출의 표현 중 수식 탭은 삭제하고 현재 줄 쌓기 탭을 기본보기로 변걍 후 탭 제목을 수식으로 변경.
2. 전역 깃 싱크. 현재 데스크탑 워크트리에 기술정보 db폴더변경되었고 tool 폴더 하위 est 테스트 프로그램은 개발 중이나 전역 깃 싱크.
## 요구 687 (11-08 · 사용자 · 전역 싱크 뒤 줄 쌓기 수정 18 — 그림 `요구687_1_기계줄.png` · `요구687_2_재료수량.png` · `요구687_3_요소키.png`)
전역 깃 싱크 후 이어서 작업
1. M01 단가산출 기초의 항목에서 로직 삭제 안됨. 캐시에만 삭제하는것으로 보임. 삭제후 리스트로 이동
2. M01 단가산출 기초의 페이지에서 상단의 신규로직 부터 저장 버튼까지 역할 점검, 고침 버림과 수식 저장도 같이 확인
3. M01 단가산출 기초의 하단 합계 컨테이너 상시보이기
4. 좌측 영역 그룹이(비우면 번호 없음) 라벨 삭제, 의미 없음.
5. 좌측영역 체크박스와 가나다 라벨 필요없음.
6. 좌측 영역 그룹제목 영역 행배치 정렬 안됨.
7. 좌측영역의 세부항목을 잘하면 1행 구조로도 표현할 수 있을것같은데... 검토
8. 좌측 영역의에서 재료인 경우 수량은
9. 좌측영역에서 재료, 인력,기계인 경우 버튼을 누르면 조회창에 해당 단어를 검색한 결과가 보이게 변경
10. 좌측영역에서 IF문에 의해 결정되는 경우 전체를 나열하는걸로 보이는데 옵션이나 값에 의해 결정된 항목만 보여주는게 어떤지. 공식 표기는 일단 유지.
11. [Image #1] 지난 수정에서 잘못 수정한 부분이 그림의 노무비, 재료비, 경비의 항목을 선택하면 아래 공통항목이 드롭다운으로 열리는게 아니라 상단 굴착기+부작용 집게 0.6을 누르면 보이게 변경. 열린 내부의 제목이 링크 걸린걸로 보임
12. [Image #2] 수량 부분이 옵션에 결려 있는걸로 보임. 다른 표기법이 있을지 검토
13. 좌측영역의 +줄 드랍다운 메뉴는 + 버튼으로 변경
14. 좌측영역에서 식 고치기가 의미가 있는지 검토
15. 단위는 드롭다운 항목으로 변경하는게 있을 것 같음. 다른 곳에 적용된 이력있음. 대신 누락된 단위가 있는지는 검토 필요.
16. M01의 좌측 패널에서 시험:줄 쌓기는 이제 필요없어보임.
17. M01의 단가산출 항목 내부의 페이지를 보면 우측에 원문 펼침 버튼이 중간에 애매하게 배치되어 있음. 좌측 여유때문으로 보여짐. 브라우져 우측에 붙게하여 내부공간을 더 효율적으로 사용
18. + 버튼으로 변경된 + 줄 드롭다운의 메뉴 중
- 율/식은 인력/기계/재료 등에 종속되어 있는 정보가 아닌지.
- 다른 단가산출의 이름을 단가산출로 변경해도 무관해보임.
- 틀찾기는 이제 삭제한 기능으로 보임
- 글/근거의 의미는 모르겠음.
- [Image #3] 요소 키값을 사용하고 있는데 확인 어려움. 원인 분석
## 요구 688 (11-08 · 사용자 · 그룹 닫으면 삭제 단추 밀림)
추가 좌측영역의 그룹을 닫으면 삭제 버튼이 아래로 밀림 개선 필요
## 요구 689 (11-08 · 사용자 · M02 단가산출 보기 = M01 과 같게 · 저장 단추 줄바꿈 · 전역 싱크)
1. m02 단가산출의 보기방식을 m01 의 단가산출 기초와 동일하게 변경. 이때 코드사용은 일원화하되 항목추가에서 m02에서만 가능한 기능이 있르니 조심.(오늘 대화한 적 있음)
2. 저장버튼이 아래로 내려가는건 레이아웃이 안깨지게 변경
3. 1,2 내용 반영 후 전역 깃싱크.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+2 -13
View File
@@ -411,17 +411,6 @@ app.mount(
)
logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}")
# 시험 페이지 「줄 쌓기 단가산출」(PLAN 804-1) — 지울 때 이 덩이 + tools/line-stack-test 폴더 + vite.config.ts 「/tools」 줄 + M01 왼쪽 패널 「시험: 줄 쌓기」 덩이
app.mount(
"/tools/line-stack-test",
_NoCacheHtmlStatic(
directory=str(Path(__file__).parent / "tools" / "line-stack-test"),
html=True,
check_dir=False,
),
name="line-stack-test",
)
# ─────────────────────────────────────────────────────────────────────────
# 기본 엔드포인트
# ─────────────────────────────────────────────────────────────────────────
@@ -564,10 +553,10 @@ app.include_router(
__import__("M01_MasterData.M01_MasterData_Alias", fromlist=["router"]).router,
dependencies=system_admin_only,
) # 별칭 장부 모으기(718-2)
# 단위 · 금액 표준 표 읽기(`GET /api/m02/df/standards` · 단위 고르기 `M02_DF_UnitPick`)
# 단위 · 금액 표준 표 읽기(`GET /api/m02/df/standards` · 단위 고르기 `M02_DF_UnitPick`) — 읽기만 · 로그인만(M02 줄 쌓기 단위)
app.include_router(
__import__("M02_MasterTemplete.DataFlow.M02_DF_Router", fromlist=["router"]).router,
dependencies=system_admin_only,
dependencies=protected,
)
# 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서).
app.include_router(m02_master_template_router, dependencies=system_admin_only)

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