- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(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
375 lines
13 KiB
TypeScript
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);
|
|
}
|