Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts
T
eomsangdonandClaude Opus 5.5 47cbbd17bd feat(M02): 51-14 일위대가 순서 칸 2행 — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 단가승격 [옵션] · 일위조합 [수정] 서고 원본 모달
- 순서 칸 = 단추 묶음 두 줄(공용 ui-btn-group 간격) · 카드 갈래는 아래 줄만
- 단가승격 줄 [옵션] = 승격 문서 넣을 값 모달 · [저장] = 서고 저장 길 · 머리 한 줄 알림(쓰는 모든 구조물에 따라감)
- 일위조합 줄 [수정] = 사전조합 상세 모달(서고 모드 표 · 옵션 · 수량 · 순서 · 삭제 · 추가) · [저장] = 서고 원본 · 취소 = 버림 물음
- 자기 층 서고만 고침(위 층 = 읽기만) · 닫으면 구조물 일위대가 다시 셈
- 확인 시나리오 resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-05 21:05:31 +09:00

565 lines
23 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Labor_Rows.ts
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
* 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) ·
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조).
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
* 금액은 calc 답(없으면 「…」).
* 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] ·
* 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 단가승격 줄 = [옵션] · 일위조합 줄 = [수정](`ref` · 서고 원본) ·
* 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type {
LaborCalc,
LaborCalcLine,
LaborCalcMat,
LaborCost,
LaborMat,
LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "@ui/ui_template_general_layout.css";
import "./M02_MasterTemplete_Labor.css";
import { valueLine } from "./M02_Labor_Values_Model";
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
const HEAD: Record<(typeof COSTS)[number], Parameters<typeof tc>[0]> = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
계: "Prev_Sum",
};
/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */
const SPAN = 5 + COSTS.length;
/** 구분(50-4 · 저장 안 함) — 줄 모양(참조 키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */
export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
const key = row.참조?.키 ?? row.로직;
return 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 });
/** 수량 글 — 「수량 손 3 개소」 · 「수량 12.5 ㎡(B6)」(칸 = 그 알약을 이은 면적산출 칸 · 44-6) ·
* 이은 칸이 비었으면 「수량 빈 칸(A1)」(수량 없음 · 서버와 같음) · 아니면 빈 글 */
export function qtyText(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
cells: ReadonlyMap<string, string> = new Map(),
): string {
if (!line) return "";
if (line.수량 === null || line.수량 === undefined) {
const at = line.알약 ? cells.get(line.알약) : undefined;
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")} ${v}(${cell})`;
}
/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */
export function baseQtyText(
line: { 배?: number | null; 분모?: string | null } | undefined,
): string {
if (!line?.분모) return "";
return `${num(line.배 || 1)} ${line.분모}`;
}
/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「12.5(B6)」 · 「빈 칸(B6)」 · 없으면 「—」 */
export function qtyShort(
line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined,
unit: string,
cells: ReadonlyMap<string, string> = new Map(),
): string {
const at = line?.알약 ? cells.get(line.알약) : undefined;
if (!line || line.수량 === null || line.수량 === undefined)
return at ? `${tc("In_QtyEmpty")}(${at})` : "—";
if (line.수량출처 === "손")
return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`;
return `${num(line.수량)}(${at ?? tc("In_QtyCell")})`;
}
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
* `open` 이 있으면 단추(줄 누름과 따로) */
function qtyCell(
line: LaborCalcLine | undefined,
cells: ReadonlyMap<string, string> | undefined,
open: (() => void) | null,
): HTMLElement {
const parts = [
el("small", { className: "m02-labrow__base", text: baseQtyText(line) || "—" }),
el("span", { className: "m02-labrow__applied", text: qtyShort(line, line?.분모 ?? "", cells) }),
];
const body = open
? el("button", {
className: "m02-labrow__qtybtn",
attrs: { type: "button", title: tc("Labor_QtyPick"), "data-labor-qty-open": "" },
children: parts,
})
: el("span", { children: parts });
if (open)
body.addEventListener("click", (e) => {
e.stopPropagation(); // 줄 누름(고름)과 따로
open();
});
return el("td", {
className: "m02-labrow__qty",
attrs: { "data-label": tc("Labor_QtyCol") },
children: [body],
});
}
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
function moneyCell(
base: number | null | undefined,
applied: number | null | undefined,
wait: boolean,
label: string,
): HTMLElement {
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
return el("td", {
className: "m02-labrow__money",
attrs: { "data-label": label },
children: [
el("small", {
className: "m02-labrow__base",
text: show(base),
attrs: { title: tc("Labor_Base") },
}),
el("span", {
className: "m02-labrow__applied",
text: show(applied),
attrs: { title: tc("Labor_Applied") },
}),
],
});
}
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
const titled = (td: HTMLElement): HTMLElement => {
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
return td;
};
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap",
children: [
el("table", {
className: "ui-general-block__table m02-labrow",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
...(foot.length ? [el("tfoot", { children: foot })] : []),
],
}),
],
});
/** 줄 누름 · 순서 · 옵션 · 삭제 — 셋 다 없으면 그 칸 없음 */
export interface RowHandlers {
open: (i: number) => void;
/** [옵션] 단추(47-5 · 넣을 값 모달) — 없으면 단추 없음 */
option?: (i: number) => void;
/** 참조 줄 단추(51-14) — 단가승격 [옵션] · 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */
ref?: (i: number) => void;
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
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 {
rows: LaborRow[];
logics: Map<string, ItemLogic>;
/** calc 답 — null = 계산 중 · 못 셈은 `fail` */
calc: LaborCalc | null;
fail?: string | null;
/** 알약 키 → 이은 면적산출 칸(「B6」) */
cells?: ReadonlyMap<string, string>;
/** 고른 줄 로직 키(47-5 · 하이라이트) */
selected?: string | null;
}
/** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 ·
* 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) ·
* 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) ·
* 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */
function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] {
const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1);
const items = line?.안
? line.안.map((x) => {
const key = x.참조?.키 ?? x.로직 ?? "";
const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만
return {
name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`,
qty: x.수량,
unit: x.단위 ?? "",
cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null,
};
})
: (line?.줄 ?? []).map((x) => ({
name: x.출처 ? `${x.이름} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : x.이름,
qty: x.수량,
unit: x.단위,
cost: { ...x.비목, 계: x.금액 } as Record<string, number>,
}));
const blank = (className: string): HTMLElement => el("td", { className, text: "" });
const none = wait ? "…" : (line?.까닭 ?? "—");
return (items.length ? items : [null]).map((x) =>
el("tr", {
className: "m02-labrow__sub",
attrs: { "data-labor-sub": "" },
children: [
blank("m02-labrow__no"),
blank("m02-labrow__kind"),
el("td", {
className: "m02-labrow__name m02-labrow__subname",
text: x?.name ?? none,
attrs: { title: x?.name ?? none },
}),
blank("m02-labrow__alias"),
el("td", {
className: "m02-labrow__qty",
text: x ? num(x.qty) : "",
attrs: { "data-label": tc("Labor_QtyCol") },
}),
el("td", { className: "m02-labrow__unit", text: x?.unit ?? "" }),
...COSTS.map((c) => {
const base = x?.cost ? Number(x.cost[c] ?? 0) : null;
return moneyCell(
base,
base === null || times === null ? null : base * times,
wait,
tc(HEAD[c]),
);
}),
...(tools ? [blank("")] : []),
],
}),
);
}
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
const wait = !data.calc && !data.fail;
const { move, remove, option, ref } = on ?? {};
// 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
const tools = !!(move || remove || option || ref);
const group = (children: HTMLElement[]): HTMLElement =>
el("div", { className: "ui-btn-group", children });
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => lineId(l) === key);
const trs = data.rows.flatMap((row, i) => {
const id = lineId(row) ?? "";
const logic = data.logics.get(row.로직);
const line = lineOf(id);
// 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정)
const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
const values = valueLine(logic?.입력 ?? [], row.입력값);
const note = values.text;
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
const small = (
label: string,
run: () => void,
variant: "ghost" | "danger" = "ghost",
): HTMLElement =>
createButton({
label,
variant,
onClick: (e) => {
e.stopPropagation(); // 줄 누름(고름)과 따로
run();
},
});
const sel = data.selected === id ? " is-selected" : "";
const tr = el("tr", {
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
attrs: { "data-labor-logic": id },
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",
children: [
el("span", { className: "m02-labrow__key", text: row.참조?.키 ?? row.로직 }),
el("strong", {
text: (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직,
}),
...(values.empty
? [
el("small", {
className: "m02-labrow__empty",
text: tc("Labor_Empty").replace("{n}", String(values.empty)),
}),
]
: []),
...(row.메모 ? [el("small", { className: "m02-labrow__note", text: row.메모 })] : []),
...(why
? [
el("small", {
className: "m02-labrow__empty",
text: `${tc("Labor_Calc_Fail")}: ${why}`,
}),
]
: []),
],
}),
),
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]))),
...(tools
? [
el("td", {
className: "m02-labrow__move",
children: [
el("div", {
className: "m02-labrow__tools",
children: [
...(move
? [
group([
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
]),
]
: []),
group([
...(row.참조
? ref
? [
small(
tc(row.참조.키.startsWith("SP-") ? "Labor_Option" : "Labor_Edit"),
() => ref(i),
),
]
: []
: option
? [small(tc("Labor_Option"), () => option(i))]
: []),
...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []),
]),
],
}),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
const subs = sel ? subRows(line, wait, tools) : [];
if (!note) return [tr, ...subs];
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
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));
return [tr, opt, ...subs];
});
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
part?.[c] ?? null;
const foot = data.rows.length
? [
el("tr", {
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: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
...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: "" })] : []),
],
}),
]
: [];
const heads = [
tc("Col_Order"),
tc("Sub"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
...(tools ? [tc("Col_Move")] : []),
];
return data.rows.length
? table(heads, trs, foot)
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
}
export interface MatTableData {
mats: LaborMat[];
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
}
/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const;
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
* 보기 갈래는 `on` 없이 */
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
const wait = !data.calc && !data.fail;
const remove = on?.remove;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
const trs = data.mats.map((mat, i) => {
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
const base = !!line?.기준칸 && line.기준칸 === slot;
return el("td", {
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
text: show(line?.가격?.[slot]),
attrs: {
"data-label": slot,
"data-price-slot": slot,
...(base ? { title: tc("Labor_BasePrice") } : {}),
},
});
};
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-mat": mat.코드 },
children: [
el("td", { className: "m02-labrow__code", text: mat.코드 }),
titled(
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: mat.이름 ?? "" }),
...(line?.까닭
? [el("small", { className: "m02-labrow__empty", text: line.까닭 })]
: []),
],
}),
),
el("td", {
className: "m02-labrow__spec",
text: mat.규격 ?? "",
attrs: { title: mat.규격 ?? "" },
}),
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", {
className: "m02-labrow__unit",
text: mat.단위 ?? "",
attrs: { "data-label": tc("Labor_Unit") },
}),
...PRICE_SLOTS.map(priceCell),
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
...(remove
? [
el("td", {
className: "m02-labrow__move",
children: [
createButton({
label: tc("Remove"),
variant: "danger",
onClick: (e) => {
e.stopPropagation();
remove(i);
},
}),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
});
const heads = [
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...PRICE_SLOTS,
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
...(remove ? [tc("Col_Move")] : []),
];
return data.mats.length
? table(heads, trs, [])
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
}