Files
Aislo/Z01_MasterData/Z01_MasterData_UI_WorkItem.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

410 lines
15 KiB
TypeScript

/* =============================================================================
* Z01_MasterData_UI_WorkItem.ts
* 공종 편집 — 왼쪽 목록(상태·검색) / 머리 / 구성 줄 표 / 품셈 원문 표+[주] (PLAN 1-4)
*
* 자동 추출이 못 읽은 공종(「입력 필요」·「일부 못 맞춤」)을 관리자가 화면에서 메움.
* ⚠ 자동저장 금지 — 고친 것은 초안(sessionStorage `z01-draft:work_item:{key}`)에 쌓고
* [저장] 한 번에 머리 + 전체 줄을 보냄 · 판(revision)이 다르면 409 → 초안을 지키고 알림.
* [초기값으로]·[검수 완료]도 서버에 바로 씀 — 화면을 잠그는 오버레이는 이 셋에만(CLAUDE.md 5장).
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
ApiError,
fetchWorkItem,
fetchWorkItems,
resetWorkItem,
saveWorkItem,
type WorkItemAxis,
type WorkItemDetail,
type WorkItemHead,
type WorkItemList,
type WorkItemListRow,
type WorkItemStatus,
} from "./Z01_MasterData_Api_Fetch";
import { latestOnly, pageCount } from "./Z01_MasterData_UI_Cells";
import {
freshBody,
hasDraft,
isDirty,
readDraft,
same,
workItemPayload,
writeDraft,
type WorkItemBody,
} from "./Z01_MasterData_UI_Draft";
import { renderSource, renderWorkRows } from "./Z01_MasterData_UI_WorkItem_Rows";
type LocaleKey = Parameters<typeof L>[0];
const PAGE_SIZE = 50;
const SEARCH_DELAY_MS = 300;
/** 목록 차례 — 메울 것부터 */
const STATUSES: WorkItemStatus[] = ["needs_input", "partial", "auto_ok", "not_item", "reviewed"];
const STATUS_LABEL: Record<WorkItemStatus, LocaleKey> = {
needs_input: "Z01_WorkItem_Status_NeedsInput",
partial: "Z01_WorkItem_Status_Partial",
auto_ok: "Z01_WorkItem_Status_AutoOk",
not_item: "Z01_WorkItem_Status_NotItem",
reviewed: "Z01_WorkItem_Status_Reviewed",
};
const scopeOf = (key: string): string => `work_item:${key}`;
export interface WorkItemView {
root: HTMLElement;
show: (axis: WorkItemAxis) => void;
}
export function buildWorkItemView(): WorkItemView {
const list = {
axis: "forest" as WorkItemAxis,
status: "" as WorkItemStatus | "",
q: "",
page: 1,
};
let listData: WorkItemList | null = null;
let detail: WorkItemDetail | null = null;
let body: WorkItemBody | null = null;
/** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함 */
let stale = false;
let searchTimer = 0;
const fetchList = latestOnly(fetchWorkItems);
const fetchOne = latestOnly(fetchWorkItem);
// ── 왼쪽 목록 ──
const statusBox = el("div");
const search = createInputField({
type: "search",
placeholder: L("Z01_MasterData_Search"),
onInput: (value) => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
list.q = value.trim();
list.page = 1;
void loadList();
}, SEARCH_DELAY_MS);
},
});
const listBox = el("div", { className: "z01-wi__list" });
const pageInfo = el("span");
const prev = createButton({
label: L("Z01_MasterData_Prev"),
variant: "ghost",
onClick: () => {
list.page -= 1;
void loadList();
},
});
const next = createButton({
label: L("Z01_MasterData_Next"),
variant: "ghost",
onClick: () => {
list.page += 1;
void loadList();
},
});
const side = el("aside", {
className: "z01-wi__side",
children: [
statusBox,
search.root,
listBox,
el("div", { className: "z01-master__pager", children: [prev, pageInfo, next] }),
],
});
// ── 오른쪽 편집 ──
const title = el("h2", { className: "z01-master__title", text: L("Z01_WorkItem_Pick") });
const titleKey = el("span", { className: "z01-master__key" });
const action = (key: LocaleKey, onClick: () => void, variant: "filled" | "ghost" = "ghost") =>
createButton({ label: L(key), variant, onClick });
const saveButton = action("Z01_MasterData_Save", () => void save(), "filled");
const discardButton = action("Z01_MasterData_Discard", discard);
const resetButton = action("Z01_MasterData_Reset", () => void reset());
const reviewButton = action("Z01_WorkItem_Review", () => void save("reviewed"));
const draftStatus = el("span", { className: "z01-master__draft-status" });
const actions = el("div", {
className: "z01-master__toolbar",
children: [saveButton, discardButton, resetButton, reviewButton, draftStatus],
});
const head = el("div", { className: "z01-wi__head" });
const rowsBox = el("div");
const sourceBox = el("div");
const editor = el("section", {
className: "z01-wi__editor",
children: [el("div", { children: [title, titleKey] }), actions, head, rowsBox, sourceBox],
});
actions.hidden = true;
const root = el("section", { className: "z01-master__panel z01-wi", children: [side, editor] });
// 저장 안 한 고침이 조용히 사라지지 않게 — 나갈 때 물음.
window.addEventListener("beforeunload", (event) => {
if (!detail || !body || !isDirty(detail, body) || !root.isConnected) return;
event.preventDefault();
event.returnValue = L("Z01_MasterData_Unsaved");
});
async function loadList(): Promise<void> {
listBox.classList.add("is-loading");
let data: WorkItemList | undefined;
try {
data = await fetchList({ ...list, size: PAGE_SIZE });
} catch (error) {
listBox.classList.remove("is-loading");
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
if (!data) return;
listBox.classList.remove("is-loading");
listData = data;
drawList();
}
function drawList(): void {
if (!listData) return;
const counts = listData.counts ?? {};
const all = STATUSES.reduce((sum, status) => sum + (counts[status] ?? 0), 0);
statusBox.replaceChildren(
createSelectField({
label: L("Z01_WorkItem_Status"),
options: [
{ value: "", text: `${L("Z01_WorkItem_Status_All")} (${all.toLocaleString("ko-KR")})` },
...STATUSES.map((status) => ({
value: status,
text: `${L(STATUS_LABEL[status])} (${(counts[status] ?? 0).toLocaleString("ko-KR")})`,
})),
],
value: list.status,
onChange: (value) => {
list.status = value as WorkItemStatus | "";
list.page = 1;
void loadList();
},
}).root,
);
listBox.replaceChildren(
...(listData.items.length
? listData.items.map(itemButton)
: [el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") })]),
);
const pages = pageCount(listData.total, PAGE_SIZE);
pageInfo.textContent = `${list.page} / ${pages} · ${listData.total.toLocaleString("ko-KR")}`;
prev.disabled = list.page <= 1;
next.disabled = list.page >= pages;
}
function itemButton(row: WorkItemListRow): HTMLElement {
// 저장 안 한 초안이 남은 공종은 ✎ — 다른 공종으로 넘어가도 초안은 탭에 남음.
const pending = hasDraft(scopeOf(row.work_item_key)) ? "✎ " : "";
const button = el("button", {
className: "z01-wi__item",
attrs: { type: "button", title: `${row.work_item_key} · ${row.path_name}` },
children: [
el("span", { className: "z01-wi__item-name", text: `${row.number} ${row.path_name}` }),
el("span", {
className: `z01-wi__status is-${row.status.replace("_", "-")}`,
text: L(STATUS_LABEL[row.status]),
}),
el("span", { className: "z01-master__key", text: `${pending}${row.row_count}` }),
],
});
button.classList.toggle("is-active", detail?.item.work_item_key === row.work_item_key);
button.addEventListener("click", () => void open(row.work_item_key));
return button;
}
async function open(key: string): Promise<void> {
let data: WorkItemDetail | undefined;
try {
data = await fetchOne(key);
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
if (data) take(data);
}
/** 받은 상세를 폄 — 같은 판 초안이 탭에 있으면 이어서. 저장·초기값 응답도 같은 모양이라 여기로. */
function take(data: WorkItemDetail): void {
detail = data;
stale = false;
const key = data.item.work_item_key;
body = readDraft<WorkItemBody>(scopeOf(key), data.item.revision) ?? freshBody(data);
drawEditor();
drawList();
}
function drawEditor(): void {
if (!detail || !body) return;
const item = detail.item;
title.textContent = `${item.number} ${item.path_name}`;
titleKey.textContent = item.work_item_key;
actions.hidden = false;
head.replaceChildren(...headFields(detail, body));
rowsBox.replaceChildren(
renderWorkRows(body, detail, (rebuild) => {
touched();
if (rebuild) drawEditor();
}),
);
sourceBox.replaceChildren(renderSource(detail));
drawActions();
}
/** 고침마다 — 초안을 탭에 두고(같아지면 지움) 단추·상태 글자를 맞춤. */
function touched(): void {
if (!detail || !body) return;
const dirty = isDirty(detail, body);
writeDraft(scopeOf(detail.item.work_item_key), detail.item.revision, dirty ? body : null);
drawActions();
drawList(); // 목록의 ✎ 표시를 맞춤
}
function drawActions(): void {
const dirty = Boolean(detail && body && isDirty(detail, body));
saveButton.disabled = !dirty || stale;
discardButton.disabled = !dirty && !stale;
// 초기값으로는 바로 저장 — 저장 안 한 초안이 있으면 먼저 저장·버리게 함.
resetButton.disabled = dirty;
resetButton.title = dirty ? L("Z01_MasterData_ResetBlocked") : "";
reviewButton.disabled = stale || (!dirty && detail?.item.status === "reviewed");
draftStatus.classList.toggle("is-warning", stale);
draftStatus.textContent = stale
? L("Z01_MasterData_Draft_Stale")
: L(dirty ? "Z01_WorkItem_Dirty" : "Z01_MasterData_Draft_None");
}
/** [저장] — status 를 주면 그 상태로 찍어 저장([검수 완료]). */
async function save(status?: WorkItemStatus): Promise<void> {
if (!detail || !body) return;
const key = detail.item.work_item_key;
const payload = workItemPayload(detail.item.revision, body);
if (status) payload.item.status = status;
showLoadingOverlay();
try {
const saved = await saveWorkItem(key, payload);
writeDraft(scopeOf(key), detail.item.revision, null);
showToast(L("Z01_MasterData_Saved"), "success");
take(saved);
void loadList();
} catch (error) {
if (error instanceof ApiError && error.status === 409) stale = true;
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
drawActions();
} finally {
hideLoadingOverlay();
}
}
function discard(): void {
if (!detail) return;
writeDraft(scopeOf(detail.item.work_item_key), detail.item.revision, null);
void open(detail.item.work_item_key);
}
async function reset(): Promise<void> {
if (!detail || (body && isDirty(detail, body))) return;
const key = detail.item.work_item_key;
const ok = await showConfirmDialog(L("Z01_WorkItem_ResetConfirm"), L("Z01_MasterData_Reset"));
if (!ok) return;
showLoadingOverlay();
try {
const restored = await resetWorkItem(key);
showToast(L("Z01_MasterData_ResetDone"), "success");
take(restored);
void loadList();
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
/** 머리 — 열쇠·번호·경로·상위·판은 읽기만 · 이름·기준 수량·기준 단위·상태는 고침. */
function headFields(shown: WorkItemDetail, draft: WorkItemBody): HTMLElement[] {
const item = shown.item;
const field = (label: LocaleKey, control: HTMLElement | string) =>
el("label", {
className: "z01-wi__field",
children: [el("span", { className: "z01-wi__label", text: L(label) }), control],
});
const readOnly = (label: LocaleKey, value: unknown) =>
field(label, value == null || value === "" ? "—" : String(value));
const editable = <K extends keyof WorkItemHead>(
label: LocaleKey,
key: K,
control: HTMLInputElement | HTMLSelectElement,
read: (raw: string) => WorkItemHead[K],
) => {
const wrap = field(label, control);
// 저장 전 고침(초안) · 저장했고 초기값과 다름(서버 changed_columns)
const mark = () => {
const pending = !same(draft.item[key], item[key]);
wrap.classList.toggle("is-draft", pending);
wrap.classList.toggle(
"is-changed",
!pending && Boolean(item.changed_columns?.includes(key)),
);
};
control.value = draft.item[key] == null ? "" : String(draft.item[key]);
control.addEventListener("change", () => {
draft.item[key] = read(control.value.trim());
mark();
touched();
});
mark();
return wrap;
};
const input = (type: "text" | "number") =>
el("input", {
className: "ui-input",
attrs: type === "number" ? { type, min: "0", step: "any" } : { type },
});
const status = el("select", { className: "ui-select" });
for (const each of STATUSES) status.append(new Option(L(STATUS_LABEL[each]), each));
return [
readOnly("Z01_WorkItem_Key", item.work_item_key),
readOnly("Z01_WorkItem_Number", item.number),
readOnly(
"Z01_WorkItem_Parent",
[item.parent_key, item.parent_mode].filter(Boolean).join(" · "),
),
readOnly("Z01_WorkItem_Edition", item.edition),
editable("Z01_WorkItem_Name", "name", input("text"), (raw) => raw),
editable("Z01_WorkItem_BasisQuantity", "basis_quantity", input("number"), (raw) =>
raw === "" ? null : Number(raw),
),
editable("Z01_WorkItem_BasisUnit", "basis_unit", input("text"), (raw) => raw || null),
editable("Z01_WorkItem_Status", "status", status, (raw) => raw as WorkItemStatus),
];
}
function show(axis: WorkItemAxis): void {
Object.assign(list, { axis, status: "", q: "", page: 1 });
search.input.value = "";
detail = null;
body = null;
stale = false;
title.textContent = L("Z01_WorkItem_Pick");
titleKey.textContent = "";
actions.hidden = true;
head.replaceChildren();
rowsBox.replaceChildren();
sourceBox.replaceChildren();
void loadList();
}
return { root, show };
}