From f7dd8668765b6d97be91b6cf14b253559652aa99 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 19:59:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EC=88=98=EB=9F=89=20=EC=B9=B8?= =?UTF-8?q?=20=EC=95=8C=EC=95=BD=ED=99=94=20=E2=80=94=20=ED=91=9C=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=EB=A7=8C=20=EC=95=8C=EC=95=BD=20=C2=B7=20?= =?UTF-8?q?=EB=82=98=EB=A8=B8=EC=A7=80=EB=8A=94=20=EA=B8=80=EC=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수량 칸을 「알약 + 사칙연산 글자」로 그림 — 찾기(...) 표 호출만 알약(조각 계약 `ref/_분석_알약_상세줄.md` ㉮ 표대로) · 나머지(숫자·연산기호·중간값·설계입력 이름)는 글자. 서버 조각 API 는 아직이라 화면에서 임시로 쪼갬(Logic_Note.ts 찾기() 규칙과 같음) — 서버가 붙으면 걷어냄. 단가·금액 칸은 안 건드림. 알약을 누르면 그 표만 바로 모달로(모달 안 알약 목록은 없앰) · 줄을 누르면 그 줄의 단가·다른 로직을 바로 보임(알약 없이 한 가지만). 인력·재료·경비 세 표를 table-layout: fixed + 같은 칸 폭으로 맞춤 · 알약은 칸 폭 기준 줄바꿈. 원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만 정수 · 수량·비율은 그대로 · 조합 미리 보기(Combo_Preview)도 같이 고침. 검증: typecheck 통과 · ORCA 로 13-4-1·12-4·12-17-1 열어 확인(알약 클릭·행 클릭·칸 폭 동일 729.57px·정수 표시 확인, data-sum 은 원래 값 그대로 유지). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8 --- .../M01_MasterData_UI_Combo_Preview.ts | 3 +- .../M01_MasterData_UI_LogicLab_Detail.ts | 60 ++++++- .../M01_MasterData_UI_LogicLab_Modal.ts | 164 +++++++++--------- .../M01_MasterData_UI_LogicLab_Pill.ts | 62 +++---- .../M01_MasterData_UI_LogicLab_Style.css | 38 +++- 5 files changed, 206 insertions(+), 121 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts index 3ac16a3c..71765bf8 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts @@ -17,7 +17,8 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { }; const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); -const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); +/** 원 단위 소수점 0 — 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ +const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); /** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 03e2b0f3..03fbe43d 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -19,9 +19,53 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; -import { openMaterialModal } from "./M01_MasterData_UI_LogicLab_Modal"; +import { openMaterialModal, openTableModal } from "./M01_MasterData_UI_LogicLab_Modal"; +import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; +import "./M01_MasterData_UI_LogicLab_Pill.css"; + +/** 원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만. 수량·비율은 `formatNumber` 그대로 둠. */ +const formatMoney = (value: unknown): string => + typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) + : formatNumber(value); + +/** 조각 계약(`ref/_분석_알약_상세줄.md` ㉮ 표) — 수량 식 안 찾기(...) 표 호출만 알약, + * 그 밖(숫자·연산기호·중간값·설계입력 이름)은 글자. 서버가 조각 목록을 아직 안 주므로 + * 여기서 임시로 쪼갬(`Logic_Note.ts` 찾기() 정규식과 같은 규칙) — 서버가 붙으면 이 파싱을 걷어냄. */ +const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g; + +function splitFindConds(raw: string): [string, string][] { + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p): [string, string] => { + const i = p.indexOf("="); + return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()]; + }); +} + +function qtyFragments(expr: string, values: Record): Fragment[] { + const out: Fragment[] = []; + let last = 0; + for (const m of expr.matchAll(FIND_RE)) { + const at = m.index ?? 0; + if (at > last) out.push({ text: expr.slice(last, at) }); + const table = m[1]; + const label = `${tl("Pill_Table")} ${table}`; + out.push({ + pill: { + label, + onClick: () => openTableModal({ title: label, table, conds: splitFindConds(m[2]), values }), + }, + }); + last = at + m[0].length; + } + if (last < expr.length) out.push({ text: expr.slice(last) }); + return out.length ? out : [{ text: expr }]; +} export interface DetailContext { row: LogicRow; @@ -56,6 +100,7 @@ interface Entry { unit: string; qty: string; qtyTag: string; + qtyFrag: Fragment[]; price: string; amount: number | null; /** 덧줄이면 true */ @@ -168,7 +213,8 @@ function entries(ctx: DetailContext): Entry[] { unit: item.단위 ?? "", qty: item.수량, qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량), - price: price === undefined || price === null ? "" : formatNumber(price), + qtyFrag: qtyFragments(item.수량, ctx.values), + price: price === undefined || price === null ? "" : formatMoney(price), amount: line ? line.금액 : null, extra: false, open: () => @@ -192,6 +238,7 @@ function entries(ctx: DetailContext): Entry[] { unit: "", qty: extra.식, qtyTag: tl("Extra"), + qtyFrag: qtyFragments(extra.식, ctx.values), price: "", amount: line ? line.금액 : null, extra: true, @@ -224,13 +271,14 @@ function lineRow(e: Entry): HTMLElement { }), cellOf(e.unit), el("td", { + className: "m01lab__qty-cell", children: [ el("span", { className: "m01-logic__tag", text: e.qtyTag }), - el("span", { className: "m01lab__qty", text: ` ${e.qty}` }), + fragmentRow(e.qtyFrag), ], }), cellOf(e.price, "m01-logic__money"), - cellOf(e.amount === null ? "" : formatNumber(e.amount), "m01-logic__money"), + cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"), ], }); tr.addEventListener("click", e.open); @@ -254,7 +302,7 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" }, children: [ el("td", { attrs: { colspan: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }), - cellOf(calculated ? formatNumber(sub) : "—", "m01-logic__money"), + cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"), ], }); const headers = [ @@ -326,7 +374,7 @@ function hoBox(ctx: DetailContext): HTMLElement { const totalLine = el("p", { className: "m01lab__total", attrs: { "data-sum": calculated ? String(total) : "" }, - text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`, + text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, }); // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임) const fold = el("details", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 7fd3ca41..69288127 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -1,25 +1,25 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Modal.ts - * 호표 줄 자료 모달 — 그 줄이 쓰는 자료를 보임 - * 표(소요량·계수)는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처 + * 자료 모달 둘 — + * ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 + * 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식 + * ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임 + * 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처 * 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices` * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { - explain, - loadTable, - matchRow, - relatedFinds, - type RelatedFind, - type TableRow, -} from "./M01_MasterData_UI_Logic_Note"; +import { explain, loadTable, matchRow, type TableRow } from "./M01_MasterData_UI_Logic_Note"; import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side"; -import { pillBar, type Pill } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -import "./M01_MasterData_UI_LogicLab_Pill.css"; + +/** 원 단위 소수점 0 — 돈 자리만(수량·비율은 `formatNumber` 그대로) */ +const formatMoney = (value: unknown): string => + typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) + : formatNumber(value); export interface ModalTarget { title: string; @@ -33,19 +33,25 @@ export interface ModalTarget { values: Record; } +/** 수량 칸 표 알약 하나가 여는 자료 — 조각 계약의 「표 찾기」 자리 */ +export interface TableTarget { + title: string; + table: string; + conds: [string, string][]; + values: Record; +} + function tableView( - find: RelatedFind, table: TableRow, + conds: [string, string][], values: Record, ): HTMLElement { - // 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌 + // 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌(줄마다 따로 봄) const hit = (table.줄 ?? []).find((line) => matchRow( { ...table, 줄: [line] }, - find.find.conds.filter( - ([col]) => line[col] !== undefined && line[col] !== null && line[col] !== "", - ), + conds.filter(([col]) => line[col] !== undefined && line[col] !== null && line[col] !== ""), { values, middle: {} }, ), ) ?? null; @@ -71,7 +77,7 @@ function tableView( }), el("p", { className: "m01-logic__muted", - text: `${find.source} → ${find.find.col} · ${hit ? tl("Modal_Hit") : tl("Modal_NoHit")}`, + text: hit ? tl("Modal_Hit") : tl("Modal_NoHit"), }), goButton({ kind: "table", key: table.키 }), el("div", { @@ -117,7 +123,7 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { ? [el("dt", { text: tl("Modal_Spec") }), el("dd", { text: brief.규격 })] : []), el("dt", { text: tl("Modal_Value") }), - el("dd", { className: "m01-logic__money", text: formatNumber(brief.값) }), + el("dd", { className: "m01-logic__money", text: formatMoney(brief.값) }), ...(brief.file ? [el("dt", { text: tl("Modal_Source") }), el("dd", { text: brief.file })] : []), @@ -129,18 +135,18 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { }); } -/** 모달을 엶 */ -export function openMaterialModal(target: ModalTarget): void { +/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */ +function openDialog(title: string, mount: (body: HTMLElement) => void): void { const body = el("div", { className: "m01lab__modal-body" }); const close = (): void => backdrop.remove(); const dialog = el("div", { className: "m01-logic__pick m01lab__modal", - attrs: { role: "dialog", "aria-label": target.title }, + attrs: { role: "dialog", "aria-label": title }, children: [ el("div", { className: "m01lab__modal-top", children: [ - el("h3", { text: target.title }), + el("h3", { text: title }), createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }), ], }), @@ -150,64 +156,62 @@ export function openMaterialModal(target: ModalTarget): void { const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); - - const finds = relatedFinds(target.expr, target.middles); - const base = target.element?.split(".")[0] ?? ""; - const price = target.element ? (target.prices[target.element] ?? target.prices[base]) : undefined; - const logicRef = target.element?.startsWith("로직(") ? target.element : ""; - const pills: Pill[] = []; - if (price && target.element) { - pills.push({ - label: `${groupOf(base)} ${price.이름 ?? base}`.trim(), - view: () => priceView(target.element as string, price), - }); - } else if (target.element && !logicRef) { - pills.push({ - label: `${groupOf(base)} ${target.title}`.trim(), - view: () => - el("p", { - className: "m01-logic__muted", - text: `${target.element} — ${tl(target.element?.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`, - }), - }); - } - if (logicRef) { - pills.push({ - label: `${tl("Pill_Logic")} ${logicRef.slice(3).split(",")[0].replace(/\)$/, "")}`, - view: () => el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }), - }); - } - for (const f of finds) { - pills.push({ - label: `${tl("Pill_Table")} ${f.find.table}`, - view: async () => { - const table = await loadTable(f.find.table); - return table - ? tableView(f, table, target.values) - : el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` }); - }, - }); - } - const unit = price ? `${tl("Modal_UnitPrice")} ${formatNumber(price.값)} × ` : ""; - const parts: HTMLElement[] = [ - el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }), - el("p", { - className: "m01lab__sum", - text: `${target.title} = ${unit}${explain(target.expr)}`, - }), - ...(pills.length - ? [el("p", { className: "m01-logic__muted", text: tl("Pill_Hint") }), pillBar(pills)] - : [el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") })]), - el("details", { - className: "m01lab__raw", - children: [ - el("summary", { text: tl("Modal_Qty") }), - el("pre", { className: "m01lab__expr", text: target.expr }), - ], - }), - ]; - body.replaceChildren(...parts); + mount(body); document.body.append(backdrop); dialog.tabIndex = -1; dialog.focus(); } + +/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) */ +export function openMaterialModal(target: ModalTarget): void { + openDialog(target.title, (body) => { + const base = target.element?.split(".")[0] ?? ""; + const price = target.element + ? (target.prices[target.element] ?? target.prices[base]) + : undefined; + const logicRef = target.element?.startsWith("로직(") ? target.element : ""; + const rateParts: HTMLElement[] = []; + if (price && target.element) { + rateParts.push(priceView(target.element, price)); + } else if (logicRef) { + rateParts.push(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` })); + } else if (target.element) { + rateParts.push( + el("p", { + className: "m01-logic__muted", + text: `${target.element} — ${tl(target.element.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`, + }), + ); + } + const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.값)} × ` : ""; + body.replaceChildren( + ...rateParts, + el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }), + el("p", { + className: "m01lab__sum", + text: `${target.title} = ${unit}${explain(target.expr)}`, + }), + el("details", { + className: "m01lab__raw", + children: [ + el("summary", { text: tl("Modal_Qty") }), + el("pre", { className: "m01lab__expr", text: target.expr }), + ], + }), + ); + }); +} + +/** 수량 칸 표 알약을 눌렀을 때 — 그 표만 보임(걸린 줄 강조) */ +export function openTableModal(target: TableTarget): void { + openDialog(target.title, (body) => { + body.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); + void loadTable(target.table).then((table) => { + body.replaceChildren( + table + ? tableView(table, target.conds, target.values) + : el("p", { className: "m01-logic__bad", text: `${target.table} ?` }), + ); + }); + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts index 7282dd4c..4e28b0ba 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts @@ -1,44 +1,44 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Pill.ts - * 줄 모달의 알약 단추 — 쓰인 자료를 알약으로만 늘어놓고, 누른 것 하나만 펼침(다시 누르면 접힘) + * 알약 단추 — 수량 칸 안 조각(표 찾기 등)을 누르면 곧장 그 자료 모달을 엶. + * (이전에는 알약을 늘어놓고 아래 자리에서 펼쳤으나, 알약이 수량 칸 안으로 들어가며 + * 「누르면 모달」 로 바뀜 — 모달 안에 알약 목록을 또 두지 않음.) * ========================================================================== */ import { el } from "@ui/ui_template_elements"; export interface Pill { - /** 알약 글 — 보기 「표 QF000421」 · 「인력 석공」 */ + /** 알약 글 — 보기 「표 QF000421」 */ label: string; - /** 펼칠 때 그림 — 표처럼 읽어 와야 하면 Promise */ - view: () => HTMLElement | Promise; + /** 누르면 할 일 — 그 자료 모달을 엶 */ + onClick: () => void; } -export function pillBar(pills: Pill[]): HTMLElement { - const stage = el("div", { className: "m01lab__pill-stage" }); - const buttons: HTMLButtonElement[] = []; - let open = -1; - let turn = 0; - const show = async (i: number): Promise => { - const mine = ++turn; - open = open === i ? -1 : i; - buttons.forEach((b, k) => { - b.setAttribute("aria-expanded", String(k === open)); - b.classList.toggle("is-open", k === open); - }); - if (open < 0) return stage.replaceChildren(); - const view = await pills[open].view(); - if (mine === turn) stage.replaceChildren(view); // 빨리 눌러도 마지막 것만 - }; - pills.forEach((p, i) => { - const b = el("button", { - className: "m01lab__pill", - text: p.label, - attrs: { type: "button", "aria-expanded": "false", "data-pill": p.label }, - }); - b.addEventListener("click", () => void show(i)); - buttons.push(b); +/** 알약 하나 — 글자 조각 사이에 끼워 쓰는 단추 */ +export function pillButton(p: Pill): HTMLButtonElement { + const b = el("button", { + className: "m01lab__pill", + text: p.label, + attrs: { type: "button", "data-pill": p.label }, }); - return el("div", { - className: "m01lab__pills", - children: [el("div", { className: "m01lab__pill-row", children: buttons }), stage], + b.addEventListener("click", (ev) => { + ev.stopPropagation(); // 줄 전체 누르기(행 모달)와 안 겹치게 + p.onClick(); + }); + return b; +} + +/** 수량 칸 조각 — 알약 아니면 그냥 글자(연산기호·숫자·중간값 이름) */ +export type Fragment = { pill: Pill } | { text: string }; + +/** 조각을 이어 그림 — 칸 폭을 넘으면 자동으로 줄바꿈(`flex-wrap`) */ +export function fragmentRow(fragments: Fragment[]): HTMLElement { + return el("span", { + className: "m01lab__qty-frags", + children: fragments.map((f) => + "pill" in f + ? pillButton(f.pill) + : el("span", { className: "m01lab__qty-text", text: f.text }), + ), }); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 4d973fd1..4e2d49a3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -39,13 +39,27 @@ margin: var(--spacing-8) 0 var(--spacing-4); } +/* 칸 폭 고정(`table-layout: fixed`) — 인력·재료·경비 세 표가 저마다 딴 `` 이라 + 내용 기준으로 폭이 갈리던 것을 막고 셋이 같은 폭으로 맞춰짐(일감35 이어 — 수량 칸 알약화). */ +.m01lab__ho { + table-layout: fixed; +} + +.m01lab__ho th:nth-child(1) { + width: 26%; +} + .m01lab__ho th:nth-child(2) { - width: 64px; + width: 8%; +} + +.m01lab__ho th:nth-child(3) { + width: 40%; } .m01lab__ho th:nth-child(4), .m01lab__ho th:nth-child(5) { - width: 110px; + width: 13%; } .m01lab__line { @@ -61,7 +75,6 @@ font-style: italic; } -.m01lab__qty, .m01lab__expr { font-family: var(--font-mono, monospace); font-size: var(--text-body-sm); @@ -69,6 +82,25 @@ word-break: break-all; } +/* 수량 칸 — 알약 + 사칙연산 글자를 이어 붙이고, 칸 폭을 넘으면 그 칸 폭 기준으로 줄바꿈 */ +.m01lab__qty-cell { + overflow-wrap: anywhere; +} + +.m01lab__qty-frags { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 4px; + min-width: 0; +} + +.m01lab__qty-text { + font-family: var(--font-mono, monospace); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + .m01lab__subtotal td { font-weight: 600; background: var(--color-paper);