Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = { 유가전력: "유가·전력" };
|
||||
/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */
|
||||
/** 유가 · 전력 — 서버로는 재료 그룹의 한 파일(재료_유가전력.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<string, HTMLElement>();
|
||||
// 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로
|
||||
const kinds = new Map<Group, SubInfo[]>();
|
||||
/** 재료 그룹 안 파일별 구분 목록(자재품목·유가전력 각자) — file 이름이 열쇠 */
|
||||
/** 재료 그룹 안 파일별 구분 목록(자재품목 · 유가전력 각자) — file 이름이 열쇠 */
|
||||
const materialKinds = new Map<string, SubInfo[]>();
|
||||
const states = new Map<Group, string[]>();
|
||||
const runs = new Map<string, () => 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<FileInfo[]> => {
|
||||
// 유가 · 전력 컨테이너는 서버 재료 그룹을 받아 그 파일만
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, Record<string, string>>;
|
||||
/** 서버 미리 보기 줄(담은 차례) · 아직 안 셌으면 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<DiagramOut, { 계: number }> => !!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<HTMLElement, State>();
|
||||
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<HTMLElement>("[data-name]")?.dataset.name;
|
||||
if (name === undefined) return;
|
||||
for (const n of world.querySelectorAll<Element>(`[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<string, number>(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<string, Element[]>();
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, Record<string, string>>,
|
||||
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<void>;
|
||||
/** 다이어그램이 쓰는 값 한 벌 — 넣은 값 · 로직 이름 · 결과(저장 안 함) */
|
||||
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<string, LogicInput[]>();
|
||||
/** 로직마다 자동값은 한 번만 받음 */
|
||||
const asked = new Set<string>();
|
||||
/** 다이어그램에 넘길 것 — 로직 이름 · 결과 단위 · 마지막 미리 보기 줄 */
|
||||
const meta = new Map<string, { name: string; unit: string }>();
|
||||
let lines: Parameters<typeof buildDiagramData>[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<void> => {
|
||||
out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
|
||||
lines = null;
|
||||
notify();
|
||||
const send: Record<string, Record<string, unknown>> = {};
|
||||
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<HTMLElement>(`[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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user