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