feat(m02): 일위대가 옵션 줄 = 구분 · 이름 · 별칭 병합 칸 · 나머지 칸 두 줄 병합(113-1)

- 옵션 글 있는 로직만 위 줄 차례 · 수량산출 · 단위 · 금액 넷 · 순서 칸 rowspan 2 · 옵션 줄 colspan 3
- CSS 아래선 · hover · 고름 띠 · 끌기 아래선 맞춤 · 좁은 칸 그대로
- ui_checks 44-6 기대 colspan 3 · 45-7 rowspan 셈 · card_shell 시험 하나

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-08 18:22:52 +09:00
co-authored by Claude Opus 5.5
parent 5f64633df3
commit 4defeb529b
5 changed files with 59 additions and 26 deletions
@@ -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;
}
@@ -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<typeof tc>[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<LaborRow, "로직" | "참조">): 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));
+15
View File
@@ -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
@@ -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(버린 뒤 별칭 빈 칸 · 옵션 줄 처음과 같음)
@@ -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,