refactor(M02): 옛 일위대가 조합 상세 Labor_Edit.ts 지움 — 그것만 쓰던 조합 길 함수(newCombo · editCombo · deleteCombo · fetchComboInputs · fetchLibLogic · fetchLibAuto) · 죽은 글 17 · CSS(미리 보기 · 조합 목록 · flash)도

- 쓰는 곳 0 은 grep 으로 확인한 것만 · Labor_Pick · View · Detail · Rows · Input · Diagram 은 남김
- 시험: test_ui_loading 의 Labor_Edit 줄 → 실무문서 상세 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-04 20:47:42 +09:00
co-authored by Claude Opus 5.5
parent b0fdc84931
commit 1cce06780e
6 changed files with 3 additions and 508 deletions
@@ -1,27 +1,13 @@
/* 일위대가 조합 화면 — 표는 칸 폭을 채움 · 미리 보기 금액은 오른쪽 맞춤 ·
/* 일위대가 화면 — 표는 칸 폭을 채움 ·
흐린 안내 글은 보조 글색(뮤트 글색은 밝은 테마에서 대비 3.1 로 모자람) */
.m01-combo .m01-master__grid {
width: 100%;
}
.m01-combo [data-combo="preview"] th:not(:first-child),
.m01-combo [data-combo="preview"] td:not(:first-child) {
text-align: right;
}
.m01-combo .m01-logic__muted {
color: var(--color-text-secondary);
}
/* 조합 목록 — 공용 표 모양(줄 선 · 머리 줄) · 줄을 누르면 상세(PLAN 30-5) */
.m02-labor__list tbody tr {
cursor: pointer;
}
.m02-labor__list tbody tr:hover {
background: var(--color-mist-violet);
}
/* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */
.m02-labrow {
width: 100%;
@@ -8,7 +8,6 @@
import { API_BASE_URL } from "@config/config_frontend";
import {
ApiError,
type LogicOne,
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
@@ -126,52 +125,12 @@ export async function fetchUsableCombos(
.flatMap((group) => group.줄.map((c) => ({ ...c, layer: group.layer })));
}
/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */
export interface ComboQuantity {
키: string;
이름: string;
단위: string;
로직: string[];
배?: number;
}
/** 조합 알약(어느 층이든 · 옛 M01 `/combo/{키}/inputs`) */
export const fetchComboInputs = (
key: string,
): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> =>
request(`/combos/key/${enc(key)}/inputs`);
/** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */
export const fetchCombo = (key: string): Promise<ComboOne & LaborScope> =>
request<Omit<ComboOne, "file" | "version"> & LaborScope & { 판: string }>(
`/combos/key/${enc(key)}`,
).then((d) => ({ ...d, file: "일위대가조합.json", version: d.판 }));
export const newCombo = (
combo: Combo,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<{ version: string; key: string }> =>
request<{ 키: string; 판: string }>(at(scope), "POST", { combo }).then((d) => ({
version: d.판,
key: d.키,
}));
export const editCombo = (
key: string,
version: string,
combo: Combo,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<{ version: string }> =>
request<{ 판: string }>(at(scope, `/${enc(key)}`), "PUT", { combo, 판: version }).then((d) => ({
version: d.판,
}));
export const deleteCombo = (
key: string,
version: string,
scope: LaborScope = SYSTEM_SCOPE,
): Promise<unknown> => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE");
/* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */
export const fetchLibLogics = (): Promise<LogicSummary[]> =>
request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics);
@@ -179,14 +138,6 @@ export const fetchLibLogics = (): Promise<LogicSummary[]> =>
export const fetchLibLogicSubs = (): Promise<SubBrief[]> =>
request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs);
export const fetchLibLogic = (key: string): Promise<LogicOne> =>
request(`/logic-lib/logic?key=${enc(key)}`);
export const fetchLibAuto = (key: string): Promise<Record<string, unknown>> =>
request<{ 값?: Record<string, unknown> }>(`/logic-lib/auto?key=${enc(key)}`)
.then((d) => d.값 ?? {})
.catch(() => ({}));
/* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */
/** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */
@@ -292,19 +292,6 @@
color: var(--color-text-muted);
}
/* 다이어그램에서 눌러 찾아온 미리 보기 입력 칸 */
.m02-labor__flash {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
animation: m02-labor-flash 0.4s ease-in-out 3;
}
@keyframes m02-labor-flash {
50% {
outline-color: transparent;
}
}
.m02-labor__dg > summary {
cursor: pointer;
font-weight: var(--font-weight-bold);
@@ -1,404 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Edit.ts
* 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표
* (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 ·
* 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감.
* 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음.
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import {
deleteCombo,
editCombo,
fetchCombo,
fetchLibLogics,
newCombo,
type Combo,
type ComboSummary,
type LaborScope,
} from "./M02_MasterTemplete_Labor_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
import "./M02_MasterTemplete_Labor.css";
const blankCombo = (path: string[] = []): Combo => ({
키: "",
이름: "",
구분: path[0] ?? null,
상세구분: path[1] ?? null,
단위: null,
담은로직: [],
출처: "자체",
소유: "현장",
본뜬키: null,
비고: null,
});
/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */
export function reason(error: unknown): string {
if (error instanceof ApiError && typeof error.detail === "object" && error.detail) {
const d = error.detail as { errors?: string[]; stale?: string[] };
// 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림
if (d.errors)
return d.errors
.map((x) => x.replace(/^(\S+\.json · )?(U[AM]\d{6} |U[CP]\d+-\d{6} )?/, ""))
.join(" / ");
if (d.stale) return tc("Stale");
}
return error instanceof Error ? error.message : tc("Load_Failed");
}
export interface LaborEditOptions {
scope: LaborScope;
canEdit: boolean;
/** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */
onSaved: (key: string | null) => Promise<void>;
/** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */
onDirty: (dirty: boolean) => void;
}
export interface LaborEditor {
root: HTMLElement;
/** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */
setList: (items: ComboSummary[]) => void;
open: (key: string) => Promise<void>;
/** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */
startNew: (path: string[]) => void;
showList: () => void;
/** 「새 조합」 — 제목 줄 오른쪽 끝에 붙임(읽기 층 = null) */
newButton: HTMLElement | null;
isDirty: () => boolean;
/** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */
current: () => string | null;
/** 라이브러리 찾기 칸 글 — 목록을 같은 규칙(`matchText`)으로 거름 */
setQuery: (text: string) => void;
/** 조합 목록을 새로 받는 동안 칸 안 「불러오는 중」(목록 화면일 때만 · 돌려받은 함수 = 끝) */
loading: () => () => void;
}
export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
const edit = opt.canEdit;
let items: ComboSummary[] = [];
let logics = new Map<string, LogicSummary>();
/** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */
let nav = 0;
/** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */
let current: { combo: Combo; version: string; saved: string } | null = null;
let q = "";
const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" });
// 찾기 칸은 라이브러리 하나(상세 위 · 나무와 같은 글 · PLAN 29-6) — `setQuery` 로 받음
const setQuery = (text: string): void => {
q = text;
if (!current) drawList();
};
const logicsReady = fetchLibLogics()
.then((all) => {
logics = new Map(all.map((x) => [x.키, x]));
})
.catch((error) =>
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
);
const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved;
const markDirty = (): void => opt.onDirty(dirty());
/* --- 목록 --- */
const drawList = (): void => {
nav++;
current = null;
opt.onDirty(false);
const shown = items.filter((x) => matchText([x.키, x.이름, x.구분, x.상세구분, x.단위], q));
const heads = [
tc("Head_Key"),
tc("Head_Name"),
tc("Sub"),
tc("Detail"),
tc("Head_Unit"),
tc("Head_Count"),
tc("Head_Origin"),
];
const rows = shown.map((x) => {
const tr = el("tr", {
className: "m01-master__table-row",
attrs: { "data-combo-key": x.키 },
children: [
x.키,
x.이름,
x.구분 ?? "",
x.상세구분 ?? "",
x.단위 ?? "",
String(x.count),
x.본뜬키 ?? tc("Origin_Self"),
].map((v) => el("td", { text: v, attrs: { title: v } })),
});
tr.addEventListener("click", () => void open(x.키));
return tr;
});
// 「새 조합」은 제목 줄 오른쪽 끝(`newButton` · PLAN 30-5) · 목록 = 공용 표 모양(줄 선 · 머리 줄)
view.replaceChildren(
...(edit ? [] : [el("p", { className: "m01-logic__muted", text: tc("ReadOnly") })]),
shown.length
? el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-labor__list",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
})
: el("p", { className: "m01-logic__muted", text: tc(items.length ? "NoMatch" : "Empty") }),
);
};
const open = async (key: string): Promise<void> => {
const my = ++nav;
const stop = showLoading(view); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
try {
const one = await fetchCombo(key);
await logicsReady;
if (my !== nav) return;
stop();
draw(one.combo, one.version);
} catch (error) {
if (my !== nav) return;
stop();
view.replaceChildren(loadingError(reason(error), () => void open(key)));
} finally {
stop();
}
};
/** 조합 목록을 새로 받는 동안 — 목록 화면일 때만 칸 안 표시(돌려받은 함수 = 끝) */
const loading = (): (() => void) => (current ? () => {} : showLoading(view));
const startNew = (path: string[]): void => draw(blankCombo(path), "");
/* --- 상세 --- */
const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => {
nav++;
current = { combo, version, saved };
opt.onDirty(dirty());
const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => {
const f = createInputField({ type: "text", label });
f.input.value = combo[name] ?? "";
f.input.readOnly = !edit;
f.input.setAttribute("data-combo-field", name);
if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음
f.input.addEventListener("input", () => {
const v = f.input.value;
if (name === "이름") combo.이름 = v;
else combo[name] = v || null;
markDirty();
});
return f.root;
};
const table = el("div", { className: "m01-master__grid-wrap" });
const drawTable = (): void => {
const move = (i: number, d: number): void => {
const j = i + d;
if (j < 0 || j >= combo.담은로직.length) return;
[combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]];
drawTable();
markDirty();
};
const trs = combo.담은로직.map((row, i) => {
const info = logics.get(row.로직);
const memo = createInputField({ type: "text" });
memo.input.value = row.메모 ?? "";
memo.input.readOnly = !edit;
memo.input.addEventListener("input", () => {
row.메모 = memo.input.value || null;
markDirty();
});
const small = (label: string, run: () => void): HTMLElement =>
createButton({ label, variant: "ghost", size: "sm", onClick: run });
const btns = el("div", {
className: "m01-logic__chips",
children: edit
? [
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
small(tc("Remove"), () => {
combo.담은로직.splice(i, 1);
drawTable();
markDirty();
}),
]
: [],
});
return el("tr", {
attrs: { "data-combo-logic": row.로직 },
children: [
el("td", { text: String(i + 1) }),
el("td", { text: row.로직 }),
el("td", { text: info?.이름 ?? row.로직 }),
el("td", { text: info?.결과단위 ?? "" }),
el("td", { children: [memo.root] }),
el("td", { children: [btns] }),
],
});
});
const heads = [
tc("Col_Order"),
tc("Col_Key"),
tc("Col_Name"),
tc("Col_Unit"),
tc("Col_Memo"),
tc("Col_Move"),
];
table.replaceChildren(
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: trs }),
],
}),
...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]),
);
};
const add = createButton({
label: tc("AddLogic"),
variant: "filled",
onClick: () =>
openLogicPicker((x) => {
if (!logics.has(x.키)) logics.set(x.키, x);
combo.담은로직.push({ 로직: x.키, 메모: null });
drawTable();
markDirty();
}),
});
const save = createButton({
label: tc("Save"),
variant: "filled",
onClick: async () => {
if (!combo.이름.trim()) return showToast(tc("NeedName"), "error");
save.disabled = true; // 연달아 눌러도 한 번만
try {
let key = combo.키;
if (key) await editCombo(key, version, combo, opt.scope);
else key = (await newCombo(combo, opt.scope)).key;
showToast(tc("Saved"), "success");
current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음
opt.onDirty(false);
await opt.onSaved(key);
await open(key);
} catch (error) {
showToast(reason(error), "error");
} finally {
save.disabled = false;
}
},
});
const remove = createButton({
label: tc("Delete"),
variant: "ghost",
onClick: async () => {
if (
!combo.키 ||
!(await showConfirmDialog(`${combo.키} ${combo.이름} — ${tc("DeleteAsk")}`))
)
return;
try {
await deleteCombo(combo.키, version, opt.scope);
showToast(tc("Deleted"), "success");
current = null;
await opt.onSaved(null);
drawList();
} catch (error) {
showToast(reason(error), "error");
}
},
});
const back = createButton({
label: "←",
variant: "ghost",
onClick: async () => {
if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
drawList();
},
});
const origin = createInputField({ type: "text", label: tc("Head_Origin") });
origin.input.value = combo.본뜬키 ?? tc("Origin_Self");
origin.input.readOnly = true;
origin.input.setAttribute("data-combo-origin", combo.본뜬키 ?? "");
view.replaceChildren(
el("div", {
className: "m01-logic__section-head",
children: [
back,
el("h3", { text: combo.키 || tc("New") }),
...(edit ? [remove, save] : []),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01lab__info",
children: [
field(tc("Head_Name"), "이름"),
field(tc("Sub"), "구분"),
field(tc("Detail"), "상세구분"),
field(tc("Head_Unit"), "단위"),
field(tc("Head_Note"), "비고"),
origin.root,
],
}),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])],
}),
table,
],
}),
);
drawTable();
};
view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") }));
return {
root: view,
setList: (next) => {
items = next;
if (!current) drawList();
},
open,
startNew,
showList: drawList,
newButton: edit
? createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })
: null,
isDirty: dirty,
current: () => current?.combo.키 ?? null,
setQuery,
loading,
};
}
@@ -7,9 +7,6 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 조합", "Unit-price combination"],
Library: ["일위대가 라이브러리", "Unit-price library"],
AddCombo: ["조합 추가", "Add combination"],
ComboName: ["조합 이름", "Combination name"],
ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"],
Sub: ["구분", "Category"],
Detail: ["상세구분", "Detail"],
@@ -18,36 +15,19 @@ const TEXT = {
New: ["새 조합", "New combination"],
Delete: ["지우기", "Delete"],
Save: ["저장", "Save"],
DeleteAsk: ["이 조합을 지울까요?", "Delete this combination?"],
Saved: ["저장함", "Saved"],
Deleted: ["지움", "Deleted"],
Stale: ["그 사이 조합 파일이 바뀜 — 다시 열어 주세요", "The file changed — reopen it"],
Loading: ["불러오는 중…", "Loading…"],
LeaveAsk: [
"저장 안 한 고침이 있음 — 로직 상세로 가면 이 화면에서는 그대로 두고 옮겨 감. 갈까요?",
"Unsaved edits — go to the logic anyway?",
],
BackAsk: [
"저장 안 한 고침이 있음 — 버리고 목록으로 갈까요?",
"Unsaved edits — discard and go back to the list?",
],
Search: ["이름 · 키로 찾기", "Search by name or key"],
NoMatch: ["찾은 조합 없음 — 거름 · 찾기 글을 바꿔 봄", "No match — change the filter or search"],
NeedName: ["이름을 적어야 저장됨", "A name is required"],
Load_Failed: ["불러오지 못함", "Load failed"],
Head_Key: ["키", "Key"],
Head_Name: ["이름", "Name"],
Head_Unit: ["단위", "Unit"],
Head_Note: ["비고", "Note"],
Head_Count: ["로직 수", "Logics"],
Head_Origin: ["본뜬키", "Copied from"],
Origin_Self: ["원본", "Original"],
Logics_Title: ["담은 로직", "Logics in it"],
Col_Order: ["차례", "No."],
Col_Key: ["로직 키", "Logic key"],
Col_Name: ["이름", "Name"],
Col_Unit: ["결과 단위", "Result unit"],
Col_Memo: ["메모", "Memo"],
Col_Move: ["순서", "Move"],
Up: ["올리기", "Up"],
Down: ["내리기", "Down"],
@@ -70,7 +50,6 @@ const TEXT = {
Prev_Expense: ["경비", "Expense"],
Prev_Sum: ["합계", "Total"],
// 일위대가 상세(39-3) — 구조물 열마다 하나
Labor_Title: ["일위대가", "Unit price"],
Labor_Unit: ["단위", "Unit"],
Labor_Origin: ["본뜬 곳", "Copied from"],
Labor_Mats: ["자재목록", "Materials"],
@@ -81,7 +60,6 @@ const TEXT = {
Labor_Applied: ["반영", "Applied"],
Labor_BasePrice: ["기준 단가", "Base price"],
Labor_AppliedSum: ["반영 금액", "Applied amount"],
Labor_Calc_Wait: ["계산 중…", "Calculating…"],
Labor_Calc_Fail: ["계산 못 함", "Calculation failed"],
Labor_Empty: ["빈 값 {n}", "{n} empty"],
Labor_Lost: [
@@ -106,7 +84,6 @@ const TEXT = {
In_Ok: ["확인", "OK"],
In_Cancel: ["취소", "Cancel"],
In_Close: ["닫기", "Close"],
In_Loading: ["입력 목록 받는 중…", "Loading inputs…"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
+2 -4
View File
@@ -77,12 +77,10 @@ def test_실패_글과_다시_불러오기_M02_자리():
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "const stop = showLoading(host);" in main
assert "loadingError(why, () => void open(next))" in main
edit = _text(M02 / "M02_MasterTemplete_Labor_Edit.ts")
assert "loadingError(reason(error), () => void open(key))" in edit
assert "(current ? () => {} : showLoading(view))" in edit
# 좌측 일위대가 조합 나무(Labor.ts)는 39-2 에 지움 — 그 목록 자리는 실무문서 화면(같은 꼴)
# 옛 조합 나무 · 상세(Labor.ts · Labor_Edit.ts)는 39 에 지움 — 그 자리는 실무문서(같은 꼴)
practice = _text(M02 / "M02_MasterTemplete_Practice.ts")
assert "const stop = showLoading(view);" in practice
assert "loadingError(why(error), () => void drawList())" in practice
assert "loadingError(why(error), () => void drawOne(key))" in practice
assert "cards.map((c) => c.shell.loading())" in _text(M02 / "M02_MasterTemplete_ItemCards.ts")
assert "loading: () => showLoading(host)," in _text(M02 / "M02_MasterTemplete_CardShell.ts")