diff --git a/M01_MasterData/M01_MasterData_UI_Head.css b/M01_MasterData/M01_MasterData_UI_Head.css new file mode 100644 index 000000000..66c1e41c3 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Head.css @@ -0,0 +1,18 @@ +.m01-head { + display: flex; + align-items: center; + gap: var(--spacing-8); + flex-wrap: wrap; +} + +.m01-head__title { + margin: 0 auto 0 0; + color: var(--color-text); + font-size: 18px; +} + +.m01-head__filters { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} diff --git a/M01_MasterData/M01_MasterData_UI_Head.ts b/M01_MasterData/M01_MasterData_UI_Head.ts new file mode 100644 index 000000000..9587effa7 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Head.ts @@ -0,0 +1,36 @@ +/* ============================================================================= + * M01_MasterData_UI_Head.ts + * 마스터 화면 머리 공용 조각 — 제목 줄(제목 왼쪽 · 단추 오른쪽) + 찾기 줄. + * 인력 · 재료 · 유가·전력 · 기계 · 소요량 · 계수 · 환율 · 요율 상세와 「단가산출 로직」 화면이 같은 조각을 써 + * 제목 크기 · 자리 · 단추 차례 · 공용 단추 크기 · 찾기 칸 꼴이 한 벌로 같음. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import "./M01_MasterData_UI_Head.css"; + +export interface HeadOptions { + /** 제목 앞에 놓을 것(예: ← 목록 단추) */ + lead?: HTMLElement[]; + /** 제목 — 글이면 h2 를 만듦 */ + title: HTMLElement | string; + /** 제목 바로 뒤 · 단추 앞 알림(고친 개수 등) */ + status?: HTMLElement[]; + /** 오른쪽 단추 — 차례 그대로(공용 단추 · 크기를 덮지 않음) */ + buttons: HTMLElement[]; +} + +/** 제목 줄 — 제목은 18px · 왼쪽 · 남는 자리를 차지해 단추를 오른쪽으로 밂 */ +export function buildHeadBar(opts: HeadOptions): { root: HTMLElement; title: HTMLElement } { + const title = typeof opts.title === "string" ? el("h2", { text: opts.title }) : opts.title; + title.classList.add("m01-head__title"); + const root = el("div", { + className: "m01-head", + children: [...(opts.lead ?? []), title, ...(opts.status ?? []), ...opts.buttons], + }); + return { root, title }; +} + +/** 찾기 줄 — 찾기 칸 · 거름 · 쪽 넘기기를 위에서 아래로 쌓음 */ +export function buildHeadFilters(children: HTMLElement[]): HTMLElement { + return el("div", { className: "m01-head__filters", children }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 3f29328c9..8f20784f8 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -52,6 +52,7 @@ import { type LogicFlow, } from "./M01_MasterData_UI_LogicLab_Flow"; import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; +import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; import "./M01_MasterData_UI_Logic_Style.css"; @@ -539,19 +540,19 @@ export async function mountM01LogicLab( }) : showToast(tl("Copy_NeedOpen"), "info"), }); - const bar = el("div", { - className: "m01-logic__bar", - children: [ - back, - el("h2", { text: tx("Title") }), - pending, + // 제목 줄 = 요소 화면(인력 · 재료 …)과 같은 공용 조각 + const bar = buildHeadBar({ + lead: [back], + title: tx("Title"), + status: [pending], + buttons: [ createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }), copyButton, createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }), discardButton, saveButton, ], - }); + }).root; host.replaceChildren( el("div", { className: "m01-logic", diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index 334592c0e..d22b7ecde 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_Logic_List.ts - * 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기 + 막힘만 + * 로직 목록 — 가운데 표 · 왼쪽 「전체」 + 구분 → 상세구분 거름이 고른 범위 + 이름 찾기 * 파일을 가로질러 한 목록 · 쪽 나눔 · 막힘(⛔)·고침(●) 표시 · 거르기는 화면에서(목록은 한 번 받음) * ========================================================================== */ @@ -8,6 +8,7 @@ import { createInputField, el } from "@ui/ui_template_elements"; import { buildPager } from "./M01_MasterData_UI_Cells"; import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; +import "./M01_MasterData_UI_Head.css"; export type ListMark = "edited" | "new" | "deleted"; @@ -47,7 +48,6 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { const pick = { sub: "", detail: "" }; const searchField = createInputField({ type: "search", placeholder: tx("List_Search") }); const search = searchField.input; - const blockedOnly = el("input", { attrs: { type: "checkbox" } }); const grid = el("div", { className: "m01-master__grid-wrap" }); const draw = (): void => { @@ -56,7 +56,6 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { (x) => (!pick.sub || x.sub === pick.sub) && (!pick.detail || x.detail === pick.detail) && - (!blockedOnly.checked || x.blocked) && (!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))), ); page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE))); @@ -107,22 +106,14 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { }; search.addEventListener("input", () => go(1)); - blockedOnly.addEventListener("change", () => go(1)); let pager = buildPager(0, SIZE, 1, go); const root = el("div", { className: "m01-logic__listview", children: [ el("div", { - className: "m01-logic__filters", - children: [ - searchField.root, - el("label", { - className: "m01-logic__check", - children: [blockedOnly, el("span", { text: tx("List_BlockedOnly") })], - }), - pager, - ], + className: "m01-head__filters", + children: [searchField.root, pager], }), grid, ], @@ -151,6 +142,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { }, setPick: (sub, detail) => { Object.assign(pick, { sub, detail }); + if (!sub && !detail) search.value = ""; // 「전체」 — 찾는 글까지 비움 go(1); }, total: () => items.length, diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 05e519d70..6b0b3a9e6 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -23,6 +23,7 @@ import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; +import { buildHeadBar, buildHeadFilters } from "./M01_MasterData_UI_Head"; import { renderRows } from "./M01_MasterData_UI_Rows"; import { buildSide, @@ -31,6 +32,7 @@ import { saveView, setMasterJump, setPendingLogicKey, + hasPendingLogicKey, takePendingLogicKey, type MasterJump, type Pick, @@ -61,7 +63,7 @@ function buildPage(): HTMLElement { let logicMounted = false; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ - const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); + const title = el("h2", { text: L("M01_PickFile") }); const searchField = createInputField({ type: "search", placeholder: L("M01_Search") }); const search = searchField.input; const summary = el("span", { className: "m01-master__summary" }); @@ -69,9 +71,11 @@ function buildPage(): HTMLElement { const drop = createButton({ label: L("M01_Discard"), variant: "ghost" }); const notice = el("div", { className: "m01-master__notice", attrs: { hidden: "" } }); const body = el("div", { className: "m01-master__body" }); + // 제목 줄 · 찾기 줄 = 「단가산출 로직」 화면과 같은 공용 조각 + const headBar = buildHeadBar({ title, status: [summary], buttons: [drop, save] }); const head = el("div", { - className: "m01-master__head", - children: [title, searchField.root, summary, drop, save], + className: "m01-master__headbox", + children: [headBar.root, buildHeadFilters([searchField.root])], }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { @@ -269,5 +273,7 @@ function buildPage(): HTMLElement { el("div", { className: "ui-workflow-layout__body", children: [main] }), overlays.root, ); + // 다른 화면(일위대가 조합 다이어그램 등)이 로직 키를 들고 왔으면 로직 탭으로 바로 엶 + if (hasPendingLogicKey()) openLogicTab(); return layout; } diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 1ac821ead..0f9e752f0 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -7,7 +7,7 @@ import { createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { t as L } from "@ui/ui_template_locale"; +import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; @@ -48,6 +48,15 @@ export function setPendingLogicKey(key: string): void { } } +/** 넘어온 키가 있나 — 페이지가 열릴 때 로직 탭으로 바로 가는지 정함(꺼내지는 않음) */ +export function hasPendingLogicKey(): boolean { + try { + return !!sessionStorage.getItem(PENDING_LOGIC_KEY); + } catch { + return false; + } +} + export function takePendingLogicKey(): string | null { try { const key = sessionStorage.getItem(PENDING_LOGIC_KEY); @@ -116,11 +125,13 @@ export interface FilterArgs { restore?: boolean; } -const MATERIAL_NAMES: Record = { 유가전력: "유가·전력" }; -/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */ +/** 유가 · 전력 — 서버로는 재료 그룹의 한 파일(재료_유가전력.json) · 왼쪽 패널에서는 재료 다음 따로 한 컨테이너 */ const FUEL = "유가전력"; +const FUEL_GROUP = "유가 · 전력"; +const FUEL_TITLE = ["유가 · 전력", "Fuel · power"] as const; const MATERIAL = "자재품목"; -const MATERIAL_ORDER = ["자재품목", "유가전력", "품셈재료"]; +/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */ +const MATERIAL_ORDER = ["자재품목", "품셈재료"]; /** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */ export const fileLabel = (file: string): string => @@ -136,8 +147,11 @@ const GROUP_TITLE = { 요율: "M01_GroupRate", } as const; -type Group = keyof typeof GROUP_TITLE; -const GROUPS = Object.keys(GROUP_TITLE) as Group[]; +type Group = keyof typeof GROUP_TITLE | typeof FUEL_GROUP; +const GROUPS: Group[] = ["인력", "재료", FUEL_GROUP, "기계", "소요량", "계수", "환율", "요율"]; +const titleOf = (group: Group): string => + group === FUEL_GROUP ? FUEL_TITLE[currentLanguageIndex === 1 ? 1 : 0] : L(GROUP_TITLE[group]); +const isFuel = (file: FileInfo): boolean => fileLabel(file.file) === FUEL; const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; @@ -149,7 +163,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si const buttons = new Map(); // 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로 const kinds = new Map(); - /** 재료 그룹 안 파일별 구분 목록(자재품목·유가전력 각자) — file 이름이 열쇠 */ + /** 재료 그룹 안 파일별 구분 목록(자재품목 · 유가전력 각자) — file 이름이 열쇠 */ const materialKinds = new Map(); const states = new Map(); const runs = new Map void>(); @@ -239,6 +253,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si /* 기억 못 해도 거름은 됨 */ } setActive(a.id); + if (a.id === LOGIC_ID) onLogic(); // 로직 거름(「전체」 · 구분) — 가운데를 로직 목록으로 lastRun.set(a.id.split(/[|\n]/)[0] as Group, a.id); a.onPick( sub.select.value, @@ -294,8 +309,15 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si return i < 0 ? MATERIAL_ORDER.length : i; }; + /** 컨테이너에 보일 파일 — 재료는 유가 · 전력 파일을 빼고 · 유가 · 전력은 그 파일 하나(둘 다 서버 재료 그룹) */ + const shown = (group: Group): FileInfo[] => { + if (group === FUEL_GROUP) return (lists.get("재료") ?? []).filter(isFuel); + const list = lists.get(group) ?? []; + return group === "재료" ? list.filter((f) => !isFuel(f)) : list; + }; + const drawGroup = (group: Group): void => { - const files = lists.get(group) ?? []; + const files = shown(group); const body = bodies.get(group); if (!body) return; const open = (file: FileInfo, sub: string, label: string, detail = "", state = ""): void => @@ -315,6 +337,20 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si 소요량: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, 계수: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, }; + if (group === FUEL_GROUP && one) { + body.replaceChildren( + filter({ + id: `${group}|`, + store: `m01.filter.재료.${FUEL}`, + subs: materialKinds.get(one.file) ?? [], + total: one.rows, + subLabel: L("M01_FuelSub"), + detailLabel: L("M01_FuelDetail"), + onPick: (sub, detail, label) => open(one, sub, label, detail), + }), + ); + return; + } const drop = dropped[group]; if (drop && one) { body.replaceChildren( @@ -337,8 +373,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si .sort((a, b) => order(a.file) - order(b.file)) .flatMap((f) => { const name = fileLabel(f.file); - const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name; - if (group === "재료" && (name === FUEL || name === MATERIAL)) + const label = name; + if (group === "재료" && name === MATERIAL) return [ filter({ id: `${group}\n${f.file}`, @@ -346,8 +382,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si subs: materialKinds.get(f.file) ?? [], total: f.rows, title: label, - subLabel: name === FUEL ? L("M01_FuelSub") : L("M01_LaborSub"), - detailLabel: name === FUEL ? L("M01_FuelDetail") : L("M01_LaborDetail"), + subLabel: L("M01_LaborSub"), + detailLabel: L("M01_LaborDetail"), onPick: (sub, detail, tail) => open(f, sub, sub || detail ? `${label} · ${tail}` : label, detail), }), @@ -370,7 +406,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si /** 처음 펼칠 때 열 줄 — 재료는 차례 첫 파일(자재품목) */ const firstRun = (group: Group): string => { if (group !== "재료") return `${group}|`; - const first = [...(lists.get(group) ?? [])].sort((a, b) => order(a.file) - order(b.file))[0]; + const first = [...shown(group)].sort((a, b) => order(a.file) - order(b.file))[0]; return first ? `${group} ${first.file}` @@ -378,6 +414,8 @@ ${first.file}` }; const refresh = async (group: string): Promise => { + // 유가 · 전력 컨테이너는 서버 재료 그룹을 받아 그 파일만 + if (group === FUEL_GROUP) return (await refresh("재료")).filter(isFuel); const list = await fetchFiles(group); if (list[0] && (group === "인력" || group === "기계")) { const got = await fetchSubsAll({ file: list[0].file }); @@ -394,6 +432,7 @@ ${first.file}` } lists.set(group as Group, list); drawGroup(group as Group); + if (group === "재료") drawGroup(FUEL_GROUP); return list; }; @@ -411,7 +450,7 @@ ${first.file}` ? null : el("div", { className: "m01-side__items ui-sidebar-list" }); if (body) bodies.set(group, body); - const root = section(L(GROUP_TITLE[group]), body); + const root = section(titleOf(group), body); if (!body) { root.classList.add("m01-side__leaf"); buttons.set(`${group}\n`, root); @@ -419,10 +458,10 @@ ${first.file}` const one = lists.get(group)?.[0]; if (!one) return; setActive(`${group}\n`); - onOpen({ group, file: one, sub: "", detail: "", label: L(GROUP_TITLE[group]) }); + onOpen({ group, file: one, sub: "", detail: "", label: titleOf(group) }); }); } - if (FILTERED.includes(group) || group === "재료") { + if (FILTERED.includes(group) || group === "재료" || group === FUEL_GROUP) { root.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { if (!root.classList.contains("is-collapsed")) return; // 닫을 때는 그대로 둠 restoring = true; @@ -449,12 +488,14 @@ ${first.file}` children: [...groups, logic], }); attachCollapsible(root); - void Promise.all(GROUPS.map(refresh)).catch((error) => + void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) => showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), ); const setOnPicked = (fn: () => void): void => { onPicked = fn; }; - const files = (group: string): FileInfo[] => lists.get(group as Group) ?? []; + // 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만 + const files = (group: string): FileInfo[] => + group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []); return { root, logicHost, refresh, files, setActive, setOnPicked, filter }; } diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index f2bd7a239..20950050a 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -32,7 +32,6 @@ padding: var(--spacing-16) var(--spacing-24); } -.m01-master__head, .m01-master__bar, .m01-master__filter { display: flex; @@ -41,11 +40,11 @@ gap: var(--spacing-12); } -.m01-master__title { - flex: 1 1 auto; - margin: 0; - color: var(--color-text); - font-size: var(--text-body); +/* 머리 상자 — 제목 줄(공용 Head) 아래 찾기 줄 */ +.m01-master__headbox { + display: flex; + flex-direction: column; + gap: var(--spacing-8); } .m01-master__summary { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css new file mode 100644 index 000000000..fa21deb41 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css @@ -0,0 +1,311 @@ +.m02-labordg { + --dg-노무비: #2f9e8f; + --dg-재료비: #d98a1d; + --dg-경비: #8a63d2; + --dg-값: #7a8699; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body-sm); + color: var(--color-text-body); +} + +.m02-labordg__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-labordg__zoom { + min-width: 3em; + font-weight: var(--font-weight-bold); +} + +.m02-labordg__hint { + color: var(--color-text-muted); +} + +.m02-labordg__viewport { + position: relative; + height: clamp(320px, 62vh, 640px); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + overflow: hidden; + cursor: default; +} + +.m02-labordg__viewport.is-space, +.m02-labordg__viewport.is-panning { + cursor: grab; +} + +.m02-labordg__viewport.is-panning { + cursor: grabbing; +} + +.m02-labordg__world { + position: absolute; + top: 0; + left: 0; + width: max-content; + transform-origin: 0 0; +} + +.m02-labordg__lines { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.m02-labordg__grid { + position: relative; + display: grid; + column-gap: 0; + row-gap: 0; + padding: var(--spacing-16); +} + +.m02-labordg__head { + padding: var(--spacing-4) var(--spacing-8); + font-weight: var(--font-weight-bold); +} + +.m02-labordg__head.is-bin, +.m02-labordg__cell { + border-left: 2px solid var(--dg-노무비); + border-right: 2px solid var(--dg-노무비); +} + +.m02-labordg__head.is-bin { + border-top: 2px solid var(--dg-노무비); + border-radius: var(--radius-cards) var(--radius-cards) 0 0; + text-align: center; + background: color-mix(in srgb, var(--dg-노무비) 22%, var(--color-surface)); +} + +.m02-labordg__cell { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 4rem; + padding: var(--spacing-4) var(--spacing-8); + background: color-mix(in srgb, var(--dg-노무비) 8%, var(--color-surface)); + border-bottom: 1px solid var(--color-border); +} + +.m02-labordg__cell.is-total { + border-bottom: 2px solid var(--dg-노무비); + border-radius: 0 0 var(--radius-cards) var(--radius-cards); + font-weight: var(--font-weight-bold); +} + +.m02-labordg__cell.is-zero { + opacity: 0.45; +} + +.m02-labordg__cell-name { + color: var(--color-text-secondary); +} + +.m02-labordg__cell-amount { + font-weight: var(--font-weight-bold); +} + +.m02-labordg__head.is-재료비, +.m02-labordg__cell.is-재료비 { + border-color: var(--dg-재료비); +} + +.m02-labordg__head.is-재료비 { + background: color-mix(in srgb, var(--dg-재료비) 22%, var(--color-surface)); +} + +.m02-labordg__cell.is-재료비 { + background: color-mix(in srgb, var(--dg-재료비) 8%, var(--color-surface)); +} + +.m02-labordg__head.is-경비, +.m02-labordg__cell.is-경비 { + border-color: var(--dg-경비); +} + +.m02-labordg__head.is-경비 { + background: color-mix(in srgb, var(--dg-경비) 22%, var(--color-surface)); +} + +.m02-labordg__cell.is-경비 { + background: color-mix(in srgb, var(--dg-경비) 8%, var(--color-surface)); +} + +.m02-labordg__head.is-값, +.m02-labordg__cell.is-값 { + border-color: var(--dg-값); +} + +.m02-labordg__head.is-값 { + background: color-mix(in srgb, var(--dg-값) 22%, var(--color-surface)); +} + +.m02-labordg__cell.is-값 { + background: color-mix(in srgb, var(--dg-값) 8%, var(--color-surface)); +} + +/* 넣을 값 묶음 */ +.m02-labordg__group { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: var(--spacing-4); + padding: var(--spacing-8); + min-width: 13rem; +} + +.m02-labordg__pill { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-labordg__pill.is-empty { + border: 2px dashed var(--color-danger); +} + +.m02-labordg__pill.is-empty .m02-labordg__pill-value { + color: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +.m02-labordg__pill-name { + font-weight: var(--font-weight-medium); +} + +.m02-labordg__pill:focus-visible, +.m02-labordg__card:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m02-labordg__pill.is-hot { + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 18%, var(--color-surface)); +} + +/* 로직 카드 — 둥근 카드 */ +.m02-labordg__card { + display: flex; + flex-direction: column; + align-self: center; + gap: 2px; + margin: var(--spacing-8) 0; + padding: var(--spacing-8) var(--spacing-12); + border: 2px solid var(--color-border); + border-radius: 1.2rem; + background: var(--color-surface-raised); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-labordg__card:hover { + border-color: var(--color-primary); +} + +.m02-labordg__card.is-stop { + border-style: dashed; + border-color: var(--color-danger); +} + +.m02-labordg__card-key { + color: var(--color-text-muted); +} + +.m02-labordg__card-name { + font-size: var(--text-body); +} + +.m02-labordg__card-unit { + color: var(--color-text-secondary); +} + +.m02-labordg__card-out { + margin-top: 2px; + font-weight: var(--font-weight-bold); +} + +/* 선 */ +.m02-labordg__wire { + fill: none; + stroke: var(--color-border); + stroke-width: 2; +} + +.m02-labordg__out { + fill: none; + stroke: var(--dg-노무비); + stroke-width: 2.5; +} + +.m02-labordg__out.is-재료비 { + stroke: var(--dg-재료비); +} + +.m02-labordg__out.is-경비 { + stroke: var(--dg-경비); +} + +.m02-labordg__out.is-값 { + stroke: var(--dg-값); +} + +.m02-labordg__out.is-zero { + opacity: 0.2; +} + +.m02-labordg__same { + fill: none; + stroke: var(--color-text-muted); + stroke-width: 1.5; + stroke-dasharray: 4 4; + opacity: 0.55; +} + +.m02-labordg__same.is-hot { + stroke: var(--color-primary); + stroke-width: 2.5; + opacity: 1; +} + +.m02-labordg__none { + margin: var(--spacing-16); + color: var(--color-text-muted); +} + +/* 다이어그램에서 눌러 찾아온 미리 보기 입력 칸 */ +.m02-labor__flash { + outline: 3px solid var(--color-primary); + outline-offset: 2px; + animation: m02-labor-flash 0.4s ease-in-out 3; +} + +@keyframes m02-labor-flash { + 50% { + outline-color: transparent; + } +} + +.m02-labor__dg > summary { + cursor: pointer; + font-weight: var(--font-weight-bold); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts new file mode 100644 index 000000000..0348ae7b4 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -0,0 +1,490 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Diagram.ts + * 일위대가 조합 다이어그램(PLAN 37) — 왼쪽 로직별 넣을 값 묶음 → 가운데 로직 카드 → 오른쪽 비용 구분 칸(노무비 · 재료비 · 경비 · 값) + * 조합은 계산하지 않음 — 미리 보기 결과를 로직마다 · 비용 구분마다 보여 주기만(고치기 없음 · 흐름도와 다른 모양 · 색) + * 값은 미리 보기와 한 벌(`buildDiagramData` 로 미리 보기가 넘김) · 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import "./M02_MasterTemplete_Labor_Diagram.css"; + +const TEXT = { + Inputs: ["넣을 값", "Inputs"], + Logic: ["일위대가", "Unit price"], + Sum: ["모은 값", "Total"], + Empty: ["값 없음", "no value"], + Wait: ["계산 전", "not calculated"], + Stop: ["멈춤", "stopped"], + Zoom_In: ["크게", "Zoom in"], + Zoom_Out: ["작게", "Zoom out"], + Fit: ["한눈에", "Fit"], + Hint: [ + "가운데 단추 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대", + "Middle button or Space + drag to move · Ctrl + wheel to zoom", + ], + None: ["담은 로직이 없음", "No logics yet"], + Open: [ + "누르면 이 로직을 단가산출 로직 화면에서 엶", + "Open this logic in the unit-price logic screen", + ], + Goto: ["미리 보기 입력 칸으로 감", "Go to this input in the preview"], + Same: ["같은 이름의 넣을 값", "Same input name"], + Won: ["원", "KRW"], + Title: ["다이어그램", "Diagram"], +} as const; + +const td = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +/** 상세의 접는 칸 제목 */ +export const diagramTitle = (): string => td("Title"); + +export const COST_BINS = ["노무비", "재료비", "경비"] as const; +type Cost = (typeof COST_BINS)[number]; + +export interface DiagramInput { + name: string; + unit: string; + /** 미리 보기에 넣은 값 — 빈 글 = 값 없음 */ + value: string; +} + +/** 로직 하나의 미리 보기 결과 — 돈이면 비용 구분별 · 값이면 값 하나 · 멈추면 까닭 · 아직이면 null */ +export type DiagramOut = + | { 노무비: number; 재료비: number; 경비: number; 계: number } + | { 값: number } + | { 멈춤: string } + | null; + +export interface DiagramLogic { + key: string; + name: string; + /** 결과 단위 */ + unit: string; + inputs: DiagramInput[]; + out: DiagramOut; +} + +export interface DiagramData { + logics: DiagramLogic[]; +} + +export interface DiagramHandlers { + /** 로직 카드 누름 — 단가산출 로직 화면에서 엶 */ + openLogic(key: string): void; + /** 넣을 값 알약 누름 — 미리 보기 그 입력 칸으로 */ + focusInput(key: string, name: string): void; +} + +/* ── 값 만들기 (미리 보기가 부름 · DOM 없음) ──────────────────────────────── */ + +interface PreviewLineLike { + ok: boolean; + 까닭?: string; + 갈래?: string; + 결과?: number; + 노무비?: number; + 재료비?: number; + 경비?: number; + 계?: number; +} + +export interface DiagramSource { + /** 담은 차례대로 */ + rows: { key: string; name: string; unit: string; inputs: { name: string; unit: string }[] }[]; + /** 로직 키 → 입력 이름 → 넣은 값 */ + values: Record>; + /** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 null */ + lines: PreviewLineLike[] | null; +} + +const outOf = (l: PreviewLineLike | undefined): DiagramOut => { + if (!l) return null; + if (!l.ok) return { 멈춤: l.까닭 ?? "" }; + if (l.갈래 === "결과") return { 값: l.결과 ?? 0 }; + return { 노무비: l.노무비 ?? 0, 재료비: l.재료비 ?? 0, 경비: l.경비 ?? 0, 계: l.계 ?? 0 }; +}; + +export function buildDiagramData(src: DiagramSource): DiagramData { + return { + logics: src.rows.map((r, i) => ({ + key: r.key, + name: r.name, + unit: r.unit, + inputs: r.inputs.map((x) => ({ + name: x.name, + unit: x.unit, + value: src.values[r.key]?.[x.name] ?? "", + })), + out: outOf(src.lines?.[i]), + })), + }; +} + +/* ── 글 ─────────────────────────────────────────────────────────────────── */ + +const num = (v: number): string => + Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +const isMoney = (o: DiagramOut): o is Extract => !!o && "계" in o; +const isValue = (o: DiagramOut): o is { 값: number } => !!o && "값" in o; +const isStop = (o: DiagramOut): o is { 멈춤: string } => !!o && "멈춤" in o; + +const amountOf = (o: DiagramOut, c: Cost): number => (isMoney(o) ? o[c] : 0); + +function summary(l: DiagramLogic): string { + if (isMoney(l.out)) return `${td("Sum")} ${formatMoney(l.out.계)} ${td("Won")}`; + if (isValue(l.out)) return `${num(l.out.값)} ${l.unit}`.trim(); + if (isStop(l.out)) return `${td("Stop")}: ${l.out.멈춤}`; + return td("Wait"); +} + +/* ── 판 (옮기기 · 확대 · 한눈에) ─────────────────────────────────────────── */ + +interface View { + x: number; + y: number; + zoom: number; + fitted: boolean; +} + +interface State { + view: View; + viewport: HTMLElement; + world: HTMLElement; + label: HTMLElement; + space: boolean; + hover: boolean; +} + +const states = new WeakMap(); +const ZOOM_MIN = 0.2; +const ZOOM_MAX = 2.5; + +function applyView(st: State): void { + const { x, y, zoom } = st.view; + st.world.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`; + st.label.textContent = `${Math.round(zoom * 100)}%`; +} + +function zoomAt(st: State, factor: number, px?: number, py?: number): void { + const v = st.view; + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.zoom * factor)); + const cx = px ?? st.viewport.clientWidth / 2; + const cy = py ?? st.viewport.clientHeight / 2; + v.x = cx - ((cx - v.x) / v.zoom) * next; + v.y = cy - ((cy - v.y) / v.zoom) * next; + v.zoom = next; + applyView(st); +} + +/** whole = 한눈에(통째로 보임) · 아니면 처음 — 너비에 맞추고 위에서 시작(글자가 너무 작아지지 않게) */ +function fit(st: State, whole = true): void { + const w = st.world.offsetWidth; + const h = st.world.offsetHeight; + if (!w || !h) return; + const z = Math.min( + ZOOM_MAX, + Math.max( + whole ? ZOOM_MIN : 0.5, + Math.min(st.viewport.clientWidth / w, whole ? st.viewport.clientHeight / h : Infinity, 1), + ), + ); + st.view = { + zoom: z, + x: (st.viewport.clientWidth - w * z) / 2, + y: whole ? Math.max(0, (st.viewport.clientHeight - h * z) / 2) : 0, + fitted: true, + }; + applyView(st); +} + +function mountShell(host: HTMLElement): State { + const world = el("div", { className: "m02-labordg__world" }); + const viewport = el("div", { + className: "m02-labordg__viewport", + attrs: { tabindex: "0" }, + children: [world], + }); + const label = el("span", { className: "m02-labordg__zoom" }); + const st: State = { + view: { x: 0, y: 0, zoom: 1, fitted: false }, + viewport, + world, + label, + space: false, + hover: false, + }; + host.replaceChildren( + el("div", { + className: "m02-labordg__bar", + children: [ + createButton({ + label: td("Zoom_Out"), + variant: "ghost", + size: "sm", + onClick: () => zoomAt(st, 1 / 1.2), + }), + createButton({ + label: td("Zoom_In"), + variant: "ghost", + size: "sm", + onClick: () => zoomAt(st, 1.2), + }), + createButton({ + label: td("Fit"), + variant: "ghost", + size: "sm", + onClick: () => fit(st, true), + }), + label, + el("span", { className: "m02-labordg__hint", text: td("Hint") }), + ], + }), + viewport, + ); + // 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기 + viewport.addEventListener("pointerdown", (e) => { + if (!(e.button === 1 || (e.button === 0 && st.space))) return; + e.preventDefault(); + try { + viewport.setPointerCapture(e.pointerId); + } catch { + /* 잡지 못해도 옮기기는 됨 */ + } + const from = { x: e.clientX - st.view.x, y: e.clientY - st.view.y }; + viewport.classList.add("is-panning"); + const move = (m: PointerEvent): void => { + st.view.x = m.clientX - from.x; + st.view.y = m.clientY - from.y; + applyView(st); + }; + const up = (): void => { + viewport.classList.remove("is-panning"); + viewport.removeEventListener("pointermove", move); + viewport.removeEventListener("pointerup", up); + viewport.removeEventListener("pointercancel", up); + }; + viewport.addEventListener("pointermove", move); + viewport.addEventListener("pointerup", up); + viewport.addEventListener("pointercancel", up); + }); + viewport.addEventListener( + "wheel", + (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + const r = viewport.getBoundingClientRect(); + zoomAt(st, e.deltaY < 0 ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top); + }, + { passive: false }, + ); + new ResizeObserver(() => { + if (!st.view.fitted && world.offsetWidth) fit(st, false); + }).observe(viewport); + viewport.addEventListener("pointerenter", () => (st.hover = true)); + viewport.addEventListener("pointerleave", () => (st.hover = false)); + const keys = (e: KeyboardEvent, on: boolean): void => { + if (e.code !== "Space" || !st.hover || !host.isConnected) return; + st.space = on; + viewport.classList.toggle("is-space", on); + e.preventDefault(); + }; + window.addEventListener("keydown", (e) => keys(e, true)); + window.addEventListener("keyup", (e) => keys(e, false)); + // 같은 이름 넣을 값 — 마우스를 올리면 같은 이름끼리 진하게 + const hot = (e: Event, on: boolean): void => { + const name = (e.target as HTMLElement).closest("[data-name]")?.dataset.name; + if (name === undefined) return; + for (const n of world.querySelectorAll(`[data-name]`)) + if ((n as HTMLElement).dataset.name === name) n.classList.toggle("is-hot", on); + }; + world.addEventListener("pointerover", (e) => hot(e, true)); + world.addEventListener("pointerout", (e) => hot(e, false)); + return st; +} + +/* ── 그리기 ─────────────────────────────────────────────────────────────── */ + +const SVG = "http://www.w3.org/2000/svg"; + +function path(d: string, cls: string, name?: string): SVGPathElement { + const p = document.createElementNS(SVG, "path"); + p.setAttribute("d", d); + p.setAttribute("class", cls); + if (name !== undefined) p.dataset.name = name; + return p; +} + +/** 다이어그램 그리기 — 칸은 고정 세 줄(넣을 값 · 로직 카드 · 비용 구분) · 자리는 저절로 */ +export function renderComboDiagram( + host: HTMLElement, + data: DiagramData, + on: DiagramHandlers, +): void { + host.classList.add("m02-labordg"); + let st = states.get(host); + if (!st || !st.viewport.isConnected) { + st = mountShell(host); + states.set(host, st); + } + const { world } = st; + if (!data.logics.length) { + world.replaceChildren(el("p", { className: "m02-labordg__none", text: td("None") })); + return; + } + const hasValue = data.logics.some((l) => isValue(l.out)); + const bins: (Cost | "값")[] = [...COST_BINS, ...(hasValue ? (["값"] as const) : [])]; + const grid = el("div", { className: "m02-labordg__grid" }); + grid.style.gridTemplateColumns = `max-content 4.5rem 15rem 5rem repeat(${bins.length}, 11rem)`; + const head = (text: string, col: number, cls = ""): HTMLElement => + el("div", { + className: `m02-labordg__head ${cls}`, + text, + attrs: { style: `grid-column:${col};grid-row:1` }, + }); + grid.append(head(td("Inputs"), 1), head(td("Logic"), 3)); + bins.forEach((b, i) => grid.append(head(b, 5 + i, `is-bin is-${b}`))); + + const sums = new Map(bins.map((b) => [b, 0])); + const cards: HTMLElement[] = []; + const groups: HTMLElement[] = []; + const cells: HTMLElement[][] = []; + data.logics.forEach((l, i) => { + const row = i + 2; + const group = el("div", { + className: "m02-labordg__group", + attrs: { style: `grid-column:1;grid-row:${row}` }, + children: l.inputs.length + ? l.inputs.map((x) => { + const empty = x.value.trim() === ""; + const pill = el("button", { + className: `m02-labordg__pill${empty ? " is-empty" : ""}`, + attrs: { type: "button", "data-name": x.name, title: td("Goto") }, + children: [ + el("span", { className: "m02-labordg__pill-name", text: x.name }), + el("span", { + className: "m02-labordg__pill-value", + text: empty ? td("Empty") : `${x.value}${x.unit ? ` ${x.unit}` : ""}`, + }), + ], + }); + pill.addEventListener("click", () => on.focusInput(l.key, x.name)); + return pill; + }) + : [], + }); + const card = el("button", { + className: `m02-labordg__card${isStop(l.out) ? " is-stop" : ""}`, + attrs: { type: "button", style: `grid-column:3;grid-row:${row}`, title: td("Open") }, + children: [ + el("span", { className: "m02-labordg__card-key", text: l.key }), + el("strong", { className: "m02-labordg__card-name", text: l.name }), + el("span", { className: "m02-labordg__card-unit", text: l.unit }), + el("span", { className: "m02-labordg__card-out", text: summary(l) }), + ], + }); + card.addEventListener("click", () => on.openLogic(l.key)); + const mine = bins.map((b, c) => { + const amount = b === "값" ? (isValue(l.out) ? l.out.값 : 0) : amountOf(l.out, b); + sums.set(b, (sums.get(b) ?? 0) + (b === "값" ? 0 : amount)); + const shown = + b === "값" + ? isValue(l.out) + ? `${num(l.out.값)} ${l.unit}`.trim() + : "" + : formatMoney(amount); + return el("div", { + className: `m02-labordg__cell is-bin is-${b}${amount === 0 ? " is-zero" : ""}`, + attrs: { style: `grid-column:${5 + c};grid-row:${row}` }, + children: [ + el("span", { className: "m02-labordg__cell-name", text: l.name }), + el("span", { className: "m02-labordg__cell-amount", text: shown }), + ], + }); + }); + groups.push(group); + cards.push(card); + cells.push(mine); + grid.append(group, card, ...mine); + }); + const total = data.logics.length + 2; + bins.forEach((b, i) => + grid.append( + el("div", { + className: `m02-labordg__cell is-bin is-${b} is-total`, + attrs: { style: `grid-column:${5 + i};grid-row:${total}` }, + children: [ + el("span", { className: "m02-labordg__cell-name", text: td("Sum") }), + el("span", { + className: "m02-labordg__cell-amount", + text: b === "값" ? "" : formatMoney(sums.get(b) ?? 0), + }), + ], + }), + ), + ); + + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("class", "m02-labordg__lines"); + world.replaceChildren(svg, grid); + + // 선 — 칸 자리를 재서 그림(판 확대와 상관없이 판 안 좌표) + const scale = st.view.zoom || 1; + const base = grid.getBoundingClientRect(); + const at = (e: Element): { l: number; r: number; t: number; b: number; m: number } => { + const r = e.getBoundingClientRect(); + const t = (r.top - base.top) / scale; + const h = r.height / scale; + return { + l: (r.left - base.left) / scale, + r: (r.right - base.left) / scale, + t, + b: t + h, + m: t + h / 2, + }; + }; + svg.setAttribute("width", String(grid.offsetWidth)); + svg.setAttribute("height", String(grid.offsetHeight)); + const curve = (x1: number, y1: number, x2: number, y2: number): string => { + const dx = (x2 - x1) / 2; + return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`; + }; + data.logics.forEach((_l, i) => { + const c = at(cards[i]); + const g = at(groups[i]); + svg.append(path(curve(g.r, c.m, c.l, c.m), "m02-labordg__wire")); + cells[i].forEach((cell, k) => { + const t = at(cell); + const zero = cell.classList.contains("is-zero"); + svg.append( + path(curve(c.r, c.m, t.l, t.m), `m02-labordg__out is-${bins[k]}${zero ? " is-zero" : ""}`), + ); + }); + }); + // 같은 이름 넣을 값 — 점선(검사 없음 · 보이기만) + const byName = new Map(); + grid.querySelectorAll(".m02-labordg__pill").forEach((p) => { + const name = (p as HTMLElement).dataset.name ?? ""; + byName.set(name, [...(byName.get(name) ?? []), p]); + }); + let lane = 0; + for (const [name, list] of byName) { + if (list.length < 2) continue; + lane++; + const rects = list.map(at); + const x = Math.min(...rects.map((r) => r.l)) - 8 - lane * 6; + for (let k = 1; k < rects.length; k++) { + const a = rects[k - 1]; + const b = rects[k]; + svg.append( + path(`M${a.l},${a.m} C${x},${a.m} ${x},${b.m} ${b.l},${b.m}`, "m02-labordg__same", name), + ); + } + } + svg.style.overflow = "visible"; + // 처음에는 너비에 맞춤 — 아직 안 보이는 칸(크기 0)이면 보이는 때 맞춤 + if (!st.view.fitted) fit(st, false); + else applyView(st); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index ce9fbb085..f09b181de 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -12,7 +12,10 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; +import { ROUTES } from "@config/config_frontend"; +import { navigateTo } from "../A00_Common/router"; import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { deleteCombo, editCombo, @@ -23,6 +26,7 @@ import { type ComboSummary, type LaborScope, } from "./M02_MasterTemplete_Labor_Api"; +import { diagramTitle, renderComboDiagram } from "./M02_MasterTemplete_Labor_Diagram"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -212,6 +216,30 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { return f.root; }; const preview = buildPreview(() => combo); + // 다이어그램 — 미리 보기와 값 한 벌(넣은 값 · 결과) · 보기만 · 로직 카드 누르면 단가산출 로직 화면 + const dgHost = el("div"); + const dgBox = el("details", { + className: "m01lab__box m01-logic__section m02-labor__dg", + attrs: { open: "" }, + children: [el("summary", { text: diagramTitle() }), dgHost], + }); + const drawDg = (): void => { + if (dgBox.open) + renderComboDiagram(dgHost, preview.data(), { + focusInput: preview.focusInput, + openLogic: async (logic) => { + if (dirty() && !(await showConfirmDialog(tc("LeaveAsk")))) return; + setPendingLogicKey(logic); + navigateTo(ROUTES.M01_MASTER_DATA); + }, + }); + }; + let dgTimer = 0; + preview.onUpdate(() => { + window.clearTimeout(dgTimer); + dgTimer = window.setTimeout(drawDg, 120); + }); + dgBox.addEventListener("toggle", drawDg); const table = el("div", { className: "m01-master__grid-wrap" }); const drawTable = (): void => { const move = (i: number, d: number): void => { @@ -381,6 +409,7 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor { table, ], }), + dgBox, preview.root, ); drawTable(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts index aeff66e03..68cc72e01 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Preview.ts @@ -13,6 +13,7 @@ import { previewCombo, type Combo, } from "./M02_MasterTemplete_Labor_Api"; +import { buildDiagramData, type DiagramData } from "./M02_MasterTemplete_Labor_Diagram"; import { tc, type ComboTextKey } from "./M02_MasterTemplete_Labor_Text"; const COSTS = ["노무비", "재료비", "경비"] as const; @@ -35,6 +36,7 @@ function inputCell( key: string, values: Record>, onEdit: () => void, + onInput: () => void, ): HTMLElement { const mine = (values[key] ??= {}); const value = mine[input.이름] ?? initial(input); @@ -48,6 +50,7 @@ function inputCell( control.setAttribute("data-input", `${key}|${input.이름}`); const edit = (): void => { mine[input.이름] = control.value; + onInput(); }; control.addEventListener("input", edit); control.addEventListener("change", () => { @@ -61,6 +64,12 @@ export interface PreviewHandle { root: HTMLElement; /** 담은 로직이 바뀐 뒤 입력 칸을 다시 그림 */ redraw: () => Promise; + /** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */ + data: () => DiagramData; + /** 입력 칸이 바뀌거나 결과가 나올 때마다 부름(돌려받은 함수 = 그만 받기) */ + onUpdate: (fn: () => void) => () => void; + /** 그 입력 칸으로 가서 깜빡임 */ + focusInput: (key: string, name: string) => void; } export function buildPreview(combo: () => Combo): PreviewHandle { @@ -69,6 +78,11 @@ export function buildPreview(combo: () => Combo): PreviewHandle { const inputs = new Map(); /** 로직마다 자동값은 한 번만 받음 */ const asked = new Set(); + /** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */ + const meta = new Map(); + let lines: Parameters[0]["lines"] = null; + const listeners = new Set<() => void>(); + const notify = (): void => listeners.forEach((f) => f()); const form = el("div", { className: "m01-logic__stack" }); const out = el("div", { className: "m01-master__grid-wrap", @@ -85,6 +99,8 @@ export function buildPreview(combo: () => Combo): PreviewHandle { const run = async (): Promise => { out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); + lines = null; + notify(); const send: Record> = {}; for (const [key, defs] of inputs) { send[key] = {}; @@ -97,6 +113,8 @@ export function buildPreview(combo: () => Combo): PreviewHandle { let got; try { got = await previewCombo(combo(), send); + lines = got.줄; + notify(); } catch (error) { out.replaceChildren( el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }), @@ -160,6 +178,7 @@ export function buildPreview(combo: () => Combo): PreviewHandle { mine[i.이름] = String(got); } inputs.set(row.로직, one.logic.입력); + meta.set(row.로직, { name: one.logic.이름, unit: one.logic.결과단위 ?? "" }); blocks.push( el("div", { className: "m01lab__info", @@ -168,7 +187,9 @@ export function buildPreview(combo: () => Combo): PreviewHandle { className: "m01lab__cell--wide", text: `${row.로직} ${one.logic.이름}`, }), - ...one.logic.입력.map((i) => inputCell(i, row.로직, values, () => void run())), + ...one.logic.입력.map((i) => + inputCell(i, row.로직, values, () => void run(), notify), + ), ], }), ); @@ -177,6 +198,7 @@ export function buildPreview(combo: () => Combo): PreviewHandle { } } form.replaceChildren(...blocks); + notify(); if (blocks.length) await run(); // 열자마자 한 번 미리 보기 계산 else out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") })); @@ -195,5 +217,28 @@ export function buildPreview(combo: () => Combo): PreviewHandle { out, ], }); - return { root, redraw }; + const data = (): DiagramData => + buildDiagramData({ + rows: combo().담은로직.map((r) => ({ + key: r.로직, + name: meta.get(r.로직)?.name ?? r.로직, + unit: meta.get(r.로직)?.unit ?? "", + inputs: (inputs.get(r.로직) ?? []).map((i) => ({ name: i.이름, unit: i.단위 ?? "" })), + })), + values, + lines, + }); + const focusInput = (key: string, name: string): void => { + const box = root.querySelector(`[data-input="${CSS.escape(`${key}|${name}`)}"]`); + if (!box) return; + box.scrollIntoView({ block: "center", behavior: "smooth" }); + box.focus({ preventScroll: true }); + box.classList.add("m02-labor__flash"); + window.setTimeout(() => box.classList.remove("m02-labor__flash"), 1600); + }; + const onUpdate = (fn: () => void): (() => void) => { + listeners.add(fn); + return () => listeners.delete(fn); + }; + return { root, redraw, data, onUpdate, focusInput }; } diff --git a/resources/tester/test_m01_side_fuel.py b/resources/tester/test_m01_side_fuel.py new file mode 100644 index 000000000..15a8e8b7f --- /dev/null +++ b/resources/tester/test_m01_side_fuel.py @@ -0,0 +1,44 @@ +"""38-2 왼쪽 패널 「유가 · 전력」 컨테이너 — 재료 다음 따로 · 재료 컨테이너에서는 뺌 · 서버 재료 그룹 그대로.""" + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SIDE = (ROOT / "M01_MasterData/M01_MasterData_UI_Side.ts").read_text(encoding="utf-8") + + +def _array(name: str) -> list[str]: + body = re.search(rf"const {name}(?:: \w+\[\])? = \[(.*?)\]", SIDE, re.S).group(1) + return [x.strip().strip('"') for x in body.split(",") if x.strip()] + + +def test_fuel_container_right_after_material(): + groups = _array("GROUPS") + assert groups[groups.index("재료") + 1] == "FUEL_GROUP" + assert 'const FUEL_GROUP = "유가 · 전력";' in SIDE + + +def test_material_container_has_no_fuel(): + assert _array("MATERIAL_ORDER") == ["자재품목", "품셈재료"] + assert "list.filter((f) => !isFuel(f))" in SIDE + + +def test_fuel_container_reads_server_material_group(): + # 서버로는 재료 그룹을 받아 유가전력 파일만 · 첫 받기에서 따로 부르지 않음 + assert 'if (group === FUEL_GROUP) return (await refresh("재료")).filter(isFuel);' in SIDE + assert "GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)" in SIDE + # 거름 저장 열쇠 그대로 — 전에 고른 구분이 이어짐 + assert "store: `m01.filter.재료.${FUEL}`" in SIDE + + +def test_fuel_file_still_in_material_group(): + data = json.loads( + (ROOT / "resources/master_data/재료_유가전력.json").read_text(encoding="utf-8") + ) + assert data + # 흐름도 넣을 값 판 · 단가산출 로직 요소 고르기는 서버 재료 그룹을 그대로 씀 + panel = (ROOT / "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts").read_text( + encoding="utf-8" + ) + assert '유가전력: { group: "재료", part: "유가전력" }' in panel diff --git a/resources/tester/test_m02_labor_diagram.py b/resources/tester/test_m02_labor_diagram.py new file mode 100644 index 000000000..bc78539fe --- /dev/null +++ b/resources/tester/test_m02_labor_diagram.py @@ -0,0 +1,110 @@ +"""일위대가 조합 다이어그램 값 만들기(`M02_MasterTemplete_Labor_Diagram.ts` 의 `buildDiagramData`) — 미리 보기 줄 → 로직 카드 출력. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS 는 이어 트랜스파일 · 화면 부품(@ui)은 빈 흉내 · +금액 모양(Logic_Money)도 빈 흉내(이 시험은 값 만들기만 봄). 견본 줄은 이 시험 안에 둠. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Diagram.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) }); +const cache = {}; +function load(f) { + if (cache[f]) return cache[f].exports; + const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = (cache[f] = { exports: {} }); + const req = (id) => { + if (id.startsWith(".") && !id.includes("M01_MasterData")) { + const p = path.resolve(path.dirname(f), id); + return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub; + } + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const m = load(file); +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(jobs.map((j) => m.buildDiagramData(j)))); +""" + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +ROWS = [ + { + "key": "GC000001", + "name": "돌쌓기", + "unit": "원/㎡", + "inputs": [{"name": "높이", "unit": "m"}, {"name": "지역", "unit": ""}], + }, + { + "key": "GF000002", + "name": "작업량", + "unit": "㎥/시간", + "inputs": [{"name": "지역", "unit": ""}], + }, + {"key": "GC000003", "name": "막힌 로직", "unit": "원/m", "inputs": []}, +] + + +def test_돈_값_멈춤_세_갈래(): + lines = [ + {"ok": True, "갈래": "돈", "노무비": 100, "재료비": 50, "경비": 0, "계": 150}, + {"ok": True, "갈래": "결과", "결과": 8.946}, + {"ok": False, "까닭": "표에 맞는 줄이 없음"}, + ] + (got,) = run([{"rows": ROWS, "values": {"GC000001": {"높이": "4"}}, "lines": lines}]) + outs = [x["out"] for x in got["logics"]] + assert outs[0] == {"노무비": 100, "재료비": 50, "경비": 0, "계": 150} + assert outs[1] == {"값": 8.946} + assert outs[2] == {"멈춤": "표에 맞는 줄이 없음"} + + +def test_넣은_값은_로직별_빈_값은_빈_글(): + (got,) = run( + [ + { + "rows": ROWS, + "values": {"GC000001": {"높이": "4"}, "GF000002": {"지역": "서울"}}, + "lines": None, + } + ] + ) + first, second, third = got["logics"] + assert [(i["name"], i["value"]) for i in first["inputs"]] == [("높이", "4"), ("지역", "")] + assert first["inputs"][0]["unit"] == "m" + assert [(i["name"], i["value"]) for i in second["inputs"]] == [("지역", "서울")] + assert third["inputs"] == [] + + +def test_아직_안_셌으면_출력_없음(): + (got,) = run([{"rows": ROWS, "values": {}, "lines": None}]) + assert [x["out"] for x in got["logics"]] == [None, None, None] + assert [x["key"] for x in got["logics"]] == ["GC000001", "GF000002", "GC000003"]