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

264 lines
8.6 KiB
TypeScript

/* =============================================================================
* 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<T>(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<string, unknown>;
}
interface BaseBody {
edits: Edit[];
added: AddedRow[];
deleted: string[];
}
export class BaseDraft {
readonly edits = new Map<string, Edit>();
added: AddedRow[] = [];
readonly deleted = new Set<string>();
onChange: () => void = () => undefined;
constructor(
readonly scope: string,
readonly revision: number,
) {
const saved = readDraft<BaseBody>(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<string, unknown> = 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<string> | "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;
}