feat(M02): 50-4 일위대가 줄 누르면 표 아래 상세 · 구분 열

- 서버 calc 답 줄마다 그 로직 재료 · 노무 · 경비 「줄」 실음(버리던 것)
- 상세 · 카드 표 줄 누름 = 표 아래 M01 시험계산 표(answerView 다시 씀) + 반영 합 · 한 줄만 · 같은 줄 다시 누름 = 닫힘
- 이름 왼쪽 「구분」 열 — 키 머리에서 읽음(SP- 단가승격 · SC- 일위조합 · 그 밖 단가산출 · 저장 안 함)
- 시험: 줄 실림 · 합 = 기준 · ui_checks 50-4 시나리오

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-05 18:41:23 +09:00
co-authored by Claude Opus 5.5
parent 3e572ef210
commit 7b28cb42d3
9 changed files with 235 additions and 9 deletions
+4 -1
View File
@@ -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["코드"],
@@ -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;
}
@@ -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 {
@@ -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<HTMLElement>("[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 {
@@ -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<typeof tc>[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"),
@@ -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}",