Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -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.열쇠)}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
@@ -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.키;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user