From feab848e697d6c9e1f88ec35af0d7016fae5d990 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 18 Sep 2026 23:55:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(Z01):=20=EA=B8=B0=EC=B4=88=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=20[=EC=A0=80=EC=9E=A5]=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EA=B5=90=EC=B2=B4=20=C2=B7=20=EA=B3=B5=EC=A2=85=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=ED=99=94=EB=A9=B4=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20PLAN=201-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(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) Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69 --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 196 ++++++-- Z01_MasterData/Z01_MasterData_UI_Draft.ts | 263 +++++++++++ Z01_MasterData/Z01_MasterData_UI_Page.ts | 29 +- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 439 ++++++++++++------ Z01_MasterData/Z01_MasterData_UI_Side.ts | 95 ++-- Z01_MasterData/Z01_MasterData_UI_Style.css | 201 ++++++++ Z01_MasterData/Z01_MasterData_UI_WorkItem.ts | 409 ++++++++++++++++ .../Z01_MasterData_UI_WorkItem_Rows.ts | 374 +++++++++++++++ resources/tester/test_z01_master_draft.py | 184 ++++++++ ui_template/ui_template_locale_b3.ts | 131 ++++-- 10 files changed, 2012 insertions(+), 309 deletions(-) create mode 100644 Z01_MasterData/Z01_MasterData_UI_Draft.ts create mode 100644 Z01_MasterData/Z01_MasterData_UI_WorkItem.ts create mode 100644 Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts create mode 100644 resources/tester/test_z01_master_draft.py diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index b4da3fc7..1fffe9fd 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -2,17 +2,12 @@ * Z01_MasterData_Api_Fetch.ts * 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만 * - * 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례. + * 응답 모양은 PLAN 1-0 계약(2026-09-18 브레인) — 정본은 DB · 고치기는 [저장] 한 번에 보냄 + * (칸 나갈 때 즉시 저장하던 옛 길 `PUT …/{row_id}`·`/overrides*` 는 서버에서 지워짐). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { - MasterColumn, - OverrideState, - ServedKind, - SortOrder, - TableMeta, -} from "./Z01_MasterData_UI_Cells"; +import type { MasterColumn, ServedKind, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -38,11 +33,16 @@ export interface MasterGroup { files: MasterFile[]; } -/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */ +/** + * 기초단가 표만 editable·locked·formula·revision 을 실음 — 트리 표는 읽기 전용이라 안 옴. + * 기초단가 줄은 `@id`(= DB row_key) · `changed_columns`(초기값과 다른 칸) · `is_added`(관리자가 넣은 줄)를 실음. + */ export interface MasterRows extends TableMeta { columns: MasterColumn[]; rows: Record[]; total: number; + /** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */ + revision?: number; } export interface RowsQuery { @@ -65,17 +65,34 @@ export function fetchBaseKinds(): Promise { return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds); } +/** 실패 응답 — `status` 409 = 낡은 판(다른 곳에서 먼저 저장함). */ +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); - const data = (await response.json()) as { detail?: string } & T; - if (!response.ok) throw new Error(data.detail ?? "Request failed"); + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) { + // 검사 실패(422)는 detail 이 목록으로 옴 — 글자로 펴서 보임(「[object Object]」 금지). + const detail = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); + throw new ApiError(detail ?? "Request failed", response.status); + } return data; } +const send = (method: string, path: string, body: unknown): Promise => + request(path, { method, body: JSON.stringify(body) }); + const pageParams = (query: RowsQuery): Record => ({ page: String(query.page), size: String(query.size), @@ -93,37 +110,142 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise< return request(`/master-data/base-prices/${kind}?${params}`); } -export interface OverrideItem { - kind: BasePriceKind; - row_id: string; - row_label: string; - column: string; - column_label: string; - original: unknown; - current?: unknown; - value: unknown; - unit?: string; - state: OverrideState; +/** 기초단가 [저장] 한 번 — 전부 아니면 전무 · edits 500 상한 · 낡은 판이면 409. */ +export interface BaseSave { + base_revision: number; + edits: { row_key: string; column: string; value: unknown }[]; + added: { data: Record }[]; + deleted: string[]; } -/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */ -export function fetchOverrides( - query: Pick, -): Promise<{ items: OverrideItem[]; total: number }> { - const params = new URLSearchParams({ page: String(query.page), size: String(query.size) }); - return request(`/master-data/overrides?${params}`); +export function saveBasePrices(kind: BasePriceKind, body: BaseSave): Promise { + return send("PUT", `/master-data/base-prices/${kind}`, body); } -/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */ -export function saveBasePrice( - kind: BasePriceKind, - rowId: string, - values: Record, -): Promise> { - return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, { - method: "PUT", - body: JSON.stringify({ values }), +/** 고른 줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨. */ +export function resetBasePrices(kind: BasePriceKind, rowKeys: string[]): Promise { + return send("POST", `/master-data/base-prices/${kind}/reset`, { row_keys: rowKeys }); +} + +/* ── 공종 마스터(PLAN 1-0) ── */ + +export type WorkItemAxis = "forest" | "const"; +/** 주입 때 한 번 판정 · `reviewed` 는 관리자만 찍음 */ +export type WorkItemStatus = "auto_ok" | "partial" | "needs_input" | "not_item" | "reviewed"; +export type ResourceKind = "labor" | "machine" | "material" | "etc"; + +export interface WorkItemListRow { + work_item_key: string; + number: string; + path_name: string; + status: WorkItemStatus; + row_count: number; +} + +export interface WorkItemList { + items: WorkItemListRow[]; + total: number; + /** 상태별 개수(검색·상태 거르기 전 그 축 전체) */ + counts: Partial>; +} + +export interface WorkItemListQuery { + axis: WorkItemAxis; + status: WorkItemStatus | ""; + q: string; + page: number; + size: number; +} + +export function fetchWorkItems(query: WorkItemListQuery): Promise { + const params = new URLSearchParams({ + axis: query.axis, + q: query.q, + page: String(query.page), + size: String(query.size), }); + if (query.status) params.set("status", query.status); + return request(`/master-data/work-items?${params}`); +} + +/** 머리 중 관리자가 고치는 칸 — PUT 에 이것만 보냄 */ +export interface WorkItemHead { + name: string; + basis_quantity: number | null; + basis_unit: string | null; + status: WorkItemStatus; +} + +export interface WorkItemItem extends WorkItemHead { + work_item_key: string; + axis: WorkItemAxis; + code: string | null; + number: string; + path_name: string; + parent_key: string | null; + parent_mode: string | null; + edition: string | null; + revision: number; + /** 머리 중 초기값과 다른 칸(저장된 고침) */ + changed_columns?: string[]; +} + +/** 구성 줄 — id 없으면 새 줄 · resource_key 없으면 기초단가와 못 맞춘 줄(raw_cell 에 원문과 까닭) */ +export interface WorkItemRow { + id?: number; + variant: string; + resource_kind: ResourceKind; + resource_key: string | null; + resource_name: string; + resource_spec: string; + amount: number | null; + amount_unit: string; + alternative_amount: number | null; + group_ratio_pct: number | null; + raw_cell: string | null; + pum_table_id: string | null; + /** GET 만 — 관리자가 넣은 줄 · 초기값과 다른 칸 */ + is_added?: boolean; + changed_columns?: string[]; +} + +/** 품셈 원문 표 한 벌 — condition_note = 머리 글자 · raw_row = 원문 칸(읽기 전용) */ +export interface WorkItemSource { + pum_table_id: string; + section?: string | null; + pum_form?: string | null; + condition_note?: unknown; + raw_row?: unknown; +} + +export interface WorkItemDetail { + item: WorkItemItem; + rows: WorkItemRow[]; + source: WorkItemSource[]; + notes: unknown[]; +} + +/** 공종 [저장] — rows 는 전체 줄(id 있으면 수정 · 없으면 추가 · 빠지면 삭제) · 낡은 판이면 409 */ +export interface WorkItemSave { + base_revision: number; + item: WorkItemHead; + rows: WorkItemRow[]; +} + +const workItemPath = (key: string) => `/master-data/work-items/${encodeURIComponent(key)}`; + +export function fetchWorkItem(key: string): Promise { + return request(workItemPath(key)); +} + +/** 응답은 새 GET 모양 그대로(다시 안 불러도 됨) · 모양 오류 422 · 낡은 판 409. */ +export function saveWorkItem(key: string, body: WorkItemSave): Promise { + return send("PUT", workItemPath(key), body); +} + +/** 머리·줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨 · 응답은 새 GET 모양. */ +export function resetWorkItem(key: string): Promise { + return send("POST", `${workItemPath(key)}/reset`, {}); } export async function fetchMasterTree(): Promise { diff --git a/Z01_MasterData/Z01_MasterData_UI_Draft.ts b/Z01_MasterData/Z01_MasterData_UI_Draft.ts new file mode 100644 index 00000000..60412835 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_Draft.ts @@ -0,0 +1,263 @@ +/* ============================================================================= + * 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; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index 021e9198..8cfa43bf 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -15,8 +15,9 @@ import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchBaseKinds, fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; import type { ServedKind } from "./Z01_MasterData_UI_Cells"; -import { buildRowsView } from "./Z01_MasterData_UI_Rows"; +import { buildRowsView, type RowsView } from "./Z01_MasterData_UI_Rows"; import { buildSidePanel } from "./Z01_MasterData_UI_Side"; +import { buildWorkItemView } from "./Z01_MasterData_UI_WorkItem"; import "./Z01_MasterData_UI_Style.css"; export async function renderZ01MasterData(root: HTMLElement): Promise { @@ -41,12 +42,32 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { } function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement { - const view = buildRowsView(); + // 오른쪽은 표 보기 · 공종 편집 둘 중 하나 — 왼쪽에서 누른 쪽만 보임. + const rows = buildRowsView(); + const items = buildWorkItemView(); + items.root.hidden = true; + const swap = (showRows: boolean): void => { + rows.root.hidden = !showRows; + items.root.hidden = showRows; + }; + const view: RowsView = { + root: rows.root, + show: (source) => { + swap(true); + rows.show(source); + }, + }; const layout = el("div", { className: "ui-workflow-layout z01-master" }); - const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] }); + const main = el("main", { + className: "ui-workflow-layout__main", + children: [rows.root, items.root], + }); const overlays = createWorkflowOverlays({ title: L("Z01_MasterData_Title"), - optionsContent: buildSidePanel(groups, kinds, view), + optionsContent: buildSidePanel(groups, kinds, view, (axis) => { + swap(false); + items.show(axis); + }), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 7309885c..1735ed17 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -1,10 +1,11 @@ /* ============================================================================= * Z01_MasterData_UI_Rows.ts - * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침 + * 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 고침 * - * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침. - * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬 + * 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `edit` 이 있어 고침. + * 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 산출근거가 섬 * (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것). + * ⚠ 자동저장 금지 — 고친 칸·추가 줄·삭제 줄은 초안(`BaseDraft`)에 쌓고 [저장] 한 번에 보냄(PLAN 1-0). * ========================================================================== */ import { @@ -12,14 +13,22 @@ import { createInputField, createSelectField, el, + hideLoadingOverlay, showConfirmDialog, + showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; +import { + ApiError, + type BaseSave, + type MasterRows, + type RowsQuery, +} from "./Z01_MasterData_Api_Fetch"; import { boldSegments, cellInfo, + cellText, clampPage, columnTitle, filterValue, @@ -32,24 +41,29 @@ import { rowCells, shownColumns, valueWithUnit, - type CellInfo, type MasterColumn, type SortOrder, } from "./Z01_MasterData_UI_Cells"; +import { BaseDraft, type AddedRow } from "./Z01_MasterData_UI_Draft"; const PAGE_SIZE = 50; const SEARCH_DELAY_MS = 300; type Row = Record; +type LocaleKey = Parameters[0]; export interface RowsSource { title: string; key: string; - /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */ + /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */ searchable?: boolean; load: (query: RowsQuery) => Promise; - /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ - save?: (rowId: string, values: Row) => Promise; + /** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */ + edit?: { + scope: string; + save: (body: BaseSave) => Promise; + reset: (rowKeys: string[]) => Promise; + }; } export interface RowsView { @@ -58,36 +72,15 @@ export interface RowsView { } /** 사전 문구의 {value} 자리에 값을 넣음. */ -function fill(key: Parameters[0], value: string): string { +function fill(key: LocaleKey, value: string): string { return L(key).replace("{value}", value); } -/** - * 뜻이 바뀐 줄의 옛 이름·지금 이름 — 서버가 합쳐 한 줄로 줌(자재는 품명·규격·인도조건이 이어 붙어 옴). - * 화면은 쪼개거나 다시 합치지 않음. 안 오면 버그로 드러냄. - */ -function meaningNames(info: CellInfo): string { - if (!info.was || !info.now) return L("Z01_MasterData_State_MeaningMissing"); - return `${fill("Z01_MasterData_State_Was", info.was)} / ${fill("Z01_MasterData_State_Now", info.now)}`; -} - -/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ -function stateText(info: CellInfo, column: MasterColumn): string { - const original = valueWithUnit(info.original, column.unit); - switch (info.state) { - case "meaning_changed": - // 값이 틀린 것보다 나쁨 — 같은 번호에 딴 물건이 앉음. 옛 이름·지금 이름을 나란히. - // ⚠ 이름이 안 오면 물음표로 때우지 않고 버그로 드러냄 — 물음표는 「원래 그런가 보다」로 넘김. - return `${L("Z01_MasterData_State_MeaningChanged")} — ${meaningNames(info)} · ${fill("Z01_MasterData_Edit_OriginalWas", original)}`; - case "source_changed": - return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; - case "orphan": - return L("Z01_MasterData_State_Orphan"); - case "missing": - return L("Z01_MasterData_State_Missing"); - default: - return fill("Z01_MasterData_State_Edited", original); - } +/** 고른 칸 — 서버 줄은 row_key(`@id`) · 저장 전 새 줄은 tmp. */ +interface Picked { + row: string; + added: boolean; + column: string; } export function buildRowsView(): RowsView { @@ -100,7 +93,10 @@ export function buildRowsView(): RowsView { }; let showHidden = false; let last: MasterRows | null = null; - let selected: { index: number; key: string } | null = null; + let picked: Picked | null = null; + let draft: BaseDraft | null = null; + /** 409 를 받음 — 다른 곳에서 먼저 저장해 이 초안은 저장 못 함(조용히 버리지 않고 알림) */ + let stale = false; let searchTimer = 0; const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query)); @@ -153,12 +149,26 @@ export function buildRowsView(): RowsView { className: "z01-master__pager", children: [first, prev, pageInput, pageInfo, next, end], }); + // 기초단가 고치기 — [저장]·[고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] + 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 addButton = action("Z01_MasterData_AddRow", addRow); + const deleteButton = action("Z01_MasterData_DeleteRow", toggleDelete); + const resetButton = action("Z01_MasterData_Reset", () => void reset()); + const draftStatus = el("span", { className: "z01-master__draft-status" }); + const actions = el("div", { + className: "z01-master__toolbar", + children: [saveButton, discardButton, addButton, deleteButton, resetButton, draftStatus], + }); const root = el("section", { className: "z01-master__panel", children: [ el("div", { children: [title, titleKey] }), notice, banner, + actions, toolbar, detail, grid, @@ -170,6 +180,13 @@ export function buildRowsView(): RowsView { detail.hidden = true; notice.hidden = true; banner.hidden = true; + actions.hidden = true; + // 저장 안 한 고침이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장). + window.addEventListener("beforeunload", (event) => { + if (!draft?.count || !root.isConnected) return; + event.preventDefault(); + event.returnValue = L("Z01_MasterData_Unsaved"); + }); function goTo(page: number): void { const pages = pageCount(last?.total ?? 0, PAGE_SIZE); @@ -204,15 +221,65 @@ export function buildRowsView(): RowsView { if (!data) return; grid.classList.remove("is-loading"); last = data; - selected = null; + const edit = source.edit; + if (edit && data.revision !== undefined) { + // 고친 것이 남은 초안은 쪽을 넘겨도 지킴 — 그새 판이 바뀌었으면 stale 로 알림(조용히 안 버림). + if (draft?.scope !== edit.scope || !draft.count) { + draft = new BaseDraft(edit.scope, data.revision); + draft.onChange = drawActions; + } + stale = draft.revision !== data.revision; + } + picked = null; detail.hidden = true; draw(); } + const rowKey = (row: Row, index: number): string => + row["@id"] == null ? `#${index}` : String(row["@id"]); + + const changedColumns = (row: Row): string[] => + Array.isArray(row.changed_columns) ? (row.changed_columns as string[]) : []; + + /** 고른 서버 줄 — 저장 전 새 줄·열쇠 없는 줄이면 없음. */ + function pickedRow(): Row | undefined { + if (!picked || picked.added) return undefined; + const key = picked.row; + return last?.rows.find((row) => row["@id"] != null && String(row["@id"]) === key); + } + + /** 새 줄에 적은 글자를 수로 읽을지 — 같은 열 다른 줄 값의 꼴을 따름(코드 칸이 수로 바뀌지 않게). */ + const sample = (column: string): unknown => + last?.rows.find((row) => row[column] != null)?.[column]; + + function drawActions(): void { + // 서버가 판(revision)을 준 표만 고침 — 안 주는 표(공종 축 등 DB 밖)는 단추를 안 세움. + actions.hidden = !draft; + const count = draft?.count ?? 0; + const row = pickedRow(); + saveButton.disabled = count === 0 || stale; + discardButton.disabled = count === 0 && !stale; + addButton.disabled = !draft || stale; + deleteButton.disabled = !draft || !(picked?.added || row); + const undo = Boolean(row && draft?.deleted.has(String(row["@id"]))); + deleteButton.querySelector(".ui-btn__label")!.textContent = L( + undo ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow", + ); + // 초기값으로는 바로 저장 — 저장 안 한 초안이 있으면 판이 갈려 초안이 버려지니 먼저 저장·버리게 함. + resetButton.disabled = !row || count > 0 || !(changedColumns(row).length || row.is_added); + resetButton.title = count > 0 ? L("Z01_MasterData_ResetBlocked") : ""; + draftStatus.classList.toggle("is-warning", stale); + draftStatus.textContent = stale + ? L("Z01_MasterData_Draft_Stale") + : count && draft + ? `${L("Z01_MasterData_Draft_Edits")} ${draft.edits.size} · ${L("Z01_MasterData_Draft_Added")} ${draft.added.length} · ${L("Z01_MasterData_Draft_Deleted")} ${draft.deleted.size} — ${L("Z01_MasterData_Draft_Pending")}` + : L("Z01_MasterData_Draft_None"); + } + function headCell(meta: MasterRows, column: MasterColumn): HTMLElement { const th = el("th", { children: [el("span", { text: columnTitle(column) })] }); const marks: [string, string][] = []; - if (source?.save && meta.editable?.includes(column.key)) { + if (source?.edit && meta.editable?.includes(column.key)) { marks.push(["✎", L("Z01_MasterData_Edit_Can")]); } const reason = meta.locked?.[column.key]; @@ -286,39 +353,11 @@ export function buildRowsView(): RowsView { const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); - meta.rows.forEach((row, index) => { - const texts = rowCells(row, columns); - const tr = el("tr"); - columns.forEach((column, i) => { - const info = cellInfo(meta, row, column.key); - const td = el("td", { text: texts[i], attrs: { title: texts[i] } }); - if (info.state) { - // 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인). - td.classList.add("is-overridden", `is-${info.state.replaceAll("_", "-")}`); - if (info.state !== "edited") { - // 뜻 바뀜은 값 어긋남보다 나쁨 — 표시부터 더 세게(2026-09-16 브레인). - td.textContent = `${info.state === "meaning_changed" ? "⛔" : "⚠"} ${texts[i]}`; - tr.classList.add(info.state === "meaning_changed" ? "has-danger" : "has-warning"); - } - td.title = stateText(info, column); - } - const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null; - if (canEdit) td.classList.add("is-editable"); - if (selected?.index === index && selected.key === column.key) - td.classList.add("is-selected"); - td.addEventListener("click", () => { - if (td.querySelector("input")) return; - grid.querySelector("td.is-selected")?.classList.remove("is-selected"); - selectCell(index, column); - td.classList.add("is-selected"); - if (canEdit) startEdit(td, index, column); - }); - tr.append(td); - }); - body.append(tr); - }); + // 저장 전 새 줄은 어느 쪽에서나 맨 위 — 쪽을 넘겨도 안 사라짐. + for (const added of draft?.added ?? []) body.append(addedRow(meta, columns, added)); + meta.rows.forEach((row, index) => body.append(serverRow(meta, columns, row, index))); grid.replaceChildren( - meta.rows.length + body.children.length ? el("table", { className: "z01-master__grid", children: [el("thead", { children: [headRow] }), body], @@ -331,25 +370,100 @@ export function buildRowsView(): RowsView { pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`; first.disabled = prev.disabled = state.page <= 1; next.disabled = end.disabled = state.page >= pages; + drawActions(); + } + + const isPicked = (row: string, added: boolean, column: string): boolean => + picked?.row === row && picked.added === added && picked.column === column; + + function serverRow(meta: MasterRows, columns: MasterColumn[], row: Row, index: number) { + const key = rowKey(row, index); + const texts = rowCells(row, columns); + const changed = changedColumns(row); + const deleted = draft?.deleted.has(key) ?? false; + const tr = el("tr"); + if (row.is_added) tr.classList.add("is-added"); + if (deleted) tr.classList.add("is-deleted"); + columns.forEach((column, i) => { + const info = cellInfo(meta, row, column.key); + const stored = row[column.key]; + const pending = draft?.edit(key, column.key); + const text = pending ? cellText(pending.value, column.unit) : texts[i]; + const td = el("td", { text, attrs: { title: text } }); + // 고친 칸 표시 — 저장 전(초안) · 저장했고 초기값과 다름(서버 changed_columns) + if (pending) td.classList.add("is-draft"); + else if (changed.includes(column.key)) td.classList.add("is-changed"); + const canEdit = Boolean(draft) && info.editable && row["@id"] != null && !deleted; + if (canEdit) td.classList.add("is-editable"); + if (isPicked(key, false, column.key)) td.classList.add("is-selected"); + td.addEventListener("click", () => { + if (td.querySelector("input")) return; + pick(td, { row: key, added: false, column: column.key }); + if (!canEdit) return; + startEdit(td, pending ? pending.value : stored, stored ?? sample(column.key), (value) => + draft?.setEdit(key, column.key, value, stored), + ); + }); + tr.append(td); + }); + return tr; + } + + function addedRow(meta: MasterRows, columns: MasterColumn[], added: AddedRow) { + const tr = el("tr", { className: "is-new", attrs: { title: L("Z01_MasterData_Row_New") } }); + for (const column of columns) { + const value = added.data[column.key]; + const td = el("td", { text: cellText(value, column.unit) }); + // 새 줄은 식으로 나오는 칸만 빼고 다 채움 — 열쇠·이름도 사람이 넣어야 줄이 섬. + const canEdit = !meta.formula?.[column.key]; + if (canEdit) td.classList.add("is-editable"); + if (isPicked(added.tmp, true, column.key)) td.classList.add("is-selected"); + td.addEventListener("click", () => { + if (td.querySelector("input")) return; + pick(td, { row: added.tmp, added: true, column: column.key }); + if (!canEdit) return; + startEdit( + td, + value, + sample(column.key), + (next) => draft?.setAdded(added.tmp, column.key, next), + true, + ); + }); + tr.append(td); + } + return tr; + } + + function pick(td: HTMLElement, next: Picked): void { + grid.querySelector("td.is-selected")?.classList.remove("is-selected"); + td.classList.add("is-selected"); + picked = next; + showDetail(); + drawActions(); } /** - * 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. - * `failure` = 방금 저장이 안 된 까닭 — 토스트만 뜨고 사라지면 사람은 저장된 줄 앎(브레인 ③). + * 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 저장 전 고침 · 초기값과 다름 · 식과 값 넣은 식. + * `failure` = 방금 고침이 안 들어간 까닭 — 토스트만 뜨고 사라지면 사람은 들어간 줄 앎(브레인 ③). */ - function selectCell(index: number, column: MasterColumn, failure?: string): void { - if (!last) return; - selected = { index, key: column.key }; - const row = last.rows[index]; + function showDetail(failure?: string): void { + const at = picked; + const column = last?.columns.find((each) => each.key === at?.column); + if (!last || !at || !column) return; + const row = at.added + ? (draft?.added.find((each) => each.tmp === at.row)?.data ?? {}) + : (pickedRow() ?? last.rows[Number(at.row.slice(1))] ?? {}); const info = cellInfo(last, row, column.key); const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; - const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); - if (failure) { - const failed = line(fill("Z01_MasterData_Edit_NotSaved", failure)); - failed.classList.add("is-warning"); - lines.push(failed); - } - if (source?.save) { + const line = (text: string, warn = false) => { + const span = el("span", { className: "z01-master__detail-line", text }); + span.classList.toggle("is-warning", warn); + return span; + }; + if (failure) lines.push(line(fill("Z01_MasterData_Edit_NotSaved", failure), true)); + if (at.added) lines.push(line(L("Z01_MasterData_Row_New"))); + else if (source?.edit) { lines.push( line( info.editable @@ -357,30 +471,14 @@ export function buildRowsView(): RowsView { : `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`, ), ); - } - if (info.state) { - const stateLine = line(stateText(info, column)); - if (info.state !== "edited") stateLine.classList.add("is-warning"); - lines.push(stateLine); - // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). - // 뜻이 바뀐 줄에서 「새 원본 값으로 받기」는 위험 — 글자를 가르고 한 번 더 물음(브레인 ③). - const target = - info.state === "meaning_changed" - ? fill("Z01_MasterData_Edit_DropOnMeaning", valueWithUnit(info.current, column.unit)) - : info.state === "source_changed" - ? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit)) - : info.state === "orphan" - ? null - : fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit)); - if (target && source?.save) { - lines.push( - createButton({ - label: target, - variant: info.state === "meaning_changed" ? "danger" : "ghost", - onClick: () => void drop(index, column, info), - }), - ); + const pending = draft?.edit(at.row, column.key); + if (pending) { + const value = valueWithUnit(pending.value, column.unit); + lines.push(line(fill("Z01_MasterData_Cell_Draft", value))); } + if (changedColumns(row).includes(column.key)) + lines.push(line(L("Z01_MasterData_Cell_Changed"))); + if (row.is_added) lines.push(line(L("Z01_MasterData_Row_Added"))); } if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`)); if (info.filled) lines.push(line(info.filled)); @@ -388,31 +486,38 @@ export function buildRowsView(): RowsView { detail.hidden = false; } - function startEdit(td: HTMLElement, index: number, column: MasterColumn): void { - if (!last) return; - const current = last.rows[index][column.key]; + /** + * 칸 고치기 — Enter·칸 나감 = 초안에 넣음(서버엔 안 감) · Esc = 그대로. + * `typeOf` = 수로 읽을지 가르는 본보기 값 · `allowEmpty` = 비우면 칸을 뺌(새 줄). + */ + function startEdit( + td: HTMLElement, + current: unknown, + typeOf: unknown, + apply: (value: unknown) => void, + allowEmpty = false, + ): void { const input = el("input", { className: "ui-input z01-master__cell-input" }); input.value = current == null ? "" : String(current); td.replaceChildren(input); input.focus(); input.select(); let finished = false; - const finish = (save: boolean): void => { + const finish = (keep: boolean): void => { if (finished) return; finished = true; - const value = save ? parseCellInput(input.value, current) : current; + const empty = allowEmpty && input.value.trim() === ""; + const value = !keep ? current : empty ? null : parseCellInput(input.value, typeOf); if (value === undefined) { const why = fill("Z01_MasterData_Edit_Invalid", input.value); showToast(why, "error"); draw(); - selectCell(index, column, why); + showDetail(why); return; } - if (value === current) { - draw(); - return; - } - void commit(index, column, value); + if (keep) apply(value); + draw(); + showDetail(); }; input.addEventListener("keydown", (event) => { if (event.key === "Enter") finish(true); @@ -421,59 +526,87 @@ export function buildRowsView(): RowsView { input.addEventListener("blur", () => finish(true)); } - /** 고침 버리기 — 뜻이 바뀐 줄은 한 번 더 묻고(딴 물건에 값이 붙은 자리라 되돌리기와 뜻이 다름). */ - async function drop(index: number, column: MasterColumn, info: CellInfo): Promise { - if (info.state === "meaning_changed") { - const ok = await showConfirmDialog( - `${meaningNames(info)}\n${L("Z01_MasterData_Edit_MeaningConfirm")}`, - L("Z01_MasterData_Edit_DropConfirm"), - ); - if (!ok) return; - } - await commit(index, column, null); + function addRow(): void { + draft?.addRow(); + draw(); } - /** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */ - async function commit(index: number, column: MasterColumn, value: unknown): Promise { - const from = source; - const table = last; - const rowId = table?.rows[index]["@id"]; - if (!from?.save || !table || rowId == null) return; + function toggleDelete(): void { + if (!draft || !picked) return; + if (picked.added) { + draft.removeAdded(picked.row); + picked = null; + detail.hidden = true; + } else if (pickedRow()) { + draft.toggleDelete(picked.row); + } + draw(); + } + + function discard(): void { + draft?.clear(); + stale = false; + void load(); + } + + /** [저장] — 초안 한 벌을 보냄. 409 면 초안을 지키고 알림(다른 곳에서 먼저 저장함). */ + async function save(): Promise { + const edit = source?.edit; + const active = draft; + if (!edit || !active?.count) return; + showLoadingOverlay(); try { - const updated = await from.save(String(rowId), { [column.key]: value }); - if (from !== source || table !== last) return; - table.rows[index] = updated; - draw(); - selectCell(index, column); - showToast( - L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"), - "success", - ); + await edit.save(active.payload()); + active.clear(); + showToast(L("Z01_MasterData_Saved"), "success"); + void load(); } catch (error) { - const why = error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"); - showToast(why, "error"); - if (from !== source || table !== last) return; - draw(); - selectCell(index, column, why); + 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 show(picked: RowsSource): void { - source = picked; + /** [초기값으로] — 고른 줄을 seed 로(추가한 줄은 지워짐) · 바로 저장이라 한 번 물음. */ + async function reset(): Promise { + const edit = source?.edit; + const row = pickedRow(); + if (!edit || !row || draft?.count) return; + const ok = await showConfirmDialog(L("Z01_MasterData_ResetConfirm"), L("Z01_MasterData_Reset")); + if (!ok) return; + showLoadingOverlay(); + try { + await edit.reset([String(row["@id"])]); + showToast(L("Z01_MasterData_ResetDone"), "success"); + void load(); + } catch (error) { + showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + function show(next: RowsSource): void { + source = next; state.page = 1; state.sort = null; state.filters = {}; filterBox.replaceChildren(); - title.textContent = picked.title; - titleKey.textContent = picked.key; + title.textContent = next.title; + titleKey.textContent = next.key; notice.hidden = true; banner.hidden = true; - search.root.hidden = picked.searchable === false; + search.root.hidden = next.searchable === false; toolbar.hidden = false; pager.hidden = false; detail.hidden = true; last = null; - selected = null; + picked = null; + draft = null; + stale = false; + actions.hidden = true; grid.replaceChildren(); void load(); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index 0e958c6d..3a3f6860 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -12,25 +12,22 @@ import { t as L } from "@ui/ui_template_locale"; import { fetchBasePrices, fetchMasterRows, - fetchOverrides, + resetBasePrices, + saveBasePrices, type MasterGroup, - type MasterRows, - type RowsQuery, - saveBasePrice, VALUES_TABLE_ID, + type WorkItemAxis, } from "./Z01_MasterData_Api_Fetch"; -import { - groupKinds, - valueWithUnit, - type OverrideState, - type ServedKind, -} from "./Z01_MasterData_UI_Cells"; +import { groupKinds, type ServedKind } from "./Z01_MasterData_UI_Cells"; import type { RowsView } from "./Z01_MasterData_UI_Rows"; +const AXES: WorkItemAxis[] = ["forest", "const"]; + export function buildSidePanel( groups: MasterGroup[], kinds: ServedKind[], view: RowsView, + openWorkItems: (axis: WorkItemAxis) => void, ): HTMLElement { const panel = el("div", { className: "z01-master__side" }); const activate = (button: HTMLElement): void => { @@ -59,29 +56,32 @@ export function buildSidePanel( title: `${box.label} › ${served.label}`, key: `base-prices/${served.kind}`, load: (query) => fetchBasePrices(served.kind, query), - save: (rowId, values) => saveBasePrice(served.kind, rowId, values), + edit: { + scope: `base:${served.kind}`, + save: (body) => saveBasePrices(served.kind, body), + reset: (rowKeys) => resetBasePrices(served.kind, rowKeys), + }, }); }); grid.append(button); } return { label: box.label, grid }; }); - const overridesButton = el("button", { - className: "z01-master__base z01-master__base--wide", - attrs: { type: "button" }, - text: L("Z01_MasterData_Overrides"), - }); - overridesButton.addEventListener("click", () => { - activate(overridesButton); - view.show({ - title: L("Z01_MasterData_Overrides"), - key: "overrides", - searchable: false, - // 갈래 이름도 서버 것 — 목록 줄의 kind 를 표 이름으로 바꿔 보임. - load: (query) => loadOverrides(query, new Map(kinds.map((k) => [k.kind, k.label]))), + + // 공종 편집 — 구성 줄을 고치고 「입력 필요」 공종을 메움(PLAN 1-4). + const workGrid = el("div", { className: "z01-master__base-grid" }); + for (const axis of AXES) { + const button = el("button", { + className: "z01-master__base", + attrs: { type: "button" }, + text: L(axis === "forest" ? "Z01_WorkItem_Axis_Forest" : "Z01_WorkItem_Axis_Const"), }); - }); - boxes[0]?.grid.append(overridesButton); + button.addEventListener("click", () => { + activate(button); + openWorkItems(axis); + }); + workGrid.append(button); + } const tree = el("nav", { className: "z01-master__tree" }); for (const group of groups) { @@ -132,54 +132,13 @@ export function buildSidePanel( panel.append( ...boxes.map((box) => section(box.label, box.grid)), + section(L("Z01_WorkItem_Title"), workGrid), section(L("Z01_MasterData_Tree"), tree), ); attachCollapsible(panel); return panel; } -const STATE_LABEL: Record[0]> = { - meaning_changed: "Z01_MasterData_List_MeaningChanged", - source_changed: "Z01_MasterData_List_SourceChanged", - orphan: "Z01_MasterData_List_Orphan", - edited: "Z01_MasterData_List_Edited", -}; - -/** - * 고친 것 목록을 표 한 벌로 — 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)과 쪽 나눔은 서버가 함. - * 화면이 다시 줄 세우면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). 서버에 검색이 없어 검색 칸은 숨김. - */ -async function loadOverrides( - query: RowsQuery, - kindLabels: Map, -): Promise { - const { items, total } = await fetchOverrides(query); - const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); - return { - columns: [ - column("state", "Z01_MasterData_List_State"), - column("kind", "Z01_MasterData_List_Kind"), - column("row_label", "Z01_MasterData_List_Row"), - column("column_label", "Z01_MasterData_List_Column"), - column("value", "Z01_MasterData_List_Value"), - column("original", "Z01_MasterData_List_Original"), - column("current", "Z01_MasterData_List_Current"), - ], - rows: items.map((item) => ({ - ...item, - value: valueWithUnit(item.value, item.unit), - original: valueWithUnit(item.original, item.unit), - ...(Object.hasOwn(item, "current") - ? { current: valueWithUnit(item.current, item.unit) } - : {}), - // state 가 안 오면 때우지 않고 드러냄(서버 버그). - state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), - kind: kindLabels.get(item.kind) ?? item.kind, - })), - total, - }; -} - function section(title: string, body: HTMLElement): HTMLElement { return el("section", { className: "z01-master__section ui-collapsible ui-sidebar-section", diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index b62321c4..24640741 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -315,3 +315,204 @@ width: 6em; text-align: right; } + +/* ── 고치기 — 저장 전 초안 · 저장된 고침(초기값과 다름) · 새 줄 · 지울 줄 (PLAN 1-4) ── */ +.z01-master__grid td.is-draft, +.z01-wi__field.is-draft { + background: color-mix(in srgb, var(--color-warning) 24%, var(--color-canvas)); + font-weight: var(--font-weight-bold); +} + +.z01-master__grid td.is-changed, +.z01-wi__field.is-changed { + background: var(--color-mist-violet); + color: var(--color-accent); +} + +.z01-master__grid tr.is-new td { + background: color-mix(in srgb, var(--color-success) 12%, var(--color-canvas)); +} + +.z01-master__grid tr.is-added td:first-child { + box-shadow: inset 4px 0 0 var(--color-success); +} + +.z01-master__grid tr.is-deleted td { + color: var(--color-text-muted); + text-decoration: line-through; +} + +.z01-master__draft-status { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.z01-master__draft-status.is-warning { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +/* ── 공종 편집 — 왼쪽 목록 / 오른쪽 머리·구성 줄·원문 ── */ +.z01-master__panel.z01-wi { + display: grid; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); + align-items: start; + gap: var(--spacing-16); +} + +.z01-wi__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + position: sticky; + top: 0; +} + +.z01-wi__list { + display: flex; + flex-direction: column; + max-height: calc(100vh - 260px); + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); +} + +.z01-wi__list.is-loading { + opacity: 0.5; +} + +.z01-wi__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--spacing-4) var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-bottom: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); + text-align: left; + cursor: pointer; +} + +.z01-wi__item:hover { + background: var(--color-paper); +} + +.z01-wi__item.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); +} + +.z01-wi__item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.z01-wi__status { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.z01-wi__status.is-needs-input, +.z01-wi__status.is-partial, +.z01-wi__unmatched { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +.z01-wi__status.is-reviewed { + color: var(--color-success); +} + +.z01-wi__editor { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.z01-wi__head { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--spacing-8) var(--spacing-16); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + font-size: var(--text-body-sm); +} + +.z01-wi__head:empty { + display: none; +} + +.z01-wi__field { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px var(--spacing-4); + border-radius: var(--radius-sm); +} + +.z01-wi__label { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.z01-wi__heading { + margin: 0; + color: var(--color-text); + font-size: var(--text-body); +} + +.z01-wi__variant { + margin: var(--spacing-8) 0 var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.z01-wi__grid td { + padding: 2px var(--spacing-4); +} + +.z01-wi__grid input, +.z01-wi__grid select { + min-width: 4em; + padding: 2px var(--spacing-4); + font-size: 12px; +} + +.z01-wi__grid select { + min-width: 6em; +} + +.z01-wi__grid input[type="number"] { + width: 6em; +} + +.z01-wi__finder { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border: 2px solid var(--color-accent); + border-radius: var(--radius-cards); + background: var(--color-surface); +} + +.z01-wi__found { + display: flex; + flex-direction: column; + max-height: 240px; + overflow: auto; +} + +.z01-wi__notes { + margin: 0; + padding-left: 1.2em; + font-size: var(--text-body-sm); +} diff --git a/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts b/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts new file mode 100644 index 00000000..51ee3e9f --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_WorkItem.ts @@ -0,0 +1,409 @@ +/* ============================================================================= + * 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 }; +} diff --git a/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts new file mode 100644 index 00000000..121ae081 --- /dev/null +++ b/Z01_MasterData/Z01_MasterData_UI_WorkItem_Rows.ts @@ -0,0 +1,374 @@ +/* ============================================================================= + * Z01_MasterData_UI_WorkItem_Rows.ts + * 공종 편집 아래쪽 — 구성 줄 표(갈래별) · 자원 찾기(기초단가 검색) · 품셈 원문 표 + [주](읽기 전용) + * + * 칸을 고치면 초안(`WorkItemBody`)만 바꿈 — 서버엔 [저장] 때 전체 줄을 한 번에 보냄(PLAN 1-0). + * 못 맞춘 줄(resource_key 없음)은 또렷이 — 자원 찾기로 기초단가 줄을 골라 이음. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { + fetchBasePrices, + type ResourceKind, + type WorkItemDetail, + type WorkItemRow, +} from "./Z01_MasterData_Api_Fetch"; +import { cellText, latestOnly } from "./Z01_MasterData_UI_Cells"; +import { + addRow, + changedFields, + groupByVariant, + type DraftRow, + type WorkItemBody, +} from "./Z01_MasterData_UI_Draft"; + +type LocaleKey = Parameters[0]; + +const KIND_LABEL: Record = { + labor: "Z01_WorkItem_Kind_Labor", + machine: "Z01_WorkItem_Kind_Machine", + material: "Z01_WorkItem_Kind_Material", + etc: "Z01_WorkItem_Kind_Etc", +}; + +/** + * 자원 찾기 — resource_kind 가 기초단가 kind 이름과 같음(노임·기계·자재 · 기타는 기초단가에 없음). + * 이름·규격 칸은 서버 `row_label` 과 같은 칸을 씀. + */ +type Findable = Exclude; +const FINDABLE: Record = { + labor: { name: "occupation_name" }, + machine: { name: "machine_name", spec: "specification" }, + material: { name: "classification_name", spec: "specification" }, +}; +const FIND_SIZE = 20; +const SEARCH_DELAY_MS = 300; + +const COLUMNS: LocaleKey[] = [ + "Z01_WorkItem_Col_Kind", + "Z01_WorkItem_Col_Resource", + "Z01_WorkItem_Col_Spec", + "Z01_WorkItem_Col_Key", + "Z01_WorkItem_Col_Amount", + "Z01_WorkItem_Col_Unit", + "Z01_WorkItem_Col_Alt", + "Z01_WorkItem_Col_Ratio", + "Z01_WorkItem_Col_Raw", + "Z01_WorkItem_Col_Table", + "Z01_WorkItem_Col_Delete", +]; + +let tmpSeq = 0; +const tmpId = (): string => `new-${Date.now()}-${++tmpSeq}`; + +/** rebuild = 줄 수·갈래·자원이 바뀜(다시 그림) · false = 칸 값만 바뀜(초점 지킴) */ +export type RowsChanged = (rebuild: boolean) => void; + +export function renderWorkRows( + body: WorkItemBody, + detail: WorkItemDetail, + changed: RowsChanged, +): HTMLElement { + const finder = buildFinder(() => changed(true)); + const box = el("div", { + className: "z01-wi__rows", + children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Rows") })], + }); + const groups = groupByVariant(body.rows); + // 줄이 하나도 없는 공종(입력 필요) — 갈래 없는 빈 묶음 하나로 [줄 추가] 를 세움. + if (!groups.length) groups.push({ variant: "", indexes: [] }); + for (const group of groups) box.append(variantBlock(group.variant, group.indexes)); + + const variantInput = el("input", { + className: "ui-input", + attrs: { placeholder: L("Z01_WorkItem_VariantName") }, + }); + const addVariant = createButton({ + label: L("Z01_WorkItem_AddVariant"), + variant: "ghost", + onClick: () => { + const name = variantInput.value.trim(); + if (!name) return; + addRow(body, name, tmpId()); + changed(true); + }, + }); + box.append( + el("div", { className: "z01-master__toolbar", children: [variantInput, addVariant] }), + finder.root, + ); + return box; + + function variantBlock(variant: string, indexes: number[]): HTMLElement { + const head = el("tr", { children: COLUMNS.map((key) => el("th", { text: L(key) })) }); + const table = el("table", { + className: "z01-master__grid z01-wi__grid", + children: [ + el("thead", { children: [head] }), + el("tbody", { children: indexes.map((index) => rowLine(body.rows[index])) }), + ], + }); + const add = createButton({ + label: L("Z01_MasterData_AddRow"), + variant: "ghost", + onClick: () => { + addRow(body, variant, tmpId()); + changed(true); + }, + }); + return el("div", { + className: "z01-wi__group", + children: [ + el("h4", { className: "z01-wi__variant", text: variant || L("Z01_WorkItem_NoVariant") }), + el("div", { className: "z01-master__grid-wrap", children: [table] }), + add, + ], + }); + } + + function rowLine(row: DraftRow): HTMLElement { + const tr = el("tr"); + const cells: [keyof WorkItemRow, HTMLElement][] = []; + // 고친 칸 표시 — 저장 전(초안 · 새 줄은 줄째) · 저장했고 초기값과 다름(서버 changed_columns) + const saved = row.changed_columns ?? []; + tr.classList.toggle("is-added", Boolean(row.is_added)); + const mark = (): void => { + const diff = changedFields(row, detail.rows); + tr.classList.toggle("is-new", diff === "added"); + tr.classList.toggle("is-deleted", Boolean(row.deleted)); + for (const [field, td] of cells) { + const draft = diff !== "added" && diff.has(field); + td.classList.toggle("is-draft", draft); + td.classList.toggle("is-changed", !draft && saved.includes(field)); + } + }; + const cell = (field: keyof WorkItemRow, ...children: (HTMLElement | string)[]) => { + const td = el("td", { children }); + cells.push([field, td]); + tr.append(td); + return td; + }; + const set = (field: keyof WorkItemRow, value: unknown): void => { + (row as unknown as Record)[field] = value; + mark(); + changed(false); + }; + const text = (field: "resource_name" | "resource_spec" | "amount_unit") => { + const input = el("input", { className: "ui-input" }); + input.value = row[field] ?? ""; + input.addEventListener("change", () => set(field, input.value.trim())); + return input; + }; + const number = ( + field: "amount" | "alternative_amount" | "group_ratio_pct", + ): HTMLInputElement => { + const input = el("input", { + className: "ui-input", + attrs: { type: "number", min: "0", step: "any" }, + }); + input.value = row[field] == null ? "" : String(row[field]); + input.addEventListener("change", () => { + const raw = input.value.trim(); + set(field, raw === "" ? null : Number(raw)); + }); + return input; + }; + + const kind = el("select", { className: "ui-select" }); + for (const each of Object.keys(KIND_LABEL) as ResourceKind[]) { + kind.append(new Option(L(KIND_LABEL[each]), each)); + } + kind.value = row.resource_kind; + // 종류가 바뀌면 이은 열쇠는 다른 표의 것 — 풀어 두고 다시 찾게 함. + kind.addEventListener("change", () => { + row.resource_kind = kind.value as ResourceKind; + row.resource_key = null; + changed(true); + }); + cell("resource_kind", kind); + cell("resource_name", text("resource_name")); + cell("resource_spec", text("resource_spec")); + const key = row.resource_key + ? el("span", { text: row.resource_key }) + : el("span", { className: "z01-wi__unmatched", text: L("Z01_WorkItem_Unmatched") }); + const find = createButton({ + label: L("Z01_WorkItem_Find"), + variant: "ghost", + disabled: row.resource_kind === "etc", + onClick: () => finder.open(row), + }); + cell("resource_key", key, find); + cell("amount", number("amount")); + cell("amount_unit", text("amount_unit")); + cell("alternative_amount", number("alternative_amount")); + cell("group_ratio_pct", number("group_ratio_pct")); + cell( + "raw_cell", + el("span", { text: row.raw_cell ?? "", attrs: { title: row.raw_cell ?? "" } }), + ); + cell("pum_table_id", row.pum_table_id ?? ""); + const drop = createButton({ + label: L(row.deleted ? "Z01_MasterData_UndeleteRow" : "Z01_MasterData_DeleteRow"), + variant: "ghost", + onClick: () => { + // 새 줄은 바로 뺌 · 서버 줄은 줄 긋고 [저장] 때 뺌(되살릴 수 있게) + if (row.id === undefined) body.rows.splice(body.rows.indexOf(row), 1); + else row.deleted = !row.deleted; + changed(true); + }, + }); + tr.append(el("td", { children: [drop] })); + mark(); + return tr; + } +} + +/** 자원 찾기 상자 — 기초단가 검색 API 로 찾아 한 줄을 고르면 그 줄에 열쇠·이름·규격을 넣음. */ +function buildFinder(picked: () => void): { root: HTMLElement; open: (row: DraftRow) => void } { + let target: DraftRow | null = null; + let timer = 0; + const search = latestOnly(fetchBasePrices); + const title = el("strong"); + const kind = el("select", { className: "ui-select" }); + for (const each of Object.keys(FINDABLE) as Findable[]) { + kind.append(new Option(L(KIND_LABEL[each]), each)); + } + const input = el("input", { + className: "ui-input", + attrs: { type: "search", placeholder: L("Z01_MasterData_Search") }, + }); + const results = el("div", { className: "z01-wi__found" }); + const close = createButton({ + label: L("Z01_WorkItem_FindClose"), + variant: "ghost", + onClick: () => { + root.hidden = true; + }, + }); + const root = el("div", { + className: "z01-wi__finder", + children: [ + title, + el("div", { className: "z01-master__toolbar", children: [kind, input, close] }), + results, + ], + }); + root.hidden = true; + kind.addEventListener("change", () => void run()); + input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void run(), SEARCH_DELAY_MS); + }); + + async function run(): Promise { + const from = kind.value as Findable; + let data; + try { + data = await search(from, { page: 1, size: FIND_SIZE, q: input.value.trim() }); + } catch (error) { + showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); + return; + } + if (!data) return; + const fields = FINDABLE[from]; + results.replaceChildren( + ...(data.rows.length + ? data.rows.map((row) => { + const name = cellText(row[fields.name]); + const spec = fields.spec ? cellText(row[fields.spec]) : ""; + const button = el("button", { + className: "z01-wi__item", + attrs: { type: "button" }, + children: [ + el("span", { className: "z01-wi__item-name", text: `${name} ${spec}`.trim() }), + el("span", { className: "z01-master__key", text: cellText(row["@id"]) }), + ], + }); + button.addEventListener("click", () => { + if (!target) return; + target.resource_kind = from; + target.resource_key = String(row["@id"]); + target.resource_name = name; + target.resource_spec = spec; + root.hidden = true; + picked(); + }); + return button; + }) + : [el("p", { className: "z01-master__note", text: L("Z01_WorkItem_FindNone") })]), + ); + } + + function open(row: DraftRow): void { + target = row; + kind.value = row.resource_kind === "etc" ? "material" : row.resource_kind; + input.value = row.resource_name; + title.textContent = `${L("Z01_WorkItem_FindTitle")} — ${row.resource_name || row.raw_cell || ""}`; + results.replaceChildren(); + root.hidden = false; + root.scrollIntoView({ block: "nearest" }); + void run(); + } + + return { root, open }; +} + +/** 품셈 원문 표 + [주] — 읽기 전용(값을 고칠 때 옆에 두고 봄). */ +export function renderSource(detail: WorkItemDetail): HTMLElement { + const box = el("div", { + className: "z01-wi__source", + children: [el("h3", { className: "z01-wi__heading", text: L("Z01_WorkItem_Source") })], + }); + if (!detail.source.length) { + box.append(el("p", { className: "z01-master__note", text: L("Z01_WorkItem_NoSource") })); + } + for (const table of detail.source) { + const caption = [table.pum_table_id, table.section, table.pum_form].filter(Boolean).join(" · "); + const heads = cells(table.condition_note); + const rows = Array.isArray(table.raw_row) ? (table.raw_row as unknown[]) : []; + const grid = el("table", { className: "z01-master__grid" }); + if (heads.length) { + grid.append( + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })], + }), + ); + } + grid.append( + el("tbody", { + children: rows.map((row) => + el("tr", { + children: cells(row).map((c) => el("td", { text: c, attrs: { title: c } })), + }), + ), + }), + ); + box.append( + el("h4", { className: "z01-wi__variant", text: caption }), + el("div", { className: "z01-master__grid-wrap", children: [grid] }), + ); + } + const notes = detail.notes ?? []; + if (notes.length) { + box.append( + el("h4", { className: "z01-wi__variant", text: L("Z01_WorkItem_Notes") }), + el("ul", { + className: "z01-wi__notes", + children: notes.map((note) => el("li", { text: noteText(note) })), + }), + ); + } + return box; +} + +/** 원문 칸 — 목록이면 칸마다 · 글자 하나면 한 칸. */ +function cells(value: unknown): string[] { + if (Array.isArray(value)) return value.map((each) => cellText(each)); + return value == null ? [] : [cellText(value)]; +} + +function noteText(note: unknown): string { + if (note && typeof note === "object" && "text" in note) return cellText(note.text); + return cellText(note); +} diff --git a/resources/tester/test_z01_master_draft.py b/resources/tester/test_z01_master_draft.py new file mode 100644 index 00000000..30135a5f --- /dev/null +++ b/resources/tester/test_z01_master_draft.py @@ -0,0 +1,184 @@ +"""Z01 고친 것 초안 — [저장] 전까지 sessionStorage 에만 · 판이 다르면 버림 (PLAN 1-0 · 1-4 · 2026-09-18). + + ① 기초단가 — 서버 값과 같아지면 고침에서 빠짐 · 지울 줄의 칸 고침은 안 보냄 · 새 줄은 data 만 보냄 + ② 같은 판이면 탭을 다시 열어도 초안이 살아남 · 판이 다르면 비고 저장소에서도 지움 + ③ 공종 — 보낼 몸은 지울 줄을 빼고 화면 표시 칸(tmp·deleted)을 뗌(빠지면 삭제 = 전체 줄 보내기) + ④ 고친 것이 없으면 dirty 아님 · 고치면 dirty · 새 줄은 「added」 · 고친 칸만 짚음 + ⑤ 갈래는 처음 나온 차례로 묶음 · 새 줄은 제 갈래 끝 바로 뒤(보낸 차례 = sort_order) + ⑥ 보낼 줄은 id + 계약 칸 열하나만 — GET 전용 칸(sort_order·is_added·changed_columns)이 실리면 422 +TS 를 실제로 돌린다(`test_z01_master_cells` 와 같은 방식). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Draft.ts" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; + +// 브라우저 sessionStorage 대역 +const store = new Map(); +globalThis.sessionStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; +const { + BaseDraft, addRow, changedFields, freshBody, groupByVariant, isDirty, workItemPayload, +} = await import("./Z01_MasterData_UI_Draft.js"); + +// ① 기초단가 +const base = new BaseDraft("base:labor", 7); +base.setEdit("labor_const/1001", "daily_wage_krw", 250000, 215907); +base.setEdit("labor_const/1002", "daily_wage_krw", 100, 100); // 서버 값과 같음 — 안 쌓임 +base.setEdit("labor_const/1003", "daily_wage_krw", 1, 2); +base.setEdit("labor_const/1003", "daily_wage_krw", 2, 2); // 되돌림 — 빠짐 +base.setEdit("labor_const/9999", "daily_wage_krw", 5, 4); +base.toggleDelete("labor_const/9999"); +const added = base.addRow(); +base.setAdded(added.tmp, "occupation_name", "새 직종"); +base.setAdded(added.tmp, "daily_wage_krw", 300000); +base.setAdded(added.tmp, "daily_wage_krw", null); // 비우면 칸을 뺌 +const payload = base.payload(); +const count = base.count; +const stored = JSON.parse(store.get("z01-draft:base:labor")); + +// ② 같은 판이면 살아남 · 다른 판이면 비고 저장소에서 지움 +const again = new BaseDraft("base:labor", 7); +const restored = again.payload(); +const newer = new BaseDraft("base:labor", 8); +const dropped = { count: newer.count, left: store.has("z01-draft:base:labor") }; +const toggled = new BaseDraft("base:oil", 1); +toggled.toggleDelete("x"); +toggled.toggleDelete("x"); +const emptied = { count: toggled.count, left: store.has("z01-draft:base:oil") }; + +// ③④⑤ 공종 +const row = (id, variant, extra = {}) => ({ + id, variant, resource_kind: "labor", resource_key: "labor_const/1001", resource_name: "보통인부", + resource_spec: "", amount: 0.5, amount_unit: "인", alternative_amount: null, + group_ratio_pct: null, raw_cell: null, pum_table_id: "T-1", + // GET 전용 칸 — PUT 에 실리면 422(extra=forbid) + sort_order: id, is_added: false, changed_columns: [], ...extra, +}); +const detail = { + item: { work_item_key: "FW-00001", axis: "forest", code: null, number: "1-1", name: "벌목", + path_name: "벌목", parent_key: null, parent_mode: null, basis_quantity: 1, + basis_unit: "ha", status: "needs_input", edition: "2026", revision: 3 }, + rows: [row(1, "갑"), row(2, "을"), row(3, "갑")], + source: [], notes: [], +}; +const body = freshBody(detail); +const clean = isDirty(detail, body); +body.rows[0].amount = 0.7; +body.rows[1].deleted = true; +addRow(body, "을", "new-1"); +body.item.status = "reviewed"; +const sent = workItemPayload(detail.item.revision, body); + +writeFileSync(process.argv[2], JSON.stringify({ + payload, count, stored, + restored, dropped, emptied, + clean, + dirty: isDirty(detail, body), + sent, + changed: [...changedFields(body.rows[0], detail.rows)], + addedRow: changedFields(body.rows[2], detail.rows), + untouched: [...changedFields(body.rows[3], detail.rows)], + groups: groupByVariant(body.rows), + freshUntouched: freshBody(detail).rows[0].amount, +})); +""" + + +def test_초안_쌓기와_보낼_몸(tmp_path: Path) -> None: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(SOURCE), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--ignoreConfig", + "--noCheck", + "--noResolve", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "output.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + got = json.loads(result.read_text(encoding="utf-8")) + + # ① 서버 값과 같은 고침은 안 쌓임 · 지울 줄(9999)의 칸 고침은 안 보냄 · 새 줄은 data 만 + assert got["payload"] == { + "base_revision": 7, + "edits": [ + {"row_key": "labor_const/1001", "column": "daily_wage_krw", "value": 250000}, + ], + "added": [{"data": {"occupation_name": "새 직종"}}], + "deleted": ["labor_const/9999"], + } + # 고친 칸 2(1001·9999) + 새 줄 1 + 지울 줄 1 + assert got["count"] == 4 + assert got["stored"]["revision"] == 7 + # ② 같은 판이면 그대로 살아남 · 판이 바뀌면 비고 저장소에서도 지움 · 다 되돌리면 저장소에서 빠짐 + assert got["restored"] == got["payload"] + assert got["dropped"] == {"count": 0, "left": False} + assert got["emptied"] == {"count": 0, "left": False} + # ③ 지울 줄(id 2) 빠짐 · 새 줄은 id 없이 · 화면 표시 칸 없음 · 머리 상태 실림 + assert got["clean"] is False + assert got["dirty"] is True + sent = got["sent"] + assert sent["base_revision"] == 3 + assert sent["item"] == { + "name": "벌목", + "basis_quantity": 1, + "basis_unit": "ha", + "status": "reviewed", + } + assert [r.get("id") for r in sent["rows"]] == [1, None, 3] + # ⑥ id + 계약 칸만(새 줄은 id 없음) · 글자 칸은 null 아닌 빈 글자 + fields = { + "variant", + "resource_kind", + "resource_key", + "resource_name", + "resource_spec", + "amount", + "amount_unit", + "alternative_amount", + "group_ratio_pct", + "raw_cell", + "pum_table_id", + } + assert [set(r) for r in sent["rows"]] == [fields | {"id"}, fields, fields | {"id"}] + assert sent["rows"][1]["variant"] == "을" and sent["rows"][1]["pum_table_id"] == "" + # ④ 고친 칸만 · 새 줄은 줄째 · 안 고친 줄은 빈 목록 · 서버 값(detail)은 안 건드림 + assert got["changed"] == ["amount"] + assert got["addedRow"] == "added" + assert got["untouched"] == [] + assert got["freshUntouched"] == 0.5 + # ⑤ 갈래는 처음 나온 차례(갑 → 을) · 새 줄은 제 갈래로 + assert got["groups"] == [ + {"variant": "갑", "indexes": [0, 3]}, + {"variant": "을", "indexes": [1, 2]}, + ] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index ccc0850a..fd7cab5e 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -142,68 +142,105 @@ export const ui_locales_b3 = { Z01_MasterData_Title: ["마스터 데이터", "Master Data"], Z01_MasterData_Tree: ["마스터 트리", "Master tree"], /* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */ - Z01_MasterData_List_Kind: ["표", "Table"], - Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], Z01_MasterData_Edit_Can: [ - "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)", - "Editable — click, type, Enter to save (Esc cancels)", + "고칠 수 있는 칸 — 눌러 고치고 Enter (Esc 취소) · [저장]을 눌러야 반영", + "Editable — click, type, Enter (Esc cancels) · applied on [Save]", ], Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"], Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"], - Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], - Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], - Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], Z01_MasterData_Edit_Invalid: [ "「{value}」 를 수로 못 읽음", "Cannot read 「{value}」 as a number", ], - Z01_MasterData_Edit_NotSaved: ["⚠ 저장 안 됨 — {value}", "⚠ Not saved — {value}"], - Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], - Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], + Z01_MasterData_Edit_NotSaved: ["⚠ 안 들어감 — {value}", "⚠ Not applied — {value}"], Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], Z01_MasterData_Formula: ["산출근거", "Basis"], - Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"], - Z01_MasterData_State_SourceChanged: [ - "⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}", - "⚠ Source changed after edit — edited value still used · new source {value}", + /* --- Z01 고치기 — [저장] 한 번에 보냄(자동저장 금지 · PLAN 1-0) --- */ + Z01_MasterData_Save: ["저장", "Save"], + Z01_MasterData_Discard: ["고친 것 버리기", "Discard edits"], + Z01_MasterData_AddRow: ["줄 추가", "Add row"], + Z01_MasterData_DeleteRow: ["줄 삭제", "Delete row"], + Z01_MasterData_UndeleteRow: ["삭제 취소", "Undo delete"], + Z01_MasterData_Reset: ["초기값으로", "Reset to initial"], + Z01_MasterData_ResetConfirm: [ + "고른 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)", + "Reset the picked row to its initial value — saved at once (admin-added rows are removed)", ], - Z01_MasterData_State_MeaningChanged: [ - "⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)", - "⛔ Edited — the row now means something else (same code, different item)", + Z01_MasterData_ResetBlocked: [ + "저장 안 한 고침이 있음 — 먼저 [저장]하거나 [고친 것 버리기]", + "Unsaved edits — save or discard them first", ], - Z01_MasterData_State_MeaningMissing: [ - "⛔ 옛 이름·지금 이름을 서버가 안 줌 — 버그", - "⛔ Server sent no before/after row names — bug", + Z01_MasterData_ResetDone: ["초기값으로 되돌렸음", "Reset to initial value"], + Z01_MasterData_Saved: ["저장했음", "Saved"], + Z01_MasterData_Unsaved: ["저장 안 한 고침이 있음", "There are unsaved edits"], + Z01_MasterData_Draft_Edits: ["고친 칸", "Edited cells"], + Z01_MasterData_Draft_Added: ["추가 줄", "Added rows"], + Z01_MasterData_Draft_Deleted: ["삭제 줄", "Deleted rows"], + Z01_MasterData_Draft_Pending: ["[저장]해야 반영됨", "applied on [Save]"], + Z01_MasterData_Draft_None: ["고친 것 없음", "No edits"], + Z01_MasterData_Draft_Stale: [ + "⚠ 다른 곳에서 먼저 저장됨 — 이 초안은 저장 못 함 · [고친 것 버리기]로 새로 읽을 것", + "⚠ Saved elsewhere first — this draft cannot be saved · discard to reload", ], - Z01_MasterData_State_Was: ["고칠 때: {value}", "when edited: {value}"], - Z01_MasterData_State_Now: ["지금: {value}", "now: {value}"], - Z01_MasterData_Edit_DropOnMeaning: [ - "고친 값 버리고 지금 줄 값 {value} 쓰기", - "Drop the edit and use the current row value {value}", + Z01_MasterData_Cell_Draft: ["저장 안 한 고침 — {value}", "Unsaved edit — {value}"], + Z01_MasterData_Cell_Changed: ["초기값과 다름(저장된 고침)", "Differs from the initial value"], + Z01_MasterData_Row_Added: ["관리자가 추가한 줄", "Row added by an admin"], + Z01_MasterData_Row_New: ["새 줄 — 저장 전", "New row — not saved yet"], + /* --- Z01 공종 편집 (PLAN 1-4) --- */ + Z01_WorkItem_Title: ["공종 편집", "Work items"], + Z01_WorkItem_Axis_Forest: ["산림 공종", "Forest"], + Z01_WorkItem_Axis_Const: ["건설 공종", "Construction"], + Z01_WorkItem_Pick: ["왼쪽 목록에서 공종을 고를 것", "Pick a work item from the list"], + Z01_WorkItem_Status: ["상태", "Status"], + Z01_WorkItem_Status_All: ["전부", "All"], + Z01_WorkItem_Status_NeedsInput: ["입력 필요", "Needs input"], + Z01_WorkItem_Status_Partial: ["일부 못 맞춤", "Partly unmatched"], + Z01_WorkItem_Status_AutoOk: ["자동 완료", "Auto OK"], + Z01_WorkItem_Status_NotItem: ["공종 아님", "Not a work item"], + Z01_WorkItem_Status_Reviewed: ["검수 완료", "Reviewed"], + Z01_WorkItem_Review: ["검수 완료", "Mark reviewed"], + Z01_WorkItem_Dirty: [ + "저장 안 한 고침 있음 — [저장]해야 반영됨", + "Unsaved edits — applied on [Save]", ], - Z01_MasterData_Edit_MeaningConfirm: [ - "줄의 뜻이 바뀌었음 — 고친 값을 버리면 지금 줄(다른 물건일 수 있음) 값을 씀. 버릴까?", - "This row now means something else. Dropping the edit uses the current row's value. Continue?", + Z01_WorkItem_ResetConfirm: [ + "이 공종의 머리·구성 줄을 초기값으로 되돌림 — 바로 저장됨(관리자가 추가한 줄은 지워짐)", + "Reset this work item's header and rows — saved at once (admin-added rows are removed)", ], - Z01_MasterData_Edit_DropConfirm: ["고친 값 버리기", "Drop the edit"], - Z01_MasterData_List_MeaningChanged: ["⛔ 뜻 바뀜", "⛔ Meaning changed"], - Z01_MasterData_State_Orphan: [ - "⚠ 주인 없음 — 갱신으로 그 줄이 사라짐", - "⚠ Orphan — the row disappeared on update", - ], - Z01_MasterData_State_Missing: [ - "⚠ 서버가 판정(state)을 안 줌 — 버그", - "⚠ Server sent no state — bug", - ], - Z01_MasterData_List_State: ["상태", "State"], - Z01_MasterData_List_Row: ["줄", "Row"], - Z01_MasterData_List_Column: ["칸", "Column"], - Z01_MasterData_List_Value: ["고친 값", "Edited value"], - Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"], - Z01_MasterData_List_Current: ["지금 원본", "Current source"], - Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"], - Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"], - Z01_MasterData_List_Edited: ["고쳐짐", "Edited"], + Z01_WorkItem_Key: ["열쇠", "Key"], + Z01_WorkItem_Number: ["번호", "Number"], + Z01_WorkItem_Parent: ["상위 공종", "Parent"], + Z01_WorkItem_Edition: ["판", "Edition"], + Z01_WorkItem_Name: ["이름", "Name"], + Z01_WorkItem_BasisQuantity: ["기준 수량", "Basis quantity"], + Z01_WorkItem_BasisUnit: ["기준 단위", "Basis unit"], + Z01_WorkItem_Rows: ["구성 줄", "Resource rows"], + Z01_WorkItem_NoVariant: ["갈래 없음", "No variant"], + Z01_WorkItem_VariantName: ["새 갈래 이름", "New variant name"], + Z01_WorkItem_AddVariant: ["갈래 추가", "Add variant"], + Z01_WorkItem_Col_Kind: ["종류", "Kind"], + Z01_WorkItem_Col_Resource: ["자원", "Resource"], + Z01_WorkItem_Col_Spec: ["규격", "Spec"], + Z01_WorkItem_Col_Key: ["기초단가 열쇠", "Base price key"], + Z01_WorkItem_Col_Amount: ["수량", "Amount"], + Z01_WorkItem_Col_Unit: ["단위", "Unit"], + Z01_WorkItem_Col_Alt: ["대체 수량", "Alternative"], + Z01_WorkItem_Col_Ratio: ["묶음 비율 %", "Group ratio %"], + Z01_WorkItem_Col_Raw: ["원문 글자", "Source text"], + Z01_WorkItem_Col_Table: ["품셈 표", "Table"], + Z01_WorkItem_Col_Delete: ["", ""], + Z01_WorkItem_Kind_Labor: ["노무", "Labor"], + Z01_WorkItem_Kind_Machine: ["기계", "Machine"], + Z01_WorkItem_Kind_Material: ["자재", "Material"], + Z01_WorkItem_Kind_Etc: ["기타", "Other"], + Z01_WorkItem_Unmatched: ["⚠ 못 맞춤", "⚠ Unmatched"], + Z01_WorkItem_Find: ["찾기", "Find"], + Z01_WorkItem_FindTitle: ["자원 찾기 — 기초단가에서 골라 이음", "Find resource in base prices"], + Z01_WorkItem_FindClose: ["닫기", "Close"], + Z01_WorkItem_FindNone: ["찾은 줄 없음", "Nothing found"], + Z01_WorkItem_Source: ["품셈 원문 (읽기 전용)", "Standard source (read-only)"], + Z01_WorkItem_NoSource: ["원문 표 없음", "No source table"], + Z01_WorkItem_Notes: ["[주]", "[Notes]"], Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"], Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"], Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"],