Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into sub_laptop_3

This commit is contained in:
2026-11-02 18:32:24 +09:00
9 changed files with 276 additions and 69 deletions
+47
View File
@@ -17,3 +17,50 @@ p[data-sheet-part] {
margin: 0;
padding-left: var(--spacing-16);
}
/* 737-1 옵션 칸 격자 — 상자 폭 따라 1 → 2 → 3 → 4 → 6 열(300 · 450 · 600 · 900) · 칸마다 이름 위 · 고르기 아래 */
.calc-sheet__opts {
container-type: inline-size;
}
.calc-sheet__opts-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-8);
}
@container (min-width: 300px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (min-width: 450px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container (min-width: 600px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@container (min-width: 900px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.calc-sheet__opts-grid > .ui-calc-row {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: var(--spacing-4);
}
.calc-sheet__opts-grid > .ui-calc-row > :is(.ui-calc-row__select, .ui-calc-row__in, .ui-field) {
grid-column: 1;
justify-self: stretch;
width: 100%;
}
+11
View File
@@ -182,6 +182,17 @@ export function varRow(spec: SheetInput, value: string, onInput: (v: string) =>
return row;
}
/** 옵션 칸들 한 덩이(737-1) — 상자 폭 따라 6 → 4 → 3 → 2 → 1 열 격자(`calc_sheet.css`) · 없으면 빈 목록 */
export const optionGrid = (rows: HTMLElement[]): HTMLElement[] =>
rows.length
? [
el("div", {
className: "calc-sheet__opts",
children: [el("div", { className: "calc-sheet__opts-grid", children: rows })],
}),
]
: [];
/** 장비 · 하위 한 줄 — 노 · 재 · 경 칸 + 합 · 누르면(Enter · Space) 몫 식 줄 펼침 */
function mergedRows(m: SheetMerged): HTMLElement[] {
const share = (cost: string): number | null =>
@@ -210,7 +210,7 @@ function hoBox(ctx: DetailContext): HTMLElement {
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine];
const calculated = ctx.lines !== null;
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho");
return block(section(tl("Ho_Title"), blocks, hint), "ho");
}
/** 상세 화면 넷을 `host` 에 쌓음 */
@@ -224,6 +224,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
? { node: active, from: active.selectionStart, to: active.selectionEnd }
: null;
// 737-3 단가산출 상세 · 텍스트 수식 두 보기 상자 제목 같음 · 「지금 입력」 꼬리표 없음
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
host.replaceChildren(
...infoBox(ctx),
@@ -237,10 +238,8 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
...(ctx.flowHost && !ctx.cover
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
: [
ctx.cover
? block(section(tl("Ho_Title"), [nowInputs(ctx.values), ctx.cover.ho]), "ho")
: hoBox(ctx),
block(section(tl("Formula_Title"), [formula]), "formula"),
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
block(section(tl("Ho_Title"), [formula]), "formula"),
]),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
@@ -3,13 +3,15 @@
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
* 714-3 — 줄이 쓰는 입력(서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
* 737-2 — 비목(노무비 · 재료비 · 경비)마다 구분 상자 · 맨 위 옵션 고르기(그 상자 줄이 쓰는 입력 · 서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각 + 어느 줄도 안 쓰는 공통 입력 · 6 → 4 → 3 → 2 → 1 열) →
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createCard } from "@ui/ui_template_card";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api";
import { optionGrid, varRow } from "../A00_Common/calc_sheet";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
@@ -118,43 +120,40 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
);
return;
}
const heads = el("div", {
className: "m01lab-fx__head",
children: [
el("strong", { text: tl("Formula_Left") }),
el("strong", { text: tl("Formula_Right") }),
],
});
const heads = (): HTMLElement =>
el("div", {
className: "m01lab-fx__head",
children: [
el("strong", { text: tl("Formula_Left") }),
el("strong", { text: tl("Formula_Right") }),
],
});
const rows: HTMLElement[] = [];
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
const vars = (line: TextLine): HTMLElement[] => {
const names = specs.map((x) => x.이름);
const seen = new Map(usedInputs(line, names));
// 서버 「쓰는입력」(714-1 · 조건 · 재료 고르기 · 중간값까지) 이 있으면 그것 · 없으면 조각에서
const used: [string, string][] = line.쓰는입력
? line.쓰는입력.filter((n) => names.includes(n)).map((n) => [n, seen.get(n) ?? ""])
: [...seen];
if (!used.length || !ctx.onInput) return [];
// 737-2 옵션 칸 = 비목 상자 맨 위 — 그 상자 줄이 쓰는 입력 + 어느 줄도 안 쓰는 입력(공통 · 737-3)
const names = specs.map((x) => x.이름);
const usedBy = (line: TextLine): string[] =>
line.쓰는입력
? line.쓰는입력.filter((n) => names.includes(n))
: usedInputs(line, names).map(([n]) => n);
const everyUsed = new Set(text.groups.flatMap((g) => g.줄.flatMap(usedBy)));
const common = names.filter((n) => !everyUsed.has(n));
const options = (g: (typeof text.groups)[number]): HTMLElement[] => {
const onInput = ctx.onInput;
return [
el("div", {
className: "m01lab-fx__vars",
children: used.map(([name, was]) =>
inputChip(
specs.find((x) => x.이름 === name)!,
ctx.values?.[name] || was,
(v) => onInput(name, v),
),
),
}),
];
if (!onInput) return [];
const mine = new Set([...g.줄.flatMap(usedBy), ...common]);
return optionGrid(
specs
.filter((x) => mine.has(x.이름))
.map((x) => varRow(x, ctx.values?.[x.이름] ?? "", (v) => onInput(x.이름, v))),
);
};
const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false
const light = (pair: string, on: boolean): void =>
rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on));
const blocks = text.groups.flatMap((g) => [
el("h4", { className: "m01lab__group m01lab-fx__wide", text: g.비목 }),
...g.줄.map((line, i) => {
// 737-2 비목마다 구분 상자 — 맨 위 옵션 칸 · 아래 두 칸 식 줄들 · 소계
const boxes = text.groups.map((g) => {
const lines = g.줄.map((line, i) => {
const pair = String(line.짝 ?? `${g.비목}${i}`);
const row = el("div", {
className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`,
@@ -166,7 +165,6 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
children: calculated
? [
el("div", { text: `${line.이름} : ${line.글}` }),
...vars(line),
...(line.까닭
? [
el("div", {
@@ -189,20 +187,36 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
});
rows.push(row);
return row;
}),
});
const card = createCard({
title: g.비목,
pad: 12,
body: [
...options(g),
el("div", {
className: "m01lab-fx",
children: [
heads(),
...lines,
el("p", {
className: "m01lab__subtotal-text m01lab-fx__wide",
attrs: { "data-text-sub": String(g.소계) },
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
}),
],
}),
],
});
card.classList.add("m01lab__ho-box");
card.dataset.fxBox = g.비목;
return card;
});
host.replaceChildren(
...boxes,
el("p", {
className: "m01lab__subtotal-text m01lab-fx__wide",
attrs: { "data-text-sub": String(g.소계) },
text: `${g.비목} ${tl("Subtotal")} ${formatMoney(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`,
}),
]);
blocks.unshift(heads);
blocks.push(
el("p", {
className: "m01lab__total m01lab-fx__wide",
className: "m01lab__total",
attrs: { "data-text-sum": String(text.계) },
text: `${tl("Total")} ${formatMoney(text.계)}`,
}),
);
host.replaceChildren(el("div", { className: "m01lab-fx", children: blocks }));
}
+32 -19
View File
@@ -3,9 +3,10 @@
* 공용 「단가산출 상세」(PLAN 722-1 · 요구서 내역16 2장 6 · 7) — M01 단가산출 로직 상세가 씀 · (724) M02 단가산출 · 일위대가 모달도.
* 하위 박스 = 서버 `묶음`(인력 · 재료 · 경비(장비) · 하위 — `/text` 줄 덧칸 714-1) · 계산 전 = 호표 종류로
* · 장비 · 하위 로직 줄 = 박스 안 한 줄(노 · 재 · 경 몫 합) + 누르면 펼침(부른 로직 시간당 줄 · 몫 식 줄)
* 박스 제목 바로 아래 = 그 박스 줄들이 쓰는 입력 고르기(`calc_sheet` 변수 칸 꼴) · 어느 박스도 안 쓰는 입력 = 맨 위 「공통 옵션」
* 박스 제목 바로 아래 = 그 박스 줄들이 쓰는 입력 고르기(`calc_sheet` 변수 칸 꼴 · 6 → 4 → 3 → 2 → 1 열 격자) +
* 어느 박스도 안 쓰는 입력(공통 옵션)도 모든 박스에 같이(737-3 · 한 값 · 어디서 바꿔도 모두 따라감)
* 「계산식」 열 = 값 든 식(「268,908 × 0.12」 · 서버 `글`) + 표 · 요소 · 로직 알약 + 그 줄 입력 칩 · 변수 이름 식(`이름글`)은 마우스 올리면
* 맨 아래 = 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계
* 맨 아래 = 한 줄 4 칸 — 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계 · 화면(모달) 바닥에 붙음(737-1)
* 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`)
* 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합) · 화면에서 다시 셈 안 함
* ========================================================================== */
@@ -17,7 +18,7 @@ import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { emptyRow, tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { sheetFormula, varRow, type SheetUnfold } from "../A00_Common/calc_sheet";
import { optionGrid, sheetFormula, varRow, type SheetUnfold } from "../A00_Common/calc_sheet";
import type {
CalcLine,
ElementBrief,
@@ -600,7 +601,13 @@ const usesOf = (b: HoBox<Entry>): string[] => [
),
];
function boxCard(b: HoBox<Entry>, ctx: HoContext, calculated: boolean): HTMLElement {
/** `common` = 어느 박스 줄도 안 쓰는 입력 — 737-3 모든 박스 맨 위 옵션 칸에 같이(한 값 · 어디서 바꿔도 모두 따라감) */
function boxCard(
b: HoBox<Entry>,
ctx: HoContext,
calculated: boolean,
common: string[],
): HTMLElement {
const name = boxName(b.묶음);
const lines = b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]));
const basis = [...new Set(lines.map((e) => e.textLine?.기준단위).filter(Boolean))];
@@ -626,7 +633,7 @@ function boxCard(b: HoBox<Entry>, ctx: HoContext, calculated: boolean): HTMLElem
body: [
...(basis.length ? [createText(`${th("Basis")} ${basis.join(" · ")}`, "muted")] : []),
...(notes.length ? [createText(notes.join(" · "), "hint")] : []),
...optionRows(usesOf(b), ctx),
...optionGrid(optionRows([...usesOf(b), ...common], ctx)),
root,
],
});
@@ -635,24 +642,21 @@ function boxCard(b: HoBox<Entry>, ctx: HoContext, calculated: boolean): HTMLElem
return card;
}
/** 어느 박스 줄도 안 쓰는 입력 — 맨 위 한 카드 */
/** 박스가 하나도 없을 때만 — 어느 박스 줄도 안 쓰는 입력 한 카드 */
function commonCard(rows: HTMLElement[]): HTMLElement {
const card = createCard({ title: th("Common"), pad: 12, body: rows });
const card = createCard({ title: th("Common"), pad: 12, body: optionGrid(rows) });
card.classList.add("m01lab__ho-box");
card.dataset.hoBox = "common";
return card;
}
/** 공용 단가산출 상세 몸 — 공통 옵션 · 박스들 · 비목 소계 셋 · 계 */
/** 공용 단가산출 상세 몸 — 박스들(맨 위 옵션 칸 · 공통 옵션 포함) · 맨 아래 한 줄(비목 소계 셋 · 계) */
export function buildHo(ctx: HoContext): HTMLElement[] {
const all = entries(ctx);
const calculated = ctx.lines !== null;
const boxes = boxesOf(all, ["인력", "재료", "경비"]);
const used = new Set(boxes.flatMap(usesOf));
const common = optionRows(
(ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n)),
ctx,
);
const common = (ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n));
const sums = (["labor", "material", "cost"] as Group[]).map((g) => {
const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0);
const out = el("span", {
@@ -666,13 +670,22 @@ export function buildHo(ctx: HoContext): HTMLElement[] {
});
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
return [
...(common.length ? [commonCard(common)] : []),
...boxes.map((b) => boxCard(b, ctx, calculated)),
el("div", { className: "m01lab__sums", children: sums }),
el("p", {
className: "m01lab__total",
attrs: { "data-sum": calculated ? String(total) : "" },
text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`,
...(boxes.length || !common.length ? [] : [commonCard(optionRows(common, ctx))]),
...boxes.map((b) => boxCard(b, ctx, calculated, common)),
// 737-1 맨 아래 한 줄 4 칸(노무비 · 재료비 · 경비 · 계) — 화면(모달) 바닥에 붙음
el("div", {
className: "m01lab__foot",
children: [
el("div", { className: "m01lab__sums", children: sums }),
el("p", {
className: "m01lab__total",
attrs: { "data-sum": calculated ? String(total) : "" },
children: [
el("span", { text: `${tl("Total")} ` }),
el("span", { text: calculated ? formatMoney(total) : "—" }),
],
}),
],
}),
];
}
@@ -241,10 +241,61 @@
gap: var(--spacing-8);
}
/* 737-1 맨 아래 한 줄 4 칸(노무비 · 재료비 · 경비 · 계) — 화면(모달) 바닥에 붙음 · 칸마다 이름 위 · 값 아래 */
.m01lab__foot {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-8);
margin-top: var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-raised);
}
/* 합계 줄이 화면 바닥에 붙으려면 메인 칸이 굴림 칸(overflow:auto)이 아니어야 함 — 메인은 높이가 안 묶여 안 굴렀음 */
.ui-workflow-layout__main:has(.m01lab__foot) {
overflow: clip;
}
.m01lab__sums {
display: contents;
}
.m01lab__foot .ui-calc-row {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
align-items: stretch;
gap: 0;
min-height: 0;
}
.m01lab__foot .ui-calc-row > :empty {
display: none;
}
.m01lab__foot .ui-calc-row__out {
padding: 0;
text-align: left;
}
.m01lab__foot .m01lab__total {
display: flex;
flex-direction: column;
margin: 0;
text-align: left;
}
.m01lab__foot .m01lab__total > :first-child {
color: var(--color-text-secondary);
font-weight: var(--font-weight-regular, 400);
}
.m01lab__foot .m01lab__total > :last-child {
font-variant-numeric: tabular-nums;
}
/* 722-2 줄 모달 「그 자리 고침」 */
@@ -339,7 +339,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
);
root.innerHTML = "";
root.append(layout);
void main.open(home());
// 첫 화면(738-1) — 단가산출 · 일위대가로 들어오면 그 컨테이너 기본 층(개인) 단추를 누른 것과 같음
// (층 목록표 · 왼쪽 나무) · 구조물 조합 · 도면 = 집계표
void main.open(
entry.open === "단가산출"
? sangeunList(sk)
: entry.open === "일위대가조합"
? precomboList(pre)
: home(),
);
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
@@ -0,0 +1,46 @@
"""737 단가산출 상세 — 옵션 칸 격자 · 합계 한 줄 바닥 · 두 보기 같은 제목 · 비목 상자 지킴 시험(글 대조 · 서버 · 화면 없음)."""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
M01 = ROOT / "M01_MasterData"
SHEET_CSS = (ROOT / "A00_Common" / "calc_sheet.css").read_text(encoding="utf-8")
STYLE = (M01 / "M01_MasterData_UI_LogicLab_Style.css").read_text(encoding="utf-8")
HO = (M01 / "M01_MasterData_UI_LogicLab_Ho.ts").read_text(encoding="utf-8")
DETAIL = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8")
FX = (M01 / "M01_MasterData_UI_LogicLab_Formula.ts").read_text(encoding="utf-8")
def test_옵션_격자_폭_따라_6_4_3_2_1():
cols = re.findall(
r"@container \(min-width: (\d+)px\) \{\s*\.calc-sheet__opts-grid \{\s*grid-template-columns: repeat\((\d), ",
SHEET_CSS,
)
assert cols == [("300", "2"), ("450", "3"), ("600", "4"), ("900", "6")]
assert "container-type: inline-size" in SHEET_CSS # 창 폭이 아니라 상자 폭 기준
def test_합계_한_줄_바닥_고정():
foot = STYLE[STYLE.index(".m01lab__foot {") :]
foot = foot[: foot.index("}")]
assert "position: sticky" in foot and "bottom: 0" in foot
assert "repeat(4, minmax(0, 1fr))" in foot
assert "overflow: clip" in STYLE # 메인 칸이 굴림 칸이면 바닥에 안 붙음
assert 'className: "m01lab__foot"' in HO
def test_공통_옵션은_모든_박스에_같이():
assert "optionRows([...usesOf(b), ...common], ctx)" in HO
assert "commonCard(" in HO and "boxes.length ||" in HO # 박스 없을 때만 따로 카드
def test_두_보기_같은_제목_지금_입력_꼬리표_없음():
assert 'section(tl("Formula_Title")' not in DETAIL
assert 'section(tl("Ho_Title"), [formula])' in DETAIL
assert "nowInputs(ctx.values), ...blocks" not in DETAIL
def test_텍스트_수식은_비목_상자_옵션은_상자_위():
assert "createCard({" in FX and "fxBox" in FX and "optionGrid(" in FX
assert "...vars(line)" not in FX # 줄마다 붙던 옵션 꼬리표 걷음
@@ -0,0 +1,18 @@
"""738-1 — M02 첫 화면: 단가산출 · 일위대가로 들어오면 그 컨테이너 기본 층 목록표, 그 밖은 집계표."""
from pathlib import Path
PAGE = Path("M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text(encoding="utf-8")
def test_첫_화면은_들어온_컨테이너의_층_목록표():
start = PAGE.index("void main.open(\n entry.open")
block = PAGE[start : start + 220]
assert 'entry.open === "단가산출"' in block and "sangeunList(sk)" in block
assert 'entry.open === "일위대가조합"' in block and "precomboList(pre)" in block
assert "home()" in block # 구조물 조합 · 도면 = 집계표 그대로
def test_단가산출_층_기본은_개인():
assert 'let sk = preScope("personal")' in PAGE
assert "let pre = preScope(entry.pre ?? defaultRoot(user))" in PAGE