feat(Z01): 기초단가 표 [저장] 방식 교체 · 공종 편집 화면 신설 — PLAN 1-4

- 기초단가 표: 칸 나갈 때 즉시 저장 없앰 · 고친 칸·새 줄·지울 줄은 초안(sessionStorage z01-draft:base:{kind})에 쌓고 [저장] 한 번에 보냄 · [고친 것 버리기]·[줄 추가]·[줄 삭제]·[초기값으로] · 409 면 초안 지키고 알림 · 서버가 revision 을 줄 때만 고치기 단추 섬
- 옛 덮개 길(칸 단위 PUT · 고친 것 모아 보기 · 덮개 판정 표시)과 그 사전 글자 뺌 — 계약 1-0 에서 서버 쪽 삭제
- 공종 편집(Z01_MasterData_UI_WorkItem.ts · _WorkItem_Rows.ts): 왼쪽 목록(상태별 개수·검색) / 머리 / 구성 줄 표(갈래별 · 자원 찾기 = 기초단가 검색) / 품셈 원문 표+[주] 읽기 전용 / [저장]·[초기값으로]·[검수 완료] · 초안 열쇠 z01-draft:work_item:{key}
- 공종 API(sub_laptop_3 e1c71c3e) 모양에 맞춤 — 줄은 id + 계약 칸 열하나만 보냄(extra=forbid) · 저장·초기값 응답(새 GET 모양)으로 바로 다시 그림 · 저장된 고침(changed_columns·is_added) 표시
- 초안 순수 함수 Z01_MasterData_UI_Draft.ts + 시험 test_z01_master_draft.py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145cr1DMC5Ft8fouQSXtp69
This commit is contained in:
2026-09-18 23:55:45 +09:00
co-authored by Claude Opus 5
parent 9629430aff
commit feab848e69
10 changed files with 2012 additions and 309 deletions
+159 -37
View File
@@ -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<string, unknown>[];
total: number;
/** 판 번호 — [저장] 때 그대로 돌려보내 낡은 판이면 409 */
revision?: number;
}
export interface RowsQuery {
@@ -65,17 +65,34 @@ export function fetchBaseKinds(): Promise<ServedKind[]> {
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<T>(path: string, init: RequestInit = {}): Promise<T> {
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 = <T>(method: string, path: string, body: unknown): Promise<T> =>
request<T>(path, { method, body: JSON.stringify(body) });
const pageParams = (query: RowsQuery): Record<string, string> => ({
page: String(query.page),
size: String(query.size),
@@ -93,37 +110,142 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<
return request<MasterRows>(`/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<string, unknown> }[];
deleted: string[];
}
/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */
export function fetchOverrides(
query: Pick<RowsQuery, "page" | "size">,
): 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<unknown> {
return send("PUT", `/master-data/base-prices/${kind}`, body);
}
/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */
export function saveBasePrice(
kind: BasePriceKind,
rowId: string,
values: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, {
method: "PUT",
body: JSON.stringify({ values }),
/** 고른 줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨. */
export function resetBasePrices(kind: BasePriceKind, rowKeys: string[]): Promise<unknown> {
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<Record<WorkItemStatus, number>>;
}
export interface WorkItemListQuery {
axis: WorkItemAxis;
status: WorkItemStatus | "";
q: string;
page: number;
size: number;
}
export function fetchWorkItems(query: WorkItemListQuery): Promise<WorkItemList> {
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<WorkItemList>(`/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<WorkItemDetail> {
return request<WorkItemDetail>(workItemPath(key));
}
/** 응답은 새 GET 모양 그대로(다시 안 불러도 됨) · 모양 오류 422 · 낡은 판 409. */
export function saveWorkItem(key: string, body: WorkItemSave): Promise<WorkItemDetail> {
return send("PUT", workItemPath(key), body);
}
/** 머리·줄을 초기값(seed)으로 — 관리자가 넣은 줄은 지워짐. 바로 저장됨 · 응답은 새 GET 모양. */
export function resetWorkItem(key: string): Promise<WorkItemDetail> {
return send("POST", `${workItemPath(key)}/reset`, {});
}
export async function fetchMasterTree(): Promise<MasterGroup[]> {
+263
View File
@@ -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<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;
}
+25 -4
View File
@@ -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<void> {
@@ -41,12 +42,32 @@ export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
}
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),
});
+286 -153
View File
@@ -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<string, unknown>;
type LocaleKey = Parameters<typeof L>[0];
export interface RowsSource {
title: string;
key: string;
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */
/** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표 */
searchable?: boolean;
load: (query: RowsQuery) => Promise<MasterRows>;
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
save?: (rowId: string, values: Row) => Promise<Row>;
/** 있으면 기초단가 — 초안에 쌓고 [저장] 한 번에 보냄 · [초기값으로]는 바로 저장 */
edit?: {
scope: string;
save: (body: BaseSave) => Promise<unknown>;
reset: (rowKeys: string[]) => Promise<unknown>;
};
}
export interface RowsView {
@@ -58,36 +72,15 @@ export interface RowsView {
}
/** 사전 문구의 {value} 자리에 값을 넣음. */
function fill(key: Parameters<typeof L>[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<void> {
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<void> {
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<void> {
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<void> {
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();
}
+27 -68
View File
@@ -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<OverrideState, Parameters<typeof L>[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<string, string>,
): Promise<MasterRows> {
const { items, total } = await fetchOverrides(query);
const column = (key: string, label: Parameters<typeof L>[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",
+201
View File
@@ -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);
}
@@ -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<typeof L>[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<WorkItemStatus, LocaleKey> = {
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<void> {
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<void> {
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<WorkItemBody>(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<void> {
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<void> {
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 = <K extends keyof WorkItemHead>(
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 };
}
@@ -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<typeof L>[0];
const KIND_LABEL: Record<ResourceKind, LocaleKey> = {
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<ResourceKind, "etc">;
const FINDABLE: Record<Findable, { name: string; spec?: string }> = {
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<string, unknown>)[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<void> {
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);
}
+184
View File
@@ -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]},
]
+84 -47
View File
@@ -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: [
"⛔ 고쳐짐 — 줄의 뜻이 바뀜(같은 번호에 다른 물건)",
"⛔ Editedthe row now means something else (same code, different item)",
Z01_MasterData_ResetBlocked: [
"저장 안 한 고침이 있음 — 먼저 [저장]하거나 [고친 것 버리기]",
"Unsaved editssave 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"],