feat(M01): 로직 개선 시험 줄 모달 — 한 줄 풀이와 알약 단추(누른 자료 하나만 펼침·접힘) (PLAN 5-3)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
@@ -9,13 +9,16 @@ import { createButton, el } from "@ui/ui_template_elements";
|
|||||||
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||||
import {
|
import {
|
||||||
|
explain,
|
||||||
loadTable,
|
loadTable,
|
||||||
matchRow,
|
matchRow,
|
||||||
relatedFinds,
|
relatedFinds,
|
||||||
type RelatedFind,
|
type RelatedFind,
|
||||||
type TableRow,
|
type TableRow,
|
||||||
} from "./M01_MasterData_UI_Logic_Note";
|
} from "./M01_MasterData_UI_Logic_Note";
|
||||||
|
import { pillBar, type Pill } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||||
|
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||||
|
|
||||||
export interface ModalTarget {
|
export interface ModalTarget {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -87,6 +90,9 @@ function tableView(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupOf = (ref: string): string =>
|
||||||
|
ref.startsWith("LB") ? "인력" : ref.startsWith("M") ? "재료" : ref.startsWith("EQ") ? "기계" : "";
|
||||||
|
|
||||||
function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||||
const cols = Object.entries(brief.값칸 ?? {});
|
const cols = Object.entries(brief.값칸 ?? {});
|
||||||
return el("div", {
|
return el("div", {
|
||||||
@@ -134,38 +140,59 @@ export function openMaterialModal(target: ModalTarget): void {
|
|||||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||||
|
|
||||||
const parts: HTMLElement[] = [
|
|
||||||
el("p", { className: "m01-logic__muted", text: tl("Modal_Qty") }),
|
|
||||||
el("pre", { className: "m01lab__expr", text: target.expr }),
|
|
||||||
];
|
|
||||||
const finds = relatedFinds(target.expr, target.middles);
|
const finds = relatedFinds(target.expr, target.middles);
|
||||||
const price = target.element ? target.prices[target.element] : undefined;
|
const base = target.element?.split(".")[0] ?? "";
|
||||||
if (target.element?.startsWith("로직(")) {
|
const price = target.element ? (target.prices[target.element] ?? target.prices[base]) : undefined;
|
||||||
parts.push(el("p", { text: `${tl("Modal_Logic")} · ${target.element}` }));
|
const logicRef = target.element?.startsWith("로직(") ? target.element : "";
|
||||||
} else if (price) {
|
const pills: Pill[] = [];
|
||||||
parts.push(
|
if (price && target.element) {
|
||||||
el("p", { className: "m01-logic__muted", text: tl("Modal_Price") }),
|
pills.push({
|
||||||
priceView(target.element as string, price),
|
label: `${groupOf(base)} ${price.이름 ?? base}`.trim(),
|
||||||
);
|
view: () => priceView(target.element as string, price),
|
||||||
|
});
|
||||||
|
} else if (target.element && !logicRef) {
|
||||||
|
pills.push({
|
||||||
|
label: `${groupOf(base)} ${target.title}`.trim(),
|
||||||
|
view: () => el("p", { text: target.element }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const tables = el("div", { className: "m01lab__tables" });
|
if (logicRef) {
|
||||||
parts.push(tables);
|
pills.push({
|
||||||
if (!finds.length && !price && !target.element?.startsWith("로직(")) {
|
label: `${tl("Pill_Logic")} ${logicRef.slice(3).split(",")[0].replace(/\)$/, "")}`,
|
||||||
parts.push(el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") }));
|
view: () => el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
for (const f of finds) {
|
||||||
|
pills.push({
|
||||||
|
label: `${tl("Pill_Table")} ${f.find.table}`,
|
||||||
|
view: async () => {
|
||||||
|
const table = await loadTable(f.find.table);
|
||||||
|
return table
|
||||||
|
? tableView(f, table, target.values)
|
||||||
|
: el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const unit = price ? `${tl("Modal_UnitPrice")} ${formatNumber(price.값)} × ` : "";
|
||||||
|
const parts: HTMLElement[] = [
|
||||||
|
el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }),
|
||||||
|
el("p", {
|
||||||
|
className: "m01lab__sum",
|
||||||
|
text: `${target.title} = ${unit}${explain(target.expr)}`,
|
||||||
|
}),
|
||||||
|
...(pills.length
|
||||||
|
? [el("p", { className: "m01-logic__muted", text: tl("Pill_Hint") }), pillBar(pills)]
|
||||||
|
: [el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") })]),
|
||||||
|
el("details", {
|
||||||
|
className: "m01lab__raw",
|
||||||
|
children: [
|
||||||
|
el("summary", { text: tl("Modal_Qty") }),
|
||||||
|
el("pre", { className: "m01lab__expr", text: target.expr }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
body.replaceChildren(...parts);
|
body.replaceChildren(...parts);
|
||||||
document.body.append(backdrop);
|
document.body.append(backdrop);
|
||||||
dialog.tabIndex = -1;
|
dialog.tabIndex = -1;
|
||||||
dialog.focus();
|
dialog.focus();
|
||||||
|
|
||||||
void Promise.all(finds.map(async (f) => ({ f, table: await loadTable(f.find.table) }))).then(
|
|
||||||
(got) =>
|
|
||||||
tables.replaceChildren(
|
|
||||||
...got.map(({ f, table }) =>
|
|
||||||
table
|
|
||||||
? tableView(f, table, target.values)
|
|
||||||
: el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* M01 로직 개선 시험 — 줄 모달의 알약 단추 */
|
||||||
|
.m01lab__pills {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m01lab__pill-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m01lab__pill {
|
||||||
|
padding: var(--spacing-4) var(--spacing-12);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m01lab__pill.is-open {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m01lab__pill-stage {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* M01_MasterData_UI_LogicLab_Pill.ts
|
||||||
|
* 줄 모달의 알약 단추 — 쓰인 자료를 알약으로만 늘어놓고, 누른 것 하나만 펼침(다시 누르면 접힘)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { el } from "@ui/ui_template_elements";
|
||||||
|
|
||||||
|
export interface Pill {
|
||||||
|
/** 알약 글 — 보기 「표 QF000421」 · 「인력 석공」 */
|
||||||
|
label: string;
|
||||||
|
/** 펼칠 때 그림 — 표처럼 읽어 와야 하면 Promise */
|
||||||
|
view: () => HTMLElement | Promise<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pillBar(pills: Pill[]): HTMLElement {
|
||||||
|
const stage = el("div", { className: "m01lab__pill-stage" });
|
||||||
|
const buttons: HTMLButtonElement[] = [];
|
||||||
|
let open = -1;
|
||||||
|
let turn = 0;
|
||||||
|
const show = async (i: number): Promise<void> => {
|
||||||
|
const mine = ++turn;
|
||||||
|
open = open === i ? -1 : i;
|
||||||
|
buttons.forEach((b, k) => {
|
||||||
|
b.setAttribute("aria-expanded", String(k === open));
|
||||||
|
b.classList.toggle("is-open", k === open);
|
||||||
|
});
|
||||||
|
if (open < 0) return stage.replaceChildren();
|
||||||
|
const view = await pills[open].view();
|
||||||
|
if (mine === turn) stage.replaceChildren(view); // 빨리 눌러도 마지막 것만
|
||||||
|
};
|
||||||
|
pills.forEach((p, i) => {
|
||||||
|
const b = el("button", {
|
||||||
|
className: "m01lab__pill",
|
||||||
|
text: p.label,
|
||||||
|
attrs: { type: "button", "aria-expanded": "false", "data-pill": p.label },
|
||||||
|
});
|
||||||
|
b.addEventListener("click", () => void show(i));
|
||||||
|
buttons.push(b);
|
||||||
|
});
|
||||||
|
return el("div", {
|
||||||
|
className: "m01lab__pills",
|
||||||
|
children: [el("div", { className: "m01lab__pill-row", children: buttons }), stage],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ const TEXT = {
|
|||||||
],
|
],
|
||||||
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
|
Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"],
|
||||||
Modal_Close: ["닫기", "Close"],
|
Modal_Close: ["닫기", "Close"],
|
||||||
|
Modal_Sum: ["한 줄 풀이", "In one line"],
|
||||||
|
Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"],
|
||||||
|
Modal_UnitPrice: ["단가", "Unit price"],
|
||||||
|
Pill_Table: ["표", "Table"],
|
||||||
|
Pill_Logic: ["로직", "Logic"],
|
||||||
Modal_Qty: ["수량 식", "Quantity formula"],
|
Modal_Qty: ["수량 식", "Quantity formula"],
|
||||||
Modal_Price: ["단가 자료", "Price source"],
|
Modal_Price: ["단가 자료", "Price source"],
|
||||||
Modal_Value: ["값", "Value"],
|
Modal_Value: ["값", "Value"],
|
||||||
|
|||||||
Reference in New Issue
Block a user