Files
Aislo/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts
T
eomsangdonandClaude Opus 5 feab848e69 feat(Z01): 기초단가 표 [저장] 방식 교체 · 공종 편집 화면 신설 — PLAN 1-4
- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(sessionStorage z01-draft:base:{kind})에 쌓고 [저장] 한 번에 보냄 · [고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] · 409 면 초안 지키고 알림 · 서버가 revision 을 줄 때만 고치기 단추 섬
- 옛 덮개 길(칸 단위 PUT · 고친 것 모아 보기 · 덮개 판정 표시)과 그 사전 글자 뺌 — 계약 1-0 에서 서버 쪽 삭제
- 공종 편집(Z01_MasterData_UI_WorkItem.ts · _WorkItem_Rows.ts): 왼쪽 목록(상태별 개수·검색) / 머리 / 구성 줄 표(갈래별 · 자원 찾기 = 기초단가 검색) / 품셈 원문 표+[주] 읽기 전용 / [저장]·[초기값으로]·[검수 완료] · 초안 열쇠 z01-draft:work_item:{key}
- 공종 API(sub_laptop_3 e1c71c3e) 모양에 맞춤 — 줄은 id + 계약 칸 열하나만 보냄(extra=forbid) · 저장·초기값 응답(새 GET 모양)으로 바로 다시 그림 · 저장된 고침(changed_columns·is_added) 표시
- 초안 순수 함수 Z01_MasterData_UI_Draft.ts + 시험 test_z01_master_draft.py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
2026-09-18 23:55:45 +09:00

375 lines
13 KiB
TypeScript

