/* ============================================================================= * 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[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 = { 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 { 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 { 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(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 { 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 { 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 = ( 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 }; }