refactor(840-1): 상세 보기에만 있던 기능 넷 판정 · 죽은 코드 걷음 — 수식 보기가 대신하는 것 지움
- 판정 = 자체 로직 편집 · 시험 값 고침 · M02 줄 덮음 · 비목 박스 소계 모두 수식 보기로 됨(살릴 것 없음) - M01 상세: 숨은 단가산출 상세 덩이 · 자체 로직 시트 편집기 · 시험 고침 · 셈 구동(createDrive) · 보기 이름표(View.ts) · data-view CSS 걷음 - M02 불러오기 모달 머리 [항목 추가] · [율 줄 추가] 단추와 그 창(Rate.ts · openSangeunLinePicker) 걷음 - 글 정리 = Info_Own 새 뜻 · 죽은 글 키 · No_Key · 항목 추가 글 걷음 - 낡은 시험 고침 = 722(시험 고침 · 덮기 셈) · 799-2 · 804-2 · 804-6 · 804-7 · 806-1 · 717-2b · 717-4 · 721 · 728-2 — 걷은 것이 없음 확인으로 - 남김 = M02 수식 문서 없는 줄의 단가산출 상세 몸(cover · buildHo) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,10 +94,6 @@ const TEXT = {
|
||||
"저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저",
|
||||
"Save or discard edits first",
|
||||
],
|
||||
No_Key: [
|
||||
"저장한 단가산출만 수식 문서를 엶 — [단가산출 상세] 탭",
|
||||
"Saved items only — use the detail tab",
|
||||
],
|
||||
Read_Only: ["읽기만", "Read only"],
|
||||
Tools: ["수식 도구", "Equation tools"],
|
||||
Tool_Frac: ["분수", "Fraction"],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab.ts
|
||||
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) · 단가산출 상세 / 오른쪽 원문
|
||||
* 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run`
|
||||
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) / 오른쪽 원문
|
||||
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
|
||||
*
|
||||
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
|
||||
@@ -29,18 +28,14 @@ import {
|
||||
fetchLogicUsers,
|
||||
removeLogic,
|
||||
saveFiles,
|
||||
type CalcLine,
|
||||
type TextAnswer,
|
||||
type ElementBrief,
|
||||
type LogicRow,
|
||||
type LogicSummary,
|
||||
type SaveFile,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { createDrive } from "./M01_MasterData_UI_Logic_Drive";
|
||||
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
|
||||
import type { SideHandle } from "./M01_MasterData_UI_Side";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail";
|
||||
import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail";
|
||||
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
@@ -67,19 +62,11 @@ interface Opened extends Draft {
|
||||
id: string;
|
||||
row: LogicRow | null;
|
||||
original: string | null;
|
||||
prices: Record<string, ElementBrief | null>;
|
||||
reasons: string[];
|
||||
lines: CalcLine[] | null;
|
||||
text: TextAnswer | null;
|
||||
/** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */
|
||||
lastText?: TextAnswer;
|
||||
ownHost: HTMLElement;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
const OWN_FILE = "로직_자체.json";
|
||||
/** 시험 계산에 넘길 줄 — 시험 고침(722-2) 덮은 사본 */
|
||||
const calcRow = (row: LogicRow | null): LogicRow | null => trial.apply(row);
|
||||
|
||||
const cacheKey = "m01_logic_drafts_lab";
|
||||
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
|
||||
@@ -105,12 +92,9 @@ export async function mountM01LogicLab(
|
||||
): Promise<LogicHandle> {
|
||||
let drafts = loadDrafts();
|
||||
let opened: Opened | null = null;
|
||||
let calcInputs = "";
|
||||
const admin = await fetchSessionUser()
|
||||
.then((u) => u?.role === "SYSTEM_ADMIN")
|
||||
.catch(() => false);
|
||||
let recalc: number | undefined;
|
||||
const drive = createDrive();
|
||||
// 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts
|
||||
const stack = createStackScreen({
|
||||
base: "/m01/logic/doc",
|
||||
@@ -122,7 +106,6 @@ export async function mountM01LogicLab(
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
|
||||
const panes = buildLogicSide(editor);
|
||||
editor.dataset.view = "stack"; // 보기 = 수식 하나(836-3) — 「단가산출 상세」 덩이는 CSS 로 숨김
|
||||
const errors = el("div", {
|
||||
className: "ui-banner ui-banner--danger m01-logic__reasons",
|
||||
attrs: { hidden: "" },
|
||||
@@ -181,13 +164,6 @@ export async function mountM01LogicLab(
|
||||
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
|
||||
else drafts[opened.id] = pick(opened, clone(opened.row));
|
||||
persist();
|
||||
const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []);
|
||||
if (inputs !== calcInputs) drawCalc();
|
||||
if (isOwnLogic(opened.row)) {
|
||||
// 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림
|
||||
window.clearTimeout(recalc);
|
||||
recalc = window.setTimeout(() => void drive.run(), 700);
|
||||
}
|
||||
};
|
||||
|
||||
const pick = (o: Opened, row: LogicRow | null): Draft => ({
|
||||
@@ -198,32 +174,6 @@ export async function mountM01LogicLab(
|
||||
row,
|
||||
});
|
||||
|
||||
/** 구동을 지금 로직으로 (다시) 시작 — 읽는 식 미리보기 · 첫 셈 */
|
||||
const drawCalc = (): void => {
|
||||
if (!opened?.row) {
|
||||
drive.stop();
|
||||
return;
|
||||
}
|
||||
const current = opened;
|
||||
calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []);
|
||||
drive.start({
|
||||
savedKey: current.origKey,
|
||||
file: current.file,
|
||||
row: calcRow(current.row) as LogicRow,
|
||||
dirty: () => current.id in drafts || trial.has(current.row),
|
||||
values: current.values,
|
||||
onText: (text) => {
|
||||
current.text = text;
|
||||
if (text.ok) current.lastText = text;
|
||||
drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리
|
||||
},
|
||||
onLines: (lines) => {
|
||||
current.lines = lines;
|
||||
drawEditor();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const drawEditor = (): void => {
|
||||
const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
|
||||
list.root.hidden = !listing || !loaded;
|
||||
@@ -242,37 +192,19 @@ export async function mountM01LogicLab(
|
||||
row: current.row as LogicRow,
|
||||
file: current.file,
|
||||
reasons: current.reasons,
|
||||
prices: current.prices,
|
||||
lines: current.lines,
|
||||
text: current.text,
|
||||
lastText: current.lastText,
|
||||
values: current.values,
|
||||
ownHost: current.ownHost,
|
||||
onChange: touch,
|
||||
onInput: (name, v) => syncLeft(current, { [name]: v }),
|
||||
onTrial: drawCalc,
|
||||
rerun: () => void drive.run(),
|
||||
admin,
|
||||
stack: stack.root,
|
||||
});
|
||||
stack.draw(current.origKey ?? "");
|
||||
};
|
||||
|
||||
/** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */
|
||||
const syncLeft = (o: Opened, ex: Record<string, string>): void => {
|
||||
const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름));
|
||||
const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v);
|
||||
if (!changed.length) return;
|
||||
for (const [k, v] of changed) o.values[k] = v;
|
||||
void drive.run();
|
||||
};
|
||||
|
||||
const show = (next: Opened | null): void => {
|
||||
opened = next;
|
||||
panes.showSource(next?.row?.출처 ?? "");
|
||||
errors.hidden = true;
|
||||
drawEditor();
|
||||
drawCalc();
|
||||
};
|
||||
|
||||
const open = async (id: string, sub: string, key: string): Promise<void> => {
|
||||
@@ -282,12 +214,8 @@ export async function mountM01LogicLab(
|
||||
...draft,
|
||||
id,
|
||||
original: null,
|
||||
prices: {},
|
||||
reasons: [],
|
||||
lines: null,
|
||||
text: null,
|
||||
ownHost: ownHost(),
|
||||
values: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -301,12 +229,8 @@ export async function mountM01LogicLab(
|
||||
origKey: key,
|
||||
row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic),
|
||||
original: JSON.stringify(one.logic),
|
||||
prices: one.prices,
|
||||
reasons: one.reasons,
|
||||
lines: null,
|
||||
text: null,
|
||||
ownHost: ownHost(),
|
||||
values: {},
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
* M01_MasterData_UI_LogicLab_Cover.ts
|
||||
* 단가산출 상세 줄 「그 자리 고침」(PLAN 722-2 · 내역 16 · 17-2) — 줄 모달 안 수량 · 단가 칸.
|
||||
* 고침 꼴 = M02 단가산출 덮개 칸과 같음(`qty` · `labor` · `material` · `expense` — `M02_Sangeun_Doc._covered` 규칙 중 품 · 단가)
|
||||
* M01 = 시험 값만 — 고친 값은 화면 메모리(로직 줄 객체 열쇠)에만 · 셈 = 줄 사본에 덮어 서버로(`/calc` · `/text` row)
|
||||
* · 정본 · 요소(노임 · 자재) · 캐시(초안) 안 바뀜 · 로직을 다시 열면 사라짐
|
||||
* M02(724) = 같은 고침 칸 · 같은 꼴을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관
|
||||
* M02(724) = 고침 칸의 값을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관 — M01 시험 고침은 840-1 에 걷음
|
||||
* 서버 셈 = 줄 `단가`(손 단가) · 로직 줄 `값묶음`(노 · 재 · 경) · `수량` 글 — `master_pick._priced` · `bundled` 가 읽음
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -23,47 +21,8 @@ export interface LineCover {
|
||||
export const COVER_PARTS = { labor: "노무비", material: "재료비", expense: "경비" } as const;
|
||||
export type CoverPart = keyof typeof COVER_PARTS;
|
||||
|
||||
/** 지수 꼴 없는 수 글(엔진 식 칸) — 1e-7 → 0.0000001 */
|
||||
const plainNum = (v: number): string =>
|
||||
v.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 });
|
||||
|
||||
/** 호표 줄(차례 열쇠)에 고침을 덮은 줄 사본 — 원본은 안 바뀜 · 덧줄은 고침 대상 밖 */
|
||||
export function applyCovers<R extends { 호표?: object[] }>(
|
||||
row: R,
|
||||
covers: Record<number, LineCover>,
|
||||
): R {
|
||||
const ho = (row.호표 ?? []).map((raw, i) => {
|
||||
const c = covers[i];
|
||||
if (!c) return raw;
|
||||
const item = raw as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = { ...item };
|
||||
if (c.qty !== undefined) out.수량 = plainNum(c.qty);
|
||||
const parts = (Object.keys(COVER_PARTS) as CoverPart[]).filter((k) => c[k] !== undefined);
|
||||
if (parts.length && item.종류 === "로직") {
|
||||
const given = parts.map((k) => [COVER_PARTS[k], c[k]]);
|
||||
out.값묶음 = { ...((item.값묶음 as object) ?? {}), ...Object.fromEntries(given) };
|
||||
} else if (parts.length) out.단가 = c[parts[0]];
|
||||
return out;
|
||||
});
|
||||
return { ...row, 호표: ho };
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 시험 고침 — 로직 줄 객체마다(다시 열면 새 객체 = 사라짐) */
|
||||
const trials = new WeakMap<object, Record<number, LineCover>>();
|
||||
export const trial = {
|
||||
of(row: object): Record<number, LineCover> {
|
||||
let got = trials.get(row);
|
||||
if (!got) trials.set(row, (got = {}));
|
||||
return got;
|
||||
},
|
||||
has: (row: object | null): boolean => !!row && Object.keys(trials.get(row) ?? {}).length > 0,
|
||||
/** 시험 계산에 넘길 줄 — 고침 없으면 원본 그대로(같은 객체) */
|
||||
apply<R extends object | null>(row: R): R {
|
||||
return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row;
|
||||
},
|
||||
};
|
||||
|
||||
const TEXT = {
|
||||
Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"],
|
||||
Note: [
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Detail.ts
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(줄 쌓기 804-2 · 804-6) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침)
|
||||
* 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 보기는 799-2 에 걷음 — 셈은 화면 칸 없이 `_Logic_Drive.ts` 구동
|
||||
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침 · 자체 로직은 이름 · 단위 · 출처도) ② 수식(줄 쌓기 804-2 · 804-6)
|
||||
* ③ M02 수식 문서 없는 줄(일위대가 줄 모달 등)만 단가산출 상세 몸(`cover` — 공용 부품 `_LogicLab_Ho.ts`)
|
||||
* 단가산출 상세 보기 · 자체 로직 시트 편집기 · 시험 고침은 840-1 에 걷음 — 자체 로직도 수식(줄 쌓기)에서 만들고 고침
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildHo, lineOpener, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho";
|
||||
import { trial } from "./M01_MasterData_UI_LogicLab_Cover";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
import { block } from "./M01_MasterData_UI_LogicLab_View";
|
||||
|
||||
/** 원문 절 머리 개정 연도 글 — [2008, 2020, 2025] → 「'08 · '20 · '25」(없으면 빈 글) */
|
||||
const revisionText = (row: LogicRow): string =>
|
||||
(row.개정 ?? []).map((y) => `'${String(y).slice(-2)}`).join(" · ");
|
||||
|
||||
export { trial };
|
||||
|
||||
export interface DetailContext extends HoContext {
|
||||
export interface DetailContext {
|
||||
row: LogicRow;
|
||||
file: string;
|
||||
reasons: string[];
|
||||
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
|
||||
/** 기본정보 접힘 기억 자리 — 다시 그려도 같은 칸을 씀 */
|
||||
ownHost: HTMLElement;
|
||||
onChange: () => void;
|
||||
/** 시스템 관리자 — 기본 로직 안내 글이 다름(시트에서 고침 / 보기만) */
|
||||
admin?: boolean;
|
||||
/** 722-2 시험 고침 뒤 — 구동을 고친 줄로 다시 시작(있으면 그 자리 고침 켜짐 · 셈은 `rerun`) */
|
||||
onTrial?: () => void;
|
||||
/** 717-2b M02 단가산출 — 단가산출 상세 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
|
||||
cover?: { ho: HTMLElement; open?: ReturnType<typeof lineOpener> };
|
||||
/** 717-2b M02 단가산출 — 수식 문서 없는 줄의 단가산출 상세 덩이(SK 셈 답 · 고침 표) */
|
||||
cover?: { ho: HTMLElement };
|
||||
/** 수식 탭 = 줄 쌓기 그릇(`A00_Common/line_stack_screen.ts` · 804-2 · 804-6) */
|
||||
stack?: HTMLElement;
|
||||
}
|
||||
@@ -148,88 +140,18 @@ function infoBox(ctx: DetailContext): HTMLElement[] {
|
||||
/** 자체 로직(키 GX…) — 정본은 아님 */
|
||||
export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX");
|
||||
|
||||
/** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */
|
||||
function ownEditor(ctx: DetailContext): HTMLElement {
|
||||
if (!ctx.ownHost.childElementCount)
|
||||
buildEditor(ctx.ownHost, {
|
||||
row: ctx.row,
|
||||
file: ctx.file,
|
||||
isNew: false,
|
||||
files: [],
|
||||
reasons: [],
|
||||
prices: ctx.prices,
|
||||
lines: ctx.lines,
|
||||
onChange: ctx.onChange,
|
||||
onFile: () => undefined,
|
||||
onPick: openPicker,
|
||||
});
|
||||
return el("details", {
|
||||
className: "m01lab__edit",
|
||||
attrs: { open: "" },
|
||||
children: [
|
||||
el("summary", { text: tl("Edit_Title") }),
|
||||
el("p", { className: "ui-text ui-text--muted", text: tl("Edit_SheetHint") }),
|
||||
ctx.ownHost,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */
|
||||
function trialEdit(ctx: DetailContext): HoEdit | undefined {
|
||||
const redraw = ctx.onTrial;
|
||||
if (!redraw) return undefined;
|
||||
const covers = trial.of(ctx.row);
|
||||
return {
|
||||
covers,
|
||||
scope: "m01",
|
||||
set: (idx, cover) => {
|
||||
if (cover) covers[idx] = cover;
|
||||
else delete covers[idx];
|
||||
redraw();
|
||||
ctx.rerun?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hoBox(ctx: DetailContext): HTMLElement {
|
||||
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
|
||||
if (!money) {
|
||||
return block(
|
||||
section(tl("Ho_Title"), [
|
||||
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
|
||||
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
||||
]),
|
||||
"ho",
|
||||
);
|
||||
}
|
||||
const body = buildHo({ ...ctx, edit: trialEdit(ctx) });
|
||||
const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠
|
||||
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임)
|
||||
const fold = el("details", {
|
||||
className: "m01lab__fold",
|
||||
attrs: ctx.ownHost.dataset.fold ? { open: "" } : {},
|
||||
children: [el("summary", { text: tl("Ho_Fold") }), ...body],
|
||||
});
|
||||
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
|
||||
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine];
|
||||
const calculated = ctx.lines !== null;
|
||||
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
|
||||
return block(section(tl("Ho_Title"), blocks, hint), "ho");
|
||||
}
|
||||
|
||||
/** 상세 화면(기본정보 · 수식 · 단가산출 상세)을 `host` 에 쌓음 */
|
||||
/** 상세 화면(기본정보 · 수식 · M02 단가산출 상세 몸)을 `host` 에 쌓음 */
|
||||
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
|
||||
// 다시 그리면 포커스가 빠짐 — 고치던 기본정보 칸을 기억해 되돌림
|
||||
const active = document.activeElement;
|
||||
const kept =
|
||||
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
|
||||
? { node: active, from: active.selectionStart, to: active.selectionEnd }
|
||||
: null;
|
||||
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임
|
||||
host.replaceChildren(
|
||||
...infoBox(ctx),
|
||||
...(ctx.stack ? [block(ctx.stack, "stack")] : []),
|
||||
ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx),
|
||||
...(ctx.stack ? [ctx.stack] : []),
|
||||
...(ctx.cover ? [section(tl("Ho_Title"), [ctx.cover.ho])] : []),
|
||||
);
|
||||
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
||||
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
||||
|
||||
@@ -31,7 +31,6 @@ import { pctView } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatLineMoney, formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover";
|
||||
import {
|
||||
condText,
|
||||
@@ -144,8 +143,6 @@ export interface HoContext {
|
||||
values: Record<string, string>;
|
||||
/** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */
|
||||
onInput?: (name: string, value: string) => void;
|
||||
/** 지금 값으로 다시 셈(구동) — 재료 후보 고르기가 씀(없으면 후보만 보임) */
|
||||
rerun?: () => void;
|
||||
/** 그 자리 고침 고리(없으면 모달은 보기만) */
|
||||
edit?: HoEdit;
|
||||
/** (M02 750-2) Ho 줄 차례 → SK 줄 cid — 읽는 식 줄을 `cid` 로 이음(이름 차례 추측 안 함) */
|
||||
@@ -279,11 +276,7 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi
|
||||
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
|
||||
kind: item?.종류,
|
||||
usedSlot: line?.품목?.값칸,
|
||||
...(item?.종류 === "재료" && ctx.pickMat
|
||||
? { onPick: ctx.pickMat(e.idx) }
|
||||
: item?.종류 === "재료" && ctx.rerun
|
||||
? { onPick: (it) => pickMaterial(ctx.values, e.idx, it, ctx.rerun!) }
|
||||
: {}),
|
||||
...(item?.종류 === "재료" && ctx.pickMat ? { onPick: ctx.pickMat(e.idx) } : {}),
|
||||
// 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만)
|
||||
...(edit && (e.extra ? edit.scope === "m02" : item)
|
||||
? {
|
||||
|
||||
@@ -177,12 +177,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김 */
|
||||
.m01-logic__editor[data-view="stack"] [data-block]:not([data-block="stack"]),
|
||||
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 기본정보 — 늘 맨 위 접힌 한 줄 */
|
||||
.m01lab__infofold > summary {
|
||||
display: flex;
|
||||
|
||||
@@ -17,19 +17,11 @@ const TEXT = {
|
||||
"Base logic — view only · only the note can be edited",
|
||||
],
|
||||
Info_Own: [
|
||||
"자체 로직 — 칸을 고치면 잠시 뒤 호표 소계가 다시 계산됨",
|
||||
"Own logic — edits recalculate the subtotals shortly",
|
||||
"자체 로직 — 이름 · 단위 · 출처 · 비고는 여기서 · 식은 수식에서 고침",
|
||||
"Own logic — edit the info here · formulas in the Equation view",
|
||||
],
|
||||
Edit_Title: [
|
||||
"고치기 (호표 · 중간 값 · 덧줄 · 끝수)",
|
||||
"Edit (lines · middles · extras · rounding)",
|
||||
],
|
||||
Ho_Fold: ["묶음 소계 표 (읽기용 — 고치기는 위 편집기)", "Group subtotals (read-only)"],
|
||||
Ho_Title: ["단가산출 상세", "Unit-price detail"],
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
View_Equation: ["수식", "Equation"],
|
||||
Edit_SheetHint: ["식은 「수식」 보기에서 고침", "Edit formulas in the Equation view"],
|
||||
View_Label: ["보기", "View"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
G_material: ["재료", "Material"],
|
||||
G_cost: ["경비", "Expense"],
|
||||
@@ -46,9 +38,6 @@ const TEXT = {
|
||||
Col_Price: ["단가", "Price"],
|
||||
Col_Amount: ["금액", "Amount"],
|
||||
Empty_Group: ["줄 없음", "No lines"],
|
||||
Not_Money: ["호표가 없는 로직 — 결과 식", "No unit-cost lines — result formula"],
|
||||
Calc_First: ["금액은 셈이 끝나면 채워짐", "Amounts fill when the calculation finishes"],
|
||||
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
|
||||
Modal_Close: ["닫기", "Close"],
|
||||
Pill_Tone: ["요소 연결", "Linked element"],
|
||||
Modal_Sum: ["한 줄 풀이", "In one line"],
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_View.ts
|
||||
* 「단가산출 로직」 상세 보기 이름표 — 보기 = 수식(줄 쌓기 `A00_Common/line_stack_screen.ts`) 하나 · 고르기 단추 없음(836-3)
|
||||
* 그릇 `data-view="stack"` 고정 — `data-block="ho"` 덩이(단가산출 상세 표)는 CSS 로 숨김
|
||||
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
|
||||
* 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위)
|
||||
* ========================================================================== */
|
||||
|
||||
export type LabView = "stack" | "ho";
|
||||
|
||||
/** 덩이에 보기 이름표를 붙여 돌려줌 */
|
||||
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
node.dataset.block = view;
|
||||
return node;
|
||||
};
|
||||
@@ -1,12 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Drive.ts
|
||||
* 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)과 로직 상세(`LogicLab`)가 같이 씀.
|
||||
* `driveRun` = 한 번 셈(`/calc` + `/text`) · `previewText` = 열 때 읽는 식 미리보기 · `autoFill` = 자동값 채움
|
||||
* `createDrive` = 로직 상세용 묶음 — 열 때 미리보기 + 첫 셈(입력 없으면 바로 · 있으면 자동값 채운 뒤) · `run` 은 칸 없이 다시 셈
|
||||
* 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)이 씀
|
||||
* `driveRun` = 한 번 셈(`/calc` + `/text`) · `autoFill` = 자동값 채움 — 로직 상세 구동은 840-1 에 걷음
|
||||
* 셈 자리 = 서버(CLAUDE.md 5 장 ③) · 화면 · sessionStorage 없음 · 고른 재료(`CalcPick`) 는 값 객체에 딸림
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchAuto,
|
||||
runCalc,
|
||||
@@ -16,8 +14,7 @@ import {
|
||||
type LogicRow,
|
||||
type TextAnswer,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { isMaterialKeyInput, pickBody } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickBody } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
|
||||
export interface DriveContext {
|
||||
/** 저장된 키 — 새 로직은 null */
|
||||
@@ -67,17 +64,6 @@ export async function driveRun(
|
||||
return { answer, draft };
|
||||
}
|
||||
|
||||
/** 열 때 읽는 식 미리보기 — [계산] 전에도 이름 식 칸이 비치게 · 늦게 온 응답은 `live()` 로 거름 */
|
||||
export function previewText(ctx: DriveContext, live: () => boolean): void {
|
||||
if (!ctx.onText) return;
|
||||
const body = { key: ctx.savedKey ?? ctx.row.키, inputs: {}, ...draftBody(ctx) };
|
||||
void runText(body).then((answer) => {
|
||||
if (!live()) return;
|
||||
(answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false;
|
||||
ctx.onText?.(answer);
|
||||
});
|
||||
}
|
||||
|
||||
/** 자동값으로 빈 칸 채움 — 채웠으면 true */
|
||||
export async function autoFill(ctx: DriveContext): Promise<boolean> {
|
||||
if (ctx.savedKey === null) return false;
|
||||
@@ -88,49 +74,3 @@ export async function autoFill(ctx: DriveContext): Promise<boolean> {
|
||||
for (const n of names) ctx.values[n] = String(auto[n]);
|
||||
return names.length > 0;
|
||||
}
|
||||
|
||||
/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */
|
||||
const first = new WeakSet<Record<string, string>>();
|
||||
|
||||
/** 로직 상세용 구동 — `start` = 로직을 열거나 입력 칸이 바뀔 때 · `run` = 지금 값으로 다시 셈 */
|
||||
export function createDrive(): {
|
||||
start: (ctx: DriveContext) => void;
|
||||
run: () => Promise<void>;
|
||||
stop: () => void;
|
||||
} {
|
||||
let seq = 0; // 로직을 바꾸면 늦게 온 응답을 거름
|
||||
let now: DriveContext | null = null;
|
||||
let ran = false; // 셈 답이 이미 왔으면 뒤늦은 미리보기로 안 덮음
|
||||
const run = async (): Promise<void> => {
|
||||
const ctx = now;
|
||||
if (!ctx) return;
|
||||
ran = true;
|
||||
try {
|
||||
await driveRun(ctx);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
const start = (ctx: DriveContext): void => {
|
||||
now = ctx;
|
||||
ran = false;
|
||||
const mine = ++seq;
|
||||
previewText(ctx, () => !ran && mine === seq);
|
||||
if (first.has(ctx.values)) return;
|
||||
if (!(ctx.row.입력 ?? []).some((spec) => !isMaterialKeyInput(spec))) {
|
||||
first.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 셈
|
||||
queueMicrotask(() => mine === seq && void run());
|
||||
} else if (ctx.savedKey !== null) {
|
||||
first.add(ctx.values);
|
||||
void autoFill(ctx).then((filled) => filled && mine === seq && void run());
|
||||
}
|
||||
};
|
||||
return {
|
||||
start,
|
||||
run,
|
||||
stop: () => {
|
||||
now = null;
|
||||
seq++;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { el } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { swapUnit } from "../A00_Common/master_unit_swap";
|
||||
import {
|
||||
fetchLogic,
|
||||
setLogicApiPaths,
|
||||
@@ -31,12 +30,10 @@ import {
|
||||
type TextAnswer,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail";
|
||||
import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
|
||||
import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho";
|
||||
import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
|
||||
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
|
||||
import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs";
|
||||
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
|
||||
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
|
||||
@@ -57,7 +54,7 @@ import {
|
||||
type SkAnswer,
|
||||
type SkDoc,
|
||||
} from "./logic/M02_Sangeun_Cover_Model";
|
||||
import { resetQty, setQty, withFirst } from "./logic/M02_Sangeun_Undo";
|
||||
import { resetQty, setQty } from "./logic/M02_Sangeun_Undo";
|
||||
|
||||
export interface SangeunDetailOptions {
|
||||
layer: Layer;
|
||||
@@ -138,74 +135,13 @@ export function mountSangeunDetail(
|
||||
}
|
||||
}
|
||||
const ownHost = el("div");
|
||||
const addButton = createButton({
|
||||
label: L("M02_SangeunAddLine"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
openSangeunLinePicker(scope, (line) => {
|
||||
if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직);
|
||||
if ("종류" in line && line.종류 === "로직") return void addBase(line);
|
||||
doc.더한줄 = [
|
||||
...(doc.더한줄 ?? []),
|
||||
withFirst(line as unknown as NonNullable<SkDoc["더한줄"]>[number]),
|
||||
];
|
||||
touch();
|
||||
}),
|
||||
});
|
||||
addButton.hidden = !edit || !!opt.source;
|
||||
// 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴)
|
||||
const rateButton = createButton({
|
||||
label: L("M02_SkRateAdd"),
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
openRateLine(
|
||||
(ans?.줄 ?? []).map((l) => ({ cid: l.cid, 이름: l.이름 })),
|
||||
(line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()),
|
||||
),
|
||||
});
|
||||
rateButton.hidden = !edit || !!opt.source;
|
||||
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
|
||||
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
|
||||
let qty = 1;
|
||||
const have = base ? baseUnitOf(base.logic.결과단위) : "";
|
||||
try {
|
||||
const swap = have
|
||||
? swapUnit(have, baseUnitOf((await fetchLogic(line.로직)).logic.결과단위))
|
||||
: null;
|
||||
if (swap?.배 != null && swap.계수 === null) qty = swap.배;
|
||||
} catch {
|
||||
/* 못 읽음 = 1 그대로 */
|
||||
}
|
||||
if (!alive) return;
|
||||
doc.더한줄 = [
|
||||
...(doc.더한줄 ?? []),
|
||||
withFirst({ ...line, 수량: qty } as unknown as NonNullable<SkDoc["더한줄"]>[number]),
|
||||
];
|
||||
touch();
|
||||
}
|
||||
/** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */
|
||||
function setBase(key: string): void {
|
||||
doc.바탕 = { 로직: key };
|
||||
delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음
|
||||
touch(false);
|
||||
void loadBase(true);
|
||||
}
|
||||
const main = el("div", {
|
||||
className: "m02-sk",
|
||||
attrs: { "data-sangeun-detail": doc.키 ?? "" },
|
||||
children: [
|
||||
head,
|
||||
notes,
|
||||
...(opt.slot
|
||||
? []
|
||||
: [
|
||||
el("div", {
|
||||
className: "m02-sk__head",
|
||||
children: [tabs.tools, addButton, rateButton],
|
||||
}),
|
||||
]),
|
||||
...(opt.slot ? [] : [el("div", { className: "m02-sk__head", children: [tabs.tools] })]),
|
||||
tabs.host,
|
||||
editor,
|
||||
],
|
||||
@@ -420,15 +356,9 @@ export function mountSangeunDetail(
|
||||
row: base?.logic ?? row,
|
||||
file: base?.file ?? "",
|
||||
reasons: base?.reasons ?? [],
|
||||
prices: base?.prices ?? {},
|
||||
lines: null,
|
||||
text,
|
||||
lastText,
|
||||
values,
|
||||
ownHost,
|
||||
onChange: () => undefined,
|
||||
onInput,
|
||||
cover: { ho, open: lineOpener(hoCtx) },
|
||||
cover: { ho },
|
||||
});
|
||||
// prettier-ignore
|
||||
side?.set([
|
||||
|
||||
@@ -4,13 +4,10 @@
|
||||
* · `listLayerDocs` = 층 목록 길(`/layers/{층}/templates` · 종류 하나)을 마스터 › 회사 › 개인 차례로 이음 ·
|
||||
* 금액 = 서버가 붙인 1 단위 `기준금액` · `listSangeun` = 종류 `sangeun` · 찾아 넣기 창(`Find_Pick`) 탭 · B09 재료 창고 출처가 씀
|
||||
* · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침)
|
||||
* · `openSangeunLinePicker` = 단가산출 [항목 추가] — 찾아 넣기 창(721-2 · M01 로직 · 인력 · 재료 · 단가산출 · 일위대가)
|
||||
* → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴 · 인력 · 재료 = 요소 키 LB · MT · 수량 1)
|
||||
* ========================================================================== */
|
||||
|
||||
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { fixedOf, openFindPicker } from "./M02_MasterTemplete_Find_Pick";
|
||||
import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc";
|
||||
|
||||
export const SANGEUN_KIND = "sangeun";
|
||||
/** 층 차례 — 위에서 아래 */
|
||||
@@ -68,29 +65,3 @@ export async function listLayerDocs(
|
||||
);
|
||||
return lists.flat();
|
||||
}
|
||||
|
||||
/** 단가산출 더한줄 한 줄(PLAN 715 이음 꼴) */
|
||||
export type SangeunLine =
|
||||
| { 종류: "로직"; 로직: string; 입력값: Record<string, never>; 수량: number }
|
||||
| { 참조: LaborRef; 수량: number }
|
||||
| { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }
|
||||
| { 고정: LaborFixed; 이름: string; 단위: string | null; 수량: number };
|
||||
|
||||
/** 단가산출 [항목 추가] — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */
|
||||
export function openSangeunLinePicker(
|
||||
scope: { layer: Layer; owner: string | null },
|
||||
done: (line: SangeunLine) => void,
|
||||
): void {
|
||||
openFindPicker({
|
||||
...scope,
|
||||
logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }),
|
||||
ref: (ref) => done({ 참조: ref, 수량: 1 }),
|
||||
element: (e) =>
|
||||
(e.종류 === "인력" || e.종류 === "재료") &&
|
||||
done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }),
|
||||
// 785-3 유가전력 = 더한줄 재료 요소 MO
|
||||
oil: (e) => done({ 종류: "재료", 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }),
|
||||
// 736-3 기계 = 고정 줄(이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경) · 수량 1
|
||||
machine: (e) => done({ 고정: fixedOf(e), 이름: e.이름, 단위: e.단위, 수량: 1 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Rate.ts
|
||||
* M02 단가산출(SK) 율 줄 넣기 창(PLAN 778-4 · 요구 623) — [율 줄 추가] → 이름 · 율 % · 기준 · 금액이 드는 칸.
|
||||
* 기준 = 묶음(인력 · 재료 · 장비 줄 합) · 비목 전체(노무비 · 재료비 · 경비) · 줄 고르기(이 SK 줄 cid 목록 ·
|
||||
* 앞 율 줄 고르면 율 위 율) → 더한줄 `{고정: {이름, 단위 %, 단가 0, 이음 없음, 비율 {기준, 비목}}, 수량 = 율}`
|
||||
* (꼴 = 서버 `M02_Sangeun_Doc` · 셈 = 서버 `M02_Sangeun_Calc._rated`) · 부품 상자만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createFormGrid } from "@ui/ui_template_general_layout";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { SkAdded } from "./logic/M02_Sangeun_Cover_Model";
|
||||
|
||||
const LINES = "줄";
|
||||
|
||||
/** 율 줄 넣기 — `rows` = 지금 셈 답 줄(기준 줄 고르기 후보) · `done` = 새 더한줄 한 줄 */
|
||||
export function openRateLine(
|
||||
rows: { cid: string; 이름: string }[],
|
||||
done: (line: SkAdded) => void,
|
||||
): void {
|
||||
const name = createInputField({ label: L("M02_SkRateName"), value: L("M02_SkRateDefault") });
|
||||
const rate = createInputField({
|
||||
label: L("M02_SkRateRate"),
|
||||
type: "number",
|
||||
value: "2",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
});
|
||||
const picks = rows.map((r) => ({
|
||||
cid: r.cid,
|
||||
box: createCheckbox(r.이름 || r.cid, { chip: true, size: 28 }),
|
||||
}));
|
||||
const list = el("div", {
|
||||
className: "m02-sk__tags",
|
||||
attrs: { "data-sk-rate-lines": "" },
|
||||
children: picks.length
|
||||
? picks.map((p) => p.box.root)
|
||||
: [el("span", { text: L("M02_SkRateNoLines") })],
|
||||
});
|
||||
list.hidden = true;
|
||||
const basis = createSelectField({
|
||||
label: L("M02_SkRateBasis"),
|
||||
value: "인력",
|
||||
options: [
|
||||
{ value: "인력", text: L("M02_SkRateGroupLabor") },
|
||||
{ value: "재료", text: L("M02_SkRateGroupMat") },
|
||||
{ value: "장비", text: L("M02_SkRateGroupMach") },
|
||||
{ value: "노무비", text: L("M02_SkRateCostLabor") },
|
||||
{ value: "재료비", text: L("M02_SkRateCostMat") },
|
||||
{ value: "경비", text: L("M02_SkRateCostExp") },
|
||||
{ value: LINES, text: L("M02_SkRateBasisLines") },
|
||||
],
|
||||
onChange: (v) => (list.hidden = v !== LINES),
|
||||
});
|
||||
const part = createSelectField({
|
||||
label: L("M02_SkRatePart"),
|
||||
value: "경비",
|
||||
options: [
|
||||
{ value: "경비", text: L("M02_SkRatePartExp") },
|
||||
{ value: "재료비", text: L("M02_SkRatePartMat") },
|
||||
{ value: "노무비", text: L("M02_SkRatePartLabor") },
|
||||
],
|
||||
});
|
||||
openModal({
|
||||
title: L("M02_SkRateTitle"),
|
||||
closeLabel: L("M02_SkRateClose"),
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
const ok = createButton({
|
||||
label: L("M02_SkRateOk"),
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
const label = name.input.value.trim();
|
||||
const q = Number(rate.input.value);
|
||||
const keys = picks.filter((p) => p.box.input.checked).map((p) => p.cid);
|
||||
const lines = basis.select.value === LINES;
|
||||
name.setError(label ? undefined : L("M02_SkRateNeedName"));
|
||||
rate.setError(rate.input.value.trim() && q >= 0 ? undefined : L("M02_SkRateBadRate"));
|
||||
if (lines && !keys.length) return void rate.setError(L("M02_SkRateNeedLine"));
|
||||
if (!label || !rate.input.value.trim() || !(q >= 0)) return;
|
||||
const fx = {
|
||||
이름: label,
|
||||
단위: "%",
|
||||
단가: { 노무비: 0, 재료비: 0, 경비: 0 },
|
||||
이음: { 노임: null, 자재: null, 기계: null },
|
||||
비율: { 기준: lines ? keys : basis.select.value, 비목: part.select.value },
|
||||
};
|
||||
done({ 고정: fx, 이름: label, 단위: "%", 수량: q });
|
||||
close();
|
||||
},
|
||||
});
|
||||
ok.dataset.skRateOk = "";
|
||||
actions.append(ok);
|
||||
const grid = createFormGrid([name.root, rate.root, basis.root, part.root]);
|
||||
grid.dataset.skRate = "";
|
||||
body.append(grid, list);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -63,7 +63,6 @@ export function createSangeunTabs(
|
||||
const show = (doc: boolean): void => {
|
||||
stack.root.hidden = tools.hidden = !doc;
|
||||
editor.hidden = doc;
|
||||
if (!doc) editor.dataset.view = "ho";
|
||||
};
|
||||
show(true);
|
||||
return {
|
||||
|
||||
@@ -165,9 +165,11 @@ def test_상세_부품_길_부품상자():
|
||||
cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts")
|
||||
assert "mountSangeunDetail(host, doc," in view and "createEmpty" not in view
|
||||
assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail
|
||||
assert "buildLabDetail(editor" in detail and "cover: { ho, open: lineOpener(hoCtx) }" in detail
|
||||
assert "buildLabDetail(editor" in detail and "cover: { ho }," in detail
|
||||
assert "buildCalc" not in detail # 799-2 시험 계산 보기 걷음
|
||||
assert "openSangeunLinePicker(scope" in detail
|
||||
assert (
|
||||
"openSangeunLinePicker" not in detail and "openRateLine" not in detail
|
||||
) # 840-1 [항목 추가] · [율 줄 추가] 걷음
|
||||
assert "if (mine !== seq || !alive) return" in detail # 최신 응답만
|
||||
# 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음
|
||||
assert "...buildHo(hoCtx)" in detail and 'scope: "m02",' in detail
|
||||
|
||||
@@ -38,10 +38,9 @@ def test_sk_list_layer_order_and_ref_shape():
|
||||
) # 일위대가 [로직 추가] → 찾아 넣기 창(785-7 `withSangeun`)
|
||||
store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts")
|
||||
assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts
|
||||
# 더한줄 고르기 — 715 이음 꼴 셋(인력 · 재료 = 요소 키)
|
||||
assert 'logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick
|
||||
assert "ref: (ref) => done({ 참조: ref, 수량: 1 })" in pick
|
||||
assert "done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 })" in pick
|
||||
assert (
|
||||
"openSangeunLinePicker" not in pick
|
||||
) # 840-1 단가산출 [항목 추가] 걷음 — 줄 더하기 = 수식 [+]
|
||||
|
||||
|
||||
def _sk(folder, name):
|
||||
|
||||
@@ -27,12 +27,12 @@ def _text(path: Path) -> str:
|
||||
def test_721_1_단추_이름과_자리():
|
||||
detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert (
|
||||
"children: [tabs.picker, tabs.tools, addButton, rateButton]" in detail
|
||||
) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 799-2 보기 · 808-1 [단가 갱신]
|
||||
"addButton" not in detail and "rateButton" not in detail
|
||||
) # 840-1 [항목 추가] · [율 줄 추가] 걷음
|
||||
assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음
|
||||
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
|
||||
assert "M02_SangeunPickBase" not in locale
|
||||
assert 'M02_SangeunAddLine: ["항목 추가"' in locale
|
||||
assert "M02_SangeunAddLine" not in locale and "M02_SkRateAdd" not in locale
|
||||
|
||||
|
||||
def test_721_2_부르는_곳_셋만_새_창():
|
||||
@@ -43,7 +43,7 @@ def test_721_2_부르는_곳_셋만_새_창():
|
||||
assert "findSangeun" not in labor_pick # 옛 창의 단가산출 탭 걷음
|
||||
assert "logicOnly" not in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") # 727-2
|
||||
assert "logicOnly" not in find
|
||||
assert "openFindPicker({" in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts")
|
||||
assert "openFindPicker" not in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") # 840-1
|
||||
assert "sangeun: true }" in labor_pick # 일위대가(785-7 `withSangeun`)
|
||||
# 옛 창 그대로 — B09 단가 연결은 sangeun 없이 부름(736-1 불러오기 · 별칭 장부는 찾아 넣기 창으로)
|
||||
for f in (
|
||||
@@ -171,6 +171,3 @@ def test_736_3_기계_고정_줄_금액():
|
||||
and "이음: { 노임: null, 자재: null, 기계: e.코드 }" in find
|
||||
)
|
||||
assert "machine: fixed &&" in _text(M02 / "M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "machine: (e) => done({ 고정: fixedOf(e)" in _text(
|
||||
M02 / "M02_MasterTemplete_Sangeun_Pick.ts"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,12 @@ def _node(code: str):
|
||||
|
||||
def test_상세가_공용_부품을_씀():
|
||||
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "buildHo({ ...ctx, edit: trialEdit(ctx) })" in detail
|
||||
assert (
|
||||
"buildHo" not in detail and "trialEdit" not in detail
|
||||
) # 840-1 M01 상세 보기 걷음 — M02 줄 모달만 씀
|
||||
assert "...buildHo(hoCtx)" in _text(
|
||||
ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_Detail.ts"
|
||||
)
|
||||
ho = _text(HO)
|
||||
# 부품 상자만 — 표 · 카드 · 계산 줄 · 변수 칸(calc_sheet) · 입력 칩(텍스트 수식)
|
||||
for part in (
|
||||
@@ -60,13 +65,13 @@ def test_상세가_공용_부품을_씀():
|
||||
)
|
||||
|
||||
|
||||
def test_시험_고침은_캐시_밖():
|
||||
def test_시험_고침은_M01_에서_걷음():
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
# 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄
|
||||
assert "=> trial.apply(row);" in lab
|
||||
assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab
|
||||
assert "onTrial: drawCalc," in lab and "rerun: () => void drive.run()," in lab # 799-2 셈 구동
|
||||
assert (
|
||||
"trial" not in lab and "drive" not in lab
|
||||
) # 840-1 — 시험 값 · 구동 걷음(수식에서 값을 바꾸고 [되돌림])
|
||||
cover = _text(COVER)
|
||||
assert "export const trial" not in cover and "applyCovers" not in cover
|
||||
assert "drafts" not in cover and "touch" not in cover and "/save" not in cover
|
||||
modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts")
|
||||
assert "...(target.edit ? [target.edit(close, head)] : [])" in modal
|
||||
@@ -91,23 +96,3 @@ console.log(JSON.stringify(got));
|
||||
assert got[3]["줄"] == [[2, 5, 2]]
|
||||
empty = _node(_pure(HO) + '\nconsole.log(JSON.stringify(boxesOf([], ["인력"])))')
|
||||
assert empty == [{"묶음": "인력", "줄": [], "소계": 0}]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_고침_덮기():
|
||||
body = """
|
||||
const row = { 키: "GF1", 호표: [
|
||||
{ 종류: "인력", 요소: "LB1", 수량: "찾기(Q).수량" },
|
||||
{ 종류: "로직", 요소: "로직(GC1)", 수량: "1 / 작업량", 값묶음: { 경비: 3 } },
|
||||
{ 종류: "재료", 요소: "MT1", 수량: "2" } ], 덧줄: [{ 이름: "공구", 식: "노무비 * 3 / 100" }] };
|
||||
const out = applyCovers(row, { 0: { qty: 0.0000001, labor: 100000 }, 1: { labor: 50000 }, 9: { qty: 1 } });
|
||||
console.log(JSON.stringify({ out, same: row.호표[0].수량, keep: out.호표[2] === row.호표[2] }));
|
||||
"""
|
||||
got = _node(_pure(COVER) + "\n" + body)
|
||||
out = got["out"]
|
||||
assert (
|
||||
out["호표"][0]["수량"] == "0.0000001" and out["호표"][0]["단가"] == 100000
|
||||
) # 지수 꼴 없음 · 손 단가
|
||||
assert out["호표"][1]["값묶음"] == {"경비": 3, "노무비": 50000} and "단가" not in out["호표"][1]
|
||||
assert got["keep"] and got["same"] == "찾기(Q).수량" # 원본 안 바뀜 · 안 고친 줄 그대로
|
||||
assert out["덧줄"] == [{"이름": "공구", "식": "노무비 * 3 / 100"}]
|
||||
|
||||
@@ -90,7 +90,7 @@ def test_둘째_기초_수량_처음값_배(whole):
|
||||
src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "swapUnit(" in src and "addBase(" in src and "baseUnitOf(" in src
|
||||
assert "addBase(" not in src and "baseUnitOf(" in src # 840-1 [항목 추가] 걷음
|
||||
|
||||
|
||||
def test_돈_아닌_기초_고르기_창에서_뺌(whole):
|
||||
|
||||
@@ -57,27 +57,23 @@ def test_걷은_파일_없고_부르는_곳_없음():
|
||||
|
||||
|
||||
def test_보기는_수식_하나_고르기_없음():
|
||||
m01 = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
|
||||
m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Tabs.ts")
|
||||
for src in (m01, m02):
|
||||
assert not re.findall(r'\{ value: "(\w+)", label: ', src) and "createSegment" not in src
|
||||
assert "createViewPicker" not in _text(M01 / "M01_MasterData_UI_LogicLab.ts") # 836-3
|
||||
assert 'editor.dataset.view = "stack";' in _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
assert 'export type LabView = "stack" | "ho";' in m01
|
||||
assert not re.findall(r'\{ value: "(\w+)", label: ', m02) and "createSegment" not in m02
|
||||
assert not (M01 / "M01_MasterData_UI_LogicLab_View.ts").exists() # 840-1 보기 이름표 걷음
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
assert "createViewPicker" not in lab and "dataset.view" not in lab # 836-3 · 840-1
|
||||
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
|
||||
assert '[data-view="ho"] [data-block]:not([data-block="ho"])' in css
|
||||
assert 'data-view="sheet"' not in css and 'data-view="calc"' not in css
|
||||
assert "data-view" not in css and "data-block" not in css # 840-1 보기 고르기 규칙 걷음
|
||||
|
||||
|
||||
def test_셈_구동은_화면_없이_상세는_구동만():
|
||||
drive = _text(M01 / "M01_MasterData_UI_Logic_Drive.ts")
|
||||
assert "document." not in drive and "sessionStorage." not in drive
|
||||
assert "export async function driveRun(" in drive and "export function createDrive(" in drive
|
||||
assert "export async function driveRun(" in drive and "createDrive" not in drive # 840-1
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
assert "createDrive()" in lab and "buildCalc" not in lab and "data-calc-run" not in lab
|
||||
assert "rerun: () => void drive.run()," in lab
|
||||
assert "createDrive" not in lab and "buildCalc" not in lab and "data-calc-run" not in lab
|
||||
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "calcHost" not in detail and "data-calc-run" not in detail and "ctx.rerun?.()" in detail
|
||||
assert "calcHost" not in detail and "data-calc-run" not in detail and "rerun" not in detail
|
||||
calc = _text(
|
||||
M01 / "M01_MasterData_UI_Logic_Calc.ts"
|
||||
) # 로직 복사 화면이 쓰는 시험 계산 칸 — 구동은 공용
|
||||
|
||||
@@ -240,8 +240,8 @@ def test_화면_이음():
|
||||
'base: "/m01/logic/doc",' in lab and 'cache: "m01_doc_drafts",' in lab
|
||||
) # 804-7 page = 페이지 [저장]
|
||||
assert "stack: stack.root" in lab and "=> stack.canLeave();" in lab # 804-6 수식 탭 = 줄 쌓기
|
||||
assert 'editor.dataset.view = "stack";' in lab # 836-3 보기 고르기 없음 · 수식 하나
|
||||
assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css")
|
||||
assert "dataset.view" not in lab # 836-3 보기 고르기 없음 · 840-1 이름표 · CSS 걷음
|
||||
assert "data-view" not in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css")
|
||||
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
|
||||
assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 1
|
||||
vite = _src("vite.config.ts")
|
||||
|
||||
@@ -51,10 +51,7 @@ def test_걷은_파일_없고_부르는_곳_없음():
|
||||
|
||||
|
||||
def test_보기_수식은_줄쌓기_첫탭():
|
||||
for rel in (
|
||||
"M01_MasterData/M01_MasterData_UI_LogicLab_View.ts",
|
||||
"M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts",
|
||||
):
|
||||
for rel in ("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts",):
|
||||
src = _text(rel)
|
||||
assert "createSegment" not in src and '"equation"' not in src, rel # 836-3 고르기 없음
|
||||
lab = _text("M01_MasterData/M01_MasterData_UI_LogicLab.ts")
|
||||
|
||||
@@ -209,7 +209,10 @@ def test_시험_페이지_걷음_화면_차림() -> None:
|
||||
def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None:
|
||||
"""804-8 — M02 단가산출 수식 탭 = M01 과 같은 공용 화면 · 페이지 [되돌림] · [저장] 한 번 · 머리 줄 한 줄."""
|
||||
screen = _src("A00_Common/line_stack_screen.ts")
|
||||
assert "...(cp && edit() && key && !mark?.자동 ? copyTools(true, (job) => void runJob(job)) : [])," in screen # 836-2 자동 층 = 단추 없음 · 프로젝트 층 = 있음
|
||||
assert (
|
||||
"...(cp && edit() && key && !mark?.자동 ? copyTools(true, (job) => void runJob(job)) : []),"
|
||||
in screen
|
||||
) # 836-2 자동 층 = 단추 없음 · 프로젝트 층 = 있음
|
||||
assert "notesOf" not in screen and "lstk__notes" not in screen # 못 고치는 칸 안내 줄 걷음
|
||||
assert "Notes:" not in _src("A00_Common/line_stack_text.ts")
|
||||
tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts")
|
||||
@@ -217,8 +220,9 @@ def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None:
|
||||
detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "page: opt.source ? () => report() : undefined," in detail
|
||||
assert "JSON.stringify(doc) !== saved || tabs.dirty()" in detail # 고침 알림 = 기본 + 수식
|
||||
assert "addButton.hidden = !edit || !!opt.source;" in detail # [항목 추가] · [율 줄 추가] 걷음
|
||||
assert "rateButton.hidden = !edit || !!opt.source;" in detail
|
||||
assert (
|
||||
"addButton" not in detail and "rateButton" not in detail
|
||||
) # 840-1 [항목 추가] · [율 줄 추가] 걷음
|
||||
assert "if (!(await tabs.save())) throw" in detail and "await refreshing;" in detail
|
||||
assert "if (tabs.dirty()) void tabs.discard();" in detail # 버리고 떠남 = 수식 캐시도
|
||||
view = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts")
|
||||
|
||||
@@ -133,8 +133,10 @@ def test_808_1_모달_머리_일원화():
|
||||
assert "actions: row.참조 ? [slot, ...tools] : tools," in row and 't("FromSk")' in row
|
||||
assert "slot: o.slot," in _src("M02_MasterTemplete_Labor_RowSk.ts")
|
||||
detail = _src("M02_MasterTemplete_Sangeun_Detail.ts")
|
||||
assert "...(opt.slot\n ? []" in detail # 머리 칸 있으면 옛 단추 줄 없음
|
||||
assert "...(opt.slot ? [] : [" in detail # 머리 칸 있으면 머리 줄 없음
|
||||
assert "children: [tabs.tools]" in detail and "tabs.picker" not in detail
|
||||
assert r"/^SK-(?!0{6})\d{6}$/" in detail
|
||||
tabs = _src("M02_MasterTemplete_Sangeun_Tabs.ts")
|
||||
assert "const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs # 836-3 · 836-5 문서 없는 줄 = 상세 몸만
|
||||
assert (
|
||||
"const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs
|
||||
) # 836-3 · 836-5 문서 없는 줄 = 상세 몸만
|
||||
|
||||
@@ -187,7 +187,6 @@ export const ui_locales_m2 = {
|
||||
M02_SangeunSwapCoef: ["환산 계수", "Conversion factor"],
|
||||
M02_SangeunSwapWeight: ["단위중량", "Unit weight"],
|
||||
M02_SangeunSwapPick: ["고르세요", "Choose"],
|
||||
M02_SangeunAddLine: ["항목 추가", "Add item"],
|
||||
M02_SangeunDrop: ["삭제", "Delete"],
|
||||
M02_SangeunDropBase: ["기초 빼기", "Remove base"],
|
||||
M02_SangeunBases: ["더한 기초", "Added bases"],
|
||||
@@ -368,7 +367,6 @@ export const ui_locales_m2 = {
|
||||
M02_ToneDanger: ["못 이음", "Not linked"],
|
||||
M02_ToneMuted: ["밖", "Outside"],
|
||||
M02_ToneMine: ["내가 고름", "My pick"],
|
||||
M02_SkRateAdd: ["율 줄 추가", "Add % line"],
|
||||
M02_SkRateTitle: ["율 줄 — 기준 × 율 %", "% line — base × rate %"],
|
||||
M02_SkRateName: ["이름", "Name"],
|
||||
M02_SkRateDefault: ["공구손료", "Tool cost"],
|
||||
|
||||
Reference in New Issue
Block a user