Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
/* 시트 로직 — 위 가로 다이어그램 */
|
||||
.m01-sldg {
|
||||
--sldg-in: var(--color-accent);
|
||||
--sldg-out: #c2610c;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .m01-sldg {
|
||||
--sldg-out: #e5a866;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .m01-sldg {
|
||||
--sldg-out: #e5a866;
|
||||
}
|
||||
}
|
||||
|
||||
.m01-sldg__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.m01-sldg__note {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-sldg__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-sldg__menu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-sldg__menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */
|
||||
.m01-sldg__band {
|
||||
max-height: 33vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.m01-sldg__inner {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(11rem, 1fr));
|
||||
gap: var(--spacing-24);
|
||||
min-width: max-content;
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-sldg__lines {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.m01-sldg__col {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 11rem;
|
||||
}
|
||||
|
||||
.m01-sldg__col-head {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* 상자 */
|
||||
.m01-sldg__box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-sldg__box:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m01-sldg__box.is-selected {
|
||||
border: 2px solid var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.m01-sldg__box-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m01-sldg__kind {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-sldg__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-sldg__pills {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-sldg__ins,
|
||||
.m01-sldg__outs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-sldg__outs {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */
|
||||
.m01-sldg__pill {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 10rem;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--sldg-in);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface));
|
||||
color: var(--sldg-in);
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-out {
|
||||
border-color: var(--sldg-out);
|
||||
background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface));
|
||||
color: var(--sldg-out);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-empty {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m01-sldg__pill.is-chosen {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
|
||||
.m01-sldg.is-linking .m01-sldg__pill.is-target {
|
||||
box-shadow: 0 0 0 2px var(--sldg-in);
|
||||
animation: m01-sldg-blink 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes m01-sldg-blink {
|
||||
to {
|
||||
background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface));
|
||||
}
|
||||
}
|
||||
|
||||
.m01-sldg__pill-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m01-sldg__pill-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-sldg__pill-value:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 선 */
|
||||
.m01-sldg__edge {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-sldg__hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 12;
|
||||
}
|
||||
|
||||
.m01-sldg__wire {
|
||||
fill: none;
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.m01-sldg__edge.is-strong .m01-sldg__wire {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.m01-sldg__edge.is-dim .m01-sldg__wire {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.m01-sldg__drag {
|
||||
fill: none;
|
||||
stroke: var(--sldg-out);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.m01-sldg.is-linking .m01-sldg__pill.is-target {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Diagram.ts
|
||||
* 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」)
|
||||
* 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) ·
|
||||
* 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG.
|
||||
* 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect ·
|
||||
* 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」.
|
||||
* 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model";
|
||||
import "./M01_SL_Diagram.css";
|
||||
|
||||
const TEXT = {
|
||||
Stage0: ["입력값", "Inputs"],
|
||||
Stage1: ["표", "Tables"],
|
||||
Stage2: ["계산", "Calculations"],
|
||||
Stage3: ["항목", "Items"],
|
||||
Stage4: ["결과", "Result"],
|
||||
K_input: ["입력값", "Input"],
|
||||
K_const: ["고정값", "Fixed value"],
|
||||
K_table: ["표", "Table"],
|
||||
K_calc: ["계산", "Calculation"],
|
||||
K_item: ["항목", "Item"],
|
||||
K_logic: ["다른 로직", "Other logic"],
|
||||
K_result: ["결과", "Result"],
|
||||
Add: ["+ 상자", "+ Box"],
|
||||
AddTitle: ["더할 상자 종류", "Kind of box to add"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Connect: ["잇기", "Connect"],
|
||||
Disconnect: ["끊기", "Disconnect"],
|
||||
Remove: ["상자 지우기", "Remove box"],
|
||||
AskRemove: [
|
||||
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
|
||||
"Remove box “{v}”? Its lines go too",
|
||||
],
|
||||
PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"],
|
||||
Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"],
|
||||
NoOut: ["내보내는 알약이 없음", "No output pills"],
|
||||
NoIn: ["받는 알약이 없음", "No input pills"],
|
||||
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
|
||||
NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"],
|
||||
NoMatch: [
|
||||
"이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요",
|
||||
"No input pill with the same name — drop on the pill itself",
|
||||
],
|
||||
Taken: [
|
||||
"이미 이어진 입력 알약 — 먼저 그 선을 끊으세요",
|
||||
"That input already has a line — disconnect it first",
|
||||
],
|
||||
From: ["어디서", "From"],
|
||||
To: ["어디로", "To"],
|
||||
Value: ["지금 값", "Now"],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
} as const;
|
||||
|
||||
const td = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
|
||||
|
||||
/** 종류 → 기본 단계(고정값 · 다른 로직은 앞 칸) */
|
||||
export const STAGE_OF: Record<SlKind, number> = {
|
||||
input: 0,
|
||||
const: 0,
|
||||
logic: 0,
|
||||
table: 1,
|
||||
calc: 2,
|
||||
item: 3,
|
||||
result: 4,
|
||||
};
|
||||
|
||||
const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"];
|
||||
const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT);
|
||||
|
||||
export interface DiagramOn {
|
||||
select(id: string | null): void;
|
||||
/** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */
|
||||
connect(from: SlEnd, to: SlEnd): string | null | void;
|
||||
disconnect(edgeId: string): void;
|
||||
add(kind: SlKind, stage: number): void;
|
||||
remove(nodeId: string): void;
|
||||
}
|
||||
|
||||
export interface DiagramOpts {
|
||||
/** 상자 id → 알약 이름 → 지금 값 */
|
||||
values?: Record<string, Record<string, unknown>>;
|
||||
/** 고른 상자 id · 선 id */
|
||||
selected?: string | null;
|
||||
}
|
||||
|
||||
/* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
/** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */
|
||||
export function layout(nodes: SlNode[]): SlNode[][] {
|
||||
const cols: SlNode[][] = [[], [], [], [], []];
|
||||
for (const n of nodes) {
|
||||
const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind];
|
||||
cols[Math.min(4, Math.max(0, s ?? 0))].push(n);
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */
|
||||
export function resolveDrop(
|
||||
sl: SheetLogic,
|
||||
from: SlEnd,
|
||||
target: { node: string; pill: string | null; dir: "in" | "out" | null } | null,
|
||||
): { to: SlEnd } | { why: string } {
|
||||
if (!target) return { why: td("NotIn") };
|
||||
if (target.node === from.node) return { why: td("SameBox") };
|
||||
if (target.dir === "out") return { why: td("NotIn") };
|
||||
const node = sl.nodes.find((n) => n.id === target.node);
|
||||
if (!node?.pillsIn.length) return { why: td("NoIn") };
|
||||
const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name;
|
||||
if (!name) return { why: td("NoMatch") };
|
||||
if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name))
|
||||
return { why: td("Taken") };
|
||||
return { to: { node: node.id, pill: name } };
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────── */
|
||||
|
||||
interface HostState {
|
||||
selected: string | null;
|
||||
/** 잇기 중 — 출력 알약 */
|
||||
linking: SlEnd | null;
|
||||
/** 고른 알약(정보 줄) */
|
||||
pill: { node: string; pill: string; dir: "in" | "out" } | null;
|
||||
note: string;
|
||||
scroll: number;
|
||||
/** 선 다시 그리기 — 띠 크기 바뀜 */
|
||||
watch?: ResizeObserver;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, HostState>();
|
||||
|
||||
const showValue = (v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
if (typeof v === "number")
|
||||
return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4));
|
||||
return String(v).slice(0, 12);
|
||||
};
|
||||
|
||||
const edgeTouches = (e: SlEdge, node: string): boolean =>
|
||||
e.from.node === node || e.to.node === node;
|
||||
|
||||
export function renderDiagram(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
on: DiagramOn,
|
||||
opts: DiagramOpts = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 };
|
||||
if (opts.selected !== undefined) st.selected = opts.selected;
|
||||
states.set(host, st);
|
||||
const values = opts.values ?? {};
|
||||
const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? "";
|
||||
const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected });
|
||||
const select = (id: string | null): void => {
|
||||
st.selected = id;
|
||||
st.pill = null;
|
||||
st.note = "";
|
||||
on.select(id);
|
||||
redraw();
|
||||
};
|
||||
const tryConnect = (from: SlEnd, target: Parameters<typeof resolveDrop>[2]): void => {
|
||||
const got = resolveDrop(sl, from, target);
|
||||
st.linking = null;
|
||||
if ("why" in got) st.note = got.why;
|
||||
else st.note = on.connect(from, got.to) || "";
|
||||
redraw();
|
||||
};
|
||||
|
||||
/* 상자 */
|
||||
const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => {
|
||||
const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name);
|
||||
const linking = st.linking;
|
||||
const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir;
|
||||
const b = el("button", {
|
||||
className: [
|
||||
"m01-sldg__pill",
|
||||
`is-${dir}`,
|
||||
wired ? "" : "is-empty",
|
||||
chosen ? "is-chosen" : "",
|
||||
linking && dir === "in" && n.id !== linking.node ? "is-target" : "",
|
||||
].join(" "),
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-node": n.id,
|
||||
"data-pill": name,
|
||||
"data-dir": dir,
|
||||
title: unit ? `${name} (${unit})` : name,
|
||||
},
|
||||
children: [
|
||||
el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }),
|
||||
el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking && dir === "in")
|
||||
return tryConnect(st.linking, { node: n.id, pill: name, dir });
|
||||
st.pill = chosen ? null : { node: n.id, pill: name, dir };
|
||||
st.linking = null;
|
||||
st.note = "";
|
||||
redraw();
|
||||
});
|
||||
if (dir === "out")
|
||||
b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name }));
|
||||
return b;
|
||||
};
|
||||
|
||||
const boxEl = (n: SlNode): HTMLElement => {
|
||||
const box = el("div", {
|
||||
className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join(
|
||||
" ",
|
||||
),
|
||||
attrs: {
|
||||
"data-node": n.id,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${kindLabel(n.kind)} · ${n.name}`,
|
||||
},
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__box-head",
|
||||
children: [
|
||||
el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }),
|
||||
el("strong", { className: "m01-sldg__name", text: n.name }),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-sldg__pills",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__ins",
|
||||
children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)),
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-sldg__outs",
|
||||
children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
box.addEventListener("click", () => {
|
||||
if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null });
|
||||
select(st.selected === n.id ? null : n.id);
|
||||
});
|
||||
box.addEventListener("keydown", (ev) => {
|
||||
if (ev.target !== box) return;
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
box.click();
|
||||
}
|
||||
});
|
||||
return box;
|
||||
};
|
||||
|
||||
const cols = layout(sl.nodes);
|
||||
const inner = el("div", {
|
||||
className: "m01-sldg__inner",
|
||||
children: cols.map((list, i) =>
|
||||
el("div", {
|
||||
className: "m01-sldg__col",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__col-head",
|
||||
text: td(`Stage${i}` as keyof typeof TEXT),
|
||||
}),
|
||||
...list.map(boxEl),
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.classList.add("m01-sldg__lines");
|
||||
inner.prepend(svg);
|
||||
inner.addEventListener("click", (ev) => {
|
||||
if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) {
|
||||
st.linking = null;
|
||||
select(null);
|
||||
}
|
||||
});
|
||||
|
||||
/* 위 줄 — 더하기 · 고른 것 · 할 일 */
|
||||
const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } });
|
||||
addMenu.append(
|
||||
el("span", { className: "m01-sldg__muted", text: td("AddTitle") }),
|
||||
...KINDS.map((k) =>
|
||||
createButton({
|
||||
label: kindLabel(k),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
addMenu.hidden = true;
|
||||
on.add(k, STAGE_OF[k]);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const addBtn = createButton({
|
||||
label: td("Add"),
|
||||
size: "sm",
|
||||
onClick: () => (addMenu.hidden = !addMenu.hidden),
|
||||
});
|
||||
const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null;
|
||||
const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null;
|
||||
const tools: HTMLElement[] = [];
|
||||
if (st.linking) {
|
||||
tools.push(
|
||||
el("span", { text: td("PickTarget", { v: st.linking.pill }) }),
|
||||
createButton({
|
||||
label: td("Cancel"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => ((st.linking = null), redraw()),
|
||||
}),
|
||||
);
|
||||
} else if (st.pill) {
|
||||
const p = st.pill;
|
||||
const linked = sl.edges.filter((e) =>
|
||||
p.dir === "in"
|
||||
? e.to.node === p.node && e.to.pill === p.pill
|
||||
: e.from.node === p.node && e.from.pill === p.pill,
|
||||
);
|
||||
const where = linked
|
||||
.map((e) =>
|
||||
p.dir === "in"
|
||||
? `${nodeName(e.from.node)} · ${e.from.pill}`
|
||||
: `${nodeName(e.to.node)} · ${e.to.pill}`,
|
||||
)
|
||||
.join(", ");
|
||||
tools.push(
|
||||
el("span", {
|
||||
text: [
|
||||
`${p.pill}`,
|
||||
`${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`,
|
||||
where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
);
|
||||
if (p.dir === "out")
|
||||
tools.push(
|
||||
createButton({
|
||||
label: td("Connect"),
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
st.linking = { node: p.node, pill: p.pill };
|
||||
st.pill = null;
|
||||
redraw();
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (selEdge) {
|
||||
tools.push(
|
||||
el("span", {
|
||||
text: td("LineOf", {
|
||||
a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`,
|
||||
b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`,
|
||||
}),
|
||||
}),
|
||||
createButton({
|
||||
label: td("Disconnect"),
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => on.disconnect(selEdge.id),
|
||||
}),
|
||||
);
|
||||
} else if (selNode) {
|
||||
tools.push(
|
||||
createButton({
|
||||
label: td("Remove"),
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => void askRemove(selNode),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const askRemove = async (n: SlNode): Promise<void> => {
|
||||
if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id);
|
||||
};
|
||||
|
||||
const band = el("div", { className: "m01-sldg__band", children: [inner] });
|
||||
const root = el("div", {
|
||||
className: `m01-sldg${st.linking ? " is-linking" : ""}`,
|
||||
attrs: { tabindex: "-1" },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-sldg__bar",
|
||||
children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })],
|
||||
}),
|
||||
addMenu,
|
||||
sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("Empty") }),
|
||||
],
|
||||
});
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape" && (st.linking || st.pill)) {
|
||||
st.linking = null;
|
||||
st.pill = null;
|
||||
redraw();
|
||||
}
|
||||
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
|
||||
if (selEdge) on.disconnect(selEdge.id);
|
||||
else if (selNode) void askRemove(selNode);
|
||||
});
|
||||
band.addEventListener("scroll", () => (st.scroll = band.scrollLeft));
|
||||
host.replaceChildren(root);
|
||||
band.scrollLeft = st.scroll;
|
||||
|
||||
/* 선 — 붙인 뒤 알약 자리를 재서 그림 */
|
||||
const drawLines = (): void => {
|
||||
const box = inner.getBoundingClientRect();
|
||||
svg.setAttribute("width", String(inner.scrollWidth));
|
||||
svg.setAttribute("height", String(inner.scrollHeight));
|
||||
svg.replaceChildren();
|
||||
const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => {
|
||||
const pill = inner.querySelector<HTMLElement>(
|
||||
`.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`,
|
||||
);
|
||||
if (!pill) return null;
|
||||
const r = pill.getBoundingClientRect();
|
||||
return {
|
||||
x: (dir === "out" ? r.right : r.left) - box.left,
|
||||
y: r.top + r.height / 2 - box.top,
|
||||
};
|
||||
};
|
||||
for (const e of sl.edges) {
|
||||
const a = at(e.from, "out");
|
||||
const b = at(e.to, "in");
|
||||
if (!a || !b) continue;
|
||||
const dx = Math.max(30, Math.abs(b.x - a.x) / 2);
|
||||
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
|
||||
const strong = st.selected === e.id || (selNode !== null && edgeTouches(e, selNode.id));
|
||||
const dim = st.selected !== null && !strong;
|
||||
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`);
|
||||
g.setAttribute("data-edge", e.id);
|
||||
g.setAttribute("data-x1", a.x.toFixed(1));
|
||||
g.setAttribute("data-y1", a.y.toFixed(1));
|
||||
g.setAttribute("data-x2", b.x.toFixed(1));
|
||||
g.setAttribute("data-y2", b.y.toFixed(1));
|
||||
for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) {
|
||||
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
p.setAttribute("d", d);
|
||||
p.setAttribute("class", cls);
|
||||
g.append(p);
|
||||
}
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
select(st.selected === e.id ? null : e.id);
|
||||
});
|
||||
svg.append(g);
|
||||
}
|
||||
};
|
||||
// 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기)
|
||||
st.watch?.disconnect();
|
||||
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines);
|
||||
st.watch?.observe(inner);
|
||||
if (inner.isConnected) drawLines();
|
||||
|
||||
/* 끌어 잇기 */
|
||||
function startDrag(ev: PointerEvent, from: SlEnd): void {
|
||||
if (ev.button !== 0) return;
|
||||
const pill = ev.currentTarget as HTMLElement;
|
||||
const box = inner.getBoundingClientRect();
|
||||
const r = pill.getBoundingClientRect();
|
||||
const x0 = r.right - box.left;
|
||||
const y0 = r.top + r.height / 2 - box.top;
|
||||
let line: SVGPathElement | null = null;
|
||||
const move = (m: PointerEvent): void => {
|
||||
const x = m.clientX - box.left;
|
||||
const y = m.clientY - box.top;
|
||||
if (!line && Math.hypot(x - x0, y - y0) < 6) return;
|
||||
if (!line) {
|
||||
line = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
line.setAttribute("class", "m01-sldg__drag");
|
||||
svg.append(line);
|
||||
}
|
||||
line.setAttribute("d", `M${x0},${y0} L${x},${y}`);
|
||||
};
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
if (!line) return; // 끌지 않음 = 누름(click 이 처리)
|
||||
line.remove();
|
||||
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
|
||||
const pillHit = hit?.closest<HTMLElement>(".m01-sldg__pill");
|
||||
const boxHit = hit?.closest<HTMLElement>(".m01-sldg__box");
|
||||
const target = pillHit
|
||||
? {
|
||||
node: pillHit.dataset.node ?? "",
|
||||
pill: pillHit.dataset.pill ?? null,
|
||||
dir: (pillHit.dataset.dir as "in" | "out") ?? null,
|
||||
}
|
||||
: boxHit
|
||||
? { node: boxHit.dataset.node ?? "", pill: null, dir: null }
|
||||
: null;
|
||||
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
|
||||
const swallow = (c: MouseEvent): void => c.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
tryConnect(from, target);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.m01-slitem__title {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-slitem__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-slitem__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slitem__input {
|
||||
box-sizing: border-box;
|
||||
width: 120px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.m01-slitem__input[readonly] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-slitem__chosen {
|
||||
margin: 0;
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-slitem__prices {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-slitem__price {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-slitem__price strong {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slitem__muted {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-slitem__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Item.ts
|
||||
* 시트 로직 — 항목 상자 손잡이. 항목 상자를 고르면 시트 판 위에 붙음.
|
||||
* 종류(노무 · 재료 · 장비 · 일위대가 · 비율) 고르기 → 상자 시트를 항목 틀로 채움 · 알약 정하기
|
||||
* 단가 칸 = 바깥 값(노임 · 자재 · 장비 = 기계경비 로직 · 일위대가 = 기존 로직 결과) — Api `slOutside` 로 받아 넣음
|
||||
* 수량 칸 = 입력 알약 · 금액 칸 = 수량 × 단가(ROUND 소수 첫째) · 출력 알약 노무비 / 재료비 / 경비
|
||||
* 비율 = 「노무비의 [3]%」 꼴 — 바탕 = 다른 항목 상자의 그 비목 합(시트 사이 참조)
|
||||
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { LogicInput } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { searchElements } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatPrice } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
|
||||
import type { PickResult } from "../EasyCalc/M01_EasyCalc_Model";
|
||||
import { slOutside, slPlain, type SlField, type SlOutside, type SlOutsideAt } from "./M01_SL_Api";
|
||||
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
|
||||
import "./M01_SL_Item.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["항목", "Item"],
|
||||
Kind: ["종류", "Kind"],
|
||||
Labor: ["노무", "Labor"],
|
||||
Material: ["재료", "Material"],
|
||||
Machine: ["장비", "Machine"],
|
||||
Logic: ["일위대가", "Unit-price job"],
|
||||
Rate: ["비율", "Rate"],
|
||||
ToolLoss: ["공구손료", "Small tools"],
|
||||
Pick: ["고르기", "Choose"],
|
||||
Repick: ["다시 고르기", "Choose again"],
|
||||
None: ["아직 고르지 않았어요", "Nothing chosen yet"],
|
||||
Qty: ["수량", "Quantity"],
|
||||
Price: ["단가", "Unit price"],
|
||||
PriceLabor: ["노무 단가", "Labor price"],
|
||||
PriceMat: ["재료 단가", "Material price"],
|
||||
PriceExp: ["경비 단가", "Expense price"],
|
||||
Reload: ["단가 다시 가져오기", "Reload prices"],
|
||||
Loading: ["단가 가져오는 중", "Loading prices"],
|
||||
Linked: ["다른 상자에서 이어진 값", "Value from another box"],
|
||||
Split: [
|
||||
"장비는 노무비 · 재료비 · 경비 셋으로 나뉘어요",
|
||||
"Machines split into labor, material and expense",
|
||||
],
|
||||
Formula: ["금액 = 수량 × 단가 (소수 첫째 자리까지)", "Amount = quantity × price (one decimal)"],
|
||||
RatePct: ["율(%)", "Rate (%)"],
|
||||
Base: ["무엇의", "Of what"],
|
||||
Into: ["넣을 자리", "Goes into"],
|
||||
Fit: ["바탕 맞추기", "Refresh base"],
|
||||
BaseHint: ["바탕 = 다른 항목 상자의 그 합계", "Base = total of the other item boxes"],
|
||||
Labor3: ["노무비", "Labor cost"],
|
||||
Mat3: ["재료비", "Material cost"],
|
||||
Exp3: ["경비", "Expense"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
NoPrice: ["단가를 찾지 못했어요", "Price not found"],
|
||||
Won: ["원", "KRW"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const ti = (key: TextKey): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
export type ItemKind = "노무" | "재료" | "장비" | "일위대가" | "비율";
|
||||
export type CostName = "노무비" | "재료비" | "경비";
|
||||
const COSTS: CostName[] = ["노무비", "재료비", "경비"];
|
||||
|
||||
/** 항목 상자가 `node.ref` 에 글(JSON)로 들고 있는 것 */
|
||||
export interface ItemRef {
|
||||
what: ItemKind;
|
||||
/** 고른 것의 PickResult.element 그대로 */
|
||||
element?: unknown;
|
||||
/** 크기(규격) */
|
||||
spec?: string;
|
||||
/** 수량 단위 */
|
||||
unit?: string;
|
||||
/** 고른 것이 받는 값(장비 지역 · 일위대가 넘길 값)과 지금 값 */
|
||||
need?: LogicInput[];
|
||||
inputs?: Record<string, unknown>;
|
||||
/** 비율 */
|
||||
base?: CostName;
|
||||
into?: CostName;
|
||||
}
|
||||
|
||||
/** 칸 자리 — 항목 틀 둘(돈 · 비율) */
|
||||
const MONEY = {
|
||||
qty: "B1",
|
||||
labor: "B2",
|
||||
mat: "B3",
|
||||
exp: "B4",
|
||||
unit: "B5",
|
||||
amount: "B6",
|
||||
cost: { 노무비: "B7", 재료비: "B8", 경비: "B9" },
|
||||
used: "B10",
|
||||
};
|
||||
const RATE = {
|
||||
rate: "B1",
|
||||
base: "B2",
|
||||
baseKind: "B3",
|
||||
amount: "B4",
|
||||
into: "B5",
|
||||
cost: { 노무비: "B6", 재료비: "B7", 경비: "B8" },
|
||||
used: "B9",
|
||||
unit: "B10",
|
||||
};
|
||||
|
||||
export function itemRefOf(node: SlNode): ItemRef | null {
|
||||
try {
|
||||
const r = node.ref ? (JSON.parse(node.ref) as ItemRef) : null;
|
||||
return r && r.what ? r : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 항목 상자의 비목 칸 자리 — 합계 · 바탕이 같은 자리를 읽음 */
|
||||
export const costCell = (ref: ItemRef, cost: CostName): string =>
|
||||
ref.what === "비율" ? RATE.cost[cost] : MONEY.cost[cost];
|
||||
|
||||
/* ---- 시트 칸 고치기(받은 것은 안 바꿈) ---- */
|
||||
const text = (v: string): Cell => ({ 값: v });
|
||||
const num = (v: number): Cell => ({ 값: v });
|
||||
const calc = (f: string): Cell => ({ 식: f });
|
||||
|
||||
function editSheet(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
edit: (cells: Record<string, Cell>, s: Sheet) => void,
|
||||
): SheetLogic {
|
||||
const 시트 = sl.workbook.시트.map((s) => {
|
||||
if (s.id !== node.sheetId) return s;
|
||||
const next = { ...s, 칸: { ...s.칸 } };
|
||||
edit(next.칸, next);
|
||||
return next;
|
||||
});
|
||||
return { ...sl, workbook: { ...sl.workbook, 시트 } };
|
||||
}
|
||||
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined =>
|
||||
sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const nodeOf = (sl: SheetLogic, id: string): SlNode | undefined =>
|
||||
sl.nodes.find((n) => n.id === id);
|
||||
const withNode = (sl: SheetLogic, node: SlNode): SheetLogic => ({
|
||||
...sl,
|
||||
nodes: sl.nodes.map((n) => (n.id === node.id ? node : n)),
|
||||
});
|
||||
const listRule = (id: string, cell: string, values: string[]) => ({
|
||||
id,
|
||||
범위: [{ r0: Number(cell.slice(1)) - 1, c0: 1, r1: Number(cell.slice(1)) - 1, c1: 1 }],
|
||||
규칙: { 종류: "목록" as const, 값들: values },
|
||||
어기면: "막음" as const,
|
||||
});
|
||||
|
||||
/* ---- 항목 틀 ---- */
|
||||
/** 일위대가표가 읽는 출력 알약 이름 */
|
||||
export const USED = "사용 수량";
|
||||
export const PILL_PRICE = "단가";
|
||||
export const PILL_AMOUNT = "금액";
|
||||
const PILL_BASE = "바탕 금액";
|
||||
|
||||
function moneyLayout(ref: ItemRef, qty: Cell | undefined, outside: SlField[]) {
|
||||
const label = (cell: string, name: string): [string, Cell] => ["A" + cell.slice(1), text(name)];
|
||||
const cells: Record<string, Cell> = Object.fromEntries([
|
||||
label(MONEY.qty, ti("Qty")),
|
||||
label(MONEY.labor, ti("PriceLabor")),
|
||||
label(MONEY.mat, ti("PriceMat")),
|
||||
label(MONEY.exp, ti("PriceExp")),
|
||||
label(MONEY.unit, PILL_PRICE),
|
||||
label(MONEY.amount, PILL_AMOUNT),
|
||||
label(MONEY.cost.노무비, ti("Labor3")),
|
||||
label(MONEY.cost.재료비, ti("Mat3")),
|
||||
label(MONEY.cost.경비, ti("Exp3")),
|
||||
label(MONEY.used, USED),
|
||||
]);
|
||||
if (qty) cells[MONEY.qty] = qty;
|
||||
// 바깥 값이 아닌 단가 칸은 0 으로 고정
|
||||
for (const [cell, field] of [
|
||||
[MONEY.labor, "노무비"],
|
||||
[MONEY.mat, "재료비"],
|
||||
[MONEY.exp, "경비"],
|
||||
] as const) {
|
||||
if (!outside.includes(field)) cells[cell] = num(0);
|
||||
}
|
||||
cells[MONEY.unit] = calc("B2+B3+B4");
|
||||
cells[MONEY.amount] = calc("ROUND(B1*B5,1)");
|
||||
cells[MONEY.cost.노무비] = calc("ROUND(B1*B2,1)");
|
||||
cells[MONEY.cost.재료비] = calc("ROUND(B1*B3,1)");
|
||||
cells[MONEY.cost.경비] = calc("ROUND(B1*B4,1)");
|
||||
cells[MONEY.used] = calc("B1");
|
||||
const unit = ref.unit ?? "";
|
||||
const won = ti("Won");
|
||||
const out = (name: string, cell: string, u = won): SlPill => ({ name, cell, unit: u });
|
||||
const pillsIn: SlPill[] = [
|
||||
{ name: ti("Qty"), cell: MONEY.qty, unit },
|
||||
...(outside.includes("노무비")
|
||||
? [{ name: ti("PriceLabor"), cell: MONEY.labor, unit: won, outside: true }]
|
||||
: []),
|
||||
...(outside.includes("재료비")
|
||||
? [{ name: ti("PriceMat"), cell: MONEY.mat, unit: won, outside: true }]
|
||||
: []),
|
||||
...(outside.includes("경비")
|
||||
? [{ name: ti("PriceExp"), cell: MONEY.exp, unit: won, outside: true }]
|
||||
: []),
|
||||
];
|
||||
const pillsOut: SlPill[] = [
|
||||
out(PILL_PRICE, MONEY.unit),
|
||||
out(PILL_AMOUNT, MONEY.amount),
|
||||
out(ti("Labor3"), MONEY.cost.노무비),
|
||||
out(ti("Mat3"), MONEY.cost.재료비),
|
||||
out(ti("Exp3"), MONEY.cost.경비),
|
||||
out(USED, MONEY.used, unit),
|
||||
];
|
||||
return { cells, pillsIn, pillsOut, rules: [] as ReturnType<typeof listRule>[] };
|
||||
}
|
||||
|
||||
function rateLayout(ref: ItemRef, rate: Cell | undefined) {
|
||||
const base = ref.base ?? "노무비";
|
||||
const into = ref.into ?? "재료비";
|
||||
const cells: Record<string, Cell> = {
|
||||
A1: text(ti("RatePct")),
|
||||
B1: rate ?? num(3),
|
||||
A2: text(PILL_BASE),
|
||||
B2: num(0),
|
||||
A3: text(ti("Base")),
|
||||
B3: text(base),
|
||||
A4: text(PILL_AMOUNT),
|
||||
B4: calc("ROUND(B2*B1/100,1)"),
|
||||
A5: text(ti("Into")),
|
||||
B5: text(into),
|
||||
A6: text(ti("Labor3")),
|
||||
B6: calc('IF(B5="노무비",B4,0)'),
|
||||
A7: text(ti("Mat3")),
|
||||
B7: calc('IF(B5="재료비",B4,0)'),
|
||||
A8: text(ti("Exp3")),
|
||||
B8: calc('IF(B5="경비",B4,0)'),
|
||||
A9: text(USED),
|
||||
B9: calc("B1"),
|
||||
A10: text(ti("Price")),
|
||||
B10: calc("B2"),
|
||||
};
|
||||
const pillsIn: SlPill[] = [
|
||||
{ name: ti("RatePct"), cell: RATE.rate, unit: "%" },
|
||||
{ name: PILL_BASE, cell: RATE.base, unit: ti("Won") },
|
||||
];
|
||||
const pillsOut: SlPill[] = [
|
||||
{ name: PILL_PRICE, cell: RATE.unit, unit: ti("Won") },
|
||||
{ name: PILL_AMOUNT, cell: RATE.amount, unit: ti("Won") },
|
||||
{ name: ti("Labor3"), cell: RATE.cost.노무비, unit: ti("Won") },
|
||||
{ name: ti("Mat3"), cell: RATE.cost.재료비, unit: ti("Won") },
|
||||
{ name: ti("Exp3"), cell: RATE.cost.경비, unit: ti("Won") },
|
||||
{ name: USED, cell: RATE.used, unit: "%" },
|
||||
];
|
||||
const rules = [listRule("sl-base", RATE.baseKind, COSTS), listRule("sl-into", RATE.into, COSTS)];
|
||||
return { cells, pillsIn, pillsOut, rules };
|
||||
}
|
||||
|
||||
/** 어떤 바깥 값이 필요한가 — 노임 = 노무 · 자재 = 재료 · 장비 · 일위대가 = 셋 */
|
||||
const outsideFields = (what: ItemKind): SlField[] =>
|
||||
what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : what === "비율" ? [] : COSTS;
|
||||
const FIELD_CELL: Record<string, string> = {
|
||||
노무비: MONEY.labor,
|
||||
재료비: MONEY.mat,
|
||||
경비: MONEY.exp,
|
||||
};
|
||||
|
||||
/** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */
|
||||
function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic {
|
||||
const sheet = sheetOf(sl, node);
|
||||
const oldRef = itemRefOf(node);
|
||||
const keep =
|
||||
oldRef && oldRef.what !== "비율" && ref.what !== "비율" ? sheet?.칸[MONEY.qty] : undefined;
|
||||
const keepRate =
|
||||
oldRef?.what === "비율" && ref.what === "비율" ? sheet?.칸[RATE.rate] : undefined;
|
||||
const given: Cell | undefined = qty !== undefined && qty !== null ? num(qty) : undefined;
|
||||
const lay =
|
||||
ref.what === "비율"
|
||||
? rateLayout(ref, keepRate)
|
||||
: moneyLayout(ref, given ?? keep, outsideFields(ref.what));
|
||||
const next = editSheet(sl, node, (cells, s) => {
|
||||
for (const k of Object.keys(cells)) delete cells[k];
|
||||
Object.assign(cells, lay.cells);
|
||||
if (lay.rules.length) s.유효성 = lay.rules;
|
||||
else delete s.유효성;
|
||||
});
|
||||
const names = new Set([...lay.pillsIn.map((p) => p.name), ...lay.pillsOut.map((p) => p.name)]);
|
||||
const kept = (e: { from: { node: string; pill: string }; to: { node: string; pill: string } }) =>
|
||||
(e.from.node !== node.id || names.has(e.from.pill)) &&
|
||||
(e.to.node !== node.id || names.has(e.to.pill));
|
||||
return {
|
||||
...withNode(next, {
|
||||
...node,
|
||||
ref: JSON.stringify(ref),
|
||||
pillsIn: lay.pillsIn,
|
||||
pillsOut: lay.pillsOut,
|
||||
stage: 3,
|
||||
}),
|
||||
edges: next.edges.filter(kept),
|
||||
};
|
||||
}
|
||||
|
||||
/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 합 — 시트 사이 참조 · 선도 함께 */
|
||||
export function refreshBases(sl: SheetLogic): SheetLogic {
|
||||
let out = sl;
|
||||
for (const node of sl.nodes) {
|
||||
const ref = itemRefOf(node);
|
||||
if (!ref || ref.what !== "비율") continue;
|
||||
const base = ref.base ?? "노무비";
|
||||
const sources = out.nodes.filter((n) => {
|
||||
const r = itemRefOf(n);
|
||||
return n.kind === "item" && r && r.what !== "비율";
|
||||
});
|
||||
const refs = sources
|
||||
.map((n) => {
|
||||
const s = sheetOf(out, n);
|
||||
return s ? `${quoteSheet(s.이름)}!${costCell(itemRefOf(n)!, base)}` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const formula = refs.length ? refs.join("+") : "0";
|
||||
out = editSheet(out, node, (cells) => {
|
||||
cells[RATE.base] = refs.length ? calc(formula) : num(0);
|
||||
});
|
||||
const mine = (id: string): boolean => id.startsWith(`e-base-${node.id}-`);
|
||||
out = {
|
||||
...out,
|
||||
edges: [
|
||||
...out.edges.filter((e) => !mine(e.id)),
|
||||
...sources.map((n) => ({
|
||||
id: `e-base-${node.id}-${n.id}`,
|
||||
from: { node: n.id, pill: base },
|
||||
to: { node: node.id, pill: PILL_BASE },
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 이 항목 상자들이 필요로 하는 바깥 값 자리 — Api `slOutside` · 저장이 같은 목록을 씀 */
|
||||
export function itemOutsides(sl: SheetLogic): SlOutsideAt[] {
|
||||
return sl.nodes.flatMap((node) => {
|
||||
const ref = itemRefOf(node);
|
||||
const sheet = sheetOf(sl, node);
|
||||
if (!ref || !sheet) return [];
|
||||
const items = outsideItems(ref, node.id);
|
||||
return items.map(({ field, item }) => ({ item, sheet: sheet.id, cell: FIELD_CELL[field] }));
|
||||
});
|
||||
}
|
||||
|
||||
function outsideItems(ref: ItemRef, id: string): { field: SlField; item: SlOutside }[] {
|
||||
const key = typeof ref.element === "string" ? ref.element : "";
|
||||
const name = (f: string): string => `${id}:${f}`;
|
||||
if (ref.what === "노무")
|
||||
return [{ field: "노무비", item: { id: name("노무비"), kind: "노임", key } }];
|
||||
if (ref.what === "재료") {
|
||||
const k = (ref.element as { 키?: string } | null)?.키 ?? "";
|
||||
return [{ field: "재료비", item: { id: name("재료비"), kind: "자재", key: k } }];
|
||||
}
|
||||
const inputs = ref.inputs ?? {};
|
||||
if (ref.what === "장비") {
|
||||
return COSTS.map((f) => ({
|
||||
field: f,
|
||||
item: { id: name(f), kind: "장비" as const, element: key, inputs, field: f },
|
||||
}));
|
||||
}
|
||||
if (ref.what === "일위대가") {
|
||||
const logicKey = key.match(/로직\(\s*([A-Z]{2}\d{6})/)?.[1] ?? "";
|
||||
return COSTS.map((f) => ({
|
||||
field: f,
|
||||
item: { id: name(f), kind: "로직" as const, key: logicKey, inputs, field: f },
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 단가를 서버에서 받아 단가 칸에 넣음 — 못 구한 것은 까닭을 돌려줌(쉬운 말) */
|
||||
export async function loadPrices(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
): Promise<{ sl: SheetLogic; why: string | null }> {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
const ref = node && itemRefOf(node);
|
||||
if (!node || !ref) return { sl, why: null };
|
||||
const items = outsideItems(ref, node.id);
|
||||
if (!items.length) return { sl, why: null };
|
||||
const got = await slOutside(items.map((i) => i.item));
|
||||
let why: string | null = null;
|
||||
const next = editSheet(sl, node, (cells) => {
|
||||
for (const { field, item } of items) {
|
||||
const v = got.find((g) => g.id === item.id);
|
||||
if (v?.ok && typeof v.value === "number") cells[FIELD_CELL[field]] = num(v.value);
|
||||
else why = v?.reason ?? ti("NoPrice");
|
||||
}
|
||||
});
|
||||
return { sl: next, why };
|
||||
}
|
||||
|
||||
const PICK_KIND = { 노무: "person", 재료: "material", 장비: "machine", 일위대가: "logic" } as const;
|
||||
const KIND_OF_PICK: Record<PickResult["kind"], ItemKind> = {
|
||||
person: "노무",
|
||||
material: "재료",
|
||||
machine: "장비",
|
||||
logic: "일위대가",
|
||||
};
|
||||
|
||||
/** 재료는 검색어 묶음만 오므로 단가를 부를 번호를 찾아 둠 */
|
||||
async function materialKey(el_: Record<string, string>): Promise<string> {
|
||||
const found = await searchElements("재료", el_["검색어"] ?? "");
|
||||
const hit = found.items.find(
|
||||
(it) => `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() === (el_["검색어"] ?? "").trim(),
|
||||
);
|
||||
return (hit ?? found.items[0])?.ref ?? "";
|
||||
}
|
||||
|
||||
/** 고른 것으로 항목 상자를 채움(단가는 따로 loadPrices) */
|
||||
export async function applyPick(
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
pick: PickResult,
|
||||
): Promise<SheetLogic> {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
if (!node) return sl;
|
||||
const what = KIND_OF_PICK[pick.kind];
|
||||
let element = pick.element;
|
||||
if (what === "재료") {
|
||||
const e = { ...(pick.element as Record<string, string>) };
|
||||
element = { ...e, 키: await materialKey(e) };
|
||||
}
|
||||
const inputs: Record<string, unknown> = {};
|
||||
for (const n of pick.needInputs ?? []) inputs[n.이름] = n.고르기?.[0] ?? n.범위?.[0];
|
||||
const ref: ItemRef = {
|
||||
what,
|
||||
element,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
need: pick.needInputs,
|
||||
inputs,
|
||||
};
|
||||
const named = withNode(sl, { ...node, name: `${pick.name}${pick.spec ? ` ${pick.spec}` : ""}` });
|
||||
return refreshBases(applyLayout(named, nodeOf(named, nodeId)!, ref, pick.qty));
|
||||
}
|
||||
|
||||
/* ---- 손잡이 ---- */
|
||||
const cellValue = (sl: SheetLogic, node: SlNode, cell: string): Cell | undefined =>
|
||||
sheetOf(sl, node)?.칸[cell];
|
||||
|
||||
export function renderItemTools(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): void {
|
||||
const node = nodeOf(sl, nodeId);
|
||||
const msg = el("p", { className: "m01-slitem__msg" });
|
||||
if (!node) {
|
||||
host.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const ref = itemRefOf(node);
|
||||
const emit = (make: () => SheetLogic): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
on.change(make());
|
||||
} catch (e) {
|
||||
msg.textContent = slPlain(e) || ti("Cant");
|
||||
}
|
||||
};
|
||||
const setRef = (patch: Partial<ItemRef>): SheetLogic => {
|
||||
const next: ItemRef = { ...ref!, ...patch };
|
||||
return refreshBases(applyLayout(sl, node, next));
|
||||
};
|
||||
const chosen = (): HTMLElement =>
|
||||
el("p", {
|
||||
className: "m01-slitem__chosen",
|
||||
text: ref && ref.what !== "비율" ? node.name : ti("None"),
|
||||
});
|
||||
|
||||
const pickAndLoad = async (kind: ItemKind): Promise<void> => {
|
||||
if (kind === "비율") {
|
||||
emit(() => {
|
||||
const named = /^항목 \d+$/.test(node.name)
|
||||
? withNode(sl, { ...node, name: ti("ToolLoss") })
|
||||
: sl;
|
||||
return refreshBases(
|
||||
applyLayout(named, nodeOf(named, node.id)!, {
|
||||
what: "비율",
|
||||
base: "노무비",
|
||||
into: "재료비",
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const got = await openEasyPick(PICK_KIND[kind]);
|
||||
if (!got) return;
|
||||
msg.textContent = ti("Loading");
|
||||
try {
|
||||
const filled = await applyPick(sl, node.id, got);
|
||||
on.change(filled);
|
||||
const priced = await loadPrices(filled, node.id);
|
||||
msg.textContent = priced.why ?? "";
|
||||
on.change(priced.sl);
|
||||
} catch (e) {
|
||||
msg.textContent = slPlain(e) || ti("Cant");
|
||||
}
|
||||
};
|
||||
|
||||
const kindField = createSelectField({
|
||||
label: ti("Kind"),
|
||||
options: (["노무", "재료", "장비", "일위대가", "비율"] as ItemKind[]).map((k) => ({
|
||||
value: k,
|
||||
text: ti(
|
||||
k === "노무"
|
||||
? "Labor"
|
||||
: k === "재료"
|
||||
? "Material"
|
||||
: k === "장비"
|
||||
? "Machine"
|
||||
: k === "비율"
|
||||
? "Rate"
|
||||
: "Logic",
|
||||
),
|
||||
})),
|
||||
value: ref?.what ?? "노무",
|
||||
compact: true,
|
||||
onChange: (v) => void pickAndLoad(v as ItemKind),
|
||||
});
|
||||
|
||||
const numberBox = (cell: string, label: string, unit: string): HTMLElement => {
|
||||
const c = cellValue(sl, node, cell);
|
||||
const linked = c?.식 !== undefined;
|
||||
const box = el("input", {
|
||||
className: "m01-slitem__input",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": label },
|
||||
});
|
||||
box.value = linked ? "" : c?.값 === undefined ? "" : String(c.값);
|
||||
box.readOnly = linked;
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const n = Number(raw);
|
||||
if (raw === "" || Number.isNaN(n)) {
|
||||
msg.textContent = ti("Bad");
|
||||
return;
|
||||
}
|
||||
emit(() => editSheet(sl, node, (cells) => (cells[cell] = num(n))));
|
||||
});
|
||||
return el("label", {
|
||||
className: "m01-slitem__field",
|
||||
children: [
|
||||
el("span", { text: `${label}${unit ? ` (${unit})` : ""}` }),
|
||||
box,
|
||||
...(linked ? [el("small", { className: "m01-slitem__muted", text: ti("Linked") })] : []),
|
||||
],
|
||||
});
|
||||
};
|
||||
const costSelect = (
|
||||
label: string,
|
||||
value: CostName,
|
||||
patch: (c: CostName) => Partial<ItemRef>,
|
||||
): HTMLElement => {
|
||||
const f = createSelectField({
|
||||
label,
|
||||
options: COSTS.map((c) => ({ value: c, text: c })),
|
||||
value,
|
||||
compact: true,
|
||||
onChange: (v) => emit(() => setRef(patch(v as CostName))),
|
||||
});
|
||||
return f.root;
|
||||
};
|
||||
|
||||
const body: HTMLElement[] = [kindField.root];
|
||||
if (ref && ref.what !== "비율") {
|
||||
const price = (cell: string, label: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-slitem__price",
|
||||
children: [
|
||||
el("span", { text: label }),
|
||||
el("strong", { text: formatPrice(cellValue(sl, node, cell)?.값 ?? 0) }),
|
||||
],
|
||||
});
|
||||
body.push(
|
||||
chosen(),
|
||||
createButton({
|
||||
label: ref.element === undefined ? ti("Pick") : ti("Repick"),
|
||||
size: "sm",
|
||||
onClick: () => void pickAndLoad(ref.what),
|
||||
}),
|
||||
numberBox(MONEY.qty, ti("Qty"), ref.unit ?? ""),
|
||||
el("div", {
|
||||
className: "m01-slitem__prices",
|
||||
children: [
|
||||
price(MONEY.labor, ti("PriceLabor")),
|
||||
price(MONEY.mat, ti("PriceMat")),
|
||||
price(MONEY.exp, ti("PriceExp")),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (ref.element !== undefined) {
|
||||
body.push(
|
||||
createButton({
|
||||
label: ti("Reload"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void loadPrices(sl, node.id).then(
|
||||
(r) => {
|
||||
msg.textContent = r.why ?? "";
|
||||
on.change(r.sl);
|
||||
},
|
||||
(e) => (msg.textContent = slPlain(e) || ti("NoPrice")),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
body.push(
|
||||
el("p", {
|
||||
className: "m01-slitem__muted",
|
||||
text: ref.what === "장비" || ref.what === "일위대가" ? ti("Split") : ti("Formula"),
|
||||
}),
|
||||
);
|
||||
} else if (ref) {
|
||||
body.push(
|
||||
numberBox(RATE.rate, ti("RatePct"), "%"),
|
||||
costSelect(ti("Base"), ref.base ?? "노무비", (c) => ({ base: c })),
|
||||
costSelect(ti("Into"), ref.into ?? "재료비", (c) => ({ into: c })),
|
||||
createButton({
|
||||
label: ti("Fit"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => emit(() => refreshBases(sl)),
|
||||
}),
|
||||
el("p", { className: "m01-slitem__muted", text: ti("BaseHint") }),
|
||||
);
|
||||
} else {
|
||||
body.push(chosen());
|
||||
}
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-slitem__title", text: `${ti("Title")} · ${node.name}` }),
|
||||
el("div", { className: "m01-slitem__body", children: body }),
|
||||
msg,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
.m01-slpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-slpanel-head,
|
||||
.m01-slpanel-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-cell {
|
||||
padding: 2px var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-slpanel-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slpanel .ui-btn,
|
||||
.m01-slpanel .ui-input {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-slpanel-name {
|
||||
flex: none;
|
||||
width: 12em;
|
||||
}
|
||||
|
||||
.m01-slpanel-msg {
|
||||
margin: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.m01-slpanel-sheet {
|
||||
position: relative;
|
||||
min-height: 260px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m01-slpanel-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-pill,
|
||||
.m01-slpanel-dir {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--spacing-12);
|
||||
border: 2px solid;
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-slpanel-dir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.m01-slpanel-pill:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.m01-slpanel-pill.is-on {
|
||||
background: var(--color-surface-raised);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.is-in {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.is-out {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.m01-slpanel-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-slpanel-infoline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-slpanel-infoline > .m01-slpanel-note {
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
/* 칸 위 알약 표시 — 색 + 글자(입력 · 출력) 둘 다 */
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="in"] {
|
||||
box-shadow: inset 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="out"] {
|
||||
box-shadow: inset 0 0 0 2px #f59e0b;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill]::after {
|
||||
content: attr(data-sl-name);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 0 0 0 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="in"]::after {
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-pill="out"]::after {
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-lock]::before {
|
||||
content: "🔒 " attr(data-sl-lock);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m01-slpanel .aislo-cell[data-sl-err] {
|
||||
outline: 2px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Panel.ts
|
||||
* 시트 로직 아래 시트 판 — 고른 상자의 시트를 우리 스프레드시트 편집기로 · 칸 → 입력/출력 알약 · 칸 위 알약 표시 ·
|
||||
* 받는 알약 칸 잠금 · 틀 단추(진짜 엑셀 수식) · 틀린 수식/안 이어진 입력 알약 한 줄
|
||||
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · M01_SL_Model 타입 그대로 · 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
CalcValue,
|
||||
CalcValues,
|
||||
SpreadsheetHandle,
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet";
|
||||
import { disconnect, type SheetLogic, type SlEdge, type SlNode, type SlPill } from "./M01_SL_Model";
|
||||
import "./M01_SL_Panel.css";
|
||||
|
||||
const TEXT = {
|
||||
Cell: ["고른 칸", "Selected cell"],
|
||||
PillIn: ["입력 알약으로", "Make input pill"],
|
||||
PillOut: ["출력 알약으로", "Make output pill"],
|
||||
AskName: ["알약 이름", "Pill name"],
|
||||
Ok: ["정함", "Set"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Frames: ["틀", "Templates"],
|
||||
FrameMore: ["할증", "Markup"],
|
||||
FrameDiv: ["나누기", "Divide"],
|
||||
FrameIf: ["만약", "If"],
|
||||
FrameMin: ["최소", "Min"],
|
||||
FrameMax: ["최대", "Max"],
|
||||
FrameUp: ["올림", "Round up"],
|
||||
FrameLook: ["표에서 찾기", "Look up in table"],
|
||||
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
|
||||
In: ["입력", "In"],
|
||||
Out: ["출력", "Out"],
|
||||
Lock: ["선", "Wire"],
|
||||
Pills: ["알약", "Pills"],
|
||||
NoPills: [
|
||||
"아직 알약이 없음 — 칸을 골라 알약으로 정함",
|
||||
"No pills yet — pick a cell and make it a pill",
|
||||
],
|
||||
Value: ["값", "Value"],
|
||||
Unit: ["단위", "Unit"],
|
||||
From: ["어디서 오나", "Comes from"],
|
||||
To: ["어디로 가나", "Goes to"],
|
||||
NoFrom: ["아직 이어지지 않음", "Not connected yet"],
|
||||
NoTo: ["아직 어디로도 안 감", "Goes nowhere yet"],
|
||||
Close: ["닫기", "Close"],
|
||||
ReadOnly: ["표준은 읽기만", "Read only"],
|
||||
Loading: ["시트를 불러오는 중", "Loading sheet"],
|
||||
BreakAsk: [
|
||||
"이 칸은 선으로 들어오는 값입니다. 선을 끊고 직접 고칠까요?",
|
||||
"This cell receives a wire. Cut the wire and edit it yourself?",
|
||||
],
|
||||
BreakOk: ["선을 끊고 고침", "Cut and edit"],
|
||||
Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"],
|
||||
Problem: ["⚠ ", "⚠ "],
|
||||
Unwired: [" 입력 알약이 아직 이어지지 않음", " input pill is not connected yet"],
|
||||
Err_DIV0: ["0으로 나눔", "divides by zero"],
|
||||
Err_NAME: ["알 수 없는 이름", "unknown name"],
|
||||
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
|
||||
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
|
||||
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
|
||||
Err_NA: ["찾는 값이 없음", "value not found"],
|
||||
Err_OTHER: ["수식이 틀림", "formula is wrong"],
|
||||
} as const;
|
||||
|
||||
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
|
||||
const FRAMES: [keyof typeof TEXT, string][] = [
|
||||
["FrameMore", "=B1*(1+B2/100)"],
|
||||
["FrameDiv", "=B1/B2"],
|
||||
["FrameIf", '=IF(B3="급",10,0)'],
|
||||
["FrameMin", "=MIN(B1,B2)"],
|
||||
["FrameMax", "=MAX(B1,B2)"],
|
||||
["FrameUp", "=ROUNDUP(B4,3)"],
|
||||
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
|
||||
];
|
||||
|
||||
const ERR_KEY: Record<string, keyof typeof TEXT> = {
|
||||
"#DIV/0!": "Err_DIV0",
|
||||
"#NAME?": "Err_NAME",
|
||||
"#REF!": "Err_REF",
|
||||
"#VALUE!": "Err_VALUE",
|
||||
"#CYCLE!": "Err_CYCLE",
|
||||
"#N/A": "Err_NA",
|
||||
};
|
||||
|
||||
type Handlers = {
|
||||
change(sl: SheetLogic): void;
|
||||
pill(nodeId: string, cell: string, dir: "in" | "out", name: string): void;
|
||||
};
|
||||
type SheetModule = typeof import("../../A00_Common/spreadsheet/spreadsheet");
|
||||
|
||||
interface State {
|
||||
nodeId: string;
|
||||
sl: SheetLogic;
|
||||
on: Handlers;
|
||||
readOnly: boolean;
|
||||
handle: SpreadsheetHandle | null;
|
||||
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
|
||||
snap: CalcValues;
|
||||
/** 편집기가 마지막으로 낸 문서(활성 빼고) — 같으면 다시 안 끼움 */
|
||||
json: string;
|
||||
cell: string;
|
||||
info: { dir: "in" | "out"; name: string } | null;
|
||||
asking: "in" | "out" | null;
|
||||
busy: boolean;
|
||||
parts: {
|
||||
cell: HTMLElement;
|
||||
bar: HTMLElement;
|
||||
msg: HTMLElement;
|
||||
sheet: HTMLElement;
|
||||
pills: HTMLElement;
|
||||
info: HTMLElement;
|
||||
};
|
||||
observer: MutationObserver | null;
|
||||
redraw(): void;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, State>();
|
||||
let sheetModule: Promise<SheetModule> | null = null;
|
||||
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
|
||||
const loadSheet = (): Promise<SheetModule> =>
|
||||
(sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet"));
|
||||
|
||||
const norm = (book: Workbook): string => JSON.stringify({ ...book, 활성: null, 계산값: null });
|
||||
const nodeOf = (st: State): SlNode | undefined => st.sl.nodes.find((n) => n.id === st.nodeId);
|
||||
const shown = (st: State): Workbook => ({
|
||||
...st.sl.workbook,
|
||||
활성: nodeOf(st)?.sheetId ?? st.sl.workbook.활성,
|
||||
});
|
||||
|
||||
/* ------------------------------ 값 · 말 ------------------------------ */
|
||||
|
||||
function valueText(v: CalcValue | number | string | boolean | undefined | null): string {
|
||||
if (v === undefined || v === null) return "";
|
||||
if (typeof v !== "object") return String(v);
|
||||
if ("수" in v) return v.수;
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참 ? "TRUE" : "FALSE";
|
||||
return v.오류;
|
||||
}
|
||||
|
||||
function valueAt(st: State, addr: string): string {
|
||||
const node = nodeOf(st);
|
||||
const sheet = st.sl.workbook.시트.find((s) => s.id === node?.sheetId);
|
||||
const cell = sheet?.칸[addr];
|
||||
if (!cell) return "";
|
||||
if (cell.식 === undefined) return valueText(cell.값);
|
||||
return valueText(st.snap[sheet!.id]?.[addr]);
|
||||
}
|
||||
|
||||
const edgesIn = (st: State, pill: string): SlEdge[] =>
|
||||
st.sl.edges.filter((e) => e.to.node === st.nodeId && e.to.pill === pill);
|
||||
const edgesOut = (st: State, pill: string): SlEdge[] =>
|
||||
st.sl.edges.filter((e) => e.from.node === st.nodeId && e.from.pill === pill);
|
||||
|
||||
/** 선으로 값을 받는 칸 — 직접 고치기 전에 물음 */
|
||||
function lockedCells(st: State): Map<string, SlEdge> {
|
||||
const out = new Map<string, SlEdge>();
|
||||
for (const p of nodeOf(st)?.pillsIn ?? []) {
|
||||
const e = edgesIn(st, p.name)[0];
|
||||
if (e) out.set(p.cell, e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 틀린 수식 · 이어지지 않은 입력 알약 — 칸 → 까닭 */
|
||||
function problems(st: State): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const node = nodeOf(st);
|
||||
if (!node || !st.handle) return out;
|
||||
const sheet = st.sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const snap = st.snap[node.sheetId] ?? {};
|
||||
for (const [addr, v] of Object.entries(snap)) {
|
||||
if (typeof v === "object" && "오류" in v) {
|
||||
out.set(addr, `${addr} ${tp(ERR_KEY[v.오류] ?? "Err_OTHER")}`);
|
||||
}
|
||||
}
|
||||
for (const p of node.pillsIn) {
|
||||
const filled = sheet?.칸[p.cell]?.식 !== undefined;
|
||||
if (!edgesIn(st, p.name).length && !filled && node.kind !== "input" && node.kind !== "const") {
|
||||
out.set(p.cell, `${p.name}(${p.cell})${tp("Unwired")}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */
|
||||
function refreshSnap(st: State): void {
|
||||
if (!st.handle) return;
|
||||
st.snap = st.handle.recalc();
|
||||
st.redraw();
|
||||
}
|
||||
|
||||
/* ------------------------------ 칸 위 표시 ------------------------------ */
|
||||
|
||||
function applyMarks(st: State): void {
|
||||
const node = nodeOf(st);
|
||||
const grid = st.parts.sheet;
|
||||
if (!node) return;
|
||||
const pill = new Map<string, { dir: "in" | "out"; p: SlPill }>();
|
||||
for (const p of node.pillsIn) pill.set(p.cell, { dir: "in", p });
|
||||
for (const p of node.pillsOut) pill.set(p.cell, { dir: "out", p });
|
||||
const lock = lockedCells(st);
|
||||
const bad = problems(st);
|
||||
for (const c of grid.querySelectorAll<HTMLElement>(".aislo-cell")) {
|
||||
const addr = c.dataset.addr ?? "";
|
||||
const hit = pill.get(addr);
|
||||
for (const a of ["slPill", "slName", "slLock", "slErr"]) delete c.dataset[a];
|
||||
if (hit) {
|
||||
c.dataset.slPill = hit.dir;
|
||||
c.dataset.slName = `${tp(hit.dir === "in" ? "In" : "Out")} ${hit.p.name}`;
|
||||
}
|
||||
if (lock.has(addr)) c.dataset.slLock = tp("Lock");
|
||||
if (bad.has(addr)) c.dataset.slErr = "1";
|
||||
}
|
||||
const first = [...bad.values()][0];
|
||||
st.parts.msg.textContent = first
|
||||
? `${tp("Problem")}${first}${bad.size > 1 ? ` (+${bad.size - 1})` : ""}`
|
||||
: "";
|
||||
st.parts.msg.hidden = !first;
|
||||
}
|
||||
|
||||
/* ------------------------------ 위 줄 · 알약 목록 ------------------------------ */
|
||||
|
||||
function nameDefault(st: State): string {
|
||||
const row = st.cell.replace(/^[A-Z]+/, "");
|
||||
const sheet = st.sl.workbook.시트.find((s) => s.id === nodeOf(st)?.sheetId);
|
||||
const a = sheet?.칸[`A${row}`];
|
||||
return a ? valueText(a.값 ?? a.식) : "";
|
||||
}
|
||||
|
||||
function drawBar(st: State): void {
|
||||
const { bar, cell } = st.parts;
|
||||
cell.textContent = `${tp("Cell")} ${st.cell}`;
|
||||
const frag: (HTMLElement | string)[] = [];
|
||||
if (st.readOnly) {
|
||||
bar.replaceChildren(el("span", { className: "m01-slpanel-note", text: tp("ReadOnly") }));
|
||||
return;
|
||||
}
|
||||
const ask = (dir: "in" | "out"): void => {
|
||||
st.asking = dir;
|
||||
drawBar(st);
|
||||
};
|
||||
const pillRow = el("div", {
|
||||
className: "m01-slpanel-row",
|
||||
children: [
|
||||
createButton({ label: tp("PillIn"), variant: "ghost", size: "sm", onClick: () => ask("in") }),
|
||||
createButton({
|
||||
label: tp("PillOut"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ask("out"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
frag.push(pillRow);
|
||||
if (st.asking) {
|
||||
const dir = st.asking;
|
||||
const input = el("input", {
|
||||
className: "ui-input m01-slpanel-name",
|
||||
attrs: { type: "text", "aria-label": tp("AskName") },
|
||||
});
|
||||
input.value = nameDefault(st);
|
||||
const done = (): void => {
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
st.asking = null;
|
||||
st.on.pill(st.nodeId, st.cell, dir, name);
|
||||
drawBar(st);
|
||||
};
|
||||
input.addEventListener("keydown", (e) => e.key === "Enter" && done());
|
||||
frag.push(
|
||||
el("div", {
|
||||
className: "m01-slpanel-row",
|
||||
children: [
|
||||
el("span", {
|
||||
className: `m01-slpanel-dir is-${dir}`,
|
||||
text: tp(dir === "in" ? "In" : "Out"),
|
||||
}),
|
||||
input,
|
||||
createButton({ label: tp("Ok"), variant: "filled", size: "sm", onClick: done }),
|
||||
createButton({
|
||||
label: tp("Cancel"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((st.asking = null), drawBar(st)),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
queueMicrotask(() => input.focus());
|
||||
}
|
||||
frag.push(
|
||||
el("div", {
|
||||
className: "m01-slpanel-row",
|
||||
children: [
|
||||
el("span", { className: "m01-slpanel-note", text: tp("Frames") }),
|
||||
...FRAMES.map(([key, formula]) =>
|
||||
createButton({
|
||||
label: tp(key),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => putFormula(st, formula),
|
||||
}),
|
||||
),
|
||||
el("span", { className: "m01-slpanel-note", text: tp("FrameHint") }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
bar.replaceChildren(...frag.filter((x): x is HTMLElement => typeof x !== "string"));
|
||||
}
|
||||
|
||||
function drawPills(st: State): void {
|
||||
const node = nodeOf(st);
|
||||
const chips: HTMLElement[] = [];
|
||||
const add = (dir: "in" | "out", p: SlPill): void => {
|
||||
const b = el("button", {
|
||||
className: `m01-slpanel-pill is-${dir}${st.info?.name === p.name && st.info.dir === dir ? " is-on" : ""}`,
|
||||
text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})`,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
b.addEventListener("click", () => ((st.info = { dir, name: p.name }), drawPills(st)));
|
||||
chips.push(b);
|
||||
};
|
||||
for (const p of node?.pillsIn ?? []) add("in", p);
|
||||
for (const p of node?.pillsOut ?? []) add("out", p);
|
||||
st.parts.pills.replaceChildren(
|
||||
...(chips.length
|
||||
? chips
|
||||
: [el("span", { className: "m01-slpanel-note", text: tp("NoPills") })]),
|
||||
);
|
||||
drawInfo(st);
|
||||
}
|
||||
|
||||
function drawInfo(st: State): void {
|
||||
const box = st.parts.info;
|
||||
const node = nodeOf(st);
|
||||
const p = (st.info?.dir === "in" ? node?.pillsIn : node?.pillsOut)?.find(
|
||||
(x) => x.name === st.info?.name,
|
||||
);
|
||||
if (!st.info || !p || !node) {
|
||||
box.hidden = true;
|
||||
box.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const dir = st.info.dir;
|
||||
const nameOf = (id: string): string => st.sl.nodes.find((n) => n.id === id)?.name ?? "";
|
||||
const links = (dir === "in" ? edgesIn(st, p.name) : edgesOut(st, p.name)).map((e) => {
|
||||
const end = dir === "in" ? e.from : e.to;
|
||||
return `${nameOf(end.node)} · ${end.pill}`;
|
||||
});
|
||||
const rows: [string, string][] = [
|
||||
[tp("Value"), valueAt(st, p.cell)],
|
||||
[tp("Unit"), p.unit ?? ""],
|
||||
[
|
||||
tp(dir === "in" ? "From" : "To"),
|
||||
links.length ? links.join(", ") : tp(dir === "in" ? "NoFrom" : "NoTo"),
|
||||
],
|
||||
];
|
||||
box.hidden = false;
|
||||
box.replaceChildren(
|
||||
el("strong", { text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})` }),
|
||||
...rows.map(([k, v]) =>
|
||||
el("div", {
|
||||
className: "m01-slpanel-infoline",
|
||||
children: [el("span", { className: "m01-slpanel-note", text: k }), v],
|
||||
}),
|
||||
),
|
||||
createButton({
|
||||
label: tp("Close"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((st.info = null), drawPills(st)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ 편집기 ------------------------------ */
|
||||
|
||||
/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */
|
||||
function putFormula(st: State, formula: string): void {
|
||||
const root = st.parts.sheet;
|
||||
const bar = root.querySelector<HTMLTextAreaElement>(".ss-formula");
|
||||
const addr = root.querySelector<HTMLInputElement>(".ss-addr");
|
||||
if (!bar || !addr || st.readOnly) return;
|
||||
const at = st.cell;
|
||||
bar.focus();
|
||||
bar.value = formula;
|
||||
bar.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
bar.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
addr.value = at;
|
||||
addr.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 편집기가 문서를 냄 — 선 받는 칸을 직접 고쳤으면 먼저 물음 */
|
||||
async function onBook(st: State, book: Workbook): Promise<void> {
|
||||
if (st.busy) return;
|
||||
const prev = st.sl;
|
||||
const node = nodeOf(st);
|
||||
const next: SheetLogic = { ...prev, workbook: { ...book, 활성: prev.workbook.활성 } };
|
||||
const before = prev.workbook.시트.find((s) => s.id === node?.sheetId);
|
||||
const after = book.시트.find((s) => s.id === node?.sheetId);
|
||||
const hit = [...lockedCells(st)].find(
|
||||
([addr]) => JSON.stringify(before?.칸[addr]) !== JSON.stringify(after?.칸[addr]),
|
||||
);
|
||||
if (!hit) {
|
||||
st.json = norm(book);
|
||||
st.sl = next;
|
||||
st.on.change(next);
|
||||
return void setTimeout(() => refreshSnap(st), 0);
|
||||
}
|
||||
st.busy = true;
|
||||
const ok = await showConfirmDialog(tp("BreakAsk"), tp("BreakOk"));
|
||||
st.busy = false;
|
||||
if (!ok) {
|
||||
st.json = norm(shown(st));
|
||||
return st.handle?.setDoc(structuredClone(shown(st)));
|
||||
}
|
||||
try {
|
||||
const cut = disconnect(next, hit[1].id);
|
||||
const sheet = cut.workbook.시트.find((s) => s.id === node?.sheetId);
|
||||
const mine = after?.칸[hit[0]];
|
||||
if (sheet) {
|
||||
if (mine) sheet.칸[hit[0]] = mine;
|
||||
else delete sheet.칸[hit[0]];
|
||||
}
|
||||
st.json = norm(cut.workbook);
|
||||
st.sl = cut;
|
||||
st.on.change(cut);
|
||||
setTimeout(() => refreshSnap(st), 0);
|
||||
} catch {
|
||||
showToast(tp("Cannot"), "error");
|
||||
st.handle?.setDoc(structuredClone(shown(st)));
|
||||
}
|
||||
}
|
||||
|
||||
async function mountSheet(st: State): Promise<void> {
|
||||
const { createSpreadsheet } = await loadSheet();
|
||||
if (!st.parts.sheet.isConnected || st.handle) return;
|
||||
st.parts.sheet.replaceChildren();
|
||||
const book = structuredClone(shown(st)); // 편집기가 제자리에서 고치므로 사본을 줌
|
||||
st.json = norm(book);
|
||||
st.handle = createSpreadsheet(st.parts.sheet, book, {
|
||||
preview: true,
|
||||
tabs: false,
|
||||
readOnly: st.readOnly,
|
||||
onChange: (b) => void onBook(st, b),
|
||||
onSelect: (s) => {
|
||||
st.cell = s.칸;
|
||||
st.parts.cell.textContent = `${tp("Cell")} ${st.cell}`;
|
||||
if (st.asking) drawBar(st);
|
||||
},
|
||||
});
|
||||
st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st)));
|
||||
st.observer.observe(st.parts.sheet, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["data-addr"],
|
||||
});
|
||||
refreshSnap(st);
|
||||
}
|
||||
|
||||
/** 아래 시트 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
|
||||
export function renderPanel(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
nodeId: string,
|
||||
on: Handlers,
|
||||
opts: { readOnly?: boolean } = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const readOnly = !!opts.readOnly;
|
||||
if (old && old.nodeId === nodeId && old.readOnly === readOnly) {
|
||||
old.sl = sl;
|
||||
old.on = on;
|
||||
if (old.handle && norm(shown(old)) !== old.json) {
|
||||
old.json = norm(shown(old));
|
||||
old.handle.setDoc(structuredClone(shown(old)));
|
||||
setTimeout(() => refreshSnap(old), 0);
|
||||
return;
|
||||
}
|
||||
return old.redraw();
|
||||
}
|
||||
if (old) {
|
||||
old.observer?.disconnect();
|
||||
old.handle?.destroy();
|
||||
}
|
||||
const parts: State["parts"] = {
|
||||
cell: el("span", { className: "m01-slpanel-cell", text: `${tp("Cell")} A1` }),
|
||||
bar: el("div", { className: "m01-slpanel-bar" }),
|
||||
msg: el("p", { className: "m01-slpanel-msg", attrs: { role: "status" } }),
|
||||
sheet: el("div", { className: "m01-slpanel-sheet", text: tp("Loading") }),
|
||||
pills: el("div", { className: "m01-slpanel-pills" }),
|
||||
info: el("div", { className: "m01-slpanel-info" }),
|
||||
};
|
||||
const st: State = {
|
||||
nodeId,
|
||||
sl,
|
||||
on,
|
||||
readOnly,
|
||||
handle: null,
|
||||
snap: {},
|
||||
json: "",
|
||||
cell: "A1",
|
||||
info: null,
|
||||
asking: null,
|
||||
busy: false,
|
||||
parts,
|
||||
observer: null,
|
||||
redraw: () => {
|
||||
drawBar(st);
|
||||
drawPills(st);
|
||||
applyMarks(st);
|
||||
},
|
||||
};
|
||||
states.set(host, st);
|
||||
const node = nodeOf(st);
|
||||
host.classList.add("m01-slpanel");
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-slpanel-head",
|
||||
children: [el("strong", { text: node?.name ?? "" }), parts.cell],
|
||||
}),
|
||||
parts.bar,
|
||||
parts.msg,
|
||||
parts.sheet,
|
||||
parts.pills,
|
||||
parts.info,
|
||||
);
|
||||
parts.msg.hidden = true;
|
||||
st.redraw();
|
||||
void mountSheet(st);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.m01-slresult__big {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-slresult__big strong {
|
||||
color: var(--color-text-body);
|
||||
font-size: 28px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-slresult__costs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4) var(--spacing-16);
|
||||
margin: 0 0 var(--spacing-12);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__costs dt {
|
||||
display: inline;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m01-slresult__costs dd {
|
||||
display: inline;
|
||||
margin: 0 0 0 var(--spacing-4);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m01-slresult__strip {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-slresult__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-slresult__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__input {
|
||||
box-sizing: border-box;
|
||||
width: 140px;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__input[readonly] {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.m01-slresult__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-slresult__msg {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-slresult__problems {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-danger);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-slresult__problems ul {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Result.ts
|
||||
* 시트 로직 — 오른쪽 결과 칸: 큰 합계 · 노무비 · 재료비 · 경비 · 일위대가표(EasyCalc Sheet 에 값을 맞춰 넣음)
|
||||
* · 예시값 띠(입력값 상자의 예시값을 여기서도 바꿈) · 오류 · 빈 입력 한 줄
|
||||
* 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌.
|
||||
* 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 8. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
Cell,
|
||||
DataValidationRule,
|
||||
Sheet,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet";
|
||||
import type { SlEvalResult } from "./M01_SL_Eval";
|
||||
import { PILL_AMOUNT, PILL_PRICE, USED, itemRefOf } from "./M01_SL_Item";
|
||||
import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model";
|
||||
import "./M01_SL_Result.css";
|
||||
|
||||
const TEXT = {
|
||||
Per: ["{per} 당", "per {per}"],
|
||||
Total: ["합계", "Total"],
|
||||
Won: ["원", "KRW"],
|
||||
Labor: ["노무비", "Labor cost"],
|
||||
Material: ["재료비", "Material cost"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Sample: ["예시값", "Sample values"],
|
||||
SampleHint: [
|
||||
"여기서 바꾸면 입력값 상자의 예시값도 바뀌어요",
|
||||
"Changing here changes the input box too",
|
||||
],
|
||||
Problems: ["고칠 것", "To fix"],
|
||||
Empty: ["「{box}」 상자의 「{pill}」 칸이 비었어요", "“{pill}” in box “{box}” is empty"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
RateOf: ["{base}의 %", "% of {base}"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tr = (key: TextKey, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m);
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
||||
|
||||
const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined =>
|
||||
sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
const colOf = (cell: string): { r: number; c: number } => ({
|
||||
r: Number(cell.replace(/\D/g, "")) - 1,
|
||||
c: cell.charCodeAt(0) - 65,
|
||||
});
|
||||
|
||||
/** 일위대가표 줄 — 항목 상자 하나 = 줄 하나(출력 알약을 읽음) */
|
||||
function linesOf(sl: SheetLogic, values: SlEvalResult): CalcLine[] {
|
||||
return sl.nodes
|
||||
.filter((n) => n.kind === "item")
|
||||
.map((n) => {
|
||||
const ref = itemRefOf(n);
|
||||
const p = values.pills[n.id] ?? {};
|
||||
const unit = n.pillsOut.find((x) => x.name === USED)?.unit ?? "";
|
||||
const pickName = (name: string): number => n0(p[name]);
|
||||
return {
|
||||
이름: n.name,
|
||||
단위: unit,
|
||||
수량: pickName(USED),
|
||||
단가: pickName(PILL_PRICE),
|
||||
금액: pickName(PILL_AMOUNT),
|
||||
비목: Object.fromEntries(COSTS.map((c) => [c, pickName(c)])),
|
||||
...(ref?.what === "비율"
|
||||
? { 품목: { 키: "", 규격: tr("RateOf", { base: ref.base ?? "노무비" }) } }
|
||||
: ref?.spec
|
||||
? { 품목: { 키: "", 규격: ref.spec } }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 빈 입력 — 입력 알약 칸에 값도 참조도 없는 것 */
|
||||
function emptyInputs(sl: SheetLogic): string[] {
|
||||
return sl.nodes.flatMap((n) => {
|
||||
const cells = sheetOf(sl, n)?.칸 ?? {};
|
||||
return n.pillsIn
|
||||
.filter(
|
||||
(p) => !p.outside && !(cells[p.cell] && ("값" in cells[p.cell] || "식" in cells[p.cell])),
|
||||
)
|
||||
.map((p) => tr("Empty", { box: n.name, pill: p.name }));
|
||||
});
|
||||
}
|
||||
|
||||
/** 예시값 칸 — 선택 목록(유효성)이 걸린 칸은 고르기 · 수범위는 범위 안내 */
|
||||
function ruleAt(sheet: Sheet, cell: string): DataValidationRule["규칙"] | null {
|
||||
const { r, c } = colOf(cell);
|
||||
const hit = (sheet.유효성 ?? []).find((v) =>
|
||||
v.범위.some((g) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1),
|
||||
);
|
||||
return hit?.규칙 ?? null;
|
||||
}
|
||||
|
||||
function sampleField(
|
||||
sl: SheetLogic,
|
||||
node: SlNode,
|
||||
pill: SlPill,
|
||||
put: (cell: string, v: number | string) => void,
|
||||
msg: HTMLElement,
|
||||
): HTMLElement {
|
||||
const sheet = sheetOf(sl, node);
|
||||
const cur: Cell | undefined = sheet?.칸[pill.cell];
|
||||
const rule = sheet ? ruleAt(sheet, pill.cell) : null;
|
||||
const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name });
|
||||
let control: HTMLElement;
|
||||
if (rule?.종류 === "목록") {
|
||||
const field = createSelectField({
|
||||
options: [{ value: "", text: tr("Pick") }, ...rule.값들.map((v) => ({ value: v, text: v }))],
|
||||
value: cur?.값 === undefined ? "" : String(cur.값),
|
||||
compact: true,
|
||||
ariaLabel: pill.name,
|
||||
onChange: (v) => {
|
||||
if (v !== "") put(pill.cell, v);
|
||||
},
|
||||
});
|
||||
control = field.root;
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m01-slresult__input",
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name },
|
||||
});
|
||||
if (rule?.종류 === "수범위") {
|
||||
box.placeholder = tr("Range", { a: String(rule.아래 ?? ""), b: String(rule.위 ?? "") });
|
||||
}
|
||||
const linked = cur?.식 !== undefined;
|
||||
box.value = linked || cur?.값 === undefined ? "" : String(cur.값);
|
||||
box.readOnly = linked;
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const n = Number(raw);
|
||||
if (raw === "" || Number.isNaN(n)) {
|
||||
msg.textContent = tr("Bad");
|
||||
return;
|
||||
}
|
||||
put(pill.cell, n);
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
return el("label", { className: "m01-slresult__field", children: [label, control] });
|
||||
}
|
||||
|
||||
/** 결과 칸 — values = 화면 계산 결과 · 예시값을 바꾸면 on.change(새 시트 로직) */
|
||||
export function renderResult(
|
||||
host: HTMLElement,
|
||||
sl: SheetLogic,
|
||||
values: SlEvalResult,
|
||||
on: { change(sl: SheetLogic): void },
|
||||
): void {
|
||||
const { 계 } = values.result;
|
||||
const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : "";
|
||||
const big = el("p", {
|
||||
className: "m01-slresult__big",
|
||||
children: [
|
||||
el("span", { text: per ? tr("Per", { per: `1${per}` }) : tr("Total") }),
|
||||
el("strong", { text: 계 === null ? "—" : `${formatMoney(계)} ${tr("Won")}` }),
|
||||
],
|
||||
});
|
||||
const costs = el("dl", {
|
||||
className: "m01-slresult__costs",
|
||||
children: COSTS.flatMap((c, i) => [
|
||||
el("dt", { text: tr((["Labor", "Material", "Expense"] as const)[i]) }),
|
||||
el("dd", { text: values.result[c] === null ? "—" : formatMoney(values.result[c]) }),
|
||||
]),
|
||||
});
|
||||
|
||||
const head = { name: sl.name, resultUnit: sl.resultUnit };
|
||||
const problems = [...values.errors, ...emptyInputs(sl)];
|
||||
const ans: CalcAnswer =
|
||||
values.errors.length || 계 === null
|
||||
? { ok: false, reason: values.errors[0] ?? emptyInputs(sl)[0] ?? "" }
|
||||
: {
|
||||
ok: true,
|
||||
lines: linesOf(sl, values),
|
||||
sums: {
|
||||
노무비: n0(values.result.노무비),
|
||||
재료비: n0(values.result.재료비),
|
||||
경비: n0(values.result.경비),
|
||||
계: n0(계),
|
||||
},
|
||||
middle: {},
|
||||
};
|
||||
const sheetHost = el("div", { className: "m01-slresult__sheet" });
|
||||
renderSheet(sheetHost, head, ans);
|
||||
|
||||
const msg = el("p", { className: "m01-slresult__msg" });
|
||||
const put =
|
||||
(node: SlNode) =>
|
||||
(cell: string, v: number | string): void => {
|
||||
msg.textContent = "";
|
||||
try {
|
||||
const 시트 = sl.workbook.시트.map((s) =>
|
||||
s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [cell]: { 값: v } } } : s,
|
||||
);
|
||||
on.change({ ...sl, workbook: { ...sl.workbook, 시트 } });
|
||||
} catch {
|
||||
msg.textContent = tr("Cant");
|
||||
}
|
||||
};
|
||||
const fields = sl.nodes
|
||||
.filter((n) => n.kind === "input")
|
||||
.flatMap((n) => n.pillsOut.map((p) => sampleField(sl, n, p, put(n), msg)));
|
||||
const strip = fields.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-slresult__strip",
|
||||
children: [
|
||||
el("strong", { text: tr("Sample") }),
|
||||
el("div", { className: "m01-slresult__fields", children: fields }),
|
||||
el("p", { className: "m01-slresult__muted", text: tr("SampleHint") }),
|
||||
msg,
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
host.replaceChildren(
|
||||
big,
|
||||
costs,
|
||||
...strip,
|
||||
...(problems.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-slresult__problems",
|
||||
children: [
|
||||
el("strong", { text: tr("Problems") }),
|
||||
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
sheetHost,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user