Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1

This commit is contained in:
2026-10-05 11:24:05 +09:00
9 changed files with 190 additions and 27 deletions
+9 -2
View File
@@ -1,6 +1,6 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}],
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
@@ -26,6 +26,7 @@ mm = mf.mp.mm
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
def _bad(text: str) -> store.StoreError:
@@ -83,10 +84,16 @@ def clean(doc: Any) -> dict[str, Any]:
given = one.get("입력값") or {}
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo = one.get("메모")
memo, alias = one.get("메모"), one.get("별칭")
if alias is not None and not isinstance(alias, str):
raise _bad(f"{where} · 별칭은 글")
alias = (alias or "").strip()
if len(alias) > ALIAS_MAX:
raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지")
lines.append(
{
"로직": code,
"별칭": alias or None,
"메모": str(memo) if memo else None,
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
@@ -78,6 +78,59 @@
white-space: nowrap;
}
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
.m02-labrow__opt {
max-width: 0;
overflow: hidden;
padding-top: 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
white-space: nowrap;
text-overflow: ellipsis;
}
.m02-labrow__row:has(+ .m02-labrow__optrow) td {
border-bottom: 0;
}
.m02-labrow__optrow.is-open {
cursor: pointer;
}
.m02-labrow__optrow.is-open:hover,
.m02-labrow__row.is-open:hover + .m02-labrow__optrow {
background: var(--color-mist-violet);
}
/* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */
.m02-labrow__alias {
min-width: 7rem;
}
.m02-labrow__aliasin {
width: 100%;
min-width: 6rem;
padding: 2px var(--spacing-4);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm, 4px);
background: transparent;
color: inherit;
font: inherit;
}
.m02-labrow__aliasin:hover,
.m02-labrow__aliasin:focus {
border-color: var(--color-primary);
border-style: solid;
}
/* 총합계 줄 전체 합계 칸(44-6) — 테두리 · 색 */
.m02-labrow__sum .m02-labrow__total {
border: 2px solid var(--color-primary);
background: var(--color-mist-violet);
color: var(--color-primary);
}
/* 입력 모달 */
.m02-labin {
width: min(40rem, calc(100vw - 32px));
@@ -234,6 +287,17 @@
order: 3;
text-align: right;
}
/* 옵션 값 줄 — 한 줄 통째 */
.m02-labrow__optrow .m02-labrow__no,
.m02-labrow__sum .m02-labrow__alias {
display: none;
}
.m02-labrow .m02-labrow__opt {
grid-column: 1 / -1;
max-width: none;
}
}
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
@@ -121,6 +121,8 @@ export type LaborQty = { 손: number } | null;
/** 담은 로직 한 줄 — 입력값 = 그 로직 입력 이름만(빈 값 = 자동값) */
export interface LaborRow {
로직: string;
/** 사용자가 지은 이름(44-6 · 빈 것 = null) */
별칭?: string | null;
메모: string | null;
입력값: Record<string, string | number>;
수량: LaborQty;
@@ -153,6 +155,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
단위: d.단위 ?? null,
담은로직: (d.담은로직 ?? []).map((r) => ({
로직: r.로직,
별칭: r.별칭 ?? null,
메모: r.메모 ?? null,
입력값: r.입력값 ?? {},
수량: r.수량 ?? null,
@@ -136,7 +136,13 @@ export function mountLaborDetail(
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit
? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow }
? {
open: openRow,
qty: openQty,
...(opt.card ? {} : { move }),
remove: removeRow,
alias: setAlias,
}
: { open: openRow },
),
);
@@ -263,6 +269,11 @@ export function mountLaborDetail(
cur.담은로직.splice(i, 1);
changed();
}
/** 별칭(44-6) — 화면 캐시만(저장은 [저장]) · 금액과 무관이라 다시 셈 · 다시 그림 없음 */
function setAlias(i: number, value: string | null): void {
cur.담은로직[i].별칭 = value;
opt.onChanged(dirty());
}
function removeMat(i: number): void {
cur.자재.splice(i, 1);
changed();
@@ -312,7 +323,7 @@ export function mountLaborDetail(
openLogicPicker((x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null });
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
void loadLogics().then(() => changed());
}),
});
@@ -1,8 +1,9 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Rows.ts
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
* 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
* 금액은 calc 답(없으면 「…」).
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
@@ -31,11 +32,14 @@ const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
계: "Prev_Sum",
};
/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
const SPAN = 4 + COSTS.length;
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) ·
* 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
/** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) ·
* 이은 칸이 비었으면 「수량 빈 칸(A1)」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
export function qtyText(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
@@ -44,12 +48,12 @@ export function qtyText(
if (!line) return "";
if (line.수량 === null || line.수량 === undefined) {
const at = line.알약 ? cells.get(line.알약) : undefined;
return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : "";
return at ? `${tc("Labor_Qty")} ${tc("In_QtyEmpty")}(${at})` : "";
}
const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`;
if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`;
const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell");
return `${tc("Labor_Qty")} ${cell} = ${v}`;
return `${tc("Labor_Qty")} ${v}(${cell})`;
}
/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */
@@ -60,7 +64,7 @@ export function baseQtyText(
return `${num(line.배 || 1)} ${line.분모}`;
}
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */
export function qtyShort(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
@@ -68,13 +72,13 @@ export function qtyShort(
): string {
const at = line?.알약 ? cells.get(line.알약) : undefined;
if (!line || line.수량 === null || line.수량 === undefined)
return at ? `${at} = ${tc("In_QtyEmpty")}` : "—";
return at ? `${tc("In_QtyEmpty")}(${at})` : "—";
if (line.수량출처 === "손")
return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`;
return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`;
return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`;
}
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) ·
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
* `open` 이 있으면 단추(줄 누름과 따로) */
function qtyCell(
line: LaborCalcLine | undefined,
@@ -155,6 +159,42 @@ export interface RowHandlers {
qty?: (i: number) => void;
move?: (i: number, d: number) => void;
remove?: (i: number) => void;
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
alias?: (i: number, value: string | null) => void;
}
/** 별칭 칸(44-6) — 고칠 수 있으면 칸 안 글상자(누르면 바로 고침 · 줄 누름과 따로) · 아니면 글 · 비면 빈 칸 */
function aliasCell(
value: string | null | undefined,
set: ((v: string | null) => void) | null,
): HTMLElement {
if (!set)
return el("td", {
className: "m02-labrow__alias",
text: value ?? "",
attrs: { "data-label": tc("Col_Alias") },
});
const input = el("input", {
className: "m02-labrow__aliasin",
attrs: {
type: "text",
maxlength: "40", // 서버 `ALIAS_MAX` 와 같음
title: tc("Alias_Edit"),
"aria-label": tc("Col_Alias"),
"data-labor-alias": "",
},
});
input.value = value ?? "";
input.addEventListener("click", (e) => e.stopPropagation()); // 줄 누름(입력 모달)과 따로
input.addEventListener("input", () => set(input.value.trim() || null));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") input.blur();
});
return el("td", {
className: "m02-labrow__alias",
attrs: { "data-label": tc("Col_Alias") },
children: [input],
});
}
export interface LogicTableData {
@@ -175,7 +215,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const tools = !!(move || remove);
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => l.로직 === key);
const trs = data.rows.map((row, i) => {
const trs = data.rows.flatMap((row, i) => {
const logic = data.logics.get(row.로직);
const line = lineOf(row.로직);
const unit = logic?.결과단위 ?? "";
@@ -201,7 +241,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
el("span", { className: "m02-labrow__key", text: row.로직 }),
el("strong", { text: logic?.이름 ?? row.로직 }),
...(note ? [el("small", { className: "m02-labrow__note", text: note })] : []),
...(values.empty
? [
el("small", {
@@ -221,6 +260,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
: []),
],
}),
aliasCell(row.별칭, on?.alias ? (v) => on.alias?.(i, v) : null),
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", { className: "m02-labrow__unit", text: unit }),
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))),
@@ -240,7 +280,23 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
if (!note) return [tr];
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
const opt = el("tr", {
className: on ? "m02-labrow__optrow is-open" : "m02-labrow__optrow",
attrs: { "data-labor-opt": row.로직 },
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));
return [tr, opt];
});
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
part?.[c] ?? null;
@@ -251,11 +307,19 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
el("td", { className: "m02-labrow__no", 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: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
...COSTS.map((c) =>
moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])),
),
...COSTS.map((c) => {
const td = moneyCell(
sum(data.calc?.기준합, c),
sum(data.calc?.반영합, c),
wait,
tc(HEAD[c]),
);
if (c === "계") td.classList.add("m02-labrow__total"); // 전체 합계 강조(44-6)
return td;
}),
...(tools ? [el("td", { text: "" })] : []),
],
}),
@@ -264,6 +328,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const heads = [
tc("Col_Order"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
@@ -27,6 +27,8 @@ const TEXT = {
Logics_Title: ["단가산출 로직", "Unit-price logics"],
Col_Order: ["차례", "No."],
Col_Name: ["이름", "Name"],
Col_Alias: ["별칭", "Alias"],
Alias_Edit: ["눌러서 별칭 고침", "Click to edit alias"],
Col_Unit: ["결과 단위", "Result unit"],
Col_Move: ["순서", "Move"],
Up: ["올리기", "Up"],