From 1fe49ad241a666a61e6a9aea8224492571c20c78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:53:15 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(m01-sl):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=9E=90=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=90=EB=A6=AC(renderLogicTools=20=EA=B0=80=20?= =?UTF-8?q?=EC=98=AC=EB=9D=BC=EC=98=A4=EB=A9=B4=20=EB=B6=80=EB=A6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index 942a536f3..7f73101d1 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -282,6 +282,10 @@ export function showPaper( }); }; + /* ponytail: 「다른 로직」 상자 손잡이(sub5 renderLogicTools)가 올라오기 전엔 없음 — 올라오면 바로 M.item.renderLogicTools 로 부름 */ + const logicTools = (M.item as { renderLogicTools?: typeof M.item.renderItemTools }) + .renderLogicTools; + const drawPanel = (): void => { const node = sl.nodes.find((n) => n.id === selected); if (!node) { @@ -306,6 +310,13 @@ export function showPaper( loadPrices(node.id); }, }); + else if (node.kind === "logic" && logicTools) + logicTools(toolsHost, sl, node.id, { + change: (next) => { + edit(next, true); + loadPrices(node.id); + }, + }); else toolsHost.replaceChildren(); }; From 2c848702ea3652bf0ca6106b5796ec4b22fc5009 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:53:46 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix(m01-sl):=20=ED=91=9C=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=EC=97=90=20=ED=91=9C=20=EB=B2=88=ED=98=B8=20=EC=95=88?= =?UTF-8?q?=20=EB=B3=B4=EC=9D=B4=EA=B2=8C=20=E2=80=94=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20=ED=8C=90?= =?UTF-8?q?=20=EB=A8=B8=EB=A6=AC=EB=A5=BC=20=ED=91=9C=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=C2=B7=20=ED=91=9C=20=EC=A4=84=20=EC=88=98?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=EA=B0=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- .../SheetLogic/M01_SL_Table_Build.ts | 57 ++++++++++++++----- resources/tester/test_m01_sl_table_build.py | 46 ++++++++++++++- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts index 977fe606a..3ec4d3f50 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Table_Build.ts @@ -1,12 +1,12 @@ /* ============================================================================= * M01_SL_Table_Build.ts - * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 + * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 합친 조건 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts` * * 시트 모양(표 상자 시트 하나): * A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움 - * 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로) - * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) + * 빈 열 하나 건너 = 찾기용 합친 조건 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 합친 조건 열이 따로) + * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 합친 조건 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) * 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A * ========================================================================== */ @@ -58,7 +58,7 @@ export interface TblPlan { outCells: Record; idxCell: string; statusCell: string; - /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */ + /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 합친 조건 열은 다시 쓸 때 지움 */ helperFrom: number; warnings: string[]; } @@ -74,7 +74,8 @@ const colIndex = (letters: string): number => [...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1; const at = (r: number, c: number): string => `${colName(c)}${r}`; -const MARGIN = 20; +/** 표 줄이 늘어도 따라가도록 블록 아래로 여유 줄을 둠 — 줄 수 이상 · 최소 20 */ +const marginOf = (n: number): number => Math.max(20, n); const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`; /** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */ @@ -148,16 +149,16 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음"); const rule = info.rule ?? "초과·이하"; const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule)); - const R = (range ? n : n + MARGIN) + 1; + const R = n + marginOf(n) + 1; const rng = (L: string): string => `${L}$2:${L}$${R}`; - // 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 + // 합친 조건 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들 const variants: string[][] = [[]]; if (!range) { const bl = blankable.slice(0, 2); const subsets = bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : []; - variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음 + variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 합친 조건가 없음 } const keyCols = exact.length ? variants : []; const kStart = W + 1; @@ -166,7 +167,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const V = L + 1; const cells: Record = {}; keyCols.forEach((skip, v) => { - cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" }; + cells[at(1, kStart + v)] = { + 값: skip.length ? `합친 조건(${skip.join("·")} 무관)` : "합친 조건", + }; const rest = exact.filter((c) => !skip.includes(c)); for (let r = 2; r <= R; r++) { const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&'); @@ -183,7 +186,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { // 찾기 판 let row = 1; - cells[at(row++, L)] = { 값: "표에서 찾기" }; + cells[at(row++, L)] = { + 값: ["표에서 찾기", info.name, info.no, info.base].filter(Boolean).join(" · "), + }; const inCells: Record = {}; const inputOf: Record = {}; for (const c of conds) { @@ -194,6 +199,14 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { inputOf[c] = at(row, V); row++; } + // 표 줄 수 — 블록 안 칸이 찬 줄 수(마스터 표 줄이 늘거나 줄어도 따라감) + const rowsCell = ((): string => { + cells[at(row, L)] = { 값: "표 줄 수" }; + cells[at(row, V)] = { + 식: `MAX(${cols.map((_, c) => `COUNTA(${rng(colName(c))})`).join(",")})`, + }; + return at(row++, V); + })(); const keyOf = (skip: string[]): string => exact .filter((c) => !skip.includes(c)) @@ -210,10 +223,10 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { let idx = ""; let count = ""; if (exact.length) { - const keyCell = idxRow("열쇠", keyOf([])); + const keyCell = idxRow("합친 조건", keyOf([])); const ks = keyCols.map((skip, v) => { if (v === 0) return keyCell; - cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` }; + cells[at(row, L)] = { 값: `합친 조건(${skip.join("·")} 무관)` }; cells[at(row, V)] = { 식: keyOf(skip) }; return at(row++, V); }); @@ -224,7 +237,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { ); count = idxRow( "일치 수", - `IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, + `IF(ISNUMBER(${idx}),IF(${idx}>${rowsCell},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`, ); } else { const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`); @@ -242,7 +255,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const inside = lowBased ? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`; - count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`); + count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${rowsCell}),IF(${inside},1,0),0)`); } else { warnings.push("조건 칸이 없음 — 찾을 줄이 없음"); count = idxRow("일치 수", "0"); @@ -294,6 +307,21 @@ export function pillsOf( const sheetIndexOf = (sl: SheetLogic, node: SlNode): number => sl.workbook.시트.findIndex((s) => s.id === node.sheetId); +/** 시트 이름을 바꿈 — 같은 이름이 있으면 번호를 붙이고 · 다른 시트가 이 시트를 가리키던 참조 수식도 따라 고침 */ +function renameSheet(sl: SheetLogic, sheet: Sheet, want: string): void { + if (sheet.이름 === want) return; + const taken = new Set(sl.workbook.시트.filter((s) => s !== sheet).map((s) => s.이름)); + let name = want; + for (let i = 2; taken.has(name); i++) name = `${want} (${i})`; + const quote = (n: string): string => `'${n.replace(/'/g, "''")}'`; + const old = sheet.이름; + const olds = [`${quote(old)}!`, ...(/^[\p{L}\p{N}_]+$/u.test(old) ? [`${old}!`] : [])]; + for (const s of sl.workbook.시트) + for (const cell of Object.values(s.칸)) + if (cell.식) for (const o of olds) cell.식 = cell.식.split(o).join(`${quote(name)}!`); + sheet.이름 = name; +} + /** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */ export function applyTable( sl: SheetLogic, @@ -331,6 +359,7 @@ export function applyTable( !(e.to.node === nodeId && gone("in", e.to.pill)), ); node.name = `표 · ${info.name}`; + renameSheet(next, sheet, node.name); node.ref = opts.ref; node.pillsIn = pills.pillsIn; node.pillsOut = pills.pillsOut; diff --git a/resources/tester/test_m01_sl_table_build.py b/resources/tester/test_m01_sl_table_build.py index a1f6eb1df..f353c54e4 100644 --- a/resources/tester/test_m01_sl_table_build.py +++ b/resources/tester/test_m01_sl_table_build.py @@ -31,11 +31,21 @@ const jobs = JSON.parse(fs.readFileSync(0, "utf8")); const out = jobs.map((job) => { if (job.op === "plan") { const plan = m.planLookup(job.info, job.roles); - const cells = { ...m.blockCells(job.info), ...plan.cells }; + const cells = { ...m.blockCells(job.blockInfo || job.info), ...plan.cells }; for (const [c, v] of Object.entries(job.inputs || {})) cells[plan.inCells[c]] = { 값: v }; return { cells, plan }; } if (job.op === "paste") return m.parsePaste(job.text); + if (job.op === "rename") { + const sl = { key: null, name: "t", resultUnit: "", edges: [], + workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [ + { id: "s1", 이름: "표", 칸: {} }, + { id: "s2", 이름: "계산", 칸: { B2: { 식: "'표'!G7*2" }, B3: { 식: "표!G7+1" } } } ] }, + nodes: [{ id: "n1", kind: "table", name: "표", sheetId: "s1", pillsIn: [], pillsOut: [], stage: 1 }] }; + const done = m.applyTable(sl, "n1", job.info, job.roles, { ref: job.info.key, fillBlock: false, outside: true }).sl; + const texts = Object.values(done.workbook.시트[0].칸).map((c) => String(c.값 ?? "")); + return { sheets: done.workbook.시트.map((x) => x.이름), refs: done.workbook.시트[1].칸, ref: done.nodes[0].ref, node: done.nodes[0].name, texts }; + } if (job.op === "reapply") { const base = { key: null, name: "t", resultUnit: "원/㎡", edges: [], workbook: { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "표", 칸: {} }] }, @@ -230,3 +240,37 @@ def test_블록_머리_차례(): "폭·아래", "폭·위", ] + + +def test_표가_늘어도_찾기_범위가_따라감(): + """마스터 표 줄이 시험 때보다 늘었을 때 — 찾기 판이 만든 줄 수 밖 새 줄도 찾음(범위 표 · 같음 표 둘 다).""" + small = {**RATE, "rows": RATE["rows"][:2]} + extra = [{"높이": [5.5 + i, 6.5 + i], "증가율": 70 + i} for i in range(5)] + grown = {**small, "rows": [*small["rows"], *extra]} + roles = {"cond": ["높이"], "out": ["증가율"]} + plan = run( + [{"op": "plan", "info": small, "roles": roles, "inputs": {"높이": 8.5}, "blockInfo": grown}] + )[0] + book = { + "종류": "통합문서", + "판": 1, + "열": "t", + "서식": [{}], + "시트": [{"id": "s1", "이름": "표", "칸": plan["cells"]}], + "활성": "s1", + } + values = recalc_workbooks([book])[0]["s1"] + assert values[plan["plan"]["statusCell"]]["글"] == "찾음" + assert float(values[plan["plan"]["outCells"]["증가율"]]["수"]) == 72 + + +def test_표_상자에_표_번호가_안_보임_이름으로_보임(): + info = {**EXACT, "key": "QF000421", "name": "메쌓기(인력)", "no": "13-4-1", "base": "1㎡"} + roles = {"cond": list(info["conds"]), "out": ["석공"]} + out = run([{"op": "rename", "info": info, "roles": roles}])[0] + assert out["sheets"] == ["표 · 메쌓기(인력)", "계산"] + assert out["ref"] == "QF000421" # 번호는 상자 ref 에만 + assert "QF000421" not in " ".join(out["texts"]) + assert any("13-4-1" in t and "1㎡" in t for t in out["texts"]) + assert out["refs"]["B2"]["식"] == "'표 · 메쌓기(인력)'!G7*2" + assert out["refs"]["B3"]["식"] == "'표 · 메쌓기(인력)'!G7+1" From e5955b705d8c9a018017c3ea2d2f77704d44e3eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:55:04 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix(m01-sl):=20=EB=B9=84=EC=9C=A8=20?= =?UTF-8?q?=EB=B0=94=ED=83=95=20=EC=9D=B4=EC=9D=8C=EC=9D=80=20=EC=95=84?= =?UTF-8?q?=EB=8A=94=20=EC=83=81=EC=9E=90=EB=A7=8C=20=C2=B7=20=EC=86=90?= =?UTF-8?q?=EC=84=A0=20=EB=B3=B4=EC=A1=B4,=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B0=92/=EB=8B=A8=EC=9C=84,=20=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=86=90=EC=9E=A1=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refreshBases 를 Model connect/disconnect 로 바꿔 모르는 항목 상자와 손으로 이은 선을 안 건드림, applyItemLayout 내보냄 - 결과 칸: 돈 아닌 결과는 값과 단위, 「100㎡ 당」 표기 - 새 LogicTools: 다른 로직 찾기 · 고르기 · 넘길 값 · 결과 알약 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- M01_MasterData/SheetLogic/M01_SL_Item.ts | 61 ++-- .../SheetLogic/M01_SL_LogicTools.css | 87 +++++ .../SheetLogic/M01_SL_LogicTools.ts | 297 ++++++++++++++++++ M01_MasterData/SheetLogic/M01_SL_Result.ts | 33 +- 4 files changed, 445 insertions(+), 33 deletions(-) create mode 100644 M01_MasterData/SheetLogic/M01_SL_LogicTools.css create mode 100644 M01_MasterData/SheetLogic/M01_SL_LogicTools.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Item.ts b/M01_MasterData/SheetLogic/M01_SL_Item.ts index 9161771de..38337c9ac 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Item.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Item.ts @@ -10,7 +10,6 @@ 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"; @@ -18,7 +17,7 @@ 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 { connect, disconnect, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; import "./M01_SL_Item.css"; const TEXT = { @@ -265,6 +264,16 @@ const FIELD_CELL: Record = { }; /** 상자 시트를 항목 틀로 채움 — 예전 수량 칸(값 · 이어진 참조)은 살림 */ +/** 상자 시트를 항목 틀로 채움 — 가져오기(Import)도 이 틀 · ref 로 맞춤 */ +export function applyItemLayout( + sl: SheetLogic, + node: SlNode, + ref: ItemRef, + qty?: number | null, +): SheetLogic { + return applyLayout(sl, node, ref, qty); +} + function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | null): SheetLogic { const sheet = sheetOf(sl, node); const oldRef = itemRefOf(node); @@ -299,39 +308,35 @@ function applyLayout(sl: SheetLogic, node: SlNode, ref: ItemRef, qty?: number | }; } -/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 합 — 시트 사이 참조 · 선도 함께 */ +/** 비율 상자마다 「바탕」 = 다른 항목 상자의 그 비목 출력 — 선 잇기(Model `connect`)로 함 + * · 아는 항목 상자(ref 가 이 틀 것)만 바탕이 됨 — 모르는 상자(가져온 것 등)는 읽지도 쓰지도 않음 + * · 비목을 바꾸면 옛 비목 선만 끊음 — 손으로 이은 다른 선 · 그 칸 수식은 그대로 */ export function refreshBases(sl: SheetLogic): SheetLogic { let out = sl; for (const node of sl.nodes) { const ref = itemRefOf(node); - if (!ref || ref.what !== "비율") continue; + if (!ref || ref.what !== "비율" || !node.pillsIn.some((p) => p.name === PILL_BASE)) continue; const base = ref.base ?? "노무비"; - const sources = out.nodes.filter((n) => { + const known = (n: SlNode): boolean => { 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 n.id !== node.id && n.kind === "item" && !!r && r.what !== "비율"; }; + for (const e of out.edges.filter((x) => x.to.node === node.id && x.to.pill === PILL_BASE)) { + const from = out.nodes.find((n) => n.id === e.from.node); + if (from && known(from) && e.from.pill !== base && (COSTS as string[]).includes(e.from.pill)) + out = disconnect(out, e.id); + } + for (const src of out.nodes.filter(known)) { + const there = out.edges.some( + (e) => + e.from.node === src.id && + e.from.pill === base && + e.to.node === node.id && + e.to.pill === PILL_BASE, + ); + if (!there) + out = connect(out, { node: src.id, pill: base }, { node: node.id, pill: PILL_BASE }).sl; + } } return out; } diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.css b/M01_MasterData/SheetLogic/M01_SL_LogicTools.css new file mode 100644 index 000000000..b8392d2e8 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_LogicTools.css @@ -0,0 +1,87 @@ +.m01-sllogic__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m01-sllogic__chosen { + margin: var(--spacing-4) 0; + color: var(--color-text-body); + font-weight: 600; +} + +.m01-sllogic__search { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-8) 0; +} + +.m01-sllogic__input { + box-sizing: border-box; + width: 200px; + max-width: 100%; + 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-sllogic__input[readonly] { + background: var(--color-surface); +} + +.m01-sllogic__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + max-height: 240px; + margin: 0 0 var(--spacing-8); + padding: 0; + overflow: auto; + list-style: none; +} + +.m01-sllogic__list .ui-btn { + max-width: 100%; + text-align: left; + white-space: normal; +} + +.m01-sllogic__fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; + color: var(--color-text-body); +} + +.m01-sllogic__fields > strong, +.m01-sllogic__fields > .m01-sllogic__muted { + flex-basis: 100%; +} + +.m01-sllogic__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-sllogic__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-sllogic__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts b/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts new file mode 100644 index 000000000..0ddf8c2b2 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_LogicTools.ts @@ -0,0 +1,297 @@ +/* ============================================================================= + * M01_SL_LogicTools.ts + * 시트 로직 — 「다른 로직」 상자 손잡이. 기존 로직을 찾아 고르면 상자 시트를 그 로직의 틀로 채움 + * 로직의 입력값마다 입력 알약(「넘길 값」 줄 · 예시값) · 결과는 바깥 값 출력 알약 + * 돈 로직 = 노무비 · 재료비 · 경비 · 계 / 돈 아닌 로직(작업량 등) = 결과 + * 틀 모양은 Import 의 다른 로직 상자와 한 벌(`outsideOf` 가 「넘길 값」 칸과 알약 이름으로 물음을 만듦) + * — 상자 `source` 는 안 씀: 한 상자에 비목별 물음이 여럿이라 알약 이름이 비목을 정함. 상자 `ref` = 로직 번호. + * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 3 · 4. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import type { Cell } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { listEasy, openEasy, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; +import { slPlain } from "./M01_SL_Api"; +import { disconnect, renameNode, type SheetLogic, type SlNode, type SlPill } from "./M01_SL_Model"; +import "./M01_SL_LogicTools.css"; + +const TEXT = { + Title: ["다른 로직", "Other logic"], + Search: ["로직 이름 찾기", "Find a logic by name"], + Find: ["찾기", "Find"], + None: ["아직 고르지 않았어요", "Nothing chosen yet"], + Chosen: ["고른 로직", "Chosen logic"], + NoHit: ["찾은 로직이 없어요", "No logic found"], + More: ["더 있어요 — 이름을 더 적어 보세요", "More exist — type more of the name"], + Loading: ["가져오는 중", "Loading"], + Inputs: ["넘길 값", "Passed values"], + NoInputs: ["넘길 값이 없는 로직이에요", "This logic takes no values"], + Linked: ["다른 상자에서 이어진 값", "Value from another box"], + Pick: ["고르세요", "Choose"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Hint: [ + "결과는 바깥 값 — 열 때 · 저장 때 넣어요", + "Result is an outside value — filled on open and save", + ], + Result: ["결과", "Result"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Sum: ["계", "Total"], + Name: ["이름", "Name"], + Value: ["값", "Value"], + Note: ["설명", "Note"], + Pass: ["넘길 값", "Passed value"], + Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"], +} as const; + +type TextKey = keyof typeof TEXT; +const tt = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const LIMIT = 30; + +/** 로직 결과가 돈인가 */ +const isMoney = (unit: string): boolean => unit.startsWith("원"); + +/** 상자 시트를 다른 로직 틀로 채움 · `trial` = 입력값 예시(열 때 받은 자동값) */ +export function applyLogic( + sl: SheetLogic, + nodeId: string, + row: LogicRow, + trial: Record = {}, +): SheetLogic { + const node = sl.nodes.find((n) => n.id === nodeId); + if (!node) return sl; + if (node.ref === row.키 && node.pillsIn.length) return sl; // 같은 로직을 또 고름 — 그대로 + // 다른 로직을 고르면 옛 선은 끊음(받는 칸 수식도 함께 정리) + let work = sl; + for (const e of sl.edges) + if (e.from.node === nodeId || e.to.node === nodeId) work = disconnect(work, e.id); + for (const name of [row.이름, `${row.이름} 2`, `${row.이름} 3`]) { + const got = renameNode(work, nodeId, name); + if (!got.why) { + work = got.sl; + break; + } + } + const cells: Record = { + A1: { 값: tt("Name") }, + B1: { 값: tt("Value") }, + C1: { 값: tt("Note") }, + }; + const pillsIn: SlPill[] = []; + const pillsOut: SlPill[] = []; + let r = 2; + for (const spec of row.입력 ?? []) { + const at = `B${r}`; + cells[`A${r}`] = { 값: spec.이름 }; + const v = trial[spec.이름]; + if (typeof v === "number" || typeof v === "string") cells[at] = { 값: v }; + cells[`C${r}`] = { 값: tt("Pass") }; + pillsIn.push({ name: spec.이름, cell: at, ...(spec.단위 ? { unit: spec.단위 } : {}) }); + r++; + } + const unit = row.결과단위 ?? ""; + const money = isMoney(unit); + const outs = money ? [tt("Labor"), tt("Material"), tt("Expense"), tt("Sum")] : [tt("Result")]; + for (const name of outs) { + const at = `B${r}`; + cells[`A${r}`] = { 값: name }; + cells[`C${r}`] = { 값: tt("Outside") }; + pillsIn.push({ name, cell: at, outside: true }); + pillsOut.push({ name, cell: at, unit: money ? "원" : unit }); + r++; + } + const 시트 = work.workbook.시트.map((s) => (s.id === node.sheetId ? { ...s, 칸: cells } : s)); + return { + ...work, + workbook: { ...work.workbook, 시트 }, + nodes: work.nodes.map((n) => { + if (n.id !== nodeId) return n; + const next: SlNode = { ...n, ref: row.키, pillsIn, pillsOut, stage: 1 }; + delete next.source; + return next; + }), + }; +} + +/** 그 로직의 입력 설명(고르기 · 범위) — 한 번 받은 것은 다시 안 받음 */ +const rows = new Map(); + +/** 다른 로직 상자 손잡이 — 찾기 → 고르기 → 넘길 값 칸 */ +export function renderLogicTools( + host: HTMLElement, + sl: SheetLogic, + nodeId: string, + on: { change(sl: SheetLogic): void }, +): void { + const node = sl.nodes.find((n) => n.id === nodeId); + const msg = el("p", { className: "m01-sllogic__msg" }); + if (!node) { + host.replaceChildren(); + return; + } + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + const results = el("ul", { className: "m01-sllogic__list" }); + const fieldsHost = el("div", { className: "m01-sllogic__fields" }); + const box = el("input", { + className: "m01-sllogic__input", + attrs: { type: "text", placeholder: tt("Search"), "aria-label": tt("Search") }, + }); + + const emit = (make: () => SheetLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + + const choose = async (item: EasyListItem): Promise => { + msg.textContent = tt("Loading"); + try { + const opened = await openEasy(item.key); + rows.set(item.key, opened.row); + msg.textContent = ""; + on.change(applyLogic(sl, node.id, opened.row, opened.trial)); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + + const search = async (): Promise => { + msg.textContent = tt("Loading"); + try { + const found = await listEasy(box.value.trim()); + msg.textContent = ""; + results.replaceChildren( + ...(found.length + ? found.slice(0, LIMIT).map((it) => { + const b = createButton({ + label: `${it.name} · ${it.resultUnit}${it.where ? ` · ${it.where}` : ""}`, + variant: "ghost", + size: "sm", + onClick: () => void choose(it), + }); + return el("li", { children: [b] }); + }) + : [el("li", { className: "m01-sllogic__muted", text: tt("NoHit") })]), + ...(found.length > LIMIT + ? [el("li", { className: "m01-sllogic__muted", text: tt("More") })] + : []), + ); + } catch (e) { + msg.textContent = slPlain(e); + } + }; + box.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") void search(); + }); + + /** 넘길 값 칸 — 고르기가 있으면 고르기 · 아니면 숫자 · 다른 상자에서 이어진 칸은 읽기만 */ + const fieldFor = (pill: SlPill, spec: LogicInput | undefined): HTMLElement => { + const cur = sheet?.칸[pill.cell]; + const linked = cur?.식 !== undefined; + const put = (v: number | string): void => + emit(() => ({ + ...sl, + workbook: { + ...sl.workbook, + 시트: sl.workbook.시트.map((s) => + s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [pill.cell]: { 값: v } } } : s, + ), + }, + })); + const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); + let control: HTMLElement; + if (spec?.고르기?.length && !linked) { + control = createSelectField({ + options: [ + { value: "", text: tt("Pick") }, + ...spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + ], + value: cur?.값 === undefined ? "" : String(cur.값), + compact: true, + ariaLabel: pill.name, + onChange: (v) => { + const hit = spec.고르기?.find((o) => String(o) === v); + if (hit !== undefined) put(hit); + }, + }).root; + } else { + const input = el("input", { + className: "m01-sllogic__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, + }); + if (spec?.범위) input.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`; + input.value = linked || cur?.값 === undefined ? "" : String(cur.값); + input.readOnly = linked; + input.addEventListener("change", () => { + const raw = input.value.trim(); + const n = Number(raw); + if (raw === "") return; + if (Number.isNaN(n)) msg.textContent = tt("Bad"); + else put(n); + }); + control = input; + } + return el("label", { + className: "m01-sllogic__field", + children: [ + label, + control, + ...(linked ? [el("small", { className: "m01-sllogic__muted", text: tt("Linked") })] : []), + ], + }); + }; + + const drawFields = (row: LogicRow | undefined): void => { + const passed = node.pillsIn.filter((p) => !p.outside); + fieldsHost.replaceChildren( + el("strong", { text: tt("Inputs") }), + ...(passed.length + ? passed.map((p) => + fieldFor( + p, + row?.입력?.find((i) => i.이름 === p.name), + ), + ) + : [el("p", { className: "m01-sllogic__muted", text: tt("NoInputs") })]), + el("p", { className: "m01-sllogic__muted", text: tt("Hint") }), + ); + }; + + host.replaceChildren( + el("strong", { className: "m01-sllogic__title", text: `${tt("Title")} · ${node.name}` }), + el("p", { + className: "m01-sllogic__chosen", + text: node.ref ? `${tt("Chosen")}: ${node.name}` : tt("None"), + }), + el("div", { + className: "m01-sllogic__search", + children: [ + box, + createButton({ label: tt("Find"), size: "sm", onClick: () => void search() }), + ], + }), + results, + ...(node.ref ? [fieldsHost] : []), + msg, + ); + if (node.ref) { + const known = rows.get(node.ref); + drawFields(known); + if (!known) { + void openEasy(node.ref).then( + (o) => { + rows.set(o.row.키, o.row); + drawFields(o.row); + }, + () => undefined, + ); + } + } +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Result.ts b/M01_MasterData/SheetLogic/M01_SL_Result.ts index 213fdd077..07c04fa7c 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Result.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Result.ts @@ -24,6 +24,7 @@ import "./M01_SL_Result.css"; const TEXT = { Per: ["{per} 당", "per {per}"], Total: ["합계", "Total"], + Value: ["결과값", "Result value"], Won: ["원", "KRW"], Labor: ["노무비", "Labor cost"], Material: ["재료비", "Material cost"], @@ -47,6 +48,7 @@ const tr = (key: TextKey, fill: Record = {}): string => TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m); const COSTS = ["노무비", "재료비", "경비"] as const; +const num = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => @@ -157,13 +159,34 @@ export function renderResult( values: SlEvalResult, on: { change(sl: SheetLogic): void }, ): void { + const money = !sl.resultUnit || sl.resultUnit.startsWith("원"); const { 계 } = values.result; - const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : ""; + /** 돈이 아닌 결과(㎥/시간 …) — 결과 상자의 「계」 · 없으면 이어진 첫 숫자 */ + const plainValue = (): number | null => { + const box = sl.nodes.find((n) => n.kind === "result"); + const ins = box ? Object.values(values.pillsIn[box.id] ?? {}) : []; + const first = [계, ...COSTS.map((c) => values.result[c]), ...ins].find( + (v) => typeof v === "number" && Number.isFinite(v), + ); + return typeof first === "number" ? first : null; + }; + const perUnit = sl.resultUnit.split("/").slice(1).join("/"); + const perText = !perUnit + ? tr("Total") + : tr("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); + const shown = money ? 계 : plainValue(); 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")}` }), + el("span", { text: money ? perText : tr("Value") }), + el("strong", { + text: + shown === null + ? "—" + : money + ? `${formatMoney(shown)} ${tr("Won")}` + : `${num(shown)} ${sl.resultUnit}`.trim(), + }), ], }); const costs = el("dl", { @@ -191,7 +214,7 @@ export function renderResult( middle: {}, }; const sheetHost = el("div", { className: "m01-slresult__sheet" }); - renderSheet(sheetHost, head, ans); + if (money) renderSheet(sheetHost, head, ans); const msg = el("p", { className: "m01-slresult__msg" }); const put = @@ -225,7 +248,7 @@ export function renderResult( : []; host.replaceChildren( big, - costs, + ...(money ? [costs] : []), ...strip, ...(problems.length ? [ From 12ec81be0815acb94601e1ebb64082a165f13bdf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:58:06 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(m01-sl):=20=EC=83=81=EC=9E=90=EC=99=80?= =?UTF-8?q?=20=EC=84=A0=20=C2=B7=20=EC=8B=9C=ED=8A=B8=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=84=B8=20=EC=B9=B8=EC=9D=84=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=20=EB=86=92=EC=9D=B4=EB=A7=8C=ED=81=BC=20=E2=80=94=20=EC=95=88?= =?UTF-8?q?=EC=AA=BD=20=EC=84=B8=EB=A1=9C=20=EB=B0=80=EA=B8=B0=20=EC=97=86?= =?UTF-8?q?=EC=95=B0(=EC=B0=BD=EB=A7=8C=20=EB=B0=88)=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=90=20=EB=86=92=EC=9D=B4=20=3D=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EC=A4=84=20+=20=EB=91=90=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Diagram.css | 6 +++--- M01_MasterData/SheetLogic/M01_SL_Entry.css | 15 +++++---------- M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts | 13 +++++++++++++ M01_MasterData/SheetLogic/M01_SL_Panel.css | 2 ++ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Diagram.css b/M01_MasterData/SheetLogic/M01_SL_Diagram.css index 22c75a9ab..e02858f66 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Diagram.css +++ b/M01_MasterData/SheetLogic/M01_SL_Diagram.css @@ -52,10 +52,10 @@ display: none; } -/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */ +/* 띠 — 세로는 상자 높이만큼 다 보임(사용자 요청) · 길면 가로로 밀기 */ .m01-sldg__band { - max-height: 33vh; - overflow: auto; + overflow-x: auto; + overflow-y: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas)); diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.css b/M01_MasterData/SheetLogic/M01_SL_Entry.css index 5f6cd2c1a..0e04f2724 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.css +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.css @@ -8,9 +8,9 @@ font-size: var(--text-body); } +/* 세 칸은 내용 높이만큼 — 넘치면 창(모달) 전체만 세로로 밈 */ .m01-sl .ui-modal__body { - flex: 1; - min-height: 0; + flex: 0 0 auto; gap: var(--spacing-12); } @@ -111,11 +111,10 @@ /* 위 가로 다이어그램 · 아래 왼쪽 시트 판 · 아래 오른쪽 결과 */ .m01-sl__layout { - flex: 1; - min-height: 0; display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - grid-template-rows: minmax(160px, 2fr) minmax(0, 3fr); + grid-template-rows: auto auto; + align-items: start; grid-template-areas: "diagram diagram" "panel result"; @@ -137,7 +136,6 @@ .m01-sl__part { display: flex; flex-direction: column; - min-height: 0; border: 1px solid var(--color-border); border-radius: var(--radius-lg); } @@ -149,9 +147,7 @@ } .m01-sl__part-body { - flex: 1; - min-height: 0; - overflow: auto; + min-width: 0; padding: var(--spacing-12); } @@ -164,7 +160,6 @@ "diagram" "panel" "result"; - overflow: auto; } } diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index 7f73101d1..ca3b354a0 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -139,6 +139,18 @@ export async function fillOutside( return { sl: M.ev.applyOutside(sl, values), errors }; } +/* 시트 판 높이 — 그 시트가 쓰는 줄 수 + 두 줄(안쪽 세로 밀기 없게 · 사용자 요청) + * ponytail: 편집기 줄 높이 · 띠 높이를 숫자로 둠 — 편집기 꾸밈이 바뀌면 같이 고침 */ +const ROW_PX = 20; +const CHROME_PX = 26 + 24 + 24 + 20; // 위 띠 · 칸 머리 · 아래 상태줄 · 가로 밀기 막대 +function sheetHeight(sheet: { 칸: Record } | null): number { + const rows = Object.keys(sheet?.칸 ?? {}).reduce( + (most, a1) => Math.max(most, Number(a1.replace(/^[A-Z]+/, "")) || 0), + 0, + ); + return CHROME_PX + (Math.max(rows, 6) + 2) * ROW_PX; +} + /** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */ function serverTotal(sl: SheetLogic): number | null { const box = sl.nodes.find((n) => n.kind === "result"); @@ -297,6 +309,7 @@ export function showPaper( panelHost.hidden = false; pickHint.hidden = true; toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움 + panelHost.style.setProperty("--sl-sheet-h", `${sheetHeight(M.model.nodeSheet(sl, node.id))}px`); M.panel.renderPanel(panelHost, sl, node.id, { change: (next) => edit(next), pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)), diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.css b/M01_MasterData/SheetLogic/M01_SL_Panel.css index d67df74d1..cda9d83ea 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Panel.css +++ b/M01_MasterData/SheetLogic/M01_SL_Panel.css @@ -54,6 +54,8 @@ .m01-slpanel-sheet { position: relative; min-height: 260px; + /* 그 시트가 쓰는 줄 수만큼(Entry 가 --sl-sheet-h 를 정함) — 안쪽 세로 밀기 없음(사용자 요청) */ + height: var(--sl-sheet-h, 260px); border: 1px solid var(--color-border); border-radius: var(--radius-cards); overflow: hidden; From 8b07250aa049f634d53dcddc6f599ad41e13b217 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:01:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix(m01-sl):=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=9D=B4=EC=9D=8C=EB=A7=A4=20=E2=80=94=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=83=81=EC=9E=90=20=3D=20Item=20=ED=8B=80(applyIt?= =?UTF-8?q?emLayout)=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=3D=20LogicTools=20=ED=8B=80=20=C2=B7=20=ED=91=9C=20=EC=A4=84?= =?UTF-8?q?=20=EC=88=98=20=EB=94=B0=EB=9D=BC=EA=B0=90=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20=EC=83=81=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 항목 상자: applyItemLayout 로 B1 수량 · B2~B4 단가 · B6 금액 · B7~B9 비목 · node.ref = ItemRef · 덧줄 「비목 × 율 / 100」 = 비율 틀 · refreshBases - 결과 상자: Model 결과 틀(B2~B4 노무비 · 재료비 · 경비 입력 알약 · 선이 여럿이면 + · B5 계) - 다른 로직 상자: LogicTools 와 같은 틀(ref = 로직 번호 · 넘길 값 칸 · 돈 로직은 노무비 · 재료비 · 경비 · 계 · 단계 1) - 표 상자: 「표」 알약 E1 만 · 맞음 칸 = 표 줄 수 이상 · 최소 20 줄 + 「표 줄 수」 칸 - 자재 검색어 묶음: 바깥 값 key = 묶음 JSON 글 · 상자 이름 = 직종 · 로직 · 표 이름(names) - 시험: 6개 /calc 같은 값(끝수 원 미만) · 1,469 중 1,370 다 바뀜 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/SheetLogic/M01_SL_Import.ts | 277 +++++++++--------- .../SheetLogic/M01_SL_Import_Parse.ts | 89 +++++- resources/tester/test_m01_sl_import.py | 56 +++- 3 files changed, 273 insertions(+), 149 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Import.ts b/M01_MasterData/SheetLogic/M01_SL_Import.ts index 1b5639b87..fe9963b40 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import.ts @@ -19,15 +19,15 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address"; import type { - CalcValues, Cell, + DataValidationKind, Sheet, Workbook, } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; import { readLogic } from "../LogicBuilder/M01_LB_Blocks"; import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model"; -import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api"; +import { applyItemLayout, refreshBases, type ItemRef } from "./M01_SL_Item"; import { parseFormula, RULES, @@ -64,6 +64,7 @@ const TEXT = { Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"], FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"], Parse: ["읽을 수 없는 글", "Text that can't be read"], + PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"], } as const; const t = (k: keyof typeof TEXT, v = ""): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); @@ -75,6 +76,8 @@ export interface SlImportOptions { tables?: Record; /** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */ examples?: Record; + /** 키 → 보일 이름(직종 · 자재 · 다른 로직) — 상자 이름에 번호 대신 */ + names?: Record; } export interface SlImportReport { @@ -120,20 +123,26 @@ class Build { readonly tables = new Map(); readonly logics = new Map(); readonly consts = new Map(); + readonly specs = new Map(); + /** 키 → 보일 이름 — 호표 줄 이름 · 받은 이름 */ + readonly names = new Map(); - constructor(readonly opts: SlImportOptions) {} + constructor(readonly opts: SlImportOptions) { + for (const [k, v] of Object.entries(opts.names ?? {})) this.names.set(k, v); + } - node(kind: SlKind, name: string, stage: number, ref?: string): SlNode { + /** 상자 하나 + 시트 — `head` 면 1행 머리(이름 · 값 · 설명) · 항목 틀은 머리 없음 */ + node(kind: SlKind, name: string, stage: number, ref?: string, head = true): SlNode { const n = this.nodes.length + 1; - const head = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore - const base = kind === "result" ? t("Result") : `${t(head)} · ${name}`.slice(0, 31); + const word = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore + const base = kind === "result" ? t("Result") : `${t(word)} · ${name}`.slice(0, 31); let title = base; for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++) title = `${base.slice(0, 28)} ${k}`; const sheet: Sheet = { id: `s${n}`, 이름: title, - 칸: { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } }, + 칸: head ? { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } } : {}, }; this.book.시트.push(sheet); this.sheets.set(sheet.id, sheet); @@ -306,7 +315,7 @@ function put(sc: Scope, label: string, text: string, pill: "out" | null, note = function constBox(b: Build, key: string): SlNode { const had = b.consts.get(key); if (had) return had; - const node = b.node("const", key, 0, key); + const node = b.node("const", b.names.get(key) ?? key, 0, key); const at = b.row(node, t("Price"), {}, t("Outside")); b.pill(node, "in", { name: t("Price"), cell: at, outside: true }); b.pill(node, "out", { name: t("Price"), cell: at }); @@ -340,16 +349,17 @@ function logicBox(b: Build, n: Extract, field?: string): Sl let node = b.logics.get(sig); if (!node) { if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head)); - node = b.node("logic", n.head, 2, n.head); + node = b.node("logic", b.names.get(n.head) ?? n.head, 1, n.head); args(b, node, n.args); b.logics.set(sig, node); } - const name = field ?? t("Result"); - if (!node.pillsOut.some((p) => p.name === name)) { - const at = b.row(node, name, {}, t("Outside")); - b.pill(node, "in", { name, cell: at, outside: true }); - b.pill(node, "out", { name, cell: at }); - } + const money = ["노무비", "재료비", "경비", "계"]; + for (const name of field && money.includes(field) ? money : [field ?? t("Result")]) + if (!node.pillsOut.some((p) => p.name === name)) { + const at = b.row(node, name, {}, t("Outside")); + b.pill(node, "in", { name, cell: at, outside: true }); + b.pill(node, "out", { name, cell: at }); + } return node; } @@ -374,7 +384,7 @@ function newTable(b: Build, n: Extract, sig: string): TableB for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k)); if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule)) throw new Fail(t("Rule", String(table.범위규칙))); - const node = b.node("table", table.이름 || n.head, 1, n.head); + const node = b.node("table", table.이름 || b.names.get(n.head) || n.head, 1, n.head); const s = b.sheet(node); // 조건 알약 — 받는 값은 넘긴 식 const cond = new Map(); @@ -383,7 +393,6 @@ function newTable(b: Build, n: Extract, sig: string): TableB // 표 E1 부터 const cols = tableCols(table); const rows = tableRows(table); - b.row(node, t("Table"), { 값: n.head }, t("Outside")); // 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움 b.pill(node, "out", { name: "표", cell: "E1", outside: true }); rows.forEach((r, i) => @@ -394,8 +403,13 @@ function newTable(b: Build, n: Extract, sig: string): TableB ); const letter = (key: string, part: "v" | "lo" | "hi"): string => colName(4 + cols.findIndex((c) => c.key === key && c.part === part)); - const N = table.줄.length; + // 마스터 표 줄이 늘어도 따라가게 — 줄 수 이상 · 최소 20 줄까지 셈 · 「표 줄 수」 칸 밖 줄은 안 맞음 + const N = Math.max(20, table.줄.length); const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`; + const count = b.row(node, "표 줄 수", { + 식: `MAX(${cols.map((_, j) => `COUNTA(${range(colName(4 + j))})`).join(",")})`, + }); + const last = `$B$${count.slice(1)}`; let free = 4 + cols.length + 1; // 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로) const M = colName(free++); @@ -424,7 +438,8 @@ function newTable(b: Build, n: Extract, sig: string): TableB : `OR(${H}="",${x}<=${H})`; return `AND(OR(${L}="",${x}${low}${L}),${up})`; }); - s.칸[`${M}${r}`] = { 식: parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1" }; + const fit = parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1"; + s.칸[`${M}${r}`] = { 식: `IF(${r - 1}>${last},0,${fit})` }; } // 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`) let hit = M; @@ -484,27 +499,14 @@ function inputBox(b: Build, spec: LogicInput): void { const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? ""); const r = Number(at.slice(1)) - 1; const whole = { r0: r, c0: 1, r1: r, c1: 1 }; - if (spec.고르기?.length) - b.sheet(node).유효성 = [ - { - id: "v1", - 범위: [whole], - 규칙: { 종류: "목록", 값들: spec.고르기.map(String) }, - 어기면: "막음", - }, - ]; - else if (spec.범위) - b.sheet(node).유효성 = [ - { - id: "v1", - 범위: [whole], - 규칙: { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] }, - 어기면: "막음", - }, - ]; + const rule: DataValidationKind | null = spec.고르기?.length + ? { 종류: "목록", 값들: spec.고르기.map(String) } + : spec.범위 ? { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] } : null; // prettier-ignore + if (rule) b.sheet(node).유효성 = [{ id: "v1", 범위: [whole], 규칙: rule, 어기면: "막음" }]; const pill = node.pillsOut[0]; if (spec.단위) pill.unit = spec.단위; b.inputs.set(spec.이름, node); + b.specs.set(spec.이름, spec); } /** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */ @@ -519,54 +521,90 @@ function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | numbe return [node, label]; } -/** 항목 상자(호표 줄) → 비목 출력 알약들 */ +/** 항목 틀 — Item `applyItemLayout` 으로 그 상자 시트 · 알약을 채움(Item 이 알아보는 꼴 한 벌) */ +function layout(b: Build, node: SlNode, ref: ItemRef, qty?: number): void { + const sheet = b.sheet(node); + const mini = { key: null, name: "", resultUnit: "", workbook: { ...b.book, 시트: [sheet] }, nodes: [node], edges: [] }; // prettier-ignore + const got = applyItemLayout(mini, node, ref, qty); + sheet.칸 = got.workbook.시트[0].칸; + const 유효성 = got.workbook.시트[0].유효성; + if (유효성) sheet.유효성 = 유효성; + Object.assign(node, got.nodes[0]); +} + +/** 항목 상자(호표 줄) — Item 틀 그대로(node.ref = ItemRef) → 비목 출력 알약들 */ function itemBox(b: Build, item: HoLine, lines: Map): [SlNode, Cost][] { const name = item.이름 || String(item.요소); - const element = typeof item.요소 === "string" ? item.요소 : JSON.stringify(item.요소); - const call = - typeof item.요소 === "string" && item.요소.startsWith("로직(") ? parseFormula(item.요소) : null; - const node = b.node("item", name, 3, call?.t === "logic" ? (call.args.some(([k]) => k === "기계") ? element : call.head) : element); // prettier-ignore - const q = qtyFrom(b, item, name); - const qty = - typeof q === "number" - ? b.row(node, t("Qty"), { 값: q }, item.단위 ?? "") - : b.link(node, q[0], q[1]); - if (typeof q === "number") b.pill(node, "in", { name: t("Qty"), cell: qty, unit: item.단위 }); - const out: [SlNode, Cost][] = []; - if (call?.t === "logic") { - args(b, node, call.args); - const unit = COSTS.map((k) => { - const at = b.row(node, `${t("Price")} · ${k}`, {}, t("Outside")); - b.pill(node, "in", { name: k, cell: at, outside: true }); - return at; - }); - const fixed = item.비목 as Cost | undefined; - const total = `${qty}*(${unit.join("+")})`; - if (fixed && (COSTS as readonly string[]).includes(fixed)) { - b.out(node, fixed, { 식: total }); - out.push([node, fixed]); - lines.set(name, [node, fixed]); - } else { - COSTS.forEach((k, i) => { - b.out(node, k, { 식: `${qty}*${unit[i]}` }); - out.push([node, k]); - }); - b.out(node, t("Amount"), { 식: total }); - lines.set(name, [node, t("Amount")]); + const el: unknown = item.요소; + const call = typeof el === "string" && el.startsWith("로직(") ? parseFormula(el) : null; + const logic = call?.t === "logic" ? call : null; + const what: ItemRef["what"] = logic + ? logic.args.some(([k]) => k === "기계") ? "장비" : "일위대가" + : item.종류 === "인력" ? "노무" : "재료"; // prettier-ignore + // 넘길 값 — 입력값 이름은 예시값으로(Item 이 ref.inputs 로 바깥 값을 받음) · 글 · 수는 요소 글 안에 그대로 + const need: LogicInput[] = []; + const inputs: Record = {}; + const bad: string[] = []; + for (const [k, v] of logic?.args ?? []) { + if (v.t !== "name") { + if (v.t !== "num" && v.t !== "str") bad.push(k); + continue; + } + const spec = b.specs.get(v.v); + if (!spec) bad.push(k); + else { + need.push({ ...spec, 이름: k }); + inputs[k] = example(spec, b.opts.examples?.[spec.이름]).값; } - return out; } - const price = b.row(node, t("Price"), {}, t("Outside")); - b.pill(node, "in", { name: t("Price"), cell: price, outside: true }); - const holes = typeof item.요소 === "string" ? [item.요소] : Object.values(item.요소 ?? {}); + const element = what === "재료" && typeof el === "string" ? { 키: el } : el; + const ref: ItemRef = { + what, + element, + ...(item.규격 ? { spec: item.규격 } : {}), + ...(item.단위 ? { unit: item.단위 } : {}), + ...(need.length ? { need, inputs } : {}), + }; + const node = b.node("item", name, 3, JSON.stringify(ref), false); + const outside: Cost[] = what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : [...COSTS]; + const q = qtyFrom(b, item, name); + layout(b, node, ref, typeof q === "number" ? q : undefined); + if (typeof q !== "number") b.link(node, q[0], q[1], node.pillsIn[0]); + const holes = typeof el === "string" ? [el] : Object.values(el ?? {}); + const priceAt = what === "재료" ? "B3" : "B2"; if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v))) - b.cant(node, price, t("Hole", element), element); + b.cant(node, priceAt, t("Hole", typeof el === "string" ? el : JSON.stringify(el)), String(el)); + if (bad.length) b.cant(node, priceAt, t("PassCalc"), bad.join(", ")); + lines.set(name, [node, "금액"]); + return outside.map((c): [SlNode, Cost] => [node, c]); +} + +/** 덧줄 — 「노무비 * 3 / 100」 꼴이면 Item 비율 틀 · 아니면 식 그대로 계산하는 항목(금액 · 비목 · 단가 알약) */ +const RATE_RE = /^\(?(노무비|재료비|경비)\)?\*(\d+(?:\.\d+)?)\/100$/; + +function extraBox( + b: Build, + extra: { 이름: string; 식: string; 비목?: string; 출처?: string }, + sc: Omit, +): [SlNode, Cost] { const cost = ( - (COSTS as readonly string[]).includes(item.비목 ?? "") ? item.비목 : "재료비" + (COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비" ) as Cost; - b.out(node, cost, { 식: `${qty}*${price}` }); - lines.set(name, [node, cost]); - return [[node, cost]]; + const m = RATE_RE.exec(extra.식.replace(/\s+/g, "")); + if (m) { + const base = m[1] as Cost; + const ref: ItemRef = { what: "비율", base, into: cost }; + const node = b.node("item", extra.이름, 3, JSON.stringify(ref), false); + b.sheet(node).칸.B1 = { 값: Number(m[2]) }; // 율 — Item 이 이 칸을 살림 + layout(b, node, ref); + return [node, cost]; + } + const node = b.node("item", extra.이름, 3); + const at = put({ ...sc, here: node }, "금액", extra.식, "out", extra.출처 ?? ""); + b.out(node, "단가", { 식: at }); + b.out(node, cost, { 식: at }); + b.out(node, "사용 수량", { 값: 1 }); + return [node, cost]; } type Cut = { 대상?: string; 자리?: number; 방법?: string }; @@ -598,8 +636,10 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 }); } else { const lines = new Map(); + for (const h of row.호표 ?? []) + if (typeof h.요소 === "string" && h.이름 && !b.names.has(h.요소)) b.names.set(h.요소, h.이름); const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines)); - // 항목 합 — 덧줄이 「노무비」 등으로 부르는 호표 줄 비목 합 + // 항목 합 — 식 덧줄이 「노무비」 등으로 부르는 앞 줄까지의 비목 합 const sum = b.node("calc", t("Sum"), 3); const costs = {} as Record; for (const k of COSTS) { @@ -608,27 +648,32 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl costs[k] = [[sum, k]]; } const extras = new Map(); + const into: [SlNode, Cost][] = [...outs]; for (const extra of row.덧줄 ?? []) { - const cost = ( - (COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비" - ) as Cost; - const node = b.node("item", extra.이름, 3, "비율"); - put({ b, here: node, costs, extras, lines }, cost, extra.식, "out", extra.출처 ?? ""); + const [node, cost] = extraBox(b, extra, { b, costs, extras, lines }); extras.set(extra.이름, [node, cost]); costs[cost] = [...costs[cost], [node, cost]]; + into.push([node, cost]); } - const total: string[] = []; + // 결과 = Model 결과 틀 — B2~B4 노무비 · 재료비 · 경비 입력 알약(선이 여럿이면 +) · B5 계 for (const k of COSTS) { - const refs = costs[k].map(([n, c]) => b.link(result, n, c)); - const at = b.out(result, k, { - 식: cutText(row, "계", refs.length > 1 ? `SUM(${refs.join(",")})` : refs[0]), - }); - total.push(at); + const at = b.row(result, k, {}); + b.pill(result, "in", { name: k, cell: at }); + const refs = into + .filter(([, c]) => c === k) + .map(([n]) => { + const src = n.pillsOut.find((p) => p.name === k)!; + const to = { node: result.id, pill: k }; + b.edges.push({ id: `e${b.edges.length + 1}`, from: { node: n.id, pill: k }, to }); + return `${quoteSheet(b.sheet(n).이름)}!${src.cell}`; + }); + if (refs.length) b.sheet(result).칸[at] = { 식: refs.join("+") }; } - b.out(result, "계", { 식: `SUM(${total.join(",")})` }, row.결과단위); + const parts = COSTS.map((_, i) => cutText(row, "계", `B${2 + i}`)); + b.out(result, "계", { 식: parts.join("+") }, row.결과단위); } b.book.활성 = b.book.시트[0]?.id; - const sl: SheetLogic = { + const built: SheetLogic = { key: null, name: row.이름, resultUnit: row.결과단위, @@ -636,6 +681,8 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl nodes: b.nodes, edges: b.edges, }; + // 비율 항목 바탕 = Item 과 같은 셈(다른 항목 상자의 그 비목 합 · 선 함께) + const sl = refreshBases(built); return { sl, problems: b.problems }; } @@ -644,48 +691,4 @@ export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLog return importLogicReport(row, opts).sl; } -/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */ -function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown { - const got = calc?.[sheetId]?.[cell]; - if (got && "수" in got) return Number(got.수); - if (got && "글" in got) return got.글; - if (got && "참" in got) return got.참; - return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null; -} - -/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */ -export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { - const out: SlOutsideAt[] = []; - for (const node of sl.nodes) { - const passed: Record = {}; - const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside); - const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); - for (const p of node.pillsIn) { - const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; - if (node.kind !== "table" && TEXT.Pass.includes(note as never)) - passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc); - } - const ref = node.ref ?? ""; - for (const p of outside) { - const id = `${node.id}:${p.name}`; - const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name) - ? (p.name as SlField) - : undefined; - let item: SlOutside; - if (node.kind === "table") item = { id, kind: "표", table: ref }; - else if ( - node.kind === "logic" || - ref.startsWith("GF") || - ref.startsWith("GC") || - ref.startsWith("GX") - ) - item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) }; - else if (ref.startsWith("로직(")) - item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) }; - else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref }; - else item = { id, kind: "자재", key: ref }; - out.push({ item, sheet: node.sheetId, cell: p.cell }); - } - } - return out; -} +export { outsideOf } from "./M01_SL_Import_Parse"; diff --git a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts index 0bdcffee6..99a8ee623 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Import_Parse.ts @@ -3,10 +3,15 @@ * 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로 * 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 · * - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null - * 아래 끝 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸) — 바깥 값 「표」 모양과 같음 + * 아래 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸 — 바깥 값 「표」 모양과 같음) · + * 바깥 값 자리(`outsideOf`) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { CalcValues } from "../../A00_Common/spreadsheet/spreadsheet_types"; +import type { SheetLogic, SlNode } from "./M01_SL_Model"; +import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api"; +import { itemRefOf, type ItemRef } from "./M01_SL_Item"; export type Ast = | { t: "num"; v: string } @@ -184,3 +189,85 @@ export function tableRows(table: SlImportTable): (string | number | null)[][] { }; return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))]; } + +/* ── 바깥 값 자리 ─────────────────────────────────────────────────────────── */ + +/** 넘길 값 칸 표시(C 칸) — `M01_SL_Import` TEXT.Pass 한 · 영 */ +const PASS = ["넘길 값", "Passed value"]; +const COST3 = ["노무비", "재료비", "경비"] as const; + +/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */ +function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown { + const got = calc?.[sheetId]?.[cell]; + if (got && "수" in got) return Number(got.수); + if (got && "글" in got) return got.글; + if (got && "참" in got) return got.참; + return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null; +} + +/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */ +export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] { + const out: SlOutsideAt[] = []; + for (const node of sl.nodes) { + const passed: Record = {}; + const outside = [...node.pillsIn, ...node.pillsOut].filter((p) => p.outside); + const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId); + for (const p of node.pillsIn) { + const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값; + if (node.kind !== "table" && PASS.includes(String(note))) + passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc); + } + const ref = node.ref ?? ""; + const item = itemRefOf(node); + if (item) { + out.push(...itemOutside(node, item)); + continue; + } + for (const p of outside) { + const id = `${node.id}:${p.name}`; + const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name) + ? (p.name as SlField) + : undefined; + let item: SlOutside; + if (node.kind === "table") item = { id, kind: "표", table: ref }; + else if ( + node.kind === "logic" || + ref.startsWith("GF") || + ref.startsWith("GC") || + ref.startsWith("GX") + ) + item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("로직(")) + item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) }; + else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref }; + else item = { id, kind: "자재", key: ref }; + out.push({ item, sheet: node.sheetId, cell: p.cell }); + } + } + return out; +} + +/** 항목 상자 바깥 값 — Item `itemOutsides` 와 같은 칸 · 자재 검색어 묶음은 key = 묶음 JSON 글(서버가 「{」 로 풂) */ +function itemOutside(node: SlNode, ref: ItemRef): SlOutsideAt[] { + const at = (cell: string, item: SlOutside): SlOutsideAt => ({ item, sheet: node.sheetId, cell }); + const id = (f: string): string => `${node.id}:${f}`; + const el = ref.element; + const inputs = ref.inputs ?? {}; + if (ref.what === "노무") return [at("B2", { id: id("노무비"), kind: "노임", key: String(el) })]; + if (ref.what === "재료") { + const keyed = typeof el === "object" && el !== null && "키" in el; + const key = keyed ? String((el as { 키: unknown }).키) : JSON.stringify(el); + return [at("B3", { id: id("재료비"), kind: "자재", key })]; + } + if (ref.what === "비율") return []; + const text = String(el); + const key = /로직\(\s*([A-Z]{2}\d{6})/.exec(text)?.[1] ?? ""; + return COST3.map((f, i) => + at( + `B${2 + i}`, + ref.what === "장비" + ? { id: id(f), kind: "장비", element: text, inputs, field: f } + : { id: id(f), kind: "로직", key, inputs, field: f }, + ), + ); +} diff --git a/resources/tester/test_m01_sl_import.py b/resources/tester/test_m01_sl_import.py index 750efad84..6d62f9a11 100644 --- a/resources/tester/test_m01_sl_import.py +++ b/resources/tester/test_m01_sl_import.py @@ -29,7 +29,7 @@ HOOKS = r""" import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { stripTypeScriptTypes } from "node:module"; -const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|spreadsheet_(address|types))(\.ts)?$/; +const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|M01_SL_(Item|Model)|spreadsheet_\w+)(\.ts)?$/; export async function resolve(spec, ctx, next) { if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) { const u = new URL(spec + ".ts", ctx.parentURL); @@ -161,12 +161,25 @@ def _tables_of(row: dict, tables: dict) -> dict: return {k: tables[k] for k in keys if k in tables} +def _names_of(row: dict, master) -> dict: + """다른 로직 · 노임 키 → 이름(화면이 마스터 목록에서 넘기는 것과 같음)""" + cm, files, _ = master + m = cm.mf.Master(files) + keys = set(re.findall(r"(?:G[FCX]|LB|MT)\d{6}", json.dumps(row, ensure_ascii=False))) + return {k: str(m.get(k).get("이름") or k) for k in keys} + + def _num(v) -> float: + """빈 칸(선이 없는 비목 알약)은 0 · 수가 아니면 nan""" + if v is None: + return 0.0 return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan") def _fill(sl: dict, places: list, py_lines: list, master) -> None: """바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`""" + from M01_MasterData.LogicBuilder import M01_LB_Calc as lb + cm, files, _ = master m = cm.mf.Master(files) nodes = {n["id"]: n for n in sl["nodes"]} @@ -180,14 +193,21 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None: elif kind == "자재": name = nodes[item["id"].split(":")[0]]["name"] value = next(x["단가"] for x in py_lines if x["이름"] == name) + elif kind == "장비": # 서버와 같음 — 요소 글 한 줄(수량 1)을 셈해 그 비목 + line = { + "종류": "로직", + "요소": item["element"], + "이름": "장비", + "단위": "시간", + "수량": "1", + } + inputs = item["inputs"] + one = {"키": "", "원문번호": "", "이름": "장비", "결과단위": "원/시간", "출처": "", + "입력": [{"이름": k} for k in inputs], "중간": [], "호표": [line], "덧줄": []} # fmt: skip + value = lb.calc(one, inputs, {})["lines"][0]["비목"].get(item["field"], 0) else: - key = ( - item["key"] - if kind == "로직" - else re.match(r"로직\((\w+)", item["element"]).group(1) - ) - got = cm.mf.run(m, key, {k: Decimal(str(v)) if isinstance(v, (int, float)) else v - for k, v in item["inputs"].items()}) # fmt: skip + got = cm.mf.run(m, item["key"], {k: Decimal(str(v)) if isinstance(v, (int, float)) else v + for k, v in item["inputs"].items()}) # fmt: skip value = got["결과"] if "결과" in got else got[item.get("field") or "계"] if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0) continue @@ -197,7 +217,8 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None: def _result_cells(sl: dict) -> dict: node = next(n for n in sl["nodes"] if n["kind"] == "result") - return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsOut"]} + # 결과 틀 — 노무비 · 재료비 · 경비 = 입력 알약 · 계(또는 결과) = 출력 알약 + return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsIn"] + node["pillsOut"]} @pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES]) @@ -206,9 +227,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m from M01_MasterData.LogicBuilder import M01_LB_Calc as lb rep = _node( - "const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });" + "const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given, names: A.names });" "out({ ...r, places: I.outsideOf(r.sl) });", - {"row": row, "tables": _tables_of(row, master[2]), "given": given}, + { + "row": row, + "tables": _tables_of(row, master[2]), + "given": given, + "names": _names_of(row, master), + }, ) assert rep["problems"] == [] sl = rep["sl"] @@ -219,6 +245,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴) if n["kind"] == "table": assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"] + # 상자 이름에 번호(키) 없음 · 항목 상자 = Item 틀(ItemRef · B1 수량 · B6 금액) + assert not [n["name"] for n in sl["nodes"] if re.fullmatch(r"[A-Z]{2}\d{6}", n["name"])] + for n in sl["nodes"]: + if n["kind"] == "item" and n.get("ref", "").startswith("{"): + ref = json.loads(n["ref"]) + assert ref["what"] in ("노무", "재료", "장비", "일위대가", "비율") + pills = {p["name"]: p["cell"] for p in n["pillsOut"]} + assert pills["금액"] == ("B4" if ref["what"] == "비율" else "B6") for e in sl["edges"]: assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"]) assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"]) From 0e72719a5d0271d68fd0b5f16cfd12f5a85c47af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:02:45 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(m01-sl):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=83=81=EC=9E=90=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=87=EA=B8=B0(renderLogicTools)=20=C2=B7=20?= =?UTF-8?q?=EB=84=98=EA=B8=B8=20=EA=B0=92=EC=9D=84=20=EC=8B=A4=EC=9D=80=20?= =?UTF-8?q?=EB=B0=94=EA=B9=A5=20=EA=B0=92=20=EB=AC=BC=EC=9D=8C=EC=9D=84=20?= =?UTF-8?q?=EC=A7=90=EC=9E=91=ED=95=9C=20=EB=AC=BC=EC=9D=8C=EB=B3=B4?= =?UTF-8?q?=EB=8B=A4=20=EB=A8=BC=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../SheetLogic/M01_SL_Entry_Paper.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts index ca3b354a0..2b4d7bdb2 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -33,7 +33,8 @@ export const loadMods = () => import("./M01_SL_Result"), import("./M01_SL_Import"), import("../../A00_Common/spreadsheet/spreadsheet_recalc"), - ]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc]) => ({ + import("./M01_SL_LogicTools"), + ]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc, logic]) => ({ model, ev, api, @@ -44,6 +45,7 @@ export const loadMods = () => result, imp, recalc, + logic, })); export type Mods = Awaited>; @@ -109,13 +111,19 @@ export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] { if (!at.has(k)) at.set(k, p); }; M.item.itemOutsides(sl).forEach(put); - for (const c of M.ev.outsideCells(sl)) { - const node = sl.nodes.find((n) => n.id === c.node); - if (node && c.source) - put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); - } + // 상자에 출처가 적힌 알약 → 넘길 값을 실은 물음(outsideOf) → 출처를 짐작한 나머지(넘길 값 없음) 차례 + const cells = M.ev.outsideCells(sl); + const fromCell = (explicit: boolean): void => { + for (const c of cells) { + const node = sl.nodes.find((n) => n.id === c.node); + if (node && c.source && !!node.source === explicit) + put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); + } + }; + fromCell(true); // 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0) M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).forEach(put); + fromCell(false); return [...at.values()]; } @@ -294,10 +302,6 @@ export function showPaper( }); }; - /* ponytail: 「다른 로직」 상자 손잡이(sub5 renderLogicTools)가 올라오기 전엔 없음 — 올라오면 바로 M.item.renderLogicTools 로 부름 */ - const logicTools = (M.item as { renderLogicTools?: typeof M.item.renderItemTools }) - .renderLogicTools; - const drawPanel = (): void => { const node = sl.nodes.find((n) => n.id === selected); if (!node) { @@ -323,8 +327,8 @@ export function showPaper( loadPrices(node.id); }, }); - else if (node.kind === "logic" && logicTools) - logicTools(toolsHost, sl, node.id, { + else if (node.kind === "logic") + M.logic.renderLogicTools(toolsHost, sl, node.id, { change: (next) => { edit(next, true); loadPrices(node.id); From 58cee6404e2026991886f21823542686df0a3483 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:03:38 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore(M01):=20=EC=8B=9C=ED=97=98=ED=8C=90?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=EA=B3=B5=EC=9A=A9=20=EC=96=91=EC=8B=9D?= =?UTF-8?q?=20=EC=A0=90=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/시험판_단추_점검.md | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/시험판_단추_점검.md diff --git a/docs/raw/M01_쉬운단가/시험판_단추_점검.md b/docs/raw/M01_쉬운단가/시험판_단추_점검.md new file mode 100644 index 000000000..f3ed3d65c --- /dev/null +++ b/docs/raw/M01_쉬운단가/시험판_단추_점검.md @@ -0,0 +1,110 @@ +# 시험판 단추 공용 양식 점검 (35-5 · sub7 · 확인만) + +대상 = `M01_MasterData/EasyCalc` · `LogicBuilder` · `SheetLogic` 의 누르는 것 전부. 화면 수치 = 내 포트 5182 ORCA · `getComputedStyle`(전환 끔 · 밝음 · 어두움) · 판정 직전 `/api/auth/session` 200. + +## 0. 한눈에 + +- **날 단추(`el("button")` 직접) 23 자리** — EasyCalc 5 · LogicBuilder 12 · SheetLogic 6(목록 줄 8 · 칩 11 · 「석공 ▾」 2 · 그림 알약 1 · 글 링크 1). 모두 자체 css 꼴(회색 바탕 · 16px · 테두리 1.75px · 둥글기 8 · 999 · **2px**). +- **공용 `createButton` 을 쓰되 css 가 모양을 덮음 4 곳** — 작은(sm 18px · 12px)을 44px · 16px 로(EasyCalc Cards · LB Steps · SL Panel) · 큰 시작 단추 56px(세 시작 화면). +- **한 화면 안 크기가 섞임** — SL 「+ 상자」 「상자 지우기」 18px 옆에 시트 판 단추 44px · LB 「문장 / 격자」 「빈틈 검사」 18px 옆에 산출근거 단추 44px. +- 색은 모두 테마 토큰 — 어두움에서 자체 꼴도 바뀜(SL 상자 · 알약 · 시트 판 알약 확인) · 문제는 **모양(높이 · 둥글기 · 글 크기 · 테두리)**. +- 시험판 들어가는 단추 셋(「쉬운 단가산출(시험)」 「로직 만들기(시험)」 「시트 로직(시험)」)은 공용 filled md 그대로 — 옆 「식으로 새로 만들기」와 같음(32px · 8px · 14px). + +## 1. 공용 양식 + +| 공용 꼴 | 어디 | 모양(밝음 실측) | 쓰임 | +| --- | --- | --- | --- | +| `createButton` md | `ui_template_elements.ts:43` · 꼴 `ui_template_elements_styles.ts:17-80` | 높이 32 · 둥글기 8px(`--radius-buttons`) · 14px · 500 · 테두리 1px | 모든 단추 | +| `createButton` sm | 같음 `.ui-btn--sm` | 높이 18 · 12px · 안쪽 2px 8px | 표 칸 · 카드 머리 작은 단추 | +| 종류 | `filled` 바탕 rgb(38,17,74) 흰 글 · `ghost` 투명 + 보라 테두리 · `pill` 투명 · 둥글기 1440 · `is-active` 때 mist-violet · `danger` 빨강 · `glass` 3D 위 | 어두움 = filled rgb(185,154,255) · ghost 글 같은 보라 | 탭 · 보기 바꿈 = `pill` + `is-active`(전용 탭 부품 없음) | +| `createTag` | `ui_template_elements.ts:217` · `.ui-tag` | 둥글기 1440 · 12px · 안쪽 4px 16px · accent · neutral · success · warning · danger | 누르지 않는 글 알약 | +| 목록 줄 `.ui-sidebar-row` | `ui_template_overlay.css:384-437` (M01 · M02 공용) | 투명 바탕 · 14px · 둥글기 8 · 안쪽 4px 8px · hover paper · `is-active` mist-violet | 왼쪽 목록 · 찾기 결과 줄 | +| 나무 줄 `createTreeRow` | `ui_template_tree.ts:118` | `.ui-sidebar-row` + 화살표 · 아이콘 · 수 | M02 나무 | +| 모달 `openModal` | `ui_template_modal.ts:20` | 머리 줄 닫기 = ghost md | 세 시험판 모두 씀 ✔ | +| (M01 자체) 알약 `pillButton` | `M01_MasterData_UI_LogicLab_Pill.ts:18` `.m01lab__pill` | M01 지금 화면 것 — ui_template 아님 | 수량 칸 안 표 조각 | +| (엔진 공용) 시트 도구막대 `ss-brush` 등 | `A00_Common/spreadsheet` | 25px · 13px · 테두리 없음 | M02 시트와 같음 — 그대로 | + +## 2. 누르는 것 전수 + +✔ = 공용 그대로 · ▲ = 공용인데 css 가 모양을 덮음 · ✖ = 날 단추(자체 꼴) + +### EasyCalc (쉬운 단가산출) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측(높이 · 둥글기 · 바탕 · 글) | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_EasyCalc_Entry.ts:177` | 시작 목록 줄 「돌쌓기 메쌓기(인력) · 내가 만든 것」 | ✖ `m01-easycalc__item` | 42 · 8 · rgb(246,247,250) · 16px · 테두리 0.9 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Entry.ts:565` | 빈 종이로 새로 만들기 | ▲ filled + `m01-easycalc__big` | **56** · 8 · 16px | 없음(큰 크기 없음) | 그대로(초등학생 큰 시작 단추) — 공용에 큰 크기 하나 청하면 덮기 지움 | +| `M01_EasyCalc_Entry.ts` 위 띠 | 되돌리기 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 · 8 · 14px | — | 그대로 | +| `M01_EasyCalc_Cards.ts:273` | 「석공 ▾」(다시 고르기) | ✖ `m01-easycards-name--pick` | 44 · 8 · 회색 · 16px · 400 | `createButton` ghost | 공용 createButton(ghost)로 | +| `M01_EasyCalc_Cards.css:145` | ▲ ▼ 지움 · 숫자로 바꾸기 | ▲ ghost **sm** → css 44px · 16px | 44 · 8 · 16px | sm 18px | 공용 createButton(ghost · md)로 · sm 덮기 지움 | +| `M01_EasyCalc_Cards.css:179` | + 사람 · + 재료 · + 기계 · + 다른 일위대가 · + 덧붙임 | ▲ ghost md → 44px · 16px | 44 · 8 · 16px | md 32 | 그대로 or 공용 큰 크기(청함) | +| `M01_EasyCalc_Cond.ts:421` | 「표에서 찾은 값」 칩 | ✖ `m01-easycond__chip` 점선 · 1440 | 36 · 999 · 투명 · 16px | `pill` 단추 | 공용 createButton(pill)로 | +| `M01_EasyCalc_Pick.ts:288` | 사람 · 재료 · 기계 · 다른 일위대가(무엇) | ✖ `m01-easypick__kind` | (큰 칸) | `pill` + `is-active` | 공용 pill 로 | +| `M01_EasyCalc_Pick.ts:433` | 고르기 목록 줄 「보통인부 인 하루 172,068」 | ✖ `m01-easypick__row` | **52** · 8 · 회색 · **18px** · 테두리 1.75 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Pick.ts` 아래 | 넣기 · 닫기 | ✔ filled · ghost md | 32 | — | 그대로 | +| `M01_EasyCalc_Table.ts:630` | 표 줄 누르기(tr) | 표 칸 | — | 표 | 그대로(표 줄 고르기) | +| `M01_EasyCalc_Table.ts` 5 곳 | 표 고르기 단추 | ✔ ghost · sm | 18 | — | 그대로 | + +### LogicBuilder (로직 만들기) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_LB_Entry.ts:194` | 시작 목록 줄 「돌쌓기 메쌓기(인력) 표준 1㎡」 | ✖ `m01-lb__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Entry.ts:691` | 단계 안내로 새로 만들기 | ▲ filled + `m01-lb__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_LB_Entry.ts:585-593` | 문장 / 격자(보기 바꿈) | ✔ sm 인데 filled ↔ ghost 를 바꿔 끼움 | **18** · 8 · 12px | `pill` + `is-active`(탭 꼴) | 공용 createButton(pill) + `is-active` 로 | +| `M01_LB_Entry_Result.ts` | 빈틈 검사 · 지금 예시값으로 한 줄 더하기 · 모두 돌리기 | ✔ ghost sm | 18 · 12px | — | 공용 그대로 · 단 옆 산출근거 44px 와 크기 다름 → md 로 | +| `M01_LB_Source.ts:177` | 표 머리 역할 칩 「조건」 「결과」 「고정」 | ✖ `m01-lbsrc__chip` | 20 · 999 · 옅은 보라 / 초록 · 12px | `createTag`(accent · success) 꼴 + 누름 | 공용 pill sm 로(색은 tag 갈래) | +| `M01_LB_Source.ts:241` | 이 표를 쓰는 로직 N 개 | ✖ `m01-lbsrc__users` 글 단추 | 21 · 0 · 테두리 없음 · 14px | 없음(글 링크) | 그대로(글 링크) 또는 ghost sm | +| `M01_LB_Source.ts:286` | 표 칸 누르기(td) | 표 칸 | — | — | 그대로 | +| `M01_LB_Steps.ts:206` | 「석공 ▾」 | ✖ `m01-lbsteps-pick` | (EasyCalc 와 같은 꼴) | ghost | 공용 createButton(ghost)로 | +| `M01_LB_Steps.css:163` | 산출근거 안 단추 모두 | ▲ `.m01-lbsteps .ui-btn` → 44px · 16px | 44 | — | 공용 md 로(덮기 지움) | +| `M01_LB_Grid.ts:509` | 격자 줄 누르기(tr) | 표 줄 | — | — | 그대로 | +| `M01_LB_Wizard.ts:250 · 291` | 단계 안내 절 · 표 목록 줄 | ✖ `m01-lbw__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Wizard.ts:376` | 칸 역할 「조건 · 고정 · 결과」 | ✖ `m01-lbw__role` | (역할 칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard.ts:499` · `Wizard_Calc.ts:199 · 448 · 459` | 종류 · 틀 · 항목 연결 · 건너뛰기 칩 | ✖ `m01-lbw__chip` | (칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard_Calc.ts:172` | 주 고르기 | ✖ `m01-lbw__note` | (칩) | pill + is-active | 공용 pill 로 | + +### SheetLogic (시트 로직) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_SL_Entry.ts:178` | 시작 목록 줄(기존 로직 가져오기) | ✖ `m01-sl__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Entry.ts:297` | 새로 만들기(빈 판) | ▲ filled + `m01-sl__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_SL_Entry_Paper.ts` | 실행 취소 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 | — | 그대로 | +| `M01_SL_Diagram.ts` | + 상자 · 상자 지우기 | ✔ filled sm · danger sm | **18** · 12px | — | 공용 md 로(옆 시트 판 44px 와 맞춤) | +| `M01_SL_Diagram.ts:181` | 상자 양옆 알약 「뒷길이 25」 「? 단가 268,908」 | ✖ `m01-sldg__pill` | 20 · 999 · 입력 옅은 주황 / 출력 옅은 보라 / 빈 칸 점선 · 12px | 그림 요소(공용 없음) | 그대로(다이어그램 그림) — 둥글기 · 글은 tag 와 같음 | +| `M01_SL_Diagram.ts:249` | 상자 누르기(div) | 그림 상자 | 80~152 · 8 | — | 그대로 | +| `M01_SL_Panel.ts:323` | 시트 판 알약 「입력 · 뒷길이 (B2)」 | ✖ `m01-slpanel-pill` | **44** · 1440 · 회색 · 16px · 테두리 1.75 | pill 단추 · 그림 알약(20px)과 같은 것인데 꼴 다름 | 공용 pill 로 · 다이어그램 알약과 한 꼴 | +| `M01_SL_Panel.css:35` | 입력 알약으로 · 출력 알약으로 · 할증 · 나누기 · 만약 · 표에서 찾기 | ▲ ghost **sm** → 44px · 16px | 44 · 8 | sm 18 | 공용 md 로 · sm 덮기 지움 | +| `M01_SL_Table.ts:301 · 338` | 품셈 표 찾기 결과 줄 | ✖ `m01-sltbl__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Table.ts:509` | 칸 역할 「높이 조건 → 입력 알약」 | ✖ `m01-sltbl__role` | **63** · **2px**(토큰 밖) · 회색 · 16px · 테두리 1.75 실선 / 점선 | pill + is-active | 공용 pill 로 · 둥글기 2px 버림 | +| `M01_SL_Item.ts` | 종류 고르기 · 다시 고르기 · 단가 다시 가져오기 | ✔ ghost · sm | — | — | 그대로 | +| 시트 도구막대 | 서식… · 붓 · ↑ … | 엔진 `ss-brush` | 25 · 0 · 13px | M02 시트와 같음 | 그대로 | + +## 3. 어두움(실측 — SL) + +| 꼴 | 바탕 | 글 | +| --- | --- | --- | +| filled md · sm | rgb(185,154,255) | rgb(22,18,31) | +| ghost | 투명 | rgb(185,154,255) | +| danger sm | rgb(232,105,90) | rgb(22,18,31) | +| `m01-sldg__box` | rgb(30,25,48) | rgb(228,224,240) | +| `m01-sldg__pill`(입력) | 어두운 주황 | rgb(229,168,102) | +| `m01-slpanel-pill` | rgb(30,25,48) | rgb(228,224,240) | + +→ 자체 꼴도 토큰이라 어두움에서 깨지지 않음 · 고칠 것은 모양뿐. + +## 4. 고칠 안 (갈래별) + +1. **목록 줄 8 자리**(Easy 시작 · Easy 고르기 · LB 시작 · LB 단계 절 · 표 · SL 시작 · SL 표 찾기 둘) → 공용 `.ui-sidebar-row` 꼴. 초등학생용 큰 줄이 필요하면 ⬜ 공용에 「큰 줄」 갈래 하나를 청함(자체 css 로 덮지 않음). +2. **칩 · 역할 · 틀 11 자리 + 보기 바꿈 1**(Cond 칩 · Pick 종류 · LB 역할 칩 · 단계 안내 칩 넷 · 역할 · 주 · SL 칸 역할 · SL 시트 판 알약 + LB 문장/격자) → 공용 `createButton({ variant: "pill" })` + `is-active`. 역할 색(조건 보라 · 결과 초록)은 `ui-tag` 색 갈래를 빌림. +3. **「석공 ▾」 다시 고르기 2 곳**(Easy Cards · LB Steps) → 공용 `createButton({ variant: "ghost" })`. +4. **sm 를 44px 로 덮는 css 3 곳**(Easy Cards · LB Steps · SL Panel) → 덮기 지우고 공용 md(32px). 한 화면에 18 · 32 · 44 가 섞이지 않게 — SL 「+ 상자」 · LB 「빈틈 검사」 sm 도 md 로. +5. **큰 시작 단추 3 곳**(56px) → 그대로(설계 「초등학생 큰 단추」) · ⬜ 공용에 큰 크기(`lg`)를 더하면 그것으로 바꿈. +6. **그대로** — 다이어그램 상자 · 알약(그림 요소) · 표 줄 · 표 칸 누르기 · 시트 도구막대(엔진 공용) · 「이 표를 쓰는 로직 N 개」 글 링크. +7. SL 칸 역할 둥글기 **2px** 는 토큰(8 · 1440) 밖 — 2번에서 함께 바뀜. + +## 덧 + +- EasyCalc 시작 화면 「내가 만든 것」 줄이 **53 개** — 여러 창의 시험 저장이 `로직_자체.json`(공유 storage)에 남은 것으로 보임(점검 범위 밖 · 사실만). +- 측정 주의 — 보기 바꿈처럼 class 를 바꾼 직후 `getComputedStyle` 은 0.12 초 전환 중 값을 줌 → 전환을 끈 뒤 잼. From bb89baa557f65d4c0c9d895e8057f8fe7489ff8f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 14:05:12 +0900 Subject: [PATCH 8/9] =?UTF-8?q?chore(M01):=20=EA=B3=B5=EC=9C=A0=20docs=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=EB=8B=B4=EC=9D=8C(=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=B0=BD=EC=9D=B4=20=EC=93=B4=20=EC=89=AC=EC=9A=B4=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EB=AC=B8=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/M01_쉬운단가/시험판_단추_점검.md | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/시험판_단추_점검.md diff --git a/docs/raw/M01_쉬운단가/시험판_단추_점검.md b/docs/raw/M01_쉬운단가/시험판_단추_점검.md new file mode 100644 index 000000000..f3ed3d65c --- /dev/null +++ b/docs/raw/M01_쉬운단가/시험판_단추_점검.md @@ -0,0 +1,110 @@ +# 시험판 단추 공용 양식 점검 (35-5 · sub7 · 확인만) + +대상 = `M01_MasterData/EasyCalc` · `LogicBuilder` · `SheetLogic` 의 누르는 것 전부. 화면 수치 = 내 포트 5182 ORCA · `getComputedStyle`(전환 끔 · 밝음 · 어두움) · 판정 직전 `/api/auth/session` 200. + +## 0. 한눈에 + +- **날 단추(`el("button")` 직접) 23 자리** — EasyCalc 5 · LogicBuilder 12 · SheetLogic 6(목록 줄 8 · 칩 11 · 「석공 ▾」 2 · 그림 알약 1 · 글 링크 1). 모두 자체 css 꼴(회색 바탕 · 16px · 테두리 1.75px · 둥글기 8 · 999 · **2px**). +- **공용 `createButton` 을 쓰되 css 가 모양을 덮음 4 곳** — 작은(sm 18px · 12px)을 44px · 16px 로(EasyCalc Cards · LB Steps · SL Panel) · 큰 시작 단추 56px(세 시작 화면). +- **한 화면 안 크기가 섞임** — SL 「+ 상자」 「상자 지우기」 18px 옆에 시트 판 단추 44px · LB 「문장 / 격자」 「빈틈 검사」 18px 옆에 산출근거 단추 44px. +- 색은 모두 테마 토큰 — 어두움에서 자체 꼴도 바뀜(SL 상자 · 알약 · 시트 판 알약 확인) · 문제는 **모양(높이 · 둥글기 · 글 크기 · 테두리)**. +- 시험판 들어가는 단추 셋(「쉬운 단가산출(시험)」 「로직 만들기(시험)」 「시트 로직(시험)」)은 공용 filled md 그대로 — 옆 「식으로 새로 만들기」와 같음(32px · 8px · 14px). + +## 1. 공용 양식 + +| 공용 꼴 | 어디 | 모양(밝음 실측) | 쓰임 | +| --- | --- | --- | --- | +| `createButton` md | `ui_template_elements.ts:43` · 꼴 `ui_template_elements_styles.ts:17-80` | 높이 32 · 둥글기 8px(`--radius-buttons`) · 14px · 500 · 테두리 1px | 모든 단추 | +| `createButton` sm | 같음 `.ui-btn--sm` | 높이 18 · 12px · 안쪽 2px 8px | 표 칸 · 카드 머리 작은 단추 | +| 종류 | `filled` 바탕 rgb(38,17,74) 흰 글 · `ghost` 투명 + 보라 테두리 · `pill` 투명 · 둥글기 1440 · `is-active` 때 mist-violet · `danger` 빨강 · `glass` 3D 위 | 어두움 = filled rgb(185,154,255) · ghost 글 같은 보라 | 탭 · 보기 바꿈 = `pill` + `is-active`(전용 탭 부품 없음) | +| `createTag` | `ui_template_elements.ts:217` · `.ui-tag` | 둥글기 1440 · 12px · 안쪽 4px 16px · accent · neutral · success · warning · danger | 누르지 않는 글 알약 | +| 목록 줄 `.ui-sidebar-row` | `ui_template_overlay.css:384-437` (M01 · M02 공용) | 투명 바탕 · 14px · 둥글기 8 · 안쪽 4px 8px · hover paper · `is-active` mist-violet | 왼쪽 목록 · 찾기 결과 줄 | +| 나무 줄 `createTreeRow` | `ui_template_tree.ts:118` | `.ui-sidebar-row` + 화살표 · 아이콘 · 수 | M02 나무 | +| 모달 `openModal` | `ui_template_modal.ts:20` | 머리 줄 닫기 = ghost md | 세 시험판 모두 씀 ✔ | +| (M01 자체) 알약 `pillButton` | `M01_MasterData_UI_LogicLab_Pill.ts:18` `.m01lab__pill` | M01 지금 화면 것 — ui_template 아님 | 수량 칸 안 표 조각 | +| (엔진 공용) 시트 도구막대 `ss-brush` 등 | `A00_Common/spreadsheet` | 25px · 13px · 테두리 없음 | M02 시트와 같음 — 그대로 | + +## 2. 누르는 것 전수 + +✔ = 공용 그대로 · ▲ = 공용인데 css 가 모양을 덮음 · ✖ = 날 단추(자체 꼴) + +### EasyCalc (쉬운 단가산출) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측(높이 · 둥글기 · 바탕 · 글) | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_EasyCalc_Entry.ts:177` | 시작 목록 줄 「돌쌓기 메쌓기(인력) · 내가 만든 것」 | ✖ `m01-easycalc__item` | 42 · 8 · rgb(246,247,250) · 16px · 테두리 0.9 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Entry.ts:565` | 빈 종이로 새로 만들기 | ▲ filled + `m01-easycalc__big` | **56** · 8 · 16px | 없음(큰 크기 없음) | 그대로(초등학생 큰 시작 단추) — 공용에 큰 크기 하나 청하면 덮기 지움 | +| `M01_EasyCalc_Entry.ts` 위 띠 | 되돌리기 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 · 8 · 14px | — | 그대로 | +| `M01_EasyCalc_Cards.ts:273` | 「석공 ▾」(다시 고르기) | ✖ `m01-easycards-name--pick` | 44 · 8 · 회색 · 16px · 400 | `createButton` ghost | 공용 createButton(ghost)로 | +| `M01_EasyCalc_Cards.css:145` | ▲ ▼ 지움 · 숫자로 바꾸기 | ▲ ghost **sm** → css 44px · 16px | 44 · 8 · 16px | sm 18px | 공용 createButton(ghost · md)로 · sm 덮기 지움 | +| `M01_EasyCalc_Cards.css:179` | + 사람 · + 재료 · + 기계 · + 다른 일위대가 · + 덧붙임 | ▲ ghost md → 44px · 16px | 44 · 8 · 16px | md 32 | 그대로 or 공용 큰 크기(청함) | +| `M01_EasyCalc_Cond.ts:421` | 「표에서 찾은 값」 칩 | ✖ `m01-easycond__chip` 점선 · 1440 | 36 · 999 · 투명 · 16px | `pill` 단추 | 공용 createButton(pill)로 | +| `M01_EasyCalc_Pick.ts:288` | 사람 · 재료 · 기계 · 다른 일위대가(무엇) | ✖ `m01-easypick__kind` | (큰 칸) | `pill` + `is-active` | 공용 pill 로 | +| `M01_EasyCalc_Pick.ts:433` | 고르기 목록 줄 「보통인부 인 하루 172,068」 | ✖ `m01-easypick__row` | **52** · 8 · 회색 · **18px** · 테두리 1.75 | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_EasyCalc_Pick.ts` 아래 | 넣기 · 닫기 | ✔ filled · ghost md | 32 | — | 그대로 | +| `M01_EasyCalc_Table.ts:630` | 표 줄 누르기(tr) | 표 칸 | — | 표 | 그대로(표 줄 고르기) | +| `M01_EasyCalc_Table.ts` 5 곳 | 표 고르기 단추 | ✔ ghost · sm | 18 | — | 그대로 | + +### LogicBuilder (로직 만들기) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_LB_Entry.ts:194` | 시작 목록 줄 「돌쌓기 메쌓기(인력) 표준 1㎡」 | ✖ `m01-lb__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Entry.ts:691` | 단계 안내로 새로 만들기 | ▲ filled + `m01-lb__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_LB_Entry.ts:585-593` | 문장 / 격자(보기 바꿈) | ✔ sm 인데 filled ↔ ghost 를 바꿔 끼움 | **18** · 8 · 12px | `pill` + `is-active`(탭 꼴) | 공용 createButton(pill) + `is-active` 로 | +| `M01_LB_Entry_Result.ts` | 빈틈 검사 · 지금 예시값으로 한 줄 더하기 · 모두 돌리기 | ✔ ghost sm | 18 · 12px | — | 공용 그대로 · 단 옆 산출근거 44px 와 크기 다름 → md 로 | +| `M01_LB_Source.ts:177` | 표 머리 역할 칩 「조건」 「결과」 「고정」 | ✖ `m01-lbsrc__chip` | 20 · 999 · 옅은 보라 / 초록 · 12px | `createTag`(accent · success) 꼴 + 누름 | 공용 pill sm 로(색은 tag 갈래) | +| `M01_LB_Source.ts:241` | 이 표를 쓰는 로직 N 개 | ✖ `m01-lbsrc__users` 글 단추 | 21 · 0 · 테두리 없음 · 14px | 없음(글 링크) | 그대로(글 링크) 또는 ghost sm | +| `M01_LB_Source.ts:286` | 표 칸 누르기(td) | 표 칸 | — | — | 그대로 | +| `M01_LB_Steps.ts:206` | 「석공 ▾」 | ✖ `m01-lbsteps-pick` | (EasyCalc 와 같은 꼴) | ghost | 공용 createButton(ghost)로 | +| `M01_LB_Steps.css:163` | 산출근거 안 단추 모두 | ▲ `.m01-lbsteps .ui-btn` → 44px · 16px | 44 | — | 공용 md 로(덮기 지움) | +| `M01_LB_Grid.ts:509` | 격자 줄 누르기(tr) | 표 줄 | — | — | 그대로 | +| `M01_LB_Wizard.ts:250 · 291` | 단계 안내 절 · 표 목록 줄 | ✖ `m01-lbw__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_LB_Wizard.ts:376` | 칸 역할 「조건 · 고정 · 결과」 | ✖ `m01-lbw__role` | (역할 칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard.ts:499` · `Wizard_Calc.ts:199 · 448 · 459` | 종류 · 틀 · 항목 연결 · 건너뛰기 칩 | ✖ `m01-lbw__chip` | (칩) | pill + is-active | 공용 pill 로 | +| `M01_LB_Wizard_Calc.ts:172` | 주 고르기 | ✖ `m01-lbw__note` | (칩) | pill + is-active | 공용 pill 로 | + +### SheetLogic (시트 로직) + +| 파일:줄 | 화면 글 | 만드는 법 | 실측 | 같은 일의 공용 꼴 | 고칠 안 | +| --- | --- | --- | --- | --- | --- | +| `M01_SL_Entry.ts:178` | 시작 목록 줄(기존 로직 가져오기) | ✖ `m01-sl__item` | 42 · 8 · 회색 · 16px | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Entry.ts:297` | 새로 만들기(빈 판) | ▲ filled + `m01-sl__big` | **56** · 16px | — | 그대로(큰 시작) · 공용 큰 크기 청함 | +| `M01_SL_Entry_Paper.ts` | 실행 취소 · 저장 · 지우기 · 닫기 | ✔ ghost · filled · danger md | 32 | — | 그대로 | +| `M01_SL_Diagram.ts` | + 상자 · 상자 지우기 | ✔ filled sm · danger sm | **18** · 12px | — | 공용 md 로(옆 시트 판 44px 와 맞춤) | +| `M01_SL_Diagram.ts:181` | 상자 양옆 알약 「뒷길이 25」 「? 단가 268,908」 | ✖ `m01-sldg__pill` | 20 · 999 · 입력 옅은 주황 / 출력 옅은 보라 / 빈 칸 점선 · 12px | 그림 요소(공용 없음) | 그대로(다이어그램 그림) — 둥글기 · 글은 tag 와 같음 | +| `M01_SL_Diagram.ts:249` | 상자 누르기(div) | 그림 상자 | 80~152 · 8 | — | 그대로 | +| `M01_SL_Panel.ts:323` | 시트 판 알약 「입력 · 뒷길이 (B2)」 | ✖ `m01-slpanel-pill` | **44** · 1440 · 회색 · 16px · 테두리 1.75 | pill 단추 · 그림 알약(20px)과 같은 것인데 꼴 다름 | 공용 pill 로 · 다이어그램 알약과 한 꼴 | +| `M01_SL_Panel.css:35` | 입력 알약으로 · 출력 알약으로 · 할증 · 나누기 · 만약 · 표에서 찾기 | ▲ ghost **sm** → 44px · 16px | 44 · 8 | sm 18 | 공용 md 로 · sm 덮기 지움 | +| `M01_SL_Table.ts:301 · 338` | 품셈 표 찾기 결과 줄 | ✖ `m01-sltbl__row` | (목록 줄) | `.ui-sidebar-row` | 공용 목록 줄 꼴로 | +| `M01_SL_Table.ts:509` | 칸 역할 「높이 조건 → 입력 알약」 | ✖ `m01-sltbl__role` | **63** · **2px**(토큰 밖) · 회색 · 16px · 테두리 1.75 실선 / 점선 | pill + is-active | 공용 pill 로 · 둥글기 2px 버림 | +| `M01_SL_Item.ts` | 종류 고르기 · 다시 고르기 · 단가 다시 가져오기 | ✔ ghost · sm | — | — | 그대로 | +| 시트 도구막대 | 서식… · 붓 · ↑ … | 엔진 `ss-brush` | 25 · 0 · 13px | M02 시트와 같음 | 그대로 | + +## 3. 어두움(실측 — SL) + +| 꼴 | 바탕 | 글 | +| --- | --- | --- | +| filled md · sm | rgb(185,154,255) | rgb(22,18,31) | +| ghost | 투명 | rgb(185,154,255) | +| danger sm | rgb(232,105,90) | rgb(22,18,31) | +| `m01-sldg__box` | rgb(30,25,48) | rgb(228,224,240) | +| `m01-sldg__pill`(입력) | 어두운 주황 | rgb(229,168,102) | +| `m01-slpanel-pill` | rgb(30,25,48) | rgb(228,224,240) | + +→ 자체 꼴도 토큰이라 어두움에서 깨지지 않음 · 고칠 것은 모양뿐. + +## 4. 고칠 안 (갈래별) + +1. **목록 줄 8 자리**(Easy 시작 · Easy 고르기 · LB 시작 · LB 단계 절 · 표 · SL 시작 · SL 표 찾기 둘) → 공용 `.ui-sidebar-row` 꼴. 초등학생용 큰 줄이 필요하면 ⬜ 공용에 「큰 줄」 갈래 하나를 청함(자체 css 로 덮지 않음). +2. **칩 · 역할 · 틀 11 자리 + 보기 바꿈 1**(Cond 칩 · Pick 종류 · LB 역할 칩 · 단계 안내 칩 넷 · 역할 · 주 · SL 칸 역할 · SL 시트 판 알약 + LB 문장/격자) → 공용 `createButton({ variant: "pill" })` + `is-active`. 역할 색(조건 보라 · 결과 초록)은 `ui-tag` 색 갈래를 빌림. +3. **「석공 ▾」 다시 고르기 2 곳**(Easy Cards · LB Steps) → 공용 `createButton({ variant: "ghost" })`. +4. **sm 를 44px 로 덮는 css 3 곳**(Easy Cards · LB Steps · SL Panel) → 덮기 지우고 공용 md(32px). 한 화면에 18 · 32 · 44 가 섞이지 않게 — SL 「+ 상자」 · LB 「빈틈 검사」 sm 도 md 로. +5. **큰 시작 단추 3 곳**(56px) → 그대로(설계 「초등학생 큰 단추」) · ⬜ 공용에 큰 크기(`lg`)를 더하면 그것으로 바꿈. +6. **그대로** — 다이어그램 상자 · 알약(그림 요소) · 표 줄 · 표 칸 누르기 · 시트 도구막대(엔진 공용) · 「이 표를 쓰는 로직 N 개」 글 링크. +7. SL 칸 역할 둥글기 **2px** 는 토큰(8 · 1440) 밖 — 2번에서 함께 바뀜. + +## 덧 + +- EasyCalc 시작 화면 「내가 만든 것」 줄이 **53 개** — 여러 창의 시험 저장이 `로직_자체.json`(공유 storage)에 남은 것으로 보임(점검 범위 밖 · 사실만). +- 측정 주의 — 보기 바꿈처럼 class 를 바꾼 직후 `getComputedStyle` 은 0.12 초 전환 중 값을 줌 → 전환을 끈 뒤 잼. From 84c2b968ec74e3912c079d7ee7ccdccb09909d58 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 15:58:33 +0900 Subject: [PATCH 9/9] =?UTF-8?q?chore(M01):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=ED=8C=90=20=EC=A0=95=ED=95=A8=20=E2=80=94=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B8=B0=EC=A4=80=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/원가다이어그램_정함.md | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/원가다이어그램_정함.md diff --git a/docs/raw/M01_쉬운단가/원가다이어그램_정함.md b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md new file mode 100644 index 000000000..bc9680deb --- /dev/null +++ b/docs/raw/M01_쉬운단가/원가다이어그램_정함.md @@ -0,0 +1,40 @@ +# 단가산출 다이어그램 시험판 — 정함 (PLAN 35-7 · 브레인 · 10-01 오후) + +사용자 기준(10-01 14시 무렵 · 요약): 다이어그램은 요소 종류로 칸을 나누지 않고 자유 배치 · 단가산출 로직 = 최종 원가를 내는 로직 · 상자는 입력 · 함수 · 출력 · 입력 상자 = M01 저장 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율) · 입력 상자를 누르면 그 정보 — M01 그룹 내용 · 요소 고르기 · M01 표 「추가」 화면을 다시 써서 추가 / 삭제 · 그룹 밖 정보(면적 · 길이 · 부피)는 변수 + 입력 정의 · 함수 상자도 변수 + 입력 정의 · 함수 상자는 받은 입력을 엑셀로 셈해 결과를 다음으로 · 함수 상자 하나 = 함수 하나(연속 연산 가능 · 출력은 하나) · 함수 상자 직렬 · 병렬 · 모든 상자 오른쪽 위에 단위(무차원 · 글 · 어려우면 「-」) · 출력 상자는 입력 · 함수 상자에 이어지고 어떤 비용인지(노무비 · 재료비 · 경비 …) 정함 · 품셈 분석보다 사용자와 브레인이 같이 쓸 프로그램이 먼저 · 상자 색 지정 · 엑셀 · 다이어그램 영역 더 높게 · 「상자와 선」 이름은 「다이어그램」 · 별도 단추로 시험판 페이지 · M02 폴더 · 기존 시험판과 다른 폴더 · 화면에 보일 임시 파일도 같은 폴더. + +## 기준 점검 — 빠진 곳 · 두 뜻인 곳과 브레인 정함(사용자가 바꿀 수 있음) + +1. **요율이 입력 그룹에 없음** — M01 에는 요율(19줄 · 공구손료 등 비율)이 있음 → 입력 그룹에 넣음. +2. **유가/전력 · 품셈재료** — M01 에서는 재료 그룹 안의 파일 → 입력 그룹에서 유가/전력은 따로 · 품셈재료는 재료 안. +3. **소요량 · 계수는 조건 표** — 요소 하나가 아니라 「조건 칸 → 값 칸」 표 → 입력 상자는 표(또는 고른 줄 하나)를 내보내고, 조건에 맞는 줄 찾기는 함수 상자의 엑셀 수식(XLOOKUP · INDEX/MATCH)이 함. +4. **함수 상자 「함수 하나 · 연속 연산 · 출력 하나」** — 상자 안에서는 여러 칸으로 이어 셈해도 되고, 밖으로 나가는 출력은 한 칸만. +5. **비용이 아닌 출력** — 시간당 작업량처럼 다른 로직이 받을 값도 있음 → 출력 종류에 「값(비용 아님)」 더함. +6. **최종 원가 합계** — 출력 상자들을 비목별로 모아 원가(노무비 · 재료비 · 경비 · 계 · 기준단위당)를 보이는 칸을 둠. +7. **「추가 / 삭제」 뜻** — 입력 상자에 넣을 요소를 M01 그룹 화면에서 골라 넣고 빼는 것. 그룹에 없는 새 요소는 M01 표 「추가」 화면 꼴로 만들되 마스터에 쓰지 않고 시험 폴더에 「사용자 요소」로 저장. +8. **단위** — 상자 오른쪽 위 = 그 상자 출력의 단위 · 입력 상자는 요소 단위를 따름(요소가 여럿이면 알약마다 · 상자 표시는 「-」) · 함수 · 출력 상자는 사용자가 정함(기본 「-」). +9. **색** — 종류별 기본 색(입력 · 함수 · 출력이 서로 다름) + 상자마다 색 바꾸기. +10. **들어가는 곳** — 별도 단추 「단가산출 다이어그램(시험)」 = M01 단가산출 막대의 다른 시험판 단추 옆 · 누르면 화면 전체를 덮는 시험판 페이지(닫기로 돌아감) — 길잡이(router)는 안 고침. +11. **폴더** — `M02_MasterTemplete/CostFlow/` · 저장 · 임시 파일 = 그 안 `_data/`(git 밖) · 화면에 처음 보일 견본 로직 = 그 안 `_sample/` · 폴더째 지우면 끝 · SheetLogic · LogicBuilder 폴더에 기대지 않음(필요한 코드는 새 폴더로 옮겨 고쳐 씀 · 엔진 · ui_template · M01 Api 는 가져다 씀). +12. **다이어그램** — 상자는 끌어 자유 배치(자리 저장) · 판 끌기 · 확대 / 축소 · 선은 굽은 선 · 고른 상자와 이어진 선만 진하게 · 「다이어그램」 이름 · 높이 크게(화면 위쪽 큰 영역) · 아래 고른 상자 판도 크게(안쪽 스크롤 없이). +13. **우선** — 품셈 1,469 로직 옮기기 · 분석은 안 함 · 사용자와 브레인이 같이 쓰는 프로그램 먼저. + +## 상자 · 선 약속 + +- 입력 상자: 갈래 = 그룹(인력 · 재료 · 유가/전력 · 기계 · 소요량 · 계수 · 환율 · 요율) 또는 변수 · 그룹이면 고른 요소마다 출력 알약(이름 · 값 · 단위) · 소요량 / 계수면 표(또는 한 줄) 알약 · 변수면 변수마다 알약(이름 · 갈래 숫자 / 선택 / 글 · 단위 · 범위 · 예시값). +- 함수 상자: 시트 하나 · 입력 알약(받는 칸) 여럿 · 변수(입력 정의) · **출력 알약 하나** · 단위. +- 출력 상자: 받는 자리 하나 · 비용 종류(노무비 · 재료비 · 경비 · 값) · 단위. +- 선: 입력 → 함수 · 입력 → 출력 · 함수 → 함수(직렬) · 여러 함수 → 한 함수(병렬 모음) · 함수 → 출력. 출력에서 나가는 선 없음 · 순환 막음. +- 계산: 우리 스프레드시트 엔진 한 벌 · 화면은 브라우저 · 저장은 서버가 같은 엔진 Node 로 다시 셈 · 입력 값(노임 · 단가 · 표)은 열 때 · 저장 때 마스터에서. + +## 파일 · 창 (`M02_MasterTemplete/CostFlow/` · 700줄 안) + +| 파일 | 창 | +| --- | --- | +| `M02_CF_Model.ts` · `M02_CF_Eval.ts` | sub7 — 타입 먼저 | +| `M02_CF_Router.py` · `M02_CF_Api.ts` · `.gitignore` | sub4 | +| `M02_CF_Diagram.ts` + css | sub8 | +| `M02_CF_FuncPanel.ts` + css | sub2 | +| `M02_CF_InputPanel.ts` + css | sub3 | +| `M02_CF_OutputPanel.ts` · `M02_CF_Summary.ts` + css | sub5 | +| `_sample/*.json` · `M02_CF_Sample.ts` | sub6 | +| `M02_CF_Entry.ts` + css | sub1 — 단추 · 시험판 페이지 · 잇기 · 끝 검증 |