feat(M01): 새로 만들기 모달에 새 길 잇기 — 절 목록으로 원문 절 고르기 · 절로 표 거르기 · 표 조건 값 목록 · 초안 시험 계산 · 자체 로직 저장(GX) · 본떠 만들기
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
@@ -7,17 +7,10 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
ApiError,
|
||||
fetchLogicFiles,
|
||||
runCalc,
|
||||
saveFiles,
|
||||
type CalcAnswer,
|
||||
type LogicFile,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { ApiError, runCalc, type CalcAnswer } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
|
||||
import { searchLogics } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import { createLogic } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import {
|
||||
atomPlain,
|
||||
buildRow,
|
||||
@@ -44,7 +37,6 @@ const fail = (e: unknown): void =>
|
||||
/** 「새로 만들기」 모달을 엶 */
|
||||
export function openLogicWizard(options: WizardOptions = {}): void {
|
||||
const d: Draft = emptyDraft();
|
||||
let files: LogicFile[] = [];
|
||||
let at = 0;
|
||||
let answered = false;
|
||||
const testValues: Record<string, string> = {};
|
||||
@@ -85,12 +77,17 @@ export function openLogicWizard(options: WizardOptions = {}): void {
|
||||
next.disabled = !steps[at].ready(d);
|
||||
};
|
||||
|
||||
const onCopied = (key: string): void => {
|
||||
close();
|
||||
options.onSaved?.(key);
|
||||
};
|
||||
|
||||
const go = (to: number): void => {
|
||||
at = Math.max(0, Math.min(steps.length - 1, to));
|
||||
if (at === STEPS.length) answered = false; // 시험 계산 걸음에 들어올 때마다 새로
|
||||
const step = steps[at];
|
||||
head.textContent = tw("Step", { n: at + 1, total: steps.length, name: tw(step.key) });
|
||||
body.replaceChildren(step.render({ d, changed }));
|
||||
body.replaceChildren(step.render({ d, changed, onCopied }));
|
||||
back.disabled = at === 0;
|
||||
next.hidden = at === steps.length - 1;
|
||||
changed();
|
||||
@@ -139,8 +136,6 @@ export function openLogicWizard(options: WizardOptions = {}): void {
|
||||
};
|
||||
|
||||
const calc = async (): Promise<CalcAnswer> => {
|
||||
if (!files.length) files = await fetchLogicFiles();
|
||||
const file = (files.find((f) => f.book === d.book) ?? files[0]).file;
|
||||
const given: Record<string, unknown> = {};
|
||||
for (const s of collectInputs(d)) {
|
||||
const raw = (testValues[s.name] ?? "").trim();
|
||||
@@ -148,56 +143,33 @@ export function openLogicWizard(options: WizardOptions = {}): void {
|
||||
const option = s.choices?.find((c) => String(c) === raw);
|
||||
given[s.name] = option ?? (Number.isNaN(Number(raw)) ? raw : Number(raw));
|
||||
}
|
||||
return runCalc({ key: "", inputs: given, row: buildRow(d), file });
|
||||
return runCalc({ key: "", inputs: given, row: buildRow(d) });
|
||||
};
|
||||
|
||||
/* ── 저장 ── */
|
||||
const saveView = (): HTMLElement => {
|
||||
const own = files.filter((f) => f.file.includes("자체"));
|
||||
const message = el("p", { className: "m01w__muted", text: tw("Save_Owner") });
|
||||
const errors = el("div", { className: "m01w__reasons", attrs: { hidden: "" } });
|
||||
let file = own[0]?.file ?? "";
|
||||
const save = createButton({
|
||||
label: tw("Save_Do"),
|
||||
disabled: !own.length,
|
||||
onClick: () => void doSave(file, errors),
|
||||
});
|
||||
return el("div", {
|
||||
className: "m01w__page",
|
||||
children: [
|
||||
el("h3", { text: tw("Q_Save") }),
|
||||
message,
|
||||
own.length
|
||||
? select(
|
||||
own.map((f) => ({ value: f.file, text: f.file })),
|
||||
file,
|
||||
(v) => (file = v),
|
||||
tw("Save_File"),
|
||||
)
|
||||
: el("p", { className: "m01w__bad", text: tw("Save_None") }),
|
||||
el("p", { className: "m01w__muted", text: tw("Save_Owner") }),
|
||||
errors,
|
||||
save,
|
||||
createButton({ label: tw("Save_Do"), onClick: () => void doSave(errors) }),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const doSave = async (file: string, errors: HTMLElement): Promise<void> => {
|
||||
const target = files.find((f) => f.file === file);
|
||||
if (!target) return;
|
||||
const doSave = async (errors: HTMLElement): Promise<void> => {
|
||||
errors.hidden = true;
|
||||
try {
|
||||
const row = buildRow(d);
|
||||
await saveFiles([
|
||||
{ file: target.file, version: target.version, changes: [{ op: "add", row }] },
|
||||
]);
|
||||
const made = await createLogic(buildRow(d));
|
||||
showToast(tw("Save_Done"), "success");
|
||||
const found = await searchLogics(row.이름);
|
||||
const mine = found.logics.filter((l) => l.이름 === row.이름).map((l) => l.키);
|
||||
close();
|
||||
if (mine.length) options.onSaved?.(mine[mine.length - 1]);
|
||||
options.onSaved?.(made.key);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && typeof e.detail === "object" && e.detail !== null) {
|
||||
const list = (e.detail as { errors?: string[]; stale?: string[] }).errors ?? [];
|
||||
const list = (e.detail as { errors?: string[] }).errors ?? [];
|
||||
errors.replaceChildren(...list.map((t) => el("div", { text: t })));
|
||||
errors.hidden = false;
|
||||
} else fail(e);
|
||||
@@ -259,9 +231,6 @@ export function openLogicWizard(options: WizardOptions = {}): void {
|
||||
};
|
||||
|
||||
document.body.append(backdrop);
|
||||
void fetchLogicFiles()
|
||||
.then((f) => (files = f))
|
||||
.catch(fail);
|
||||
go(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { ApiError, type ElementBrief } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { ApiError, type ElementBrief, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export interface TableBrief {
|
||||
file: string;
|
||||
@@ -19,10 +19,26 @@ export interface TableBrief {
|
||||
용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] };
|
||||
}
|
||||
|
||||
/** 표 통째 — `줄` 에서 조건 값 목록을 뽑음 */
|
||||
export interface TableFull extends TableBrief {
|
||||
줄?: Record<string, unknown>[];
|
||||
주?: string[];
|
||||
/** 표 조건 칸의 값만(`/table/options`) — 줄을 통째로 받지 않음 */
|
||||
export interface TableInfo {
|
||||
키: string;
|
||||
이름: string;
|
||||
조건?: Record<string, string>;
|
||||
값칸?: Record<string, string>;
|
||||
/** 고르기 조건 = 줄에 나온 값 차례 · 범위 조건 = [[아래, 위]…] */
|
||||
값?: Record<string, (string | number)[] | [number, number][]>;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
|
||||
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, params: Record<string, string>): Promise<T> {
|
||||
@@ -34,31 +50,58 @@ async function getJson<T>(path: string, params: Record<string, string>): Promise
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 책 + 절 번호의 표 — 소요량·계수 둘을 다 봄 · 하위 절(13-4-1-1)도 같이 */
|
||||
/** 원문 절 한 줄 — `/sections` */
|
||||
export interface SectionBrief {
|
||||
book: string;
|
||||
division: string;
|
||||
chapter: string;
|
||||
chapterNo: string;
|
||||
section: string;
|
||||
title: string;
|
||||
/** 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절 */
|
||||
tables: number;
|
||||
logics: number;
|
||||
}
|
||||
|
||||
export const fetchSections = (book: string, division = ""): Promise<SectionBrief[]> =>
|
||||
getJson<{ sections: SectionBrief[] }>("/sections", {
|
||||
book,
|
||||
...(division ? { division } : {}),
|
||||
limit: "2000",
|
||||
}).then((d) => d.sections);
|
||||
|
||||
/** 그 절(아래 절 포함)의 표 — 소요량·계수 둘을 다 봄 */
|
||||
export async function fetchSectionTables(book: string, section: string): Promise<TableBrief[]> {
|
||||
const found = await Promise.all(
|
||||
["소요량", "계수"].map((group) =>
|
||||
getJson<{ tables: TableBrief[] }>("/tables", { group, q: section, size: "100" }),
|
||||
getJson<{ tables: TableBrief[] }>("/tables", { group, section, size: "100" }),
|
||||
),
|
||||
);
|
||||
return found
|
||||
.flatMap((f) => f.tables)
|
||||
.filter(
|
||||
(t) => t.구분 === book && (t.원문번호 === section || t.원문번호.startsWith(`${section}-`)),
|
||||
);
|
||||
return found.flatMap((f) => f.tables).filter((t) => t.구분 === book);
|
||||
}
|
||||
|
||||
const tableCache = new Map<string, Promise<TableFull>>();
|
||||
const tableCache = new Map<string, Promise<TableInfo>>();
|
||||
|
||||
export function fetchTableFull(file: string, key: string): Promise<TableFull> {
|
||||
export function fetchTableInfo(file: string, key: string): Promise<TableInfo> {
|
||||
let got = tableCache.get(key);
|
||||
if (!got) {
|
||||
got = getJson<{ table: TableFull }>("/table", { file, key }).then((d) => d.table);
|
||||
got = getJson<TableInfo>("/table/options", { file, key });
|
||||
tableCache.set(key, got);
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
/** 자체 로직 만들기 — 키(GX)는 서버가 붙임 */
|
||||
export interface Made {
|
||||
file: string;
|
||||
version: string;
|
||||
key: string;
|
||||
logic: LogicRow;
|
||||
}
|
||||
export const createLogic = (logic: LogicRow): Promise<Made> => postJson("/logic/new", { logic });
|
||||
export const copyLogic = (key: string, name: string): Promise<Made> =>
|
||||
postJson("/logic/copy", { key, 이름: name });
|
||||
|
||||
/** 공표 직종 찾기 */
|
||||
export const searchJobs = (q: string): Promise<{ items: ElementBrief[] }> =>
|
||||
getJson("/pick", { kind: "job", q, limit: "100" });
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { createInputField, createSelectField, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchTableFull,
|
||||
fetchTableInfo,
|
||||
searchLogics,
|
||||
type LogicBrief,
|
||||
type TableBrief,
|
||||
@@ -181,7 +181,7 @@ function tableBox(
|
||||
const load = (key: string): void => {
|
||||
const brief = tables.find((t) => t.키 === key);
|
||||
if (!brief) return (drawDetail(), onChange());
|
||||
void fetchTableFull(brief.file, key)
|
||||
void fetchTableInfo(brief.file, key)
|
||||
.then((full) => {
|
||||
tableInfo.set(key, full);
|
||||
for (const c of Object.keys(full.조건 ?? {})) atom.cond[c] ??= { ask: true };
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Copy.ts
|
||||
* 본떠 만들기 — 이미 있는 로직(정본·자체)을 찾아 새 이름으로 복제(`POST /logic/copy`) · 키 GX 는 서버가 붙임
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { copyLogic, searchLogics, type LogicBrief } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
|
||||
import { fail } from "./M01_MasterData_UI_Logic_Wizard_Ui";
|
||||
|
||||
export function copyBox(done: (key: string) => void): HTMLElement {
|
||||
const find = createInputField({ placeholder: tw("Copy_Find"), type: "search" });
|
||||
const list = el("div", { className: "m01w__found" });
|
||||
const name = createInputField({ placeholder: tw("Copy_Name"), type: "text" });
|
||||
let from: LogicBrief | null = null;
|
||||
const go = createButton({
|
||||
label: tw("Copy_Do"),
|
||||
disabled: true,
|
||||
onClick: () => {
|
||||
if (!from) return;
|
||||
const target = from;
|
||||
void copyLogic(target.키, name.input.value.trim() || `${target.이름} (수정)`)
|
||||
.then((made) => {
|
||||
showToast(tw("Copy_Done"), "success");
|
||||
done(made.key);
|
||||
})
|
||||
.catch(fail);
|
||||
},
|
||||
});
|
||||
let timer = 0;
|
||||
find.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
const q = find.input.value.trim();
|
||||
if (!q) return list.replaceChildren();
|
||||
void searchLogics(q)
|
||||
.then((r) =>
|
||||
list.replaceChildren(
|
||||
...r.logics.slice(0, 8).map((l) => {
|
||||
const row = el("button", {
|
||||
className: "m01w__row",
|
||||
attrs: { type: "button", "data-copy": l.키 },
|
||||
text: `${l.원문번호} ${l.이름} · ${l.결과단위} · ${l.키}`,
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
from = l;
|
||||
name.input.value = `${l.이름} (수정)`;
|
||||
go.disabled = false;
|
||||
list.replaceChildren(row);
|
||||
});
|
||||
return row;
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(fail);
|
||||
}, 250);
|
||||
});
|
||||
return el("div", {
|
||||
className: "m01w__card",
|
||||
children: [el("h4", { text: tw("Copy_Title") }), find.root, list, name.root, go],
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import type { TableBrief, TableFull } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import type { TableBrief, TableInfo } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
|
||||
export type Bind = { ask: true } | { ask: false; value: string };
|
||||
export type Atom =
|
||||
@@ -82,7 +82,7 @@ export const COST_OF: Record<LineKind, string> = {
|
||||
};
|
||||
|
||||
/** 표 통째 — 고르기 목록·조건 종류를 셈할 때 씀(모달이 읽어 두는 곳) */
|
||||
export const tableInfo = new Map<string, TableFull>();
|
||||
export const tableInfo = new Map<string, TableInfo>();
|
||||
|
||||
export const emptyAtom = (): Atom => ({ kind: "value", value: "" });
|
||||
export const emptyQty = (): Qty => ({ atom: emptyAtom(), times: "" });
|
||||
@@ -153,8 +153,7 @@ export const qtyPlain = (q: Qty): string =>
|
||||
export function condChoices(table: string, cond: string): (string | number)[] | undefined {
|
||||
const info = tableInfo.get(table);
|
||||
if (!info || info.조건?.[cond] !== "고르기") return undefined;
|
||||
const seen = (info.줄 ?? []).map((r) => r[cond] as string | number | undefined | null);
|
||||
return [...new Set(seen.filter((v): v is string | number => v !== undefined && v !== null))];
|
||||
return info.값?.[cond] as (string | number)[] | undefined;
|
||||
}
|
||||
|
||||
/** 만드는 중인 것이 모든 식에서 받는 설계 입력 이름들(등장 차례) — 이름 · 모양 */
|
||||
@@ -216,12 +215,12 @@ export function buildRow(d: Draft): LogicRow {
|
||||
const row: LogicRow = {
|
||||
키: "",
|
||||
원문번호: d.section,
|
||||
구분: d.book,
|
||||
상세구분: "자체",
|
||||
구분: "자체",
|
||||
상세구분: "",
|
||||
이름: d.name.trim(),
|
||||
결과단위: d.unit.trim(),
|
||||
출처: source,
|
||||
소유: "개인",
|
||||
소유: "현장",
|
||||
입력: collectInputs(d).map((s) => inputOf(s, d.meta[s.name])),
|
||||
중간: d.middles.map((m): NamedFormula => ({ 이름: m.name, 식: middleText(m), 출처: source })),
|
||||
};
|
||||
@@ -243,7 +242,7 @@ export function buildRow(d: Draft): LogicRow {
|
||||
row.덧줄 = d.extras
|
||||
.filter((e) => e.name.trim() && isNumber(e.pct))
|
||||
.map((e) => extraOf(e, source));
|
||||
row.끝수 = d.round || null;
|
||||
Object.assign(row, { 끝수: d.round ? { 대상: "계", 자리: 0, 방법: d.round } : null });
|
||||
} else row.결과 = { 식: qtyText(d.result) };
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Source.ts
|
||||
* 만들기 둘째 걸음 — 원문 절 고르기(책 → 부문 → 장 → 절) · 그 절(아래 절 포함)의 표를 용도와 함께 제시
|
||||
* 절 목록 = `GET /sections` · 표 = `GET /tables?section=` — 번호를 손으로 넣지 않음
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchSections,
|
||||
fetchSectionTables,
|
||||
type SectionBrief,
|
||||
type TableBrief,
|
||||
} from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import { select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
|
||||
import type { Draft } from "./M01_MasterData_UI_Logic_Wizard_Model";
|
||||
import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text";
|
||||
import { fail, note, page, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui";
|
||||
|
||||
const sections = new Map<string, Promise<SectionBrief[]>>();
|
||||
const load = (book: string): Promise<SectionBrief[]> => {
|
||||
let got = sections.get(book);
|
||||
if (!got) sections.set(book, (got = fetchSections(book)));
|
||||
return got;
|
||||
};
|
||||
|
||||
const distinct = (rows: SectionBrief[], key: "division" | "chapter"): string[] => [
|
||||
...new Set(rows.map((r) => r[key]).filter(Boolean)),
|
||||
];
|
||||
|
||||
export const sourceStep: Step = {
|
||||
key: "S_Source",
|
||||
question: "Q_Source",
|
||||
ready: (d) => d.section.trim() !== "",
|
||||
render: ({ d, changed }) => {
|
||||
const filters = el("div", { className: "m01w__inline" });
|
||||
const list = el("div", { className: "m01w__found" });
|
||||
const picked = el("p", { className: "m01w__picked" });
|
||||
const tables = el("div", { className: "m01w__stack" });
|
||||
let all: SectionBrief[] = [];
|
||||
let division = "";
|
||||
let chapter = "";
|
||||
let q = "";
|
||||
|
||||
const drawTables = (): void =>
|
||||
tables.replaceChildren(
|
||||
...(d.section && !d.tables.length
|
||||
? [note(tw("Section_None")), note(tw("Section_Skip"))]
|
||||
: d.tables.map((t) => tableRow(t, d, changed))),
|
||||
);
|
||||
|
||||
const choose = (s: SectionBrief): void => {
|
||||
d.section = s.section;
|
||||
d.tables = [];
|
||||
picked.textContent = `${tw("Section_Picked")} · ${s.section} ${s.title}`;
|
||||
changed();
|
||||
drawList();
|
||||
void fetchSectionTables(d.book, s.section)
|
||||
.then((found) => {
|
||||
d.tables = [...found]; // 처음에는 이 절의 표를 모두 씀 — 빼려면 체크를 풂
|
||||
drawTables();
|
||||
changed();
|
||||
})
|
||||
.catch(fail);
|
||||
};
|
||||
|
||||
const options = (values: string[]): { value: string; text: string }[] =>
|
||||
values.map((v) => ({ value: v, text: v }));
|
||||
|
||||
const draw = (): void => {
|
||||
const inDivision = all.filter((s) => !division || s.division === division);
|
||||
const chapters = distinct(inDivision, "chapter");
|
||||
if (!chapters.includes(chapter)) chapter = chapters[0] ?? "";
|
||||
const divisions = distinct(all, "division");
|
||||
filters.replaceChildren(
|
||||
select(
|
||||
["산림품셈", "건설품셈"].map((b) => ({ value: b, text: b })),
|
||||
d.book,
|
||||
(v) => {
|
||||
d.book = v;
|
||||
d.section = "";
|
||||
d.tables = [];
|
||||
division = chapter = "";
|
||||
picked.textContent = "";
|
||||
drawTables();
|
||||
changed();
|
||||
start();
|
||||
},
|
||||
tw("Book"),
|
||||
),
|
||||
...(divisions.length
|
||||
? [
|
||||
select(
|
||||
options(divisions),
|
||||
division || divisions[0],
|
||||
(v) => ((division = v), (chapter = ""), draw()),
|
||||
tw("Division"),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
select(options(chapters), chapter, (v) => ((chapter = v), draw()), tw("Chapter")),
|
||||
textBox(q, tw("Section_Search"), (v) => ((q = v.trim()), drawList())),
|
||||
);
|
||||
drawList();
|
||||
};
|
||||
|
||||
const drawList = (): void => {
|
||||
const inChapter = all.filter(
|
||||
(s) =>
|
||||
(!division || s.division === division) &&
|
||||
s.chapter === chapter &&
|
||||
(!q || s.section.startsWith(q) || s.title.includes(q)),
|
||||
);
|
||||
list.replaceChildren(
|
||||
...inChapter.slice(0, 80).map((s) => {
|
||||
const row = el("button", {
|
||||
className: `m01w__row${s.section === d.section ? " is-active" : ""}`,
|
||||
attrs: { type: "button", "data-section": s.section },
|
||||
text: `${s.section} ${s.title} · ${tw("Section_Counts", { t: s.tables, l: s.logics })}`,
|
||||
});
|
||||
row.addEventListener("click", () => choose(s));
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const start = (): void => {
|
||||
void load(d.book)
|
||||
.then((rows) => {
|
||||
all = rows;
|
||||
const divisions = distinct(rows, "division");
|
||||
division = divisions[0] ?? "";
|
||||
const own = rows.find((s) => s.section === d.section);
|
||||
if (own) {
|
||||
division = own.division;
|
||||
chapter = own.chapter;
|
||||
picked.textContent = `${tw("Section_Picked")} · ${own.section} ${own.title}`;
|
||||
}
|
||||
draw();
|
||||
})
|
||||
.catch(fail);
|
||||
};
|
||||
start();
|
||||
drawTables();
|
||||
return page("Q_Source", filters, list, picked, tables);
|
||||
},
|
||||
};
|
||||
|
||||
function tableRow(t: TableBrief, d: Draft, changed: () => void): HTMLElement {
|
||||
const check = el("input", { attrs: { type: "checkbox", "data-table": t.키 } });
|
||||
check.checked = d.tables.some((x) => x.키 === t.키);
|
||||
check.addEventListener("change", () => {
|
||||
d.tables = check.checked ? [...d.tables, t] : d.tables.filter((x) => x.키 !== t.키);
|
||||
changed();
|
||||
});
|
||||
const usage = t.용도?.대상?.join("·") ?? "";
|
||||
return el("label", {
|
||||
className: "m01w__table",
|
||||
children: [
|
||||
check,
|
||||
el("div", {
|
||||
className: "m01w__stack",
|
||||
children: [
|
||||
el("strong", { text: `${t.원문번호} ${t.이름} · ${t.키}` }),
|
||||
el("span", {
|
||||
className: "m01w__muted",
|
||||
text: [
|
||||
t.기준 ? `${tw("Table_Base")} ${t.기준}` : "",
|
||||
`${tw("Table_Cols")} ${Object.keys(t.값칸 ?? {}).join("·")}`,
|
||||
usage ? `${tw("Table_Usage")} ${usage}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -6,11 +6,7 @@
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { searchElements, searchPrice } from "./M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
fetchSectionTables,
|
||||
searchJobs,
|
||||
type TableBrief,
|
||||
} from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import { searchJobs, type TableBrief } from "./M01_MasterData_UI_Logic_Wizard_Api";
|
||||
import { atomEditor, select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom";
|
||||
import {
|
||||
BASES,
|
||||
@@ -31,26 +27,11 @@ import {
|
||||
} from "./M01_MasterData_UI_Logic_Wizard_Model";
|
||||
import { tw, type WizardTextKey } from "./M01_MasterData_UI_Logic_Wizard_Text";
|
||||
|
||||
export interface Ctx {
|
||||
d: Draft;
|
||||
/** 값이 바뀜 — 미리보기·다음 단추 다시 셈 */
|
||||
changed: () => void;
|
||||
}
|
||||
export interface Step {
|
||||
key: WizardTextKey;
|
||||
question: WizardTextKey;
|
||||
render: (ctx: Ctx) => HTMLElement;
|
||||
ready: (d: Draft) => boolean;
|
||||
}
|
||||
import { copyBox } from "./M01_MasterData_UI_Logic_Wizard_Copy";
|
||||
import { sourceStep } from "./M01_MasterData_UI_Logic_Wizard_Source";
|
||||
import { fail, note, page, type Ctx, type Step } from "./M01_MasterData_UI_Logic_Wizard_Ui";
|
||||
|
||||
const page = (question: WizardTextKey, ...body: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01w__page",
|
||||
children: [el("h3", { text: tw(question) }), ...body],
|
||||
});
|
||||
const note = (text: string): HTMLElement => el("p", { className: "m01w__muted", text });
|
||||
const fail = (e: unknown): void =>
|
||||
showToast(e instanceof Error ? e.message : tw("Failed"), "error");
|
||||
export type { Ctx, Step };
|
||||
|
||||
/* ── 1. 이름 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -60,7 +41,7 @@ const nameStep: Step = {
|
||||
key: "S_Name",
|
||||
question: "Q_Name",
|
||||
ready: (d) => d.name.trim() !== "" && d.unit.trim() !== "",
|
||||
render: ({ d, changed }) => {
|
||||
render: ({ d, changed, onCopied }) => {
|
||||
const other = !UNITS.includes(d.unit);
|
||||
const own = el("div");
|
||||
const drawOwn = (on: boolean): void =>
|
||||
@@ -94,104 +75,11 @@ const nameStep: Step = {
|
||||
),
|
||||
own,
|
||||
note(tw("Unit_Money")),
|
||||
copyBox(onCopied),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/* ── 2. 원문 절 ──────────────────────────────────────────────────────── */
|
||||
|
||||
const sourceStep: Step = {
|
||||
key: "S_Source",
|
||||
question: "Q_Source",
|
||||
ready: (d) => d.section.trim() !== "",
|
||||
render: ({ d, changed }) => {
|
||||
const list = el("div", { className: "m01w__stack" });
|
||||
const box = createInputField({
|
||||
value: d.section,
|
||||
placeholder: tw("Section_Ph"),
|
||||
type: "text",
|
||||
});
|
||||
const draw = (found: TableBrief[], searched: boolean): void => {
|
||||
list.replaceChildren(
|
||||
...(found.length
|
||||
? found.map((t) => tableRow(t, d, changed))
|
||||
: searched
|
||||
? [note(tw("Section_None")), note(tw("Section_Skip"))]
|
||||
: []),
|
||||
);
|
||||
};
|
||||
const find = (): void => {
|
||||
d.section = box.input.value.trim();
|
||||
d.tables = [];
|
||||
changed();
|
||||
if (!d.section) return draw([], false);
|
||||
void fetchSectionTables(d.book, d.section)
|
||||
.then((found) => {
|
||||
d.tables = [...found]; // 처음에는 이 절의 표를 모두 씀 — 빼려면 체크를 풂
|
||||
draw(found, true);
|
||||
changed();
|
||||
})
|
||||
.catch(fail);
|
||||
};
|
||||
box.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find());
|
||||
box.input.addEventListener("input", () => {
|
||||
d.section = box.input.value.trim();
|
||||
changed();
|
||||
});
|
||||
if (d.section && !d.tables.length) find();
|
||||
else draw(d.tables, d.tables.length > 0);
|
||||
return page(
|
||||
"Q_Source",
|
||||
select(
|
||||
["산림품셈", "건설품셈"].map((b) => ({ value: b, text: b })),
|
||||
d.book,
|
||||
(v) => {
|
||||
d.book = v;
|
||||
find();
|
||||
},
|
||||
tw("Book"),
|
||||
),
|
||||
el("div", {
|
||||
className: "m01w__inline",
|
||||
children: [box.root, createButton({ label: tw("Section_Find"), onClick: find })],
|
||||
}),
|
||||
list,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function tableRow(t: TableBrief, d: Draft, changed: () => void): HTMLElement {
|
||||
const check = el("input", { attrs: { type: "checkbox", "data-table": t.키 } });
|
||||
check.checked = d.tables.some((x) => x.키 === t.키);
|
||||
check.addEventListener("change", () => {
|
||||
d.tables = check.checked ? [...d.tables, t] : d.tables.filter((x) => x.키 !== t.키);
|
||||
changed();
|
||||
});
|
||||
const usage = t.용도?.대상?.join("·") ?? "";
|
||||
return el("label", {
|
||||
className: "m01w__table",
|
||||
children: [
|
||||
check,
|
||||
el("div", {
|
||||
className: "m01w__stack",
|
||||
children: [
|
||||
el("strong", { text: `${t.이름} · ${t.키}` }),
|
||||
el("span", {
|
||||
className: "m01w__muted",
|
||||
text: [
|
||||
t.기준 ? `${tw("Table_Base")} ${t.기준}` : "",
|
||||
`${tw("Table_Cols")} ${Object.keys(t.값칸 ?? {}).join("·")}`,
|
||||
usage ? `${tw("Table_Usage")} ${usage}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 3. 중간 값(조건 나누기) ─────────────────────────────────────────── */
|
||||
|
||||
const newMiddle = (): Middle => ({ name: "", branches: [], otherwise: emptyAtom() });
|
||||
@@ -643,8 +531,9 @@ const extraStep: Step = {
|
||||
select(
|
||||
[
|
||||
{ value: "", text: tw("Round_None") },
|
||||
{ value: "원 미만 버림", text: tw("Round_Down") },
|
||||
{ value: "원 미만 반올림", text: tw("Round_Half") },
|
||||
{ value: "버림", text: tw("Round_Down") },
|
||||
{ value: "올림", text: tw("Round_Up") },
|
||||
{ value: "반올림", text: tw("Round_Half") },
|
||||
],
|
||||
d.round,
|
||||
(v) => {
|
||||
@@ -652,7 +541,6 @@ const extraStep: Step = {
|
||||
changed();
|
||||
},
|
||||
),
|
||||
note(tw("Round_Note")),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -162,3 +162,8 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.m01w__row.is-active {
|
||||
border-color: var(--color-accent, #2b6cb0);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,20 @@ const TEXT = {
|
||||
"Units starting with 원 give money",
|
||||
],
|
||||
Unit_Other: ["그 밖 (직접)", "Other"],
|
||||
Copy_Title: ["또는 이미 있는 로직을 본떠 만들기", "Or copy an existing logic"],
|
||||
Copy_Find: ["본뜰 로직 이름·번호 찾기", "Find a logic to copy"],
|
||||
Copy_Name: ["새 이름", "New name"],
|
||||
Copy_Do: ["본떠서 저장", "Copy and save"],
|
||||
Copy_Done: ["본떠서 저장했습니다", "Copied"],
|
||||
Unit_Own_Ph: ["예: ㎥/시간", "e.g. ㎥/h"],
|
||||
|
||||
Q_Source: ["어느 원문 절을 옮기나요?", "Which source section?"],
|
||||
Book: ["책", "Book"],
|
||||
Section_Ph: ["절 번호 (예: 13-4-1)", "Section (e.g. 13-4-1)"],
|
||||
Section_Find: ["표 찾기", "Find tables"],
|
||||
Division: ["부문", "Division"],
|
||||
Chapter: ["장", "Chapter"],
|
||||
Section_Search: ["절 번호·제목 찾기", "Search sections"],
|
||||
Section_Counts: ["표 {t} · 로직 {l}", "{t} tables · {l} logics"],
|
||||
Section_Picked: ["고른 절", "Chosen section"],
|
||||
Section_None: [
|
||||
"이 절에 쓰는 표가 없습니다 — 절 번호를 확인해 주세요",
|
||||
"No tables in this section",
|
||||
@@ -112,8 +120,8 @@ const TEXT = {
|
||||
Round_Title: ["끝수", "Rounding"],
|
||||
Round_None: ["처리 안 함", "None"],
|
||||
Round_Down: ["원 미만 버림", "Round down"],
|
||||
Round_Up: ["원 미만 올림", "Round up"],
|
||||
Round_Half: ["원 미만 반올림", "Round half"],
|
||||
Round_Note: ["끝수는 적어만 둠 — 계산에는 아직 안 붙음", "Rounding is recorded only"],
|
||||
|
||||
Q_Test: ["견본 값을 넣어 계산해 보세요", "Try sample values"],
|
||||
Test_Run: ["시험 계산", "Calculate"],
|
||||
@@ -126,11 +134,9 @@ const TEXT = {
|
||||
Sum_Result: ["결과 값", "Result"],
|
||||
|
||||
Q_Save: ["이대로 저장할까요?", "Save as is?"],
|
||||
Save_Owner: ["개인 로직으로 저장됩니다 — 키는 저장할 때 서버가 붙임", "Saved as personal logic"],
|
||||
Save_File: ["저장할 파일", "File"],
|
||||
Save_None: [
|
||||
"자체 로직 파일이 아직 없어 저장할 수 없습니다 — 서버 준비 뒤 저장됨",
|
||||
"No own-logic file yet",
|
||||
Save_Owner: [
|
||||
"자체 로직으로 저장됩니다 — 키(GX…)는 저장할 때 서버가 붙임",
|
||||
"Saved as your own logic; the server issues the key",
|
||||
],
|
||||
Save_Do: ["저장", "Save"],
|
||||
Save_Done: ["저장했습니다", "Saved"],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Wizard_Ui.ts
|
||||
* 「새로 만들기」 걸음 화면이 같이 쓰는 작은 부품 — 걸음 모양 · 쪽 · 안내 글 · 오류 알림
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import type { Draft } from "./M01_MasterData_UI_Logic_Wizard_Model";
|
||||
import { tw, type WizardTextKey } from "./M01_MasterData_UI_Logic_Wizard_Text";
|
||||
|
||||
export interface Ctx {
|
||||
d: Draft;
|
||||
/** 값이 바뀜 — 미리보기·다음 단추 다시 셈 */
|
||||
changed: () => void;
|
||||
/** 본떠 만들기가 끝남 — 모달이 닫고 새 키를 넘김 */
|
||||
onCopied: (key: string) => void;
|
||||
}
|
||||
export interface Step {
|
||||
key: WizardTextKey;
|
||||
question: WizardTextKey;
|
||||
render: (ctx: Ctx) => HTMLElement;
|
||||
ready: (d: Draft) => boolean;
|
||||
}
|
||||
|
||||
export const page = (question: WizardTextKey, ...body: (HTMLElement | string)[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01w__page",
|
||||
children: [el("h3", { text: tw(question) }), ...body],
|
||||
});
|
||||
export const note = (text: string): HTMLElement => el("p", { className: "m01w__muted", text });
|
||||
export const fail = (e: unknown): void =>
|
||||
showToast(e instanceof Error ? e.message : tw("Failed"), "error");
|
||||
@@ -38,7 +38,7 @@
|
||||
- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴.
|
||||
- `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A·B·C 가 부품 `M01_MasterData_UI_Test_Pick.ts` 하나를 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘). `값` 이 비어 있어도 후보로 둠. 옛 재료 줄(품셈재료 키)은 품셈재료 이름(없으면 이름 첫 낱말)으로 `pick?kind=price` 후보. 후보 글 = 이름 규격 · 값 있는 열 전부(물가자료 · 유통물가 …). 멈춤 까닭 `입력 「X」 없음` · `맞는 줄 0개` 는 방식 A·B·C 가 쉬운 말로 바꿔 보임(`plainReason`) · 빈 수 칸은 「값을 넣어 주세요」 안내 글.
|
||||
- 소요량·계수 표의 `용도`({공종, 대상, 로직키}) = 방식 B 원문 표 미리보기 머리에 안내 · 칸이 없으면 안 보임 · `/tables` 는 `usage` 로 `용도.대상` 거름(기계 `sub`·`detail` 은 인력과 같이 구분 → 상세구분).
|
||||
- 「새로 만들기」 모달(`M01_MasterData_UI_Logic_Wizard*.ts`) — 읽기는 기존 길만 씀: 원문 절 = `/tables`(`group` 소요량·계수 · `q` 절 번호)를 책·원문번호로 거름 · 표 조건 값 목록 = `/table` 줄에서 화면이 뽑음 · 인력 = `/pick?kind=job` · 재료 = `/pick?kind=price` 로 찾아 구분·상세구분 조건 묶음으로 넣음 · 기계 = `/elements` · 다른 로직 = `/logics?q`. 시험 계산 = `/calc` `row`+`file`(키 "") · 저장 = `/save` `add` — 파일 이름에 「자체」 가 든 로직 파일이 있어야 저장 단추가 열림.
|
||||
- 「새로 만들기」 모달(`M01_MasterData_UI_Logic_Wizard*.ts`) — 원문 절 = `/sections` · 그 절의 표 = `/tables?section=`(소요량·계수) · 표 조건 값 = `/table/options` · 인력 = `/pick?kind=job` · 재료 = `/pick?kind=price`(구분·상세구분 조건 묶음으로 넣음) · 기계 = `/elements` · 다른 로직 = `/logics?q`. 시험 계산 = `/calc` 키 "" + `row`(파일 안 보냄) · 저장 = `/logic/new`(소유 `현장` · 키 GX 는 서버) · 본떠 만들기 = `/logic/copy`. 끝수는 묶음 `{대상: 계, 자리: 0, 방법}` 으로 보냄.
|
||||
- `section` = 절 번호 거름 — 그 절과 그 아래 절만(`13-4` 는 `13-4` · `13-4-1` 만 · `13-40` 은 안 걸림). 「만들기」가 절을 고르면 그 절의 표만 보임.
|
||||
- `/sections` = 만들기 첫 걸음 — 원문 본문의 절 제목 줄에서 모음(`book` = 산림품셈 · 건설품셈 · `division` = 건설품셈 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) · `chapter` = 장 번호 두 자리 「13」 · `q` = 절 번호·제목 찾기). `chapter` = 「부문 NN장 장 제목」 · `chapterNo` = 「NN」 · `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절.
|
||||
- `/table/options` = 조건 값 고르기 — `고르기` 조건은 줄에 나온 값 차례대로(중복 없음) · `범위` 조건은 `[[아래, 위]…]` · `count` = 줄 수. 표 몸을 통째로 받지 않으려는 자리에만 씀(통째는 `/table`).
|
||||
|
||||
Reference in New Issue
Block a user