Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-11-01 15:21:30 +09:00
70 changed files with 3696 additions and 619 deletions
+215
View File
@@ -0,0 +1,215 @@
"""M01 별칭 장부 시스템 모으기(PLAN 718-2 · 요구서 내역12 4장 23 · 4-1 확정 6).
모든 회사 · 개인 층의 별칭 장부(`실무자료/맞춤.json` · `M02_Hopyo_Ledger`)와 B09 공사 내역 맞춤
기억(`실무자료/내역맞춤.json` · 707-4)을 읽어 후보로 모음 — 읽기만(공유 storage 안 씀).
후보 = (종류, 열쇠, 서명) · 서명 = 짝 내용 지문(같은 짝이면 같은 서명) · 회사 수 · 사람 수만
(누가 · 처음파일 · 층 주인은 안 내보냄 = 익명) · 충돌 = 같은 열쇠 다른 서명 · 마스터와 다름.
승인 = 시스템 관리자만 → 마스터 층에 씀(맞춤 = `ledger.put` 재검사 · 내역맞춤 = 기억 파일) ·
반려 = 마스터 층 `실무자료/별칭반려.json` 에 서명 기억(같은 짝은 다시 안 보임) · 자동 반영 없음.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from common_util.common_util_auth import verify_session
from common_util.common_util_time import utc_now_text
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
ALIAS, BILL = "맞춤", "내역맞춤"
KINDS = (ALIAS, BILL)
REJECT = "별칭반려"
router = APIRouter(prefix="/api/m01/alias-ledger", tags=["M01 별칭 장부"])
# ── 읽기 ─────────────────────────────────────────────────────────────
def layer_folders() -> list[tuple[str, str | None, Path]]:
"""(회사, 사용자 | None, 층 폴더) — storage 의 회사 · 개인 층(숫자 폴더만 · 두 단만 훑음)."""
root = layers.storage_root()
out: list[tuple[str, str | None, Path]] = []
for c in sorted(root.iterdir() if root.is_dir() else []):
if not (c.is_dir() and c.name.isdigit()):
continue
out.append((c.name, None, layers.company_dir(c.name)))
for u in sorted(c.iterdir()):
if u.is_dir() and u.name.isdigit():
out.append((c.name, u.name, layers.personal_dir(c.name, u.name)))
return out
def _body(kind: str, row: dict[str, Any]) -> dict[str, Any]:
"""서명에 드는 내용 — 맞춤 = 갈래 · 짝 · 내역맞춤 = 확정 줄 키 · 이름."""
if kind == ALIAS:
return {"갈래": row.get("갈래"), "짝": row.get("짝") or []}
return {"확정": row.get("확정"), "이름": row.get("이름")}
def sign(kind: str, row: dict[str, Any]) -> str:
raw = json.dumps(_body(kind, row), ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _book(kind: str, folder: Path) -> dict[str, dict[str, Any]]:
if kind == ALIAS:
return ledger.read(folder)
got = hopyo_store.memory(folder, BILL)
return {
k: v
for k, v in got.items()
if isinstance(v, dict) and v.get("확정") and str(k).count("|") == 2
} # 잊음 · 깨진 열쇠 뺌
def _origin(full: str, row: dict[str, Any]) -> dict[str, str]:
if isinstance(row.get("원래"), dict):
return {k: str(row["원래"].get(k) or "") for k in ("품명", "규격", "단위")}
return ledger._plain_of(full)
def rejected() -> dict[str, dict[str, Any]]:
return hopyo_store.memory(store.FOLDER, REJECT)
def _reject_id(kind: str, full: str, sig: str) -> str:
return f"{kind}|{sig}|{full}"
def gather() -> dict[str, Any]:
"""후보 모음 `{후보, 모은곳{회사, 사람}, 반려}` — 마스터와 같은 짝 · 반려한 짝은 뺌."""
seen: dict[tuple[str, str, str], dict[str, Any]] = {}
firms, people = set(), set()
for company, user, folder in layer_folders():
for kind in KINDS:
for full, row in _book(kind, folder).items():
sig = sign(kind, row)
who = (
(row.get("누가") or {}).get("id") if isinstance(row.get("누가"), dict) else None
)
person = f"{company}/{user}" if user else f"{company}/{who or '-'}"
one = seen.setdefault(
(kind, full, sig),
{"종류": kind, "열쇠": full, "서명": sig, "원래": _origin(full, row),
**_body(kind, row), "회사": set(), "사람": set(), "때": None},
) # fmt: skip
one["회사"].add(company)
one["사람"].add(person)
firms.add(company)
people.add(person)
stamp = row.get("때")
if stamp and (one["때"] is None or str(stamp) > str(one["때"])):
one["때"] = stamp
master = {kind: _book(kind, Path(store.FOLDER)) for kind in KINDS}
gone = rejected()
live = []
for (kind, full, sig), one in seen.items():
mine = master[kind].get(full)
if (mine and sign(kind, mine) == sig) or _reject_id(kind, full, sig) in gone:
continue
one["마스터"] = "다름" if mine else None
live.append(one)
many: dict[tuple[str, str], int] = {}
for one in live:
many[(one["종류"], one["열쇠"])] = many.get((one["종류"], one["열쇠"]), 0) + 1
out = []
for one in sorted(live, key=lambda x: (x["종류"], x["열쇠"], -len(x["사람"]), x["서명"])):
clash = many[(one["종류"], one["열쇠"])] > 1 or one["마스터"] == "다름"
out.append({**one, "회사": len(one["회사"]), "사람": len(one["사람"]), "충돌": clash})
return {"후보": out, "모은곳": {"회사": len(firms), "사람": len(people)}, "반려": len(gone)}
def _find(kind: str, full: str, sig: str) -> dict[str, Any]:
if kind not in KINDS:
raise store.StoreError(400, f"종류는 {' · '.join(KINDS)}")
got = next(
(x for x in gather()["후보"] if (x["종류"], x["열쇠"], x["서명"]) == (kind, full, sig)),
None,
)
if got is None:
raise store.StoreError(404, f"후보가 없음(이미 승인 · 반려 · 바뀜) 「{full}」")
return got
# ── 쓰기(마스터 층만) ─────────────────────────────────────────────────
def approve(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
"""후보 하나 → 마스터 장부(서버가 다시 모아 찾음 · 맞춤은 `ledger.put` 재검사)."""
one = _find(kind, full, sig)
if kind == ALIAS:
ledger.put(store.FOLDER, {full: {"원래": one["원래"], **_body(kind, one)}}, who)
else:
row = {**_body(kind, one), "누가": who, "때": utc_now_text()}
hopyo_store.remember(store.FOLDER, {full: row}, BILL)
return gather()
def reject(kind: str, full: str, sig: str, who: Any) -> dict[str, Any]:
_find(kind, full, sig)
row = {"종류": kind, "열쇠": full, "서명": sig, "누가": who, "때": utc_now_text()}
hopyo_store.remember(store.FOLDER, {_reject_id(kind, full, sig): row}, REJECT)
return gather()
def master_bill() -> list[dict[str, Any]]:
"""마스터 내역맞춤 줄(보기 · 지움) — 맞춤 장부는 `/api/m02/layers/system/hopyo-ledger`."""
book = _book(BILL, Path(store.FOLDER))
return [
{"열쇠": k, "원래": ledger._plain_of(k), **_body(BILL, v)} for k, v in sorted(book.items())
]
def forget_bill(full: str) -> list[dict[str, Any]]:
if full not in _book(BILL, Path(store.FOLDER)):
raise store.StoreError(404, f"마스터 내역맞춤에 없음 「{full}」")
hopyo_store.remember(store.FOLDER, {full: None}, BILL)
return master_bill()
# ── 길(시스템 관리자만 — main.py 에서 system_admin_only) ───────────────
def _who(session: dict[str, Any]) -> dict[str, Any]:
return {"id": session.get("user_id"), "이름": session.get("name")}
def _call(fn, *args):
try:
return fn(*args)
except store.StoreError as error:
raise HTTPException(error.status, error.detail) from error
class Pick(BaseModel):
종류: str = Field(max_length=10)
열쇠: str = Field(min_length=1, max_length=400)
서명: str = Field(min_length=16, max_length=16)
@router.get("")
def candidates() -> dict[str, Any]:
return {**gather(), "마스터내역": master_bill()}
@router.post("/approve")
def approve_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(approve, body.종류, body.열쇠, body.서명, _who(session))
@router.post("/reject")
def reject_one(body: Pick, session: dict = Depends(verify_session)) -> dict[str, Any]:
return _call(reject, body.종류, body.열쇠, body.서명, _who(session))
class Forget(BaseModel):
열쇠: str = Field(min_length=1, max_length=400)
@router.post("/master-bill/forget")
def forget_one(body: Forget) -> dict[str, Any]:
return {"마스터내역": _call(forget_bill, body.열쇠)}
+237
View File
@@ -0,0 +1,237 @@
/* =============================================================================
* M01_MasterData_UI_Alias.ts
* M01 별칭 장부(PLAN 718-3 · 요구서 내역12 4장 23 · 4-1 확정 6) — 본 칸 · 시스템 관리자만.
* · 「모인 후보」 = 모든 회사 · 개인 층 별칭 장부(일위대가 맞춤) + 공사 내역 맞춤을 서버가 모음
* (`/api/m01/alias-ledger` · 718-2) · 이름 · 짝 · 회사 수 · 사람 수(누군지 안 보임) · 충돌 ·
* [승인] = 마스터 장부에 넣음(모든 사용자 제안에 쓰임) · [반려] = 다시 안 보임 · 둘 다 확인 창
* · 「마스터 장부」 = M02 별칭 장부 부품(`buildHopyoLedger` · 마스터 층) 그대로 + 마스터 공사 내역 맞춤 줄 [지움]
* · 자동 반영 없음 — 승인한 것만 모두에게 · 승인 · 반려 · 지움은 누를 때 바로 서버(확인 창 뒤)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { askConfirm, createButton, el, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createTable, type Column } from "@ui/ui_template_table";
import { createTabs } from "@ui/ui_template_tabs";
import "@ui/ui_template_general_layout.css"; // 마스터 장부 부품(M02 Hopyo_Ledger) 표 꼴 — M01 엔 안 실려 있음
import type { LedgerPair } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
import {
buildHopyoLedger,
KIND_TEXT,
LEDGER_HINT,
pairText,
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger";
export const ALIAS_TITLE = "별칭 장부";
const KIND_NAME: Record<string, string> = { 맞춤: "일위대가", 내역맞춤: "공사 내역" };
interface Origin {
품명: string;
규격: string;
단위: string;
}
export interface AliasCandidate {
종류: "맞춤" | "내역맞춤";
열쇠: string;
서명: string;
원래: Origin;
갈래?: string;
짝?: LedgerPair[];
확정?: string;
이름?: string;
회사: number;
사람: number;
때: string | null;
충돌: boolean;
마스터: "다름" | null;
}
interface MasterBill {
열쇠: string;
원래: Origin;
확정: string;
이름?: string;
}
export interface AliasBoard {
후보: AliasCandidate[];
모은곳: { 회사: number; 사람: number };
반려: number;
마스터내역?: MasterBill[];
}
async function call<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01/alias-ledger${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const got = (await res.json()) as T & { detail?: unknown };
if (!res.ok) throw new Error(String(got.detail ?? `HTTP ${res.status}`));
return got;
}
/** 짝 글 — 일위대가 = 갈래 · 우리 로직 · 공사 내역 = 우리 공종 이름 */
export const candidateText = (c: AliasCandidate): string =>
c.종류 === "내역맞춤"
? `${c.이름 ?? ""} (${c.확정 ?? ""})`
: c.갈래 === "짝"
? (c.짝 ?? []).map(pairText).join(" + ")
: (KIND_TEXT[c.갈래 ?? ""] ?? c.갈래 ?? "");
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const pick = (c: AliasCandidate) => ({ 종류: c.종류, 열쇠: c.열쇠, 서명: c.서명 });
const CANDIDATE_COLS: Column[] = [
{ label: "종류", width: "84px" },
{ label: "엑셀 품명" },
{ label: "규격" },
{ label: "단위", width: "64px" },
{ label: "짝" },
{ label: "회사", width: "56px", align: "end" },
{ label: "사람", width: "56px", align: "end" },
{ label: "충돌", width: "96px" },
{ label: "", width: "136px" },
];
const BILL_COLS: Column[] = [
{ label: "엑셀 품명" },
{ label: "규격" },
{ label: "단위", width: "64px" },
{ label: "우리 공종" },
{ label: "", width: "72px" },
];
export function buildAliasLedger(): HTMLElement {
const root = el("div", { className: "m01-alias", attrs: { "data-alias-ledger": "" } });
const body = el("div", { className: "m01-alias__body" });
let view = "candidates";
async function act(c: AliasCandidate, ok: boolean): Promise<void> {
const name = `${c.원래.품명} ${c.원래.규격}`.trim();
const yes = await askConfirm({
title: ok ? "마스터 장부에 승인" : "후보 반려",
message: ok
? `「${name}」 → ${candidateText(c)}\n마스터 장부에 넣으면 모든 사용자 제안에 쓰임(각자 장부가 먼저).`
: `「${name}」 → ${candidateText(c)}\n반려하면 같은 짝은 후보에 다시 안 보임.`,
okLabel: ok ? "승인" : "반려",
danger: !ok,
});
if (!yes) return;
try {
const got = await call<AliasBoard>(ok ? "/approve" : "/reject", pick(c));
showToast(ok ? "마스터 장부에 넣음" : "반려함", "success");
paintCandidates(got);
} catch (error) {
showToast(`${ok ? "승인" : "반려"} 못 함 — ${why(error)}`, "error");
}
}
function candidateRow(c: AliasCandidate): (string | Node)[] {
const clash = c.마스터 === "다름" ? "마스터와 다름" : c.충돌 ? "다른 짝 있음" : "";
return [
KIND_NAME[c.종류] ?? c.종류,
c.원래.품명,
c.원래.규격,
c.원래.단위,
candidateText(c),
String(c.회사),
String(c.사람),
clash ? createBadge(clash, "warning") : "",
el("div", {
className: "ui-toolbar",
children: [
createButton({
label: "승인",
variant: "filled",
size: "sm",
onClick: () => void act(c, true),
}),
createButton({
label: "반려",
variant: "ghost",
size: "sm",
onClick: () => void act(c, false),
}),
],
}),
];
}
function paintCandidates(board: AliasBoard): void {
const head = createPanelHead({
title: "모인 후보",
sub: `${LEDGER_HINT} · 회사 ${board.모은곳.회사} · 사람 ${board.모은곳.사람}의 장부에서 모음 · 반려 ${board.반려} · 승인한 것만 모두에게 · 누가 적었는지는 안 보임`,
});
body.replaceChildren(
head.root,
createTable(CANDIDATE_COLS, board.후보.map(candidateRow), {
density: "data",
empty: "모인 후보 없음",
}),
);
}
async function forget(b: MasterBill): Promise<void> {
const yes = await askConfirm({
title: "마스터 공사 내역 맞춤 지움",
message: `「${b.원래.품명} ${b.원래.규격}」 → ${b.이름 ?? b.확정}\n지우면 모든 사용자 제안에서 빠짐.`,
okLabel: "지움",
danger: true,
});
if (!yes) return;
try {
const got = await call<{ 마스터내역: MasterBill[] }>("/master-bill/forget", { 열쇠: b.열쇠 });
showToast("지움", "success");
paintMaster(got.마스터내역);
} catch (error) {
showToast(`지우지 못 함 — ${why(error)}`, "error");
}
}
function paintMaster(bill: MasterBill[]): void {
const rows = bill.map((b) => [
b.원래.품명,
b.원래.규격,
b.원래.단위,
`${b.이름 ?? ""} (${b.확정})`,
createButton({ label: "지움", variant: "danger", size: "sm", onClick: () => void forget(b) }),
]);
body.replaceChildren(
buildHopyoLedger({ scope: { layer: "system", owner: null }, canWrite: true }),
createPanelHead({
title: "마스터 공사 내역 맞춤",
sub: "B09 내역 줄 ↔ 우리 공종(각자 기억이 먼저)",
}).root,
createTable(BILL_COLS, rows, { density: "data", empty: "마스터 공사 내역 맞춤 없음" }),
);
}
async function load(): Promise<void> {
const stop = showLoading(body);
try {
const got = await call<AliasBoard>("");
if (view === "candidates") paintCandidates(got);
else paintMaster(got.마스터내역 ?? []);
} catch (error) {
body.replaceChildren(loadingError(why(error), () => void load()));
} finally {
stop();
}
}
const tabs = createTabs({
tabs: [
{ key: "candidates", label: "모인 후보" },
{ key: "master", label: "마스터 장부" },
],
value: view,
onChange: (key) => {
view = key;
void load();
},
});
root.append(tabs.root, body); // 제목 = 본 칸 머리(ALIAS_TITLE · 왼쪽 판이 넘김)
void load();
return root;
}
+5 -26
View File
@@ -1,10 +1,10 @@
/* =============================================================================
* M01_MasterData_UI_Cells.ts
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 · 쪽 나눔 띠 (요소·표 화면 공용)
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 (요소·표 화면 공용) · 쪽 단추 옛 이름
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { createPager } from "@ui/ui_template_pager";
import type { Row } from "./M01_MasterData_Api_Fetch";
/** 묶음 값(`값 = {가격: …}`)을 「값›가격」 칸으로 펼칠 때의 구분자. */
@@ -94,29 +94,8 @@ export function startEdit(td: HTMLElement, text: string, commit: (value: string)
input.addEventListener("blur", () => finish(true));
}
/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */
export function buildPager(
total: number,
size: number,
page: number,
goto: (page: number) => void,
): HTMLElement {
const last = Math.max(1, Math.ceil(total / size));
const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 });
const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last });
prev.addEventListener("click", () => goto(page - 1));
next.addEventListener("click", () => goto(page + 1));
return el("div", {
className: "ui-toolbar m01-master__pager",
children: [
el("span", {
text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`,
}),
prev,
next,
],
});
}
/** 쪽 나눔 띠 — 부품 `createPager` 를 부르는 옛 이름(PickTable · M02 목록이 씀). */
export const buildPager = createPager;
/** 칸 하나 — 노랑(고침) · 눌러 고침. */
export function buildCell(
+4 -1
View File
@@ -52,6 +52,7 @@ import {
} from "./M01_MasterData_UI_LogicLab_Flow";
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
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";
@@ -130,6 +131,7 @@ export async function mountM01LogicLab(
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘(흐름도 · 원문 · 하나만 열림 · 폭 공유)
const panes = buildLogicSide(editor);
const views = createViewPicker(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽
const errors = el("div", {
className: "ui-banner ui-banner--danger m01-logic__reasons",
attrs: { hidden: "" },
@@ -236,7 +238,7 @@ export async function mountM01LogicLab(
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
panes.root.hidden = !opened;
back.hidden = listing;
back.hidden = views.root.hidden = listing;
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true }));
@@ -582,6 +584,7 @@ export async function mountM01LogicLab(
title: tx("Title"),
status: [pending],
buttons: [
views.root,
createButton({ label: tf("New"), variant: "filled", onClick: () => void onNew() }),
copyButton,
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
@@ -17,7 +17,8 @@ import type {
TextLine,
TextPiece,
} from "./M01_MasterData_UI_Logic_Api";
import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { pctView } from "./M01_MasterData_UI_Logic_Api";
import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
@@ -36,6 +37,8 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
import "./M01_MasterData_UI_LogicLab_Style.css";
import "./M01_MasterData_UI_LogicLab_Pill.css";
import { createBadge } from "@ui/ui_template_badge";
import { createEmpty } from "@ui/ui_template_empty";
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] {
@@ -97,6 +100,8 @@ interface Entry {
spec: string;
unit: string;
qty: string;
/** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */
qtyValue: string;
qtyTag: string;
qtyFrag: Fragment[];
/** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */
@@ -139,7 +144,7 @@ const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
],
});
function infoBox(ctx: DetailContext): HTMLElement {
function infoBox(ctx: DetailContext): HTMLElement[] {
const row = ctx.row;
const own = isOwnLogic(row);
const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => {
@@ -186,34 +191,51 @@ function infoBox(ctx: DetailContext): HTMLElement {
}),
]
: [];
return section(tl("Info_Title"), [
el("p", {
className: "ui-text ui-text--muted",
text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"),
}),
...reasons,
el("div", {
className: "m01lab__info",
children: [
cell(tx("Head_File"), ctx.file),
cell(tx("Head_Key"), row.키),
field(tx("Head_Number"), "원문번호"),
field(tx("Head_Name"), "이름"),
field(tx("Head_Unit"), "결과단위"),
field(tx("Head_Source"), "출처"),
cell(tx("Head_Owner"), row.소유 ?? ""),
el("label", {
className: "m01lab__cell m01lab__cell--wide",
children: [
el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }),
note,
],
}),
],
}),
]);
const grid = el("div", {
className: "m01lab__info",
children: [
cell(tx("Head_File"), ctx.file),
cell(tx("Head_Key"), row.키),
field(tx("Head_Number"), "원문번호"),
field(tx("Head_Name"), "이름"),
field(tx("Head_Unit"), "결과단위"),
field(tx("Head_Source"), "출처"),
cell(tx("Head_Owner"), row.소유 ?? ""),
el("label", {
className: "m01lab__cell m01lab__cell--wide",
children: [
el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }),
note,
],
}),
],
});
// 늘 맨 위 접힌 한 줄 — 펼침은 이 로직 안에서 기억(다시 그려도) · 막힘 까닭은 접어도 보임
const head = [row.키, row.원문번호, row.이름, row.결과단위].filter(Boolean).join(" · ");
const fold = el("details", {
className: "m01lab__box m01lab__infofold",
attrs: ctx.ownHost.dataset.info ? { open: "" } : {},
children: [
el("summary", {
children: [
el("strong", { text: tl("Info_Title") }),
el("span", { className: "ui-text ui-text--muted", text: head }),
],
}),
el("p", {
className: "ui-text ui-text--muted",
text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"),
}),
grid,
],
});
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.info = fold.open ? "1" : ""));
return [fold, ...reasons];
}
/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */
const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—");
/** 계산된 줄은 서버가 준 비목별 몫으로 묶음에 나눠 넣음 — 로직을 부르는 줄도 노무비·재료비·경비로 갈림 */
const spread = (e: Entry, line?: CalcLine): Entry[] => {
const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0);
@@ -249,6 +271,7 @@ function entries(ctx: DetailContext): Entry[] {
spec: item.규격 ?? brief?.규격 ?? "",
unit: item.단위 ?? "",
qty: item.수량,
qtyValue: qtyOf(line),
qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량),
qtyFrag: [{ text: item.수량 }],
price: price === undefined || price === null ? "" : formatPrice(price),
@@ -278,8 +301,9 @@ function entries(ctx: DetailContext): Entry[] {
group: OF_COST[extra.비목 ?? ""] ?? "other",
name: extra.이름,
spec: "",
unit: "",
unit: line ? pctView(line).unit : "",
qty: extra.식,
qtyValue: qtyOf(line),
qtyTag: tl("Extra"),
qtyFrag: [{ text: extra.식 }],
price: "",
@@ -356,6 +380,7 @@ function lineRow(e: Entry): HTMLElement {
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
],
}),
cellOf(e.qtyValue, "m01-logic__money"),
cellOf(e.unit),
el("td", {
className: "m01lab__qty-cell",
@@ -375,7 +400,7 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme
const empty = el("tr", {
children: [
el("td", {
attrs: { colspan: "5" },
attrs: { colspan: "6" },
className: "ui-text ui-text--muted",
text: tl("Empty_Group"),
}),
@@ -385,14 +410,15 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme
className: "m01lab__subtotal",
attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" },
children: [
el("td", { attrs: { colspan: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
el("td", { attrs: { colspan: "5" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"),
],
});
const headers = [
tl("Col_Name"),
tl("Col_Unit"),
tl("Col_Qty"),
tl("Col_Unit"),
tl("Col_Logic"),
tl("Col_Price"),
tl("Col_Amount"),
];
@@ -443,10 +469,13 @@ function ownEditor(ctx: DetailContext): HTMLElement {
function hoBox(ctx: DetailContext): HTMLElement {
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
if (!money) {
return section(tl("Ho_Title"), [
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
]);
return block(
section(tl("Ho_Title"), [
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
]),
"ho",
);
}
const all = entries(ctx);
const calculated = ctx.lines !== null;
@@ -469,7 +498,7 @@ function hoBox(ctx: DetailContext): HTMLElement {
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine];
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
return section(tl("Ho_Title"), blocks, hint);
return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho");
}
/** 상세 화면 넷을 `host` 에 쌓음 */
@@ -482,10 +511,14 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
? { node: active, from: active.selectionStart, to: active.selectionEnd }
: null;
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
host.replaceChildren(
infoBox(ctx),
section(tl("Calc_Title"), [ctx.calcHost]),
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
...infoBox(ctx),
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"),
...(ctx.flowHost
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
: [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
@@ -159,9 +159,11 @@ export function buildFlowView(
try {
ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics });
} catch (e) {
host.replaceChildren(
section(tf("Detail"), [el("p", { className: "ui-text ui-text--muted", text: String(e) })]),
);
const failed = section(tf("Detail"), [
el("p", { className: "ui-text ui-text--muted", text: String(e) }),
]);
failed.dataset.block = "ho";
host.replaceChildren(failed);
return;
}
const totalRound = totalRoundOf(lg, totalRoundNow());
@@ -196,5 +198,8 @@ export function buildFlowView(
tf("Formula"),
bodies.length ? bodies : [el("p", { className: "ui-text ui-text--muted", text: tf("NoFunc") })],
);
// 보기 고르기 이름표(`LogicLab_View.ts`) — 고른 덩이만 보임
detail.dataset.block = "ho";
text.dataset.block = "formula";
host.replaceChildren(detail, text);
}
@@ -45,20 +45,25 @@
table-layout: fixed;
}
/* 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액 */
.m01lab__ho th:nth-child(1) {
width: 26%;
width: 22%;
}
.m01lab__ho th:nth-child(2) {
width: 8%;
width: 9%;
}
.m01lab__ho th:nth-child(3) {
width: 40%;
width: 7%;
}
.m01lab__ho th:nth-child(4),
.m01lab__ho th:nth-child(5) {
.m01lab__ho th:nth-child(4) {
width: 36%;
}
.m01lab__ho th:nth-child(5),
.m01lab__ho th:nth-child(6) {
width: 13%;
}
@@ -185,3 +190,28 @@
.m01lab__pickrow.is-picked {
font-weight: 600;
}
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
.m01-logic__editor[data-view="calc"] [data-block]:not([data-block="calc"]),
.m01-logic__editor[data-view="formula"] [data-block]:not([data-block="formula"]) {
display: none;
}
/* 기본정보 — 늘 맨 위 접힌 한 줄 */
.m01lab__infofold > summary {
display: flex;
align-items: baseline;
gap: var(--spacing-8);
cursor: pointer;
}
.m01lab__infofold[open] > summary {
margin-bottom: var(--spacing-8);
}
/* 상세 머리 「지금 입력」 한 줄 */
.m01lab__now {
margin: 0;
}
@@ -28,6 +28,14 @@ const TEXT = {
Formula_Title: ["텍스트 수식", "Text formula"],
Formula_Slot: ["시험 계산을 누르면 식이 글로 나옴", "Run the trial calc to see the formula"],
Calc_Title: ["시험 계산", "Trial calculation"],
View_Est: ["인력·재료·경비", "Labor·material·expense"],
View_Label: ["보기", "View"],
View_Soon: [
"준비 중 — 인력 · 재료 · 경비 묶음 보기",
"Coming soon — labor · material · expense view",
],
Now_Inputs: ["지금 입력", "Current inputs"],
Now_None: ["없음", "none"],
G_labor: ["인력", "Labor"],
G_material: ["재료", "Material"],
G_cost: ["경비", "Expense"],
@@ -40,6 +48,7 @@ const TEXT = {
Col_Name: ["이름", "Name"],
Col_Unit: ["단위", "Unit"],
Col_Qty: ["수량", "Quantity"],
Col_Logic: ["로직", "Logic"],
Col_Price: ["단가", "Price"],
Col_Amount: ["금액", "Amount"],
Empty_Group: ["줄 없음", "No lines"],
@@ -0,0 +1,50 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_View.ts
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
* 보기 넷 = 단가산출 상세(기본) · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 단가산출 상세)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createSegment } from "@ui/ui_template_segment";
import { createToolbar } from "@ui/ui_template_toolbar";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export type LabView = "ho" | "est" | "calc" | "formula";
/** 덩이에 보기 이름표를 붙여 돌려줌 */
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
node.dataset.block = view;
return node;
};
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
host.dataset.view = "ho";
const segment = createSegment({
options: [
{ value: "ho", label: tl("Ho_Title") },
{ value: "est", label: tl("View_Est") },
{ value: "calc", label: tl("Calc_Title") },
{ value: "formula", label: tl("Formula_Title") },
],
value: "ho",
size: 32,
ariaLabel: tl("View_Label"),
onChange: (v) => (host.dataset.view = v),
});
return { root: createToolbar({ items: [segment.root, "divider"], label: tl("View_Label") }) };
}
/** 상세 머리 「지금 입력」 한 줄(읽기만) — 숨은 시험 계산 칸에 든 값 */
export function nowInputs(values: Record<string, string>): HTMLElement {
const filled = Object.entries(values).filter(([, v]) => String(v ?? "").trim() !== "");
const text = filled.length ? filled.map(([k, v]) => `${k} ${v}`).join(" · ") : tl("Now_None");
return el("p", {
className: "ui-text ui-text--muted m01lab__now",
attrs: { "data-now": "" },
text: `${tl("Now_Inputs")} · ${text}`,
});
}
+11 -2
View File
@@ -28,6 +28,7 @@ import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
import { createBadge } from "@ui/ui_template_badge";
import { createDivider } from "@ui/ui_template_divider";
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
const KEY_RE = /^[A-Z]{2}\d{6}$/;
@@ -67,6 +68,9 @@ export interface CalcContext {
onText?: (answer: TextAnswer) => void;
}
const grid = (children: HTMLElement[]): HTMLElement =>
el("div", { className: "m01-logic__head", children });
const SUMS = ["노무비", "재료비", "경비", "계"];
/** 로직마다 자동값을 한 번만 받음(다시 그려도 안 부름) */
@@ -122,7 +126,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
control = box;
}
const label = spec.단위 ? `${name} (${spec.단위})` : name;
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
// 긴 이름 줄만 두 칸 차지 — 짧으면 4 열에 맞춰 나란히
const wide = label.length > 10;
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
@@ -196,8 +200,13 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
runButton.setAttribute("data-calc-run", "");
host.replaceChildren(
el("h3", { text: tx("Calc_Title") }),
// 옵션 고르기 · 자재 고르기 두 격자(기본 4 열) — 사이 수평 구분선(709-1 부품)
...(fields.length || materials.length
? [el("div", { className: "m01-logic__head", children: [...fields, ...materials] })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
? [
...(fields.length ? [grid(fields)] : []),
...(fields.length && materials.length ? [createDivider()] : []),
...(materials.length ? [grid(materials)] : []),
]
: [el("p", { className: "ui-text ui-text--muted", text: tx("Calc_NoInputs") })]),
runButton,
out,
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_Logic_CalcPick.ts
* 시험 계산의 재료 · 기계 고르기 — 재료 줄마다 「잡힌 품목 + 품목 고르기」 · 기계 입력은 단추 하나
* 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
* ========================================================================== */
@@ -49,10 +49,11 @@ const isMachineInput = (spec: LogicInput): boolean =>
const pickButton = (label: string, onClick: () => void): HTMLButtonElement =>
createButton({ label, variant: "ghost", onClick });
const line = (label: string, current: HTMLElement, button: HTMLElement): HTMLElement =>
/** 자재 칸 — 이름 + 잡힌 품목(누르면 고르기 모달) · 긴 이름은 두 칸 */
const line = (label: string, current: HTMLElement): HTMLElement =>
el("div", {
className: "m01-logic__field m01-logic__field--wide",
children: [el("span", { text: label }), el("div", { children: [current, button] })],
className: `m01-logic__field${label.length > 10 ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), current],
});
/** 재료 줄마다 한 칸 — 지금 잡힌 품목 + 품목 고르기 */
@@ -80,12 +81,16 @@ export function materialRows(
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
.map(headWord)
.filter((w, k, all) => w && all.indexOf(w) === k);
const current = el("span", {
className: "ui-text ui-text--muted",
text: `${nowName || tx("Mat_NoPrice")} `,
attrs: { "data-mat-idx": String(i), "data-ref": brief?.ref ?? "" },
// 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음)
const current = createButton({
label: nowName || tx("Mat_NoPrice"),
variant: "link",
onClick: () => pick(),
});
const button = pickButton(tx("Pick_Item"), () =>
current.dataset.matIdx = String(i);
current.dataset.ref = brief?.ref ?? "";
current.title = tx("Pick_Item");
const pick = (): void =>
openPickTable({
kind: "재료",
title: `${tx("Pick_MatTitle")} · ${label}`,
@@ -94,12 +99,11 @@ export function materialRows(
isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref),
onPick: (r) => {
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
current.textContent = `${nameOf(r)} `;
current.textContent = nameOf(r);
again();
},
}),
);
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current, button);
});
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current);
});
}
@@ -115,7 +119,7 @@ export function showItems(
const item = lines[i]?.품목;
if (!item || mine.has(i)) return;
span.dataset.ref = item.키;
span.textContent = `${`${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키} `;
span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키;
});
}
+19 -6
View File
@@ -8,6 +8,7 @@
import { createInputField, el } from "@ui/ui_template_elements";
import { aliasText, matchLogic } from "@ui/ui_template_logic_find";
import { buildPager } from "./M01_MasterData_UI_Cells";
import { pageKeepFirst, watchFit } from "@ui/ui_template_pager";
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Head.css";
@@ -41,7 +42,7 @@ export interface ListHandle {
export const logicId = (sub: string, key: string): string => `${sub}\n${key}`;
const SIZE = 30;
const DEFAULT_SIZE = 30; // 붙기 전 어림 — 붙은 뒤 화면 높이로 다시 셈
const COLS = [
"키",
"원문번호",
@@ -64,6 +65,7 @@ export function buildList(
let aliases = new Map<string, string>();
const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias;
let page = 1;
let size = DEFAULT_SIZE;
const pick = { sub: "", detail: "" };
const searchField = createInputField({ type: "search", placeholder: tx("List_Search") });
const search = searchField.input;
@@ -84,9 +86,9 @@ export function buildList(
별칭: aliasOf(x),
}).ok,
);
page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE)));
const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line);
pager.replaceWith((pager = buildPager(shown.length, SIZE, page, go)));
page = Math.min(page, Math.max(1, Math.ceil(shown.length / size)));
const rows = shown.slice((page - 1) * size, page * size).map(line);
pager.replaceWith((pager = buildPager(shown.length, size, page, go)));
grid.replaceChildren(
el("table", {
className: "ui-table m01-master__grid",
@@ -144,18 +146,29 @@ export function buildList(
};
search.addEventListener("input", () => go(1));
let pager = buildPager(0, SIZE, 1, go);
let pager = buildPager(0, size, 1, go);
const root = el("div", {
className: "m01-logic__listview",
children: [
el("div", {
className: "m01-head__filters",
children: [searchField.root, pager],
children: [searchField.root],
}),
grid,
pager,
],
});
watchFit(
root,
() => grid,
(rows) => {
if (rows === size) return;
page = pageKeepFirst(page, size, rows);
size = rows;
draw();
},
);
return {
root,
setItems: (logics) => {
@@ -7,6 +7,7 @@
import { createInputField, el, showToast } from "@ui/ui_template_elements";
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
import { buildPager, flatten, show } from "./M01_MasterData_UI_Cells";
import { fitRows, PAGER_ROOM, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { openModal } from "@ui/ui_template_modal";
@@ -29,7 +30,8 @@ const COLS: Record<PickTableKind, string[]> = {
],
기계: ["원문번호", "구분", "상세구분", "이름", "규격", "값›가격"],
};
const SIZE = 30;
/** 모달 안 표 칸 + 쪽 단추가 쓸 높이 — 모달 최대 높이(80vh)에서 제목 · 찾기 줄을 뺌 */
const room = (): number => window.innerHeight * 0.8 - 130;
export interface PickTableOptions {
kind: PickTableKind;
@@ -60,13 +62,14 @@ export function openPickTable(opt: PickTableOptions): void {
});
let page = 1;
let size = fitRows(room() - PAGER_ROOM); // 처음 어림 — 붙은 뒤 다시 재서 어긋나면 바로잡음
const tried = new Set([opt.seed]);
let seq = 0;
const load = async (fallback = false): Promise<void> => {
const mine = ++seq;
try {
const q = search.value.trim();
const got = await fetchRows(FILES[opt.kind], page, SIZE, q);
const got = await fetchRows(FILES[opt.kind], page, size, q);
if (mine !== seq) return; // 최신 응답만
if (!got.total && fallback) {
const next = [...(q.includes(" ") ? [q.split(/\s+/)[0]] : []), ...(opt.alt ?? [])].find(
@@ -94,10 +97,6 @@ export function openPickTable(opt: PickTableOptions): void {
return tr;
});
host.replaceChildren(
buildPager(got.total, SIZE, got.page, (next) => {
page = next;
void load();
}),
el("div", {
className: "ui-table-wrap m01-master__grid-wrap",
children: [
@@ -112,12 +111,28 @@ export function openPickTable(opt: PickTableOptions): void {
}),
],
}),
buildPager(got.total, size, got.page, (next) => {
page = next;
void load();
}),
);
fit.ask();
host.querySelector(".is-picked")?.scrollIntoView({ block: "nearest" });
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const fit = watchFit(
host,
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
(rows) => {
if (rows === size) return;
page = pageKeepFirst(page, size, rows);
size = rows;
void load();
},
{ cap: room, once: true },
);
let timer = 0;
search.addEventListener("input", () => {
window.clearTimeout(timer);
@@ -73,11 +73,24 @@
gap: var(--spacing-8);
}
/* 시험 계산 입력만 2열 고정 — 요소 표 쪽(auto-fill)은 그대로 둠
/* 시험 계산 입력만 4열 고정(옵션 · 자재 격자 둘) — 요소 표 쪽(auto-fill)은 그대로 둠 · 긴 이름 칸은 두 칸
실제 그릇은 LogicLab 이 씌우는 `.m01lab__calc`(`.m01-logic__calc` 는 옛 테스트 컨테이너 몫이라 안 걸림) */
.m01lab__calc .m01-logic__head,
.m01-logic__calc .m01-logic__head {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.m01lab__calc .m01-logic__field--wide {
grid-column: span 2;
}
/* 자재 칸 잡힌 품목 링크 — 칸 폭 안에서 왼쪽 맞춤 · 넘치면 말줄임 */
.m01lab__calc .m01-logic__field > .ui-btn--link {
justify-content: flex-start;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m01-logic__field {
@@ -211,6 +224,11 @@
white-space: nowrap;
}
/* 표 칸은 칸 그대로 — block 이면 이웃 칸이 한 칸에 위아래로 쌓임(단가 2 행) */
td.m01-logic__money {
display: table-cell;
}
/* 목록 「별칭」 칸(208-1) — 칸 안 글 상자 · 테두리는 올렸을 때만 */
.m01-logic__alias {
width: 160px;
+54 -6
View File
@@ -1,8 +1,9 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직)
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b)
* / 우측 요소 표 · 표 목록 · 로직 편집
* · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) · 구조물 옵션 정의(716-1 · 화면은 M02 `StructOptions/`)
* / 우측 요소 표 · 표 목록 · 로직 편집 · 옵션 정의 · 별칭 장부(718-3) ·
* 주소 `?open=로직|옵션|별칭` = 그 칸을 고른 채 열림(716-2)
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
* 자동저장 없음.
@@ -26,7 +27,7 @@ import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { ROUTES } from "@config/config_frontend";
import { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { currentQuery, navigateTo, setLeaveGuard } 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";
@@ -89,6 +90,7 @@ function buildPage(): HTMLElement {
children: [headBar.root, buildHeadFilters([searchField.root])],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const optionsHost = el("div", { className: "m02-sod__host", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [
@@ -112,12 +114,50 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (mode: "elements" | "logic"): void => {
const showMode = (mode: "elements" | "logic" | "options"): void => {
logicHost.hidden = mode !== "logic";
elementView.hidden = mode !== "elements";
optionsHost.hidden = mode !== "options";
if (mode !== "options") side.setOptionsMark(false);
};
/* --- 구조물 옵션 정의(716-1) — 저장 안 한 고침은 칸을 바꿀 때 · 페이지를 떠날 때 물음 --- */
let optionsDirty = false;
const leaveOptions = async (): Promise<boolean> => {
if (!optionsDirty || optionsHost.hidden) return true;
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false;
optionsDirty = false;
return true;
};
/** 옵션 정의에 고침이 있으면 물은 뒤에 `run` — 취소면 옵션 줄 표시를 되돌려 머묾 */
const afterLeave = (run: () => void): boolean => {
if (!optionsDirty || optionsHost.hidden) return true;
void leaveOptions().then((ok) => {
if (ok) return run();
side.setActive(null);
side.setOptionsMark(true);
});
return false;
};
const showOptions = (label: string, node: HTMLElement): ((dirty: boolean) => void) => {
dispose();
dispose = (): void => {};
current = null;
optionsDirty = false;
optionsHost.replaceChildren(
el("div", {
className: "m01-master__headbox",
children: [buildHeadBar({ title: label, buttons: [] }).root],
}),
node,
);
showMode("options");
setLeaveGuard(leaveOptions);
return (dirty) => (optionsDirty = dirty);
};
const openFile = (pick: Pick): void => {
if (!afterLeave(() => openFile(pick))) return;
dispose();
showMode("elements");
if (pick.view) {
@@ -203,6 +243,7 @@ function buildPage(): HTMLElement {
/** 「단가산출 로직」 탭으로 전환 — `key` 가 있으면 그 로직을 바로 엶. */
const openLogicTab = (key?: string): void => {
if (key) setPendingLogicKey(key);
if (!afterLeave(() => openLogicTab())) return;
dispose();
dispose = (): void => {};
current = null;
@@ -259,7 +300,11 @@ function buildPage(): HTMLElement {
setMasterJump((target) => void jump(target));
/* --- 좌측: 컨테이너 --- */
const side = buildSide(openFile, () => openLogicTab());
const side = buildSide(openFile, () => openLogicTab(), {
confirmLeave: leaveOptions,
show: showOptions,
onOpen: () => side.setActive(null),
});
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
@@ -267,7 +312,7 @@ function buildPage(): HTMLElement {
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost],
children: [elementView, logicHost, optionsHost],
}),
],
});
@@ -287,5 +332,8 @@ function buildPage(): HTMLElement {
);
// 다른 화면(일위대가 조합 다이어그램 등)이 로직 키를 들고 왔으면 로직 탭으로 바로 엶
if (hasPendingLogicKey()) openLogicTab();
// 주소 `?open=` — 대시보드 설계 기본값 줄 · 옛 M02 `open=옵션` 즐겨찾기가 그 칸을 고른 채 엶
const want = currentQuery().get("open");
if (want === "로직" || want === "옵션" || want === "별칭") side.reveal(want);
return layout;
}
+22 -4
View File
@@ -29,8 +29,10 @@ import {
withCell,
} from "./M01_MasterData_UI_Cells";
import { createEmpty } from "@ui/ui_template_empty";
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
const SIZE = 50;
/** 표 칸 위에 오는 도구줄(단추 줄 + 간격) 높이 — 첫 줄 수 어림에만 씀 */
const BAR_H = 52;
const MARKET = "재료_자재품목.json";
const LINKED = "재료_품셈재료.json";
const JOB = ["인력.json"];
@@ -94,6 +96,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
const { sub, detail, state } = pick;
const file = pick.file.file;
let page = pick.view?.page ?? 1;
let size = guessRows(host, BAR_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음
let data: RowsPage | null = null;
let unlinked = false;
@@ -210,7 +213,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
host.replaceChildren(
el("div", {
className: "ui-toolbar m01-master__bar",
children: [add, ...only, buildPager(d.total, SIZE, d.page, go)],
children: [add, ...only],
}),
el("div", {
className: "ui-table-wrap m01-master__grid-wrap",
@@ -221,8 +224,10 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
}),
],
}),
buildPager(d.total, size, d.page, go),
...empty,
);
fit.ask();
};
const go = (next: number): void => {
@@ -232,7 +237,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
const load = async (): Promise<void> => {
try {
data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail, state);
data = await fetchRows(file, page, size, q, unlinked, sub, detail, state);
for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name);
saveView(pick.group, { page: data.page });
paint();
@@ -241,9 +246,22 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void
}
};
const fit = watchFit(
host,
() => host.querySelector<HTMLElement>(".m01-master__grid-wrap"),
(rows) => {
if (rows === size) return;
page = pageKeepFirst(data?.page ?? page, size, rows);
size = rows;
void load();
},
);
const off = onDraftChange(paint);
void load();
return off;
return () => {
fit.stop();
off();
};
}
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
+62 -4
View File
@@ -1,13 +1,21 @@
/* =============================================================================
* M01_MasterData_UI_Side.ts
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 아홉 개
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`)
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b)
* ─ 구분선 ─ 구조물 옵션 정의(716-1 · 화면 코드는 M02 `StructOptions/` 그대로 · 입구만 여기)
* · 별칭 장부(718-3 · 누르면 본 칸에 모인 후보 · 마스터 장부 · `M01_MasterData_UI_Alias`)
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
* ========================================================================== */
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createDivider } from "@ui/ui_template_divider";
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import {
buildOptionsSection,
type OptionsSectionOptions,
} from "../M02_MasterTemplete/StructOptions/M02_SO_Options";
import { ALIAS_TITLE, buildAliasLedger } from "./M01_MasterData_UI_Alias";
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";
@@ -103,6 +111,10 @@ export interface SideHandle {
setOnPicked: (fn: () => void) => void;
/** 「전체」 + 드롭다운 거름 한 벌 — 로직 컨테이너도 씀 */
filter: (args: FilterArgs) => HTMLElement;
/** 주소 `open` — 그 컨테이너를 펼치고 고름(로직 = 로직 목록 · 옵션 = 정의 화면까지) */
reveal: (target: "로직" | "옵션" | "별칭") => void;
/** 옵션 줄 고름 표시 지움(다른 화면을 열었음) · 켬(떠나기를 취소해 머묾) */
setOptionsMark: (on: boolean) => void;
}
export interface FilterArgs {
@@ -156,8 +168,13 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const ALIAS_ID = "별칭|";
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
optionsOpt: Omit<OptionsSectionOptions, "isAdmin">,
): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
@@ -483,9 +500,25 @@ ${first.file}`
onLogic();
});
// 구조물 옵션 정의 — 로직 칸 아래 구분선 + 칸 하나(시스템 관리자 화면이라 고침 가능)
const options = buildOptionsSection({ ...optionsOpt, isAdmin: true });
const optionsSec = section(options.title, options.body);
// 별칭 장부(718-3) — 옵션 칸 아래 칸 하나 · 누르면 본 칸(옵션 정의와 같은 자리)
const aliasSec = section(ALIAS_TITLE, null);
aliasSec.classList.add("m01-side__leaf");
buttons.set(ALIAS_ID, aliasSec);
const openAlias = async (): Promise<void> => {
if (!(await optionsOpt.confirmLeave())) return;
optionsOpt.show(ALIAS_TITLE, buildAliasLedger());
options.clear();
setActive(ALIAS_ID);
};
aliasSec
.querySelector(".ui-collapsible__title")
?.addEventListener("click", () => void openAlias());
const root = el("div", {
className: "m01-side ui-sidebar-panel",
children: [...groups, logic],
children: [...groups, logic, createDivider(), optionsSec, aliasSec],
});
attachCollapsible(root);
void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) =>
@@ -497,5 +530,30 @@ ${first.file}`
// 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만
const files = (group: string): FileInfo[] =>
group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []);
return { root, logicHost, refresh, files, setActive, setOnPicked, filter };
const reveal = (target: "로직" | "옵션" | "별칭"): void => {
if (target === "별칭") return void openAlias();
if (target === "옵션") {
optionsSec.classList.remove("is-collapsed");
options.body.querySelector<HTMLElement>("button")?.click();
return;
}
logic.classList.remove("is-collapsed");
setActive(buttons.has(LOGIC_ID) ? LOGIC_ID : null);
onLogic();
};
const setOptionsMark = (on: boolean): void => {
if (!on) return options.clear();
options.body.querySelector("button")?.classList.add("is-active");
};
return {
root,
logicHost,
refresh,
files,
setActive,
setOnPicked,
filter,
reveal,
setOptionsMark,
};
}
+6 -10
View File
@@ -84,9 +84,9 @@ html.is-bm-route .m01-master__headbox {
min-width: 0;
}
/* 겉 = 표 부품(ui-table-wrap · ui-table) · 여기는 높이 · 폭 · 머리 고정 · 칸 상태만 */
.m01-master__grid-wrap {
max-height: calc(100dvh - 300px);
/* 겉 = 표 부품(ui-table-wrap · ui-table) · 여기는 높이 · 폭 · 머리 고정 · 칸 상태만
쪽 있는 표 = 높이 제한 없음(줄 수를 화면 높이로 셈 · 쪽 단추 부품 watchFit) · 쪽 없는 한 장 표(--sheet) = 화면 아래까지 안에서 스크롤 */
.m01-master__grid-wrap--sheet {
overflow: auto;
}
@@ -130,13 +130,9 @@ table.m01-master__grid {
padding: 2px var(--spacing-4);
}
.m01-master__pager {
display: flex;
align-items: center;
gap: var(--spacing-12);
margin-left: auto;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
/* 쪽 단추(부품 ui-pager) — 간격은 부품이 줌(표 아래 한 칸) · 이미 칸 사이 간격이 있는 몸통은 겹치지 않게 뺌 */
.m01-master__body > .ui-table-wrap + .ui-pager {
margin-top: 0;
}
.m01-master__table-row {
+52 -28
View File
@@ -30,8 +30,10 @@ import {
toNumberish,
toRange,
} from "./M01_MasterData_UI_Cells";
import { guessRows, pageKeepFirst, watchFit, watchHeight } from "@ui/ui_template_pager";
const LIST_SIZE = 30;
/** 표 칸 위에 오는 거름 줄 높이 — 첫 줄 수 어림에만 씀 */
const FILTER_H = 52;
const LIST_COLS = ["키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "비고"] as const;
/** 용도 대상 거름 드롭다운 후보 — PLAN 1-1 셋째 줄 그대로. */
const USAGE_TARGETS = ["인력", "기계", "재료", "할증", "환산", "작업량 조건"];
@@ -45,16 +47,22 @@ export function renderTables(
): () => void {
let stop = (): void => {};
let usage = pick.view?.usage ?? "";
let size = guessRows(host, FILTER_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음
let at = 0; // 목록을 보고 있는 쪽(0 = 표 하나를 보는 중)
const list = (page = pick.view?.page ?? 1): void => {
stop();
stop = (): void => {};
at = page;
void showList(
host,
pick,
q,
usage,
page,
size,
list,
(file, key) => {
at = 0;
stop = openTable(host, file, key, () => list(page), openLogic);
},
(next) => {
@@ -62,11 +70,22 @@ export function renderTables(
saveView(pick.group, { usage, page: 1 });
list(1);
},
);
).then(() => fit.ask());
};
const fit = watchFit(
host,
() => host.querySelector<HTMLElement>(".m01-master__tables"),
(rows) => {
if (!at || rows === size) return;
const page = pageKeepFirst(at, size, rows);
size = rows;
list(page);
},
);
list();
const off = onDraftChange(() => host.querySelector(".m01-master__tables") && markChanged(host));
return () => {
fit.stop();
stop();
off();
};
@@ -86,12 +105,14 @@ async function showList(
q: string,
usage: string,
page: number,
size: number,
goPage: (page: number) => void,
open: (file: string, key: string) => void,
onUsageChange: (usage: string) => void,
): Promise<void> {
let got: { total: number; tables: TableHead[] };
try {
got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, LIST_SIZE, usage);
got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, size, usage);
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
@@ -121,12 +142,6 @@ async function showList(
usageField.select.addEventListener("change", () => onUsageChange(usageField.select.value));
host.replaceChildren(
el("div", { className: "m01-master__filter", children: [usageField.root] }),
buildPager(
got.total,
LIST_SIZE,
page,
(p) => void showList(host, pick, q, usage, p, open, onUsageChange),
),
el("div", {
className: "ui-table-wrap m01-master__grid-wrap m01-master__tables",
children: [
@@ -141,6 +156,7 @@ async function showList(
}),
],
}),
buildPager(got.total, size, page, goPage),
);
}
@@ -153,6 +169,7 @@ function openTable(
openLogic: (key: string) => void,
): () => void {
let off = (): void => {};
let unfit = (): void => {}; // 쪽 없는 한 장 표 — 화면 아래까지 높이 묶음
void (async () => {
let version: string;
let table: Row & { 줄: Row[] };
@@ -211,6 +228,7 @@ function openTable(
const backButton = createButton({ label: L("M01_BackToTables"), variant: "ghost" });
backButton.addEventListener("click", () => {
off();
unfit();
back();
});
const notes = ((table.주 ?? []) as string[]).map((n) =>
@@ -224,6 +242,24 @@ function openTable(
btn.addEventListener("click", () => openLogic(k));
return i === 0 ? btn : el("span", { text: ", ", children: [btn] });
});
const sheet = el("div", {
className: "ui-table-wrap m01-master__grid-wrap m01-master__grid-wrap--sheet",
children: [
el("table", {
className: "ui-table m01-master__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))],
}),
],
}),
body,
],
}),
],
});
host.replaceChildren(
el("div", {
className: "ui-toolbar m01-master__bar",
@@ -246,29 +282,17 @@ function openTable(
add,
],
}),
el("div", {
className: "ui-table-wrap m01-master__grid-wrap",
children: [
el("table", {
className: "ui-table m01-master__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))],
}),
],
}),
body,
],
}),
],
}),
sheet,
...notes,
);
unfit();
unfit = watchHeight(sheet);
};
off = onDraftChange(paint);
paint();
})();
return () => off();
return () => {
off();
unfit();
};
}