diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 79f3171c7..25c01e014 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -131,7 +131,8 @@ opacity: 0.5; } -.m02-labrow tr.is-dragunder td { +.m02-labrow tr.is-dragunder td, +.m02-labrow tr:has(+ tr.m02-labrow__optrow.is-dragunder) > td[rowspan] { box-shadow: inset 0 -2px 0 var(--color-primary); } @@ -157,7 +158,7 @@ font-size: var(--text-caption); } -/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */ +/* 옵션 값 줄(44-6 · 113-1) — 구분 ~ 별칭에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */ .m02-labrow .m02-labrow__optrow td { padding-top: 0; padding-bottom: var(--spacing-4); @@ -173,7 +174,7 @@ text-overflow: ellipsis; } -.m02-labrow__row:has(+ .m02-labrow__optrow) td { +.m02-labrow__row:has(+ .m02-labrow__optrow) td:not([rowspan]) { border-bottom: 0; } @@ -182,18 +183,18 @@ } .m02-labrow__optrow.is-open:hover, -.m02-labrow__row.is-open:hover + .m02-labrow__optrow { +.m02-labrow__row.is-open:hover + .m02-labrow__optrow, +.m02-labrow__row.is-open:has(+ .m02-labrow__optrow:hover) { background: var(--color-mist-violet); } -/* 고른 줄(47-5) — 옵션 값 줄까지 한 덩이 · 왼쪽 띠 */ +/* 고른 줄(47-5) — 옵션 값 줄까지 한 덩이 · 왼쪽 띠(차례 칸 = 두 줄 병합 · 113-1) */ .m02-labrow .m02-labrow__row.is-selected, .m02-labrow .m02-labrow__optrow.is-selected { background: var(--color-mist-violet); } -.m02-labrow__row.is-selected > td:first-child, -.m02-labrow__optrow.is-selected > td:first-child { +.m02-labrow__row.is-selected > td:first-child { box-shadow: inset 3px 0 0 var(--color-primary); } @@ -419,7 +420,6 @@ } /* 옵션 값 줄 — 한 줄 통째 */ - .m02-labrow__optrow .m02-labrow__no, .m02-labrow__sum .m02-labrow__kind, .m02-labrow__sum .m02-labrow__alias { display: none; @@ -432,7 +432,8 @@ /* 끌기 선(102-2) — 칸마다 말고 줄 카드 하나에 */ .m02-labrow tr.is-dragover td, - .m02-labrow tr.is-dragunder td { + .m02-labrow tr.is-dragunder td, + .m02-labrow tr:has(+ tr.m02-labrow__optrow.is-dragunder) > td[rowspan] { box-shadow: none; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 8c4d4f164..6184cf511 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -3,7 +3,8 @@ * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. * 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) · * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · - * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조). + * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 구분 ~ 별칭에 걸친 한 줄(나머지 칸은 위 줄과 두 줄 병합 · 113-1) · + * 맨 아래 합계 줄(전체 합계 강조). * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). * 금액은 calc 답(없으면 「…」). * 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] · @@ -37,8 +38,8 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { 계: "Prev_Sum", }; -/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ -const SPAN = 5 + COSTS.length; +/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭(113-1 · 나머지 칸은 위 줄에서 rowspan 2) */ +const SPAN = 3; /** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */ export const kindOf = (row: Pick): string => { @@ -442,18 +443,20 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement if (on) tr.addEventListener("click", () => on.open(i)); const subs = sel ? subRows(line, wait, tools) : []; if (!note) return [tr, ...subs]; - // 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 + // 옵션 값 줄(44-6 · 113-1) — 구분 · 이름 · 별칭 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 · + // 차례 · 수량산출 · 단위 · 금액 넷 · 순서 칸은 위 줄에서 두 줄 병합 + for (const td of tr.children) + if (!td.matches(".m02-labrow__kind, .m02-labrow__name, .m02-labrow__alias")) + td.setAttribute("rowspan", "2"); const opt = el("tr", { className: (on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow") + sel, attrs: { "data-labor-opt": id }, children: [ - el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__opt", text: note, attrs: { colspan: String(SPAN), title: note }, }), - ...(tools ? [el("td", { text: "" })] : []), ], }); if (on) opt.addEventListener("click", () => on.open(i)); diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index bed5215ae..3cfce2bf0 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -163,3 +163,18 @@ def test_순서칸_2행_참조줄_옵션_수정(): assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body)" in ref assert 'mode: "precombo",' in ref and "showConfirmDialog" in ref assert 'Labor_Edit: ["수정"' in _text("M02_MasterTemplete_Labor_Text.ts") + + +def test_옵션_줄_구분_이름_별칭_병합(): + """113-1 — 옵션 글 = 구분 · 이름 · 별칭 병합 칸(colspan 3) · 나머지 칸은 위 줄에서 rowspan 2 · + 옵션 없는 로직은 한 줄 · 아래선 · hover · 고름 띠 · 끌기 아래선 맞춤.""" + rows = _text("M02_MasterTemplete_Labor_Rows.ts") + assert "const SPAN = 3;" in rows + assert "if (!note) return [tr, ...subs];" in rows + assert '".m02-labrow__kind, .m02-labrow__name, .m02-labrow__alias"' in rows + assert 'td.setAttribute("rowspan", "2");' in rows + css = _text("M02_MasterTemplete_Labor.css") + assert ".m02-labrow__row:has(+ .m02-labrow__optrow) td:not([rowspan])" in css + assert ".m02-labrow__row.is-open:has(+ .m02-labrow__optrow:hover)" in css + assert "tr:has(+ tr.m02-labrow__optrow.is-dragunder) > td[rowspan]" in css + assert ".m02-labrow__optrow.is-selected > td:first-child" not in css diff --git a/resources/tester/ui_checks/44-6_일위대가_표.js b/resources/tester/ui_checks/44-6_일위대가_표.js index e64d366e3..030a8102c 100644 --- a/resources/tester/ui_checks/44-6_일위대가_표.js +++ b/resources/tester/ui_checks/44-6_일위대가_표.js @@ -4,7 +4,7 @@ * 첫 로직 줄(GF000219 돌쌓기)에 고르기 입력이 있음 · 저장은 안 함(끝에 [다시 열기] → 확인 → 버림). * 기대 수치: * heads[2] = "별칭" · alias.inputs = 줄 수 · foot.totalBorder ≥ 1.5(2px · 화면 배율) · foot.totalBg ≠ 다른 금액 칸 배경 - * opt.row = true · opt.colspan = 8(이름 ~ 합계) · opt.nowrap = "nowrap" · opt.ellipsis = "ellipsis" · + * opt.row = true · opt.colspan = 3(구분 ~ 별칭 · 나머지 칸은 위 줄 rowspan 2 · 113-1) · opt.nowrap = "nowrap" · opt.ellipsis = "ellipsis" · * opt.height < 30(한 줄) · opt.mainGrow ≤ 2(옵션을 넣어도 위 줄 높이 그대로) · opt.title = 옵션 글 전체 * alias.noModal = true(칸 누름이 입력 모달을 안 엶) · alias.dirty = true · alias.kept = "시험별칭"(다시 그려도 남음) * discarded = true(버린 뒤 별칭 빈 칸 · 옵션 줄 처음과 같음) diff --git a/resources/tester/ui_checks/45-7_일위대가_표_줄.js b/resources/tester/ui_checks/45-7_일위대가_표_줄.js index 04b66d735..cfbf819d8 100644 --- a/resources/tester/ui_checks/45-7_일위대가_표_줄.js +++ b/resources/tester/ui_checks/45-7_일위대가_표_줄.js @@ -5,8 +5,8 @@ * 하는 일: item1 카드 표를 잼 → 일위대가 카드 [수정]으로 상세를 열어 잼 → item1 을 다시 눌러 카드 화면으로 돌아감(값 안 바꿈) * 기대 수치(카드 · 상세 각각): * - nameLines = 1(이름 칸 · 코드 · 규격 칸 글마다 줄 수 최대) · headLines = 1(머리 칸 글 줄 수 최대) - * - spanBad = 0(각 줄 칸 수 = 머리 칸 수 · colspan 셈) - * - broken = 0(칸 위·아래가 줄 위·아래와 어긋남 · 한 줄 안 아래 테두리 굵기가 다름 · 칸 사이 틈) + * - spanBad = 0(각 줄 칸 수 = 머리 칸 수 · colspan 셈 · 위 줄에서 내려온 rowspan 칸 포함 · 113-1) + * - broken = 0(칸 위·아래가 걸친 줄 위·아래와 어긋남 · 같은 줄에서 끝나는 칸끼리 아래 테두리 굵기가 다름 · 칸 사이 틈) * - ellipsis = 이름 글에 말줄임 꼴(text-overflow ellipsis) · titled = 이름 글마다 title * - 카드는 가로 스크롤 그대로(cardScroll = true · 표 폭 > 감싼 칸 폭) */ @@ -26,10 +26,20 @@ const tops = [...r.getClientRects()].filter((q) => q.width > 0).map((q) => Math.round(q.top)); return new Set(tops).size; }; - const span = (tr) => [...tr.cells].reduce((a, c) => a + (c.colSpan || 1), 0); + const span = (cells) => cells.reduce((a, c) => a + (c.colSpan || 1), 0); const measure = (t) => { - const head = span(t.tHead.rows[0]); + const head = span([...t.tHead.rows[0].cells]); const body = [...t.querySelectorAll("tbody tr, tfoot tr")]; + // 줄마다 걸친 칸(위 줄에서 내려온 rowspan 칸 포함) · 칸마다 시작 줄 + const cover = body.map(() => []); + const from = new Map(); + body.forEach((tr, j) => { + for (const c of tr.cells) { + from.set(c, j); + for (let k = 0; k < (c.rowSpan || 1) && j + k < body.length; k++) cover[j + k].push(c); + } + }); + const last = (c) => from.get(c) + (c.rowSpan || 1) - 1; const texts = [ ...t.querySelectorAll( ".m02-labrow__name > *, tbody .m02-labrow__code, tbody .m02-labrow__spec", @@ -39,13 +49,17 @@ const kids = [...t.querySelectorAll(".m02-labrow__name > *")]; let broken = 0; const why = []; - for (const tr of body) { - const R = tr.getBoundingClientRect(); - const cells = [...tr.cells].filter((c) => c.getClientRects().length); - const widths = new Set(cells.map((c) => getComputedStyle(c).borderBottomWidth)); + body.forEach((tr, j) => { + const cells = cover[j] + .filter((c) => c.getClientRects().length) + .sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left); + const ends = cells.filter((c) => last(c) === j); + const widths = new Set(ends.map((c) => getComputedStyle(c).borderBottomWidth)); if (widths.size > 1) (broken++, why.push(`굵기 ${tr.className} ${[...widths]}`)); cells.forEach((c, i) => { const r = c.getBoundingClientRect(); + const top = body[from.get(c)].getBoundingClientRect().top; + const R = { top, bottom: body[last(c)].getBoundingClientRect().bottom }; if (Math.abs(r.top - R.top) > 1 || Math.abs(r.bottom - R.bottom) > 1) (broken++, why.push(`높이 ${tr.className} ${c.className} ${Math.round(r.bottom - R.bottom)}`)); @@ -53,12 +67,12 @@ if (n && Math.abs(n.getBoundingClientRect().left - r.right) > 1) (broken++, why.push(`틈 ${tr.className} ${c.className}`)); }); - } + }); const wrap = t.parentElement; return { head, rows: body.length, - spanBad: body.filter((tr) => span(tr) !== head).length, + spanBad: cover.filter((cells) => span(cells) !== head).length, nameLines: Math.max(0, ...texts.map(lines)), headLines: Math.max(0, ...[...t.tHead.rows[0].cells].map(lines)), broken,