Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2

This commit is contained in:
2026-10-01 11:41:51 +09:00
4 changed files with 392 additions and 209 deletions
@@ -173,13 +173,14 @@ async function repick(ctx: Ctx, card: EasyCard): Promise<void> {
if (picking || card.kind === "locked" || card.kind === "percent") return;
picking = true;
try {
const pick = await openEasyPick(card.kind);
const pick = await openEasyPick(card.kind, { qty: card.qty });
if (!pick) return;
const next = updateCard(ctx.doc, card.id, {
name: pick.name,
spec: pick.spec,
unit: pick.unit,
element: pick.element,
qty: pick.qty,
});
ctx.on.change(withInputs(next, pick.needInputs ?? []));
} finally {
+330 -194
View File
@@ -1,8 +1,10 @@
/* =============================================================================
* M01_EasyCalc_Entry.ts
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들)
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
* 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음.
* 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다
* EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본)
* · 지우기(usesOf 먼저 · 물음 한 번) · Ctrl+Z · Ctrl+S
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -16,15 +18,32 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import {
calcLater,
copyEasy,
deleteEasy,
editEasy,
listEasy,
newEasy,
openEasy,
plainError,
usesOf,
type EasyListItem,
type EasyOpened,
} from "./M01_EasyCalc_Api";
import type { EasyDoc } from "./M01_EasyCalc_Model";
import { renderCards } from "./M01_EasyCalc_Cards";
import {
checkName,
EasyHistory,
newDoc,
setEasyModelLanguage,
toDoc,
toRow,
type EasyCard,
type EasyDoc,
} from "./M01_EasyCalc_Model";
import { renderSheet, renderTrialStrip } from "./M01_EasyCalc_Sheet";
import "./M01_EasyCalc_Style.css";
const TEXT = {
@@ -42,10 +61,8 @@ const TEXT = {
Start_Found: ["찾은 것", "Found"],
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
Start_None: ["찾은 것 없음", "Nothing found"],
Base: ["품셈 기본", "Standard"],
Mine: ["내가 만든 것", "Made by me"],
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
Per: ["{u} 당", "per {u}"],
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
Name: ["이름", "Name"],
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
Spec: ["크기", "Size"],
@@ -55,15 +72,27 @@ const TEXT = {
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함", "Not saved"],
BaseNote: [
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
"Standard items are read-only — use the button below to make your own copy",
],
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
Trial: ["설계 때 정하는 값", "Values set at design time"],
Copying: ["내 것으로 만드는 중", "Making your copy"],
Copied: ["내 것으로 만들었음 — 이제 고칠 수 있음", "Your copy is ready — you can edit it now"],
Cards: ["무엇이 들어가나", "What goes in"],
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
NotYet: ["준비 중", "Coming soon"],
BadLines: [
"⚠ 표시 줄을 먼저 고쳐 주세요 — 빈 이름 · 숫자 아님 · 0 · 0 보다 작은 수는 저장 안 됨",
"Fix the ⚠ lines first — empty names, non-numbers, 0 or negative amounts can't be saved",
],
Uses_Wait: ["쓰는 곳을 찾는 중 — 10초쯤 걸림", "Looking for places that use it — about 10 s"],
Uses_Block: ["쓰는 곳이 있어 못 지움 — {v}", "Cannot delete — it is used by {v}"],
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Delete_Ok: ["지우기", "Delete"],
Deleted: ["지웠음", "Deleted"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
@@ -82,68 +111,6 @@ const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
/** 목록에 한 번에 보이는 줄 수 */
const SHOWN = 50;
/* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */
const loadList = (): Promise<EasyListItem[]> =>
listEasy("").catch(() =>
fetchLogics().then((all) =>
all.map((x) => {
const isBase = !x.키.startsWith("GX");
const canOpen = x.결과단위.startsWith("원/");
return {
key: x.키,
name: x.이름,
isBase,
owner: isBase ? te("Base") : te("Mine"),
resultUnit: x.결과단위,
canOpen,
whyNot: canOpen ? null : te("CannotOpen"),
where: `${x.구분} ${x.상세구분}`.trim(),
};
}),
),
);
const loadOne = (key: string): Promise<EasyOpened> =>
openEasy(key).catch(() =>
fetchLogic(key).then((one) => ({
row: one.logic,
version: one.version,
isBase: !one.logic.키.startsWith("GX"),
trial: {},
})),
);
const plain = (error: unknown): string => {
try {
return plainError(error);
} catch {
return error instanceof Error ? error.message : String(error);
}
};
/** 빈 종이 — 꼭 채울 칸만(검토 A 「꼭 채울 것」) · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
const blankRow = (resultUnit: string): LogicRow => ({
키: "",
원문번호: "",
이름: "",
결과단위: resultUnit,
출처: "자체",
입력: [],
중간: [],
호표: [],
덧줄: [],
끝수: null,
});
const docOf = (opened: EasyOpened, key: string | null): EasyDoc => ({
key,
version: opened.version,
isBase: opened.isBase,
name: opened.row.이름,
spec: "",
resultUnit: opened.row.결과단위,
inputs: opened.row.입력 ?? [],
cards: [],
row: opened.row,
});
/** 「원/㎡」 → 「㎡」 */
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */
@@ -152,50 +119,82 @@ const amountOf = (resultUnit: string): string => {
return /^\d/.test(unit) ? unit : `1${unit}`;
};
/** 저장을 막는 줄 — 이름 빔 · 수량이 숫자 아님 · 0 · 음수(서버가 0 · 음수를 받아 줌) · 같은 줄 · 너무 큼은 경고만 */
const blocksSave = (c: EasyCard): boolean =>
!c.name.trim() ||
(c.qtyFormula === null && !(c.qty !== null && Number.isFinite(c.qty) && c.qty > 0));
/** 카드를 넣어 새로 생긴 설계 값의 시험값 — 서버 자동값(`master_auto.values`)과 같은 규칙:
* 고르기 = 첫째 · 범위 = 낮은 끝 · 그 밖 숫자 0 · 값이 「원」 인 가격 값은 비워 둠(0 원처럼 안 보이게) */
function fillNew(
inputs: LogicInput[],
trial: Record<string, unknown>,
known: Set<string>,
): Record<string, unknown> {
const out = { ...trial };
for (const spec of inputs) {
if (known.has(spec.이름)) continue;
known.add(spec.이름);
if (out[spec.이름] !== undefined || String(spec.단위 ?? "").includes("원")) continue;
out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0);
}
return out;
}
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
export function createEasyCalcButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
}
function openEasyCalc(): void {
setEasyModelLanguage(currentLanguageIndex);
let body: HTMLElement;
let paper: EasyDoc | null = null;
let dirty = false;
/** 고치는 화면이 열려 있을 때만 */
let paper: { history: EasyHistory; save: () => Promise<void>; undo: () => void } | null = null;
let logics: EasyListItem[] | null = null;
let query = "";
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
const mayLeave = async (): Promise<boolean> =>
!paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
!paper?.history.dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const notYet = (): void => showToast(te("NotYet"), "info");
/** Ctrl+Z · Ctrl+S — 이 창 안에서만(고르기 창 등 다른 창은 제 몫) */
const onKey = (ev: KeyboardEvent): void => {
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
};
/* ---------- 시작 화면 ---------- */
const listRow = (item: EasyListItem): HTMLElement => {
const ok = item.canOpen;
const row = el("button", {
className: "m01-easycalc__item",
attrs: { type: "button" },
attrs: { type: "button", title: item.where },
children: [
el("span", { className: "m01-easycalc__item-name", text: item.name }),
createTag(item.owner, item.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-easycalc__item-unit",
text: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
text: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
}),
...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]),
...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]),
],
});
row.disabled = !ok;
row.title = item.where;
if (item.whyNot) row.title = item.whyNot;
row.disabled = !item.canOpen;
row.addEventListener("click", () => void openPaper(item.key));
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
dirty = false;
const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") });
const mineHost = el("div", { className: "m01-easycalc__list" });
const foundHost = el("div", { className: "m01-easycalc__list" });
@@ -252,9 +251,9 @@ function openEasyCalc(): void {
search.input.focus();
try {
logics ??= await loadList();
logics ??= await listEasy("");
} catch (error) {
status.textContent = te("LoadFailed", { v: plain(error) });
status.textContent = te("LoadFailed", { v: plainError(error) });
return;
}
const mine = logics.filter((x) => !x.isBase);
@@ -266,85 +265,73 @@ function openEasyCalc(): void {
drawFound();
};
/* ---------- 고치는 화면 ---------- */
const openBlank = (): void => {
const row = blankRow(unitChoices()[0] ?? "원/㎡");
paper = { ...docOf({ row, version: "", isBase: false, trial: {} }, null), version: null };
showPaper();
};
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
try {
paper = docOf(await loadOne(key), key);
showPaper();
} catch (error) {
showToast(plain(error), "error");
void showStart();
}
};
/** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */
/** 단위 고르기 후보 — 목록에 다섯 번 넘게 쓰인 「원/…」 차례 · 설명 붙은 드문 단위는 뺌(연 로직 것은 따로 더함) */
const unitChoices = (): string[] => {
const count = new Map<string, number>();
for (const x of logics ?? []) {
if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
}
return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u);
return [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
};
const showPaper = (): void => {
const p = paper;
if (!p) return;
const readOnly = p.isBase;
/* ---------- 고치는 화면 ---------- */
const openBlank = (): void => showPaper(newDoc("", unitOf(unitChoices()[0] ?? "원/㎡")), {});
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
try {
const opened = await openEasy(key);
showPaper(
toDoc(opened.row, { key, version: opened.version, isBase: opened.isBase }),
opened.trial,
);
} catch (error) {
showToast(plainError(error), "error");
void showStart();
}
};
const showPaper = (first: EasyDoc, firstTrial: Record<string, unknown>): void => {
let doc = first;
let trial = firstTrial;
let ans: CalcAnswer | null = null;
let history = new EasyHistory();
history.push(doc);
let busy = false;
const unsaved = createTag(te("Unsaved"), "warning");
unsaved.hidden = true;
const total = el("div", { className: "m01-easycalc__total" });
const drawTotal = (): void => {
total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" });
};
const touched = (): void => {
dirty = true;
unsaved.hidden = false;
};
const note = el("p", { className: "m01-easycalc__note", attrs: { role: "status" } });
const trialHost = el("div", { className: "m01-easycalc__part m01-easycalc__trial" });
const cardsHost = el("div", { className: "m01-easycalc__cards" });
const sheetHost = el("div");
let trialShape = "";
/** 연 때 있던 설계 값 — 서버 자동값을 그대로 둠 · 이 밖에 새로 생긴 값만 채움 */
const known = new Set(doc.inputs.map((i) => i.이름));
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: p.name,
onInput: (v) => {
p.name = v;
touched();
},
onInput: (v) => edit({ ...doc, name: v }),
});
const spec = createInputField({
label: te("Spec"),
value: p.spec,
onInput: (v) => {
p.spec = v;
touched();
},
});
const units = unitChoices();
if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit);
name.root.classList.add("m01-easycalc__name");
const spec = createInputField({ label: te("Spec"), onInput: (v) => edit({ ...doc, spec: v }) });
const unit = createSelectField({
label: te("Unit"),
options: units.map((u) => ({ value: u, text: unitOf(u) })),
value: p.resultUnit,
disabled: readOnly,
onChange: (v) => {
p.resultUnit = v;
drawTotal();
touched();
},
options: [],
onChange: (v) => edit({ ...doc, resultUnit: v }),
});
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: () => doUndo() });
const saveButton = createButton({ label: te("Save"), onClick: () => void doSave() });
const deleteButton = createButton({
label: te("Delete"),
variant: "danger",
onClick: () => void doDelete(),
});
name.input.disabled = readOnly;
spec.input.disabled = readOnly;
name.root.classList.add("m01-easycalc__name");
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
undo.disabled = true;
const close = createButton({
label: te("Close"),
variant: "ghost",
@@ -353,62 +340,210 @@ function openEasyCalc(): void {
if (await mayLeave()) void showStart();
})(),
});
const actions = readOnly
? [close]
: [
undo,
createButton({ label: te("Save"), onClick: notYet }),
...(p.key && !p.isBase
? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })]
: []),
close,
];
const section = (title: string, extra = ""): HTMLElement =>
el("section", {
className: `m01-easycalc__part ${extra}`.trim(),
children: [
el("h4", { text: title }),
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
],
});
const sheet = el("details", {
className: "m01-easycalc__part m01-easycalc__sheet",
const actions = el("div", { className: "m01-easycalc__actions" });
const baseBar = el("div", {
className: "m01-easycalc__base",
children: [
el("summary", { text: te("Sheet") }),
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
el("p", { text: te("BaseNote") }),
createBigButton(te("BaseCopy"), () => void doCopy()),
],
});
drawTotal();
/** 위 띠 칸을 doc 로 — 열 때 · 되돌릴 때 · 저장 뒤 */
const fillStrip = (): void => {
const units = unitChoices();
if (!units.includes(doc.resultUnit)) units.unshift(doc.resultUnit);
unit.setOptions(
units.map((u) => ({ value: u, text: unitOf(u) })),
doc.resultUnit,
);
name.input.value = doc.name;
spec.input.value = doc.spec;
for (const box of [name.input, spec.input, unit.select]) box.disabled = doc.isBase;
actions.replaceChildren(
...(doc.isBase ? [] : [undo, saveButton]),
...(doc.key && !doc.isBase ? [deleteButton] : []),
close,
);
baseBar.hidden = !doc.isBase;
};
/** 금액 · 카드 · 표 — 바뀔 때마다(pending) · 계산 답이 올 때마다(answer) */
const draw = (pending: boolean, answer = false): void => {
const value = ans?.ok ? formatMoney(ans.result ?? ans.sums?.["계"] ?? 0) : "—";
total.textContent = te("Total", { u: amountOf(doc.resultUnit), v: value });
total.classList.toggle("is-pending", pending);
sheetHost.classList.toggle("is-pending", pending);
unsaved.hidden = !history.dirty;
undo.disabled = !history.canUndo;
const shape = JSON.stringify(doc.inputs);
if (shape !== trialShape) {
trialShape = shape;
trial = fillNew(doc.inputs, trial, known);
renderTrialStrip(trialHost, doc.inputs, trial, (next) => {
trial = next;
calc();
});
}
drawCards(answer);
renderSheet(sheetHost, { name: doc.name, resultUnit: doc.resultUnit }, ans);
};
/** 계산 답만 왔을 때 카드 칸에 글을 넣는 중이면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게).
* doc 가 바뀌면 늘 바로 그림 — 카드가 옛 doc 를 쥐고 있으면 그다음 조작이 옛 판본 위에 쌓임 */
let waiting = false;
const drawCards = (answer = false): void => {
const active = document.activeElement;
if (answer && active instanceof HTMLInputElement && cardsHost.contains(active)) {
if (!waiting) {
waiting = true;
cardsHost.addEventListener(
"focusout",
() =>
setTimeout(() => {
waiting = false;
drawCards(true);
}),
{ once: true },
);
}
return;
}
renderCards(cardsHost, doc, ans, { change: edit, trial: () => trial });
};
const calc = (): void => {
draw(true);
calcLater(toRow(doc), trial, (next, plain) => {
ans = next;
note.textContent = plain ?? "";
draw(false, true);
});
};
const edit = (next: EasyDoc): void => {
doc = next;
history.push(doc);
calc();
};
const doUndo = (): void => {
const back = history.undo();
if (!back) return;
doc = back;
fillStrip();
calc();
};
/** 저장 뒤 · 본뜬 뒤 — 서버가 준 줄로 다시 엶 · 되돌리기 기록은 여기서 새로 */
const reopen = (next: EasyDoc): void => {
doc = next;
history = new EasyHistory();
history.push(doc);
logics = null;
fillStrip();
calc();
};
const doSave = async (): Promise<void> => {
if (doc.isBase || busy) return;
const mine = (logics ?? []).filter((x) => !x.isBase && x.key !== doc.key).map((x) => x.name);
const bad = checkName(doc.name, mine);
name.setError(bad ?? undefined);
if (bad) return void name.input.focus();
if (doc.cards.some(blocksSave)) return void (note.textContent = te("BadLines"));
busy = true;
saveButton.disabled = true;
note.textContent = te("Saving");
try {
const row = toRow(doc);
const saved =
doc.key && doc.version ? await editEasy(doc.key, doc.version, row) : await newEasy(row);
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
note.textContent = "";
showToast(te("Saved"), "success");
} catch (error) {
note.textContent = `${te("SaveFailed")} — ${plainError(error)}`;
} finally {
busy = false;
saveButton.disabled = false;
}
};
const doCopy = async (): Promise<void> => {
if (!doc.key || busy) return;
busy = true;
note.textContent = te("Copying");
try {
const saved = await copyEasy(doc.key);
reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false }));
showToast(te("Copied"), "success");
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
}
};
const doDelete = async (): Promise<void> => {
if (!doc.key || !doc.version || doc.isBase || busy) return;
busy = true;
deleteButton.disabled = true;
note.textContent = te("Uses_Wait");
try {
const uses = await usesOf(doc.key);
const blocking = uses.filter((u) => u.key);
if (blocking.length) {
note.textContent = te("Uses_Block", {
v: blocking.map((u) => (u.where ? `${u.where} ${u.name}` : u.name)).join(", "),
});
return;
}
note.textContent = uses.map((u) => u.name).join(" · ");
if (!(await showConfirmDialog(te("Delete_Ask", { v: doc.name }), te("Delete_Ok")))) {
return;
}
await deleteEasy(doc.key, doc.version);
logics = null;
paper = null;
showToast(te("Deleted"), "success");
void showStart();
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
deleteButton.disabled = false;
}
};
paper = {
get history() {
return history;
},
save: doSave,
undo: doUndo,
};
body.replaceChildren(
el("div", {
className: "m01-easycalc__strip",
children: [
name.root,
spec.root,
unit.root,
total,
unsaved,
el("div", { className: "m01-easycalc__actions", children: actions }),
],
children: [name.root, spec.root, unit.root, total, unsaved, actions],
}),
note,
baseBar,
trialHost,
el("section", {
className: "m01-easycalc__part",
children: [el("h4", { text: te("Cards") }), cardsHost],
}),
el("details", {
className: "m01-easycalc__part m01-easycalc__sheet",
children: [el("summary", { text: te("Sheet") }), sheetHost],
}),
...(readOnly
? [
el("div", {
className: "m01-easycalc__base",
children: [
el("p", { text: te("BaseNote") }),
createBigButton(te("BaseCopy"), notYet),
],
}),
]
: []),
section(te("Trial"), "m01-easycalc__trial"),
section(te("Cards"), "m01-easycalc__cards"),
sheet,
);
if (!readOnly) name.input.focus();
fillStrip();
calc();
if (!doc.isBase) name.input.focus();
};
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
@@ -419,6 +554,7 @@ function openEasyCalc(): void {
beforeClose: mayLeave,
mount: (host) => {
body = host;
(host.parentElement ?? host).addEventListener("keydown", onKey);
void showStart();
},
});
+44 -14
View File
@@ -15,12 +15,14 @@ import {
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch";
import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api";
import { fetchLogic, fetchMaterials, type ElementBrief } from "../M01_MasterData_UI_Logic_Api";
import { listEasy, listMachines, machineElement, plainError } from "./M01_EasyCalc_Api";
import type { PickResult } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Pick.css";
type Kind = PickResult["kind"];
/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */
type Brief = ElementBrief & { 구분?: string; 상세구분?: string };
const TEXT = {
Title: ["넣을 것 고르기", "Pick what to add"],
@@ -96,6 +98,23 @@ interface Source {
load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>;
}
/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 ·
* 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */
const byName = <T extends { 이름?: string }>(items: T[], q: string): T[] => {
const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase();
const w = squash(q);
const rank = (it: T): number => {
const n = squash(it.이름 ?? "");
if (n === w) return 0;
if (!n.startsWith(w)) return 3;
return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1;
};
return items
.map((it, i) => ({ it, i, r: rank(it) }))
.sort((a, b) => a.r - b.r || a.i - b.i)
.map((x) => x.it);
};
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const SOURCES: Partial<Record<Kind, Source>> = {
@@ -135,14 +154,14 @@ const SOURCES: Partial<Record<Kind, Source>> = {
const found = f.q
? await fetchPick("price", f.q).then((r) => ({
total: r.total,
items: r.items
.filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub)
.map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })),
items: byName(r.items, f.q)
.filter((it) => !f.sub || (it as Brief).구분 === f.sub)
.map((it) => ({ ...it, 구분: (it as Brief).구분 })),
}))
: await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then(
(r) => ({
total: r.total,
items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })),
items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? f.sub })),
}),
);
return {
@@ -153,7 +172,7 @@ const SOURCES: Partial<Record<Kind, Source>> = {
const unit = typeof it.단위 === "string" ? it.단위 : "";
const price = num(it.값);
const element: Record<string, string> = { 구분: it.구분 ?? f.sub };
const detail = (it as { 상세구분?: string }).상세구분;
const detail = (it as Brief).상세구분;
if (detail) element["상세구분"] = detail;
element["검색어"] = `${name} ${spec}`.trim();
return {
@@ -215,13 +234,20 @@ const SOURCES: Partial<Record<Kind, Source>> = {
price: null,
priceText: "",
tag: e.owner,
make: async () => ({
kind: "logic",
name: e.name,
spec: "",
unit,
element: `로직(${e.key})`,
}),
make: async () => {
// 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴)
const needInputs = (await fetchLogic(e.key)).logic.입력;
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
return {
kind: "logic",
name: e.name,
spec: "",
unit,
element: `로직(${e.key}${rest})`,
needInputs,
};
},
};
}),
};
@@ -230,7 +256,10 @@ const SOURCES: Partial<Record<Kind, Source>> = {
};
/** 물음 셋 — 고른 것 · 취소는 null */
export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
export function openEasyPick(
kind?: Kind,
opts: { qty?: number | null } = {},
): Promise<PickResult | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (r: PickResult | null): void => {
@@ -337,6 +366,7 @@ export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
placeholder: te("QtyPlace"),
});
qty.input.inputMode = "decimal";
if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움
const rough = el("p", { className: "m01-easypick__rough" });
const parse = (): number | null => {
const n = Number(qty.input.value.trim().replace(/,/g, ""));
@@ -157,3 +157,19 @@
cursor: pointer;
font-weight: 600;
}
/* 고친 뒤 금액을 다시 셈하는 동안 옛 금액을 흐리게 */
.m01-easycalc .is-pending {
opacity: 0.45;
}
.m01-easycalc__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m01-easycalc__note:empty {
display: none;
}