From 3e572ef2104ccd8ff6b66f2eef88c846cb4049ec Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:20:18 +0900 Subject: [PATCH 01/12] =?UTF-8?q?chore(plan):=2010-5=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=C2=B7=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=EC=82=B0=EC=B6=9C=20=EC=95=8C=EC=95=BD=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 023a682e3..c8dbf6477 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -72,3 +72,19 @@ 추가로 원문에서 표는 스크롤로 매우 낮은 공간만 보이고 스크롤 표현되어서 보기가 힘듬. 그냥 표가 길더라도 보여줄것. 어차피 상세페이지와 원문의 우측 패널의 영역은 별도의 스크롤 영역이라 무관함. 원문은 M01과 M02에 표현되고 있는데 일원화 할것. M02가 더 가독성이 좋음. + +## 일위대가 조합 · 면적산출 알약 (10-5 · 원문 그대로) + +다시 돌아가서 M02 페이지에 +1-1. 구조물 조합의 면적산출에서 도면으로 보내진 셀의 영역을 표현이 되게하기 +1-2. 구조물 조합의 면적산출에서 알약을 선택을 했을때 셀이 반짝임은 있지만 반대로 셀을 선택했을때는 잘 안보임. +1-3. 구조물 조합의 면적산출에서 일위대가의 로직을 삭제했을때 기 지정된 알약이 잔존하고 있음. 삭제가 힘들다면 느낌표라도 보여지게 하고 삭제가능한지 검토 +2. 원문 표기의 '산림품셈 3-4-2 1' 항목 확인 +3. 구조물 조합의 일위대가 상세 화면에서 단가산출 로직에 들어가는 로직항목들은 단가산출 로직 뿐만아니라 단일 단가산출 로직을 일위대가로 승격시킨 선택지와 다른 일위대가로 만들어진 조합도 선택이 되어야함. +4. 방법 : 구조물 조합 컨테이너 하단에 일위대가 조합 컨테이너를 추가하고 여기에 마스터, 회사, 개인의 권한을 구현, 이때는 두가지의 선택지가 있음. 하나는 일위대가로 승격시킨 단가산출로직의 리스트와 일위대가에 사용하기 전 사전 조합으로 만들어진 조합으로 구분함. +5. 단가산출 승격은 단가산출 로직의 항목들을 검색하여 선택하면 일위대가 형태로 저장이 됨. (도면 X, 면적산출 X) +6. 일위대가 사전 조합은 단가산출 승격 + 다른 일위대가의 조합으로 그룹(도면 X, 면적산출 X) +7. 일위대가 조합은 구조물 조합의 일위대가 상세화면에서 선택할 수 있어야함. +8. 구조물 조합의 일위대가 상세화면에서의 로직테이블은 일위대가 목록표임. 여기서 행을 눌렀을때 하단에 상세 내역이 보였으면 함. 상세내역은 M01페이지의 단가산출 로직을 보면 시험계산 컨테이너 내부에 기준단위에 대한 테이블 리스트가 있음. 이걸 B02 구조물 조합의 일위대가 조합에 맞게 넣어주면 됨. 행을 선택했을때만 보여주면 되고 다른 행을 선택하면 닫히면 됨. +9. 구조물 조합의 일위대가 상세화면에 단가산출 로직 테이블의 이름 좌측 열을 추가하고 구분을 추가함. 구분은 출처로 단가산출인지, 일위대가 조합인지, 단가산출을 승격한 정보인지 표기 항목은 4음절로 표기, +일단 상세히 반영 가능성에 대해 상세히 검토후 계획서에 반영 From e313a3f6045a9d54276cb5987a8034711f7af9b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:32:57 +0900 Subject: [PATCH 02/12] =?UTF-8?q?chore(sync):=20=EA=B3=B5=EC=9C=A0=20docs?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EB=8B=B4=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=BB=A4=EB=B0=8B=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=82=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/사용자요구/2026-10-05_수정요구.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 023a682e3..c8dbf6477 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -72,3 +72,19 @@ 추가로 원문에서 표는 스크롤로 매우 낮은 공간만 보이고 스크롤 표현되어서 보기가 힘듬. 그냥 표가 길더라도 보여줄것. 어차피 상세페이지와 원문의 우측 패널의 영역은 별도의 스크롤 영역이라 무관함. 원문은 M01과 M02에 표현되고 있는데 일원화 할것. M02가 더 가독성이 좋음. + +## 일위대가 조합 · 면적산출 알약 (10-5 · 원문 그대로) + +다시 돌아가서 M02 페이지에 +1-1. 구조물 조합의 면적산출에서 도면으로 보내진 셀의 영역을 표현이 되게하기 +1-2. 구조물 조합의 면적산출에서 알약을 선택을 했을때 셀이 반짝임은 있지만 반대로 셀을 선택했을때는 잘 안보임. +1-3. 구조물 조합의 면적산출에서 일위대가의 로직을 삭제했을때 기 지정된 알약이 잔존하고 있음. 삭제가 힘들다면 느낌표라도 보여지게 하고 삭제가능한지 검토 +2. 원문 표기의 '산림품셈 3-4-2 1' 항목 확인 +3. 구조물 조합의 일위대가 상세 화면에서 단가산출 로직에 들어가는 로직항목들은 단가산출 로직 뿐만아니라 단일 단가산출 로직을 일위대가로 승격시킨 선택지와 다른 일위대가로 만들어진 조합도 선택이 되어야함. +4. 방법 : 구조물 조합 컨테이너 하단에 일위대가 조합 컨테이너를 추가하고 여기에 마스터, 회사, 개인의 권한을 구현, 이때는 두가지의 선택지가 있음. 하나는 일위대가로 승격시킨 단가산출로직의 리스트와 일위대가에 사용하기 전 사전 조합으로 만들어진 조합으로 구분함. +5. 단가산출 승격은 단가산출 로직의 항목들을 검색하여 선택하면 일위대가 형태로 저장이 됨. (도면 X, 면적산출 X) +6. 일위대가 사전 조합은 단가산출 승격 + 다른 일위대가의 조합으로 그룹(도면 X, 면적산출 X) +7. 일위대가 조합은 구조물 조합의 일위대가 상세화면에서 선택할 수 있어야함. +8. 구조물 조합의 일위대가 상세화면에서의 로직테이블은 일위대가 목록표임. 여기서 행을 눌렀을때 하단에 상세 내역이 보였으면 함. 상세내역은 M01페이지의 단가산출 로직을 보면 시험계산 컨테이너 내부에 기준단위에 대한 테이블 리스트가 있음. 이걸 B02 구조물 조합의 일위대가 조합에 맞게 넣어주면 됨. 행을 선택했을때만 보여주면 되고 다른 행을 선택하면 닫히면 됨. +9. 구조물 조합의 일위대가 상세화면에 단가산출 로직 테이블의 이름 좌측 열을 추가하고 구분을 추가함. 구분은 출처로 단가산출인지, 일위대가 조합인지, 단가산출을 승격한 정보인지 표기 항목은 4음절로 표기, +일단 상세히 반영 가능성에 대해 상세히 검토후 계획서에 반영 From 28f399bc2d081515623273fdf8f1b19c45730151 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:40:05 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=95=8C=EC=95=BD=20=C2=B7=20=EB=B3=B4=EB=82=B8=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=ED=91=9C=EC=8B=9C=20=E2=80=94=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EB=B3=B4=EB=82=B8=20=EB=B2=94=EC=9C=84=20=EC=A0=90?= =?UTF-8?q?=EC=84=A0=20+=20=EC=9D=B4=EB=A6=84=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B3=A0=EB=A5=B4=EB=A9=B4=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=20=C2=B7=20=EB=82=A8=EC=9D=80=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=E2=9A=A0=20+=20=C3=97?= 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_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet_links.css | 69 +++++++++++++++++ A00_Common/spreadsheet/spreadsheet_links.ts | 76 +++++++++++++++++-- .../M02_MasterTemplete_AreaToDrawing.ts | 26 ++++++- .../M02_MasterTemplete_Structure.ts | 4 +- .../M02_MasterTemplete_UI_Main.ts | 7 +- resources/tester/spreadsheet/harness_links.ts | 18 ++++- resources/tester/test_m02_area_marks.py | 35 +++++++++ .../tester/ui_checks/50-1_보낸범위_읽기.js | 42 ++++++++++ .../tester/ui_checks/50-1_알약_강조_표시.js | 52 +++++++++++++ 9 files changed, 316 insertions(+), 13 deletions(-) create mode 100644 resources/tester/test_m02_area_marks.py create mode 100644 resources/tester/ui_checks/50-1_보낸범위_읽기.js create mode 100644 resources/tester/ui_checks/50-1_알약_강조_표시.js diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 01a82b2c9..30694cd8e 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -195,3 +195,72 @@ transform: translate(-100%, 0); pointer-events: none; } + +/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */ +.ss-link.is-here { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + font-weight: 600; +} + +.ss-link.is-pop { + animation: ss-link-pop 1.2s ease-out 1 both; +} + +@keyframes ss-link-pop { + 0%, + 40%, + 80% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent); + } + 20%, + 60%, + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */ +.ss-link.is-orphan { + border: 1px dashed var(--color-danger); + color: var(--color-danger); +} + +.ss-link__x { + margin-left: 2px; + padding: 0 4px; + border-radius: 50%; + font-weight: 700; + cursor: pointer; +} + +.ss-link__x:hover { + background: color-mix(in srgb, var(--color-danger) 20%, transparent); +} + +.ss-link-cell.is-orphan { + outline: 1px dashed var(--color-danger); + outline-offset: -2px; +} + +/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */ +.ss-link-range { + position: absolute; + z-index: 3; + box-sizing: border-box; + border: 2px dashed var(--color-accent); + pointer-events: none; +} + +.ss-link-range__tag { + position: absolute; + left: -2px; + bottom: 100%; + padding: 0 4px; + border-radius: 3px 3px 0 0; + background: var(--color-accent); + color: var(--color-canvas); + font-size: 10px; + line-height: 14px; + white-space: nowrap; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 978a9c8b4..7525085a4 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -15,7 +15,7 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { el, showToast } from "@ui/ui_template_elements"; -import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; +import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; @@ -53,6 +53,11 @@ export interface LinkOptions { onItemsChange?: (항목: LinkItem[]) => void; /** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */ 자재찾기?: (글: string) => Promise; + /** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만 + * 목록에 없는 연결 알약에 ⚠ + [×] */ + 확정?: boolean; + /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */ + 표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[]; } export interface Links extends PartHandle { @@ -74,6 +79,8 @@ const TEXT = { fail: "자재 목록을 못 받음", picked: "칸을 고른 뒤 새 알약을 누르면 연결", dup: "이미 목록에 있는 자재", + orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음", + orphanX: "연결 풀기", }; type RowCol = Extract; @@ -146,6 +153,8 @@ export function mountLinks( let dragging: LinkItem | null = null; const dropMark = el("div", { className: "ss-link-drop" }); let pick: HTMLElement | null = null; + /** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */ + let lastAt = ""; /** 키 → 연결(시트 · 줄) */ function found(): Map { @@ -204,6 +213,8 @@ export function mountLinks( item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined, hue: number | undefined, + orphan: boolean, + here: string, ) { const where = hit ? hit.sheet.id === ctx.selection.시트 @@ -211,15 +222,30 @@ export function mountLinks( : `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}` : ""; const b = el("button", { - className: hit ? "ss-link is-linked" : "ss-link", - attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 }, + className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${ + here ? ` is-here${here === "new" ? " is-pop" : ""}` : "" + }`, + attrs: { + type: "button", + title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free, + "data-key": item.키, + }, children: [ - el("span", { text: item.이름 }), + el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }), ...(item.단위 ? [el("small", { text: item.단위 })] : []), ...(where ? [el("code", { text: where })] : []), ], }); if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue)); + if (orphan && !ctx.readOnly) { + const x = el("span", { + className: "ss-link__x", + text: "×", + attrs: { role: "button", title: TEXT.orphanX }, + }); + x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back())); + b.append(x); + } b.draggable = !ctx.readOnly; b.addEventListener("dragstart", (e) => { dragging = item; @@ -233,13 +259,13 @@ export function mountLinks( // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item))); - const unlink = (): void => { + function unlink(): void { if (hit) put( hit.sheet, (hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키), ); - }; + } b.addEventListener("contextmenu", (e) => { e.preventDefault(); const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키); @@ -307,7 +333,17 @@ export function mountLinks( : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, ); const hues = linkHues(map.keys()); - const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키))); + const listed = new Set(items.map((i) => i.키)); + const sure = opts.모드 === "입력" && opts.확정 === true; + // 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new") + const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`; + const fresh = at !== lastAt; + lastAt = at; + const pills = shown.map((item) => { + const hit = map.get(item.키); + const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : ""; + return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here); + }); if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) { const more = el("button", { className: "ss-link ss-link--add", @@ -318,6 +354,8 @@ export function mountLinks( pills.push(more); } bar.replaceChildren(...pills); + if (fresh) + bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */ @@ -325,6 +363,8 @@ export function mountLinks( for (const d of dots) d.remove(); dots = []; const hues = linkHues(found().keys()); + const listed = new Set(items.map((i) => i.키)); + const sure = opts?.모드 === "입력" && opts.확정 === true; // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 const byCell = new Map(); for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]); @@ -340,6 +380,7 @@ export function mountLinks( dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; const marks = [tint, dot]; + if (sure && !listed.has(first.키)) tint.classList.add("is-orphan"); if (list.length > 1) { const count = el("div", { className: "ss-link-count", text: String(list.length) }); count.style.left = `${box.x + box.w}px`; @@ -350,6 +391,22 @@ export function mountLinks( layer.append(...marks); dots.push(...marks); } + // 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침) + for (const mark of opts?.표시범위 ?? []) { + const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; + if (!g) continue; + const { layer, box } = slot(ctx, g.r0, g.c0); + const end = slot(ctx, g.r1, g.c1).box; + if (!box.w || !box.h) continue; + const frame = el("div", { + className: "ss-link-range", + attrs: { "data-range": mark.범위 }, + children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })], + }); + frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`; + layer.append(frame); + dots.push(frame); + } } /** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */ @@ -360,6 +417,11 @@ export function mountLinks( const names = a1 ? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키) : []; + if (hit && hit.r >= 0 && hit.c >= 0) + for (const m of opts?.표시범위 ?? []) { + const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null; + if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름); + } if (names.length) ctx.grid.root.title = names.join(" · "); else if (tipped) ctx.grid.root.removeAttribute("title"); tipped = names.length > 0; diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index f686ed1e3..da16b9918 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -19,7 +19,13 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; +import { + getActiveOwner, + readTemplate, + type Layer, + type StructureDoc, +} from "./M02_MasterTemplete_Api_Fetch"; +import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; /** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */ @@ -71,6 +77,8 @@ const TEXT = { noPlace: "이 도면 화면은 표 놓기를 아직 못 함", stale: "면적산출 값이 바뀐 표 {n}개", update: "업데이트", + tag: "{id} 도면", + tip: "도면 표 {id} 로 보낸 범위 {range} · 글자 {size}", updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음", } as const; @@ -112,6 +120,22 @@ const sameValues = (a: Record = {}, b: Record = return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]); }; +/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */ +export async function sentRanges(at: Selection): Promise> { + try { + const got = await readTemplate(at.layer, "structure", at.name, at.projectId); + const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? []; + return rows.map((row) => ({ + 시트: row.시트, + 범위: row.범위, + 이름: fill(TEXT.tag, "id", row.연결), + 설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기), + })); + } catch { + return []; + } +} + export interface AreaToDrawingOptions { /** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */ open: (sel: Selection) => Promise; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 11c49c3d3..8bf976944 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -160,7 +160,9 @@ export async function pillsFor( ...(p.자재 ? { 자재: p.자재 } : {}), 고정: true, })); - return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills); + // 받기 성공 = 목록 확정 — 이 목록에 없는 연결 알약만 ⚠(받기 실패 때는 표시 안 함) + const links: LinkOptions = { 항목, 모드: "입력", 확정: true }; + return 항목.length ? { links } : { ...none(TEXT.noPills), links }; } catch (error) { return none(fill(TEXT.pillsFailed, why(error))); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 37867bab2..999937ca8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -28,7 +28,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; -import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing"; +import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing"; import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp"; import { helpParts } from "./M02_MasterTemplete_CardShell"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; @@ -273,13 +273,14 @@ export function createMain( ): Promise { const mine = seq; try { - const [{ createSpreadsheet }, pills] = await Promise.all([ + const [{ createSpreadsheet }, pills, sent] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), pillsFor(kind, at.name), + sentRanges(at), // 도면으로 보낸 범위 — 시트 위 점선 테두리 ]); if (mine !== seq) return; if (pills.notice) showNotice([pills.notice]); - const links: LinkOptions = pills.links; + const links: LinkOptions = { ...pills.links, 표시범위: sent }; const handle: SpreadsheetHandle = createSpreadsheet( host, doc as Parameters[1], diff --git a/resources/tester/spreadsheet/harness_links.ts b/resources/tester/spreadsheet/harness_links.ts index ea6b0ff85..6572337da 100644 --- a/resources/tester/spreadsheet/harness_links.ts +++ b/resources/tester/spreadsheet/harness_links.ts @@ -6,7 +6,10 @@ import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet"; import type { LinkMaterial, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet"; const w = window as unknown as Record; -const mode = new URLSearchParams(location.search).get("mode") === "자재" ? "자재" : "입력"; +const q = new URLSearchParams(location.search); +const mode = q.get("mode") === "자재" ? "자재" : "입력"; +// ?ghost=1 — 목록 확정 + 목록에 없는 연결 알약 X(B1) · 목록 알약 L(B2) / ?range=1 — 도면 보낸 범위 A1:B2 +const ghost = q.get("ghost") === "1"; const book: Workbook = { 종류: "통합문서", @@ -29,6 +32,11 @@ const book: Workbook = { ], 활성: "s1", }; +if (ghost) + book.시트[0].연결 = [ + { 키: "X", 칸: "B1", 이름: "지운로직", 단위: "m" }, + { 키: "L", 칸: "B2", 이름: "길이", 단위: "m" }, + ]; const MATERIALS: LinkMaterial[] = [ { 코드: "M001", 이름: "레미콘", 규격: "25-21-12", 단위: "m³" }, @@ -59,6 +67,14 @@ w.ss = createSpreadsheet(document.getElementById("host")!, book, { { 키: "H", 이름: "높이", 단위: "m" }, { 키: "A", 이름: "면적", 단위: "m²" }, ], + ...(ghost ? { 확정: true } : {}), + ...(q.get("range") === "1" + ? { + 표시범위: [ + { 시트: "s1", 범위: "A1:B2", 이름: "T1 도면", 설명: "도면 표 T1 로 보낸 범위" }, + ], + } + : {}), onItemsChange: (목록) => (w.ssItems as unknown[]).push(목록), 자재찾기: async (글) => { asked.push(글); diff --git a/resources/tester/test_m02_area_marks.py b/resources/tester/test_m02_area_marks.py new file mode 100644 index 000000000..f542edb8c --- /dev/null +++ b/resources/tester/test_m02_area_marks.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +"""50-1 면적산출 알약 · 보낸 범위 표시 — 원문 규칙 점검(화면 수치는 ui_checks/50-1_*.js 를 ORCA 로).""" + +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.join(HERE, "..", "..") + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +def test_orphan_mark_only_when_list_confirmed(): + # ⚠ 는 호스트가 목록을 확정했을 때만 — 알약 받기 실패(확정 없음)는 표시 안 함 + links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts") + assert 'opts.모드 === "입력" && opts.확정 === true' in links + structure = _read("M02_MasterTemplete", "M02_MasterTemplete_Structure.ts") + assert "확정: true" in structure + assert structure.count("확정: true") == 1 # 실패 길(none)은 확정 없음 + + +def test_cell_pick_highlights_pill_and_range_is_unfilled(): + links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts") + assert "is-here" in links and "scrollIntoView" in links + css = _read("A00_Common", "spreadsheet", "spreadsheet_links.css") + block = css.split(".ss-link-range {", 1)[1].split("}", 1)[0] + assert "dashed" in block and "background" not in block # 채움 없음 = 알약 색과 안 겹침 + + +def test_sent_ranges_from_same_column_drawing(): + src = _read("M02_MasterTemplete", "M02_MasterTemplete_AreaToDrawing.ts") + assert 'readTemplate(at.layer, "structure", at.name, at.projectId)' in src + assert '"{id} 도면"' in src diff --git a/resources/tester/ui_checks/50-1_보낸범위_읽기.js b/resources/tester/ui_checks/50-1_보낸범위_읽기.js new file mode 100644 index 000000000..0f4353814 --- /dev/null +++ b/resources/tester/ui_checks/50-1_보낸범위_읽기.js @@ -0,0 +1,42 @@ +/* 50-1 면적 시트 「도면으로 보낸 범위」 읽기(`sentRanges`) — orca eval 한 번(서버 쓰기 없음 · 가짜 응답). + * 시작 화면: 로그인한 M02(#/m02-master-template?open=구조물조합&root=master) 아무 화면. + * 하는 일: 도면 읽기 길만 가짜 연결표(T1 · T3)로 바꿔 부름 → 그 답 · 실패 때(500) 빈 목록 → fetch 되돌림. + * 기대(out): rows = 2 · tag = ["T1 도면","T3 도면"] · tip0 = 「도면 표 T1 로 보낸 범위 A1:B2 · 글자 2.5」 · + * sheet0 = 가짜 시트 id · failed = [] . + */ +(async () => { + const mod = + await import("/@fs/C:/Aislo_wt/sub_laptop_3/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts"); + const real = window.fetch; + const at = { layer: "system", projectId: null, kind: "area", name: "item1", label: "item1" }; + const out = {}; + try { + window.fetch = async (url, init) => { + if (!String(url).includes("/templates/structure/item1")) return real(url, init); + return new Response( + JSON.stringify({ + 종류: "structure", + 이름: "item1", + 판: "1", + 문서: { + 연결표: [ + { 연결: "T1", 시트: "s9", 범위: "A1:B2", 글자크기: 2.5, 값: {} }, + { 연결: "T3", 시트: "s9", 범위: "C3:D5", 글자크기: 3, 값: {} }, + ], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + const rows = await mod.sentRanges(at); + out.rows = rows.length; + out.tag = rows.map((r) => r.이름); + out.tip0 = rows[0]?.설명; + out.sheet0 = rows[0]?.시트; + window.fetch = async () => new Response("{}", { status: 500 }); + out.failed = await mod.sentRanges(at); + } finally { + window.fetch = real; + } + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/50-1_알약_강조_표시.js b/resources/tester/ui_checks/50-1_알약_강조_표시.js new file mode 100644 index 000000000..10004fb5b --- /dev/null +++ b/resources/tester/ui_checks/50-1_알약_강조_표시.js @@ -0,0 +1,52 @@ +/* 50-1 면적산출 알약 · 칸 · 보낸 범위 표시 — orca eval 한 번에 도는 시나리오(저장 · 서버 없음). + * 시작 화면: /@fs/C:/Aislo_wt/sub_laptop_3/resources/tester/spreadsheet/harness_links.html?ghost=1&range=1 (알약 L · H · A · 연결 X=B1 · L=B2 · 범위 A1:B2). + * 하는 일: ① 알약 줄 ② 보낸 범위 상자 ③ 칸 B1 · B2 를 눌러 그 칸 알약 강조 ④ X 의 [×] 로 연결 풀기 + * 기대(out): orphan = X 만 ⚠ · 목록 알약 L 은 아님 · rangeTag "T1 도면" · rangeFill 투명 · rangeDashed true · + * hereB1 = ["X"] · hereB2 = ["L"] · popOnce true(칸이 바뀐 첫 그림만) · xGone true. + */ +(async () => { + const W = (t) => new Promise((r) => setTimeout(r, t)); + const q = (s) => document.querySelector(s); + const all = (s) => [...document.querySelectorAll(s)]; + const click = (el) => { + const r = el.getBoundingClientRect(); + const at = { + bubbles: true, + button: 0, + clientX: r.left + r.width / 2, + clientY: r.top + r.height / 2, + }; + const t = document.elementFromPoint(at.clientX, at.clientY) ?? el; + for (const type of ["mousedown", "mouseup", "click"]) t.dispatchEvent(new MouseEvent(type, at)); + }; + await W(800); + const out = {}; + const pill = (k) => q(`.ss-link[data-key="${k}"]`); + out.orphan = all(".ss-link.is-orphan").map((b) => b.dataset.key); + out.xText = pill("X")?.textContent; + out.lOrphan = pill("L")?.classList.contains("is-orphan"); + const range = q(".ss-link-range"); + const cs = range && getComputedStyle(range); + out.rangeTag = range?.querySelector(".ss-link-range__tag")?.textContent; + out.rangeFill = cs?.backgroundColor; + out.rangeDashed = cs?.borderTopStyle === "dashed"; + out.rangeSize = range && [Math.round(range.offsetWidth), Math.round(range.offsetHeight)]; + const byKey = (k) => all(".ss-link-cell").find((t) => t.dataset.key === k); // 칸 표시는 고를 때마다 새로 얹힘 + out.cellOrphan = byKey("X")?.classList.contains("is-orphan"); + const here = () => all(".ss-link.is-here").map((b) => b.dataset.key); + click(byKey("X")); // B1 + await W(300); + out.hereB1 = here(); + out.popOnce = all(".ss-link.is-pop").length === 1; + click(byKey("L")); // B2 + await W(300); + out.hereB2 = here(); + const doc0 = window.ss.getDoc(); + const n0 = doc0.시트[0].연결.length; + click(pill("X").querySelector(".ss-link__x")); + await W(300); + const n1 = window.ss.getDoc().시트[0].연결.length; + out.xGone = n1 === n0 - 1 && !pill("X"); + out.xCell = !all(".ss-link-cell").some((t) => t.dataset.key === "X"); + return out; +})(); From 7b28cb42d3028a0e9dd9d2bb3d80353fb93e2db9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:41:23 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(M02):=2050-4=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A4=84=20=EB=88=84=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=20=EC=95=84=EB=9E=98=20=EC=83=81=EC=84=B8=20=C2=B7=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 calc 답 줄마다 그 로직 재료 · 노무 · 경비 「줄」 실음(버리던 것) - 상세 · 카드 표 줄 누름 = 표 아래 M01 시험계산 표(answerView 다시 씀) + 반영 합 · 한 줄만 · 같은 줄 다시 누름 = 닫힘 - 이름 왼쪽 「구분」 열 — 키 머리에서 읽음(SP- 단가승격 · SC- 일위조합 · 그 밖 단가산출 · 저장 안 함) - 시험: 줄 실림 · 합 = 기준 · ui_checks 50-4 시나리오 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M01_MasterData_UI_Logic_Calc.ts | 2 +- M02_MasterTemplete/M02_Labor_Doc.py | 5 +- .../M02_MasterTemplete_Labor.css | 18 +++ .../M02_MasterTemplete_Labor_Calc.ts | 4 + .../M02_MasterTemplete_Labor_Detail.ts | 57 +++++++- .../M02_MasterTemplete_Labor_Rows.ts | 13 +- .../M02_MasterTemplete_Labor_Text.ts | 6 + resources/tester/test_m02_labor_doc.py | 8 ++ .../tester/ui_checks/50-4_일위대가_줄_상세.js | 131 ++++++++++++++++++ 9 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 resources/tester/ui_checks/50-4_일위대가_줄_상세.js diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index f27f458d8..98b79eb28 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -203,7 +203,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { } /** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */ -function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] { +export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] { const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : []; if (!answer.ok) { return [ diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 51189cd41..55b2834fa 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -302,7 +302,10 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: if "결과" in got: # 돈 아닌 로직 — 반영 없음 lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) continue - lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) + # 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4) + lines.append( + {**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None} + ) mats = [ { "코드": one["코드"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index d80e0e147..3dd6d2015 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -130,6 +130,17 @@ box-shadow: inset 3px 0 0 var(--color-primary); } +/* 구분 칸(50-4) — 넉 자 · 흐린 글 */ +.m02-labrow__kind { + white-space: nowrap; + color: var(--color-text-secondary); +} + +/* 줄 상세(50-4) — 표 아래 · 고른 줄만 */ +.m02-labor__linedet { + margin-top: var(--spacing-8); +} + /* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */ .m02-labrow__alias { min-width: 7rem; @@ -259,6 +270,12 @@ max-width: none; } + .m02-labrow .m02-labrow__kind { + grid-column: 1 / -1; + order: 1; + font-size: var(--text-caption); + } + .m02-labrow .m02-labrow__no, .m02-labrow .m02-labrow__unit:not([data-label]), .m02-labrow .m02-labrow__code, @@ -322,6 +339,7 @@ /* 옵션 값 줄 — 한 줄 통째 */ .m02-labrow__optrow .m02-labrow__no, + .m02-labrow__sum .m02-labrow__kind, .m02-labrow__sum .m02-labrow__alias { display: none; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index dd9da6fe9..b038f3c29 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -5,6 +5,8 @@ * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */ +import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; + export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; export type Money = Record<(typeof MONEY_KEYS)[number], number>; export type QtySource = "손" | "칸" | "없음"; @@ -22,6 +24,8 @@ export interface CalcLine { 반영?: Money | null; 까닭?: string | null; 결과?: unknown; + /** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */ + 줄?: LogicLine[]; } export interface CalcMaterial { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 0e34d21ee..c02cc57ac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Labor_Detail.ts * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. - * · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) · [옵션] = 입력 모달(`Labor_Input`) · + * · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 표 아래 그 줄 상세(50-4 · M01 시험계산 표) · + * 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · * [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 @@ -15,6 +16,9 @@ import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { answerView } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; +import type { CalcAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; import { @@ -24,7 +28,7 @@ import { type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; -import { settle } from "./M02_MasterTemplete_Labor_Calc"; +import { MONEY_KEYS, settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, @@ -134,6 +138,46 @@ export function mountLaborDetail( const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); + /** 고른 줄 상세(50-4) — M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) + 반영 합 · 고른 줄 없으면 비움 */ + const lineDet = el("div", { + className: "m02-labor__linedet", + attrs: { "data-labor-linedet": "" }, + }); + function showLine(): void { + if (!picked) return void lineDet.replaceChildren(); + const logic = logics.get(picked); + const head = el("h4", { + text: tc("Labor_LineDetail").replace("{value}", `${picked} ${logic?.이름 ?? ""}`.trim()), + }); + const line = lineOf(picked); + if (!line) + return void lineDet.replaceChildren( + head, + el("p", { className: "m01-logic__muted", text: fail ?? "…" }), + ); + const answer: CalcAnswer = line.까닭 + ? { ok: false, reason: line.까닭 } + : { ok: true, lines: line.줄, sums: line.기준 ?? undefined, result: line.결과, middle: {} }; + // 반영 = 기준 × 수량 ÷ 배(표 아래 줄과 같음) · 수량 없으면 「—」 · 돈 아닌 로직은 없음 + const applied = line.기준 + ? [ + el("h4", { text: tc("Labor_LineApplied") }), + el("dl", { + className: "m01-logic__sums", + children: MONEY_KEYS.flatMap((k) => [ + el("dt", { text: k === "계" ? tc("Prev_Sum") : k }), + el("dd", { + className: "m01-logic__money", + text: line.반영 ? formatMoney(line.반영[k]) : "—", + }), + ]), + }), + ] + : []; + const money = (logic?.결과단위 ?? "").startsWith("원"); + lineDet.replaceChildren(head, ...answerView(answer, false, money), ...applied); + } + const drawDg = (): void => { if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음 const data = buildLaborDiagram({ @@ -172,7 +216,9 @@ export function mountLaborDetail( } : { open: pick, option: openRow }, ), + lineDet, ); + showLine(); matHost.replaceChildren( matTable( { mats: cur.자재, calc, fail, cells: cells() }, @@ -293,11 +339,14 @@ export function mountLaborDetail( [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; changed(); } - /** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · 화면 캐시 안 바뀜 */ + /** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 줄 상세(50-4) + 원문 패널(47-4) · + * 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */ function pick(i: number): void { - picked = cur.담은로직[i].로직; + const key = cur.담은로직[i].로직; + picked = picked === key ? null : key; for (const tr of logicHost.querySelectorAll("[data-labor-logic],[data-labor-opt]")) tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked); + showLine(); showSource(); } function removeRow(i: number): void { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index b6993e8eb..cdae92877 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Rows.ts * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. - * 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) · + * 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) · * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조). * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). @@ -34,8 +34,12 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { 계: "Prev_Sum", }; -/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ -const SPAN = 4 + COSTS.length; +/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ +const SPAN = 5 + COSTS.length; + +/** 구분(50-4 · 저장 안 함) — 줄 모양(키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */ +export const kindOf = (key: string): string => + tc(key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic"); const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); @@ -253,6 +257,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement attrs: { "data-labor-logic": row.로직 }, children: [ el("td", { className: "m02-labrow__no", text: String(i + 1) }), + el("td", { className: "m02-labrow__kind", text: kindOf(row.로직) }), titled( el("td", { className: "m02-labrow__name", @@ -331,6 +336,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement className: "m02-labrow__sum", children: [ el("td", { className: "m02-labrow__no", text: "" }), + el("td", { className: "m02-labrow__kind", text: "" }), el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), el("td", { className: "m02-labrow__alias", text: "" }), el("td", { className: "m02-labrow__qty", text: "" }), @@ -352,6 +358,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []; const heads = [ tc("Col_Order"), + tc("Sub"), tc("Col_Name"), tc("Col_Alias"), tc("Labor_QtyCol"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index db14ee6bc..ed3e78489 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -84,6 +84,12 @@ const TEXT = { Labor_BasePrice: ["기준 단가", "Base price"], Labor_AppliedSum: ["반영 금액", "Applied amount"], Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], + // 구분 · 줄 상세(50-4) + Kind_Logic: ["단가산출", "Logic"], + Kind_Promote: ["단가승격", "Promoted"], + Kind_Combo: ["일위조합", "Combination"], + Labor_LineDetail: ["상세 — {value}", "Detail — {value}"], + Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"], Labor_Empty: ["빈 값 {n}", "{n} empty"], Labor_Lost: [ "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index a2a629fd0..c42ce06ed 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -90,6 +90,14 @@ def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_ assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"]) +def test_돈_로직_줄에_재료_노무_경비_줄_실림_합은_기준(): + got = labor.calc(labor.clean(_doc(담은로직=[{"로직": TEN}, {"로직": PLAIN}])), {}) + ten, plain = got["줄"] + assert ten["줄"] and all({"이름", "수량", "단위", "금액"} <= set(x) for x in ten["줄"]) + assert sum(x["금액"] for x in ten["줄"]) == pytest.approx(ten["기준"]["계"]) + assert "줄" not in plain + + def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): doc = labor.clean( _doc( diff --git a/resources/tester/ui_checks/50-4_일위대가_줄_상세.js b/resources/tester/ui_checks/50-4_일위대가_줄_상세.js new file mode 100644 index 000000000..11fecbbb2 --- /dev/null +++ b/resources/tester/ui_checks/50-4_일위대가_줄_상세.js @@ -0,0 +1,131 @@ +/* 50-4 — 일위대가 단가산출 로직 표 줄 누름 = 표 아래 그 줄 상세(M01 시험계산 표) · 다시 누르면 닫힘 · 「구분」 열 — 카드 · 상세 두 곳 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1(구조물 조합 · 일위대가 로직 둘 이상)이 있음 + * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: item1 카드 표 줄 누름 · 다른 줄 · 같은 줄 다시 → 일위대가 카드 [수정]으로 상세 → 같은 차례 → item1 다시(값 안 바뀜) + * 기대 수치(카드 · 상세 같음): + * - head[1] = "구분" · kinds = 줄마다 "단가산출" + * - first.open = 1(상세 상자 하나) · first.rows ≥ 1 · first.sum = first.total = first.base(줄 합 = 상세 계 = 표 기준 합계) + * - first.applied = first.appliedRow(상세 반영 계 = 표 반영 합계 · 수량 없으면 둘 다 「—」 = 0) + * - second.title 가 둘째 줄 키 · second.open = 1(한 줄만) + * - again.open = 0 · again.selected = 0(같은 줄 다시 = 닫힘) + * - back = true(카드 화면으로 돌아감) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0; + const rowsOf = (t) => [...t.querySelectorAll("tbody tr[data-labor-logic]")]; + const settled = (get) => + until(() => { + const t = get(); + return t && !/…/.test(t.querySelector("tbody").innerText) ? t : null; + }, 10000); + /* 상세 상자 읽기 — 표 줄 금액 합 · 기준 계 · 반영 계 */ + const det = (box) => { + const dl = [...box.querySelectorAll("dl.m01-logic__sums")]; + const total = (d) => { + const dt = d && [...d.querySelectorAll("dt")].find((x) => /계|합계|Total/.test(x.innerText)); + return dt ? n(dt.nextElementSibling.innerText) : null; + }; + const rows = [...box.querySelectorAll("table.m01-logic__grid tbody tr")]; + return { + title: box.querySelector("h4")?.innerText ?? null, + rows: rows.length, + sum: rows.reduce((a, r) => a + n(r.lastElementChild.innerText), 0), + total: total(dl[0]), + applied: dl[1] ? total(dl[1]) : "없음", + }; + }; + const measure = async (get, boxOf) => { + const t = await settled(get); + const rows = rowsOf(t); + const head = [...t.querySelectorAll("thead th")].map((th) => th.innerText.trim()); + const kinds = rows.map((r) => r.querySelector(".m02-labrow__kind")?.innerText.trim()); + const click = async (key) => { + get().querySelector(`tr[data-labor-logic="${key}"] .m02-labrow__name`).click(); + await w(300); + }; + const opened = () => + [...boxOf().querySelectorAll("[data-labor-linedet]")].filter((b) => b.childElementCount); + const k0 = rows[0].getAttribute("data-labor-logic"); + const k1 = rows[1]?.getAttribute("data-labor-logic") ?? k0; + const sumCell = (key) => { + const tds = get().querySelectorAll(`tr[data-labor-logic="${key}"] .m02-labrow__money`); + const last = tds[tds.length - 1]; + return { + base: n(last.querySelector(".m02-labrow__base").innerText), + applied: n(last.querySelector(".m02-labrow__applied").innerText), + }; + }; + await click(k0); + const d0 = det(opened()[0]); + const row0 = sumCell(k0); + const first = { + key: k0, + open: opened().length, + ...d0, + base: row0.base, + appliedRow: row0.applied, + }; + await click(k1); + const second = { + key: k1, + open: opened().length, + title: opened()[0]?.querySelector("h4")?.innerText, + }; + await click(k1); + const again = { + open: opened().length, + selected: rowsOf(get()).filter((r) => r.classList.contains("is-selected")).length, + }; + return { head, kinds, first, second, again }; + }; + const node = await until(() => document.querySelector('[data-node="i|item1"]')); + if (!node) return JSON.stringify({ error: "item1 없음 — 폴더를 펼쳐 둘 것" }); + node.click(); + const card = await until(() => + [...document.querySelectorAll(".m02-struct__card")].find((c) => + c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"), + ), + ); + if (!card) return JSON.stringify({ error: "일위대가 카드 표 없음" }); + await w(1500); + const out = { + card: await measure( + () => card.querySelector("table.m02-labrow"), + () => card, + ), + }; + const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find( + (b) => b.innerText.trim() === "수정", + ); + if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" }); + edit.click(); + const dt = await until(() => + [...document.querySelectorAll("table.m02-labrow")].find( + (t) => !t.closest(".m02-struct__card") && t.querySelector("tbody tr[data-labor-logic]"), + ), + ); + if (!dt) return JSON.stringify({ ...out, error: "상세 표 없음" }); + await w(2000); + const table = () => + [...document.querySelectorAll("table.m02-labrow")].find((t) => !t.closest(".m02-struct__card")); + out.detail = await measure(table, () => table().closest("[data-labor]")); + /* 카드 화면으로 돌아감(고름만 했으니 버림 물음 없음 — 뜨면 확인) */ + document.querySelector('[data-node="i|item1"]')?.click(); + await w(800); + [...document.querySelectorAll(".ui-modal button, dialog button")] + .find((b) => /확인|버림|떠나기/.test(b.innerText)) + ?.click(); + await w(1200); + out.back = !!document.querySelector(".m02-struct__card table.m02-labrow"); + return JSON.stringify(out); +})(); From 2d954a14dddffdfddad1c2b04a473d3af1ff534b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:42:15 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(M01):=2050-2=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=E2=80=94=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EA=B8=B0=ED=98=B8=20=EB=B2=97=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EC=B6=9C=EC=B2=98=20=ED=95=AD=20=C2=B7=20=EC=A3=BC=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 원문 조각에서 역슬래시 · 인용 머리 · 글 안 주석 벗김(원본 자료 손 안 댐) - 출처 항 · 주 번호(3-4-2 1 · 9-7-1 주⑥)를 source() 답에 실음 — 그 조각 hit 표시 - 패널이 그 항을 옅은 바탕 + 막대로 강조 · 보이게 스크롤 · 머리에 「1항」 · 「주⑥」 - 시험 3 추가 · 화면 시나리오 50-2 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M01_MasterData/M01_MasterData_Store_Make.py | 66 +++++++++++++++-- .../M01_MasterData_UI_LogicLab_Side.css | 6 ++ .../M01_MasterData_UI_LogicLab_Source.ts | 20 +++++- resources/tester/test_m01_logic_source.py | 43 +++++++++++ .../tester/ui_checks/50-2_원문_항강조.js | 71 +++++++++++++++++++ 5 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 resources/tester/ui_checks/50-2_원문_항강조.js diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 029ce0d35..13762011a 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -104,7 +104,16 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi # ── 로직 원문(출처 글 → 품셈 본문 절) ────────────────────────────────── -_NUMBER = re.compile(r"(?~`])") # 마크다운 이스케이프 `\.` `\-` … +_COMMENT = re.compile(r"") # 글 안 주석(글자층 표시) — 글자만 남김 +_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" + + +def _plain(text: str) -> str: + """원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `.""" + text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip() + return text[1:].strip() if text.startswith(">") else text def _blocks(lines: list[str]) -> list[dict]: @@ -117,10 +126,15 @@ def _blocks(lines: list[str]) -> list[dict]: if not text or text.startswith("