feat(M02): 구조물 조합 일위대가 카드 = 상세 그대로 — 단가산출 로직 2행 표 + 자재목록(Labor_Detail 카드 갈래 · 순서 · 로직 추가 · 다이어그램 · 정보 칸 없음 · 빼기 · 자재 추가 그대로) · 카드 [저장] · Ctrl+S

43-4 — 줄 누름 = 넣을 값 모달 · 수량산출 칸 = 수량 모달(상세와 같은 함수) · 저장 차례 = 면적 먼저 → 일위대가 · 표 감싸개 안 가로 스크롤 · 40-2 한 줄 표(compactTable · Labor_View compact · m02-labline css) 지움 · 빼기만 있는 줄도 끝 칸 · 화면 시나리오 ui_checks/43-4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-05 00:33:55 +09:00
co-authored by Claude Opus 5.5
parent 31a753b7fd
commit ca4fcf0364
7 changed files with 195 additions and 262 deletions
@@ -19,7 +19,12 @@ import type {
SpreadsheetHandle,
Workbook,
} from "../A00_Common/spreadsheet/spreadsheet";
import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch";
import {
readTemplate,
saveTemplate,
type StructureDoc,
type StructureNumbers,
} from "./M02_MasterTemplete_Api_Fetch";
import {
cardShell,
helpButton,
@@ -35,7 +40,7 @@ import {
type DrawingCardHandle,
} from "./M02_MasterTemplete_DrawingCard";
import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource";
import type { LaborViewHandle } from "./M02_MasterTemplete_Labor_View";
import type { LaborDetailHandle } from "./M02_MasterTemplete_Labor_Detail";
import "./M02_MasterTemplete_Structure.css";
/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */
@@ -124,7 +129,7 @@ export interface ItemCardsOptions {
/** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 ·
* 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */
onClear?: (kind: CardKind) => Promise<void>;
/** 일위대가 카드(39-5 · 몸 = 일위대가 보기 갈래 `Labor_View`) — 도면 다음 · 지금 층에서 읽음 ·
/** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 ·
* [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */
labor?: () => void;
}
@@ -327,35 +332,57 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
});
paintPills(readStateRaw("m02-card-pills") === "true");
sheets[0].shell.tools.prepend(pills);
// 일위대가 카드 — 보기만(고침 = [일위대가 수정] 상세) · 부품은 처음 열 때 받음
// 일위대가 카드(43-4) — 상세의 단가산출 로직 표(2행) + 자재목록 그대로(`Labor_Detail` 카드 갈래 ·
// 순서 · 로직 추가 · 다이어그램 없음) · 고침은 카드 [저장] · Ctrl+S · 부품은 처음 열 때 받음
const laborHost = el("div", { className: "m02-struct__labor" });
const laborCard = opts.labor
? makeCard("labor", PART_WORD.labor, laborHost, () => Promise.resolve())
? makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())
: null;
let laborView: LaborViewHandle | null = null;
/** 일위대가 카드 — 그 열 문서 · calc 를 받아 그림 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */
let laborEd: LaborDetailHandle | null = null;
let laborVer = "";
/** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */
const paintLabor = async (at: string, mine: number): Promise<void> => {
if (!laborCard) return;
laborEd?.destroy();
laborEd = null;
const layer = opts.source().layer;
let mod: typeof import("./M02_MasterTemplete_Labor_Detail");
let got: Awaited<ReturnType<typeof readTemplate>>;
try {
// 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 금액 넷 · 40-2) — 자세한 것은 [수정] 상세
laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(undefined, {
compact: true,
});
[mod, got] = await Promise.all([
import("./M02_MasterTemplete_Labor_Detail"),
readTemplate(layer, "labor", at, null),
]);
} catch (error) {
if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload);
return;
}
if (mine !== seq) return;
const view = laborView;
laborHost.replaceChildren(view.root);
await view.show(opts.source().layer, at);
if (mine !== seq) return;
const d = view.doc();
laborVer = got.판 ?? "";
const d = (got.문서 ?? {}) as { 일위대가번?: string; 담은로직?: unknown[]; 자재?: unknown[] };
laborCard.no.textContent =
d?.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
if (blank(laborCard, !!d && !d.담은로직.length && !d.자재.length))
laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.()));
d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
if (blank(laborCard, !d.담은로직?.length && !d.자재?.length))
return void laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.()));
laborEd = mod.mountLaborDetail(laborHost, got.문서, {
layer,
column: at,
readOnly: !opts.editable(),
onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()),
goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음
card: true,
});
};
/** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */
async function saveLabor(): Promise<void> {
const at = column;
if (!laborEd || !at) return;
const layer = opts.source().layer;
const areaSaved = await laborEd.saveArea();
if (areaSaved) laborVer = (await readTemplate(layer, "labor", at, null)).판 ?? "";
laborVer = (await saveTemplate(layer, "labor", at, null, laborVer, laborEd.getDoc())).판;
if (areaSaved) window.setTimeout(reload, 0); // 면적산출 카드도 새 연결로
}
const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets];
const defaults: Record<CardKind, string> = {
structure: TEXT.edit,
@@ -375,8 +402,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const drawEdit = drawingEditable(opts.editable(), opts.source());
const sheetEdit = !!opts.sheetEdit?.can();
for (const c of cards) {
const can = c.kind === "structure" ? drawEdit : c.kind !== "labor" && sheetEdit;
const can =
c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit;
c.saver.button.hidden = c.help.hidden = !can;
if (c.kind === "labor") c.help.hidden = true; // 일위대가 카드엔 단축키 상자 없음
c.edit.hidden = !opts.editable();
c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김
}
@@ -533,7 +562,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
}
const source = opts.source();
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) — 일위대가 카드는 뺌:
// 몸(Labor_View)은 따로 그려지고 제 「불러오는 중」 이 있음 · 넣으면 덮였다 지워져 빈 카드(40 D1)
// 몸(일위대가 상세 카드 갈래)은 따로 읽어 그림(금액 「…」 이 기다림 표시) · 넣으면 덮였다 지워져 빈 카드(40 D1)
const stops = cards.filter((c) => c !== laborCard).map((c) => c.shell.loading());
const [drawing, ...books] = await Promise.allSettled([
source.read("structure", col.id),
@@ -582,7 +611,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
destroy: () => {
seq += 1;
drop();
laborView?.destroy();
laborEd?.destroy();
root.remove();
},
};
@@ -229,105 +229,6 @@
}
}
/* 한 줄 표(카드용 · 40-1) — 금액 = 수량 반영 값 · 반영이 없으면 기준 값 흐리게 */
.m02-labline {
width: 100%;
font-size: var(--text-caption);
}
.m02-labline th,
.m02-labline td {
padding: var(--spacing-4) var(--spacing-6, 6px);
}
.m02-labline th:nth-child(n + 4),
.m02-labline .m02-labrow__num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.m02-labline .m02-labrow__name {
display: table-cell;
min-width: 0;
overflow-wrap: anywhere;
}
.m02-labline .m02-labrow__qty,
.m02-labline .m02-labrow__unit {
color: var(--color-text-secondary);
overflow-wrap: anywhere;
}
.m02-labline .is-base {
color: var(--color-text-secondary);
font-style: italic;
}
.m02-labline .m02-labrow__sum td {
font-weight: 600;
border-top: 2px solid var(--color-border);
}
/* 아주 좁으면(카드 3열 · 400px 이하) 결과 단위 칸을 접음 — 수량산출 글에 단위가 같이 보임 */
@container m02-labview (max-width: 400px) {
.m02-labline th:nth-child(3),
.m02-labline .m02-labrow__unit {
display: none;
}
.m02-labline th,
.m02-labline td {
padding: var(--spacing-4) 3px;
}
}
/* 더 좁으면(카드 320px 안팎 · 340px 이하) 줄마다 두 줄 — 윗줄 항목(3칸) · 수량산출(1칸) · 아랫줄 금액 넷 ·
머리 줄도 같은 꼴(글은 그대로) · 가로 넘침 없음 */
@container m02-labview (max-width: 340px) {
.m02-labline,
.m02-labline thead,
.m02-labline tbody,
.m02-labline tfoot {
display: block;
width: 100%;
}
.m02-labline tr {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-bottom: 1px solid var(--color-border);
}
.m02-labline th,
.m02-labline td {
display: block;
min-width: 0;
padding: 2px;
border: 0;
}
.m02-labline th:first-child,
.m02-labline .m02-labrow__name {
grid-column: span 3;
}
.m02-labline th:nth-child(2),
.m02-labline .m02-labrow__qty {
text-align: right;
white-space: normal;
}
.m02-labline th:nth-child(3),
.m02-labline .m02-labrow__unit {
display: none;
}
.m02-labline .m02-labrow__num {
font-size: 11px;
}
}
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
.m02-labrow__qty {
white-space: nowrap;
@@ -54,6 +54,9 @@ export interface LaborDetailOptions {
onChanged: (dirty: boolean) => void;
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
goArea: GoArea | null;
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 올리기 · 내리기 · 로직 추가 ·
* 정보(단위 · 본뜬) · 다이어그램 없음 · 빼기 · 자재 추가 · 줄 · 수량 모달은 상세와 같음 */
card?: boolean;
}
export interface LaborDetailHandle {
@@ -103,7 +106,7 @@ export function mountLaborDetail(
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
const drawDg = (): void => {
if (!dgBox.open) return;
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
const data = buildLaborDiagram({
rows: cur.담은로직,
logics: [...logics.values()],
@@ -129,7 +132,9 @@ export function mountLaborDetail(
logicTable(
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow },
edit
? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow }
: { open: openRow },
),
);
matHost.replaceChildren(
@@ -384,13 +389,19 @@ export function mountLaborDetail(
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })],
}),
section(tc("Logics_Title"), addLogic, logicHost),
...(opt.card
? []
: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
],
}),
]),
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), addMat, matHost),
dgBox,
...(opt.card ? [] : [dgBox]),
],
}),
);
@@ -170,7 +170,8 @@ export interface LogicTableData {
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
const wait = !data.calc && !data.fail;
const { move, remove } = on ?? {};
const tools = !!(move && remove);
// 빼기만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
const tools = !!(move || remove);
const lineOf = (key: string): LaborCalcLine | undefined =>
data.calc?.줄.find((l) => l.로직 === key);
const trs = data.rows.map((row, i) => {
@@ -222,14 +223,15 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
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]))),
...(move && remove
...(tools
? [
el("td", {
className: "m02-labrow__move",
children: [
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
small(tc("Remove"), () => remove(i)),
...(move
? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))]
: []),
...(remove ? [small(tc("Remove"), () => remove(i))] : []),
],
}),
]
@@ -361,115 +363,3 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
? table(heads, trs, [])
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
}
export interface CompactData {
rows: LaborRow[];
mats: LaborMat[];
logics: Map<string, ItemLogic>;
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
}
/** 한 줄 표(카드용 · 40-1) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 ·
* 반영이 없으면 기준 값 흐리게(`is-base`) · 자재 줄은 같은 표 아래(재료비 · 합계만) · 맨 아래 합계 줄 */
export function compactTable(data: CompactData): HTMLElement {
const wait = !data.calc && !data.fail;
const cell = (v: number | null | undefined, base: boolean, label: string): HTMLElement =>
el("td", {
className: `m02-labrow__num${base ? " is-base" : ""}`,
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) },
});
// 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계)
const heads = [
tc("Labor_Item"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...COSTS.map((c) => tc(HEAD[c])),
];
const lines = data.rows.map((row) => {
const line = data.calc?.줄.find((l) => l.로직 === row.로직);
const money = line?.반영 ?? line?.기준;
const base = !line?.반영 && !!line?.기준;
const logic = data.logics.get(row.로직);
return el("tr", {
className: "m02-labrow__row",
attrs: { "data-labor-logic": row.로직 },
children: [
el("td", {
className: "m02-labrow__name",
text: logic?.이름 ?? row.로직,
attrs: { title: `${row.로직}${line?.까닭 ? ` · ${line.까닭}` : ""}` },
}),
el("td", {
className: "m02-labrow__qty",
text: qtyShort(line, line?.분모 ?? "", data.cells),
attrs: { "data-label": tc("Labor_QtyCol") },
}),
el("td", { className: "m02-labrow__unit", text: logic?.결과단위 ?? "" }),
...COSTS.map((c) => cell(money?.[c], base, tc(HEAD[c]))),
],
});
});
const mats = data.mats.map((mat) => {
const line = data.calc?.자재.find((m) => m.코드 === mat.코드);
const has = line?.반영 !== null && line?.반영 !== undefined;
const v = has ? line?.반영 : line?.기준단가;
return el("tr", {
className: "m02-labrow__row is-mat",
attrs: { "data-labor-mat": mat.코드 },
children: [
el("td", {
className: "m02-labrow__name",
text: mat.이름 ?? mat.코드,
attrs: { title: [mat.코드, mat.규격].filter(Boolean).join(" · ") },
}),
el("td", {
className: "m02-labrow__qty",
text: qtyShort(line, mat.단위 ?? "", data.cells),
attrs: { "data-label": tc("Labor_QtyCol") },
}),
el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "" }),
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.노무비) } }),
cell(v, !has && v !== null && v !== undefined, tc(HEAD.재료비)),
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.경비) } }),
cell(v, !has && v !== null && v !== undefined, tc(HEAD.계)),
],
});
});
const applied = data.calc?.반영합;
const anyApplied = !!applied && (applied.계 ?? 0) !== 0;
const total = anyApplied ? applied : data.calc?.기준합;
const foot =
data.rows.length + data.mats.length
? [
el("tr", {
className: "m02-labrow__sum",
children: [
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
el("td", { className: "m02-labrow__qty", text: "" }),
el("td", { className: "m02-labrow__unit", text: "" }),
...COSTS.map((c) => cell(total?.[c], !anyApplied && !!total, tc(HEAD[c]))),
],
}),
]
: [];
return data.rows.length + data.mats.length
? el("div", {
className: "ui-general-block__table-wrap m02-labcompact",
children: [
el("table", {
className: "ui-general-block__table m02-labline",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: [...lines, ...mats] }),
...(foot.length ? [el("tfoot", { children: foot })] : []),
],
}),
],
})
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
}
@@ -1,12 +1,11 @@
/* =============================================================================
* M02_MasterTemplete_Labor_View.ts
* 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영).
* 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`).
* 면적산출 상세 오른쪽 패널(sub3)이 씀(구조물 조합 카드는 43-4 부터 `Labor_Detail` 카드 갈래). 표 모양은 상세와 한 벌(`Labor_Rows`).
* · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만)
* · `setQty({알약 키: 칸 값}, {알약 키: 칸 주소}?)` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로
* 바로 곱함 · 칸 주소를 주면 저장 전 연결(setLink · 알약 끌기)도 「수량 B6 = 20」 으로 보임(안 주면 저장본 연결)
* (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」)
* · `compact` = 한 줄 표(구조물 조합 일위대가 카드 · 40-1)
* · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음
* ========================================================================== */
@@ -21,7 +20,7 @@ import {
type LaborDoc,
} from "./M02_MasterTemplete_Labor_Api";
import { settle } from "./M02_MasterTemplete_Labor_Calc";
import { compactTable, logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
@@ -47,15 +46,7 @@ export interface LaborViewHandle {
destroy: () => void;
}
/** `compact` = 한 줄 표(카드용 · 항목 · 수량산출 · 단위 · 금액 넷 · 자재 줄 같은 표 아래 · 줄 누름 없음) */
export interface LaborViewOptions {
compact?: boolean;
}
export function createLaborView(
onRow?: (row: LaborViewRow) => void,
opts: LaborViewOptions = {},
): LaborViewHandle {
export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle {
const root = el("div", { className: "m02-labview" });
const logics = new Map<string, ItemLogic>();
let doc: LaborDoc | null = null;
@@ -93,10 +84,6 @@ export function createLaborView(
unit: mat.단위 ?? "",
})?.();
};
if (opts.compact)
return void root.replaceChildren(
compactTable({ rows: d.담은로직, mats: d.자재, logics, calc, fail, cells }),
);
root.replaceChildren(
el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }),
logicTable(