/* ============================================================================= * Z01_MasterData_UI_Draft.ts * 고친 것 초안 — [저장] 전까지 이 탭(sessionStorage)에만 둠 · 판(revision)이 다르면 버림 * * 본보기 `B08_Quantity_UI_StructureSummary.ts` 의 Draft · 열쇠 `z01-draft:{scope}` * (scope = `base:{kind}` · `work_item:{key}`) — PLAN 1-0 계약 · 자동저장 금지(CLAUDE.md 5장). * 런타임 import 없음 — 시험(`test_z01_master_draft.py`)이 그대로 돌림. * ========================================================================== */ import type { BaseSave, WorkItemDetail, WorkItemHead, WorkItemRow, WorkItemSave, } from "./Z01_MasterData_Api_Fetch"; const PREFIX = "z01-draft:"; /** 저장소의 초안 — 판이 다르거나 못 읽으면 null(낡은 판 초안은 지움). */ export function readDraft(scope: string, revision: number): T | null { try { const raw = sessionStorage.getItem(PREFIX + scope); const saved = JSON.parse(raw ?? "null") as { revision: number; body: T } | null; if (saved?.revision === revision) return saved.body; if (saved) sessionStorage.removeItem(PREFIX + scope); } catch { // 캐시를 못 읽으면 빈 초안 — 정본은 그대로. } return null; } /** body null = 지움. */ export function writeDraft(scope: string, revision: number, body: unknown): void { try { if (body === null) sessionStorage.removeItem(PREFIX + scope); else sessionStorage.setItem(PREFIX + scope, JSON.stringify({ revision, body })); } catch { // 저장소가 막혀도 화면 초안은 살아 있음. } } export function hasDraft(scope: string): boolean { try { return sessionStorage.getItem(PREFIX + scope) !== null; } catch { return false; } } /** 값이 같은가 — null·undefined 는 같게 봄. */ export function same(a: unknown, b: unknown): boolean { return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); } /* ── 기초단가 — 칸 고침 · 줄 추가 · 줄 삭제 ── */ type Edit = BaseSave["edits"][number]; export interface AddedRow { /** 화면 안 임시 이름 — 서버에 안 감 */ tmp: string; data: Record; } interface BaseBody { edits: Edit[]; added: AddedRow[]; deleted: string[]; } export class BaseDraft { readonly edits = new Map(); added: AddedRow[] = []; readonly deleted = new Set(); onChange: () => void = () => undefined; constructor( readonly scope: string, readonly revision: number, ) { const saved = readDraft(scope, revision); for (const edit of saved?.edits ?? []) this.edits.set(`${edit.row_key}|${edit.column}`, edit); this.added = saved?.added ?? []; for (const key of saved?.deleted ?? []) this.deleted.add(key); } get count(): number { return this.edits.size + this.added.length + this.deleted.size; } edit(rowKey: string, column: string): Edit | undefined { return this.edits.get(`${rowKey}|${column}`); } /** 서버 값과 같아지면 고침에서 뺌. */ setEdit(rowKey: string, column: string, value: unknown, stored: unknown): void { const slot = `${rowKey}|${column}`; if (same(value, stored)) this.edits.delete(slot); else this.edits.set(slot, { row_key: rowKey, column, value }); this.persist(); } addRow(): AddedRow { const row = { tmp: `new-${Date.now()}-${this.added.length}`, data: {} }; this.added.push(row); this.persist(); return row; } /** 빈 값(null)이면 그 칸을 뺌 — 서버가 기본값을 씀. */ setAdded(tmp: string, column: string, value: unknown): void { const row = this.added.find((each) => each.tmp === tmp); if (!row) return; if (value === null || value === "") delete row.data[column]; else row.data[column] = value; this.persist(); } removeAdded(tmp: string): void { this.added = this.added.filter((each) => each.tmp !== tmp); this.persist(); } toggleDelete(rowKey: string): void { if (!this.deleted.delete(rowKey)) this.deleted.add(rowKey); this.persist(); } clear(): void { this.edits.clear(); this.added = []; this.deleted.clear(); this.persist(); } /** 보낼 몸 — 지울 줄의 칸 고침은 안 보냄. */ payload(): BaseSave { return { base_revision: this.revision, edits: [...this.edits.values()].filter((edit) => !this.deleted.has(edit.row_key)), added: this.added.map((row) => ({ data: row.data })), deleted: [...this.deleted], }; } private persist(): void { const body: BaseBody = { edits: [...this.edits.values()], added: this.added, deleted: [...this.deleted], }; writeDraft(this.scope, this.revision, this.count ? body : null); this.onChange(); } } /* ── 공종 — 머리 + 전체 줄(PUT 이 전체 줄을 받음) ── */ /** 화면 줄 — tmp = 저장 전 새 줄 · deleted = [저장] 때 뺄 줄(줄은 그대로 보이며 줄 그음) */ export type DraftRow = WorkItemRow & { tmp?: string; deleted?: boolean }; export interface WorkItemBody { item: WorkItemHead; rows: DraftRow[]; } export const HEAD_FIELDS = ["name", "basis_quantity", "basis_unit", "status"] as const; /** * 줄에서 PUT 으로 보내는 칸 — 서버 요청 모델이 모르는 칸(is_added·changed_columns·sort_order)은 * 422(extra=forbid). 차례는 보낸 차례가 곧 sort_order. */ export const ROW_FIELDS = [ "variant", "resource_kind", "resource_key", "resource_name", "resource_spec", "amount", "amount_unit", "alternative_amount", "group_ratio_pct", "raw_cell", "pum_table_id", ] as const; /** 글자 칸 — 서버가 null 을 안 받음(빈 글자로) */ const TEXT_FIELDS = new Set([ "variant", "resource_name", "resource_spec", "amount_unit", "pum_table_id", ]); /** 서버 값 그대로의 초안(고친 것 없음). */ export function freshBody(detail: WorkItemDetail): WorkItemBody { const item = Object.fromEntries( HEAD_FIELDS.map((field) => [field, detail.item[field]]), ) as unknown as WorkItemHead; return { item, rows: detail.rows.map((row) => ({ ...row })) }; } /** 보낼 줄 — id + 계약 칸만 · 글자 칸 null 은 빈 글자 · 빈 열쇠는 null(못 맞춘 줄). */ function rowPayload(row: DraftRow): WorkItemRow { const out: Record = row.id === undefined ? {} : { id: row.id }; for (const field of ROW_FIELDS) { const value = row[field]; out[field] = TEXT_FIELDS.has(field) ? (value ?? "") : value === "" ? null : (value ?? null); } return out as unknown as WorkItemRow; } /** 보낼 몸 — 지울 줄은 빼고(빠지면 삭제) · 화면 표시 칸(tmp·deleted)과 GET 전용 칸은 뗌. */ export function workItemPayload(revision: number, body: WorkItemBody): WorkItemSave { return { base_revision: revision, item: { ...body.item }, rows: body.rows.filter((row) => !row.deleted).map(rowPayload), }; } export function isDirty(detail: WorkItemDetail, body: WorkItemBody): boolean { const base = detail.item.revision; return !same(workItemPayload(base, body), workItemPayload(base, freshBody(detail))); } /** 서버 값과 다른 칸 — 새 줄은 「added」(줄째 새것). */ export function changedFields(row: DraftRow, saved: WorkItemRow[]): Set | "added" { const original = row.id === undefined ? undefined : saved.find((each) => each.id === row.id); if (!original) return "added"; return new Set(ROW_FIELDS.filter((key) => !same(row[key], original[key]))); } /** 갈래별로 묶음 — 처음 나온 차례 그대로 · 줄 번호(rows 안 자리)만 담음. */ export function groupByVariant(rows: DraftRow[]): { variant: string; indexes: number[] }[] { const groups: { variant: string; indexes: number[] }[] = []; rows.forEach((row, index) => { const group = groups.find((each) => each.variant === row.variant); if (group) group.indexes.push(index); else groups.push({ variant: row.variant, indexes: [index] }); }); return groups; } /** 새 줄 — 그 갈래 마지막 줄 바로 뒤에 넣음(보낸 차례가 sort_order 라 갈래가 흩어지지 않게). */ export function addRow(body: WorkItemBody, variant: string, tmp: string): DraftRow { const row: DraftRow = { tmp, variant, resource_kind: "material", resource_key: null, resource_name: "", resource_spec: "", amount: null, amount_unit: "", alternative_amount: null, group_ratio_pct: null, raw_cell: null, pum_table_id: "", }; const last = body.rows.map((each) => each.variant).lastIndexOf(variant); body.rows.splice(last === -1 ? body.rows.length : last + 1, 0, row); return row; }