/* =============================================================================
* Z01_MasterData_UI_WorkItem_Rows.ts
* 공종 편집 아래쪽 — 구성 줄 표(갈래별) · 자원 찾기(기초단가 검색) · 품셈 원문 표 + [주](읽기 전용)
*
* 칸을 고치면 초안(`WorkItemBody`)만 바꿈 — 서버엔 [저장] 때 전체 줄을 한 번에 보냄(PLAN 1-0).
* 못 맞춘 줄(resource_key 없음)은 또렷이 — 자원 찾기로 기초단가 줄을 골라 이음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
fetchBasePrices,
type ResourceKind,
type WorkItemDetail,
type WorkItemRow,
} from "./Z01_MasterData_Api_Fetch";
import { cellText, latestOnly } from "./Z01_MasterData_UI_Cells";
import {
addRow,
changedFields,
groupByVariant,
type DraftRow,
type WorkItemBody,
} from "./Z01_MasterData_UI_Draft";
type LocaleKey = Parameters<typeof L>[0];
const KIND_LABEL: Record<ResourceKind, LocaleKey> = {
labor: "Z01_WorkItem_Kind_Labor",
machine: "Z01_WorkItem_Kind_Machine",
material: "Z01_WorkItem_Kind_Material",
etc: "Z01_WorkItem_Kind_Etc",
};
/**
* 자원 찾기 — resource_kind 가 기초단가 kind 이름과 같음(노임·기계·자재 · 기타는 기초단가에 없음).
* 이름·규격 칸은 서버 `row_label` 과 같은 칸을 씀.
*/
type Findable = Exclude<ResourceKind, "etc">;
const FINDABLE: Record<Findable, { name: string; spec?: string }> = {
labor: { name: "occupation_name" },
machine: { name: "machine_name", spec: "specification" },
material: { name: "classification_name", spec: "specification" },
};
const FIND_SIZE = 20;
const SEARCH_DELAY_MS = 300;
const COLUMNS: LocaleKey[] = [
"Z01_WorkItem_Col_Kind",
"Z01_WorkItem_Col_Resource",
"Z01_WorkItem_Col_Spec",
"Z01_WorkItem_Col_Key",
"Z01_WorkItem_Col_Amount",
"Z01_WorkItem_Col_Unit",
"Z01_WorkItem_Col_Alt",
"Z01_WorkItem_Col_Ratio",
"Z01_WorkItem_Col_Raw",
"Z01_WorkItem_Col_Table",
"Z01_WorkItem_Col_Delete",
];
let tmpSeq = 0;
const tmpId = (): string => `new-${Date.now()}-${++tmpSeq}`;
/** rebuild = 줄 수·갈래·자원이 바뀜(다시 그림) · false = 칸 값만 바뀜(초점 지킴) */
export type RowsChanged = (rebuild: boolean) => void;
export function renderWorkRows(
body: WorkItemBody,
detail: WorkItemDetail,
changed: RowsChanged,
): HTMLElement {
const finder = buildFinder(() => changed(true));
const box = el("div", {
className: "z01-wi__rows",
children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Rows") })],
});
const groups = groupByVariant(body.rows);
// 줄이 하나도 없는 공종(입력 필요) — 갈래 없는 빈 묶음 하나로 [줄 추가] 를 세움.
if (!groups.length) groups.push({ variant: "", indexes: [] });
for (const group of groups) box.append(variantBlock(group.variant, group.indexes));
const variantInput = el("input", {
className: "ui-input",
attrs: { placeholder: L("Z01_WorkItem_VariantName") },
});
const addVariant = createButton({
label: L("Z01_WorkItem_AddVariant"),
variant: "ghost",
onClick: () => {
const name = variantInput.value.trim();
if (!name) return;
addRow(body, name, tmpId());
changed(true);
},
});
box.append(
el("div", { className: "z01-master__toolbar", children: [variantInput, addVariant] }),
finder.root,
);
return box;
function variantBlock(variant: string, indexes: number[]): HTMLElement {
const head = el("tr", { children: COLUMNS.map((key) => el("th", { text: L(key) })) });
const table = el("table", {
className: "z01-master__grid z01-wi__grid",
children: [
el("thead", { children: [head] }),
el("tbody", { children: indexes.map((index) => rowLine(body.rows[index])) }),
],
});
const add = createButton({
label: L("Z01_MasterData_AddRow"),
variant: "ghost",
onClick: () => {
addRow(body, variant, tmpId());
changed(true);
},
});
return el("div", {
className: "z01-wi__group",
children: [
el("h4", { className: "z01-wi__variant", text: variant || L("Z01_WorkItem_NoVariant") }),
el("div", { className: "z01-master__grid-wrap", children: [table] }),
add,
],
});
}
function rowLine(row: DraftRow): HTMLElement {
const tr = el("tr");
const cells: [keyof WorkItemRow, HTMLElement][] = [];
// 고친 칸 표시 — 저장 전(초안 · 새 줄은 줄째) · 저장했고 초기값과 다름(서버 changed_columns)
const saved = row.changed_columns ?? [];
tr.classList.toggle("is-added", Boolean(row.is_added));
const mark = (): void => {
const diff = changedFields(row, detail.rows);
tr.classList.toggle("is-new", diff === "added");
tr.classList.toggle("is-deleted", Boolean(row.deleted));
for (const [field, td] of cells) {
const draft = diff !== "added" && diff.has(field);
td.classList.toggle("is-draft", draft);
td.classList.toggle("is-changed", !draft && saved.includes(field));
}
};
const cell = (field: keyof WorkItemRow, ...children: (HTMLElement | string)[]) => {
const td = el("td", { children });
cells.push([field, td]);
tr.append(td);
return td;
};
const set = (field: keyof WorkItemRow, value: unknown): void => {
(row as unknown as Record<string, unknown>)[field] = value;
mark();
changed(false);
};
const text = (field: "resource_name" | "resource_spec" | "amount_unit") => {
const input = el("input", { className: "ui-input" });
input.value = row[field] ?? "";
input.addEventListener("change", () => set(field, input.value.trim()));
return input;
};
const number = (
field: "amount" | "alternative_amount" | "group_ratio_pct",
): HTMLInputElement => {
const input = el("input", {
className: "ui-input",
attrs: { type: "number", min: "0", step: "any" },
});
input.value = row[field] == null ? "" : String(row[field]);
input.addEventListener("change", () => {
const raw = input.value.trim();
set(field, raw === "" ? null : Number(raw));
});
return input;
};
const kind = el("select", { className: "ui-select" });
for (const each of Object.keys(KIND_LABEL) as ResourceKind[]) {
kind.append(new Option(L(KIND_LABEL[each]), each));
}
kind.value = row.resource_kind;
// 종류가 바뀌면 이은 열쇠는 다른 표의 것 — 풀어 두고 다시 찾게 함.
kind.addEventListener("change", () => {
row.resource_kind = kind.value as ResourceKind;
row.resource_key = null;
changed(true);
});
cell("resource_kind", kind);
cell("resource_name", text("resource_name"));
cell("resource_spec", text("resource_spec"));
const key = row.resource_key
? el("span", { text: row.resource_key })
: el("span", { className: "z01-wi__unmatched", text: L("Z01_WorkItem_Unmatched") });
const find = createButton({
label: L("Z01_WorkItem_Find"),
variant: "ghost",
disabled: row.resource_kind === "etc",
onClick: () => finder.open(row),
});
cell("resource_key", key, find);
cell("amount", number("amount"));
cell("amount_unit", text("amount_unit"));
cell("alternative_amount", number("alternative_amount"));
cell("group_ratio_pct", number("group_ratio_pct"));
cell(
"raw_cell",
el("span", { text: row.raw_cell ?? "", attrs: { title: row.raw_cell ?? "" } }),
);
cell("pum_table_id", row.pum_table_id ?? "");
const drop = createButton({
label: L(row.deleted ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow"),
variant: "ghost",
onClick: () => {
// 새 줄은 바로 뺌 · 서버 줄은 줄 긋고 [저장] 때 뺌(되살릴 수 있게)
if (row.id === undefined) body.rows.splice(body.rows.indexOf(row), 1);
else row.deleted = !row.deleted;
changed(true);
},
});
tr.append(el("td", { children: [drop] }));
mark();
return tr;
}
}
/** 자원 찾기 상자 — 기초단가 검색 API 로 찾아 한 줄을 고르면 그 줄에 열쇠·이름·규격을 넣음. */
function buildFinder(picked: () => void): { root: HTMLElement; open: (row: DraftRow) => void } {
let target: DraftRow | null = null;
let timer = 0;
const search = latestOnly(fetchBasePrices);
const title = el("strong");
const kind = el("select", { className: "ui-select" });
for (const each of Object.keys(FINDABLE) as Findable[]) {
kind.append(new Option(L(KIND_LABEL[each]), each));
}
const input = el("input", {
className: "ui-input",
attrs: { type: "search", placeholder: L("Z01_MasterData_Search") },
});
const results = el("div", { className: "z01-wi__found" });
const close = createButton({
label: L("Z01_WorkItem_FindClose"),
variant: "ghost",
onClick: () => {
root.hidden = true;
},
});
const root = el("div", {
className: "z01-wi__finder",
children: [
title,
el("div", { className: "z01-master__toolbar", children: [kind, input, close] }),
results,
],
});
root.hidden = true;
kind.addEventListener("change", () => void run());
input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void run(), SEARCH_DELAY_MS);
});
async function run(): Promise<void> {
const from = kind.value as Findable;
let data;
try {
data = await search(from, { page: 1, size: FIND_SIZE, q: input.value.trim() });
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
if (!data) return;
const fields = FINDABLE[from];
results.replaceChildren(
...(data.rows.length
? data.rows.map((row) => {
const name = cellText(row[fields.name]);
const spec = fields.spec ? cellText(row[fields.spec]) : "";
const button = el("button", {
className: "z01-wi__item",
attrs: { type: "button" },
children: [
el("span", { className: "z01-wi__item-name", text: `${name} ${spec}`.trim() }),
el("span", { className: "z01-master__key", text: cellText(row["@id"]) }),
],
});
button.addEventListener("click", () => {
if (!target) return;
target.resource_kind = from;
target.resource_key = String(row["@id"]);
target.resource_name = name;
target.resource_spec = spec;
root.hidden = true;
picked();
});
return button;
})
: [el("p", { className: "z01-master__note", text: L("Z01_WorkItem_FindNone") })]),
);
}
function open(row: DraftRow): void {
target = row;
kind.value = row.resource_kind === "etc" ? "material" : row.resource_kind;
input.value = row.resource_name;
title.textContent = `${L("Z01_WorkItem_FindTitle")}${row.resource_name || row.raw_cell || ""}`;
results.replaceChildren();
root.hidden = false;
root.scrollIntoView({ block: "nearest" });
void run();
}
return { root, open };
}
/** 품셈 원문 표 + [주] — 읽기 전용(값을 고칠 때 옆에 두고 봄). */
export function renderSource(detail: WorkItemDetail): HTMLElement {
const box = el("div", {
className: "z01-wi__source",
children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Source") })],
});
if (!detail.source.length) {
box.append(el("p", { className: "z01-master__note", text: L("Z01_WorkItem_NoSource") }));
}
for (const table of detail.source) {
const caption = [table.pum_table_id, table.section, table.pum_form].filter(Boolean).join(" · ");
const heads = cells(table.condition_note);
const rows = Array.isArray(table.raw_row) ? (table.raw_row as unknown[]) : [];
const grid = el("table", { className: "z01-master__grid" });
if (heads.length) {
grid.append(
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })],
}),
);
}
grid.append(
el("tbody", {
children: rows.map((row) =>
el("tr", {
children: cells(row).map((c) => el("td", { text: c, attrs: { title: c } })),
}),
),
}),
);
box.append(
el("h4", { className: "z01-wi__variant", text: caption }),
el("div", { className: "z01-master__grid-wrap", children: [grid] }),
);
}
const notes = detail.notes ?? [];
if (notes.length) {
box.append(
el("h4", { className: "z01-wi__variant", text: L("Z01_WorkItem_Notes") }),
el("ul", {
className: "z01-wi__notes",
children: notes.map((note) => el("li", { text: noteText(note) })),
}),
);
}
return box;
}
/** 원문 칸 — 목록이면 칸마다 · 글자 하나면 한 칸. */
function cells(value: unknown): string[] {
if (Array.isArray(value)) return value.map((each) => cellText(each));
return value == null ? [] : [cellText(value)];
}
function noteText(note: unknown): string {
if (note && typeof note === "object" && "text" in note) return cellText(note.text);
return cellText(note);
}