Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -30,6 +30,8 @@ export interface RowsPage {
|
||||
page: number;
|
||||
size: number;
|
||||
rows: Row[];
|
||||
/** 참조 칸 키 → 이름(키 옆에 같이 보임) */
|
||||
refs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TableHead {
|
||||
@@ -90,7 +92,9 @@ export const fetchRows = (
|
||||
size: number,
|
||||
q: string,
|
||||
unlinked = false,
|
||||
): Promise<RowsPage> => get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0 });
|
||||
sub = "",
|
||||
): Promise<RowsPage> =>
|
||||
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub });
|
||||
|
||||
export const fetchTables = (
|
||||
file: string,
|
||||
|
||||
@@ -69,8 +69,10 @@ def get_files(group: str) -> dict:
|
||||
|
||||
|
||||
@router.get("/rows")
|
||||
def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False) -> dict:
|
||||
return _call(store.rows, file, page, size, q, unlinked)
|
||||
def get_rows(
|
||||
file: str, page: int = 1, size: int = 50, q: str = "", unlinked: bool = False, sub: str = ""
|
||||
) -> dict:
|
||||
return _call(store.rows, file, page, size, q, unlinked, sub)
|
||||
|
||||
|
||||
@router.get("/tables")
|
||||
|
||||
@@ -98,19 +98,57 @@ def files_of(group: str) -> list[dict]:
|
||||
return sorted(out, key=lambda f: (f["order"], f["file"]))
|
||||
|
||||
|
||||
def rows(file: str, page: int, size: int, q: str, unlinked: bool = False) -> dict:
|
||||
_REF_KEY = re.compile(r"^[A-Z]{2}\d{6}$")
|
||||
_REF_FILE = {
|
||||
"LB": "인력.json",
|
||||
"MT": "재료_시중물가.json",
|
||||
"MN": "재료_나라장터자재.json",
|
||||
"MP": "재료_품셈재료.json",
|
||||
"EQ": "기계.json",
|
||||
}
|
||||
|
||||
|
||||
def _ref_keys(row: dict) -> list[str]:
|
||||
"""줄이 가리키는 키 — 준용 · 연결 · 조달 출처(「나라장터:<키>」)."""
|
||||
put = (
|
||||
row.get("준용"),
|
||||
row.get("연결"),
|
||||
(row.get("값") or {}).get("조달", {}).get("출처")
|
||||
if isinstance(row.get("값"), dict)
|
||||
else None,
|
||||
)
|
||||
out = [v.removeprefix("나라장터:") for v in put if isinstance(v, str)]
|
||||
return [k for k in out if _REF_KEY.match(k)]
|
||||
|
||||
|
||||
def refs_of(rows_: list[dict]) -> dict[str, str]:
|
||||
"""참조 칸에 키 옆에 보일 이름 — {키: 「이름 규격」}."""
|
||||
want = {k for r in rows_ for k in _ref_keys(r)}
|
||||
out: dict[str, str] = {}
|
||||
for file in {_REF_FILE[k[:2]] for k in want if k[:2] in _REF_FILE}:
|
||||
for r in read(file)[0].get("줄") or []:
|
||||
if r.get("키") in want:
|
||||
out[r["키"]] = f"{r.get('이름') or ''} {r.get('규격') or ''}".strip()
|
||||
return out
|
||||
|
||||
|
||||
def rows(file: str, page: int, size: int, q: str, unlinked: bool = False, sub: str = "") -> dict:
|
||||
data, version = read(file)
|
||||
hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)]
|
||||
if sub: # 인력 조사 · 기계 세부분류
|
||||
hits = [r for r in hits if sub in (r.get("조사"), r.get("세부분류"))]
|
||||
if unlinked: # 품셈재료 — 아직 못 이은 줄
|
||||
hits = [r for r in hits if not r.get("연결")]
|
||||
page, size = max(page, 1), min(max(size, 1), 500)
|
||||
shown = hits[(page - 1) * size : page * size]
|
||||
return {
|
||||
"file": file,
|
||||
"version": version,
|
||||
"total": len(hits),
|
||||
"page": page,
|
||||
"size": size,
|
||||
"rows": hits[(page - 1) * size : page * size],
|
||||
"rows": shown,
|
||||
"refs": refs_of(shown),
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +190,7 @@ def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
|
||||
files = cm.load(folder=FOLDER)
|
||||
whole = cm.mf.Master(files)
|
||||
out = []
|
||||
for name, data in _logic_files(files):
|
||||
for name, data in sorted(_logic_files(files), key=lambda f: f[1].get("차례") or 0):
|
||||
if (book and data.get("원문") != book) or (chapter and chapter_of(name, data) != chapter):
|
||||
continue
|
||||
for row in data.get("줄") or []:
|
||||
@@ -393,14 +431,16 @@ def _atom(v) -> str:
|
||||
return json.dumps(v, ensure_ascii=False)
|
||||
|
||||
|
||||
def _flat(v, tight: bool = False) -> str:
|
||||
def _flat(v, tight: bool = False, inner: bool | None = None) -> str:
|
||||
"""한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음)."""
|
||||
inner = tight if inner is None else inner
|
||||
if isinstance(v, dict):
|
||||
if not v:
|
||||
return "{}"
|
||||
body = ", ".join(f"{_atom(k)}: {_flat(x, tight)}" for k, x in v.items())
|
||||
body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items())
|
||||
return "{" + body + "}" if tight else "{ " + body + " }"
|
||||
if isinstance(v, list):
|
||||
return "[" + ", ".join(_flat(x, tight) for x in v) + "]"
|
||||
return "[" + ", ".join(_flat(x, inner) for x in v) + "]"
|
||||
return _atom(v)
|
||||
|
||||
|
||||
@@ -443,7 +483,8 @@ def keep_shape(old_text: str, before: dict, after: dict) -> str | None:
|
||||
if was == row:
|
||||
parts.append(text)
|
||||
elif "\n" not in shape:
|
||||
parts.append(_flat(row, tight=shape.startswith('{"')))
|
||||
tight = shape.startswith('{"')
|
||||
parts.append(_flat(row, tight, tight or ': {"' in shape))
|
||||
else:
|
||||
parts.append(dump(row, indent, expand=True).replace("\n", nl))
|
||||
out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :]
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ListItem {
|
||||
book: string;
|
||||
chapter: string;
|
||||
key: string;
|
||||
number: string;
|
||||
name: string;
|
||||
blocked: boolean;
|
||||
reasons: string[];
|
||||
@@ -65,7 +66,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
(!book.value || x.book === book.value) &&
|
||||
(!chapter.value || x.chapter === chapter.value) &&
|
||||
(!blockedOnly.checked || x.blocked) &&
|
||||
(!q || x.key.toLowerCase().includes(q) || x.name.toLowerCase().includes(q)),
|
||||
(!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))),
|
||||
);
|
||||
count.textContent = tx("List_Count", { n: shown.length });
|
||||
list.replaceChildren(...shown.slice(0, 500).map(line));
|
||||
@@ -90,8 +91,11 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
className: `m01-logic__item${item.id === active ? " is-active" : ""}`,
|
||||
attrs: { type: "button", title: item.key },
|
||||
children: [
|
||||
el("span", { className: "m01-logic__muted", text: `${item.book} ${item.chapter}` }),
|
||||
el("span", { text: item.name || item.key }),
|
||||
el("span", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${item.book} ${item.chapter} · ${item.number}`,
|
||||
}),
|
||||
el("span", { text: item.name || item.number || item.key }),
|
||||
el("span", { className: "m01-logic__badges", children: badges }),
|
||||
],
|
||||
});
|
||||
@@ -134,6 +138,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle {
|
||||
book: x.book,
|
||||
chapter: x.chapter,
|
||||
key: x.키,
|
||||
number: x.원문번호,
|
||||
name: x.이름,
|
||||
blocked: x.blocked,
|
||||
reasons: x.reasons,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_Page.ts
|
||||
* 관리자 화면 — 로직 칸. 왼쪽 목록 / 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산
|
||||
* 진입: 공용 진입 파일이 `mountM01Logic(host)` 한 줄로 붙임(요소 칸은 sub_laptop_3 몫)
|
||||
* 관리자 화면 — 로직 칸. 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산
|
||||
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「일위대가 로직」 컨테이너(`listHost`) 안
|
||||
*
|
||||
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
|
||||
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
|
||||
@@ -64,7 +64,7 @@ function loadDrafts(): Record<string, Draft> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function mountM01Logic(host: HTMLElement): Promise<void> {
|
||||
export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): Promise<void> {
|
||||
let drafts = loadDrafts();
|
||||
let files: LogicFile[] = [];
|
||||
let opened: Opened | null = null;
|
||||
@@ -91,7 +91,8 @@ export async function mountM01Logic(host: HTMLElement): Promise<void> {
|
||||
id,
|
||||
book: d.book,
|
||||
chapter: "",
|
||||
key: d.row.키 || d.row.원문번호,
|
||||
key: d.row.키,
|
||||
number: d.row.원문번호,
|
||||
name: d.row.이름,
|
||||
blocked: false,
|
||||
reasons: [],
|
||||
@@ -350,12 +351,12 @@ export async function mountM01Logic(host: HTMLElement): Promise<void> {
|
||||
el("div", {
|
||||
className: "m01-logic",
|
||||
children: [
|
||||
list.root,
|
||||
el("main", { className: "m01-logic__main", children: [bar, errors, editor] }),
|
||||
calc,
|
||||
],
|
||||
}),
|
||||
);
|
||||
listHost.replaceChildren(list.root);
|
||||
show(null);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.m01-logic {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr) 280px;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: var(--spacing-12);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -25,7 +25,6 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01-logic__side,
|
||||
.m01-logic__calc {
|
||||
padding: var(--spacing-8);
|
||||
border-radius: var(--radius-cards);
|
||||
@@ -386,3 +385,8 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 왼쪽 패널 컨테이너 안 로직 목록 */
|
||||
.m01-side__items .m01-logic__list {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,27 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Page.ts
|
||||
* 마스터 요소 화면 — 좌측 도킹 패널(그룹 탭 7 · 파일 고르기) / 우측 요소 표 · 표 목록
|
||||
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직)
|
||||
* / 우측 요소 표 · 표 목록 · 로직 편집
|
||||
*
|
||||
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
|
||||
* 자동저장 없음. 로직 그룹은 다음 화면.
|
||||
* 자동저장 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import "@ui/ui_template_workflow_layout.css";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import {
|
||||
fetchFiles,
|
||||
fetchGroups,
|
||||
saveFiles,
|
||||
type FileInfo,
|
||||
type GroupInfo,
|
||||
} from "./M01_MasterData_Api_Fetch";
|
||||
import { saveFiles } from "./M01_MasterData_Api_Fetch";
|
||||
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
|
||||
import { renderRows } from "./M01_MasterData_UI_Rows";
|
||||
import { buildSide, fileLabel, type Pick } from "./M01_MasterData_UI_Side";
|
||||
import { renderTables } from "./M01_MasterData_UI_Tables";
|
||||
import "./M01_MasterData_UI_Style.css";
|
||||
|
||||
const TABLE_GROUPS = ["소요량", "계수"];
|
||||
const fileLabel = (file: string): string => file.replace(/^[^_]+_/, "").replace(/\.json$/, "");
|
||||
|
||||
export async function renderM01MasterData(root: HTMLElement): Promise<void> {
|
||||
const user = await fetchSessionUser().catch(() => null);
|
||||
@@ -41,24 +30,15 @@ export async function renderM01MasterData(root: HTMLElement): Promise<void> {
|
||||
navigateTo(ROUTES.B01_ACCOUNT);
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
let groups: GroupInfo[] = [];
|
||||
try {
|
||||
groups = (await fetchGroups()).filter((g) => g.group !== "로직");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
root.innerHTML = "";
|
||||
root.append(buildPage(groups));
|
||||
root.append(buildPage());
|
||||
}
|
||||
|
||||
function buildPage(groups: GroupInfo[]): HTMLElement {
|
||||
let group = "";
|
||||
let current: FileInfo | null = null;
|
||||
function buildPage(): HTMLElement {
|
||||
let current: Pick | null = null;
|
||||
let query = "";
|
||||
let dispose = (): void => {};
|
||||
let logicMounted = false;
|
||||
|
||||
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
|
||||
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
|
||||
@@ -75,6 +55,11 @@ function buildPage(groups: GroupInfo[]): HTMLElement {
|
||||
className: "m01-master__head",
|
||||
children: [title, search, summary, drop, save],
|
||||
});
|
||||
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
|
||||
const elementView = el("div", {
|
||||
className: "m01-master__elements",
|
||||
children: [head, notice, body],
|
||||
});
|
||||
|
||||
const showNotice = (nodes: (HTMLElement | string)[]): void => {
|
||||
notice.replaceChildren(...nodes);
|
||||
@@ -88,13 +73,20 @@ function buildPage(groups: GroupInfo[]): HTMLElement {
|
||||
onDraftChange(refreshBar);
|
||||
refreshBar();
|
||||
|
||||
const openFile = (file: FileInfo): void => {
|
||||
const showMode = (logic: boolean): void => {
|
||||
logicHost.hidden = !logic;
|
||||
elementView.hidden = logic;
|
||||
};
|
||||
|
||||
const openFile = (pick: Pick): void => {
|
||||
dispose();
|
||||
current = file;
|
||||
title.textContent = `${group} · ${fileLabel(file.file)}`;
|
||||
const view = TABLE_GROUPS.includes(group) ? renderTables : renderRows;
|
||||
dispose = view(body, file.file, query);
|
||||
showNotice(isStale(file.file, file.version) ? [L("M01_FileStale")] : []);
|
||||
showMode(false);
|
||||
current = pick;
|
||||
const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label];
|
||||
title.textContent = groupTitle.join(" · ");
|
||||
const view = TABLE_GROUPS.includes(pick.group) ? renderTables : renderRows;
|
||||
dispose = view(body, pick.file.file, query, pick.sub);
|
||||
showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []);
|
||||
};
|
||||
|
||||
let searchTimer: number | undefined;
|
||||
@@ -112,6 +104,18 @@ function buildPage(groups: GroupInfo[]): HTMLElement {
|
||||
showNotice([]);
|
||||
});
|
||||
|
||||
/** 저장·되받기 뒤 — 지금 열린 그룹의 새 판본으로 다시 그림 */
|
||||
const reloadFiles = async (): Promise<void> => {
|
||||
if (!current) return;
|
||||
const pick = current;
|
||||
try {
|
||||
const again = (await side.refresh(pick.group)).find((f) => f.file === pick.file.file);
|
||||
if (again) openFile({ ...pick, file: again });
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
@@ -150,89 +154,27 @@ function buildPage(groups: GroupInfo[]): HTMLElement {
|
||||
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
|
||||
};
|
||||
|
||||
/* --- 좌측: 그룹 탭 + 파일 --- */
|
||||
const tabs = el("div", { className: "m01-master__tabs" });
|
||||
const files = el("div", { className: "m01-master__files" });
|
||||
|
||||
const reloadFiles = async (): Promise<void> => {
|
||||
if (!group) return;
|
||||
let list: FileInfo[] = [];
|
||||
try {
|
||||
list = await fetchFiles(group);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
}
|
||||
files.replaceChildren(
|
||||
...list.map((f) => {
|
||||
const button = el("button", {
|
||||
className: "m01-master__file",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", { text: fileLabel(f.file) }),
|
||||
el("span", { className: "m01-master__muted", text: f.rows.toLocaleString("ko-KR") }),
|
||||
],
|
||||
});
|
||||
button.classList.toggle("is-active", f.file === current?.file);
|
||||
button.addEventListener("click", () => {
|
||||
files.querySelector(".is-active")?.classList.remove("is-active");
|
||||
button.classList.add("is-active");
|
||||
openFile(f);
|
||||
});
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
const again = list.find((f) => f.file === current?.file);
|
||||
if (again) openFile(again);
|
||||
};
|
||||
|
||||
for (const g of groups) {
|
||||
const tab = el("button", {
|
||||
className: "m01-master__tab",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", { text: g.group }),
|
||||
el("span", { className: "m01-master__muted", text: String(g.files) }),
|
||||
],
|
||||
});
|
||||
tab.addEventListener("click", () => {
|
||||
tabs.querySelector(".is-active")?.classList.remove("is-active");
|
||||
tab.classList.add("is-active");
|
||||
group = g.group;
|
||||
current = null;
|
||||
dispose();
|
||||
body.replaceChildren();
|
||||
title.textContent = L("M01_PickFile");
|
||||
void reloadFiles();
|
||||
});
|
||||
tabs.append(tab);
|
||||
}
|
||||
|
||||
const logicTab = el("button", {
|
||||
className: "m01-master__tab",
|
||||
attrs: { type: "button" },
|
||||
text: L("M01_LogicTab"),
|
||||
});
|
||||
logicTab.addEventListener("click", () => {
|
||||
tabs.querySelector(".is-active")?.classList.remove("is-active");
|
||||
logicTab.classList.add("is-active");
|
||||
group = "";
|
||||
current = null;
|
||||
/* --- 좌측: 컨테이너 여덟 --- */
|
||||
const side = buildSide(openFile, () => {
|
||||
dispose();
|
||||
files.replaceChildren();
|
||||
body.replaceChildren();
|
||||
title.textContent = L("M01_LogicTab");
|
||||
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(body));
|
||||
dispose = (): void => {};
|
||||
current = null;
|
||||
showMode(true);
|
||||
if (logicMounted) return;
|
||||
logicMounted = true;
|
||||
void import("./M01_MasterData_UI_Logic_Page").then((m) =>
|
||||
m.mountM01Logic(logicHost, side.logicHost),
|
||||
);
|
||||
});
|
||||
tabs.append(logicTab);
|
||||
|
||||
const layout = el("div", { className: "ui-workflow-layout m01-master" });
|
||||
const main = el("main", {
|
||||
className: "ui-workflow-layout__main",
|
||||
children: [el("div", { className: "m01-master__panel", children: [head, notice, body] })],
|
||||
children: [el("div", { className: "m01-master__panel", children: [elementView, logicHost] })],
|
||||
});
|
||||
const overlays = createWorkflowOverlays({
|
||||
title: L("M01_Title"),
|
||||
optionsContent: el("div", { className: "m01-master__side", children: [tabs, files] }),
|
||||
optionsContent: el("div", { className: "m01-master__side", children: [side.root] }),
|
||||
showProjectName: false,
|
||||
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface PickOptions {
|
||||
/** 처음 찾을 글 — 그 재료 이름·규격 (없는 결과면 첫 낱말만으로 다시). */
|
||||
seed: string;
|
||||
/** 고르면 ref · 연결 끊기 = null. */
|
||||
onPick: (ref: string | null) => void;
|
||||
onPick: (ref: string | null, name?: string) => void;
|
||||
/** 있으면 「후보 조건으로 연결」 칸이 뜸. */
|
||||
cond?: { 이름: string; 규격: string; onCond: (c: Cond) => void };
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function openPickModal(opt: PickOptions): void {
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
opt.onPick(it.ref);
|
||||
opt.onPick(it.ref, `${it.이름} ${it.규격}`.trim());
|
||||
close();
|
||||
});
|
||||
return b;
|
||||
|
||||
@@ -71,8 +71,20 @@ const linkText = (link: unknown): string =>
|
||||
? `${L("M01_PriceCondUsed")}: ${(link as Row)["이름"]} ${(link as Row)["규격"] ?? ""}`.trim()
|
||||
: String(link ?? "");
|
||||
|
||||
/** 키 → 이름(서버가 준 것 + 방금 고른 것) — 참조 칸에 키 옆에 같이 보임 */
|
||||
const names = new Map<string, string>();
|
||||
const KEY = /[A-Z]{2}\d{6}/;
|
||||
const named = (text: string): string => {
|
||||
const name = names.get(KEY.exec(text)?.[0] ?? "");
|
||||
return name ? `${text} · ${name}` : text;
|
||||
};
|
||||
const remember = (ref: string | null, name?: string): void => {
|
||||
const key = KEY.exec(ref ?? "")?.[0];
|
||||
if (key && name) names.set(key, name);
|
||||
};
|
||||
|
||||
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
||||
export function renderRows(host: HTMLElement, file: string, q: string): () => void {
|
||||
export function renderRows(host: HTMLElement, file: string, q: string, sub = ""): () => void {
|
||||
let page = 1;
|
||||
let data: RowsPage | null = null;
|
||||
let unlinked = false;
|
||||
@@ -162,7 +174,11 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo
|
||||
]
|
||||
: [];
|
||||
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
|
||||
add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK)));
|
||||
add.addEventListener("click", () => {
|
||||
const fresh = blank(d.rows[0] ?? FALLBACK);
|
||||
const col = ["조사", "세부분류"].find((c) => c in fresh);
|
||||
addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh);
|
||||
});
|
||||
const empty =
|
||||
d.rows.length || adds.length
|
||||
? []
|
||||
@@ -192,7 +208,8 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
data = await fetchRows(file, page, SIZE, q, unlinked);
|
||||
data = await fetchRows(file, page, SIZE, q, unlinked, sub);
|
||||
for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name);
|
||||
paint();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
|
||||
@@ -222,7 +239,7 @@ const rowBtn = (text: string): HTMLButtonElement =>
|
||||
/** 시중물가 — 조달 값 · 기준 · 조달 연결(「나라장터:<키>」 · 「가격정보」 줄은 원천이라 그대로). */
|
||||
function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] {
|
||||
const link = show(deep(cur, SOURCE));
|
||||
const td = buildCell(link, { changed: link !== show(deep(row, SOURCE)) });
|
||||
const td = buildCell(named(link), { changed: link !== show(deep(row, SOURCE)) });
|
||||
if (put && (!link || link.startsWith(NARA))) {
|
||||
const btn = rowBtn(L("M01_ProcureFind"));
|
||||
btn.addEventListener("click", () =>
|
||||
@@ -231,7 +248,10 @@ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] {
|
||||
title: L("M01_ProcureTitle"),
|
||||
current: link,
|
||||
seed: `${cur["이름"] ?? ""} ${cur["규격"] ?? ""}`.trim(),
|
||||
onPick: (ref) => put(withDeep(cur, SOURCE, ref)),
|
||||
onPick: (ref, name) => {
|
||||
remember(ref, name);
|
||||
put(withDeep(cur, SOURCE, ref));
|
||||
},
|
||||
}),
|
||||
);
|
||||
td.append(btn);
|
||||
@@ -246,7 +266,7 @@ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] {
|
||||
/** 품셈재료 — 가격 연결(시중물가 키 · 후보 조건 · 없음) · 고르기 모달. */
|
||||
function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
const link = cur["연결"];
|
||||
const td = buildCell(linkText(link), {
|
||||
const td = buildCell(named(linkText(link)), {
|
||||
changed: JSON.stringify(link ?? null) !== JSON.stringify(row["연결"] ?? null),
|
||||
});
|
||||
if (put) {
|
||||
@@ -260,7 +280,10 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
title: `${L("M01_PriceTitle")} · ${cur["이름"] ?? ""}`,
|
||||
current: linkText(link),
|
||||
seed: `${name} ${spec}`.trim(),
|
||||
onPick: (ref) => put(withCell(cur, "연결", ref)),
|
||||
onPick: (ref, name) => {
|
||||
remember(ref, name);
|
||||
put(withCell(cur, "연결", ref));
|
||||
},
|
||||
cond: { 이름: name, 규격: spec, onCond: (c) => put(withCell(cur, "연결", c)) },
|
||||
}),
|
||||
);
|
||||
@@ -272,7 +295,7 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
/** 값 없는 인력 줄(건설업 미공표 · 준용대상)의 준용 = 공표 직종 키 고르기. */
|
||||
function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
const text = show(cur["준용"]);
|
||||
const td = buildCell(text, { changed: text !== show(row["준용"]) });
|
||||
const td = buildCell(named(text), { changed: text !== show(row["준용"]) });
|
||||
if (put && row["값"] === null) {
|
||||
const btn = rowBtn(L("M01_JobFind"));
|
||||
btn.addEventListener("click", () =>
|
||||
@@ -281,7 +304,10 @@ function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`,
|
||||
current: text,
|
||||
seed: "",
|
||||
onPick: (ref) => put(withCell(cur, "준용", ref)),
|
||||
onPick: (ref, name) => {
|
||||
remember(ref, name);
|
||||
put(withCell(cur, "준용", ref));
|
||||
},
|
||||
}),
|
||||
);
|
||||
td.append(btn);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Side.ts
|
||||
* 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 여덟 개
|
||||
* 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 일위대가 로직
|
||||
* 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import { fetchFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
|
||||
|
||||
/** 요소 화면에서 열 것 — 파일 · 하위 거름(조사·세부분류) · 제목 */
|
||||
export interface Pick {
|
||||
group: string;
|
||||
file: FileInfo;
|
||||
sub: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
root: HTMLElement;
|
||||
/** 일위대가 로직 컨테이너 안(로직 목록이 들어갈 자리) */
|
||||
logicHost: HTMLElement;
|
||||
/** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */
|
||||
refresh: (group: string) => Promise<FileInfo[]>;
|
||||
setActive: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const LABOR_SURVEYS = [
|
||||
"건설업",
|
||||
"제조업",
|
||||
"엔지니어링",
|
||||
"측량",
|
||||
"건설사업관리",
|
||||
"SW",
|
||||
"산림",
|
||||
"준용대상",
|
||||
];
|
||||
const MACHINE_KINDS = ["건설품셈", "산림품셈"];
|
||||
const MATERIAL_NAMES: Record<string, string> = { 나라장터자재: "나라장터", 오피넷유가: "유가" };
|
||||
|
||||
/** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */
|
||||
export const fileLabel = (file: string): string =>
|
||||
file.replace(/^[^_]+_/, "").replace(/\.json$/, "");
|
||||
|
||||
const GROUP_TITLE = {
|
||||
인력: "M01_GroupLabor",
|
||||
재료: "M01_GroupMaterial",
|
||||
기계: "M01_GroupMachine",
|
||||
소요량: "M01_GroupQuantity",
|
||||
계수: "M01_GroupFactor",
|
||||
환율: "M01_GroupExchange",
|
||||
요율: "M01_GroupRate",
|
||||
} as const;
|
||||
|
||||
type Group = keyof typeof GROUP_TITLE;
|
||||
const GROUPS = Object.keys(GROUP_TITLE) as Group[];
|
||||
const LEAF: Group[] = ["환율", "요율"];
|
||||
|
||||
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
|
||||
const lists = new Map<Group, FileInfo[]>();
|
||||
const bodies = new Map<Group, HTMLElement>();
|
||||
const buttons = new Map<string, HTMLElement>();
|
||||
|
||||
const setActive = (id: string | null): void => {
|
||||
for (const [key, button] of buttons) button.classList.toggle("is-active", key === id);
|
||||
};
|
||||
|
||||
const item = (id: string, text: string, count: number | null, run: () => void): HTMLElement => {
|
||||
const button = el("button", {
|
||||
className: "m01-side__item",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", { text }),
|
||||
...(count === null
|
||||
? []
|
||||
: [el("span", { className: "m01-side__muted", text: count.toLocaleString("ko-KR") })]),
|
||||
],
|
||||
});
|
||||
buttons.set(id, button);
|
||||
button.addEventListener("click", () => {
|
||||
setActive(id);
|
||||
run();
|
||||
});
|
||||
return button;
|
||||
};
|
||||
|
||||
const drawGroup = (group: Group): void => {
|
||||
const files = lists.get(group) ?? [];
|
||||
const body = bodies.get(group);
|
||||
if (!body) return;
|
||||
const open = (file: FileInfo, sub: string, label: string): void =>
|
||||
onOpen({ group, file, sub, label });
|
||||
const one = files[0];
|
||||
if (group === "인력" || group === "기계") {
|
||||
const kinds = group === "인력" ? LABOR_SURVEYS : MACHINE_KINDS;
|
||||
body.replaceChildren(
|
||||
...(one
|
||||
? [
|
||||
item(`${group}\n`, L("M01_All"), one.rows, () => open(one, "", L("M01_All"))),
|
||||
...kinds.map((k) => item(`${group}\n${k}`, k, null, () => open(one, k, k))),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...files.map((f) => {
|
||||
const name = fileLabel(f.file);
|
||||
const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name;
|
||||
return item(`${group}\n${f.file}`, label, f.rows, () => open(f, "", label));
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const refresh = async (group: string): Promise<FileInfo[]> => {
|
||||
const list = await fetchFiles(group);
|
||||
lists.set(group as Group, list);
|
||||
drawGroup(group as Group);
|
||||
return list;
|
||||
};
|
||||
|
||||
const section = (title: string, body: HTMLElement | null): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01-side__group ui-collapsible ui-sidebar-section is-collapsed",
|
||||
children: [
|
||||
el("h3", { className: "ui-collapsible__title", text: title }),
|
||||
...(body ? [body] : []),
|
||||
],
|
||||
});
|
||||
|
||||
const groups = GROUPS.map((group) => {
|
||||
const body = LEAF.includes(group) ? null : el("div", { className: "m01-side__items" });
|
||||
if (body) bodies.set(group, body);
|
||||
const root = section(L(GROUP_TITLE[group]), body);
|
||||
if (!body) {
|
||||
root.classList.add("m01-side__leaf");
|
||||
buttons.set(`${group}\n`, root);
|
||||
root.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
const one = lists.get(group)?.[0];
|
||||
if (!one) return;
|
||||
setActive(`${group}\n`);
|
||||
onOpen({ group, file: one, sub: "", label: L(GROUP_TITLE[group]) });
|
||||
});
|
||||
}
|
||||
return root;
|
||||
});
|
||||
|
||||
const logicHost = el("div", { className: "m01-side__items" });
|
||||
const logic = section(L("M01_GroupLogic"), logicHost);
|
||||
logic.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
setActive(null);
|
||||
onLogic();
|
||||
});
|
||||
|
||||
const root = el("div", { className: "m01-side", children: [...groups, logic] });
|
||||
attachCollapsible(root);
|
||||
void Promise.all(GROUPS.map(refresh)).catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
|
||||
);
|
||||
return { root, logicHost, refresh, setActive };
|
||||
}
|
||||
@@ -4,33 +4,53 @@
|
||||
}
|
||||
|
||||
.m01-master__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-master__tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
/* 왼쪽 패널 — 공용 접기 컨테이너 여덟 */
|
||||
.m01-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01-master__files {
|
||||
.m01-side__group {
|
||||
padding: calc(var(--spacing-8) + var(--spacing-4));
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-side__group > h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-side__leaf > h3::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-side__leaf.is-active > h3 {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-side__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 50vh;
|
||||
margin-top: var(--spacing-8);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m01-master__tab,
|
||||
.m01-master__file {
|
||||
.m01-side__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border: 0;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-surface);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
@@ -38,22 +58,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-master__file {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.m01-master__tab:hover,
|
||||
.m01-master__file:hover {
|
||||
.m01-side__item:hover {
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
.m01-master__tab.is-active,
|
||||
.m01-master__file.is-active {
|
||||
.m01-side__item.is-active {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-side__muted,
|
||||
.m01-master__muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
const LIST_SIZE = 30;
|
||||
|
||||
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
|
||||
export function renderTables(host: HTMLElement, file: string, q: string): () => void {
|
||||
export function renderTables(host: HTMLElement, file: string, q: string, _sub = ""): () => void {
|
||||
let stop = (): void => {};
|
||||
const list = (page = 1): void => {
|
||||
stop();
|
||||
|
||||
@@ -304,3 +304,31 @@ def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClien
|
||||
one = _get(client, "/api/m01/logic", key=key)
|
||||
power = one["prices"][key_of("MP:전력")]
|
||||
assert power["값"] > 0 and power["출처"].startswith(key_of("MT:M0005250064") + ".")
|
||||
|
||||
|
||||
def test_하위_거름과_참조_이름(client: TestClient) -> None:
|
||||
every = _get(client, "/api/m01/rows", file=LABOR, size=500)
|
||||
survey = _get(client, "/api/m01/rows", file=LABOR, size=500, sub="측량")
|
||||
assert 0 < survey["total"] < every["total"] and {r["조사"] for r in survey["rows"]} == {"측량"}
|
||||
machine = _get(client, "/api/m01/rows", file="기계.json", size=500, sub="산림품셈")
|
||||
assert 0 < machine["total"] < 100 and {r["세부분류"] for r in machine["rows"]} == {"산림품셈"}
|
||||
linked = _get(client, "/api/m01/rows", file="재료_품셈재료.json", size=500)
|
||||
keyed = [r for r in linked["rows"] if isinstance(r["연결"], str)]
|
||||
assert keyed and all(linked["refs"][r["연결"]] for r in keyed)
|
||||
|
||||
|
||||
def test_시중물가_저장은_띄어쓰기를_안_바꿈(client: TestClient) -> None:
|
||||
market = "재료_시중물가.json"
|
||||
old = (store.FOLDER / market).read_text(encoding="utf-8").split("\n")
|
||||
rows = _get(client, "/api/m01/rows", file=market, size=1)
|
||||
row = rows["rows"][0]
|
||||
row["값"]["시중"]["가격정보"] = 1
|
||||
change = {"op": "edit", "key": row["키"], "row": row}
|
||||
body = {"files": [{"file": market, "version": rows["version"], "changes": [change]}]}
|
||||
assert client.post("/api/m01/save", json=body).status_code == 200
|
||||
new = (store.FOLDER / market).read_text(encoding="utf-8").split("\n")
|
||||
diff = [(a, b) for a, b in zip(old, new) if a != b]
|
||||
assert len(new) == len(old) and len(diff) == 1
|
||||
assert diff[0][1].replace('"가격정보": 1,', '"가격정보": X,').count("{ ") == diff[0][0].count(
|
||||
"{ "
|
||||
)
|
||||
|
||||
@@ -55,4 +55,13 @@ export const ui_locales_m1 = {
|
||||
M01_JobFind: ["직종 고르기", "Pick job"],
|
||||
M01_JobTitle: ["준용 직종 고르기", "Pick substitute job"],
|
||||
M01_JobCol: ["준용", "Substitute"],
|
||||
M01_All: ["전체", "All"],
|
||||
M01_GroupLabor: ["인력", "Labor"],
|
||||
M01_GroupMaterial: ["재료", "Materials"],
|
||||
M01_GroupMachine: ["기계", "Machines"],
|
||||
M01_GroupQuantity: ["소요량", "Quantities"],
|
||||
M01_GroupFactor: ["계수", "Factors"],
|
||||
M01_GroupExchange: ["환율", "Exchange rates"],
|
||||
M01_GroupRate: ["요율", "Rates"],
|
||||
M01_GroupLogic: ["일위대가 로직", "Unit-cost logic"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user